fix(runtime): keep listed handles when graph sync learns a PTY incarnation

reconcilePtyIncarnationHandles compared a null retained incarnation against
the learned one and staled the handle. Daemon-hosted PTYs are recorded from
first output before the spawn commit reports an incarnation, so on Windows
`orca terminal create` returned a handle that was stale by the next graph
publish. Treat null-to-known as un-fenced like every other site; keep the
known-to-different and preallocated-handle invalidations.
This commit is contained in:
Neil
2026-09-14 22:21:57 -07:00
parent f92076aefb
commit c2f597dd2a
2 changed files with 42 additions and 7 deletions
@@ -54,16 +54,17 @@ export class OrcaRuntimeWithBindPtyIncarnationHandle extends OrcaRuntimeWithBuil
for (const [ptyId, retained] of this.handleByPtyIncarnation) {
const pty = this.ptysById.get(ptyId)
const leaves = this.getLeavesForPty(ptyId)
if (!pty || leaves.length !== 1) {
// Why: a handle issued before the host reported the incarnation is un-fenced, so
// learning it is not a replacement; only a known-to-different incarnation is.
const incarnationReplaced =
retained.incarnationId !== null &&
pty !== undefined &&
pty.incarnationId !== retained.incarnationId
if (!pty || incarnationReplaced || leaves.length !== 1 || this.handleByPtyId.has(ptyId)) {
this.invalidatePtyIncarnationHandle(ptyId)
continue
}
// PTY startup can replace the shell incarnation (for example Git Bash
// entering cmd.exe on Windows). Keep the caller's preallocated handle
// bound to the live PTY instead of making the create receipt stale.
if (pty.incarnationId !== retained.incarnationId) {
retained.incarnationId = pty.incarnationId
}
retained.incarnationId = pty.incarnationId
this.bindPtyIncarnationHandle(retained, leaves[0])
}
}
@@ -107,6 +107,40 @@ describe('runtime terminal handle incarnation fencing', () => {
await expect(runtime.readTerminal(handle)).resolves.toMatchObject({ handle, status: 'running' })
})
it('keeps a listed handle when graph sync learns the incarnation after issue', async () => {
// Daemon-hosted PTYs are recorded from first output before the spawn commit reports an
// incarnation, so the handle is issued un-fenced and must survive learning it.
const { runtime } = makeRuntime()
runtime.registerPty(PTY_ID, WORKTREE_ID, 'target', { tabId: TAB_ID, leafId: LEAF_ID })
syncGraph(runtime)
const [listed] = (await runtime.listTerminals()).terminals
register(runtime, 'incarnation-learned')
syncGraph(runtime)
await expect(runtime.readTerminal(listed.handle)).resolves.toMatchObject({
handle: listed.handle,
status: 'running'
})
})
it('stales a listed handle when graph sync sees a replaced incarnation', async () => {
const { runtime } = makeRuntime()
register(runtime, 'incarnation-old')
syncGraph(runtime)
const [listed] = (await runtime.listTerminals()).terminals
// Rotate the record directly so reconcile is the only fence exercised.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test reaches the runtime's protected pty record map to bypass the registerPty fence.
const internals = runtime as unknown as {
ptysById: Map<string, { incarnationId: string | null }>
}
internals.ptysById.get(PTY_ID)!.incarnationId = 'incarnation-new'
syncGraph(runtime)
await expect(runtime.readTerminal(listed.handle)).rejects.toThrow('terminal_handle_stale')
})
it('invalidates a direct handle when a reused PTY id gets a new incarnation', async () => {
const { runtime, writes } = makeRuntime()
const staleHandle = runtime.preAllocateHandleForPty(PTY_ID)