Fix Codex wire frames and bound grace timers

This commit is contained in:
pku-xht
2026-08-05 04:21:29 +08:00
parent c4650c0eab
commit a49ef7581f
25 changed files with 100 additions and 136 deletions

View File

@@ -29,11 +29,13 @@
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -15,6 +15,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { setTimeout as sleepMs } from 'node:timers/promises'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type {
CollectedOutput,
SubprocessCollect,
@@ -55,47 +56,6 @@ function sleepTick(): Promise<void> {
return sleepMs(15)
}
/** Largest delay Node schedules without collapsing it to one millisecond. */
const MAX_TIMER_DELAY_MS = 2_147_483_647n
/**
* Schedule a positive finite millisecond delay across as many Node-safe timer
* segments as necessary. Fractional milliseconds round up so a grace never
* expires earlier than configured.
* @param delayMs - positive finite delay in milliseconds.
* @param callback - work to run after the complete delay.
* @returns a handle that cancels the active segment and all future segments.
*/
export function scheduleFiniteTimeout(
delayMs: number,
callback: () => void,
): { cancel(): void } {
let remaining = BigInt(Math.ceil(delayMs))
let timer: ReturnType<typeof setTimeout> | undefined
const arm = (): void => {
const chunk = remaining > MAX_TIMER_DELAY_MS
? MAX_TIMER_DELAY_MS
: remaining
remaining -= chunk
timer = setTimeout(() => {
timer = undefined
if (remaining === 0n) {
callback()
} else {
arm()
}
}, Number(chunk))
}
arm()
return {
cancel(): void {
if (timer === undefined) return
clearTimeout(timer)
timer = undefined
},
}
}
let spillCounter = 0
let defaultSpillDir: string | undefined
@@ -339,8 +299,12 @@ function signalTree(
* @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment.
* @param internals - test-only spill-directory, platform, and taskkill overrides.
* @returns live subprocess handle.
* @throws when `graceMs` cannot be represented by one Node timer.
*/
export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle {
if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0 || spec.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
}
const spillDir = internals.spillDir ?? privateSpillDir()
const platform = internals.platform ?? process.platform
const taskkill = internals.taskkill ?? taskkillProcessTree
@@ -382,7 +346,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
const stdoutCollector = collectStream(outMode, child.stdout, 'stdout')
const stderrCollector = collectStream(errMode, child.stderr, 'stderr')
let graceTimer: ReturnType<typeof scheduleFiniteTimeout> | undefined
let graceTimer: ReturnType<typeof setTimeout> | undefined
let treeExitObserved = false
let treeExitObservation: Promise<void> | undefined
let settled = false
@@ -426,7 +390,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
treeExitObservation ??= (async () => {
while (treeAlive()) await sleepTick()
treeExitObserved = true
graceTimer?.cancel()
if (graceTimer !== undefined) clearTimeout(graceTimer)
graceTimer = undefined
})()
return treeExitObservation
@@ -457,7 +421,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
// kill() re-probes tree liveness before force-killing. It stays ref'd:
// the pending SIGKILL is a commitment, and a parent exiting before it
// fires would orphan a trapped survivor. Self-bounds at graceMs.
graceTimer = scheduleFiniteTimeout(spec.graceMs, () => { kill('SIGKILL') })
graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs)
}
// The caller owns timeout classification; this layer only reacts to abort.
@@ -472,7 +436,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
}
const done = new Promise<SubprocessOutcome>((resolve, reject) => {
let pipeDrainTimer: ReturnType<typeof scheduleFiniteTimeout> | undefined
let pipeDrainTimer: ReturnType<typeof setTimeout> | undefined
const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
if (settled) return
settled = true
@@ -495,15 +459,15 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
// A surviving descendant that inherited a pipe must not hold the
// outcome open indefinitely: after exit, the same bounded grace that
// governs kills also bounds the close wait.
pipeDrainTimer = scheduleFiniteTimeout(spec.graceMs, () => {
pipeDrainTimer = setTimeout(() => {
settle(exitCode, signal)
})
}, spec.graceMs)
})
child.on('close', settle)
function cleanup(): void {
// graceTimer deliberately NOT cleared: the SIGKILL escalation must be
// able to reach tree survivors after the direct child settles.
pipeDrainTimer?.cancel()
if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer)
spec.signal?.removeEventListener('abort', onAbort)
}
})

View File

