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' + }) +})