fix(subprocess-local): probe Linux native containment once per provider

Every Linux spawn() and spawnTerminal() ran a synchronous spawnSync probe
before launching the command: the full systemd-run scope probe until it first
succeeded (a failed verdict was never cached), then a systemctl manager recheck.
spawnSync blocks the host event loop until the probe exits, so hosts without a
usable user manager paid the full probe on every grep, glob, and shell command.

Keep the first verdict, positive or negative, for the provider lifetime and
drop the per-spawn manager recheck.
This commit is contained in:
imccyu committed 2026-10-14 17:24:14 +08:00
1 parent 4b3cb7629e
commit 404e64b435
7 files changed
+16 -66

No files matched your search

@@ -63,8 +63,8 @@
en: e290ccc9b3c92c08
zh: d75d48f485447a74
/deepseek-ai-dsh-subprocess-local/known-limitations-and-deferred-work:
en: aca923fe6b461acb
zh: ee19a96907134d85
en: 0555e58fbd816c9a
zh: a7e8162c40245f5d
/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 and `systemd-run --expand-environment=no`; older systemd versions 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 repeats the bootstrap entry, libc `execve`/`fcntl` bindings, live user manager, and literal-argv scope probe until it first succeeds; later eligible ordinary or terminal spawns recheck only the live user manager. Windows rechecks the runner entry, bindings, and current Job support before every ordinary spawn. Successful Linux deep-probe state and fallback-warning de-duplication persist for the provider lifetime. All probes finish before the user command can run, and child-process probes have a 5-second timeout. 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, live user manager, and literal-argv scope probe once, on the first eligible ordinary or terminal spawn, and keeps that verdict for the provider lifetime: a user manager that stops later makes scope launches fail, and one that starts later is used only after the provider restarts. 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, and child-process probes have a 5-second timeout. 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 --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。
- **native 选择具有有界的每次 spawn 成本**——Linux 会重复检查 bootstrap 入口、libc `execve`/`fcntl` bindings、存活的 user manager 与 literal-argv scope 支持,直到这套完整探测首次成功;后续符合条件的普通命令或终端 spawn 只重新检查存活的 user manager。Windows 会在每次普通 spawn 前重新检查 runner 入口、bindings 与当前 Job 支持。Linux 深度探测的成功状态与 fallback 告警去重会在提供方生命周期内持续保留。所有探测都会在用户命令可能运行前完成,子进程探测的超时为 5 秒。每次 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 会让 scope 启动失败,之后才启动的 user manager 要等提供方重启后才会被使用。Windows 会在每次普通 spawn 前重新检查 runner 入口、bindings 与当前 Job 支持。fallback 告警去重同样在提供方生命周期内持续保留。所有探测都会在用户命令可能运行前完成,子进程探测的超时为 5 秒。每次 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)。
@@ -36,7 +36,6 @@ import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts'
import {
launchLinuxScope,
prepareLinuxTerminalScope,
probeLinuxManager,
probeLinuxNative,
signalLinuxDirectProcess,
} from './linux-scope.ts'
@@ -67,8 +66,8 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
internals: SpawnInternals = {}
/** Provider-lifetime latch suppressing repeated weaker-containment warnings. */
private fallbackWarningIssued = false
/** Positive-only cache for the expensive Linux bootstrap and scope probe. */
private linuxDeepProbePassed = false
/** Provider-lifetime verdict of the synchronous Linux native probe, set by the first eligible spawn. */
private linuxNativeAvailable: boolean | undefined
/** Test hook for platform process inspection; production resolves lazily on terminal spawn. */
terminalInspector: ProcessInspector | undefined
@@ -216,11 +215,8 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
const platform = this.internals.platform ?? process.platform
let fallbackReason: string | undefined
if (platform === 'linux') {
const available = this.linuxDeepProbePassed
? probeLinuxManager()
: probeLinuxNative()
if (available) this.linuxDeepProbePassed = true
if (available) return 'linux-scope'
this.linuxNativeAvailable ??= probeLinuxNative()
if (this.linuxNativeAvailable) return 'linux-scope'
fallbackReason = 'the current user-systemd scope or private bootstrap is unavailable'
}
if (kind === 'ordinary' && platform === 'win32') {
@@ -131,22 +131,7 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean {
}
/**
* Confirm that the current user manager remains reachable after a positive deep probe.
* @param internals - optional systemctl seam used by tests.
* @returns whether one lightweight manager query succeeds.
*/
export function probeLinuxManager(internals: LinuxScopeInternals = {}): boolean {
const result = (internals.spawnSync ?? spawnSync)(internals.systemctl ?? 'systemctl', [
'--user',
'show',
'--property=Version',
'--value',
], { env: managerEnvironment(), stdio: 'ignore', timeout: SYSTEMCTL_TIMEOUT_MS })
return result.error === undefined && result.status === 0
}
/**
* Re-check every Linux native prerequisite for one eligible spawn.
* Check every Linux native prerequisite; the provider keeps the first verdict for its lifetime.
* @param internals - optional native capability seams used by tests.
* @returns whether the Linux native containment path is currently available.
*/
@@ -7,7 +7,6 @@ import {
launchLinuxScope,
prepareLinuxTerminalScope,
probeLinuxBootstrap,
probeLinuxManager,
probeLinuxNative,
probeLinuxScope,
signalLinuxDirectProcess,
@@ -210,8 +209,7 @@ describe('Linux native capability selection', () => {
it('uses the default command adapters and runner resolution', () => {
childProcessMocks.spawnSync.mockReturnValue({ status: 0, error: undefined })
expect(probeLinuxScope()).toBe(true)
expect(probeLinuxManager()).toBe(true)
expect(childProcessMocks.spawnSync).toHaveBeenCalledTimes(2)
expect(childProcessMocks.spawnSync).toHaveBeenCalledOnce()
expect(probeLinuxBootstrap({ loadLinuxExecve: () => vi.fn() as never })).toBe(true)
expect(probeLinuxBootstrap({
runnerInvocation: [process.execPath],
@@ -219,26 +217,15 @@ describe('Linux native capability selection', () => {
})).toBe(process.platform !== 'win32')
})
it('keeps quieting on the transient-scope probe but preserves manager diagnostics', () => {
it('quiets systemd logging on the transient-scope probe', () => {
const spawnSync = vi.fn((
_command: string,
_args: readonly string[],
_options: unknown,
) => ({ status: 0, error: undefined }))
expect(probeLinuxScope({ spawnSync: spawnSync as never })).toBe(true)
expect(probeLinuxManager({ spawnSync: spawnSync as never })).toBe(true)
const scopeOptions = spawnSync.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv }
const managerOptions = spawnSync.mock.calls[1]?.[2] as { env: NodeJS.ProcessEnv }
expect(scopeOptions.env).toMatchObject({ LC_ALL: 'C', SYSTEMD_LOG_TARGET: 'null' })
expect(managerOptions.env).toMatchObject({ LC_ALL: 'C' })
expect(managerOptions.env).not.toHaveProperty('SYSTEMD_LOG_TARGET')
expect(probeLinuxManager({
spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as never,
})).toBe(false)
expect(probeLinuxManager({
spawnSync: vi.fn(() => ({ status: null, error: new Error('missing') })) as never,
})).toBe(false)
})
})
@@ -594,7 +594,6 @@ describe('LocalSubprocessRuntime', () => {
cleanup: vi.fn(),
}))
const probeLinuxNative = vi.fn(() => true)
const probeLinuxManager = vi.fn(() => true)
const inspector = {
foregroundPgid: () => undefined,
isStdinWaiting: () => false,
@@ -615,7 +614,6 @@ describe('LocalSubprocessRuntime', () => {
signalLinuxDirectProcess,
launchLinuxScope: vi.fn(),
prepareLinuxTerminalScope,
probeLinuxManager,
probeLinuxNative,
}))
let fiber: { dispose(): Promise<void> } | undefined
@@ -716,7 +714,6 @@ describe('LocalSubprocessRuntime', () => {
signalLinuxDirectProcess,
launchLinuxScope: vi.fn(),
prepareLinuxTerminalScope,
probeLinuxManager: () => true,
probeLinuxNative: () => true,
}))
let fiber: { dispose(): Promise<void> } | undefined
@@ -882,13 +879,12 @@ describe('LocalSubprocessRuntime', () => {
}
})
it('rechecks native prerequisites for every eligible spawn and prepares storage before launch', async () => {
it('probes Linux native support once, rechecks the Windows Job per spawn, and prepares storage before launch', async () => {
const linuxLaunch = { kind: 'linux' }
const windowsLaunch = { kind: 'windows' }
const launchLinuxScope = vi.fn(() => linuxLaunch)
const launchWindowsJob = vi.fn(() => windowsLaunch)
const probeLinuxNative = vi.fn(() => true)
const probeLinuxManager = vi.fn(() => true)
const probeWindowsJob = vi.fn(() => true)
const prepareManagedProcessBinding = vi.fn(() => ({ spillDir: '/tmp/dsh-test-spill' }))
const handles = [true, false, false].map((failFirstWait) => {
@@ -918,7 +914,6 @@ describe('LocalSubprocessRuntime', () => {
signalLinuxDirectProcess,
launchLinuxScope,
prepareLinuxTerminalScope: vi.fn(),
probeLinuxManager,
probeLinuxNative,
}))
vi.doMock('../src/windows-job.ts', () => ({ launchWindowsJob, probeWindowsJob }))
@@ -947,7 +942,6 @@ describe('LocalSubprocessRuntime', () => {
await linuxRuntime.spawn(spec('true')).done
await new Promise(resolve => setImmediate(resolve))
expect(probeLinuxNative).toHaveBeenCalledOnce()
expect(probeLinuxManager).toHaveBeenCalledTimes(2)
expect(launchLinuxScope).toHaveBeenCalledTimes(2)
const windowsContext = new Context()
@@ -977,15 +971,8 @@ describe('LocalSubprocessRuntime', () => {
}
})
it('retries failed Linux deep probes, caches the first success, and rechecks the manager', async () => {
const probeLinuxNative = vi.fn()
.mockReturnValueOnce(false)
.mockReturnValueOnce(false)
.mockReturnValueOnce(false)
.mockReturnValueOnce(true)
const probeLinuxManager = vi.fn()
.mockReturnValueOnce(false)
.mockReturnValueOnce(true)
it('keeps a failed Linux native probe for the provider lifetime and rechecks the Windows Job', async () => {
const probeLinuxNative = vi.fn(() => false)
const probeWindowsJob = vi.fn()
.mockReturnValueOnce(false)
.mockReturnValueOnce(true)
@@ -997,7 +984,6 @@ describe('LocalSubprocessRuntime', () => {
signalLinuxDirectProcess,
launchLinuxScope: vi.fn(),
prepareLinuxTerminalScope: vi.fn(),
probeLinuxManager,
probeLinuxNative,
}))
vi.doMock('../src/windows-job.ts', () => ({ launchWindowsJob: vi.fn(), probeWindowsJob }))
@@ -1015,13 +1001,9 @@ describe('LocalSubprocessRuntime', () => {
}).selectContainmentMode.bind(linuxRuntime)
expect(linuxSelect('ordinary')).toBe('fallback')
expect(linuxSelect('terminal')).toBe('fallback')
expect(linuxSelect('ordinary')).toBe('fallback')
expect(linuxSelect('ordinary')).toBe('fallback')
expect(linuxSelect('ordinary')).toBe('linux-scope')
expect(linuxSelect('ordinary')).toBe('fallback')
expect(linuxSelect('ordinary')).toBe('linux-scope')
expect(probeLinuxNative).toHaveBeenCalledTimes(4)
expect(probeLinuxManager).toHaveBeenCalledTimes(2)
expect(probeLinuxNative).toHaveBeenCalledOnce()
const windowsContext = new Context()
vi.spyOn(windowsContext.logger, 'warn').mockImplementation(() => {})