refactor(runtime): collapse speculative portability layers

Remove the one-consumer bounded-read primitive and shared terminal lifecycle controller, make terminal cleanup one awaited provider operation, and reuse one Code Runtime contract suite. Keep only reproduced cancellation and policy fixes; defer unproven replacement, prompt-attribution, and streaming-frame concerns to scoped markers.
This commit is contained in:
Tianyi Cui
2026-07-29 18:26:21 +08:00
parent 4fecc54998
commit c1d550de58
65 changed files with 1265 additions and 948 deletions

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: d03824da33bb44b2525b1343a27557ed15823418
README.zh.md: 34cc75c3cfc8148754343e92b0c204760b1fb543
README.md: 2fca5cdd08f3cbd36d8ef492226c91681b5568d0
README.zh.md: 5830d0fc394160bc557e24646cc76d4bcfead13c

View File

@@ -11,8 +11,7 @@ The subprocess seam (`ctx.subprocess`) is the process half of one execution worl
- 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.
- 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).
- `spawnTerminal(spec)` is the only non-pipe primitive. Its handle owns a real PTY, valid-UTF-8 byte I/O, foreground-process-group inspection/signalling, TERM-to-KILL whole-session cleanup, and a quiescence wait. The output stream ends after queued output when the top-level process exits; a live transport failure rejects `done`. These operations remain one substrate primitive because ordinary pipes cannot allocate a controlling terminal or prove and clean the complete terminal session; readiness, scrollback, and owner policy remain in the PTY consumer.
- `SubprocessTerminalLifecycle` composes a handle's top-level `done` promise with its provider-owned session cleanup. It binds lifetime cancellation, shares one active cleanup attempt, permits a failed attempt to retry, normalizes cleanup rejections, and bounds quiescence observation without knowing the provider's process mechanics.
- `spawnTerminal(spec)` is the only non-pipe primitive. Its handle owns a real PTY, UTF-8 text I/O, foreground-process-group inspection/signalling, and one awaited `terminate()` operation that reaches whole-session quiescence and settles in-flight handle calls. The spec signal cancels allocation only; the published handle owns its lifetime. The output stream ends after queued output when the top-level process exits, and a live transport failure rejects `done`. These operations remain one substrate primitive because ordinary pipes cannot allocate a controlling terminal or clean the complete terminal session; readiness, scrollback, and owner policy remain in the PTY consumer.
- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and explicit `env` merges after the scrub. The local ordinary and terminal spawns both apply it; SDK-managed transports that own their spawn may import it directly.
- Disposal of the service terminates all still-running managed processes and awaits their exit.

View File

