fix(terminal): tear down runtime-owned headless PTYs on whole-tab close (#9114)

* fix(terminal): tear down runtime-owned headless PTYs on whole-tab close

#8958 placed the closeTerminalTab renderer relay ahead of the authoritative
headless teardown in closeMobileSessionTab. Production always wires
closeTerminalTab, so the teardown became dead code for whole-parent closes
whenever a window is attached. The relay resolves close targets only from
renderer state (resolveTerminalCloseTarget), which never contains a
runtime-owned headless tab, so it acks success without killing the PTY —
leaking SSH-durable/serve PTYs and letting syncMobileSessionTabs republish
the "closed" tab (the exact case the removed comment warned about).

Run the isRuntimeOwnedHeadlessMobileTab teardown before the relay so headless
tabs are killed and pruned authoritatively even with a renderer attached;
normal adopted tabs still take the durable closeTerminalTab path. Add a
regression test that wires both notifiers (prior tests covered only the
no-notifier and in-graph cases).

* fix(terminal): preserve adopted tab close guards

* fix(runtime): preserve headless close durability on notifier failure
This commit is contained in:
Brennan Benson
2026-07-16 18:41:49 -07:00
committed by GitHub
parent f6f2561623
commit 91ece6ca7e
2 changed files with 157 additions and 9 deletions
+128
View File
@@ -19722,6 +19722,134 @@ describe('OrcaRuntimeService', () => {
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined()
})
it('durably tears down a runtime-owned SSH headless tab when renderer cleanup fails', async () => {
// #8958 regression: the acknowledged renderer relay cannot see headless tabs;
// its advisory fallback must not prevent authoritative teardown or flushing.
const persistedPtyId = 'ssh:ssh-1@@relay-pty'
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal({
tabsByWorktree: {
[TEST_WORKTREE_ID]: [
{
id: 'host-tab',
ptyId: persistedPtyId,
worktreeId: TEST_WORKTREE_ID,
title: 'Remote Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: persistedPtyId })
}
})
)
const kill = vi.fn(() => true)
const flushOrThrow = vi.fn()
const rendererError = new Error('renderer unavailable')
const closeTerminal = vi.fn(() => {
throw rendererError
})
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const closeTerminalTab = vi.fn(async () => {})
const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never)
runtime.setPtyController({
write: () => true,
kill,
getForegroundProcess: async () => null,
listProcesses: async () => [{ id: persistedPtyId, cwd: TEST_WORKTREE_PATH, title: 'Remote' }]
})
runtime.setNotifier({ closeTerminal, closeTerminalTab } as never)
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
expect(closeTerminalTab).not.toHaveBeenCalled()
expect(kill).toHaveBeenCalledWith(persistedPtyId)
expect(closeTerminal).toHaveBeenCalledWith('host-tab')
expect(flushOrThrow).toHaveBeenCalledTimes(1)
expect(warn).toHaveBeenCalledWith(
'[runtime] failed to notify renderer after headless terminal close',
{ parentTabId: 'host-tab', error: rendererError }
)
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined()
})
it('keeps the renderer close transaction for an adopted runtime-owned tab', async () => {
// The renderer pin state can be newer than the debounced workspace session.
// Once adopted, its live close guard must win over stale persisted metadata.
const servePtyId = 'serve-adopted-1'
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal({
tabsByWorktree: {
[TEST_WORKTREE_ID]: [
{
id: 'host-tab',
ptyId: servePtyId,
worktreeId: TEST_WORKTREE_ID,
title: 'Adopted Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
isPinned: false
}
]
},
terminalLayoutsByTabId: {
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: servePtyId })
}
})
)
const kill = vi.fn(() => true)
const closeTerminal = vi.fn()
const closeTerminalTab = vi.fn(async () => {
throw new Error('terminal_tab_pinned')
})
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setPtyController({
write: () => true,
kill,
getForegroundProcess: async () => null,
listProcesses: async () => [{ id: servePtyId, cwd: TEST_WORKTREE_PATH, title: 'Adopted' }]
})
runtime.setNotifier({ closeTerminal, closeTerminalTab } as never)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'host-tab',
worktreeId: TEST_WORKTREE_ID,
title: 'Adopted Terminal',
activeLeafId: HEADLESS_LEAF_ID,
layout: null
}
],
leaves: [
{
tabId: 'host-tab',
worktreeId: TEST_WORKTREE_ID,
leafId: HEADLESS_LEAF_ID,
paneRuntimeId: 1,
ptyId: servePtyId
}
]
})
await expect(
runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
).rejects.toThrow('terminal_tab_pinned')
expect(closeTerminalTab).toHaveBeenCalledWith('host-tab')
expect(closeTerminal).not.toHaveBeenCalled()
expect(kill).not.toHaveBeenCalled()
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1)
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined()
})
it('materializes hydrated pending headless terminals with the persisted session identity', async () => {
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal()
+29 -9
View File
@@ -4685,26 +4685,33 @@ export class OrcaRuntimeService {
(candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId
).length
const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1
// Why: a runtime-owned headless tab is absent from renderer state, so the
// closeTerminalTab relay below would ack success without killing its PTY,
// and syncMobileSessionTabs would republish the "closed" tab. Only bypass
// the relay when no renderer owns the parent: an adopted tab needs the
// renderer's live pin guard and durable close transaction.
if (closingWholeParent && !this.tabs.has(tab.parentTabId)) {
this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab)
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
this.store?.flushOrThrow?.()
return { closed: true }
}
if (closingWholeParent && this.notifier?.closeTerminalTab) {
// Why: whole-tab close is a lifecycle transaction. The renderer reply
// arrives only after canonical retirement and a forced session flush.
await this.notifier.closeTerminalTab(tab.parentTabId)
return { closed: true }
}
if (!this.notifier?.closeTerminal) {
// Why: notifier implementations without the acknowledged relay may expose
// only raw pane close. Runtime-owned parents still need de-persist + kill.
if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) {
this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab)
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
this.store?.flushOrThrow?.()
return { closed: true }
}
// Why: a runtime-owned headless tab whose whole parent is being closed must
// be torn down authoritatively even with a renderer attached — kill the
// PTY, drop the persisted binding, and prune+emit — or syncMobileSessionTabs
// keeps republishing the "closed" tab with a live PTY. Best-effort notify the
// renderer too so any adopted pane closes (no dead pane). A single split leaf
// (exact id, multi-leaf parent) keeps the per-leaf path so siblings survive.
if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) {
if (!this.notifier?.closeTerminal) {
this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab)
this.notifier?.closeTerminal(tab.parentTabId)
this.store?.flushOrThrow?.()
return { closed: true }
}
@@ -4732,6 +4739,19 @@ export class OrcaRuntimeService {
return { closed: true }
}
private notifyRendererOfHeadlessTerminalClose(parentTabId: string): void {
// Why: this relay is advisory after main owns teardown; renderer failure must
// not prevent the authoritative session flush or turn the close into failure.
try {
this.notifier?.closeTerminal(parentTabId)
} catch (error) {
console.warn('[runtime] failed to notify renderer after headless terminal close', {
parentTabId,
error
})
}
}
private async closeHeadlessMobileBrowserTab(
worktreeId: string,
snapshot: RuntimeMobileSessionTabsSnapshot,