mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
refactor(subprocess): share terminal lifecycle
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 .agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md
|
||||
2026-07-28-portable-execution-world-consumers.md: 3d1928be882fb3fb621e733649146445c69e8a5b
|
||||
2026-07-28-portable-execution-world-consumers.zh.md: f385e502d5d737fe0d5ae6e28ac7a3a097d85a6b
|
||||
2026-07-28-portable-execution-world-consumers.md: a5df393f31a25b7d16e63af240882e1d94093e34
|
||||
2026-07-28-portable-execution-world-consumers.zh.md: 87f11fa53b5b7579969dcb15dc3aaad978018773
|
||||
|
||||
@@ -16,7 +16,7 @@ Ordinary pipes do not cover one requirement. A persistent terminal needs PTY all
|
||||
|
||||
The filesystem interface owns the path facts that another capability needs without exposing its opaque target identity: a canonical process path, canonical `file:` URI, containment, and a bounded stable-handle text read. The existing text and mutation operations remain filesystem-owned.
|
||||
|
||||
The subprocess interface owns the process coordinates and primitives: canonical cwd, private runtime storage, executable lookup, ordinary raw or collected process spawning, and `spawnTerminal()`. The terminal operation is one deep primitive whose handle owns byte I/O, foreground groups, signalling, TERM-to-KILL session cleanup, and a quiescence wait. Prompt detection, idle inference, scrollback, sandbox policy, and owner lifecycle remain in the PTY consumer.
|
||||
The subprocess interface owns the process coordinates and primitives: canonical cwd, private runtime storage, executable lookup, ordinary raw or collected process spawning, and `spawnTerminal()`. The terminal operation is one deep primitive whose handle owns byte I/O, foreground groups, signalling, TERM-to-KILL session cleanup, and a quiescence wait. The interface package also exports a provider-neutral lifecycle controller that joins top-level settlement, lifetime cancellation, retryable provider cleanup, and bounded quiescence observation; each implementation supplies only its session-cleanup transaction. Prompt detection, idle inference, scrollback, sandbox policy, and owner lifecycle remain in the PTY consumer.
|
||||
|
||||
Generic consumers use that execution world:
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Status: implemented
|
||||
|
||||
文件系统接口负责其他能力需要的路径事实,同时不公开其不透明目标身份:规范化进程路径、规范化 `file:` URI、包含关系,以及通过稳定句柄执行的有界文本读取。现有文本与变更操作仍归文件系统负责。
|
||||
|
||||
进程管理接口负责进程运行坐标与原语:规范化 cwd、私有运行时存储、可执行文件查找、以原始或收集模式 spawn 普通进程,以及 `spawnTerminal()`。终端操作是一项深层原语,其句柄负责字节 I/O、前台进程组管理、信号发送、TERM→KILL 会话清理以及等待完全停稳。提示符检测、空闲推断、scrollback、沙箱策略和所有者生命周期仍由 PTY 消费方负责。
|
||||
进程管理接口负责进程运行坐标与原语:规范化 cwd、私有运行时存储、可执行文件查找、以原始或收集模式 spawn 普通进程,以及 `spawnTerminal()`。终端操作是一项深层原语,其句柄负责字节 I/O、前台进程组管理、信号发送、TERM→KILL 会话清理以及等待完全停稳。接口包还导出一个提供方无关的生命周期控制器,用于组合顶层结算、生命周期取消、可重试的提供方清理与有界的完全停稳观测;每个实现只需提供自身的会话清理事务。提示符检测、空闲推断、scrollback、沙箱策略和所有者生命周期仍由 PTY 消费方负责。
|
||||
|
||||
通用消费方使用该执行世界:
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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,
|
||||
@@ -33,9 +34,8 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
private readonly outcome = Promise.withResolvers<SubprocessOutcome>()
|
||||
private readonly dataDisposable: IDisposable
|
||||
private readonly exitDisposable: IDisposable
|
||||
private readonly lifecycle: SubprocessTerminalLifecycle
|
||||
private exited = false
|
||||
private termination: Promise<void> | undefined
|
||||
private removeAbort: (() => void) | undefined
|
||||
private trackedDescendants: ProcessIdentity[] = []
|
||||
|
||||
/**
|
||||
@@ -63,12 +63,11 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
})
|
||||
this.terminate()
|
||||
})
|
||||
if (signal !== undefined) {
|
||||
const onAbort = (): void => { this.terminate() }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
this.removeAbort = () => { signal.removeEventListener('abort', onAbort) }
|
||||
if (signal.aborted) this.terminate()
|
||||
}
|
||||
this.lifecycle = new SubprocessTerminalLifecycle({
|
||||
done: this.done,
|
||||
cleanup: () => this.closeOnce(),
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
// node-pty writes synchronously; the seam returns a promise for remote transports.
|
||||
@@ -109,37 +108,11 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.termination ??= this.closeOnce().catch((error: unknown) => {
|
||||
this.termination = undefined
|
||||
throw error
|
||||
})
|
||||
void this.termination.catch(() => {})
|
||||
this.lifecycle.terminate()
|
||||
}
|
||||
|
||||
async waitForExit(signal?: AbortSignal): Promise<boolean> {
|
||||
// A caller may begin waiting before the top-level process exits. The exit
|
||||
// callback starts descendant cleanup in the same turn, so resolve that
|
||||
// eventual transaction after `done` instead of snapshotting only `done`.
|
||||
const quiescence = this.termination ?? this.done.then(() => this.termination)
|
||||
if (signal === undefined) {
|
||||
await quiescence
|
||||
return true
|
||||
}
|
||||
if (signal.aborted) return false
|
||||
return await new Promise<boolean>((resolve, reject) => {
|
||||
const onAbort = (): void => { cleanup(); resolve(false) }
|
||||
const cleanup = (): void => { signal.removeEventListener('abort', onAbort) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void quiescence.then(
|
||||
() => { cleanup(); resolve(true) },
|
||||
(error: unknown) => {
|
||||
cleanup()
|
||||
// The owned cleanup transaction only throws Error diagnostics.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
reject(error)
|
||||
},
|
||||
)
|
||||
})
|
||||
return await this.lifecycle.waitForExit(signal)
|
||||
}
|
||||
|
||||
private survivors(members: ProcessIdentity[]): ProcessIdentity[] {
|
||||
@@ -225,8 +198,6 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
|
||||
}
|
||||
await this.stopShell()
|
||||
this.removeAbort?.()
|
||||
this.removeAbort = undefined
|
||||
this.dataDisposable.dispose()
|
||||
this.exitDisposable.dispose()
|
||||
}
|
||||
|
||||
@@ -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: 84b4b0c11c74c96929fa97b58fb33156d44e6ef1
|
||||
README.zh.md: dbd80a1c975719884481501f5cc43798e464a4fb
|
||||
README.md: d03824da33bb44b2525b1343a27557ed15823418
|
||||
README.zh.md: 34cc75c3cfc8148754343e92b0c204760b1fb543
|
||||
|
||||
@@ -12,6 +12,7 @@ The subprocess seam (`ctx.subprocess`) is the process half of one execution worl
|
||||
- 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.
|
||||
- `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.
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- 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 与由提供方负责的会话清理组合起来。它绑定生命周期取消,共享同一个进行中的清理尝试,允许失败的尝试重试,规范化清理拒绝,并在不了解提供方进程机制的情况下对完全停稳观测施加上限。
|
||||
- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并。本地普通 spawn 与终端 spawn 都应用这一定义;自行拥有 spawn 的 SDK 管理传输层可以直接导入它。
|
||||
- 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ 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,
|
||||
|
||||
104
packages/subprocess/subprocess/src/terminal-lifecycle.ts
Normal file
104
packages/subprocess/subprocess/src/terminal-lifecycle.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
125
packages/subprocess/subprocess/tests/terminal-lifecycle.spec.ts
Normal file
125
packages/subprocess/subprocess/tests/terminal-lifecycle.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
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