From da5e23e8e85489e9da0ef5e16d2bd0af44d24bc7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:51:05 +0800 Subject: [PATCH] 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. --- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess/subprocess-local/src/index.ts | 71 +++--- .../src/linux-scope-selector.ts | 101 ++++++++ .../subprocess-local/src/linux-scope.ts | 30 ++- .../tests/linux-scope-selector.spec.ts | 241 ++++++++++++++++++ .../tests/linux-scope.spec.ts | 53 +++- .../subprocess-local/tests/local.spec.ts | 125 +++++++-- 9 files changed, 550 insertions(+), 79 deletions(-) create mode 100644 packages/subprocess/subprocess-local/src/linux-scope-selector.ts create mode 100644 packages/subprocess/subprocess-local/tests/linux-scope-selector.spec.ts diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 2ee0c64680..98a5c40de8 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -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 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index b40ae5cf9a..05a6c3e41c 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -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). diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index fa22529771..e8589765c0 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.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)。 diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index f8b9560532..be24927093 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -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 | 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): 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, 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 | 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 => { // terminate() can wait on this direct-exit promise. directSettlement.resolve() diff --git a/packages/subprocess/subprocess-local/src/linux-scope-selector.ts b/packages/subprocess/subprocess-local/src/linux-scope-selector.ts new file mode 100644 index 0000000000..552b5d004f --- /dev/null +++ b/packages/subprocess/subprocess-local/src/linux-scope-selector.ts @@ -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 } + } +} diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 69ac3cf94f..09cbc10224 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -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 + 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 { diff --git a/packages/subprocess/subprocess-local/tests/linux-scope-selector.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope-selector.spec.ts new file mode 100644 index 0000000000..029cda42bb --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/linux-scope-selector.spec.ts @@ -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 } + /** The host changes while the next probe runs, after the selector read its listener and launcher. */ + | { readonly duringProbe: Partial } + /** 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 | 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() + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 382f0b4964..1af050125b 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -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' } diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 220a63139b..2c4c0b2f67 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -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 } | 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 + 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> = [] 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 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,