diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index dab6531c425..3b9b6ae3591 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -31399,6 +31399,58 @@ describe('OrcaRuntimeService', () => { expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([]) }) + it('keeps a confirmed PTY stop successful when its exited headless tab is already absent', async () => { + const ptyId = 'headless-worker-exited-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 }) + } + }) + ) + let runtime!: OrcaRuntimeService + const kill = vi.fn((closedPtyId: string) => { + runtime.onPtyExit(closedPtyId, 143) + return true + }) + 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)).resolves.toEqual({ + handle, + tabId: 'host-tab', + ptyKilled: true + }) + expect(kill).toHaveBeenCalledWith(ptyId) + }) + it('keeps the renderer close transaction for an adopted runtime-owned tab', async () => { // The renderer pin state can be newer than the debounced session, so once adopted its live close guard must win over stale persisted metadata. const servePtyId = 'serve-adopted-1' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5e48785e74e..4fbe9b38973 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -29820,10 +29820,17 @@ export class OrcaRuntimeService { try { await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId) } catch (error) { - if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { + // The PTY exit may retire its headless tab before this late surface cleanup. + if ( + !(error instanceof Error) || + (error.message !== 'workspace_session_unavailable' && + error.message !== 'tab_not_found') + ) { throw error } - this.notifier?.closeTerminal(tabId) + if (error.message === 'workspace_session_unavailable') { + this.notifier?.closeTerminal(tabId) + } } } else { this.notifier?.closeTerminal(tabId) diff --git a/tests/e2e/windows-headless-terminal-close-lifecycle.spec.ts b/tests/e2e/windows-headless-terminal-close-lifecycle.spec.ts new file mode 100644 index 00000000000..1b345664f63 --- /dev/null +++ b/tests/e2e/windows-headless-terminal-close-lifecycle.spec.ts @@ -0,0 +1,175 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { test, expect } from './helpers/orca-app' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' +import type { RuntimeClient } from '../../src/cli/runtime/client' +import type { + RuntimeMobileSessionTabsResult, + RuntimeTerminalClose, + RuntimeTerminalCreate, + RuntimeTerminalListResult, + RuntimeTerminalRead +} from '../../src/shared/runtime-types' + +const execFileAsync = promisify(execFile) +const CHILD_PID_PREFIX = 'STA4903_CHILD_PID=' + +type WindowsProcessEvidence = { + pid: number + parentPid: number + name: string + handleCount: number | null +} + +async function queryWindowsAncestry(pid: number): Promise { + const script = [ + `$targetPid = ${pid}`, + '$rows = Get-CimInstance Win32_Process', + '$byPid = @{}', + 'foreach ($row in $rows) { $byPid[[int]$row.ProcessId] = $row }', + '$result = @()', + 'while ($byPid.ContainsKey($targetPid) -and $result.Count -lt 3) {', + ' $row = $byPid[$targetPid]', + ' $process = Get-Process -Id $targetPid -ErrorAction SilentlyContinue', + ' $result += [pscustomobject]@{ pid = [int]$row.ProcessId; parentPid = [int]$row.ParentProcessId; name = [string]$row.Name; handleCount = if ($process) { [int]$process.HandleCount } else { $null } }', + ' if ([int]$row.ParentProcessId -eq 0) { break }', + ' $targetPid = [int]$row.ParentProcessId', + '}', + '$result | ConvertTo-Json -Compress' + ].join('; ') + const { stdout } = await execFileAsync( + 'powershell.exe', + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], + { encoding: 'utf8', windowsHide: true, timeout: 10_000 } + ) + const parsed = JSON.parse(stdout.trim() || '[]') as + | WindowsProcessEvidence + | WindowsProcessEvidence[] + return Array.isArray(parsed) ? parsed : [parsed] +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +async function readChildPid(client: RuntimeClient, handle: string): Promise { + const read = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', { + terminal: handle + }) + const marker = read.result.terminal.tail.find((line) => line.includes(CHILD_PID_PREFIX)) + const value = marker?.slice(marker.indexOf(CHILD_PID_PREFIX) + CHILD_PID_PREFIX.length).trim() + const pid = Number.parseInt(value ?? '', 10) + return Number.isSafeInteger(pid) && pid > 0 ? pid : null +} + +test.describe.configure({ mode: 'serial' }) + +test('physical Windows exit wins the headless-tab close race', async ({ testRepoPath }) => { + test.skip(process.platform !== 'win32', 'Physical Windows PTY lifecycle coverage') + test.setTimeout(120_000) + const host = await launchHeadlessPairedRuntimeHost() + + try { + const client = host.client + const added = await client.call<{ repo: { id: string } }>('repo.add', { + path: testRepoPath, + kind: 'git' + }) + let worktreeId = '' + await expect + .poll( + async () => { + const listed = await client.call<{ worktrees: { id: string }[] }>('worktree.list', { + repo: `id:${added.result.repo.id}` + }) + worktreeId = listed.result.worktrees[0]?.id ?? '' + return worktreeId + }, + { message: 'Headless host did not publish the physical test worktree' } + ) + .not.toBe('') + const created = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', { + worktree: `id:${worktreeId}`, + title: 'STA-4903 physical close race', + command: `node -e "console.log('${CHILD_PID_PREFIX}' + process.pid); setInterval(() => {}, 1000)"`, + presentation: 'background' + }) + const terminal = created.result.terminal + + let childPid: number | null = null + await expect + .poll( + async () => { + childPid = await readChildPid(client, terminal.handle) + return childPid + }, + { message: 'Background PTY did not publish its physical Windows child PID' } + ) + .not.toBeNull() + const processBefore = await queryWindowsAncestry(childPid!) + const windowCountBefore = await host.app.evaluate( + ({ BrowserWindow }) => BrowserWindow.getAllWindows().length + ) + const hostTabsBefore = await client.call('session.tabs.list', { + worktree: `id:${worktreeId}` + }) + const inventoryBefore = await client.call('terminal.list', { + worktree: `id:${worktreeId}` + }) + expect(windowCountBefore).toBe(0) + expect(hostTabsBefore.result.tabs.some((tab) => tab.parentTabId === terminal.tabId)).toBe(true) + expect(inventoryBefore.result.terminals).toContainEqual( + expect.objectContaining({ handle: terminal.handle, connected: true, writable: true }) + ) + + const closeStartedAt = performance.now() + let close: RuntimeTerminalClose | null = null + let closeError: string | null = null + try { + close = ( + await client.call<{ close: RuntimeTerminalClose }>('terminal.close', { + terminal: terminal.handle + }) + ).result.close + } catch (error) { + closeError = error instanceof Error ? error.message : String(error) + } + const closeElapsedMs = performance.now() - closeStartedAt + + await expect + .poll(() => isProcessAlive(childPid!), { message: 'Windows child survived close' }) + .toBe(false) + let inventoryAfter: RuntimeTerminalListResult | null = null + await expect + .poll( + async () => { + inventoryAfter = ( + await client.call('terminal.list', { + worktree: `id:${worktreeId}` + }) + ).result + return inventoryAfter.terminals.some((entry) => entry.handle === terminal.handle) + }, + { message: 'Closed terminal remained in the authoritative runtime inventory' } + ) + .toBe(false) + const hostTabsAfter = await client.call('session.tabs.list', { + worktree: `id:${worktreeId}` + }) + + console.log( + `[sta4903] ${JSON.stringify({ terminal, processBefore, windowCountBefore, hostTabIdsBefore: hostTabsBefore.result.tabs.map((tab) => tab.parentTabId), close, closeError, closeElapsedMs, processAliveAfter: isProcessAlive(childPid!), hostTabIdsAfter: hostTabsAfter.result.tabs.map((tab) => tab.parentTabId), inventoryHandlesAfter: inventoryAfter?.terminals.map((entry) => entry.handle) })}` + ) + + expect(closeError).toBeNull() + expect(close).toMatchObject({ handle: terminal.handle, ptyKilled: expect.any(Boolean) }) + expect(hostTabsAfter.result.tabs.some((tab) => tab.parentTabId === terminal.tabId)).toBe(false) + } finally { + await host.dispose() + } +})