fix(pty): retry failed lifecycle closes

This commit is contained in:
Tianyi Cui
2026-07-23 02:14:15 +08:00
parent 7887391afe
commit db68a90f41
6 changed files with 21 additions and 8 deletions

View File

@@ -13,7 +13,7 @@ Owner-scoped persistent PTY seam. `PtyService` registers as `ctx.pty`, mints opa
- 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.
- `PtySendResult.waitReason` and `sessionStatus` are independent. `session_exit` describes the top-level PTY process, not an arbitrary foreground command.
- `kill()` and disposal resolve only after the backend's captured process tree is quiescent. A cleanup failure rejects instead of claiming success and leaves the close retriable.
- `kill()` and disposal resolve only after the backend's captured process tree is quiescent. A cleanup failure rejects instead of claiming success and clears the matching backend and registry fences so a later close can retry without disturbing a newer attempt.
The seam contains no `node-pty`, sandbox, tool-schema, prompt, task, or terminal-rendering policy. Implementations own terminal mechanics; consumers own model presentation and optional background-task registration.

View File

@@ -457,8 +457,14 @@ export class PtyService extends Service {
const results = await Promise.allSettled(records.map(async (record) => {
const closing = record.closing ?? record.session.close(reason)
record.closing = closing
await closing
this.sessions.delete(record.id)
try {
await closing
this.sessions.delete(record.id)
} catch (error: unknown) {
// A concurrent retry may already own a newer fence; never clear it.
if (record.closing === closing) record.closing = undefined
throw error
}
}))
const failures = results
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')

View File

@@ -540,8 +540,15 @@ describe('PtyService ownership and lifecycle', () => {
sessions: Map<PtySessionIdType, unknown>
closeRecords(records: unknown[], reason: string): Promise<void>
}
await expect(internal.closeRecords([...internal.sessions.values()], 'test failure')).rejects.toThrow('failed to close 1 PTY session')
const records = [...internal.sessions.values()]
const firstFailure = expect(internal.closeRecords(records, 'test failure')).rejects.toThrow('failed to close 1 PTY session')
const joinedFailure = expect(internal.closeRecords(records, 'joined failure')).rejects.toThrow('failed to close 1 PTY session')
await firstFailure
await joinedFailure
b.sessions[0]!.rejectClose = false
await expect(internal.closeRecords([...internal.sessions.values()], 'retry')).resolves.toBeUndefined()
expect(b.sessions[0]!.closed).toEqual(['test failure', 'retry'])
expect(internal.sessions.size).toBe(0)
await disposePtyService(ctx)
await expect(service.spawn(owner, { type: 'stub' })).rejects.toMatchObject({ code: 'SERVICE_DISPOSING' })
})