refactor(subprocess-local): decide Linux containment in a testable selector
The provider's Linux decisions now live in LinuxScopeSelector, a state machine over injected observations: the deep probe, the user-manager listener, the manager confirmation, and the systemd-run binary identity. Each decision carries its reason and probe trigger, the runtime logs it at debug level, and table-driven scenarios cover every transition. - A positive verdict probes again when the systemd-run found through PATH changes device, inode, size, or modification time, so an in-place upgrade past v254 cannot reuse options that no longer keep argv literal. - The listener is read before the probe, so a manager that starts during a failed probe counts as a new listener. - The kernel socket table is decoded as UTF-8, matching runtime directories outside ASCII. - LinuxScopeNotStartedError drops its unused code field.
This commit is contained in:
1 parent
71de43d8ee
commit
da5e23e8e8
9 files changed
+550
-79
No files matched your search
@@ -63,8 +63,8 @@
|
||||
en: e290ccc9b3c92c08
|
||||
zh: d75d48f485447a74
|
||||
/deepseek-ai-dsh-subprocess-local/known-limitations-and-deferred-work:
|
||||
en: e9e76a139fbbd62b
|
||||
zh: 3d3ae5dbbee39cf4
|
||||
en: 4b45e4e4ca67af72
|
||||
zh: 5185485974cd3fa0
|
||||
/deepseek-ai-dsh-subprocess-local/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -147,7 +147,7 @@ These limits define when the provider is a poor fit or needs special operational
|
||||
|
||||
- **Linux direct exit has no independent deadline** — after direct `SIGKILL` is acknowledged, uninterruptible kernel I/O can keep disposal pending indefinitely; `graceMs` and scope-query polling budgets do not bound this wait.
|
||||
- **Native ownership has explicit host requirements** — Linux needs a readable user manager whose `systemd-run` supports `--collect` (systemd 236 and later) and keeps scope arguments literal: version 254 and later receive `--expand-environment=no`, and earlier versions, whose scope mode never expands arguments, run without it. Hosts without a usable user manager use the warned PGID fallback. macOS always uses that fallback because no supported public persistent owner exists.
|
||||
- **Native selection has bounded per-spawn costs** — Linux checks the bootstrap entry, libc `execve`/`fcntl` bindings, reachable user manager, and literal-argv scope probe on the first eligible ordinary or terminal spawn and keeps that verdict, including a failed or timed-out probe. The user manager is identified by the process listening on `$XDG_RUNTIME_DIR/systemd/private`, the socket `systemd-run --user` connects through, as listed in the kernel's unix socket table (`/proc/net/unix`) for the same network namespace; reading the table costs time proportional to its size. After a positive verdict, each eligible spawn confirms the manager: a listener confirms it without a child process; otherwise one `systemctl --user show` query decides, and that spawn uses the fallback when the query fails. A scope launch whose bootstrap never consumed its launch request, such as one sent to a hung manager that still listens, fails that command, and the next eligible spawn probes again. After a negative verdict, the provider probes again only when a different listener appears, so a manager that starts later is used without a restart. Windows rechecks the runner entry, bindings, and current Job support before every ordinary spawn. Fallback-warning de-duplication also persists for the provider lifetime. All probes finish before the user command can run; each child-process probe has a 5-second timeout, and a `systemd-run` that rejects `--expand-environment=no` is probed once more without it. Each Linux launch creates a private request directory, checks unresolved scope establishment every 50 milliseconds, then exponentially backs off an established active scope to at most 5 seconds between queries; a Windows ordinary launch keeps one runner and IPC channel until the Job reports zero active processes. Target standard handles are inherited directly, with no named-pipe stdio or result files.
|
||||
- **Native selection has bounded per-spawn costs** — Linux checks the bootstrap entry, libc `execve`/`fcntl` bindings, reachable user manager, and literal-argv scope probe on the first eligible ordinary or terminal spawn and keeps that verdict, including a failed or timed-out probe. The user manager is identified by the process listening on `$XDG_RUNTIME_DIR/systemd/private`, the socket `systemd-run --user` connects through, as listed in the kernel's unix socket table (`/proc/net/unix`) for the same network namespace; reading the table costs time proportional to its size. After a positive verdict, each eligible spawn probes again when the `systemd-run` found through `PATH` has a different device, inode, size, or modification time, and otherwise confirms the manager: a listener confirms it without a child process; otherwise one `systemctl --user show` query decides, and that spawn uses the fallback when the query fails. A scope launch whose bootstrap never consumed its launch request, such as one sent to a hung manager that still listens, fails that command, as do scope launches issued before that failure arrives, and the next eligible spawn probes again. After a negative verdict, the provider probes again only when a different listener appears, so a manager that starts later is used without a restart. Windows rechecks the runner entry, bindings, and current Job support before every ordinary spawn. Fallback-warning de-duplication also persists for the provider lifetime. All probes finish before the user command can run; each child-process probe has a 5-second timeout, and a `systemd-run` that rejects `--expand-environment=no` is probed once more without it. Each Linux launch creates a private request directory, checks unresolved scope establishment every 50 milliseconds, then exponentially backs off an established active scope to at most 5 seconds between queries; a Windows ordinary launch keeps one runner and IPC channel until the Job reports zero active processes. Target standard handles are inherited directly, with no named-pipe stdio or result files.
|
||||
- **Windows Job inheritance has defined exclusions** — ordinary descendants inherit the Job by default, but breakaway processes are outside the guarantee. The target starts only after Job assignment; external termination of the runner in the narrow create-to-assignment interval can leave a suspended target behind.
|
||||
- **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that the console host turns into a console-wide CTRL_C event (on Windows that host is node-pty's bundled OpenConsole); SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest.
|
||||
- **Windows terminal allocation requires node-pty's bundled console host assets** — every Windows terminal is spawned with `useConptyDll: true`, so allocation resolves `conpty/conpty.dll` and `OpenConsole.exe` relative to node-pty's own addon; an asset that is missing or unpacked wrong makes `spawnTerminal` throw instead of falling back to the operating system console host. node-pty marks the option experimental, so a node-pty upgrade can change this behaviour. See the [bundled console host note](../../../.agents/notes/implemented/bug-fix/2026-09-28-pty-bundled-console-host.md).
|
||||
|
||||
@@ -147,7 +147,7 @@ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目
|
||||
|
||||
- **Linux 直接进程退出没有独立截止时间**——直接 `SIGKILL` 获得确认后,不可中断的内核 I/O 可能让 dispose 无限期等待;`graceMs` 和 scope 查询的轮询预算不限制此等待。
|
||||
- **native ownership 有明确宿主要求**——Linux 需要可读的 user manager,且其 `systemd-run` 支持 `--collect`(systemd 236 及以上)并保持 scope 参数按字面传递:254 及以上版本传入 `--expand-environment=no`,更早版本的 scope 模式从不展开参数,因此不传该选项。没有可用 user manager 的宿主使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。
|
||||
- **native 选择具有有界的每次 spawn 成本**——Linux 在第一次符合条件的普通命令或终端 spawn 时检查 bootstrap 入口、libc `execve`/`fcntl` bindings、可连接的 user manager 与 literal-argv scope 支持,并保留这一结论,探测失败或超时的结论也是如此。user manager 以内核 unix socket 表(`/proc/net/unix`)中同一网络命名空间里在 `$XDG_RUNTIME_DIR/systemd/private`(`systemd-run --user` 连接 manager 所用的 socket)上监听的进程来识别;读取该表的耗时与表的大小成正比。结论为可用时,每次符合条件的 spawn 都会确认 manager:有监听者时无需子进程即可确认;否则由一次 `systemctl --user show` 查询决定,查询失败时这次 spawn 使用 fallback。bootstrap 从未消费 launch request 的 scope 启动(例如发给卡死但仍在监听的 manager)会让这条命令失败,下一次符合条件的 spawn 会重新探测。结论为不可用时,只有出现不同的监听者才会重新探测,因此之后才启动的 manager 无需重启即可被使用。Windows 会在每次普通 spawn 前重新检查 runner 入口、bindings 与当前 Job 支持。fallback 告警去重同样在提供方生命周期内持续保留。所有探测都会在用户命令可能运行前完成;每次子进程探测的超时为 5 秒,`systemd-run` 拒绝 `--expand-environment=no` 时会去掉该选项再探测一次。每次 Linux 启动都会创建私有请求目录,以 50 毫秒间隔检查尚未确定的 scope 建立状态;scope 已建立且仍 active 后,查询间隔按指数增长,最多为 5 秒。Windows 普通命令会保留一个 runner 与一条 IPC 通道,直到 Job 报告活动进程数为零。目标会直接继承标准句柄,不使用 named-pipe stdio 或结果文件。
|
||||
- **native 选择具有有界的每次 spawn 成本**——Linux 在第一次符合条件的普通命令或终端 spawn 时检查 bootstrap 入口、libc `execve`/`fcntl` bindings、可连接的 user manager 与 literal-argv scope 支持,并保留这一结论,探测失败或超时的结论也是如此。user manager 以内核 unix socket 表(`/proc/net/unix`)中同一网络命名空间里在 `$XDG_RUNTIME_DIR/systemd/private`(`systemd-run --user` 连接 manager 所用的 socket)上监听的进程来识别;读取该表的耗时与表的大小成正比。结论为可用时,若经 `PATH` 找到的 `systemd-run` 的设备号、inode、大小或修改时间发生变化,下一次符合条件的 spawn 会重新探测;否则每次都会确认 manager:有监听者时无需子进程即可确认;否则由一次 `systemctl --user show` 查询决定,查询失败时这次 spawn 使用 fallback。bootstrap 从未消费 launch request 的 scope 启动(例如发给卡死但仍在监听的 manager)会让这条命令失败,在该失败到达之前发起的 scope 启动也会失败,下一次符合条件的 spawn 会重新探测。结论为不可用时,只有出现不同的监听者才会重新探测,因此之后才启动的 manager 无需重启即可被使用。Windows 会在每次普通 spawn 前重新检查 runner 入口、bindings 与当前 Job 支持。fallback 告警去重同样在提供方生命周期内持续保留。所有探测都会在用户命令可能运行前完成;每次子进程探测的超时为 5 秒,`systemd-run` 拒绝 `--expand-environment=no` 时会去掉该选项再探测一次。每次 Linux 启动都会创建私有请求目录,以 50 毫秒间隔检查尚未确定的 scope 建立状态;scope 已建立且仍 active 后,查询间隔按指数增长,最多为 5 秒。Windows 普通命令会保留一个 runner 与一条 IPC 通道,直到 Job 报告活动进程数为零。目标会直接继承标准句柄,不使用 named-pipe stdio 或结果文件。
|
||||
- **Windows Job inheritance 有明确排除项**——普通后代默认继承 Job,但 breakaway 进程不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。
|
||||
- **Windows 终端信号是控制台级的**——SIGINT 以 `\x03` Ctrl-C 输入写入投递,由控制台宿主(Windows 上为 node-pty 自带的 OpenConsole)转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默与计时档覆盖。
|
||||
- **Windows 终端分配依赖 node-pty 自带的控制台宿主资产**——每个 Windows 终端都以 `useConptyDll: true` 启动,分配时按 node-pty 自身 addon 的相对位置解析 `conpty/conpty.dll` 与 `OpenConsole.exe`;资产缺失或解包错误会让 `spawnTerminal` 抛错,而不是回退到操作系统控制台宿主。node-pty 把该选项标为实验性,升级 node-pty 可能改变这一行为。见[自带控制台宿主说明](../../../.agents/notes/implemented/bug-fix/2026-09-28-pty-bundled-console-host.zh.md)。
|
||||
|
||||
@@ -41,8 +41,10 @@ import {
|
||||
prepareLinuxTerminalScope,
|
||||
probeLinuxNative,
|
||||
signalLinuxDirectProcess,
|
||||
systemdRunIdentity,
|
||||
} from './linux-scope.ts'
|
||||
import type { LiteralArgvOptions } from './linux-scope.ts'
|
||||
import { LinuxScopeSelector } from './linux-scope-selector.ts'
|
||||
import type { LinuxScopeDecision } from './linux-scope-selector.ts'
|
||||
import { launchWindowsJob, probeWindowsJob } from './windows-job.ts'
|
||||
import { targetEnvironment } from './runner-launch.ts'
|
||||
import { createProcessInspector } from './process-inspector.ts'
|
||||
@@ -70,18 +72,18 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
|
||||
internals: SpawnInternals = {}
|
||||
/** Provider-lifetime latch suppressing repeated weaker-containment warnings. */
|
||||
private fallbackWarningIssued = false
|
||||
/** Whether linuxLiteralArgv holds a Linux native probe result; cleared when that result must be probed again. */
|
||||
private linuxProbed = false
|
||||
/**
|
||||
* Latest Linux native probe result: literal-argv scope options, or undefined when unavailable. Each probe is a
|
||||
* spawnSync that blocks the event loop and forks the host, so it reruns only after a scope never started its
|
||||
* target or a new user-manager listener appeared; a positive result otherwise only confirms the manager per spawn.
|
||||
* Linux native containment decisions. Each probe is a spawnSync that blocks the event loop and forks the
|
||||
* host, so the selector reruns it only after an event invalidates the previous verdict.
|
||||
*/
|
||||
private linuxLiteralArgv: LiteralArgvOptions | undefined
|
||||
/** Count of Linux native probes; a failed scope launch resets the result only for the probe it launched under. */
|
||||
private linuxProbeGeneration = 0
|
||||
/** User-manager listener seen when the probe last failed; only a different listener triggers another probe. */
|
||||
private linuxUnavailableListener: string | undefined
|
||||
private readonly linuxScope = new LinuxScopeSelector({
|
||||
probe: () => probeLinuxNative(),
|
||||
managerListener: () => linuxManagerListener(),
|
||||
confirmManager: () => confirmLinuxManager(),
|
||||
launcherIdentity: () => systemdRunIdentity(),
|
||||
})
|
||||
/** Scope decision of the launch in progress; selectContainmentMode sets it immediately before the launch reads it. */
|
||||
private linuxScopeDecision: Extract<LinuxScopeDecision, { kind: 'scope' }> | undefined
|
||||
/** Test hook for platform process inspection; production resolves lazily on terminal spawn. */
|
||||
terminalInspector: ProcessInspector | undefined
|
||||
|
||||
@@ -202,12 +204,12 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
|
||||
handle = spawnSubprocess(spec, internals)
|
||||
} else {
|
||||
const binding = prepareManagedProcessBinding(internals)
|
||||
const linuxScope = containmentMode === 'linux-scope' ? this.linuxLiteralArgv : undefined
|
||||
const linuxScope = containmentMode === 'linux-scope' ? this.linuxScopeDecision : undefined
|
||||
const launch = linuxScope === undefined
|
||||
? launchWindowsJob(spec, env)
|
||||
: launchLinuxScope(spec, env, linuxScope)
|
||||
: launchLinuxScope(spec, env, linuxScope.literalArgv)
|
||||
handle = bindManagedProcess(spec, launch, binding)
|
||||
if (linuxScope !== undefined) this.watchLinuxScopeStart(handle.done)
|
||||
if (linuxScope !== undefined) this.watchLinuxScopeStart(handle.done, linuxScope.generation)
|
||||
}
|
||||
this.live.add(handle)
|
||||
const control = handle.control
|
||||
@@ -225,31 +227,10 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
|
||||
return handle
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe on the first eligible ordinary or terminal spawn. After a positive result, confirm the user manager
|
||||
* before each launch; after a negative one, probe again only when a new user-manager listener appears.
|
||||
*/
|
||||
private linuxScopeAvailable(): boolean {
|
||||
if (this.linuxProbed && this.linuxLiteralArgv === undefined) {
|
||||
const listener = linuxManagerListener()
|
||||
if (listener === undefined || listener === this.linuxUnavailableListener) return false
|
||||
this.linuxProbed = false
|
||||
}
|
||||
if (!this.linuxProbed) {
|
||||
this.linuxProbed = true
|
||||
this.linuxProbeGeneration += 1
|
||||
this.linuxLiteralArgv = probeLinuxNative()
|
||||
if (this.linuxLiteralArgv === undefined) this.linuxUnavailableListener = linuxManagerListener()
|
||||
return this.linuxLiteralArgv !== undefined
|
||||
}
|
||||
return confirmLinuxManager()
|
||||
}
|
||||
|
||||
/** Probe again before the next launch when a scope launched under the current probe never started its target. */
|
||||
private watchLinuxScopeStart(done: Promise<unknown>): void {
|
||||
const generation = this.linuxProbeGeneration
|
||||
/** Probe again before the next launch when a scope launched under generation never started its target. */
|
||||
private watchLinuxScopeStart(done: Promise<unknown>, generation: number): void {
|
||||
void done.catch((error: unknown) => {
|
||||
if (error instanceof LinuxScopeNotStartedError && generation === this.linuxProbeGeneration) this.linuxProbed = false
|
||||
if (error instanceof LinuxScopeNotStartedError) this.linuxScope.scopeNotStarted(generation)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -259,7 +240,13 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
|
||||
const platform = this.internals.platform ?? process.platform
|
||||
let fallbackReason: string | undefined
|
||||
if (platform === 'linux') {
|
||||
if (this.linuxScopeAvailable()) return 'linux-scope'
|
||||
const decision = this.linuxScope.select()
|
||||
const probe = decision.probe === undefined ? '' : `, probed after ${decision.probe}`
|
||||
this.ctx.logger.debug(`linux native containment: ${decision.kind} (${decision.reason}${probe})`)
|
||||
if (decision.kind === 'scope') {
|
||||
this.linuxScopeDecision = decision
|
||||
return 'linux-scope'
|
||||
}
|
||||
fallbackReason = 'the current user-systemd scope or private bootstrap is unavailable'
|
||||
}
|
||||
if (kind === 'ordinary' && platform === 'win32') {
|
||||
@@ -324,13 +311,13 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
|
||||
// verbatim, see [Agent Note](../../../../.agents/notes/implemented/bug-fix/2026-09-28-pty-bundled-console-host.md).
|
||||
...(platform === 'win32' ? { useConptyDll: true } : {}),
|
||||
}
|
||||
const linuxScope = containmentMode === 'linux-scope' ? this.linuxScopeDecision : undefined
|
||||
let scope: ReturnType<typeof prepareLinuxTerminalScope> | undefined
|
||||
let terminal: NodePty.IPty
|
||||
try {
|
||||
const linuxScope = containmentMode === 'linux-scope' ? this.linuxLiteralArgv : undefined
|
||||
scope = linuxScope === undefined
|
||||
? undefined
|
||||
: prepareLinuxTerminalScope(launch, { ...activity?.env ?? env, PWD: spec.cwd, TERM: spec.terminalType }, linuxScope)
|
||||
: prepareLinuxTerminalScope(launch, { ...activity?.env ?? env, PWD: spec.cwd, TERM: spec.terminalType }, linuxScope.literalArgv)
|
||||
if (scope !== undefined) { options.cwd = scope.cwd; options.env = scope.env }
|
||||
terminal = requireNodePty().spawn(
|
||||
scope?.command ?? file,
|
||||
@@ -363,7 +350,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
|
||||
spec.shellActivity === true,
|
||||
)
|
||||
this.terminals.add(handle)
|
||||
if (scope !== undefined) this.watchLinuxScopeStart(handle.done)
|
||||
if (linuxScope !== undefined) this.watchLinuxScopeStart(handle.done, linuxScope.generation)
|
||||
const release = async (): Promise<void> => {
|
||||
// terminate() can wait on this direct-exit promise.
|
||||
directSettlement.resolve()
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/** Linux native containment decisions over injected host observations. */
|
||||
|
||||
import type { LiteralArgvOptions } from './linux-scope.ts'
|
||||
|
||||
/** Host observations behind each decision; the runtime supplies the real checks and tests script them. */
|
||||
export interface LinuxScopeObservations {
|
||||
/** Run the deep transient-scope probe: literal-argv options when a scope can start, otherwise undefined. */
|
||||
probe(): LiteralArgvOptions | undefined
|
||||
/** Identify the process listening on the user manager's private socket, or undefined when none listens. */
|
||||
managerListener(): string | undefined
|
||||
/** Confirm the user manager before a launch under a positive verdict. */
|
||||
confirmManager(): boolean
|
||||
/** Identify the systemd-run binary scope launches resolve, or undefined when none resolves. */
|
||||
launcherIdentity(): string | undefined
|
||||
}
|
||||
|
||||
/** Event that made the selector probe: the first selection or one that invalidated the previous verdict. */
|
||||
export type LinuxProbeTrigger = 'first' | 'scope-not-started' | 'new-listener' | 'launcher-changed'
|
||||
|
||||
/** One containment decision and the branch that produced it. */
|
||||
export type LinuxScopeDecision =
|
||||
| {
|
||||
readonly kind: 'scope'
|
||||
/** A probe found scope support, or a positive verdict confirmed the user manager. */
|
||||
readonly reason: 'probe-available' | 'manager-confirmed'
|
||||
readonly literalArgv: LiteralArgvOptions
|
||||
/** Probe count when the verdict was made; pass it to scopeNotStarted for a failed launch. */
|
||||
readonly generation: number
|
||||
readonly probe: LinuxProbeTrigger | undefined
|
||||
}
|
||||
| {
|
||||
readonly kind: 'fallback'
|
||||
/**
|
||||
* A probe found no scope support; a positive verdict failed to confirm the manager for this launch;
|
||||
* or a negative verdict saw no listener or the listener present at the failed probe.
|
||||
*/
|
||||
readonly reason: 'probe-unavailable' | 'manager-unconfirmed' | 'no-listener' | 'same-listener'
|
||||
readonly probe: LinuxProbeTrigger | undefined
|
||||
}
|
||||
|
||||
type LinuxScopeState =
|
||||
| { readonly kind: 'unprobed'; readonly trigger: LinuxProbeTrigger }
|
||||
| { readonly kind: 'available'; readonly literalArgv: LiteralArgvOptions; readonly launcher: string | undefined }
|
||||
| { readonly kind: 'unavailable'; readonly listener: string | undefined }
|
||||
|
||||
/**
|
||||
* Provider-lifetime Linux scope selection. A probe runs on the first selection and again only after
|
||||
* a scope launch never started its target, a different user-manager listener appears under a
|
||||
* negative verdict, or the systemd-run binary changes under a positive one. A positive verdict
|
||||
* confirms the user manager on every selection. Selection is synchronous, so a decision stays
|
||||
* valid for the launch that follows it.
|
||||
*/
|
||||
export class LinuxScopeSelector {
|
||||
private state: LinuxScopeState = { kind: 'unprobed', trigger: 'first' }
|
||||
private generation = 0
|
||||
|
||||
/** @param observe - host checks consulted by each selection. */
|
||||
constructor(private readonly observe: LinuxScopeObservations) {}
|
||||
|
||||
/**
|
||||
* Choose containment for one eligible launch.
|
||||
* @returns the decision and the branch that produced it.
|
||||
*/
|
||||
select(): LinuxScopeDecision {
|
||||
const state = this.state
|
||||
if (state.kind === 'unprobed') return this.probe(state.trigger)
|
||||
if (state.kind === 'available') {
|
||||
if (this.observe.launcherIdentity() !== state.launcher) return this.probe('launcher-changed')
|
||||
return this.observe.confirmManager()
|
||||
? { kind: 'scope', reason: 'manager-confirmed', literalArgv: state.literalArgv, generation: this.generation, probe: undefined }
|
||||
: { kind: 'fallback', reason: 'manager-unconfirmed', probe: undefined }
|
||||
}
|
||||
const listener = this.observe.managerListener()
|
||||
if (listener === undefined) return { kind: 'fallback', reason: 'no-listener', probe: undefined }
|
||||
if (listener === state.listener) return { kind: 'fallback', reason: 'same-listener', probe: undefined }
|
||||
return this.probe('new-listener')
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a scope launched under generation exited before its bootstrap started the target,
|
||||
* so the next selection probes again. Launches from a superseded probe are ignored.
|
||||
* @param generation - generation of the scope decision that launched it.
|
||||
*/
|
||||
scopeNotStarted(generation: number): void {
|
||||
if (generation === this.generation) this.state = { kind: 'unprobed', trigger: 'scope-not-started' }
|
||||
}
|
||||
|
||||
private probe(trigger: LinuxProbeTrigger): LinuxScopeDecision {
|
||||
this.generation += 1
|
||||
// Read before probing: a manager that starts during a failed probe then counts as a new listener.
|
||||
const listener = this.observe.managerListener()
|
||||
const launcher = this.observe.launcherIdentity()
|
||||
const literalArgv = this.observe.probe()
|
||||
if (literalArgv === undefined) {
|
||||
this.state = { kind: 'unavailable', listener }
|
||||
return { kind: 'fallback', reason: 'probe-unavailable', probe: trigger }
|
||||
}
|
||||
this.state = { kind: 'available', literalArgv, launcher }
|
||||
return { kind: 'scope', reason: 'probe-available', literalArgv, generation: this.generation, probe: trigger }
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { controlPipe } from './control-spawn.ts'
|
||||
import { execFile, spawn, spawnSync } from 'node:child_process'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { setTimeout as sleepMs } from 'node:timers/promises'
|
||||
import type {
|
||||
@@ -186,7 +186,7 @@ export function probeLinuxManager(internals: LinuxScopeInternals = {}): boolean
|
||||
function unixSocketListener(path: string, table: string): string | undefined {
|
||||
let text: string
|
||||
try {
|
||||
text = readFileSync(table, 'latin1')
|
||||
text = readFileSync(table, 'utf8')
|
||||
} catch (_unreadableSocketTable) {
|
||||
// Without the socket table nothing is known, so callers fall back to systemctl or keep their verdict.
|
||||
return undefined
|
||||
@@ -215,6 +215,30 @@ export function linuxManagerListener(internals: LinuxScopeInternals = {}): strin
|
||||
return unixSocketListener(join(runtimeDirectory, 'systemd', 'private'), internals.unixSocketTable ?? '/proc/net/unix')
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the systemd-run binary that scope launches resolve through PATH, so a package upgrade
|
||||
* that replaces it triggers another probe of its literal-argv options.
|
||||
* @param internals - optional systemd-run seam used by tests.
|
||||
* @returns the device, inode, size, and modification time of the first match, or undefined when none resolves.
|
||||
*/
|
||||
export function systemdRunIdentity(internals: LinuxScopeInternals = {}): string | undefined {
|
||||
const command = internals.systemdRun ?? 'systemd-run'
|
||||
const candidates = command.includes('/')
|
||||
? [command]
|
||||
: (childEnv().PATH ?? '').split(':').filter(directory => directory !== '').map(directory => join(directory, command))
|
||||
for (const candidate of candidates) {
|
||||
let stats: ReturnType<typeof statSync>
|
||||
try {
|
||||
stats = statSync(candidate, { throwIfNoEntry: false })
|
||||
} catch (_unreadableCandidate) {
|
||||
// A PATH entry that is not a searchable directory cannot hold systemd-run.
|
||||
continue
|
||||
}
|
||||
if (stats?.isFile() === true) return `${String(stats.dev)}:${String(stats.ino)}:${String(stats.size)}:${String(stats.mtimeMs)}`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user manager before a scope launch. A listener on its private socket confirms it
|
||||
* without a child process; otherwise one systemctl query decides. A hung manager that still
|
||||
@@ -237,8 +261,6 @@ interface DirectRange {
|
||||
/** A scope launch whose bootstrap never consumed its launch request, so its target never ran. */
|
||||
export class LinuxScopeNotStartedError extends Error {
|
||||
override readonly name = 'LinuxScopeNotStartedError'
|
||||
/** Stable code for callers that classify failures without instanceof. */
|
||||
readonly code = 'ERR_SUBPROCESS_SCOPE_NOT_STARTED'
|
||||
}
|
||||
|
||||
class LinuxScopeStartup {
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { LinuxScopeSelector } from '../src/linux-scope-selector.ts'
|
||||
import type { LinuxScopeDecision } from '../src/linux-scope-selector.ts'
|
||||
|
||||
/** Host state the selector observes; scenarios change it between launches. */
|
||||
interface World {
|
||||
/** What a deep probe returns now: literal-argv options, or undefined when no scope can start. */
|
||||
probe: readonly string[] | undefined
|
||||
/** Socket inode of the process listening on the user manager's private socket, or undefined. */
|
||||
listener: string | undefined
|
||||
/** Whether confirming the user manager succeeds now. */
|
||||
confirm: boolean
|
||||
/** Identity of the resolved systemd-run binary, or undefined when none resolves. */
|
||||
launcher: string | undefined
|
||||
}
|
||||
|
||||
type Step =
|
||||
/** One eligible launch selects containment. */
|
||||
| 'launch'
|
||||
/** The host changes before the next launch. */
|
||||
| { readonly set: Partial<World> }
|
||||
/** The host changes while the next probe runs, after the selector read its listener and launcher. */
|
||||
| { readonly duringProbe: Partial<World> }
|
||||
/** A scope launched under this generation exited before its bootstrap started the target. */
|
||||
| { readonly notStarted: number }
|
||||
|
||||
interface Scenario {
|
||||
readonly name: string
|
||||
readonly world: World
|
||||
readonly steps: readonly Step[]
|
||||
/** Summary of each launch's decision, in order. */
|
||||
readonly decisions: readonly string[]
|
||||
readonly probes: number
|
||||
}
|
||||
|
||||
const OPTION = '--expand-environment=no'
|
||||
const healthy: World = { probe: [OPTION], listener: 'manager-1', confirm: true, launcher: 'systemd-run-255' }
|
||||
const absent: World = { probe: undefined, listener: undefined, confirm: false, launcher: 'systemd-run-245' }
|
||||
|
||||
function summary(decision: LinuxScopeDecision): string {
|
||||
const probe = decision.probe === undefined ? '' : ` after ${decision.probe}`
|
||||
return decision.kind === 'scope'
|
||||
? `scope[${decision.literalArgv.join(' ')}]#${String(decision.generation)} ${decision.reason}${probe}`
|
||||
: `fallback ${decision.reason}${probe}`
|
||||
}
|
||||
|
||||
function run(initial: World, steps: readonly Step[]): { decisions: string[]; probes: number } {
|
||||
const world = { ...initial }
|
||||
let duringProbe: Partial<World> | undefined
|
||||
let probes = 0
|
||||
const selector = new LinuxScopeSelector({
|
||||
probe: () => {
|
||||
probes += 1
|
||||
const result = world.probe
|
||||
Object.assign(world, duringProbe)
|
||||
duringProbe = undefined
|
||||
return result
|
||||
},
|
||||
managerListener: () => world.listener,
|
||||
confirmManager: () => world.confirm,
|
||||
launcherIdentity: () => world.launcher,
|
||||
})
|
||||
const decisions: string[] = []
|
||||
for (const step of steps) {
|
||||
if (step === 'launch') decisions.push(summary(selector.select()))
|
||||
else if ('set' in step) Object.assign(world, step.set)
|
||||
else if ('duringProbe' in step) duringProbe = step.duringProbe
|
||||
else selector.scopeNotStarted(step.notStarted)
|
||||
}
|
||||
return { decisions, probes }
|
||||
}
|
||||
|
||||
const scenarios: readonly Scenario[] = [
|
||||
{
|
||||
name: 'no user manager: probe once, then fall back without probing',
|
||||
world: absent,
|
||||
steps: ['launch', 'launch', 'launch'],
|
||||
decisions: ['fallback probe-unavailable after first', 'fallback no-listener', 'fallback no-listener'],
|
||||
probes: 1,
|
||||
},
|
||||
{
|
||||
name: 'systemd 254 and later: probe once, then confirm the manager per launch',
|
||||
world: healthy,
|
||||
steps: ['launch', 'launch', 'launch'],
|
||||
decisions: [
|
||||
`scope[${OPTION}]#1 probe-available after first`,
|
||||
`scope[${OPTION}]#1 manager-confirmed`,
|
||||
`scope[${OPTION}]#1 manager-confirmed`,
|
||||
],
|
||||
probes: 1,
|
||||
},
|
||||
{
|
||||
name: 'systemd 236 to 253: scope launches carry no expansion option',
|
||||
world: { ...healthy, probe: [], launcher: 'systemd-run-249' },
|
||||
steps: ['launch', 'launch'],
|
||||
decisions: ['scope[]#1 probe-available after first', 'scope[]#1 manager-confirmed'],
|
||||
probes: 1,
|
||||
},
|
||||
{
|
||||
name: 'a manager that stops and returns under a positive verdict falls back only while it is gone',
|
||||
world: healthy,
|
||||
steps: [
|
||||
'launch',
|
||||
{ set: { listener: undefined, confirm: false } },
|
||||
'launch',
|
||||
'launch',
|
||||
{ set: { listener: 'manager-2', confirm: true } },
|
||||
'launch',
|
||||
],
|
||||
decisions: [
|
||||
`scope[${OPTION}]#1 probe-available after first`,
|
||||
'fallback manager-unconfirmed',
|
||||
'fallback manager-unconfirmed',
|
||||
`scope[${OPTION}]#1 manager-confirmed`,
|
||||
],
|
||||
probes: 1,
|
||||
},
|
||||
{
|
||||
name: 'a manager hung at the first probe is not probed again until a new manager listens',
|
||||
world: { ...absent, listener: 'hung-manager' },
|
||||
steps: ['launch', 'launch', 'launch', { set: { ...healthy, listener: 'manager-2' } }, 'launch', 'launch'],
|
||||
decisions: [
|
||||
'fallback probe-unavailable after first',
|
||||
'fallback same-listener',
|
||||
'fallback same-listener',
|
||||
`scope[${OPTION}]#2 probe-available after new-listener`,
|
||||
`scope[${OPTION}]#2 manager-confirmed`,
|
||||
],
|
||||
probes: 2,
|
||||
},
|
||||
{
|
||||
name: 'a manager that hangs under a positive verdict costs one probe after its scope never starts',
|
||||
world: { ...healthy, listener: 'hung-manager' },
|
||||
steps: [
|
||||
'launch',
|
||||
'launch',
|
||||
// The hung manager still listens, so confirmation passes but its scopes never start.
|
||||
{ set: { probe: undefined } },
|
||||
'launch',
|
||||
{ notStarted: 1 },
|
||||
'launch',
|
||||
// A second launch from generation 1 fails late; the newer verdict stands.
|
||||
{ notStarted: 1 },
|
||||
'launch',
|
||||
{ set: { listener: 'manager-2', probe: [OPTION] } },
|
||||
'launch',
|
||||
],
|
||||
decisions: [
|
||||
`scope[${OPTION}]#1 probe-available after first`,
|
||||
`scope[${OPTION}]#1 manager-confirmed`,
|
||||
`scope[${OPTION}]#1 manager-confirmed`,
|
||||
'fallback probe-unavailable after scope-not-started',
|
||||
'fallback same-listener',
|
||||
`scope[${OPTION}]#3 probe-available after new-listener`,
|
||||
],
|
||||
probes: 3,
|
||||
},
|
||||
{
|
||||
name: 'a scope that never starts under a healthy manager probes again and keeps the scope',
|
||||
world: healthy,
|
||||
steps: ['launch', { notStarted: 1 }, 'launch', 'launch'],
|
||||
decisions: [
|
||||
`scope[${OPTION}]#1 probe-available after first`,
|
||||
`scope[${OPTION}]#2 probe-available after scope-not-started`,
|
||||
`scope[${OPTION}]#2 manager-confirmed`,
|
||||
],
|
||||
probes: 2,
|
||||
},
|
||||
{
|
||||
name: 'a manager that starts after a failed first probe is used without a restart',
|
||||
world: absent,
|
||||
steps: ['launch', 'launch', { set: { ...healthy, listener: 'manager-1' } }, 'launch', 'launch'],
|
||||
decisions: [
|
||||
'fallback probe-unavailable after first',
|
||||
'fallback no-listener',
|
||||
`scope[${OPTION}]#2 probe-available after new-listener`,
|
||||
`scope[${OPTION}]#2 manager-confirmed`,
|
||||
],
|
||||
probes: 2,
|
||||
},
|
||||
{
|
||||
name: 'a manager that starts during a failed probe counts as a new listener',
|
||||
world: absent,
|
||||
steps: [{ duringProbe: { ...healthy, listener: 'manager-1' } }, 'launch', 'launch'],
|
||||
decisions: ['fallback probe-unavailable after first', `scope[${OPTION}]#2 probe-available after new-listener`],
|
||||
probes: 2,
|
||||
},
|
||||
{
|
||||
name: 'a systemd-run upgrade across v254 probes the literal-argv options again',
|
||||
world: { ...healthy, probe: [], launcher: 'systemd-run-253' },
|
||||
steps: ['launch', 'launch', { set: { probe: [OPTION], launcher: 'systemd-run-258' } }, 'launch', 'launch'],
|
||||
decisions: [
|
||||
'scope[]#1 probe-available after first',
|
||||
'scope[]#1 manager-confirmed',
|
||||
`scope[${OPTION}]#2 probe-available after launcher-changed`,
|
||||
`scope[${OPTION}]#2 manager-confirmed`,
|
||||
],
|
||||
probes: 2,
|
||||
},
|
||||
{
|
||||
name: 'a removed systemd-run turns a positive verdict negative',
|
||||
world: healthy,
|
||||
steps: ['launch', { set: { probe: undefined, launcher: undefined } }, 'launch', 'launch'],
|
||||
decisions: [
|
||||
`scope[${OPTION}]#1 probe-available after first`,
|
||||
'fallback probe-unavailable after launcher-changed',
|
||||
'fallback same-listener',
|
||||
],
|
||||
probes: 2,
|
||||
},
|
||||
]
|
||||
|
||||
describe('Linux scope selection', () => {
|
||||
it.each(scenarios)('$name', ({ world, steps, decisions, probes }) => {
|
||||
expect(run(world, steps)).toEqual({ decisions, probes })
|
||||
})
|
||||
|
||||
it('consults only the observations each verdict needs', () => {
|
||||
const observe = {
|
||||
probe: vi.fn((): readonly string[] | undefined => undefined),
|
||||
managerListener: vi.fn((): string | undefined => undefined),
|
||||
confirmManager: vi.fn(() => true),
|
||||
launcherIdentity: vi.fn((): string | undefined => 'systemd-run'),
|
||||
}
|
||||
const selector = new LinuxScopeSelector(observe)
|
||||
selector.select()
|
||||
selector.select()
|
||||
expect(observe.probe).toHaveBeenCalledOnce()
|
||||
expect(observe.managerListener).toHaveBeenCalledTimes(2)
|
||||
expect(observe.launcherIdentity).toHaveBeenCalledOnce()
|
||||
expect(observe.confirmManager).not.toHaveBeenCalled()
|
||||
|
||||
observe.probe.mockReturnValue([])
|
||||
observe.managerListener.mockReturnValue('manager-1')
|
||||
selector.select()
|
||||
observe.managerListener.mockClear()
|
||||
selector.select()
|
||||
expect(observe.managerListener).not.toHaveBeenCalled()
|
||||
expect(observe.confirmManager).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { createServer } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
probeLinuxNative,
|
||||
probeLinuxScope,
|
||||
signalLinuxDirectProcess,
|
||||
systemdRunIdentity,
|
||||
} from '../src/linux-scope.ts'
|
||||
import type { LinuxScopeInternals } from '../src/linux-scope.ts'
|
||||
import {
|
||||
@@ -283,7 +284,7 @@ describe('Linux native capability selection', () => {
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'linux')('reads a real listening socket from the kernel socket table', async () => {
|
||||
const runtime = mkdtempSync(join(tmpdir(), 'dsh-runtime-'))
|
||||
const runtime = mkdtempSync(join(tmpdir(), 'dsh-运行时-'))
|
||||
directories.push(runtime)
|
||||
mkdirSync(join(runtime, 'systemd'))
|
||||
const server = createServer()
|
||||
@@ -308,6 +309,54 @@ describe('Linux native capability selection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('matches a socket path outside ASCII in the kernel socket table', () => {
|
||||
const runtime = mkdtempSync(join(tmpdir(), 'dsh-运行时-'))
|
||||
directories.push(runtime)
|
||||
const table = join(runtime, 'unix')
|
||||
writeFileSync(table, `0000000048ab7e3a: 00000002 00000000 00010000 0001 01 77 ${join(runtime, 'systemd', 'private')}\n`)
|
||||
try {
|
||||
vi.stubEnv('XDG_RUNTIME_DIR', runtime)
|
||||
expect(linuxManagerListener({ unixSocketTable: table })).toBe('77')
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('identifies the systemd-run binary that PATH resolves', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-path-'))
|
||||
directories.push(root)
|
||||
const fileEntry = join(root, 'file-entry')
|
||||
writeFileSync(fileEntry, '')
|
||||
// A self-referencing symlink makes stat fail with ELOOP instead of reporting no entry.
|
||||
const loop = join(root, 'loop')
|
||||
symlinkSync(loop, loop)
|
||||
const shadow = join(root, 'shadow')
|
||||
mkdirSync(join(shadow, 'systemd-run'), { recursive: true })
|
||||
const bin = join(root, 'bin')
|
||||
mkdirSync(bin)
|
||||
const binary = join(bin, 'systemd-run')
|
||||
writeFileSync(binary, 'v253')
|
||||
try {
|
||||
vi.stubEnv('PATH', ['', join(root, 'missing'), fileEntry, loop, shadow, bin].join(':'))
|
||||
const installed = systemdRunIdentity()
|
||||
expect(installed).toMatch(/^\d+:\d+:4:/u)
|
||||
expect(systemdRunIdentity()).toBe(installed)
|
||||
// A package upgrade replaces the file, which changes its identity.
|
||||
rmSync(binary)
|
||||
writeFileSync(binary, 'v258.1')
|
||||
const upgraded = systemdRunIdentity()
|
||||
expect(upgraded).not.toBe(installed)
|
||||
expect(systemdRunIdentity({ systemdRun: binary })).toBe(upgraded)
|
||||
|
||||
vi.stubEnv('PATH', join(root, 'missing'))
|
||||
expect(systemdRunIdentity()).toBeUndefined()
|
||||
vi.stubEnv('PATH', undefined)
|
||||
expect(systemdRunIdentity()).toBeUndefined()
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops the expansion opt-out only for a systemd-run that rejects it', () => {
|
||||
const rejected = { status: 1, stderr: "systemd-run: unrecognized option '--expand-environment=no'\n" }
|
||||
const unavailable = { status: 1, stderr: 'Failed to create bus connection: No such file or directory\n' }
|
||||
|
||||
@@ -614,6 +614,7 @@ describe('LocalSubprocessRuntime', () => {
|
||||
confirmLinuxManager: vi.fn(() => true),
|
||||
LinuxScopeNotStartedError,
|
||||
linuxManagerListener: vi.fn(() => undefined),
|
||||
systemdRunIdentity: vi.fn(() => 'launcher'),
|
||||
signalLinuxDirectProcess,
|
||||
launchLinuxScope: vi.fn(),
|
||||
prepareLinuxTerminalScope,
|
||||
@@ -685,6 +686,82 @@ describe('LocalSubprocessRuntime', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('probes Linux again after a terminal scope never starts its target', async () => {
|
||||
const exits: Array<(event: { exitCode: number; signal?: number }) => void> = []
|
||||
const nodePtySpawn = vi.fn((_file: string, _args: readonly string[], _options: unknown) => ({
|
||||
pid: 123,
|
||||
onData: () => ({ dispose: () => {} }),
|
||||
onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => {
|
||||
exits.push(listener)
|
||||
return { dispose: () => {} }
|
||||
},
|
||||
write: () => {},
|
||||
kill: () => {},
|
||||
}))
|
||||
const prepareLinuxTerminalScope = vi.fn(() => ({
|
||||
command: '/usr/bin/systemd-run',
|
||||
args: ['--user', '--scope'],
|
||||
cwd: '/bootstrap',
|
||||
env: {},
|
||||
bindOwner: () => ({ signal: vi.fn(), waitForExit: vi.fn(async () => {}), terminateForHostExit: vi.fn() }),
|
||||
resolveOutcome: () => { throw new LinuxScopeNotStartedError('terminal scope never started') },
|
||||
cleanup: vi.fn(),
|
||||
}))
|
||||
const probeLinuxNative = vi.fn<() => string[] | undefined>()
|
||||
.mockReturnValueOnce([])
|
||||
.mockReturnValueOnce(undefined)
|
||||
|
||||
vi.resetModules()
|
||||
mockWin32ForIsolatedRuntime()
|
||||
mockNodePtyForIsolatedRuntime(nodePtySpawn)
|
||||
vi.doMock('../src/linux-scope.ts', () => ({
|
||||
confirmLinuxManager: vi.fn(() => true),
|
||||
LinuxScopeNotStartedError,
|
||||
linuxManagerListener: vi.fn(() => undefined),
|
||||
signalLinuxDirectProcess,
|
||||
systemdRunIdentity: vi.fn(() => 'launcher'),
|
||||
launchLinuxScope: vi.fn(),
|
||||
prepareLinuxTerminalScope,
|
||||
probeLinuxNative,
|
||||
}))
|
||||
let fiber: { dispose(): Promise<void> } | undefined
|
||||
try {
|
||||
const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
|
||||
const ctx = new Context()
|
||||
vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
|
||||
const runtime = ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
|
||||
runtime.internals = { platform: 'linux' }
|
||||
runtime.terminalInspector = {
|
||||
foregroundPgid: () => undefined,
|
||||
isStdinWaiting: () => false,
|
||||
snapshot: () => ({ tree: () => [], session: () => [], alive: () => false }),
|
||||
isAlive: () => false,
|
||||
signalGroup: () => {},
|
||||
signalProcess: () => {},
|
||||
}
|
||||
const terminalSpec = { argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, terminalType: 'dumb', graceMs: 10 }
|
||||
|
||||
const scoped = await runtime.spawnTerminal(terminalSpec)
|
||||
exits[0]?.({ exitCode: 1 })
|
||||
await expect(scoped.done).rejects.toBeInstanceOf(LinuxScopeNotStartedError)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
const direct = await runtime.spawnTerminal(terminalSpec)
|
||||
|
||||
expect(probeLinuxNative).toHaveBeenCalledTimes(2)
|
||||
expect(prepareLinuxTerminalScope).toHaveBeenCalledOnce()
|
||||
expect(nodePtySpawn.mock.calls.map(([file]) => file)).toEqual(['/usr/bin/systemd-run', 'shell'])
|
||||
exits[1]?.({ exitCode: 0 })
|
||||
await direct.done
|
||||
} finally {
|
||||
await fiber?.dispose()
|
||||
unmockLazyRequireForIsolatedRuntime()
|
||||
vi.doUnmock('../src/linux-scope.ts')
|
||||
unmockWin32ForIsolatedRuntime()
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
it('cleans the Linux terminal launch protocol when node-pty throws synchronously', async () => {
|
||||
const launchFailure = new Error('node-pty launch failed')
|
||||
const cleanup = vi.fn()
|
||||
@@ -718,6 +795,7 @@ describe('LocalSubprocessRuntime', () => {
|
||||
confirmLinuxManager: vi.fn(() => true),
|
||||
LinuxScopeNotStartedError,
|
||||
linuxManagerListener: vi.fn(() => undefined),
|
||||
systemdRunIdentity: vi.fn(() => 'launcher'),
|
||||
signalLinuxDirectProcess,
|
||||
launchLinuxScope: vi.fn(),
|
||||
prepareLinuxTerminalScope,
|
||||
@@ -931,6 +1009,7 @@ describe('LocalSubprocessRuntime', () => {
|
||||
confirmLinuxManager,
|
||||
LinuxScopeNotStartedError,
|
||||
linuxManagerListener: vi.fn(() => undefined),
|
||||
systemdRunIdentity: vi.fn(() => 'launcher'),
|
||||
signalLinuxDirectProcess,
|
||||
launchLinuxScope,
|
||||
prepareLinuxTerminalScope: vi.fn(),
|
||||
@@ -1001,17 +1080,11 @@ describe('LocalSubprocessRuntime', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('probes Linux again after a scope never starts its target or a new user-manager listener appears', async () => {
|
||||
it('routes Linux scope decisions into launches and probes again after a scope never starts', async () => {
|
||||
const probeLinuxNative = vi.fn<() => string[] | undefined>()
|
||||
.mockReturnValueOnce([])
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce([])
|
||||
const linuxManagerListener = vi.fn<() => string | undefined>()
|
||||
.mockReturnValueOnce('101')
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce('101')
|
||||
.mockReturnValueOnce('202')
|
||||
const confirmLinuxManager = vi.fn(() => true)
|
||||
const launchLinuxScope = vi.fn((_spec: unknown, _env: unknown, _literalArgv: readonly string[]) => ({ kind: 'linux' }))
|
||||
const scopeOutcomes: Array<PromiseWithResolvers<{ exitCode: number; signal: null }>> = []
|
||||
const fakeHandle = (done: Promise<{ exitCode: number; signal: null }>) => ({
|
||||
collected: {},
|
||||
@@ -1030,11 +1103,12 @@ describe('LocalSubprocessRuntime', () => {
|
||||
vi.resetModules()
|
||||
mockWin32ForIsolatedRuntime()
|
||||
vi.doMock('../src/linux-scope.ts', () => ({
|
||||
confirmLinuxManager,
|
||||
confirmLinuxManager: vi.fn(() => true),
|
||||
LinuxScopeNotStartedError,
|
||||
linuxManagerListener,
|
||||
linuxManagerListener: vi.fn(() => undefined),
|
||||
signalLinuxDirectProcess,
|
||||
launchLinuxScope: vi.fn(() => ({ kind: 'linux' })),
|
||||
systemdRunIdentity: vi.fn(() => 'launcher'),
|
||||
launchLinuxScope,
|
||||
prepareLinuxTerminalScope: vi.fn(),
|
||||
probeLinuxNative,
|
||||
}))
|
||||
@@ -1052,6 +1126,7 @@ describe('LocalSubprocessRuntime', () => {
|
||||
const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
|
||||
const ctx = new Context()
|
||||
vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const debug = vi.spyOn(ctx.logger, 'debug').mockImplementation(() => {})
|
||||
fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
|
||||
const runtime = ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
|
||||
runtime.internals = { platform: 'linux' }
|
||||
@@ -1062,29 +1137,24 @@ describe('LocalSubprocessRuntime', () => {
|
||||
scopeOutcomes[0]?.reject(new Error('scope target failed after starting'))
|
||||
await settle()
|
||||
runtime.spawn(spec('true'))
|
||||
expect(probeLinuxNative).toHaveBeenCalledOnce()
|
||||
expect(confirmLinuxManager).toHaveBeenCalledTimes(2)
|
||||
|
||||
// A scope that never started its target makes the next spawn probe; the failed probe records listener 101.
|
||||
scopeOutcomes[1]?.reject(new LinuxScopeNotStartedError('scope never started'))
|
||||
await settle()
|
||||
runtime.spawn(spec('true'))
|
||||
expect(probeLinuxNative).toHaveBeenCalledTimes(2)
|
||||
expect(spawnSubprocess).toHaveBeenCalledOnce()
|
||||
|
||||
// A launch from the superseded probe, no listener, and the same listener keep the fallback.
|
||||
// A launch from the superseded probe keeps the newer verdict.
|
||||
scopeOutcomes[2]?.reject(new LinuxScopeNotStartedError('superseded launch'))
|
||||
await settle()
|
||||
runtime.spawn(spec('true'))
|
||||
runtime.spawn(spec('true'))
|
||||
expect(probeLinuxNative).toHaveBeenCalledTimes(2)
|
||||
expect(spawnSubprocess).toHaveBeenCalledTimes(3)
|
||||
|
||||
// A new listener means a new manager instance, which is probed once.
|
||||
runtime.spawn(spec('true'))
|
||||
expect(probeLinuxNative).toHaveBeenCalledTimes(3)
|
||||
expect(bindManagedProcess).toHaveBeenCalledTimes(4)
|
||||
expect(linuxManagerListener).toHaveBeenCalledTimes(4)
|
||||
expect(probeLinuxNative).toHaveBeenCalledTimes(2)
|
||||
expect(launchLinuxScope.mock.calls.map(([, , literalArgv]) => literalArgv)).toEqual([[], [], []])
|
||||
expect(spawnSubprocess).toHaveBeenCalledTimes(2)
|
||||
expect(debug.mock.calls.map(([message]) => String(message)).filter(message => message.startsWith('linux native'))).toEqual([
|
||||
'linux native containment: scope (probe-available, probed after first)',
|
||||
'linux native containment: scope (manager-confirmed)',
|
||||
'linux native containment: scope (manager-confirmed)',
|
||||
'linux native containment: fallback (probe-unavailable, probed after scope-not-started)',
|
||||
'linux native containment: fallback (no-listener)',
|
||||
])
|
||||
} finally {
|
||||
for (const outcome of scopeOutcomes) outcome.resolve({ exitCode: 0, signal: null })
|
||||
await fiber?.dispose()
|
||||
@@ -1112,6 +1182,7 @@ describe('LocalSubprocessRuntime', () => {
|
||||
LinuxScopeNotStartedError,
|
||||
linuxManagerListener,
|
||||
signalLinuxDirectProcess,
|
||||
systemdRunIdentity: vi.fn(() => 'launcher'),
|
||||
launchLinuxScope: vi.fn(),
|
||||
prepareLinuxTerminalScope: vi.fn(),
|
||||
probeLinuxNative,
|
||||
|
||||
Reference in new issue
Block a user