fix(pty): retain backend cleanup failures

This commit is contained in:
Tianyi Cui
2026-07-23 00:59:16 +08:00
parent be20804684
commit 7f0f70ce3c
12 changed files with 100 additions and 25 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
2026-07-16-persistent-pty-sessions.md: 689019d56d719884761407f288e1e765dd19c35d
2026-07-16-persistent-pty-sessions.zh.md: 14490137a003e2ca67b594628e506f0b74e3d4b7
2026-07-16-persistent-pty-sessions.md: ba8d8579c107f89f83b2a9ab40298ac876df4521
2026-07-16-persistent-pty-sessions.zh.md: 44e66094b905560e0cd5f3c30204e46e93351791

View File

@@ -34,7 +34,7 @@ Idle detection is backend behavior, not a second public seam. A remote or contai
There are no plugin-load auto-start sessions. `terminal_open` creates a session only during an agent tool call, when ownership and the owning event-sourced session are known. A future declarative startup feature must compose through unpublished agent setup rather than create shared global terminals.
Agent-scope disposal closes registrations first, then awaits quiescent teardown of every owned PTY. Unpublished backend setup is a tracked lifecycle operation: owner or service disposal aborts its service-owned signal, waits for backend settlement and rollback, and only then returns. Caller cancellation retains its exact `AbortSignal.reason` even when the backend rejects in response; a rollback close failure rejects both the spawn and the disposing lifecycle. Backend or tool-plugin reload does not orphan sessions: ownership lives in `PtyService` until the agent ends, following the same service-owned-record pattern as [`ctx.tasks`](../../../../packages/tasks/tasks/README.md). The service reserves the session synchronously for one active send before returning its operation, including before a background task id becomes visible; a second send fails with `SEND_ACTIVE`, so output and cancellation cannot cross operation ownership.
Agent-scope disposal closes registrations first, then awaits quiescent teardown of every owned PTY. Unpublished backend setup is a tracked lifecycle operation: owner or service disposal aborts its service-owned signal, waits for backend settlement and rollback, and only then returns. Caller cancellation retains its exact `AbortSignal.reason` even when the backend rejects in response; a service rollback close failure rejects both the spawn and the disposing lifecycle, while `PtyBackendCleanupError` lets a backend preserve its own failed startup cleanup for the disposing lifecycle without replacing that caller reason. Backend or tool-plugin reload does not orphan sessions: ownership lives in `PtyService` until the agent ends, following the same service-owned-record pattern as [`ctx.tasks`](../../../../packages/tasks/tasks/README.md). The service reserves the session synchronously for one active send before returning its operation, including before a background task id becomes visible; a second send fails with `SEND_ACTIVE`, so output and cancellation cannot cross operation ownership.
### Security and process boundary

View File

@@ -34,7 +34,7 @@ idle 检测属于后端行为,不是第二条公共 seam。远程或容器后
实现不提供插件加载期 auto-start 会话。`terminal_open` 只在 agent 工具调用期间创建会话,此时所有权和所属的事件溯源会话都已确定。未来的声明式启动功能必须通过尚未发布的 agent setup 组合,而不能创建全局共享终端。
agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出。未发布的后端 setup 同样是受追踪的生命周期操作owner 或服务 dispose 会中止服务自有的 signal等待后端结算与回滚完成后才返回。即使后端响应取消而 reject调用方取消仍原样保留其 `AbortSignal.reason`回滚 close 失败spawn 与正在执行的 lifecycle dispose 都 reject。后端或工具插件 reload 不会遗留会话:所有权持续存放在 `PtyService` 中,直到 agent 结束,与 [`ctx.tasks`](../../../../packages/tasks/tasks/README.md) 的服务持有记录模式一致。服务会先同步把会话预留给一次活跃发送,再返回该操作;后台发送同样会在 task id 对外可见前完成预留。第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消无法跨越操作所有权。
agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出。未发布的后端 setup 同样是受追踪的生命周期操作owner 或服务 dispose 会中止服务自有的 signal等待后端结算与回滚完成后才返回。即使后端响应取消而 reject调用方取消仍原样保留其 `AbortSignal.reason`服务侧回滚 close 失败会使 spawn 与正在执行的 lifecycle dispose 都 reject,而 `PtyBackendCleanupError` 让后端在不替换该调用方原因的前提下,为正在执行的 dispose 保留自身的启动清理失败。后端或工具插件 reload 不会遗留会话:所有权持续存放在 `PtyService` 中,直到 agent 结束,与 [`ctx.tasks`](../../../../packages/tasks/tasks/README.md) 的服务持有记录模式一致。服务会先同步把会话预留给一次活跃发送,再返回该操作;后台发送同样会在 task id 对外可见前完成预留。第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消无法跨越操作所有权。
### 安全与进程边界

