diff --git a/.github/workflows/windows-terminal-restart-e2e.yml b/.github/workflows/windows-terminal-restart-e2e.yml index 6a47b10a1a4..a3df3f2d639 100644 --- a/.github/workflows/windows-terminal-restart-e2e.yml +++ b/.github/workflows/windows-terminal-restart-e2e.yml @@ -59,7 +59,8 @@ jobs: pnpm run test:e2e tests/e2e/restart-restore-terminal-input.spec.ts tests/e2e/terminal-restart-persistence.spec.ts - --grep "clean restart with a live daemon session|cold-restored pane accepts typing|daemon snapshot relaunch preserves" + tests/e2e/windows-headless-terminal-close-lifecycle.spec.ts + --grep "clean restart with a live daemon session|cold-restored pane accepts typing|daemon snapshot relaunch preserves|physical Windows exit wins the headless-tab close race" --workers=1 - name: Upload Playwright traces diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3b9b6ae3591..ab0e866a253 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -31441,7 +31441,6 @@ describe('OrcaRuntimeService', () => { runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) const handle = runtime.preAllocateHandleForPty(ptyId) - vi.spyOn(runtime, 'closeMobileSessionTab').mockRejectedValue(new Error('tab_not_found')) await expect(runtime.closeTerminal(handle)).resolves.toEqual({ handle, @@ -31449,6 +31448,52 @@ describe('OrcaRuntimeService', () => { ptyKilled: true }) expect(kill).toHaveBeenCalledWith(ptyId) + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([]) + }) + + it('does not mask a missing durable tab while its headless surface remains live', async () => { + const ptyId = 'headless-worker-live-tab' + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Headless worker', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: ptyId }) + } + }) + ) + const kill = vi.fn(() => false) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.registerPty(ptyId, TEST_WORKTREE_ID, null, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const handle = runtime.preAllocateHandleForPty(ptyId) + vi.spyOn(runtime, 'closeMobileSessionTab').mockRejectedValue(new Error('tab_not_found')) + + await expect(runtime.closeTerminal(handle)).rejects.toThrow('tab_not_found') + expect(kill).toHaveBeenCalledWith(ptyId) + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).not.toEqual([]) }) it('keeps the renderer close transaction for an adopted runtime-owned tab', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 67be18d694f..6d22603a132 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -29820,16 +29820,17 @@ export class OrcaRuntimeService { try { await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId) } catch (error) { - // Exit handling or another host-tab transaction may retire this surface first. - if ( - !(error instanceof Error) || - (error.message !== 'workspace_session_unavailable' && - error.message !== 'tab_not_found') - ) { + if (!(error instanceof Error)) { throw error } if (error.message === 'workspace_session_unavailable') { this.notifier?.closeTerminal(tabId) + } else if ( + error.message !== 'tab_not_found' || + this.findMobileTerminalSurface(pty.pty.worktreeId, tabId) + ) { + // Only absence in both persistence and the live publication proves another owner finished teardown. + throw error } } } else { diff --git a/tests/e2e/windows-headless-terminal-close-lifecycle.spec.ts b/tests/e2e/windows-headless-terminal-close-lifecycle.spec.ts index 1166074eed9..e95633bfe89 100644 --- a/tests/e2e/windows-headless-terminal-close-lifecycle.spec.ts +++ b/tests/e2e/windows-headless-terminal-close-lifecycle.spec.ts @@ -118,7 +118,7 @@ test('physical Windows exit wins the headless-tab close race', async ({ testRepo const processBefore = await queryWindowsAncestry(childPid!) expect(processBefore).toMatchObject([ { pid: childPid, name: 'node.exe' }, - { name: 'pwsh.exe' }, + { name: expect.stringMatching(/^(?:pwsh|powershell)\.exe$/i) }, { name: 'electron.exe' } ]) const ptyShellPid = processBefore[1]!.pid