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:
imccyu committed 2026-10-15 00:51:05 +08:00
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,