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:
@@ -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-local/README.md
|
||||
README.md: 9f30fa6dc676e7b82f87b75b78b7d3143f204c94
|
||||
README.zh.md: 0b6813ec7d754f42e8bf4c65a1dee33d77bf3787
|
||||
README.md: 31c5539750c4af2b1c4169ac1dce4c91da587af7
|
||||
README.zh.md: 74aed52084c0db3ed9bfe4fcb991f781976fb7b5
|
||||
@@ -11,7 +11,7 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README
|
||||
- **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement.
|
||||
- **Execution-world coordinates** — `cwd` is the host process cwd, `runtimeRoot` is an owner-private temporary directory removed on disposal before any process-cleanup failure is reported, and `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions.
|
||||
- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal bytes, inspects and signals the current foreground process group, and sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. The higher PTY backend owns prompt readiness, buffers, and model-facing operations.
|
||||
- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. The higher PTY backend owns prompt readiness, buffers, and model-facing operations.
|
||||
- **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
- **基于偏移量的读取**:收集模式的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。
|
||||
- **执行世界坐标**:`cwd` 是宿主进程 cwd,`runtimeRoot` 是所有者私有的临时目录,会在资源释放时删除,并且删除发生在报告任何进程清理失败之前;`resolveExecutable` 检查绝对文件,或使用平台感知的可执行扩展名在清理后的有效 PATH 中查找。
|
||||
- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端字节,检查当前前台进程组并向其发送信号,并在终止顶层 shell 前后清理后代。每次前台检查都会保留有根进程树中的精确身份;Linux 还会在会话 leader 退出后枚举该 POSIX 会话。因此,先前观察到的 macOS 后代以及任何同会话 Linux 成员在重新设定父进程后仍受身份围栏保护,而 pid/启动身份可防止清理因 PID 复用而跟随到其他进程。上层 PTY 后端负责提示符就绪检测、缓冲和面向模型的操作。
|
||||
- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,并公开一项须等待的终止操作,该操作会在终止顶层 shell 前后清理后代。每次前台检查都会保留有根进程树中的精确身份;Linux 还会在会话 leader 退出后枚举该 POSIX 会话。因此,先前观察到的 macOS 后代以及任何同会话 Linux 成员在重新设定父进程后仍受身份围栏保护,而 pid/启动身份可防止清理因 PID 复用而跟随到其他进程。上层 PTY 后端负责提示符就绪检测、缓冲和面向模型的操作。
|
||||
- **先终止再等待退出的 dispose**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -59,12 +59,7 @@ export class LocalSubprocessService extends SubprocessService {
|
||||
pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit()))
|
||||
}
|
||||
for (const terminal of this.terminals) {
|
||||
terminal.terminate()
|
||||
// Cleanup may reject before the top-level process exits (for example,
|
||||
// an identity-fenced descendant survives escalation). Await the cleanup
|
||||
// transaction directly so disposal reports that failure rather than
|
||||
// waiting forever on `done`.
|
||||
pending.push(terminal.waitForExit().then(() => { this.terminals.delete(terminal) }))
|
||||
pending.push(terminal.terminate().then(() => { this.terminals.delete(terminal) }))
|
||||
}
|
||||
this.live.clear()
|
||||
const outcomes = [
|
||||
@@ -136,11 +131,6 @@ export class LocalSubprocessService extends SubprocessService {
|
||||
if (file === undefined || file.length === 0) {
|
||||
throw new Error('subprocess-local: terminal argv must contain a program')
|
||||
}
|
||||
for (const [name, value] of [['rows', spec.rows], ['cols', spec.cols], ['graceMs', spec.graceMs]] as const) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`subprocess-local: terminal ${name} must be a positive safe integer`)
|
||||
}
|
||||
}
|
||||
spec.signal?.throwIfAborted()
|
||||
const options: IPtyForkOptions = {
|
||||
name: 'dumb',
|
||||
@@ -151,10 +141,10 @@ export class LocalSubprocessService extends SubprocessService {
|
||||
}
|
||||
const inspector = this.terminalInspector ?? createProcessInspector()
|
||||
const terminal = nodePty.spawn(file, [...spec.argv.slice(1)], options)
|
||||
const handle = new LocalTerminalHandle(terminal, inspector, spec.graceMs, spec.signal)
|
||||
const handle = new LocalTerminalHandle(terminal, inspector, spec.graceMs)
|
||||
this.terminals.add(handle)
|
||||
const release = async (): Promise<void> => {
|
||||
await handle.waitForExit()
|
||||
await handle.terminate()
|
||||
this.terminals.delete(handle)
|
||||
}
|
||||
void handle.done.then(release, release).catch(() => {})
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Buffer } from 'node:buffer'
|
||||
import { constants } from 'node:os'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import type { IDisposable, IPty } from 'node-pty'
|
||||
import { SubprocessTerminalLifecycle } from '@deepseek-ai/dsh-subprocess'
|
||||
import type {
|
||||
SubprocessOutcome,
|
||||
SubprocessTerminalForeground,
|
||||
@@ -34,7 +33,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
private readonly outcome = Promise.withResolvers<SubprocessOutcome>()
|
||||
private readonly dataDisposable: IDisposable
|
||||
private readonly exitDisposable: IDisposable
|
||||
private readonly lifecycle: SubprocessTerminalLifecycle
|
||||
private cleanup: Promise<void> | undefined
|
||||
private exited = false
|
||||
private trackedDescendants: ProcessIdentity[] = []
|
||||
|
||||
@@ -42,13 +41,11 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
* @param terminal - allocated node-pty process.
|
||||
* @param inspector - platform process/session operations.
|
||||
* @param graceMs - TERM-to-KILL and exit-wait grace.
|
||||
* @param signal - optional lifetime cancellation.
|
||||
*/
|
||||
constructor(
|
||||
private readonly terminal: IPty,
|
||||
private readonly inspector: ProcessInspector,
|
||||
private readonly graceMs: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
this.pid = terminal.pid
|
||||
this.done = this.outcome.promise
|
||||
@@ -61,26 +58,15 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
exitCode: exitSignal === undefined || exitSignal === 0 ? exitCode : null,
|
||||
signal: signalName(exitSignal),
|
||||
})
|
||||
this.terminate()
|
||||
})
|
||||
this.lifecycle = new SubprocessTerminalLifecycle({
|
||||
done: this.done,
|
||||
cleanup: () => this.closeOnce(),
|
||||
signal,
|
||||
void this.terminate().catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
// node-pty writes synchronously; the seam returns a promise for remote transports.
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async write(data: Uint8Array): Promise<void> {
|
||||
async write(data: string): Promise<void> {
|
||||
if (this.exited) throw new Error('terminal process has exited')
|
||||
let text: string
|
||||
try {
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(data)
|
||||
} catch (error: unknown) {
|
||||
throw new Error('terminal input must be valid UTF-8', { cause: error })
|
||||
}
|
||||
this.terminal.write(text)
|
||||
this.terminal.write(data)
|
||||
}
|
||||
|
||||
// Local inspection is synchronous; the seam returns a promise for remote transports.
|
||||
@@ -107,12 +93,12 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
return foreground.processGroupId
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.lifecycle.terminate()
|
||||
}
|
||||
|
||||
async waitForExit(signal?: AbortSignal): Promise<boolean> {
|
||||
return await this.lifecycle.waitForExit(signal)
|
||||
terminate(): Promise<void> {
|
||||
if (this.cleanup !== undefined) return this.cleanup
|
||||
const cleanup = this.closeOnce()
|
||||
this.cleanup = cleanup
|
||||
void cleanup.catch(() => { this.cleanup = undefined })
|
||||
return cleanup
|
||||
}
|
||||
|
||||
private survivors(members: ProcessIdentity[]): ProcessIdentity[] {
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('LocalSubprocessService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('validates terminal spawn specs before allocating a PTY', async () => {
|
||||
it('validates terminal allocation inputs before allocating a PTY', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalSubprocessService)
|
||||
const base: SubprocessTerminalSpawnSpec = {
|
||||
@@ -89,9 +89,6 @@ describe('LocalSubprocessService', () => {
|
||||
}
|
||||
await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [] })).rejects.toThrow('must contain a program')
|
||||
await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [''] })).rejects.toThrow('must contain a program')
|
||||
await expect(ctx.subprocess.spawnTerminal({ ...base, rows: 1.5 })).rejects.toThrow('rows')
|
||||
await expect(ctx.subprocess.spawnTerminal({ ...base, cols: 0 })).rejects.toThrow('cols')
|
||||
await expect(ctx.subprocess.spawnTerminal({ ...base, graceMs: 0 })).rejects.toThrow('graceMs')
|
||||
await expect(ctx.subprocess.spawnTerminal({ ...base, signal: AbortSignal.abort('stop') })).rejects.toBe('stop')
|
||||
await fiber.dispose()
|
||||
})
|
||||
@@ -99,8 +96,7 @@ describe('LocalSubprocessService', () => {
|
||||
it('terminates and joins an owned terminal during disposal', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalSubprocessService)
|
||||
const terminate = vi.fn()
|
||||
const waitForExit = vi.fn(async () => true)
|
||||
const terminate = vi.fn(async () => {})
|
||||
const terminal: SubprocessTerminalHandle = {
|
||||
pid: 1,
|
||||
output: new PassThrough(),
|
||||
@@ -109,13 +105,11 @@ describe('LocalSubprocessService', () => {
|
||||
inspectForeground: async () => undefined,
|
||||
signalForeground: async () => 1,
|
||||
terminate,
|
||||
waitForExit,
|
||||
}
|
||||
const terminals = (ctx.subprocess as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals
|
||||
terminals.add(terminal)
|
||||
await fiber.dispose()
|
||||
expect(terminate).toHaveBeenCalledOnce()
|
||||
expect(waitForExit).toHaveBeenCalledOnce()
|
||||
expect(terminals.size).toBe(0)
|
||||
})
|
||||
|
||||
@@ -124,8 +118,8 @@ describe('LocalSubprocessService', () => {
|
||||
const fiber = await ctx.plugin(LocalSubprocessService)
|
||||
const service = ctx.subprocess
|
||||
const runtimeRoot = service.runtimeRoot
|
||||
const firstFailure = new Error('first retryable cleanup failure')
|
||||
const secondFailure = new Error('second retryable cleanup failure')
|
||||
const firstFailure = new Error('first cleanup failure')
|
||||
const secondFailure = new Error('second cleanup failure')
|
||||
const disposalErrors: unknown[] = []
|
||||
ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
|
||||
const failedTerminal: SubprocessTerminalHandle = {
|
||||
@@ -135,22 +129,19 @@ describe('LocalSubprocessService', () => {
|
||||
write: async () => {},
|
||||
inspectForeground: async () => undefined,
|
||||
signalForeground: async () => 1,
|
||||
terminate: vi.fn(),
|
||||
waitForExit: vi.fn(async () => { throw firstFailure }),
|
||||
terminate: vi.fn(async () => { throw firstFailure }),
|
||||
}
|
||||
const secondFailedTerminal: SubprocessTerminalHandle = {
|
||||
...failedTerminal,
|
||||
terminate: vi.fn(),
|
||||
waitForExit: vi.fn(async () => { throw secondFailure }),
|
||||
terminate: vi.fn(async () => { throw secondFailure }),
|
||||
}
|
||||
let finishCleanup!: () => void
|
||||
const cleanup = new Promise<boolean>((resolve) => {
|
||||
finishCleanup = () => { resolve(true) }
|
||||
const cleanup = new Promise<void>((resolve) => {
|
||||
finishCleanup = resolve
|
||||
})
|
||||
const drainingTerminal: SubprocessTerminalHandle = {
|
||||
...failedTerminal,
|
||||
terminate: vi.fn(),
|
||||
waitForExit: vi.fn(() => cleanup),
|
||||
terminate: vi.fn(() => cleanup),
|
||||
}
|
||||
const terminals = (service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals
|
||||
terminals.add(failedTerminal)
|
||||
@@ -187,8 +178,7 @@ describe('LocalSubprocessService', () => {
|
||||
write: async () => {},
|
||||
inspectForeground: async () => undefined,
|
||||
signalForeground: async () => 1,
|
||||
terminate: vi.fn(),
|
||||
waitForExit: vi.fn(async () => { throw failure }),
|
||||
terminate: vi.fn(async () => { throw failure }),
|
||||
}
|
||||
const terminals = (service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals
|
||||
terminals.add(terminal)
|
||||
@@ -247,7 +237,7 @@ describe('LocalSubprocessService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('contains a terminal release failure after top-level exit', async () => {
|
||||
it('retains a terminal whose automatic cleanup fails', async () => {
|
||||
let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
|
||||
const terminal = {
|
||||
pid: 123,
|
||||
@@ -264,6 +254,8 @@ describe('LocalSubprocessService', () => {
|
||||
try {
|
||||
const { default: IsolatedLocalSubprocessService } = await import('../src/index.ts')
|
||||
const ctx = new Context()
|
||||
const disposalErrors: unknown[] = []
|
||||
ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
|
||||
const fiber = await ctx.plugin(IsolatedLocalSubprocessService)
|
||||
const alive = new Set([124])
|
||||
;(ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessService>).terminalInspector = {
|
||||
@@ -281,10 +273,9 @@ describe('LocalSubprocessService', () => {
|
||||
exitListener?.({ exitCode: 0 })
|
||||
await handle.done
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
alive.clear()
|
||||
handle.terminate()
|
||||
await handle.waitForExit()
|
||||
expect((ctx.subprocess as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(1)
|
||||
await fiber.dispose()
|
||||
expect(disposalErrors).toHaveLength(1)
|
||||
} finally {
|
||||
vi.doUnmock('node-pty')
|
||||
vi.resetModules()
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('LocalTerminalHandle', () => {
|
||||
handle.output.on('data', (chunk: Buffer) => { chunks.push(chunk) })
|
||||
|
||||
pty.emitData('hello €')
|
||||
await handle.write(Buffer.from('input\r'))
|
||||
await handle.write('input\r')
|
||||
expect(pty.writes).toEqual(['input\r'])
|
||||
expect(await handle.inspectForeground()).toEqual({ processGroupId: 456, inputWaiting: true })
|
||||
expect(await handle.signalForeground('SIGINT')).toBe(456)
|
||||
@@ -98,16 +98,14 @@ describe('LocalTerminalHandle', () => {
|
||||
pty.emitExit(7, 9)
|
||||
pty.emitExit(0)
|
||||
expect(await handle.done).toEqual({ exitCode: null, signal: 'SIGKILL' })
|
||||
expect(await handle.waitForExit()).toBe(true)
|
||||
await handle.terminate()
|
||||
expect(Buffer.concat(chunks).toString('utf8')).toBe('hello €')
|
||||
})
|
||||
|
||||
it('rejects invalid input and unsafe foreground signals', async () => {
|
||||
it('rejects unsafe foreground signals and writes after exit', async () => {
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
await expect(handle.write(Uint8Array.from([0xff]))).rejects.toThrow('valid UTF-8')
|
||||
|
||||
inspector.pgid = handle.pid
|
||||
await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session')
|
||||
inspector.pgid = undefined
|
||||
@@ -116,8 +114,8 @@ describe('LocalTerminalHandle', () => {
|
||||
|
||||
pty.emitExit(3)
|
||||
expect(await handle.done).toEqual({ exitCode: 3, signal: null })
|
||||
await handle.waitForExit()
|
||||
await expect(handle.write(Buffer.from('late'))).rejects.toThrow('has exited')
|
||||
await handle.terminate()
|
||||
await expect(handle.write('late')).rejects.toThrow('has exited')
|
||||
})
|
||||
|
||||
it('keeps the shell alive until forced descendants leave', async () => {
|
||||
@@ -129,15 +127,14 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.removeOnSignal = false
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
|
||||
|
||||
handle.terminate()
|
||||
const quiescent = handle.waitForExit()
|
||||
const quiescent = handle.terminate()
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(inspector.processes).toContainEqual([124, 'SIGKILL'])
|
||||
expect(pty.kills).toEqual([])
|
||||
|
||||
inspector.alive.delete(124)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(await quiescent).toBe(true)
|
||||
await quiescent
|
||||
expect(pty.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
@@ -149,17 +146,16 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.alive.add(124)
|
||||
inspector.removeOnSignal = false
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
|
||||
const waiting = handle.waitForExit()
|
||||
pty.emitExit()
|
||||
const waiting = handle.terminate()
|
||||
let settled = false
|
||||
void waiting.then(() => { settled = true })
|
||||
|
||||
pty.emitExit()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(settled).toBe(false)
|
||||
|
||||
inspector.alive.delete(124)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(await waiting).toBe(true)
|
||||
await waiting
|
||||
})
|
||||
|
||||
it('cleans a same-session descendant after the top-level shell exits naturally', async () => {
|
||||
@@ -172,7 +168,7 @@ describe('LocalTerminalHandle', () => {
|
||||
|
||||
pty.emitExit()
|
||||
|
||||
expect(await handle.waitForExit()).toBe(true)
|
||||
await handle.terminate()
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM']])
|
||||
})
|
||||
|
||||
@@ -188,7 +184,7 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.members = []
|
||||
pty.emitExit()
|
||||
|
||||
expect(await handle.waitForExit()).toBe(true)
|
||||
await handle.terminate()
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM']])
|
||||
})
|
||||
|
||||
@@ -209,8 +205,7 @@ describe('LocalTerminalHandle', () => {
|
||||
return []
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
handle.terminate()
|
||||
await handle.waitForExit()
|
||||
await handle.terminate()
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']])
|
||||
expect(pty.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
@@ -225,14 +220,13 @@ describe('LocalTerminalHandle', () => {
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
|
||||
handle.terminate()
|
||||
await handle.waitForExit()
|
||||
await handle.terminate()
|
||||
|
||||
expect(inspector.processes).toEqual([[late.pid, 'SIGTERM']])
|
||||
expect(pty.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
it('keeps a failed post-shell sweep retryable until its survivor leaves', async () => {
|
||||
it('retries failed cleanup after a surviving descendant leaves', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
@@ -244,14 +238,15 @@ describe('LocalTerminalHandle', () => {
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
|
||||
handle.terminate()
|
||||
const failed = expect(handle.waitForExit()).rejects.toThrow('surviving pids: 124')
|
||||
const first = handle.terminate()
|
||||
const failed = expect(first).rejects.toThrow('surviving pids: 124')
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await failed
|
||||
|
||||
inspector.alive.delete(late.pid)
|
||||
handle.terminate()
|
||||
expect(await handle.waitForExit()).toBe(true)
|
||||
const retry = handle.terminate()
|
||||
expect(retry).not.toBe(first)
|
||||
await retry
|
||||
expect(inspector.processes).toEqual([[late.pid, 'SIGTERM'], [late.pid, 'SIGKILL']])
|
||||
})
|
||||
|
||||
@@ -268,82 +263,35 @@ describe('LocalTerminalHandle', () => {
|
||||
if (signal === 'SIGKILL') inspector.alive.delete(identity.pid)
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
|
||||
handle.terminate()
|
||||
const quiescent = handle.waitForExit()
|
||||
const quiescent = handle.terminate()
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
expect(await quiescent).toBe(true)
|
||||
await quiescent
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [124, 'SIGKILL']])
|
||||
})
|
||||
|
||||
it('allows cleanup to retry after a surviving descendant leaves', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.members = [{ pid: 124, started: 'child' }]
|
||||
inspector.alive.add(124)
|
||||
inspector.removeOnSignal = false
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
|
||||
handle.terminate()
|
||||
const first = expect(handle.waitForExit(new AbortController().signal)).rejects.toThrow('surviving pids: 124')
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await first
|
||||
|
||||
inspector.alive.delete(124)
|
||||
handle.terminate()
|
||||
expect(await handle.waitForExit()).toBe(true)
|
||||
expect(pty.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
it('bounds waits and reports a top-level process that ignores escalation', async () => {
|
||||
it('reports a top-level process that ignores escalation', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pty = new FakePty()
|
||||
pty.autoExitOnKill = false
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10)
|
||||
expect(await handle.waitForExit(AbortSignal.abort())).toBe(false)
|
||||
const controller = new AbortController()
|
||||
const bounded = handle.waitForExit(controller.signal)
|
||||
controller.abort()
|
||||
expect(await bounded).toBe(false)
|
||||
|
||||
handle.terminate()
|
||||
const failed = expect(handle.waitForExit()).rejects.toThrow('surviving pid: 123')
|
||||
const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123')
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await failed
|
||||
expect(pty.kills).toEqual(['SIGTERM', 'SIGKILL'])
|
||||
|
||||
pty.emitExit(0, 999)
|
||||
expect(await handle.done).toEqual({ exitCode: null, signal: null })
|
||||
handle.terminate()
|
||||
expect(await handle.waitForExit()).toBe(true)
|
||||
await handle.terminate()
|
||||
})
|
||||
|
||||
it('contains process races and reacts to lifetime cancellation', async () => {
|
||||
it('contains process races while reporting surviving descendants', async () => {
|
||||
const pty = new FakePty()
|
||||
pty.throwKill = true
|
||||
const inspector = new FakeInspector()
|
||||
inspector.members = [{ pid: 124, started: 'child' }]
|
||||
inspector.alive.add(124)
|
||||
inspector.throwProcess = true
|
||||
const controller = new AbortController()
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 1, controller.signal)
|
||||
controller.abort()
|
||||
const failed = expect(handle.waitForExit()).rejects.toThrow('surviving pids: 124')
|
||||
await failed
|
||||
|
||||
inspector.alive.delete(124)
|
||||
pty.throwKill = false
|
||||
handle.terminate()
|
||||
await handle.waitForExit()
|
||||
|
||||
const preAbortedPty = new FakePty()
|
||||
const preAborted = new LocalTerminalHandle(
|
||||
preAbortedPty.asPty(),
|
||||
new FakeInspector(),
|
||||
1,
|
||||
AbortSignal.abort('stop'),
|
||||
)
|
||||
await preAborted.waitForExit()
|
||||
expect(preAbortedPty.kills).toEqual(['SIGTERM'])
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 1)
|
||||
await expect(handle.terminate()).rejects.toThrow('surviving pids: 124')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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、ACP(Agent 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(资源释放)会终止所有仍在运行的受管进程并等待其退出。
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 () => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user