From ea1cb2f18fc9b3d12e8621de30f23ad9b973b737 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:15:42 -0700 Subject: [PATCH] fix(terminal): a WSL bridge pane that lost its distro keeps its session across a relaunch (#34200) #34095 turns a bridge pane's exit into host loss (code -1) when its distro is gone, so the window keeps the tab and the history stays unended. But main's exit handling still retired the pane's surface from the saved workspace session, as for any provider-observed exit, so the next launch found no session id for the tab and started a fresh shell instead of cold-restoring. The adapter now labels that exit hostLost, the pty listener forwards it, and the runtime keeps the surface, as it already does for a sleep or an SSH loss. Co-authored-by: Claude --- ...daemon-pty-adapter-wsl-distro-loss.test.ts | 3 +- src/main/daemon/daemon-pty-adapter.ts | 6 +- .../daemon/daemon-pty-listener-emission.ts | 5 +- src/main/daemon/daemon-pty-router-events.ts | 2 + src/main/ipc/pty/provider/bind-listeners.ts | 3 +- src/main/ipc/pty/pty-host-lost-exit.test.ts | 90 +++++++++++++++++++ src/main/providers/pty-provider-contract.ts | 2 + .../terminal/lifecycle/pty-exit-lifecycle.ts | 8 +- .../wsl-distro-loss-exit-retention.test.ts | 47 ++++++++++ 9 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 src/main/ipc/pty/pty-host-lost-exit.test.ts create mode 100644 src/main/runtime/wsl-distro-loss-exit-retention.test.ts diff --git a/src/main/daemon/daemon-pty-adapter-wsl-distro-loss.test.ts b/src/main/daemon/daemon-pty-adapter-wsl-distro-loss.test.ts index 63f767c3766..21e3765da48 100644 --- a/src/main/daemon/daemon-pty-adapter-wsl-distro-loss.test.ts +++ b/src/main/daemon/daemon-pty-adapter-wsl-distro-loss.test.ts @@ -80,7 +80,7 @@ describe('a WSL bridge pane exit', () => { subprocesses[0]!._simulateExit(1) await waitFor(() => exits.length > 0) - expect(exits).toEqual([{ id, code: -1, incarnationId: expect.any(String) }]) + expect(exits).toEqual([{ id, code: -1, incarnationId: expect.any(String), hostLost: true }]) // Ending its history would refuse the cold restore the kept tab needs. expect(clear).toHaveBeenCalledWith(id, -1, expect.any(String), { closeHistory: false }) }) @@ -93,6 +93,7 @@ describe('a WSL bridge pane exit', () => { await waitFor(() => exits.length > 0) expect(exits[0]).toMatchObject({ id, code: 1, cause: { kind: 'exited', exitCode: 1 } }) + expect(exits[0]).not.toHaveProperty('hostLost') }) it('stays a normal exit when the running list cannot answer', async () => { diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 31e741c921e..f626f3b538e 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -253,7 +253,11 @@ export class DaemonPtyAdapter extends DaemonPtyDaemonRecovery implements IPtyPro this.finishExit(sessionId, exit) return } - const hostLoss = { code: WSL_DISTRO_LOSS_EXIT_CODE, incarnationId: exit.incarnationId } + const hostLoss = { + code: WSL_DISTRO_LOSS_EXIT_CODE, + incarnationId: exit.incarnationId, + hostLost: true as const + } this.hostLossSessionIds.add(sessionId) this.clearExitedSessionState(sessionId, hostLoss.code, hostLoss.incarnationId, { closeHistory: false diff --git a/src/main/daemon/daemon-pty-listener-emission.ts b/src/main/daemon/daemon-pty-listener-emission.ts index a40eda35b74..1a0db224be8 100644 --- a/src/main/daemon/daemon-pty-listener-emission.ts +++ b/src/main/daemon/daemon-pty-listener-emission.ts @@ -7,13 +7,14 @@ export function emitPtyListeners(listeners: readonly T[], emit: (listener: T) export function createPtyExitPayload( id: string, - { code, incarnationId, cause, replacedByRestart }: Omit + { code, incarnationId, cause, replacedByRestart, hostLost }: Omit ): DaemonPtyRouterExitEvent { return { id, code, ...(incarnationId ? { incarnationId } : {}), ...(cause ? { cause } : {}), - ...(replacedByRestart ? { replacedByRestart } : {}) + ...(replacedByRestart ? { replacedByRestart } : {}), + ...(hostLost ? { hostLost } : {}) } } diff --git a/src/main/daemon/daemon-pty-router-events.ts b/src/main/daemon/daemon-pty-router-events.ts index eb12c2d7cfe..e454f22fc3a 100644 --- a/src/main/daemon/daemon-pty-router-events.ts +++ b/src/main/daemon/daemon-pty-router-events.ts @@ -17,4 +17,6 @@ export type DaemonPtyRouterExitEvent = { cause?: TerminalExitCause /** Main ended the session to restart the daemon; the pane stays for its next process. */ replacedByRestart?: true + /** The session's host went away (a WSL bridge pane's distro stopped); its pane and history stay to cold-restore. */ + hostLost?: true } diff --git a/src/main/ipc/pty/provider/bind-listeners.ts b/src/main/ipc/pty/provider/bind-listeners.ts index 0323d6995ea..e217a40c970 100644 --- a/src/main/ipc/pty/provider/bind-listeners.ts +++ b/src/main/ipc/pty/provider/bind-listeners.ts @@ -106,7 +106,8 @@ export function bindProviderListeners(session: PtyIpcSession): void { session.runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId, { providerExitObserved: true, ...(viewShown ? { viewShown } : {}), - ...(payload.cause ? { cause: payload.cause } : {}) + ...(payload.cause ? { cause: payload.cause } : {}), + ...(payload.hostLost ? { hostLost: true } : {}) }) } // Control replies may overtake stream data; physical exit must still retire late output. diff --git a/src/main/ipc/pty/pty-host-lost-exit.test.ts b/src/main/ipc/pty/pty-host-lost-exit.test.ts new file mode 100644 index 00000000000..aa7f5bf0cbd --- /dev/null +++ b/src/main/ipc/pty/pty-host-lost-exit.test.ts @@ -0,0 +1,90 @@ +import { expect, it, vi, type MockInstance } from 'vitest' +import { setupPtyIpcSuite } from '../pty-ipc-test-harness' +import { registerPtyHandlers } from './register-handlers' +import { getLocalPtyProvider } from './provider/registry' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' + +vi.mock('electron', () => import('../pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('../pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('../pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('../pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../../opencode/hook-service', () => + import('../pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../../mimo/hook-service', () => + import('../pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../../agent-hooks/server', () => + import('../pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../../pi/titlebar-extension-service', () => + import('../pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../../pwsh', () => import('../pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../../wsl', async (importOriginal) => + (await import('../pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../../telemetry/client', () => + import('../pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../../telemetry/classify-error', () => + import('../pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../../cli/linux-terminal-orca-cli-shim', () => + import('../pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../../memory/pty-registry', () => + import('../pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../../agent-hooks/migration-unsupported-pty-state', () => + import('../pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../../codex/codex-pane-account-registry', () => + import('../pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../../codex/codex-state-db-backfill-recovery', () => + import('../pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +const { handlers, mainWindow } = setupPtyIpcSuite() + +type Exit = { id: string; code: number; hostLost?: true } + +function registerWithProviderExits(): { + onPtyExit: MockInstance + emitExit: (exit: Exit) => void +} { + const exitListeners = new Set<(payload: Exit) => void>() + vi.spyOn(getLocalPtyProvider(), 'onExit').mockImplementation((listener) => { + exitListeners.add(listener) + return () => exitListeners.delete(listener) + }) + const runtime = new OrcaRuntimeService() + const onPtyExit = vi.spyOn(runtime, 'onPtyExit').mockImplementation(() => {}) + handlers.clear() + registerPtyHandlers(mainWindow, runtime) + return { + onPtyExit, + emitExit: (exit) => exitListeners.forEach((listener) => listener(exit)) + } +} + +// The runtime keeps a lost-host pane's binding only if the provider's label reaches it. +it('tells the runtime a WSL bridge pane lost its distro', () => { + const { onPtyExit, emitExit } = registerWithProviderExits() + emitExit({ id: 'bridge-pty', code: -1, hostLost: true }) + expect(onPtyExit).toHaveBeenCalledWith('bridge-pty', -1, undefined, { + providerExitObserved: true, + hostLost: true + }) +}) + +it('never labels an ordinary exit as host loss', () => { + const { onPtyExit, emitExit } = registerWithProviderExits() + emitExit({ id: 'bridge-pty', code: 1 }) + expect(onPtyExit).toHaveBeenCalledWith('bridge-pty', 1, undefined, { + providerExitObserved: true + }) +}) diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 930636c9461..ebf8dd13237 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -221,6 +221,8 @@ export type IPtyProvider = { cause?: TerminalExitCause /** Main ended the session to restart the daemon; the pane stays for its next process. */ replacedByRestart?: true + /** The session's host went away; its pane and history stay to cold-restore. */ + hostLost?: true /** The emitting provider already reported this exit to the runtime through configure().onExit. */ runtimeFed?: true }) => void diff --git a/src/main/runtime/terminal/lifecycle/pty-exit-lifecycle.ts b/src/main/runtime/terminal/lifecycle/pty-exit-lifecycle.ts index 475bf22b81b..d6011d9cb73 100644 --- a/src/main/runtime/terminal/lifecycle/pty-exit-lifecycle.ts +++ b/src/main/runtime/terminal/lifecycle/pty-exit-lifecycle.ts @@ -65,6 +65,8 @@ export abstract class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDi providerExitObserved?: boolean /** A window's view of the pane was showing when it ended (layout-owner build). */ viewShown?: boolean + /** Its host went away (a WSL bridge pane's distro stopped): keep the pane to cold-restore, as after a daemon crash. */ + hostLost?: boolean } = {} ): void | Promise { const pty = this.ptysById.get(ptyId) @@ -242,7 +244,11 @@ export abstract class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDi this.resolvePtyExitWaiters(pty, ptyId) this.pruneDisconnectedPtyTranscript(pty) } - if (preservesIntentionallyStoppedSurface || preservesAbnormalSshSurface) { + if ( + preservesIntentionallyStoppedSurface || + preservesAbnormalSshSurface || + options.hostLost === true + ) { // Why: relay loss is recoverable; keep the HUB-owned pane addressable through the bounded reconnect grace. this.touchMobileSessionSnapshotsForPty(ptyId, { immediate: true }) } else { diff --git a/src/main/runtime/wsl-distro-loss-exit-retention.test.ts b/src/main/runtime/wsl-distro-loss-exit-retention.test.ts new file mode 100644 index 00000000000..5fa290d23aa --- /dev/null +++ b/src/main/runtime/wsl-distro-loss-exit-retention.test.ts @@ -0,0 +1,47 @@ +import { afterEach, expect, it } from 'vitest' +import { + ACK_INCARNATION, + ACK_LEAF, + ACK_SECOND_LEAF, + ACK_TAB, + createAcknowledgedTabRetirementFixture +} from './acknowledged-terminal-tab-retirement-fixture' + +const fixtures: ReturnType[] = [] +afterEach(async () => { + for (const fixture of fixtures.splice(0)) { + await fixture.dispose() + } +}) + +async function boundFixture() { + const f = createAcknowledgedTabRetirementFixture(true) + fixtures.push(f) + await f.store.flushPendingOrThrowAsync() + return f +} + +// verify-15b 5e: after `wsl --terminate` the kept tabs opened fresh sessions on relaunch. +it('keeps a pane whose WSL distro went away bound to its session across a relaunch', async () => { + const f = await boundFixture() + await f.runtime.onPtyExit('pty-a', -1, ACK_INCARNATION, { + providerExitObserved: true, + hostLost: true + }) + await f.quit() + const saved = f.readDisk().workspaceSession + expect(saved.terminalLayoutsByTabId[ACK_TAB]?.ptyIdsByLeafId).toEqual({ + [ACK_LEAF]: 'pty-a', + [ACK_SECOND_LEAF]: 'pty-b' + }) + expect(saved.terminalPtyIncarnationsByPaneKey?.[`${ACK_TAB}:${ACK_LEAF}`]).toBe(ACK_INCARNATION) +}) + +it('still retires the pane of a shell that exited on its own', async () => { + const f = await boundFixture() + await f.runtime.onPtyExit('pty-a', 1, ACK_INCARNATION, { providerExitObserved: true }) + await f.quit() + expect(f.readDisk().workspaceSession.terminalLayoutsByTabId[ACK_TAB]?.ptyIdsByLeafId).toEqual({ + [ACK_SECOND_LEAF]: 'pty-b' + }) +})