mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix(runtime): preserve split siblings during close race
This commit is contained in:
@@ -571,6 +571,15 @@ jobs:
|
||||
src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts
|
||||
src/shared/secure-file-fsync-flags.test.ts
|
||||
|
||||
- name: Build Electron app for Windows terminal lifecycle E2E
|
||||
run: pnpm exec electron-vite build --mode e2e
|
||||
|
||||
- name: Run Windows terminal close lifecycle regression
|
||||
env:
|
||||
SKIP_BUILD: '1'
|
||||
ORCA_E2E_FORWARD_APP_LOGS: '1'
|
||||
run: pnpm run test:e2e tests/e2e/windows-headless-terminal-close-lifecycle.spec.ts --workers=1
|
||||
|
||||
# Why the :parallel variant: identical to build:release except the three
|
||||
# electron-vite targets overlap instead of running back to back. The Linux package
|
||||
# job already packages and smoke-tests an AppImage built that way.
|
||||
|
||||
@@ -31475,9 +31475,10 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
)
|
||||
const kill = vi.fn(() => true)
|
||||
const closeTerminal = vi.fn()
|
||||
const closeTerminalTab = vi.fn(async () => {})
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
runtime.setNotifier({ closeTerminalTab } as never)
|
||||
runtime.setNotifier({ closeTerminal, closeTerminalTab } as never)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill,
|
||||
@@ -31526,6 +31527,7 @@ describe('OrcaRuntimeService', () => {
|
||||
ptyKilled: true
|
||||
})
|
||||
expect(kill).toHaveBeenCalledWith(ptyId)
|
||||
expect(closeTerminal).toHaveBeenCalledWith('host-tab')
|
||||
expect(closeTerminalTab).not.toHaveBeenCalled()
|
||||
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
|
||||
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([])
|
||||
@@ -31576,6 +31578,113 @@ describe('OrcaRuntimeService', () => {
|
||||
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).not.toEqual([])
|
||||
})
|
||||
|
||||
it('does not settle a missing mobile projection while the renderer still owns the parent', () => {
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession())
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'host-tab',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Headless worker',
|
||||
activeLeafId: HEADLESS_LEAF_ID,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: []
|
||||
})
|
||||
const isAlreadyRetired = (
|
||||
runtime as unknown as {
|
||||
isAlreadyRetiredMobileTerminalSurfaceError: (
|
||||
error: Error,
|
||||
worktreeId: string,
|
||||
parentTabId: string
|
||||
) => boolean
|
||||
}
|
||||
).isAlreadyRetiredMobileTerminalSurfaceError.bind(runtime)
|
||||
|
||||
expect(isAlreadyRetired(new Error('tab_not_found'), TEST_WORKTREE_ID, 'host-tab')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a split sibling when exit retires the addressed leaf before an unconfirmed stop returns', async () => {
|
||||
const tabId = '33333333-3333-4333-8333-333333333333'
|
||||
const leftPtyId = 'headless-split-left'
|
||||
const rightPtyId = 'headless-split-right'
|
||||
const layout = makeHeadlessTerminalLayout({
|
||||
[HEADLESS_LEAF_ID]: leftPtyId,
|
||||
[HEADLESS_SECOND_LEAF_ID]: rightPtyId
|
||||
})
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
activeTabId: tabId,
|
||||
activeTabIdByWorktree: { [TEST_WORKTREE_ID]: tabId },
|
||||
tabsByWorktree: {
|
||||
[TEST_WORKTREE_ID]: [
|
||||
{
|
||||
id: tabId,
|
||||
ptyId: leftPtyId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Split workers',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: { [tabId]: layout }
|
||||
})
|
||||
)
|
||||
let runtime!: OrcaRuntimeService
|
||||
let tabIdsAfterExit: string[] = []
|
||||
const kill = vi.fn(() => false)
|
||||
const stopAndWait = vi.fn(async (ptyId: string) => {
|
||||
runtime.onPtyExit(ptyId, 0)
|
||||
tabIdsAfterExit = (await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs.map(
|
||||
(tab) => tab.id
|
||||
)
|
||||
return false
|
||||
})
|
||||
runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill,
|
||||
stopAndWait,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => []
|
||||
})
|
||||
runtime.registerPty(leftPtyId, TEST_WORKTREE_ID, null, {
|
||||
tabId,
|
||||
leafId: HEADLESS_LEAF_ID
|
||||
})
|
||||
runtime.registerPty(rightPtyId, TEST_WORKTREE_ID, null, {
|
||||
tabId,
|
||||
leafId: HEADLESS_SECOND_LEAF_ID
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
const rightHandle = runtime.preAllocateHandleForPty(rightPtyId)
|
||||
|
||||
await expect(runtime.closeTerminal(rightHandle)).resolves.toMatchObject({
|
||||
handle: rightHandle,
|
||||
tabId,
|
||||
ptyKilled: false,
|
||||
ptyStopVerdict: 'unverifiable'
|
||||
})
|
||||
|
||||
expect(stopAndWait).toHaveBeenCalledWith(rightPtyId, expect.anything())
|
||||
expect(tabIdsAfterExit).toEqual([`${tabId}::${HEADLESS_LEAF_ID}`])
|
||||
expect(kill).toHaveBeenCalledWith(rightPtyId)
|
||||
expect(kill).not.toHaveBeenCalledWith(leftPtyId)
|
||||
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1)
|
||||
expect(getSession().terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId).toMatchObject({
|
||||
[HEADLESS_LEAF_ID]: leftPtyId
|
||||
})
|
||||
expect(
|
||||
(await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs.map((tab) => tab.id)
|
||||
).toEqual([`${tabId}::${HEADLESS_LEAF_ID}`])
|
||||
})
|
||||
|
||||
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'
|
||||
|
||||
@@ -29161,7 +29161,7 @@ export class OrcaRuntimeService {
|
||||
private findMobileTerminalSurface(
|
||||
worktreeId: string,
|
||||
parentTabId: string,
|
||||
options: { requireReady?: boolean } = {}
|
||||
options: { leafId?: string; requireReady?: boolean } = {}
|
||||
): RuntimeMobileSessionCreateTerminalResult | null {
|
||||
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
if (!snapshot) {
|
||||
@@ -29169,7 +29169,10 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
const result = this.toMobileSessionTabsResult(snapshot)
|
||||
const tab = result.tabs.find(
|
||||
(candidate) => candidate.type === 'terminal' && candidate.parentTabId === parentTabId
|
||||
(candidate) =>
|
||||
candidate.type === 'terminal' &&
|
||||
candidate.parentTabId === parentTabId &&
|
||||
(options.leafId === undefined || candidate.leafId === options.leafId)
|
||||
)
|
||||
if (!tab || tab.type !== 'terminal') {
|
||||
return null
|
||||
@@ -29192,7 +29195,9 @@ export class OrcaRuntimeService {
|
||||
): boolean {
|
||||
// Why: missing persistence is ambiguous while a sibling or stale live publication still owns the surface.
|
||||
return (
|
||||
error.message === 'tab_not_found' && !this.findMobileTerminalSurface(worktreeId, parentTabId)
|
||||
error.message === 'tab_not_found' &&
|
||||
!this.tabs.has(parentTabId) &&
|
||||
!this.findMobileTerminalSurface(worktreeId, parentTabId)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29208,7 +29213,7 @@ export class OrcaRuntimeService {
|
||||
candidate.parentLayout?.ptyIdsByLeafId?.[candidate.leafId] === ptyId)
|
||||
)
|
||||
return tab?.type === 'terminal'
|
||||
? this.findMobileTerminalSurface(worktreeId, tab.parentTabId)
|
||||
? this.findMobileTerminalSurface(worktreeId, tab.parentTabId, { leafId: tab.leafId })
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -29795,9 +29800,8 @@ export class OrcaRuntimeService {
|
||||
if (pty) {
|
||||
// Why: PTY exit can immediately replace a ready SSH publication with a pending one, so capture its durable HUB surface before killing it.
|
||||
const surface =
|
||||
(pty.pty.tabId
|
||||
? this.findMobileTerminalSurface(pty.pty.worktreeId, pty.pty.tabId)
|
||||
: null) ?? this.findMobileTerminalSurfaceForPty(pty.pty.worktreeId, pty.pty.ptyId)
|
||||
this.findMobileTerminalSurfaceForPty(pty.pty.worktreeId, pty.pty.ptyId) ??
|
||||
(pty.pty.tabId ? this.findMobileTerminalSurface(pty.pty.worktreeId, pty.pty.tabId) : null)
|
||||
const tabId = surface?.tab.parentTabId ?? pty.pty.tabId ?? pty.record.tabId
|
||||
// Why: relay recovery can leave stale renderer leaves; the persisted HUB layout defines whether closing this PTY closes the whole surface.
|
||||
const siblingCount = surface?.tab.parentLayout
|
||||
@@ -29831,7 +29835,16 @@ export class OrcaRuntimeService {
|
||||
return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, ptyKilled)
|
||||
}
|
||||
const ptyKilled = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId)
|
||||
if (!ptyKilled || siblingCount <= 1) {
|
||||
// Why: an exit callback can retire the addressed split leaf before an unconfirmed stop returns; parent fallback would destroy its surviving siblings.
|
||||
const addressedSplitSurfaceRetired =
|
||||
siblingCount > 1 &&
|
||||
surface !== null &&
|
||||
!this.mobileSessionSnapshotHasSurface(
|
||||
pty.pty.worktreeId,
|
||||
surface.tab.parentTabId,
|
||||
surface.tab.leafId
|
||||
)
|
||||
if ((!ptyKilled || siblingCount <= 1) && !addressedSplitSurfaceRetired) {
|
||||
if (surface) {
|
||||
// Why: paired viewers keep ended streams mounted until the HUB publishes removal, so explicit close uses the durable host-tab transaction instead of viewer-local exit handling.
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user