mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Fix terminal split source incarnation and rejection cleanup (#14238)
* fix(terminal): fence split source incarnation * fix(terminal): retire rejected split safely - Track retired rejected PTYs to prevent synthetic exits from landing after split rejection completes. - Validate splits using persisted incarnation IDs only, allowing restored sessions without incarnation maps to work correctly. - Reduce stop timeout from 10s to 2s to avoid stalling on unreachable hosts.
This commit is contained in:
@@ -6296,7 +6296,7 @@ describe('registerPtyHandlers', () => {
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
|
||||
})
|
||||
|
||||
it('preserves an SSH lease when runtime controller kill shutdown fails transiently', async () => {
|
||||
it('retires a rejected SSH PTY after generic kill shutdown fails transiently', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const store = {
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
@@ -6339,22 +6339,30 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
|
||||
kill: (ptyId: string) => boolean
|
||||
retireRejectedPty: (ptyId: string) => void
|
||||
}
|
||||
|
||||
try {
|
||||
expect(controller.kill('remote-pty')).toBe(true)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
'remote-pty',
|
||||
'terminated'
|
||||
)
|
||||
controller.retireRejectedPty('remote-pty')
|
||||
} finally {
|
||||
warnSpy.mockRestore()
|
||||
deletePtyOwnership('remote-pty')
|
||||
}
|
||||
|
||||
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
|
||||
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
'remote-pty',
|
||||
'terminated'
|
||||
)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 0, undefined)
|
||||
})
|
||||
|
||||
it('strips ORCA_PANE_KEY/TAB_ID/WORKTREE_ID from SSH spawn env when remote agent hooks are disabled', async () => {
|
||||
|
||||
+48
-6
@@ -3563,6 +3563,23 @@ export function registerPtyHandlers(
|
||||
syntheticKillExitPtyIds.set(id, cleanupTimer)
|
||||
}
|
||||
|
||||
// Why: a rejected split retires its PTY while kill's shutdown is still in flight; kill's late
|
||||
// synthetic exit must not land afterwards, or an SSH pane's code -1 would re-preserve the
|
||||
// surface the retirement just removed. Timed like the duplicate window so a reused id is free.
|
||||
const retiredRejectedPtyIds = new Map<string, NodeJS.Timeout>()
|
||||
|
||||
function rememberRetiredRejectedPty(id: string): void {
|
||||
const existing = retiredRejectedPtyIds.get(id)
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
}
|
||||
const cleanupTimer = setTimeout(() => {
|
||||
retiredRejectedPtyIds.delete(id)
|
||||
}, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS)
|
||||
cleanupTimer.unref?.()
|
||||
retiredRejectedPtyIds.set(id, cleanupTimer)
|
||||
}
|
||||
|
||||
function consumeSyntheticKillExit(id: string): boolean {
|
||||
const cleanupTimer = syntheticKillExitPtyIds.get(id)
|
||||
if (!cleanupTimer) {
|
||||
@@ -5472,19 +5489,23 @@ export function registerPtyHandlers(
|
||||
// Why: controller is synchronous, but keep ownership until async shutdown proves whether the provider emitted an exit.
|
||||
void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false })
|
||||
.then((providerExitObserved) => {
|
||||
const retired = retiredRejectedPtyIds.has(ptyId)
|
||||
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
|
||||
if (!providerExitObserved) {
|
||||
if (!providerExitObserved && !retired) {
|
||||
runtime?.onPtyExit(ptyId, -1, incarnationId)
|
||||
rememberSyntheticKillExit(ptyId)
|
||||
sendPtyExitToRenderer({ id: ptyId, code: -1 })
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const retired = retiredRejectedPtyIds.has(ptyId)
|
||||
if (isPtyAlreadyGoneError(err)) {
|
||||
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
|
||||
runtime?.onPtyExit(ptyId, -1, incarnationId)
|
||||
rememberSyntheticKillExit(ptyId)
|
||||
sendPtyExitToRenderer({ id: ptyId, code: -1 })
|
||||
if (!retired) {
|
||||
runtime?.onPtyExit(ptyId, -1, incarnationId)
|
||||
rememberSyntheticKillExit(ptyId)
|
||||
sendPtyExitToRenderer({ id: ptyId, code: -1 })
|
||||
}
|
||||
return
|
||||
}
|
||||
console.warn(
|
||||
@@ -5492,7 +5513,9 @@ export function registerPtyHandlers(
|
||||
)
|
||||
// Why: close runtime tails without clearing provider ownership, so
|
||||
// a retry can still target a PTY that survived the failed shutdown.
|
||||
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
|
||||
if (!retired) {
|
||||
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -5503,12 +5526,31 @@ export function registerPtyHandlers(
|
||||
console.warn(
|
||||
`[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
|
||||
if (!retiredRejectedPtyIds.has(ptyId)) {
|
||||
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
return killWithCurrentProvider()
|
||||
},
|
||||
retireRejectedPty: (ptyId) => {
|
||||
rememberRetiredRejectedPty(ptyId)
|
||||
// Why: a completed stop already cleared provider state, tombstoned the lease and told the
|
||||
// renderer; repeating that double-fires the exit IPC. The runtime still needs code 0 so an
|
||||
// SSH pane retires for good instead of staying preserved by the stop's negative exit.
|
||||
if (!ptyOwnership.has(ptyId)) {
|
||||
runtime?.onPtyExit(ptyId, 0, ptyIncarnationById.get(ptyId))
|
||||
return
|
||||
}
|
||||
let connectionId: string | null | undefined = ptyOwnership.get(ptyId)
|
||||
const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null
|
||||
connectionId ??= parsedSshId?.connectionId
|
||||
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
|
||||
runtime?.onPtyExit(ptyId, 0, incarnationId)
|
||||
rememberSyntheticKillExit(ptyId)
|
||||
sendPtyExitToRenderer({ id: ptyId, code: 0 })
|
||||
},
|
||||
markReversibleStops: (ptyIds) => {
|
||||
for (const ptyId of ptyIds) {
|
||||
reversibleStopOwnersByPtyId.set(ptyId, (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) + 1)
|
||||
|
||||
@@ -9765,6 +9765,99 @@ describe('Store', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('requires an expected split source incarnation in the owning host partition', async () => {
|
||||
for (const hostId of [undefined, 'ssh:ssh-1']) {
|
||||
const store = await createStore()
|
||||
const sourceSession: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-source' })]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
tab1: {
|
||||
root: { type: 'leaf', leafId: TEST_LEAF_1 },
|
||||
activeLeafId: TEST_LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-source' }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hostId) {
|
||||
store.setWorkspaceSession(
|
||||
{
|
||||
...sourceSession,
|
||||
terminalPtyIncarnationsByPaneKey: {
|
||||
[`tab1:${TEST_LEAF_1}`]: 'live-incarnation'
|
||||
}
|
||||
},
|
||||
undefined
|
||||
)
|
||||
}
|
||||
store.setWorkspaceSession(sourceSession, hostId)
|
||||
|
||||
expect(
|
||||
store.persistPtyBinding(
|
||||
{
|
||||
worktreeId: 'wt1',
|
||||
tabId: 'tab1',
|
||||
leafId: TEST_LEAF_2,
|
||||
ptyId: 'pty-split',
|
||||
expectedSourceBinding: {
|
||||
worktreeId: 'wt1',
|
||||
tabId: 'tab1',
|
||||
leafId: TEST_LEAF_1,
|
||||
ptyId: 'pty-source',
|
||||
incarnationId: 'live-incarnation'
|
||||
}
|
||||
},
|
||||
hostId
|
||||
)
|
||||
).toBe(false)
|
||||
expect(store.getWorkspaceSession(hostId).terminalLayoutsByTabId.tab1.root).toEqual({
|
||||
type: 'leaf',
|
||||
leafId: TEST_LEAF_1
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Why: a session restored without an incarnation map still owns a valid source binding, so the
|
||||
// split path must be able to fence on the pane alone instead of an id persistence never recorded.
|
||||
it('admits a split whose source pane has no persisted incarnation', async () => {
|
||||
const store = await createStore()
|
||||
store.setWorkspaceSession(
|
||||
{
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-source' })]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
tab1: {
|
||||
root: { type: 'leaf', leafId: TEST_LEAF_1 },
|
||||
activeLeafId: TEST_LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-source' }
|
||||
}
|
||||
}
|
||||
},
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(
|
||||
store.persistPtyBinding({
|
||||
worktreeId: 'wt1',
|
||||
tabId: 'tab1',
|
||||
leafId: TEST_LEAF_2,
|
||||
ptyId: 'pty-split',
|
||||
expectedSourceBinding: {
|
||||
worktreeId: 'wt1',
|
||||
tabId: 'tab1',
|
||||
leafId: TEST_LEAF_1,
|
||||
ptyId: 'pty-source'
|
||||
}
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects competing PTY and incarnation changes during reconciliation', async () => {
|
||||
const store = await createStore()
|
||||
const paneKey = `tab1:${TEST_LEAF_1}`
|
||||
|
||||
@@ -69,35 +69,69 @@ function remoteSnapshot(): RuntimeMobileSessionTabsSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
function createHarness(includeSource = true) {
|
||||
function createHarness(
|
||||
includeSource = true,
|
||||
options: {
|
||||
connectionId?: string | null
|
||||
deferReveal?: boolean
|
||||
deferSpawn?: boolean
|
||||
includePairedSnapshot?: boolean
|
||||
rendererMounted?: boolean
|
||||
sourceIncarnationId?: string
|
||||
stopAndWaitResult?: boolean
|
||||
} = {}
|
||||
) {
|
||||
let session = persistedSession(includeSource)
|
||||
const connectionId = options.connectionId ?? null
|
||||
const ownerHostId = connectionId ? `ssh:${connectionId}` : 'local'
|
||||
const requestedSessionHostIds: (string | undefined)[] = []
|
||||
const repo = {
|
||||
id: REPO_ID,
|
||||
path: '/workspace',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1
|
||||
addedAt: 1,
|
||||
...(connectionId ? { connectionId } : {})
|
||||
}
|
||||
const store = {
|
||||
getRepos: () => [repo],
|
||||
getRepo: (id: string) => (id === REPO_ID ? repo : undefined),
|
||||
getWorkspaceSession: () => session,
|
||||
getWorkspaceSession: (hostId?: string) => {
|
||||
requestedSessionHostIds.push(hostId)
|
||||
return hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession()
|
||||
},
|
||||
setWorkspaceSession: (next: WorkspaceSessionState) => {
|
||||
session = next
|
||||
},
|
||||
persistPtyBinding: () => true
|
||||
}
|
||||
const spawn = vi.fn(async () => ({ id: SPLIT_PTY_ID }))
|
||||
let resolveSpawn: ((result: { id: string }) => void) | undefined
|
||||
const spawn = options.deferSpawn
|
||||
? vi.fn(
|
||||
() =>
|
||||
new Promise<{ id: string }>((resolve) => {
|
||||
resolveSpawn = resolve
|
||||
})
|
||||
)
|
||||
: vi.fn(async () => ({ id: SPLIT_PTY_ID }))
|
||||
const kill = vi.fn(() => true)
|
||||
const revealTerminalSession = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error(`Terminal tab ${TAB_ID} not found`))
|
||||
const retireRejectedPty = vi.fn()
|
||||
const stopAndWait = vi.fn(async () => options.stopAndWaitResult ?? true)
|
||||
let resolveReveal: ((result: { tabId: string }) => void) | undefined
|
||||
const revealTerminalSession = options.deferReveal
|
||||
? vi.fn(
|
||||
() =>
|
||||
new Promise<{ tabId: string }>((resolve) => {
|
||||
resolveReveal = resolve
|
||||
})
|
||||
)
|
||||
: vi.fn().mockRejectedValue(new Error(`Terminal tab ${TAB_ID} not found`))
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
Object.assign(runtime, {
|
||||
resolveTerminalWorkspaceLaunchScope: vi.fn(async () => ({
|
||||
id: WORKTREE_ID,
|
||||
path: '/workspace',
|
||||
connectionId: null,
|
||||
connectionId,
|
||||
repo,
|
||||
folderWorkspace: null
|
||||
}))
|
||||
@@ -106,35 +140,77 @@ function createHarness(includeSource = true) {
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill,
|
||||
retireRejectedPty,
|
||||
...(options.stopAndWaitResult !== undefined ? { stopAndWait } : {}),
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.setNotifier({ revealTerminalSession } as never)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: includeSource ? [remoteSnapshot()] : []
|
||||
tabs:
|
||||
includeSource && options.rendererMounted
|
||||
? [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Restored terminal',
|
||||
activeLeafId: SOURCE_LEAF_ID,
|
||||
layout: { type: 'leaf', leafId: SOURCE_LEAF_ID } as const
|
||||
}
|
||||
]
|
||||
: [],
|
||||
leaves:
|
||||
includeSource && options.rendererMounted
|
||||
? [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
leafId: SOURCE_LEAF_ID,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: SOURCE_PTY_ID
|
||||
}
|
||||
]
|
||||
: [],
|
||||
mobileSessionTabs: (options.includePairedSnapshot ?? includeSource) ? [remoteSnapshot()] : []
|
||||
})
|
||||
runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, null, {
|
||||
runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, connectionId, {
|
||||
tabId: TAB_ID,
|
||||
leafId: SOURCE_LEAF_ID
|
||||
leafId: SOURCE_LEAF_ID,
|
||||
...(options.sourceIncarnationId ? { incarnationId: options.sourceIncarnationId } : {})
|
||||
})
|
||||
const internals = runtime as unknown as {
|
||||
getTerminalHandleForPaneKey: (paneKey: string) => string | null
|
||||
issuePtyHandle: (pty: unknown) => string
|
||||
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
|
||||
ptysById: Map<string, unknown>
|
||||
}
|
||||
const handle =
|
||||
internals.getTerminalHandleForPaneKey(makePaneKey(TAB_ID, SOURCE_LEAF_ID)) ??
|
||||
internals.issuePtyHandle(internals.ptysById.get(SOURCE_PTY_ID))
|
||||
const handle = internals.issuePtyHandle(internals.ptysById.get(SOURCE_PTY_ID))
|
||||
return {
|
||||
runtime,
|
||||
handle,
|
||||
spawn,
|
||||
kill,
|
||||
retireRejectedPty,
|
||||
stopAndWait,
|
||||
revealTerminalSession,
|
||||
getSession: () => session,
|
||||
getSnapshot: () => internals.mobileSessionTabsByWorktree.get(WORKTREE_ID)
|
||||
getSnapshot: () => internals.mobileSessionTabsByWorktree.get(WORKTREE_ID),
|
||||
requestedSessionHostIds,
|
||||
replaceSourceIncarnation: (incarnationId: string) =>
|
||||
runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, connectionId, {
|
||||
tabId: TAB_ID,
|
||||
leafId: SOURCE_LEAF_ID,
|
||||
incarnationId
|
||||
}),
|
||||
replacePersistedSourceIncarnation: (incarnationId: string) => {
|
||||
session = {
|
||||
...session,
|
||||
terminalPtyIncarnationsByPaneKey: {
|
||||
...session.terminalPtyIncarnationsByPaneKey,
|
||||
[makePaneKey(TAB_ID, SOURCE_LEAF_ID)]: incarnationId
|
||||
}
|
||||
}
|
||||
},
|
||||
resolveReveal: () => resolveReveal?.({ tabId: TAB_ID }),
|
||||
resolveSpawn: () => resolveSpawn?.({ id: SPLIT_PTY_ID })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,4 +261,118 @@ describe('remote runtime terminal split authority', () => {
|
||||
expect.soft(harness.kill).not.toHaveBeenCalled()
|
||||
expect.soft(harness.revealTerminalSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'local', connectionId: null, expectedHostId: 'local' },
|
||||
{ label: 'SSH', connectionId: 'ssh-1', expectedHostId: 'ssh:ssh-1' }
|
||||
])(
|
||||
'rejects a same-ID $label source replacement when restored persistence lacks an incarnation',
|
||||
async ({ connectionId, expectedHostId }) => {
|
||||
const harness = createHarness(true, {
|
||||
connectionId,
|
||||
deferSpawn: true,
|
||||
includePairedSnapshot: false,
|
||||
rendererMounted: true,
|
||||
sourceIncarnationId: 'source-before'
|
||||
})
|
||||
|
||||
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'vertical' })
|
||||
await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce())
|
||||
|
||||
expect(
|
||||
harness.getSession().terminalPtyIncarnationsByPaneKey?.[makePaneKey(TAB_ID, SOURCE_LEAF_ID)]
|
||||
).toBeUndefined()
|
||||
expect(harness.spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expectedSourceBinding: expect.objectContaining({ ptyId: SOURCE_PTY_ID })
|
||||
})
|
||||
)
|
||||
// Why: persistence never recorded this incarnation, so sending it would make the store
|
||||
// reject every split from a restored pane; the live id is fenced in-runtime instead.
|
||||
expect(harness.spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expectedSourceBinding: expect.not.objectContaining({ incarnationId: expect.anything() })
|
||||
})
|
||||
)
|
||||
|
||||
harness.replaceSourceIncarnation('source-after')
|
||||
harness.resolveSpawn()
|
||||
|
||||
await expect(split).rejects.toThrow('terminal_split_source_not_found')
|
||||
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
|
||||
expect(harness.requestedSessionHostIds).toContain(expectedHostId)
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects a same-ID paired-runtime source replacement recovered without an incarnation map', async () => {
|
||||
const harness = createHarness(true, {
|
||||
deferSpawn: true,
|
||||
includePairedSnapshot: true,
|
||||
rendererMounted: false,
|
||||
sourceIncarnationId: 'remote-before'
|
||||
})
|
||||
|
||||
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
|
||||
await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce())
|
||||
expect(harness.spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expectedSourceBinding: expect.not.objectContaining({ incarnationId: expect.anything() })
|
||||
})
|
||||
)
|
||||
|
||||
harness.replaceSourceIncarnation('remote-after')
|
||||
harness.resolveSpawn()
|
||||
|
||||
await expect(split).rejects.toThrow('terminal_split_source_not_found')
|
||||
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
|
||||
expect(harness.revealTerminalSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a persisted-only source incarnation change during spawn', async () => {
|
||||
const harness = createHarness(true, {
|
||||
deferSpawn: true,
|
||||
includePairedSnapshot: false,
|
||||
stopAndWaitResult: true
|
||||
})
|
||||
harness.replacePersistedSourceIncarnation('persisted-before')
|
||||
|
||||
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
|
||||
await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce())
|
||||
harness.replacePersistedSourceIncarnation('persisted-after')
|
||||
harness.resolveSpawn()
|
||||
|
||||
await expect(split).rejects.toThrow('terminal_split_source_not_found')
|
||||
expect(harness.stopAndWait).toHaveBeenCalledWith(
|
||||
SPLIT_PTY_ID,
|
||||
expect.objectContaining({ deadlineMs: expect.any(Number) })
|
||||
)
|
||||
expect(harness.kill).not.toHaveBeenCalled()
|
||||
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID)
|
||||
})
|
||||
|
||||
it('revalidates a projected paired-runtime source after renderer adoption', async () => {
|
||||
const harness = createHarness(false, {
|
||||
deferReveal: true,
|
||||
includePairedSnapshot: true,
|
||||
sourceIncarnationId: 'projected-before',
|
||||
stopAndWaitResult: false
|
||||
})
|
||||
|
||||
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
|
||||
await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce())
|
||||
expect(harness.spawn).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ expectedSourceBinding: expect.anything() })
|
||||
)
|
||||
|
||||
harness.replaceSourceIncarnation('projected-after')
|
||||
harness.resolveReveal()
|
||||
|
||||
await expect(split).rejects.toThrow('terminal_split_source_not_found')
|
||||
expect(harness.stopAndWait).toHaveBeenCalledWith(
|
||||
SPLIT_PTY_ID,
|
||||
expect.objectContaining({ deadlineMs: expect.any(Number) })
|
||||
)
|
||||
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
|
||||
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14898,6 +14898,8 @@ describe('OrcaRuntimeService', () => {
|
||||
const splitSpawn = spawn.mock.calls[0]?.[0] as
|
||||
| { expectedSourceBinding?: { incarnationId?: string } }
|
||||
| undefined
|
||||
// Why: persistence never recorded an incarnation for this pane, so sending the live-only id
|
||||
// would make the store's fence reject every split from a restored session.
|
||||
expect(splitSpawn?.expectedSourceBinding).not.toHaveProperty('incarnationId')
|
||||
|
||||
runtime.syncWindowGraph(1, {
|
||||
|
||||
@@ -1725,6 +1725,7 @@ type RuntimePtyController = {
|
||||
* False on doubt (absent session, SSH-scoped id, non-daemon provider). */
|
||||
attach?(ptyId: string): Promise<boolean>
|
||||
kill(ptyId: string): boolean
|
||||
retireRejectedPty?(ptyId: string): void
|
||||
stopAndWait?(
|
||||
ptyId: string,
|
||||
opts?: { keepHistory?: boolean; deadlineMs?: number }
|
||||
@@ -1828,6 +1829,9 @@ const BRACKETED_PASTE_END = '\x1b[201~'
|
||||
const BRACKETED_PASTE_QUIET_MS = 1500
|
||||
const DRAFT_PASTE_READY_TIMEOUT_MS = 8000
|
||||
const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000
|
||||
// Why: the split already failed; the caller waits on this teardown only to learn whether the
|
||||
// fallback kill is needed, so keep it short — an unreachable host must not stall the rejection.
|
||||
const REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS = 2_000
|
||||
const MOBILE_TERMINAL_READY_FALLBACK_MS = 1000
|
||||
const SSH_PANE_RECOVERY_GRACE_MS = 30_000
|
||||
// Why: long enough that a keystroke burst to a proven-dead leaf probes once,
|
||||
@@ -27376,6 +27380,8 @@ export class OrcaRuntimeService {
|
||||
if (!sourceAuthority) {
|
||||
throw new Error('terminal_split_source_not_found')
|
||||
}
|
||||
const sourceIncarnationId =
|
||||
sourceAuthority.liveIncarnationId ?? sourceAuthority.persistedIncarnationId
|
||||
const leafId = randomUUID()
|
||||
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
|
||||
const paneKey = makePaneKey(parentTabId, leafId)
|
||||
@@ -27402,6 +27408,9 @@ export class OrcaRuntimeService {
|
||||
tabId: parentTabId,
|
||||
leafId: parsedPaneKey.leafId,
|
||||
ptyId: pty.ptyId,
|
||||
// Why: the store can only match its own persisted map, so a live-only id it never
|
||||
// recorded would reject every split from a session restored without incarnations.
|
||||
// The live id is fenced by revalidateSourceAuthority below instead.
|
||||
...(sourceAuthority.persistedIncarnationId
|
||||
? { incarnationId: sourceAuthority.persistedIncarnationId }
|
||||
: {})
|
||||
@@ -27440,17 +27449,29 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
try {
|
||||
const revalidatedAuthority = this.resolveTerminalSplitSourceAuthority(
|
||||
workspace.id,
|
||||
parentTabId,
|
||||
parsedPaneKey.leafId,
|
||||
pty.ptyId
|
||||
)
|
||||
if (!revalidatedAuthority || (sourceAuthority.persisted && !revalidatedAuthority.persisted)) {
|
||||
throw new Error('terminal_split_source_not_found')
|
||||
const revalidateSourceAuthority = (): void => {
|
||||
const current = this.resolveTerminalSplitSourceAuthority(
|
||||
workspace.id,
|
||||
parentTabId,
|
||||
parsedPaneKey.leafId,
|
||||
pty.ptyId
|
||||
)
|
||||
if (
|
||||
!current ||
|
||||
(sourceAuthority.persisted && !current.persisted) ||
|
||||
(sourceIncarnationId !== null &&
|
||||
(current.liveIncarnationId ?? current.persistedIncarnationId) !== sourceIncarnationId)
|
||||
) {
|
||||
throw new Error('terminal_split_source_not_found')
|
||||
}
|
||||
}
|
||||
revalidateSourceAuthority()
|
||||
if (!sourceAuthority.persisted) {
|
||||
await revealSplit()
|
||||
// Why: rejecting here unmounts the pane the reveal just added only because the retire
|
||||
// below always emits its exit and the tab still holds the source sibling — the renderer's
|
||||
// exit handler closes non-final panes. Never close it by tabId: that drops the whole tab.
|
||||
revalidateSourceAuthority()
|
||||
}
|
||||
if (createdPty) {
|
||||
const persisted = this.persistHeadlessTerminalSplit({
|
||||
@@ -27474,7 +27495,19 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
} catch (error) {
|
||||
this.setPairedRendererSessionOwnership(result.id, false)
|
||||
this.ptyController.kill?.(result.id)
|
||||
let stopped = false
|
||||
try {
|
||||
stopped =
|
||||
(await this.ptyController.stopAndWait?.(result.id, {
|
||||
deadlineMs: Date.now() + REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS
|
||||
})) ?? false
|
||||
} catch {
|
||||
// Best-effort fallback below preserves the original split authority error.
|
||||
}
|
||||
if (!stopped) {
|
||||
this.ptyController.kill(result.id)
|
||||
}
|
||||
this.ptyController.retireRejectedPty?.(result.id)
|
||||
throw error
|
||||
}
|
||||
const committedSourceAuthority = sourceAuthority.persisted
|
||||
@@ -27503,6 +27536,7 @@ export class OrcaRuntimeService {
|
||||
rendererMounted: boolean
|
||||
persistedWorktreeId: string | null
|
||||
persistedIncarnationId: string | null
|
||||
liveIncarnationId: string | null
|
||||
} | null {
|
||||
const session = this.getWorkspaceSessionForWorktree(worktreeId)
|
||||
const sessionWorktreeId = session ? resolveTerminalSessionWorktreeId(session, worktreeId) : null
|
||||
@@ -27541,7 +27575,8 @@ export class OrcaRuntimeService {
|
||||
persisted: true,
|
||||
rendererMounted,
|
||||
persistedWorktreeId: sessionWorktreeId,
|
||||
persistedIncarnationId
|
||||
persistedIncarnationId,
|
||||
liveIncarnationId
|
||||
}
|
||||
}
|
||||
// Why: renderer adoption can precede graph sync; this path still requires reveal success before commit.
|
||||
@@ -27563,7 +27598,8 @@ export class OrcaRuntimeService {
|
||||
persisted: false,
|
||||
rendererMounted,
|
||||
persistedWorktreeId: null,
|
||||
persistedIncarnationId: null
|
||||
persistedIncarnationId: null,
|
||||
liveIncarnationId
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user