diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 02d7b7ee790..42d1855decb 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3264,7 +3264,7 @@ "file": "src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts", "assertions": [ "65 exit-before-handler identities evict the oldest and retain the newest", - "a reused PTY id refreshes recency, delivers only its latest exit once, and filters older-lifecycle data and exits by cursor", + "a reused PTY id refreshes both data and exit recency, delivers only its latest exit once, and filters older-lifecycle data and exits by cursor", "warning identities are released with exact data-map eviction so warning state cannot grow independently" ] }, @@ -3272,7 +3272,7 @@ "file": "src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts", "assertions": [ "pre-handler data drains before its exit", - "fresh PTY-id reuse filters old lifecycle events while explicit daemon reattach preserves pre-connect backlog", + "fresh PTY-id reuse and failed-reattach fresh fallback filter old lifecycle events while explicit daemon reattach preserves pre-connect backlog", "a registered exit handler remains authoritative while 65 orphan exits churn through the bounded buffer" ] }, @@ -3291,7 +3291,7 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts src/renderer/src/lib/launch-agent-background-session.test.ts src/renderer/src/lib/launch-worktree-background-terminals.test.ts", "result": "passed", "durationSeconds": 1, - "summary": "5 test files passed, 112 tests passed on main@20ebe858af plus the #7695 candidate. The current-main repro retained the 65th oldest exit (received code 0 instead of eviction); the bounded candidate evicts it and preserves all ordering/reuse cases." + "summary": "5 test files passed, 114 tests passed on main@20ebe858af plus the #7695 candidate. Main@20ebe858af retained the 65th oldest exit (received code 0 instead of eviction); the bounded candidate evicts it and preserves all ordering/reuse cases." } ], "runtimeBudget": { diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts index 811b9cebab1..0dc36efbc7a 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts @@ -368,6 +368,38 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { expect(events).toEqual(['data:new-data', 'exit:2']) }) + it('filters an older lifecycle when a failed reattach spawns fresh with the same id', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const { ensurePtyDispatcher } = await import('./pty-dispatcher') + const events: string[] = [] + let resolveSpawn: (value: { id: string }) => void = () => {} + + ensurePtyDispatcher() + dispatcherCallback?.({ id: 'pty-reattach-fallback', data: 'old-data' }) + exitDispatcherCallback?.({ id: 'pty-reattach-fallback', code: 1 }) + ;(window.api.pty.spawn as ReturnType).mockReturnValueOnce( + new Promise((resolve) => { + resolveSpawn = resolve + }) + ) + + const transport = createIpcPtyTransport() + const connectPromise = transport.connect({ + url: '', + sessionId: 'pty-reattach-fallback', + callbacks: { + onData: (data) => events.push(`data:${data}`), + onExit: (code) => events.push(`exit:${code}`) + } + }) + dispatcherCallback?.({ id: 'pty-reattach-fallback', data: 'fresh-data' }) + exitDispatcherCallback?.({ id: 'pty-reattach-fallback', code: 2 }) + resolveSpawn({ id: 'pty-reattach-fallback' }) + await connectPromise + + expect(events).toEqual(['data:fresh-data', 'exit:2']) + }) + it('preserves pre-connect backlog for an explicit daemon reattach', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const { ensurePtyDispatcher } = await import('./pty-dispatcher') diff --git a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts index d30fadc58d7..3a2bc3ceb17 100644 --- a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts @@ -20,6 +20,10 @@ const WARN_PTY_IDS = Array.from( { length: PRE_HANDLER_PTY_MAX_PTYS + 1 }, (_, index) => `pty-pre-handler-warn-${index}` ) +const DATA_PTY_IDS = Array.from( + { length: PRE_HANDLER_PTY_MAX_PTYS + 1 }, + (_, index) => `pty-pre-handler-data-${index}` +) describe('pre-handler PTY buffer', () => { afterEach(() => { @@ -32,6 +36,9 @@ describe('pre-handler PTY buffer', () => { for (const ptyId of WARN_PTY_IDS) { clearPreHandlerPtyState(ptyId) } + for (const ptyId of DATA_PTY_IDS) { + clearPreHandlerPtyState(ptyId) + } }) it('does not rescan historical chunks while buffering small startup output', () => { @@ -128,6 +135,26 @@ describe('pre-handler PTY buffer', () => { expect(reusedHandler).toHaveBeenCalledWith(99) }) + it('refreshes data recency when a reused PTY id receives current lifecycle output', () => { + bufferPreHandlerPtyData(DATA_PTY_IDS[0], 'old-data') + for (let index = 1; index < PRE_HANDLER_PTY_MAX_PTYS; index += 1) { + bufferPreHandlerPtyData(DATA_PTY_IDS[index], `data-${index}`) + } + + bufferPreHandlerPtyData(DATA_PTY_IDS[0], 'current-data') + bufferPreHandlerPtyData(DATA_PTY_IDS.at(-1)!, 'newest-data') + + const oldestHandler = vi.fn() + drainPreHandlerPtyData(DATA_PTY_IDS[1], oldestHandler) + expect(oldestHandler).not.toHaveBeenCalled() + const reusedHandler = vi.fn() + drainPreHandlerPtyData(DATA_PTY_IDS[0], reusedHandler) + expect(reusedHandler.mock.calls).toEqual([ + ['old-data', undefined], + ['current-data', undefined] + ]) + }) + it('clears an old lifecycle before a PTY id is reused and drains the new exit once', () => { const ptyId = EXIT_PTY_IDS[0] bufferPreHandlerPtyExit(ptyId, 1) diff --git a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts index 5883be0a222..96da1ba98cd 100644 --- a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts +++ b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts @@ -58,6 +58,11 @@ export function bufferPreHandlerPtyData(ptyId: string, data: string, meta?: PtyD if (!state) { state = { chunks: [], head: 0, bytes: 0 } preHandlerPtyData.set(ptyId, state) + } else { + // Why: relay-style providers can reuse ids. New output makes this identity + // current, so later churn must evict an actually older buffered PTY. + preHandlerPtyData.delete(ptyId) + preHandlerPtyData.set(ptyId, state) } state.chunks.push({ data: chunk.data, diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index b33a5ed75cd..9c92c6e0d59 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -691,7 +691,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } try { - const freshSpawnCursor = options.sessionId ? undefined : capturePreHandlerPtyEventCursor() + // Why: a requested reattach can fall back to a fresh PTY with the same + // id. Keep the cursor unless the provider confirms an actual restore. + const freshSpawnCursor = capturePreHandlerPtyEventCursor() // Why: missing-cwd recovery is only valid for fresh local spawns — // reattach must keep the session's exact cwd and SSH-tagged transports // resolve cwd on the remote host.