diff --git a/src/main/persistence/terminal-topology/windowless-restart-lost-binding.test.ts b/src/main/persistence/terminal-topology/windowless-restart-lost-binding.test.ts new file mode 100644 index 00000000000..2d422737966 --- /dev/null +++ b/src/main/persistence/terminal-topology/windowless-restart-lost-binding.test.ts @@ -0,0 +1,83 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { Store } from '../loading-store/store' +import { leafIds } from './terminal-topology-window-session-fixture' +import { + emptyTerminalSessionProfile, + FIXTURE_GIT_WORKTREE_ID, + openTopologyStore, + reopenTopologyStore +} from './terminal-topology-profile-fixture' + +vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/config/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const WORKTREE = FIXTURE_GIT_WORKTREE_ID +const TAB = 'tab-cli' +const LEAF = '11111111-1111-4111-8111-111111111111' +const PTY = 'pty-live' +const directories: string[] = [] + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +/** What a serve host with no window stores for a client-created terminal, as main writes it. */ +async function windowlessHostWithTerminal(): Promise<{ store: Store; directory: string }> { + const directory = mkdtempSync(join(tmpdir(), 'orca-windowless-restart-')) + directories.push(directory) + const store = await openTopologyStore(directory, emptyTerminalSessionProfile()) + const created = await store.applyLayoutCommand({ + type: 'createTerminalTab', + workspace: WORKTREE, + tabId: TAB, + leafId: LEAF + }) + expect(created.ok).toBe(true) + await store.persistPtyBinding({ + worktreeId: WORKTREE, + tabId: TAB, + leafId: LEAF, + ptyId: PTY, + incarnationId: 'inc-1' + }) + await store.flushPendingOrThrowAsync() + return { store, directory } +} + +/** The reported loss: the pane's binding is gone; the tab, its pane tree and the PTY stay. */ +function withoutPaneBinding(session: WorkspaceSessionState): WorkspaceSessionState { + const layout = session.terminalLayoutsByTabId[TAB]! + return { + ...session, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + [TAB]: { ...layout, ptyIdsByLeafId: {} } + } + } +} + +describe('a windowless host restarted after a pane lost its terminal binding (layout-owner build)', () => { + it('keeps the tab and binds its pane again from the tab row', async () => { + const { store, directory } = await windowlessHostWithTerminal() + const stored = store.getWorkspaceSession() + expect(stored.activeWorktreeIdsOnShutdown).toBeUndefined() + store.setWorkspaceSession(withoutPaneBinding(stored)) + + const relaunched = await reopenTopologyStore(store, directory) + const session = relaunched.getWorkspaceSession() + + expect(session.tabsByWorktree[WORKTREE]?.map((row) => row.id)).toEqual([TAB]) + expect(leafIds(session.terminalLayoutsByTabId[TAB]?.root)).toEqual([LEAF]) + expect(session.terminalLayoutsByTabId[TAB]?.ptyIdsByLeafId).toEqual({ [LEAF]: PTY }) + }) +}) diff --git a/tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts b/tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts index 7047f8a6dcb..b019231ef90 100644 --- a/tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts +++ b/tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts @@ -47,6 +47,9 @@ import { const scratch = createRetentionFixtureDirectory() const fixturePath = path.join(scratch, 'serve-restart-binding-terminal.mjs') const sinkPath = path.join(scratch, 'serve-restart-binding-terminal.log') +const LAYOUT_OWNER_BUILD = existsSync( + path.join(process.cwd(), 'out', 'main', 'ORCA_LAYOUT_OWNER_MAIN') +) writeFileSync( fixturePath, @@ -634,29 +637,33 @@ test('retains a verified mirrored PTY binding through a serve restart pending sn expect(host.app.process().pid, 'The serve Electron process must actually be replaced').not.toBe( hostPidBeforeRestart ) - let pendingReceipt: PairedTerminalSnapshotReceipt | null = null - await expect - .poll( - async () => { - pendingReceipt = - (await readPairedTerminalSnapshotProbe(client!.page)).receipts.find( - (receipt) => receipt.status === 'pending-handle' - ) ?? null - return pendingReceipt - }, - { - timeout: 60_000, - message: 'Paired renderer never received the replacement host pending-handle frame' - } - ) - .not.toBeNull() - expect(pendingReceipt).toMatchObject({ - leafId: terminal.leafId, - parentTabId: terminal.parentTabId, - ptyId: terminal.ptyId, - status: 'pending-handle', - terminal: null - }) + // Layout-owner build: main's load binds the pane again from its tab row while the PTY lives, so + // the replacement host publishes it ready at once and never pending. + if (!LAYOUT_OWNER_BUILD) { + let pendingReceipt: PairedTerminalSnapshotReceipt | null = null + await expect + .poll( + async () => { + pendingReceipt = + (await readPairedTerminalSnapshotProbe(client!.page)).receipts.find( + (receipt) => receipt.status === 'pending-handle' + ) ?? null + return pendingReceipt + }, + { + timeout: 60_000, + message: 'Paired renderer never received the replacement host pending-handle frame' + } + ) + .not.toBeNull() + expect(pendingReceipt).toMatchObject({ + leafId: terminal.leafId, + parentTabId: terminal.parentTabId, + ptyId: terminal.ptyId, + status: 'pending-handle', + terminal: null + }) + } let recoveredSurface: HostSurface | null = null await expect @@ -676,6 +683,10 @@ test('retains a verified mirrored PTY binding through a serve restart pending sn ptyId: terminal.ptyId, status: 'ready' }) + if (LAYOUT_OWNER_BUILD) { + const { receipts } = await readPairedTerminalSnapshotProbe(client.page) + expect(receipts.filter((receipt) => receipt.status === 'pending-handle')).toEqual([]) + } const recoveredRemotePtyId = toRemoteRuntimePtyId(liveHandle!, client.environmentId) await waitForClientBinding(client, worktreeId, terminal, recoveredRemotePtyId)