View File

@@ -841,7 +841,7 @@ list(owner: Agent): PtySessionSnapshot[]
Types: [Agent](../core-data-structures/core.md) · [PtyBackend](../core-data-structures/pty.md) · [PtyReadRequest](../core-data-structures/pty.md) · [PtyReadResult](../core-data-structures/pty.md) · [PtySendOperation](../core-data-structures/pty.md) · [PtySendRequest](../core-data-structures/pty.md) · [PtySessionId](../core-data-structures/pty.md) · [PtySessionSnapshot](../core-data-structures/pty.md) · [PtySignal](../core-data-structures/pty.md) · [PtySignalResult](../core-data-structures/pty.md) · [PtySpawnRequest](../core-data-structures/pty.md) · [PtySpawnResult](../core-data-structures/pty.md)
Source: [`packages/pty/pty/src/index.ts:102`](../../packages/pty/pty/src/index.ts)
Source: [`packages/pty/pty/src/index.ts:104`](../../packages/pty/pty/src/index.ts)
## `ctx.sandbox` — `SandboxProvider` (abstract seam)

View File

@@ -22,14 +22,14 @@ type PtySessionStatus =
## Backend and live session
A backend owns how one registered type starts and detects readiness. `PtyService` publishes the returned session only after setup succeeds, then owns id authorization and cleanup. A backend session owns terminal state and captured-resource quiescence.
A backend owns how one registered type starts and detects readiness. `PtyService` publishes the returned session only after setup succeeds, then owns id authorization and cleanup. A backend that cannot clean partial startup resources rejects with `PtyBackendCleanupError`, allowing disposal to retain the cleanup failure without replacing the caller's cancellation reason. A backend session owns terminal state and captured-resource quiescence.
```ts type-equiv
/** Replaceable provider for one PTY session type. */
interface PtyBackend {
/** Stable type selected by {@link PtySpawnRequest.type}. */
readonly type: string
/** Create an unpublished session or reject after cleaning partial resources. */
/** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses {@link PtyBackendCleanupError}. */
spawn(spec: PtyBackendSpawnSpec): Promise<PtyBackendSession>
}
```

View File

@@ -6,7 +6,7 @@ Local `node-pty` backend for `ctx.pty`. It starts an interactive shell under the
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity is gone or, on Linux, a non-executing zombie before stopping the shell; zombie entries are quiescent and are reaped as the shell exits. A survivor failure does not cache a permanently rejected close; a later close retries the teardown.

View File

@@ -9,6 +9,7 @@ import * as nodePty from 'node-pty'
import type { IPtyForkOptions } from 'node-pty'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty'
import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
@@ -123,7 +124,7 @@ export class LocalPtyBackend implements PtyBackend {
try {
await session.close('PTY startup failed')
} catch (closeError: unknown) {
throw new AggregateError([error, closeError], 'PTY startup and cleanup both failed')
throw new PtyBackendCleanupError(error, closeError)
}
throw error
}

View File