@@ -11,8 +11,7 @@
- spec 完全显式argv、cwd、按流划分的 stdio 处置方式disposition、宽限期因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`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、拆卸阶梯与原因分类归调用方所有
- `spawnTerminal(spec)` 是唯一的非管道原语。其句柄负责真实 PTY、有效 UTF-8 字节 I/O、前台进程组检查信号发送、TERM→KILL 全会话清理,以及等待完全停稳。顶层进程退出后,输出流会在排完队列中的输出后结束;存活期间的传输故障会拒绝 `done`。这些操作仍属于一项基底原语,因为普通管道无法分配控制终端,也无法证明并清理完整的终端会话就绪检测、scrollback 与所有者策略仍归 PTY 消费方所有。
- `SubprocessTerminalLifecycle` 把句柄的顶层 `done` promise 与由提供方负责的会话清理组合起来。它绑定生命周期取消,共享同一个进行中的清理尝试,允许失败的尝试重试,规范化清理拒绝,并在不了解提供方进程机制的情况下对完全停稳观测施加上限。
- `spawnTerminal(spec)` 是唯一的非管道原语。其句柄负责真实 PTY、UTF-8 文本 I/O、前台进程组检查信号发送,以及一项须等待的 `terminate()` 操作该操作会使整个会话完全停稳并让所有在途句柄调用结算。spec 信号只取消分配;句柄一经发布,便负责自身生命周期。顶层进程退出后,输出流会在排完队列中的输出后结束;存活期间的传输故障会拒绝 `done`。这些操作仍属于一项基底原语因为普通管道无法分配控制终端也无法清理完整的终端会话就绪检测、scrollback 与所有者策略仍归 PTY 消费方所有。
- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并。本地普通 spawn 与终端 spawn 都应用这一定义;自行拥有 spawn 的 SDK 管理传输层可以直接导入它。
- 服务自身的 dispose资源释放会终止所有仍在运行的受管进程并等待其退出。

View File

@@ -14,8 +14,6 @@ import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts'
import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts'
export { DSH_ENV_PREFIX } from './types.ts'
export { SubprocessTerminalLifecycle } from './terminal-lifecycle.ts'
export type { SubprocessTerminalLifecycleOptions } from './terminal-lifecycle.ts'
export type {
CollectedOutput,
DshEnvironment,
@@ -95,10 +93,11 @@ declare module 'cordis' {
* quiescence.
* - Disposal of the service terminates all still-running managed processes
* and awaits their exit.
* - {@link spawnTerminal} owns terminal allocation, byte transport,
* foreground groups, signalling, and whole-session quiescence; readiness
* and persistent-shell policy stay in the PTY consumer. Its output stream
* ends after queued terminal output when the top-level process exits.
* - {@link spawnTerminal} owns terminal allocation, text transport,
* foreground groups, signalling, and whole-session quiescence behind one
* awaited termination method; readiness and persistent-shell policy stay
* in the PTY consumer. Its output stream ends after queued terminal output
* when the top-level process exits.
*/
export abstract class SubprocessService extends Service {
constructor(ctx: Context) {
@@ -138,7 +137,7 @@ export abstract class SubprocessService extends Service {
* Allocate a real terminal and start one owned process session. This is the
* only non-pipe process primitive: implementations own terminal byte I/O,
* foreground groups, signals, and complete session-tree cleanup.
* @param spec - fully specified argv, cwd, environment, dimensions, grace, and cancellation.
* @param spec - fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.
* @returns the live terminal handle after allocation succeeds.
*/
abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle>

View File

@@ -1,104 +0,0 @@
/** Provider-neutral lifecycle transaction for terminal-process handles. */
/** Inputs owned by one terminal-process lifecycle controller. */
export interface SubprocessTerminalLifecycleOptions {
/** Settlement of the top-level terminal process or its live transport. */
readonly done: Promise<unknown>
/** Provider-owned cleanup that reaches whole-session quiescence. */
readonly cleanup: () => Promise<void>
/** Optional cancellation for the complete terminal lifetime. */
readonly signal?: AbortSignal | undefined
}
function normalizeCleanupError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
/**
* Coordinates terminal cleanup without knowing how a provider allocates or
* terminates its process session. One active cleanup attempt is shared by all
* callers; a rejected attempt may be retried, and successful cleanup removes
* the lifetime abort listener.
*/
export class SubprocessTerminalLifecycle {
private cleanupAttempt: Promise<void> | undefined
private removeLifetimeAbort: (() => void) | undefined
/**
* @param options - top-level settlement, provider cleanup, and lifetime cancellation.
*/
constructor(private readonly options: SubprocessTerminalLifecycleOptions) {
const onDone = (): void => { this.terminate() }
void options.done.then(onDone, onDone)
if (options.signal !== undefined) {
const onAbort = (): void => { this.terminate() }
options.signal.addEventListener('abort', onAbort, { once: true })
this.removeLifetimeAbort = () => { options.signal?.removeEventListener('abort', onAbort) }
if (options.signal.aborted) this.terminate()
}
}
/** Begin an idempotent provider cleanup attempt. */
terminate(): void {
void this.startCleanup().catch(() => {})
}
/**
* Wait for top-level settlement and successful whole-session cleanup.
* @param signal - optional bound for this observation only.
* @returns true after quiescence, false when the observer signal aborts first.
*/
async waitForExit(signal?: AbortSignal): Promise<boolean> {
const quiescence = this.cleanupAttempt ?? this.options.done.then(
() => this.startCleanup(),
() => this.startCleanup(),
)
if (signal === undefined) {
await quiescence
return true
}
if (signal.aborted) return false
return await new Promise<boolean>((resolve, reject) => {
let settled = false
const finish = (complete: () => void): void => {
if (settled) return
settled = true
signal.removeEventListener('abort', onAbort)
complete()
}
const onAbort = (): void => { finish(() => { resolve(false) }) }
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) onAbort()
void quiescence.then(
() => { finish(() => { resolve(true) }) },
(error: unknown) => { finish(() => { reject(normalizeCleanupError(error)) }) },
)
})
}
private startCleanup(): Promise<void> {
if (this.cleanupAttempt !== undefined) return this.cleanupAttempt
const outcome = Promise.withResolvers<void>()
this.cleanupAttempt = outcome.promise.catch((error: unknown) => {
this.cleanupAttempt = undefined
throw normalizeCleanupError(error)
})
void this.cleanupAttempt.then(
() => {
this.removeLifetimeAbort?.()
this.removeLifetimeAbort = undefined
},
() => {},
)
try {
void this.options.cleanup().then(outcome.resolve, outcome.reject)
} catch (error: unknown) {
outcome.reject(error)
}
return this.cleanupAttempt
}
}

View File

@@ -210,7 +210,7 @@ export interface SubprocessTerminalSpawnSpec {
cols: number
/** TERM-to-KILL cleanup grace for the complete terminal session. */
graceMs: number
/** Cancellation of setup or the live terminal session. */
/** Cancellation of terminal allocation; a published handle owns its later lifetime. */
signal?: AbortSignal | undefined
}
@@ -236,10 +236,10 @@ export interface SubprocessTerminalHandle {
/** Resolves when the top-level process exits; rejects only for a live transport failure. */
readonly done: Promise<SubprocessOutcome>
/**
* Write bytes to the terminal input.
* @param data - valid UTF-8 bytes to deliver without implicit newline conversion.
* Write text to the terminal input.
* @param data - text to deliver without implicit newline conversion.
*/
write(data: Uint8Array): Promise<void>
write(data: string): Promise<void>
/**
* Inspect the current foreground process group.
* @returns its id and input-wait fact, or undefined when no foreground group can be resolved.
@@ -251,12 +251,9 @@ export interface SubprocessTerminalHandle {
* @returns the exact group id that received it.
*/
signalForeground(signal: SubprocessTerminalSignal): Promise<number>
/** Begin idempotent TERM-to-KILL cleanup of the complete terminal session. */
terminate(): void
/**
* Await whole-session quiescence, not only top-level process exit.
* @param signal - optional bound for this wait.
* @returns true after quiescence, false when `signal` aborts first.
* Idempotently terminate the complete terminal session and await whole-session quiescence.
* After settlement, no write, inspection, or signal call remains in flight.
*/
waitForExit(signal?: AbortSignal): Promise<boolean>
terminate(): Promise<void>
}

View File

@@ -48,8 +48,7 @@ class StubSubprocessService extends SubprocessService {
write: async () => {},
inspectForeground: async () => ({ processGroupId: 1, inputWaiting: true }),
signalForeground: async () => 1,
terminate: () => {},
waitForExit: async () => true,
terminate: async () => {},
}
}
}

View File

@@ -1,125 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { SubprocessTerminalLifecycle } from '@deepseek-ai/dsh-subprocess'
describe('SubprocessTerminalLifecycle', () => {
it('waits for top-level settlement and the provider cleanup transaction', async () => {
const done = Promise.withResolvers<undefined>()
const cleanupGate = Promise.withResolvers<undefined>()
const cleanup = vi.fn(() => cleanupGate.promise)
const lifecycle = new SubprocessTerminalLifecycle({ done: done.promise, cleanup })
const waiting = lifecycle.waitForExit()
expect(cleanup).not.toHaveBeenCalled()
done.resolve(undefined)
await vi.waitFor(() => { expect(cleanup).toHaveBeenCalledOnce() })
const observed = vi.fn()
void waiting.then(observed)
await Promise.resolve()
expect(observed).not.toHaveBeenCalled()
cleanupGate.resolve(undefined)
await expect(waiting).resolves.toBe(true)
lifecycle.terminate()
await expect(lifecycle.waitForExit()).resolves.toBe(true)
expect(cleanup).toHaveBeenCalledOnce()
})
it('normalizes cleanup failures, permits retries, and retains lifetime cancellation until success', async () => {
const done = Promise.withResolvers<undefined>()
const lifetime = new AbortController()
const removeListener = vi.spyOn(lifetime.signal, 'removeEventListener')
const synchronousFailure = new Error('first cleanup failed')
let attempt = 0
const cleanup = vi.fn((): Promise<void> => {
attempt += 1
if (attempt === 1) throw synchronousFailure
if (attempt === 2) {
return Promise.resolve().then(() => {
const nonErrorRejection: unknown = 'cleanup transport gone'
throw nonErrorRejection
})
}
return Promise.resolve()
})
const lifecycle = new SubprocessTerminalLifecycle({
done: done.promise,
cleanup,
signal: lifetime.signal,
})
lifecycle.terminate()
await expect(lifecycle.waitForExit()).rejects.toBe(synchronousFailure)
lifecycle.terminate()
await expect(lifecycle.waitForExit()).rejects.toThrow('cleanup transport gone')
lifetime.abort()
await expect(lifecycle.waitForExit()).resolves.toBe(true)
expect(cleanup).toHaveBeenCalledTimes(3)
expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function))
done.reject(new Error('top-level transport failed'))
await Promise.resolve()
expect(cleanup).toHaveBeenCalledTimes(3)
})
it('starts cleanup for a pre-aborted lifetime and bounds a wait that is already aborted', async () => {
const cleanupGate = Promise.withResolvers<undefined>()
const cleanup = vi.fn(() => cleanupGate.promise)
const lifecycle = new SubprocessTerminalLifecycle({
done: new Promise(() => {}),
cleanup,
signal: AbortSignal.abort(new Error('lifetime cancelled')),
})
expect(cleanup).toHaveBeenCalledOnce()
await expect(lifecycle.waitForExit(AbortSignal.abort())).resolves.toBe(false)
cleanupGate.resolve(undefined)
await expect(lifecycle.waitForExit()).resolves.toBe(true)
})
it('contains cleanup settlement after an observer aborts between signal checks', async () => {
const firstCleanup = Promise.withResolvers<undefined>()
const cleanup = vi.fn()
.mockImplementationOnce(() => firstCleanup.promise)
.mockResolvedValueOnce(undefined)
const lifecycle = new SubprocessTerminalLifecycle({ done: Promise.resolve(), cleanup })
const observer = new AbortController().signal
vi.spyOn(observer, 'aborted', 'get')
.mockReturnValueOnce(false)
.mockReturnValueOnce(true)
await expect(lifecycle.waitForExit(observer)).resolves.toBe(false)
firstCleanup.reject(new Error('late cleanup failure'))
await vi.waitFor(() => { expect(cleanup).toHaveBeenCalledOnce() })
await Promise.resolve()
lifecycle.terminate()
await expect(lifecycle.waitForExit()).resolves.toBe(true)
expect(cleanup).toHaveBeenCalledTimes(2)
})
it('reports bounded cleanup success and failure', async () => {
const successful = new SubprocessTerminalLifecycle({
done: Promise.resolve(),
cleanup: async () => {},
})
await expect(successful.waitForExit(new AbortController().signal)).resolves.toBe(true)
const failure = new Error('quiescence failed')
const failed = new SubprocessTerminalLifecycle({
done: Promise.resolve(),
cleanup: () => Promise.reject(failure),
})
await expect(failed.waitForExit(new AbortController().signal)).rejects.toBe(failure)
const failedDone = Promise.withResolvers<undefined>()
const afterTransportFailure = new SubprocessTerminalLifecycle({
done: failedDone.promise,
cleanup: async () => {},
})
const waiting = afterTransportFailure.waitForExit()
failedDone.reject(new Error('transport failed'))
await expect(waiting).resolves.toBe(true)
})
})