mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user