diff --git a/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts b/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts index 2c9d7b653fc..6ab9eec8563 100644 --- a/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts +++ b/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts @@ -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]) } } diff --git a/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts b/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts index 9765465fdf0..bac59e614b2 100644 --- a/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts +++ b/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts @@ -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 + } + 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)