fix(pty): retry failed lifecycle closes
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user