test(layout): a serve restart that lost a pane's binding shows the terminal ready at once in the layout-owner build (#31132)

In the layout-owner build, main's load binds the pane again from its tab row while the
terminal is alive, so the replacement host publishes the pane ready and never pending.
The serve-restart spec waited for a pending frame that this build correctly never sends.
It now expects pending only in the current build, and asserts no pending frame in the
layout-owner build. A unit test pins the precondition on a windowless host: main's own
writes leave no shutdown list, and a real store load restores the binding.

Co-authored-by: E2E Test <e2e@test.local>
This commit is contained in:
Jinwoo Hong
2026-10-20 09:53:52 -04:00
committed by GitHub
co-authored by E2E Test
parent 5f1a66a5cb
commit bfc436be60
2 changed files with 117 additions and 23 deletions
@@ -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 })
})
})
@@ -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)