mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,13 +7,14 @@ export function emitPtyListeners<T>(listeners: readonly T[], emit: (listener: T)
|
||||
|
||||
export function createPtyExitPayload(
|
||||
id: string,
|
||||
{ code, incarnationId, cause, replacedByRestart }: Omit<DaemonPtyRouterExitEvent, 'id'>
|
||||
{ code, incarnationId, cause, replacedByRestart, hostLost }: Omit<DaemonPtyRouterExitEvent, 'id'>
|
||||
): DaemonPtyRouterExitEvent {
|
||||
return {
|
||||
id,
|
||||
code,
|
||||
...(incarnationId ? { incarnationId } : {}),
|
||||
...(cause ? { cause } : {}),
|
||||
...(replacedByRestart ? { replacedByRestart } : {})
|
||||
...(replacedByRestart ? { replacedByRestart } : {}),
|
||||
...(hostLost ? { hostLost } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<OrcaRuntimeService['onPtyExit']>
|
||||
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
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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<typeof createAcknowledgedTabRetirementFixture>[] = []
|
||||
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'
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user