fix(pty): await unpublished spawn teardown
This commit is contained in:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write
|
# pnpm run verify-translation-pairing --write
|
||||||
2026-07-16-persistent-pty-sessions.md: afa6f1771931ed437d8c2c34204ad4a59d02248e
|
2026-07-16-persistent-pty-sessions.md: a73b8d0247243cc0d9cc93f27d84a8540b3b271d
|
||||||
2026-07-16-persistent-pty-sessions.zh.md: 2af97c3d126f8639e6b52685fb7fea1292343c9b
|
2026-07-16-persistent-pty-sessions.zh.md: b0dc4010f69206b7aaeab4164199ffd935165955
|
||||||
|
|||||||
@@ -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.
|
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. 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. 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
|
### Security and process boundary
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ The package ships concise tool guidance explaining persistent state, owner isola
|
|||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
- Per-file coverage pins owner fencing, concurrent reservations, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents.
|
- Per-file coverage pins owner fencing, concurrent reservations, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents.
|
||||||
- Linux process fixtures cover non-leader and non-main-thread stdin waits, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite.
|
- Linux process fixtures cover non-leader and non-main-thread stdin waits, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite.
|
||||||
- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts.
|
- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts.
|
||||||
- A Loader-driven `cordis.yml` test mounts the real three-package composition, while ACP and headless snapshots pin the six schemas, bounded results, error rendering, and terminal/generic cards through opt-in overlays.
|
- A Loader-driven `cordis.yml` test mounts the real three-package composition, while ACP and headless snapshots pin the six schemas, bounded results, error rendering, and terminal/generic cards through opt-in overlays.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ idle 检测属于后端行为,不是第二条公共 seam。远程或容器后
|
|||||||
|
|
||||||
实现不提供插件加载期 auto-start 会话。`terminal_open` 只在 agent 工具调用期间创建会话,此时所有权和所属的事件溯源会话都已确定。未来的声明式启动功能必须通过尚未发布的 agent setup 组合,而不能创建全局共享终端。
|
实现不提供插件加载期 auto-start 会话。`terminal_open` 只在 agent 工具调用期间创建会话,此时所有权和所属的事件溯源会话都已确定。未来的声明式启动功能必须通过尚未发布的 agent setup 组合,而不能创建全局共享终端。
|
||||||
|
|
||||||
agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出。后端或工具插件 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`。后端或工具插件 reload 不会遗留会话:所有权持续存放在 `PtyService` 中,直到 agent 结束,与 [`ctx.tasks`](../../../../packages/tasks/tasks/README.md) 的服务持有记录模式一致。服务会先同步把会话预留给一次活跃发送,再返回该操作;后台发送同样会在 task id 对外可见前完成预留。第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消无法跨越操作所有权。
|
||||||
|
|
||||||
### 安全与进程边界
|
### 安全与进程边界
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ plugins:
|
|||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
- 每文件覆盖率固定 owner 隔离、并发预留、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。
|
- 每文件覆盖率固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。
|
||||||
- Linux 进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、僵尸进程静止性、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。
|
- Linux 进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、僵尸进程静止性、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。
|
||||||
- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、raw mode 下的前台 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即静默。
|
- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、raw mode 下的前台 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即静默。
|
||||||
- Loader 驱动的 `cordis.yml` 测试挂载真实三包组合;ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果、错误渲染和 terminal/generic card。
|
- Loader 驱动的 `cordis.yml` 测试挂载真实三包组合;ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果、错误渲染和 terminal/generic card。
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Owner-scoped persistent PTY seam. `PtyService` registers as `ctx.pty`, mints opa
|
|||||||
|
|
||||||
- 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.
|
||||||
- Spawn cancellation preserves the caller's exact abort reason. Service disposal and owner loss remain distinct machine-routable failures after backend setup.
|
- 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.
|
||||||
- `hasOwnerActivity(owner)` spans unpublished setup through final close, so lifecycle policy can fence the exact owner without a publication race.
|
- `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.
|
- 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.
|
- One session accepts at most one live send operation. Reads and signals may observe it; another send fails until the operation settles.
|
||||||
|
|||||||
@@ -87,12 +87,22 @@ interface SessionRecord {
|
|||||||
closing: Promise<void> | undefined
|
closing: Promise<void> | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PendingSpawn {
|
||||||
|
readonly controller: AbortController
|
||||||
|
readonly settled: Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpawnReservation {
|
||||||
|
readonly signal: AbortSignal
|
||||||
|
release(): void
|
||||||
|
}
|
||||||
|
|
||||||
/** In-process registry for replaceable PTY backends and exact-Agent sessions. */
|
/** In-process registry for replaceable PTY backends and exact-Agent sessions. */
|
||||||
export class PtyService extends Service {
|
export class PtyService extends Service {
|
||||||
private readonly backends = new Map<string, PtyBackend>()
|
private readonly backends = new Map<string, PtyBackend>()
|
||||||
private readonly sessions = new Map<PtySessionId, SessionRecord>()
|
private readonly sessions = new Map<PtySessionId, SessionRecord>()
|
||||||
private readonly reservedNames = new Map<Agent, Set<string>>()
|
private readonly reservedNames = new Map<Agent, Set<string>>()
|
||||||
private readonly pendingSpawns = new Map<Agent, number>()
|
private readonly pendingSpawns = new Map<Agent, Set<PendingSpawn>>()
|
||||||
private readonly ownerCleanups = new Map<Agent, () => Promise<void> | void>()
|
private readonly ownerCleanups = new Map<Agent, () => Promise<void> | void>()
|
||||||
private readonly disposedOwners = new WeakSet<Agent>()
|
private readonly disposedOwners = new WeakSet<Agent>()
|
||||||
private nextId = 0
|
private nextId = 0
|
||||||
@@ -145,7 +155,10 @@ export class PtyService extends Service {
|
|||||||
if (backend === undefined) throw new PtyError(`no PTY backend registered for "${request.type}"`, 'NO_BACKEND')
|
if (backend === undefined) throw new PtyError(`no PTY backend registered for "${request.type}"`, 'NO_BACKEND')
|
||||||
if (request.name !== undefined && request.name.length === 0) throw new Error('PTY session name must be non-empty')
|
if (request.name !== undefined && request.name.length === 0) throw new Error('PTY session name must be non-empty')
|
||||||
const releaseName = this.reserveName(owner, request.name)
|
const releaseName = this.reserveName(owner, request.name)
|
||||||
const releaseSpawn = this.reserveSpawn(owner)
|
const spawnReservation = this.reserveSpawn(owner)
|
||||||
|
const backendSignal = signal === undefined
|
||||||
|
? spawnReservation.signal
|
||||||
|
: AbortSignal.any([signal, spawnReservation.signal])
|
||||||
const sessionId = PtySessionId(`pty-${++this.nextId}`)
|
const sessionId = PtySessionId(`pty-${++this.nextId}`)
|
||||||
let session: PtyBackendSession | undefined
|
let session: PtyBackendSession | undefined
|
||||||
try {
|
try {
|
||||||
@@ -155,7 +168,7 @@ export class PtyService extends Service {
|
|||||||
type: request.type,
|
type: request.type,
|
||||||
...request.name !== undefined ? { name: request.name } : {},
|
...request.name !== undefined ? { name: request.name } : {},
|
||||||
...request.cwd !== undefined ? { cwd: request.cwd } : {},
|
...request.cwd !== undefined ? { cwd: request.cwd } : {},
|
||||||
...signal !== undefined ? { signal } : {},
|
signal: backendSignal,
|
||||||
})
|
})
|
||||||
signal?.throwIfAborted()
|
signal?.throwIfAborted()
|
||||||
if (this.disposing) {
|
if (this.disposing) {
|
||||||
@@ -176,16 +189,27 @@ export class PtyService extends Service {
|
|||||||
this.sessions.set(sessionId, record)
|
this.sessions.set(sessionId, record)
|
||||||
return this.snapshot(record, session.motd)
|
return this.snapshot(record, session.motd)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
let rollbackFailure: { error: unknown } | undefined
|
||||||
if (session !== undefined && !this.sessions.has(sessionId)) {
|
if (session !== undefined && !this.sessions.has(sessionId)) {
|
||||||
try {
|
try {
|
||||||
await session.close('PTY spawn rolled back')
|
await session.close('PTY spawn rolled back')
|
||||||
} catch (closeError: unknown) {
|
} catch (closeError: unknown) {
|
||||||
throw new AggregateError([error, closeError], 'PTY spawn and rollback both failed')
|
rollbackFailure = { error: closeError }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw error
|
let failure: unknown = error
|
||||||
|
try {
|
||||||
|
signal?.throwIfAborted()
|
||||||
|
spawnReservation.signal.throwIfAborted()
|
||||||
|
} catch (cancellation: unknown) {
|
||||||
|
failure = cancellation
|
||||||
|
}
|
||||||
|
if (rollbackFailure !== undefined) {
|
||||||
|
throw new AggregateError([failure, rollbackFailure.error], 'PTY spawn and rollback both failed')
|
||||||
|
}
|
||||||
|
throw failure
|
||||||
} finally {
|
} finally {
|
||||||
releaseSpawn()
|
spawnReservation.release()
|
||||||
releaseName()
|
releaseName()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,7 +220,7 @@ export class PtyService extends Service {
|
|||||||
* @returns true across the entire spawn-to-close interval, with no publication gap.
|
* @returns true across the entire spawn-to-close interval, with no publication gap.
|
||||||
*/
|
*/
|
||||||
hasOwnerActivity(owner: Agent): boolean {
|
hasOwnerActivity(owner: Agent): boolean {
|
||||||
return (this.pendingSpawns.get(owner) ?? 0) > 0
|
return (this.pendingSpawns.get(owner)?.size ?? 0) > 0
|
||||||
|| [...this.sessions.values()].some(record => record.owner === owner)
|
|| [...this.sessions.values()].some(record => record.owner === owner)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,15 +338,31 @@ export class PtyService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private reserveSpawn(owner: Agent): () => void {
|
private reserveSpawn(owner: Agent): SpawnReservation {
|
||||||
this.pendingSpawns.set(owner, (this.pendingSpawns.get(owner) ?? 0) + 1)
|
const controller = new AbortController()
|
||||||
return () => {
|
const settlement = Promise.withResolvers<void>()
|
||||||
const remaining = (this.pendingSpawns.get(owner) ?? 1) - 1
|
const pending: PendingSpawn = { controller, settled: settlement.promise }
|
||||||
if (remaining === 0) this.pendingSpawns.delete(owner)
|
const owned = this.pendingSpawns.get(owner) ?? new Set<PendingSpawn>()
|
||||||
else this.pendingSpawns.set(owner, remaining)
|
owned.add(pending)
|
||||||
|
this.pendingSpawns.set(owner, owned)
|
||||||
|
return {
|
||||||
|
signal: controller.signal,
|
||||||
|
release: () => {
|
||||||
|
owned.delete(pending)
|
||||||
|
if (owned.size === 0) this.pendingSpawns.delete(owner)
|
||||||
|
settlement.resolve()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async abortPendingSpawns(owner: Agent | undefined, reason: PtyError): Promise<void> {
|
||||||
|
const pending = owner === undefined
|
||||||
|
? [...this.pendingSpawns.values()].flatMap(owned => [...owned])
|
||||||
|
: [...(this.pendingSpawns.get(owner) ?? [])]
|
||||||
|
for (const spawn of pending) spawn.controller.abort(reason)
|
||||||
|
await Promise.all(pending.map(spawn => spawn.settled))
|
||||||
|
}
|
||||||
|
|
||||||
private expectOwned(owner: Agent, id: PtySessionId): SessionRecord {
|
private expectOwned(owner: Agent, id: PtySessionId): SessionRecord {
|
||||||
const record = this.sessions.get(id)
|
const record = this.sessions.get(id)
|
||||||
if (record === undefined) throw new PtyError(`unknown PTY session ${id}`, 'NO_SESSION')
|
if (record === undefined) throw new PtyError(`unknown PTY session ${id}`, 'NO_SESSION')
|
||||||
@@ -344,6 +384,7 @@ export class PtyService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async disposeOwned(owner: Agent): Promise<void> {
|
private async disposeOwned(owner: Agent): Promise<void> {
|
||||||
|
await this.abortPendingSpawns(owner, new PtyError('PTY owner is no longer live', 'OWNER_NOT_LIVE'))
|
||||||
const owned = [...this.sessions.values()].filter(record => record.owner === owner)
|
const owned = [...this.sessions.values()].filter(record => record.owner === owner)
|
||||||
await this.closeRecords(owned, 'PTY owner disposed')
|
await this.closeRecords(owned, 'PTY owner disposed')
|
||||||
this.reservedNames.delete(owner)
|
this.reservedNames.delete(owner)
|
||||||
@@ -351,11 +392,12 @@ export class PtyService extends Service {
|
|||||||
|
|
||||||
private async disposeAll(): Promise<void> {
|
private async disposeAll(): Promise<void> {
|
||||||
this.disposing = true
|
this.disposing = true
|
||||||
const records = [...this.sessions.values()]
|
|
||||||
// Teardown is best-effort: a close failure still clears registries and runs
|
// Teardown is best-effort: a close failure still clears registries and runs
|
||||||
// owner cleanups before the aggregated error propagates, so one stuck
|
// owner cleanups before the aggregated error propagates, so one stuck
|
||||||
// session cannot orphan backends, reservations, or owner detachers.
|
// session cannot orphan backends, reservations, or owner detachers.
|
||||||
try {
|
try {
|
||||||
|
await this.abortPendingSpawns(undefined, new PtyError('PTY service is disposing', 'SERVICE_DISPOSING'))
|
||||||
|
const records = [...this.sessions.values()]
|
||||||
await this.closeRecords(records, 'PTY service disposed')
|
await this.closeRecords(records, 'PTY service disposed')
|
||||||
} finally {
|
} finally {
|
||||||
this.backends.clear()
|
this.backends.clear()
|
||||||
|
|||||||
@@ -206,9 +206,10 @@ describe('PtyService ownership and lifecycle', () => {
|
|||||||
ctx.agents.register(owner)
|
ctx.agents.register(owner)
|
||||||
const pending = ctx.pty.spawn(owner, { type: 'slow', name: 'main' })
|
const pending = ctx.pty.spawn(owner, { type: 'slow', name: 'main' })
|
||||||
await expect(ctx.pty.spawn(owner, { type: 'slow', name: 'main' })).rejects.toMatchObject({ code: 'DUPLICATE_NAME' })
|
await expect(ctx.pty.spawn(owner, { type: 'slow', name: 'main' })).rejects.toMatchObject({ code: 'DUPLICATE_NAME' })
|
||||||
await disposeAgentScope(owner)
|
const disposal = disposeAgentScope(owner)
|
||||||
gate.resolve(session)
|
gate.resolve(session)
|
||||||
await expect(pending).rejects.toMatchObject({ code: 'OWNER_NOT_LIVE' })
|
await expect(pending).rejects.toMatchObject({ code: 'OWNER_NOT_LIVE' })
|
||||||
|
await disposal
|
||||||
expect(session.closed).toEqual(['PTY spawn rolled back'])
|
expect(session.closed).toEqual(['PTY spawn rolled back'])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -231,19 +232,70 @@ describe('PtyService ownership and lifecycle', () => {
|
|||||||
expect(ctx.agents.get(owner.id)).toBe(owner)
|
expect(ctx.agents.get(owner.id)).toBe(owner)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rolls back an unpublished backend session when service disposal wins', async () => {
|
it('preserves caller cancellation when a backend rejects in response to it', async () => {
|
||||||
|
const ctx = await harness()
|
||||||
|
const started = Promise.withResolvers<undefined>()
|
||||||
|
const backendFailure = new Error('backend observed cancellation')
|
||||||
|
ctx.pty.registerBackend({
|
||||||
|
type: 'abortable',
|
||||||
|
spawn: ({ signal }) => new Promise((_resolve, reject) => {
|
||||||
|
if (signal === undefined) throw new Error('missing spawn signal')
|
||||||
|
started.resolve(undefined)
|
||||||
|
signal.addEventListener('abort', () => { reject(backendFailure) }, { once: true })
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const owner = stubAgent(ctx, 'owner')
|
||||||
|
ctx.agents.register(owner)
|
||||||
|
const controller = new AbortController()
|
||||||
|
const reason = new Error('cancelled by caller')
|
||||||
|
|
||||||
|
const pending = ctx.pty.spawn(owner, { type: 'abortable' }, controller.signal)
|
||||||
|
await started.promise
|
||||||
|
controller.abort(reason)
|
||||||
|
|
||||||
|
await expect(pending).rejects.toBe(reason)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ scope: 'owner', code: 'OWNER_NOT_LIVE' },
|
||||||
|
{ scope: 'service', code: 'SERVICE_DISPOSING' },
|
||||||
|
] as const)('$scope disposal aborts and awaits unpublished backend setup', async ({ scope, code }) => {
|
||||||
const ctx = await harness()
|
const ctx = await harness()
|
||||||
const gate = Promise.withResolvers<PtyBackendSession>()
|
const gate = Promise.withResolvers<PtyBackendSession>()
|
||||||
|
const started = Promise.withResolvers<undefined>()
|
||||||
const session = new StubSession()
|
const session = new StubSession()
|
||||||
ctx.pty.registerBackend({ type: 'slow', spawn: () => gate.promise })
|
let backendSignal: AbortSignal | undefined
|
||||||
|
ctx.pty.registerBackend({
|
||||||
|
type: 'slow',
|
||||||
|
spawn: (spec) => {
|
||||||
|
backendSignal = spec.signal
|
||||||
|
started.resolve(undefined)
|
||||||
|
return gate.promise
|
||||||
|
},
|
||||||
|
})
|
||||||
const owner = stubAgent(ctx, 'owner')
|
const owner = stubAgent(ctx, 'owner')
|
||||||
ctx.agents.register(owner)
|
ctx.agents.register(owner)
|
||||||
|
|
||||||
const pending = ctx.pty.spawn(owner, { type: 'slow' })
|
const pending = ctx.pty.spawn(owner, { type: 'slow' })
|
||||||
await disposePtyService(ctx)
|
const pendingFailure = pending.then(
|
||||||
|
() => { throw new Error('pending spawn unexpectedly succeeded') },
|
||||||
|
(error: unknown) => error,
|
||||||
|
)
|
||||||
|
await started.promise
|
||||||
|
let disposalSettled = false
|
||||||
|
const disposal = (scope === 'owner' ? disposeAgentScope(owner) : disposePtyService(ctx))
|
||||||
|
.then(() => { disposalSettled = true })
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
const signalAbortedBeforeRelease = backendSignal?.aborted ?? false
|
||||||
|
const signalReasonBeforeRelease = backendSignal?.reason as unknown
|
||||||
|
const disposalSettledBeforeRelease = disposalSettled
|
||||||
gate.resolve(session)
|
gate.resolve(session)
|
||||||
|
|
||||||
await expect(pending).rejects.toMatchObject({ code: 'SERVICE_DISPOSING' })
|
expect(await pendingFailure).toMatchObject({ code })
|
||||||
|
await disposal
|
||||||
|
expect(signalAbortedBeforeRelease).toBe(true)
|
||||||
|
expect(signalReasonBeforeRelease).toMatchObject({ code })
|
||||||
|
expect(disposalSettledBeforeRelease).toBe(false)
|
||||||
expect(session.closed).toEqual(['PTY spawn rolled back'])
|
expect(session.closed).toEqual(['PTY spawn rolled back'])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -290,14 +342,22 @@ describe('PtyService ownership and lifecycle', () => {
|
|||||||
ctx.agents.register(owner)
|
ctx.agents.register(owner)
|
||||||
const failedSpawn = new StubSession()
|
const failedSpawn = new StubSession()
|
||||||
failedSpawn.rejectClose = true
|
failedSpawn.rejectClose = true
|
||||||
|
let ownerDisposal = Promise.resolve()
|
||||||
ctx.pty.registerBackend({
|
ctx.pty.registerBackend({
|
||||||
type: 'bad-spawn',
|
type: 'bad-spawn',
|
||||||
async spawn() {
|
async spawn({ signal }) {
|
||||||
await disposeAgentScope(owner)
|
if (signal === undefined) throw new Error('missing spawn signal')
|
||||||
|
ownerDisposal = disposeAgentScope(owner)
|
||||||
|
if (!signal.aborted) {
|
||||||
|
await new Promise<undefined>((resolve) => {
|
||||||
|
signal.addEventListener('abort', () => { resolve(undefined) }, { once: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
return failedSpawn
|
return failedSpawn
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await expect(ctx.pty.spawn(owner, { type: 'bad-spawn' })).rejects.toThrow('spawn and rollback both failed')
|
await expect(ctx.pty.spawn(owner, { type: 'bad-spawn' })).rejects.toThrow('spawn and rollback both failed')
|
||||||
|
await ownerDisposal
|
||||||
|
|
||||||
const nextOwner = stubAgent(ctx, 'next')
|
const nextOwner = stubAgent(ctx, 'next')
|
||||||
ctx.agents.register(nextOwner)
|
ctx.agents.register(nextOwner)
|
||||||
|
|||||||
Reference in New Issue
Block a user