@@ -5,11 +5,11 @@ import { describe, expect, it, vi } from 'vitest'
import {
killGroup,
OutputCollector,
scheduleFiniteTimeout,
spawnSubprocess,
taskkillProcessTree,
} from '../src/spawn.ts'
import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
failNextClose: { value: false },
@@ -107,31 +107,15 @@ async function waitForPidFile(path: string, timeoutMs = 5_000): Promise<number>
throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`)
}
describe('scheduleFiniteTimeout', () => {
it('rounds fractions up, chains Node-safe segments, and cancels idempotently', async () => {
vi.useFakeTimers()
try {
const fired = vi.fn()
const chained = scheduleFiniteTimeout(2_147_483_647.25, fired)
await vi.advanceTimersByTimeAsync(2_147_483_647)
expect(fired).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(fired).toHaveBeenCalledOnce()
chained.cancel()
const cancelled = vi.fn()
const timer = scheduleFiniteTimeout(0.25, cancelled)
timer.cancel()
timer.cancel()
await vi.advanceTimersByTimeAsync(1)
expect(cancelled).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
})
describe('spawnSubprocess', () => {
it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, MAX_TIMER_DELAY_MS + 1])(
'rejects an invalid grace before spawning: %s',
(graceMs) => {
expect(() => spawnSubprocess(spec('true', { graceMs })))
.toThrow(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
},
)
it('captures stdout on success', async () => {
const result = await finish(spawnSubprocess(spec('echo hello')))
expect(result.exitCode).toBe(0)
@@ -194,17 +178,6 @@ describe('spawnSubprocess', () => {
expect(result.signal).toBe('SIGKILL')
})
it('cancels a larger-than-Node escalation timer once SIGTERM removes the tree', async () => {
const running = spawnSubprocess(spec('echo ready; sleep 60', {
graceMs: Number.MAX_VALUE,
}))
await waitForStdout(running, 'ready\n')
running.terminate()
const result = await running.done
expect(result.signal).toBe('SIGTERM')
await expect(running.waitForExit()).resolves.toBe(true)
})
it('cancels escalation when the terminated group vanishes before collected pipes drain', async () => {
const pidFile = join(spillDir, `escaped-pipe-holder-${Date.now()}.pid`)
const graceMs = 160

View File

@@ -17,6 +17,9 @@
{
"path": "../subprocess"
},
{
"path": "../../util/timeout"
},
{
"path": "../../support/invariants"
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subprocess/subprocess/README.md
README.md: c360437bf2b2b95734f55f6aec46b0cecffb9260
README.zh.md: dac459a6ed1b92c2354bf0a2cc4e0c23e824154f
README.md: a8061e67a5346fce785f3c9fb27c8f885bf99921
README.zh.md: 0e6a7e9192eb7028ea951681f5e013f8012a4121

View File

@@ -7,7 +7,7 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes
## Contract
- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures.
- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself.
- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). Grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so the implementation can represent it with one Node timer instead of accepting a value that Node collapses to one millisecond. `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself.
- Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement.
- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence — the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification).
- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and the spec's explicit `env` merges after the scrub with no namespace validation — a deliberately forwarded credential or a current `DSH_*` fact survives precisely because it is an explicit caller opt-in, while the stale ambient namesake never reaches the child. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub.

View File

@@ -7,7 +7,7 @@
## 契约
- `spawn(spec)` 立即返回一个活动句柄;`done` 在进程关闭时以退出事实 resolve`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。
- spec 完全显式argv、cwd、按流划分的 stdio 处置方式disposition、宽限期因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的子进程默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`
- spec 完全显式argv、cwd、按流划分的 stdio 处置方式disposition、宽限期因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的子进程默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样实现便可用一个 Node 定时器表示它,而不会接受会被 Node 折叠为 1 毫秒的值。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`
- stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧LSP 的 JSON-RPC、ACPAgent Client Protocol的 ndjson`'inherit'` 直通父进程描述符以承载诊断输出收集模式collect`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。
- 终止在每个平台上都以进程树为范围POSIX 用 detached 进程组并以直接子进程回退Windows 用 `taskkill /T``terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit(signal?)` 观察整棵进程树的存活状态使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。管理器只响应中止但绝不判定原因deadline、拆卸阶梯与原因分类归调用方所有
- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃spec 的显式 `env` 在清除之后合并且不做命名空间校验——有意转发的凭据或当前 `DSH_*` 事实之所以能保留下来,正因为它是调用方的显式选择,而陈旧的同名环境值永远到不了子进程。无法把 spawn 路由到该服务的进程启动方node-pty 后端、由 SDK 管理的传输层)改为导入环境清理函数。

View File

@@ -80,10 +80,11 @@ export interface SubprocessSpawnSpec {
/** Per-stream stdio dispositions. */
stdio: SubprocessStdio
/**
* Grace period in milliseconds for the {@link SubprocessHandle.terminate}
* escalation and for draining still-open collected pipes after the process
* exits (an inherited descriptor held by a surviving descendant cannot hold
* the outcome open indefinitely).
* Positive finite grace period in milliseconds, no greater than
* `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation
* and for draining still-open collected pipes after the process exits (an
* inherited descriptor held by a surviving descendant cannot hold the
* outcome open indefinitely).
*/
graceMs: number
/**