fix(runtime): settle exited headless terminal closes

This commit is contained in:
OrcaWin
2026-08-19 23:51:36 -07:00
parent 4daace6251
commit 6d54be27b0
3 changed files with 236 additions and 2 deletions
+52
View File
@@ -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'
+9 -2
View File
@@ -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)
@@ -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<WindowsProcessEvidence[]> {
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<number | null> {
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<RuntimeMobileSessionTabsResult>('session.tabs.list', {
worktree: `id:${worktreeId}`
})
const inventoryBefore = await client.call<RuntimeTerminalListResult>('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<RuntimeTerminalListResult>('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<RuntimeMobileSessionTabsResult>('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()
}
})