@@ -8,7 +8,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty'
import PtyService, { PtyBackendCleanupError, PtySessionId } from '@deepseek-ai/dsh-pty'
import { LocalPtyBackend } from '@deepseek-ai/dsh-pty-local'
import * as ptyLocal from '@deepseek-ai/dsh-pty-local'
import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
@@ -112,12 +112,18 @@ describe('LocalPtyBackend startup rollback', () => {
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('startup failed')
expect(closed).toHaveBeenCalledWith('PTY startup failed')
const startupFailure = new Error('startup failed')
const cleanupFailure = new Error('cleanup failed')
const doublyFailed = {
initialize: () => Promise.reject(new Error('startup failed')),
close: () => Promise.reject(new Error('cleanup failed')),
initialize: () => Promise.reject(startupFailure),
close: () => Promise.reject(cleanupFailure),
} as unknown as LocalPtySession
const aggregate = new LocalPtyBackend(ctx, config(), inspector, spawnTerminal, () => doublyFailed)
await expect(aggregate.spawn(spec(agent(ctx)))).rejects.toThrow('startup and cleanup both failed')
await expect(aggregate.spawn(spec(agent(ctx)))).rejects.toEqual(expect.objectContaining({
name: 'PtyBackendCleanupError',
spawnError: startupFailure,
cleanupError: cleanupFailure,
} satisfies Partial<PtyBackendCleanupError>))
})
it('wraps confined argv, scrubs the environment, and returns initialized sessions', async () => {

View File

@@ -4,10 +4,10 @@ Owner-scoped persistent PTY seam. `PtyService` registers as `ctx.pty`, mints opa
## Contract
- Backends register one stable `type` and return an unpublished `PtyBackendSession`; failed or cancelled setup must clean partial resources.
- Backends register one stable `type` and return an unpublished `PtyBackendSession`; failed or cancelled setup must clean partial resources, and a failed cleanup rejects with `PtyBackendCleanupError` so the registry can retain it across cancellation.
- Spawn cancellation preserves the caller's exact abort reason. Service disposal and owner loss remain distinct machine-routable failures after backend setup.
- Owner and service disposal abort unpublished setup through a service-owned signal and await backend settlement plus rollback before returning.
- A rollback close failure rejects both the spawn and the disposing lifecycle instead of claiming quiescence.
- A service rollback or backend-reported startup cleanup failure rejects the disposing lifecycle instead of claiming quiescence; the spawn caller still receives its exact cancellation reason.
- `hasOwnerActivity(owner)` spans unpublished setup through final close, so lifecycle policy can fence the exact owner without a publication race.
- A successful spawn publishes one `PtySessionId`. The optional `name` is owner-local display metadata, never authority.
- One session accepts at most one live send operation. Reads and signals may observe it; another send fails until the operation settles.

View File

@@ -6,6 +6,7 @@
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { PtyBackendCleanupError } from './types.ts'
import type {
PtyBackend,
PtyBackendSession,
@@ -39,6 +40,7 @@ export type {
PtySpawnResult,
PtyWaitReason,
} from './types.ts'
export { PtyBackendCleanupError } from './types.ts'
/** Opaque identity minted by {@link PtyService} for one live PTY session. */
export type PtySessionId = PtySessionIdValue
@@ -90,12 +92,12 @@ interface SessionRecord {
interface PendingSpawn {
readonly controller: AbortController
readonly settled: Promise<void>
rollbackFailure: { error: unknown } | undefined
cleanupFailure: { error: unknown } | undefined
}
interface SpawnReservation {
readonly signal: AbortSignal
release(rollbackFailure: { error: unknown } | undefined): void
release(cleanupFailure: { error: unknown } | undefined): void
}
/** In-process registry for replaceable PTY backends and exact-Agent sessions. */
@@ -162,7 +164,7 @@ export class PtyService extends Service {
: AbortSignal.any([signal, spawnReservation.signal])
const sessionId = PtySessionId(`pty-${++this.nextId}`)
let session: PtyBackendSession | undefined
let rollbackFailure: { error: unknown } | undefined
let cleanupFailure: { error: unknown } | undefined
try {
session = await backend.spawn({
sessionId,
@@ -191,11 +193,16 @@ export class PtyService extends Service {
this.sessions.set(sessionId, record)
return this.snapshot(record, session.motd)
} catch (error) {
if (error instanceof PtyBackendCleanupError) {
cleanupFailure = { error: error.cleanupError }
}
let rollbackFailure: { error: unknown } | undefined
if (session !== undefined && !this.sessions.has(sessionId)) {
try {
await session.close('PTY spawn rolled back')
} catch (closeError: unknown) {
rollbackFailure = { error: closeError }
cleanupFailure = rollbackFailure
}
}
let failure: unknown = error
@@ -210,7 +217,7 @@ export class PtyService extends Service {
}
throw failure
} finally {
spawnReservation.release(rollbackFailure)
spawnReservation.release(cleanupFailure)
releaseName()
}
}
@@ -342,14 +349,14 @@ export class PtyService extends Service {
private reserveSpawn(owner: Agent): SpawnReservation {
const controller = new AbortController()
const settlement = Promise.withResolvers<void>()
const pending: PendingSpawn = { controller, settled: settlement.promise, rollbackFailure: undefined }
const pending: PendingSpawn = { controller, settled: settlement.promise, cleanupFailure: undefined }
const owned = this.pendingSpawns.get(owner) ?? new Set<PendingSpawn>()
owned.add(pending)
this.pendingSpawns.set(owner, owned)
return {
signal: controller.signal,
release: (rollbackFailure) => {
pending.rollbackFailure = rollbackFailure
release: (cleanupFailure) => {
pending.cleanupFailure = cleanupFailure
owned.delete(pending)
if (owned.size === 0) this.pendingSpawns.delete(owner)
settlement.resolve()
@@ -363,7 +370,7 @@ export class PtyService extends Service {
: [...(this.pendingSpawns.get(owner) ?? [])]
for (const spawn of pending) spawn.controller.abort(reason)
await Promise.all(pending.map(spawn => spawn.settled))
const failures = pending.flatMap(spawn => spawn.rollbackFailure === undefined ? [] : [spawn.rollbackFailure.error])
const failures = pending.flatMap(spawn => spawn.cleanupFailure === undefined ? [] : [spawn.cleanupFailure.error])
if (failures.length > 0) {
throw new AggregateError(failures, 'failed to roll back unpublished PTY setup')
}

View File

@@ -10,6 +10,21 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
/** Internal exported basis for the public `PtySessionId` type/value pair. */
export type PtySessionIdValue = Branded<'PtySessionId'>
/**
* Backend-reported failure to clean partial resources after unpublished setup failed.
* @param spawnError - original setup or cancellation failure.
* @param cleanupError - failure that may leave backend-owned resources alive.
*/
export class PtyBackendCleanupError extends AggregateError {
constructor(
readonly spawnError: unknown,
readonly cleanupError: unknown,
) {
super([spawnError, cleanupError], 'PTY backend startup and cleanup both failed')
this.name = 'PtyBackendCleanupError'
}
}
/** Why one interactive send returned control to its caller. */
export type PtyWaitReason = 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit'
@@ -147,7 +162,7 @@ export interface PtyBackendSession {
export interface PtyBackend {
/** Stable type selected by {@link PtySpawnRequest.type}. */
readonly type: string
/** Create an unpublished session or reject after cleaning partial resources. */
/** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses {@link PtyBackendCleanupError}. */
spawn(spec: PtyBackendSpawnSpec): Promise<PtyBackendSession>
}

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import PtyService, { PtyError, PtySessionId } from '@deepseek-ai/dsh-pty'
import PtyService, { PtyBackendCleanupError, PtyError, PtySessionId } from '@deepseek-ai/dsh-pty'
import type {
PtyBackend,
PtyBackendSession,
@@ -319,6 +319,52 @@ describe('PtyService ownership and lifecycle', () => {
expect(session.closed).toEqual(['PTY spawn rolled back'])
})
it.each([
{ scope: 'owner', code: 'OWNER_NOT_LIVE' },
{ scope: 'service', code: 'SERVICE_DISPOSING' },
] as const)('$scope disposal retains backend-side startup cleanup failure', async ({ scope, code }) => {
const ctx = await harness()
const started = Promise.withResolvers<undefined>()
const cleanupFailure = new Error('backend cleanup failed')
let backendAbortReason: unknown
ctx.pty.registerBackend({
type: 'cleanup-failing',
spawn: ({ signal }) => new Promise((_resolve, reject) => {
if (signal === undefined) throw new Error('missing spawn signal')
started.resolve(undefined)
signal.addEventListener('abort', () => {
backendAbortReason = signal.reason
reject(new PtyBackendCleanupError(signal.reason, cleanupFailure))
}, { once: true })
}),
})
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const pending = ctx.pty.spawn(owner, { type: 'cleanup-failing' })
await started.promise
const internal = ctx.pty as unknown as {
disposeOwned(owner: Agent): Promise<void>
disposeAll(): Promise<void>
}
const disposal = scope === 'owner' ? internal.disposeOwned(owner) : internal.disposeAll()
const pendingError = await pending.then(
() => { throw new Error('pending spawn unexpectedly succeeded') },
(error: unknown) => error,
)
expect(pendingError).toBe(backendAbortReason)
expect(pendingError).toMatchObject({ code })
const disposalError = await disposal.then(
() => { throw new Error('disposal unexpectedly succeeded') },
(error: unknown) => error,
)
expect(disposalError).toMatchObject({ message: 'failed to clean up PTY lifecycle' })
const rollbackError = (disposalError as AggregateError).errors[0] as unknown
const cleanupErrors = (rollbackError as AggregateError).errors as unknown[]
expect(cleanupErrors).toEqual([cleanupFailure])
})
it('keeps independent reservations and handles provider failure before publication', async () => {
const ctx = await harness()
const firstGate = Promise.withResolvers<PtyBackendSession>()