mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
fix(terminal): drain renderer teardown admissions
This commit is contained in:
@@ -3600,17 +3600,17 @@
|
||||
"providers": ["ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["ssh"],
|
||||
"coverageNotes": "A macOS unit harness loads the installed patched node-pty WindowsTerminal prototype without constructing native state, proving pre-ready kill/destroy dispatch immediately. A relay artifact harness reproduces vanilla npm node-pty retaining one deferred kill with zero native close calls, then proves both Windows relay artifacts hash, ship, apply, include the local ConPTY console-list fallback, and idempotently verify the fix. Relay tests simulate win32 shutdown, repeated stale-spawn native failure, one-shot ownership, and retry after native failure without deleting the only handle before close is proven. Relay boot UUIDs scope recycled pty-N counters, while pane identity protects older relay shutdown. Renderer pre-handler exits and retained shutdown retries are capped at 64, and lifecycle retry performs at most one attempt with backoff. Project removal keeps local or remote ownership until provider-verified shutdown and successful VM cleanup; runtime repo removal holds terminal admission closed across fresh liveness, stop, and metadata mutation. Targeted terminal.stop probes skip unrelated sessions. The same relay contract serves paired web/mobile clients; no live Windows ConPTY or Windows-over-SSH run is recorded yet.",
|
||||
"coverageNotes": "A macOS unit harness loads the installed patched node-pty WindowsTerminal prototype without constructing native state, proving pre-ready kill/destroy dispatch immediately. A relay artifact harness reproduces vanilla npm node-pty retaining one deferred kill with zero native close calls, then proves both Windows relay artifacts hash, ship, apply, include the local ConPTY console-list fallback, and idempotently verify the fix. Relay tests simulate win32 shutdown, repeated stale-spawn native failure, one-shot ownership, and retry after native failure without deleting the only handle before close is proven. Relay boot UUIDs scope recycled pty-N counters through initial spawn, durable-lease reattach, and SSH target-id migration, while pane identity protects older relays. Renderer pre-handler exits and retained shutdown retries are capped at 64, and lifecycle retry performs at most one attempt with backoff. Project removal keeps local or remote ownership until provider-verified shutdown and successful VM cleanup; runtime repo removal drains headless, renderer-backed mobile, renderer split, and selector-less renderer admissions before fresh liveness, stop, and metadata mutation. Targeted terminal.stop probes skip unrelated sessions. The same relay contract serves paired web/mobile clients; no live Windows ConPTY or Windows-over-SSH run is recorded yet.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/8260",
|
||||
"https://github.com/stablyai/orca/pull/8279"
|
||||
],
|
||||
"invariant": "A Windows relay PTY closed before its first output executes exactly one no-argument node-pty shutdown immediately; Orca does not delete ownership for a native kill failure, and a later retry can target only the same relay generation and pane while project removal fences new terminal admission.",
|
||||
"oracle": "Load the installed patched WindowsTerminal prototype in a pre-ready state and assert kill and destroy each close the terminal and native agent once with zero deferred work, while signal arguments still throw. Build both Windows SSH relay artifacts, reproduce vanilla npm node-pty with close=0/native-kill=0/deferred=1, apply the shipped patch, and assert close=1/native-kill=1/deferred=0, AttachConsole failure falls back to the shell pid, and the artifact version derives from all patch bytes. Assert remote npm install executes patch enforcement before its runtime probe and never finalizes a failed patch. In the relay, assert immediate, graceful, stale-spawn fallback, and handler-dispose paths preserve ownership through native throw and issue at most one successful no-argument shutdown. In the renderer, reject the first kill, retain exact identity, and assert the next lifecycle/removal retry succeeds before ownership state is purged.",
|
||||
"oracle": "Load the installed patched WindowsTerminal prototype in a pre-ready state and assert kill and destroy each close the terminal and native agent once with zero deferred work, while signal arguments still throw. Build both Windows SSH relay artifacts, reproduce vanilla npm node-pty with close=0/native-kill=0/deferred=1, apply the shipped patch, and assert close=1/native-kill=1/deferred=0, AttachConsole failure falls back to the shell pid, and the artifact version derives from all patch bytes. Assert remote npm install executes patch enforcement before its runtime probe and never finalizes a failed patch. In the relay, assert immediate, graceful, stale-spawn fallback, and handler-dispose paths preserve ownership through native throw and issue at most one successful no-argument shutdown. Assert reattach and target migration preserve relay generation. In the renderer, reject the first kill, retain exact identity, and assert the next lifecycle/removal retry succeeds before ownership state is purged. Start each terminal creation surface before project removal and assert removal performs no liveness snapshot until the create or split publishes its PTY; failed and concurrent admissions must release without deadlock.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts config/scripts/build-relay-windows-node-pty-patch.test.mjs src/main/ssh/ssh-relay-native-deps-install.test.ts src/relay/node-pty-windows-pre-ready-shutdown.test.ts src/relay/pty-handler.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-runtime-vm-removal.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/ssh-pty-id.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/ssh/ssh-relay-session.test.ts src/relay/pty-handler.test.ts src/relay/subprocess.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/lib/pty-kill-retry-ownership.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/store/slices/repos.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/pty.test.ts"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/ssh-pty-id.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-target-id-migration.test.ts src/relay/pty-handler.test.ts src/relay/subprocess.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/lib/pty-kill-retry-ownership.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/store/slices/repos.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/pty.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"config/scripts/build-relay-windows-node-pty-patch.test.mjs",
|
||||
@@ -3626,6 +3626,7 @@
|
||||
"src/shared/ssh-pty-id.test.ts",
|
||||
"src/main/providers/ssh-pty-provider.test.ts",
|
||||
"src/main/ssh/ssh-relay-session.test.ts",
|
||||
"src/main/ssh/ssh-target-id-migration.test.ts",
|
||||
"src/relay/subprocess.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts",
|
||||
"src/renderer/src/lib/pty-kill-retry-ownership.test.ts",
|
||||
@@ -3698,7 +3699,8 @@
|
||||
"terminal.stop awaits stopAndWait and fresh post-stop liveness before acknowledging provider shutdown",
|
||||
"provider shutdown rejection fails terminal.stop closed",
|
||||
"project removal fences paired terminal admission through provider stop and repo mutation",
|
||||
"project removal drains create-first in-flight admissions before its liveness snapshot",
|
||||
"project removal drains create-first headless, renderer-backed mobile, renderer split, and selector-less renderer admissions before its liveness snapshot",
|
||||
"failed and concurrent admissions release without deadlocking project removal",
|
||||
"targeted terminal stop omits foreground probes for unrelated sessions"
|
||||
]
|
||||
},
|
||||
@@ -3714,6 +3716,18 @@
|
||||
"SSH shutdown forwards pane identity and rejects an older relay generation before RPC"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/ssh-relay-session.test.ts",
|
||||
"assertions": [
|
||||
"durable-lease reattach rebuilds app PTY ownership with the current relay generation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/ssh-target-id-migration.test.ts",
|
||||
"assertions": [
|
||||
"SSH target re-adoption rewrites only the target id and preserves relay generation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/relay/subprocess.test.ts",
|
||||
"assertions": [
|
||||
@@ -3762,10 +3776,10 @@
|
||||
"date": "2026-07-12",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/ssh-pty-id.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/ssh/ssh-relay-session.test.ts src/relay/pty-handler.test.ts src/relay/subprocess.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/lib/pty-kill-retry-ownership.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/store/slices/repos.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/pty.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/ssh-pty-id.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-target-id-migration.test.ts src/relay/pty-handler.test.ts src/relay/subprocess.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/lib/pty-kill-retry-ownership.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/store/slices/repos.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/pty.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 12,
|
||||
"summary": "1236 tests passed with relay-generation identity, stale-spawn retained ownership, bounded renderer maps/retry work, VM cleanup success ordering, atomic runtime removal admission, and targeted liveness probes."
|
||||
"summary": "1247 tests passed with relay-generation identity preserved across reattach and migration, stale-spawn retained ownership, bounded renderer maps/retry work, VM cleanup success ordering, all renderer/runtime removal admissions drained, and targeted liveness probes."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-12",
|
||||
@@ -3774,7 +3788,7 @@
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 18,
|
||||
"summary": "664 runtime tests passed, including removal-first rejection and create-first admission drain before provider liveness snapshots."
|
||||
"summary": "668 runtime tests passed, including removal-first rejection and create-first admission drain for headless, renderer-backed mobile, renderer split, and selector-less renderer paths before provider liveness snapshots."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
|
||||
@@ -96,6 +96,10 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
return this.connectionId
|
||||
}
|
||||
|
||||
getRelayInstanceId(): string | undefined {
|
||||
return this.relayInstanceId
|
||||
}
|
||||
|
||||
private toRelayPtyId(id: string): string {
|
||||
const parsed = parseAppSshPtyId(id)
|
||||
if (
|
||||
|
||||
@@ -22301,6 +22301,207 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(removeProject).toHaveBeenCalledWith(TEST_REPO_ID)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['renderer-backed mobile create', 'mobile'],
|
||||
['selector-less renderer create', 'global']
|
||||
] as const)('drains a create-first %s before project removal', async (_name, kind) => {
|
||||
const removeProject = vi.fn()
|
||||
const runtime = new OrcaRuntimeService({ ...store, removeProject } as never)
|
||||
const processLists = [
|
||||
[{ id: 'pty-renderer-late', cwd: TEST_WORKTREE_PATH, title: 'Shell' }],
|
||||
[]
|
||||
]
|
||||
const stopAndWait = vi.fn(async (ptyId: string) => {
|
||||
runtime.onPtyExit(ptyId, -1)
|
||||
return true
|
||||
})
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => false,
|
||||
stopAndWait,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => processLists.shift() ?? []
|
||||
})
|
||||
runtime.setNotifier({ focusTerminal: vi.fn(), reposChanged: vi.fn() } as never)
|
||||
const webContents = { send: vi.fn() }
|
||||
const send = vi.fn((_channel: string, payload: { requestId: string }) => {
|
||||
ipcMain.emit(
|
||||
'terminal:tabCreateReply',
|
||||
{ sender: webContents },
|
||||
{ requestId: payload.requestId, tabId: 'tab-renderer-late', title: 'Terminal' }
|
||||
)
|
||||
})
|
||||
webContents.send = send
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
webContents
|
||||
})
|
||||
|
||||
const create =
|
||||
kind === 'mobile'
|
||||
? runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { activate: false })
|
||||
: runtime.createTerminal()
|
||||
await vi.waitFor(() => expect(send).toHaveBeenCalledOnce())
|
||||
const removal = runtime.removeProject(`id:${TEST_REPO_ID}`)
|
||||
await Promise.resolve()
|
||||
expect(processLists).toHaveLength(2)
|
||||
expect(removeProject).not.toHaveBeenCalled()
|
||||
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-renderer-late',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'pane:late',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-renderer-late'
|
||||
}
|
||||
],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: `admission-${kind}`,
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: 'tab-renderer-late::pane:late',
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-renderer-late::pane:late',
|
||||
parentTabId: 'tab-renderer-late',
|
||||
leafId: 'pane:late',
|
||||
title: 'Terminal',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await expect(create).resolves.toBeDefined()
|
||||
await expect(removal).resolves.toEqual({ removed: true })
|
||||
expect(stopAndWait).toHaveBeenCalledWith('pty-renderer-late')
|
||||
expect(removeProject).toHaveBeenCalledWith(TEST_REPO_ID)
|
||||
})
|
||||
|
||||
it('drains a renderer-owned split before project removal snapshots liveness', async () => {
|
||||
const removeProject = vi.fn()
|
||||
const splitTerminal = vi.fn()
|
||||
const runtime = new OrcaRuntimeService({ ...store, removeProject } as never)
|
||||
const processLists = [
|
||||
[
|
||||
{ id: 'pty-left', cwd: TEST_WORKTREE_PATH, title: 'Shell' },
|
||||
{ id: 'pty-right', cwd: TEST_WORKTREE_PATH, title: 'Shell' }
|
||||
],
|
||||
[]
|
||||
]
|
||||
const stopAndWait = vi.fn(async (ptyId: string) => {
|
||||
runtime.onPtyExit(ptyId, -1)
|
||||
return true
|
||||
})
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => false,
|
||||
stopAndWait,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => processLists.shift() ?? []
|
||||
})
|
||||
runtime.setNotifier({ splitTerminal, reposChanged: vi.fn() } as never)
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-split',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Terminal',
|
||||
activeLeafId: 'pane:left',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-split',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'pane:left',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-left'
|
||||
}
|
||||
]
|
||||
})
|
||||
const handle = await runtime.resolveActiveTerminal(`id:${TEST_WORKTREE_ID}`)
|
||||
expect(handle).not.toBeNull()
|
||||
|
||||
const split = runtime.splitTerminal(handle!)
|
||||
await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledOnce())
|
||||
const removal = runtime.removeProject(`id:${TEST_REPO_ID}`)
|
||||
await Promise.resolve()
|
||||
expect(processLists).toHaveLength(2)
|
||||
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-split',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Terminal',
|
||||
activeLeafId: 'pane:right',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-split',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'pane:left',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-left'
|
||||
},
|
||||
{
|
||||
tabId: 'tab-split',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'pane:right',
|
||||
paneRuntimeId: 2,
|
||||
ptyId: 'pty-right'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await expect(split).resolves.toBeDefined()
|
||||
await expect(removal).resolves.toEqual({ removed: true })
|
||||
expect(stopAndWait).toHaveBeenCalledWith('pty-left')
|
||||
expect(stopAndWait).toHaveBeenCalledWith('pty-right')
|
||||
})
|
||||
|
||||
it('releases failed and concurrent terminal admissions without deadlocking removal', async () => {
|
||||
const removeProject = vi.fn()
|
||||
const runtime = new OrcaRuntimeService({ ...store, removeProject } as never)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => false,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => []
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
const first = deferred<void>()
|
||||
const second = deferred<void>()
|
||||
const firstAdmission = runtime['withTerminalAdmission'](TEST_WORKTREE_ID, () => first.promise)
|
||||
const secondAdmission = runtime['withTerminalAdmission'](TEST_WORKTREE_ID, () => second.promise)
|
||||
const removal = runtime.removeProject(`id:${TEST_REPO_ID}`)
|
||||
await Promise.resolve()
|
||||
expect(removeProject).not.toHaveBeenCalled()
|
||||
|
||||
first.reject(new Error('spawn failed'))
|
||||
second.resolve()
|
||||
await expect(firstAdmission).rejects.toThrow('spawn failed')
|
||||
await expect(secondAdmission).resolves.toBeUndefined()
|
||||
await expect(removal).resolves.toEqual({ removed: true })
|
||||
expect(removeProject).toHaveBeenCalledWith(TEST_REPO_ID)
|
||||
})
|
||||
|
||||
it('rejects terminal.stop when provider shutdown is not accepted', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
|
||||
+151
-142
@@ -1260,6 +1260,7 @@ 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
|
||||
const GLOBAL_TERMINAL_ADMISSION_KEY = '__global_renderer_terminal_admission__'
|
||||
const MOBILE_TERMINAL_READY_FALLBACK_MS = 1000
|
||||
const RECENT_PTY_OUTPUT_LIMIT = 64 * 1024
|
||||
const RECENT_PTY_PATH_CANDIDATE_LIMIT = 1024
|
||||
@@ -12202,7 +12203,11 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
private beginTerminalAdmission(worktreeId: string): () => void {
|
||||
if (this.terminalAdmissionBlockedWorktreeIds.has(worktreeId)) {
|
||||
if (
|
||||
worktreeId === GLOBAL_TERMINAL_ADMISSION_KEY
|
||||
? this.terminalAdmissionBlockedWorktreeIds.size > 0
|
||||
: this.terminalAdmissionBlockedWorktreeIds.has(worktreeId)
|
||||
) {
|
||||
throw new Error('terminal_admission_blocked_for_project_removal')
|
||||
}
|
||||
let resolveDone = (): void => {}
|
||||
@@ -12260,7 +12265,7 @@ export class OrcaRuntimeService {
|
||||
// Why: a create that passed admission before the block can still have a
|
||||
// provider spawn in flight. Let it register first so the fresh stop
|
||||
// snapshot sees and terminates it.
|
||||
await this.drainTerminalAdmissions(worktreeIds)
|
||||
await this.drainTerminalAdmissions([GLOBAL_TERMINAL_ADMISSION_KEY, ...worktreeIds])
|
||||
// Why: hold admission closed across both the fresh liveness proof and
|
||||
// repo mutation. A separate terminal.stop RPC leaves an inter-call gap.
|
||||
for (const id of worktreeIds) {
|
||||
@@ -17813,7 +17818,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
const { reply, handle } = workspace
|
||||
? await this.withTerminalAdmission(workspace.id, createRendererTerminal)
|
||||
: await createRendererTerminal()
|
||||
: await this.withTerminalAdmission(GLOBAL_TERMINAL_ADMISSION_KEY, createRendererTerminal)
|
||||
return {
|
||||
handle,
|
||||
tabId: reply.tabId,
|
||||
@@ -17943,146 +17948,148 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
)
|
||||
}
|
||||
const requestId = randomUUID()
|
||||
const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
ipcMain.removeListener('terminal:tabCreateReply', handler)
|
||||
opts.signal?.removeEventListener('abort', onAbort)
|
||||
reject(new Error('Terminal creation timed out'))
|
||||
}, 10_000)
|
||||
// Why: a dead client connection cancels the wait; the renderer tab (and
|
||||
// its shell) stays alive for the host and mirrors on reconnect (#7718).
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer)
|
||||
ipcMain.removeListener('terminal:tabCreateReply', handler)
|
||||
reject(new Error('client_disconnected'))
|
||||
}
|
||||
|
||||
const handler = (
|
||||
event: Electron.IpcMainEvent,
|
||||
r: { requestId: string; tabId?: string; title?: string; error?: string }
|
||||
): void => {
|
||||
if (event.sender !== win.webContents || r.requestId !== requestId) {
|
||||
return
|
||||
return await this.withTerminalAdmission(worktreeId, async () => {
|
||||
const requestId = randomUUID()
|
||||
const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
ipcMain.removeListener('terminal:tabCreateReply', handler)
|
||||
opts.signal?.removeEventListener('abort', onAbort)
|
||||
reject(new Error('Terminal creation timed out'))
|
||||
}, 10_000)
|
||||
// Why: a dead client connection cancels the wait; the renderer tab (and
|
||||
// its shell) stays alive for the host and mirrors on reconnect (#7718).
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer)
|
||||
ipcMain.removeListener('terminal:tabCreateReply', handler)
|
||||
reject(new Error('client_disconnected'))
|
||||
}
|
||||
clearTimeout(timer)
|
||||
ipcMain.removeListener('terminal:tabCreateReply', handler)
|
||||
opts.signal?.removeEventListener('abort', onAbort)
|
||||
if (r.error) {
|
||||
reject(new Error(r.error))
|
||||
} else {
|
||||
resolve({ tabId: r.tabId!, title: r.title ?? '' })
|
||||
}
|
||||
}
|
||||
opts.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
ipcMain.on('terminal:tabCreateReply', handler)
|
||||
win.webContents.send('terminal:requestTabCreate', {
|
||||
requestId,
|
||||
worktreeId,
|
||||
afterTabId: afterDesktopTabId,
|
||||
targetGroupId: opts.targetGroupId,
|
||||
command: startupCommand.command,
|
||||
cwd,
|
||||
...(startupCommand.env ? { env: startupCommand.env } : {}),
|
||||
...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}),
|
||||
...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}),
|
||||
startupCommandDelivery: startupCommand.startupCommandDelivery,
|
||||
source: 'runtime-session',
|
||||
activate: opts.activate
|
||||
})
|
||||
})
|
||||
|
||||
if (opts.activate !== false) {
|
||||
this.notifier?.focusTerminal(reply.tabId, worktreeId, null)
|
||||
}
|
||||
// Why: register the wait before the renderer's PTY spawn arrives so that
|
||||
// spawn (registerPty) can publish the pty-backed surface main-side even if
|
||||
// graph-sync is stalled (#7587). Removed in the finally below.
|
||||
const pendingCreateKey = `${worktreeId}::${reply.tabId}`
|
||||
// Why: a rescue publishes into the active group (opts.targetGroupId is not
|
||||
// threaded); the renderer's reconciling publication then moves the tab to the
|
||||
// requested group, so any wrong-group placement is cosmetic and stall-window-only.
|
||||
this.pendingMobileTerminalCreatesByKey.set(pendingCreateKey, {
|
||||
activate: opts.activate !== false,
|
||||
selectIfNoActiveTab: true
|
||||
})
|
||||
try {
|
||||
// Why: the PTY spawn and the tabCreate reply race on independent IPC
|
||||
// channels; if the spawn already registered, publish immediately so the
|
||||
// wait resolves without depending on a graph sync.
|
||||
this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId)
|
||||
const surface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, {
|
||||
timeoutMs: MOBILE_TERMINAL_SURFACE_TIMEOUT_MS,
|
||||
signal: opts.signal
|
||||
})
|
||||
if (this.isReadyMobileTerminalSurface(surface)) {
|
||||
return surface
|
||||
}
|
||||
const readySurface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, {
|
||||
timeoutMs: MOBILE_TERMINAL_READY_FALLBACK_MS,
|
||||
requireReady: true,
|
||||
signal: opts.signal
|
||||
}).catch(() => null)
|
||||
if (readySurface) {
|
||||
return readySurface
|
||||
}
|
||||
if (opts.signal?.aborted) {
|
||||
// Why: nobody is waiting for this create anymore; do not materialize
|
||||
// or roll back — the renderer's own publication settles the tab.
|
||||
throw new Error('client_disconnected')
|
||||
}
|
||||
const pendingSurface = this.findMobileTerminalSurface(worktreeId, reply.tabId)
|
||||
if (!pendingSurface) {
|
||||
throw new Error('Timed out waiting for terminal surface after creation')
|
||||
}
|
||||
// Why: hidden/occluded renderer windows can publish the tab shell before
|
||||
// TerminalPane mounts and spawns the PTY. Materialize into the same
|
||||
// identity so later renderer focus adopts instead of creating another tab.
|
||||
return await this.createHeadlessMobileSessionTerminal(
|
||||
worktreeId,
|
||||
opts.activate !== false,
|
||||
opts.afterTabId,
|
||||
{
|
||||
const handler = (
|
||||
event: Electron.IpcMainEvent,
|
||||
r: { requestId: string; tabId?: string; title?: string; error?: string }
|
||||
): void => {
|
||||
if (event.sender !== win.webContents || r.requestId !== requestId) {
|
||||
return
|
||||
}
|
||||
clearTimeout(timer)
|
||||
ipcMain.removeListener('terminal:tabCreateReply', handler)
|
||||
opts.signal?.removeEventListener('abort', onAbort)
|
||||
if (r.error) {
|
||||
reject(new Error(r.error))
|
||||
} else {
|
||||
resolve({ tabId: r.tabId!, title: r.title ?? '' })
|
||||
}
|
||||
}
|
||||
opts.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
ipcMain.on('terminal:tabCreateReply', handler)
|
||||
win.webContents.send('terminal:requestTabCreate', {
|
||||
requestId,
|
||||
worktreeId,
|
||||
afterTabId: afterDesktopTabId,
|
||||
targetGroupId: opts.targetGroupId,
|
||||
command: startupCommand.command,
|
||||
cwd,
|
||||
env: startupCommand.env,
|
||||
...(startupCommand.env ? { env: startupCommand.env } : {}),
|
||||
...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}),
|
||||
...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}),
|
||||
startupCommandDelivery: startupCommand.startupCommandDelivery,
|
||||
identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId },
|
||||
launchAgent: startupCommand.launchAgent,
|
||||
targetGroupId: opts.targetGroupId,
|
||||
launchConfig: startupCommand.launchConfig
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
// Why: publication latency (throttled/hidden renderer), not spawn failure,
|
||||
// can trip the surface timeout. Rescue only when a live PTY actually backs
|
||||
// the tab — gating on a surface would let a handle-less shell (or a failed
|
||||
// materialize) resolve as success and skip the ghost-tab rollback (#7587).
|
||||
if (this.findLiveRegisteredPtyForRendererTab(worktreeId, reply.tabId)) {
|
||||
const rescued = this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId)
|
||||
if (rescued) {
|
||||
return rescued
|
||||
}
|
||||
source: 'runtime-session',
|
||||
activate: opts.activate
|
||||
})
|
||||
})
|
||||
|
||||
if (opts.activate !== false) {
|
||||
this.notifier?.focusTerminal(reply.tabId, worktreeId, null)
|
||||
}
|
||||
// Why: don't roll back when (a) the client connection died — the wait
|
||||
// was cancelled, not the spawn — or (b) a live shell already backs the
|
||||
// tab (its pane key may simply not be registered yet). Killing a real
|
||||
// terminal the host user can see is the "tab dies after ~10s" bug (#7718).
|
||||
if (
|
||||
isClientDisconnectedError(error) ||
|
||||
this.hasLiveShellForRendererTab(worktreeId, reply.tabId)
|
||||
) {
|
||||
// Why: register the wait before the renderer's PTY spawn arrives so that
|
||||
// spawn (registerPty) can publish the pty-backed surface main-side even if
|
||||
// graph-sync is stalled (#7587). Removed in the finally below.
|
||||
const pendingCreateKey = `${worktreeId}::${reply.tabId}`
|
||||
// Why: a rescue publishes into the active group (opts.targetGroupId is not
|
||||
// threaded); the renderer's reconciling publication then moves the tab to the
|
||||
// requested group, so any wrong-group placement is cosmetic and stall-window-only.
|
||||
this.pendingMobileTerminalCreatesByKey.set(pendingCreateKey, {
|
||||
activate: opts.activate !== false,
|
||||
selectIfNoActiveTab: true
|
||||
})
|
||||
try {
|
||||
// Why: the PTY spawn and the tabCreate reply race on independent IPC
|
||||
// channels; if the spawn already registered, publish immediately so the
|
||||
// wait resolves without depending on a graph sync.
|
||||
this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId)
|
||||
const surface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, {
|
||||
timeoutMs: MOBILE_TERMINAL_SURFACE_TIMEOUT_MS,
|
||||
signal: opts.signal
|
||||
})
|
||||
if (this.isReadyMobileTerminalSurface(surface)) {
|
||||
return surface
|
||||
}
|
||||
const readySurface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, {
|
||||
timeoutMs: MOBILE_TERMINAL_READY_FALLBACK_MS,
|
||||
requireReady: true,
|
||||
signal: opts.signal
|
||||
}).catch(() => null)
|
||||
if (readySurface) {
|
||||
return readySurface
|
||||
}
|
||||
if (opts.signal?.aborted) {
|
||||
// Why: nobody is waiting for this create anymore; do not materialize
|
||||
// or roll back — the renderer's own publication settles the tab.
|
||||
throw new Error('client_disconnected')
|
||||
}
|
||||
const pendingSurface = this.findMobileTerminalSurface(worktreeId, reply.tabId)
|
||||
if (!pendingSurface) {
|
||||
throw new Error('Timed out waiting for terminal surface after creation')
|
||||
}
|
||||
// Why: hidden/occluded renderer windows can publish the tab shell before
|
||||
// TerminalPane mounts and spawns the PTY. Materialize into the same
|
||||
// identity so later renderer focus adopts instead of creating another tab.
|
||||
return await this.createHeadlessMobileSessionTerminal(
|
||||
worktreeId,
|
||||
opts.activate !== false,
|
||||
opts.afterTabId,
|
||||
{
|
||||
command: startupCommand.command,
|
||||
cwd,
|
||||
env: startupCommand.env,
|
||||
startupCommandDelivery: startupCommand.startupCommandDelivery,
|
||||
identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId },
|
||||
launchAgent: startupCommand.launchAgent,
|
||||
targetGroupId: opts.targetGroupId,
|
||||
launchConfig: startupCommand.launchConfig
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
// Why: publication latency (throttled/hidden renderer), not spawn failure,
|
||||
// can trip the surface timeout. Rescue only when a live PTY actually backs
|
||||
// the tab — gating on a surface would let a handle-less shell (or a failed
|
||||
// materialize) resolve as success and skip the ghost-tab rollback (#7587).
|
||||
if (this.findLiveRegisteredPtyForRendererTab(worktreeId, reply.tabId)) {
|
||||
const rescued = this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId)
|
||||
if (rescued) {
|
||||
return rescued
|
||||
}
|
||||
}
|
||||
// Why: don't roll back when (a) the client connection died — the wait
|
||||
// was cancelled, not the spawn — or (b) a live shell already backs the
|
||||
// tab (its pane key may simply not be registered yet). Killing a real
|
||||
// terminal the host user can see is the "tab dies after ~10s" bug (#7718).
|
||||
if (
|
||||
isClientDisconnectedError(error) ||
|
||||
this.hasLiveShellForRendererTab(worktreeId, reply.tabId)
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
// Why: the renderer created the tab but no live PTY backs it (true PTY
|
||||
// spawn/handle failure). Roll the half-created tab back via the renderer
|
||||
// close path so it can't linger as a ghost in mobile snapshots, then
|
||||
// surface the failure to the caller.
|
||||
this.notifier?.closeTerminal(reply.tabId)
|
||||
throw error
|
||||
} finally {
|
||||
this.pendingMobileTerminalCreatesByKey.delete(pendingCreateKey)
|
||||
}
|
||||
// Why: the renderer created the tab but no live PTY backs it (true PTY
|
||||
// spawn/handle failure). Roll the half-created tab back via the renderer
|
||||
// close path so it can't linger as a ghost in mobile snapshots, then
|
||||
// surface the failure to the caller.
|
||||
this.notifier?.closeTerminal(reply.tabId)
|
||||
throw error
|
||||
} finally {
|
||||
this.pendingMobileTerminalCreatesByKey.delete(pendingCreateKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async resolveMobileSessionTerminalCommand(
|
||||
@@ -18651,14 +18658,16 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
this.notifier?.splitTerminal(leaf.tabId, leaf.paneRuntimeId, {
|
||||
direction,
|
||||
command: opts.command,
|
||||
telemetrySource: opts.telemetrySource
|
||||
})
|
||||
return await this.withTerminalAdmission(leaf.worktreeId, async () => {
|
||||
this.notifier?.splitTerminal(leaf.tabId, leaf.paneRuntimeId, {
|
||||
direction,
|
||||
command: opts.command,
|
||||
telemetrySource: opts.telemetrySource
|
||||
})
|
||||
|
||||
const newHandle = await this.waitForNewLeafInTab(leaf.tabId, leafKeysBefore)
|
||||
return { handle: newHandle, tabId: leaf.tabId, paneRuntimeId: leaf.paneRuntimeId }
|
||||
const newHandle = await this.waitForNewLeafInTab(leaf.tabId, leafKeysBefore)
|
||||
return { handle: newHandle, tabId: leaf.tabId, paneRuntimeId: leaf.paneRuntimeId }
|
||||
})
|
||||
}
|
||||
|
||||
private async splitPtyBackedTerminal(
|
||||
|
||||
@@ -562,6 +562,7 @@ describe('SshRelaySession', () => {
|
||||
const mockAttach = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(getSshPtyProvider).mockReturnValue({
|
||||
attachForReconnect: mockAttach,
|
||||
getRelayInstanceId: () => 'relay-generation',
|
||||
dispose: vi.fn()
|
||||
} as unknown as ReturnType<typeof getSshPtyProvider>)
|
||||
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
|
||||
@@ -576,7 +577,8 @@ describe('SshRelaySession', () => {
|
||||
|
||||
expect(mockAttach).toHaveBeenCalledWith('pty-live')
|
||||
expect(mockAttach).not.toHaveBeenCalledWith('pty-expired')
|
||||
expect(setPtyOwnership).toHaveBeenCalledWith('ssh:target-1@@pty-live', 'target-1')
|
||||
const expectedAppPtyId = 'ssh:target-1@@relay-generation@@pty-live'
|
||||
expect(setPtyOwnership).toHaveBeenCalledWith(expectedAppPtyId, 'target-1')
|
||||
expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('target-1', 'pty-live', 'attached')
|
||||
})
|
||||
|
||||
|
||||
@@ -1230,6 +1230,7 @@ export class SshRelaySession {
|
||||
if (!ptyProvider) {
|
||||
return
|
||||
}
|
||||
const relayInstanceId = ptyProvider.getRelayInstanceId?.()
|
||||
for (const ptyId of ptyIds) {
|
||||
if (!shouldContinue()) {
|
||||
return
|
||||
@@ -1243,7 +1244,7 @@ export class SshRelaySession {
|
||||
if (!shouldContinue()) {
|
||||
return
|
||||
}
|
||||
const appPtyId = toAppSshPtyId(this.targetId, ptyId)
|
||||
const appPtyId = toAppSshPtyId(this.targetId, ptyId, relayInstanceId)
|
||||
setPtyOwnership(appPtyId, this.targetId)
|
||||
this.store.markSshRemotePtyLease(this.targetId, ptyId, 'attached')
|
||||
this.forwardReattachReplay(appPtyId, attachResult.replay ?? '')
|
||||
@@ -1251,7 +1252,7 @@ export class SshRelaySession {
|
||||
if (!isSshPtyNotFoundError(err)) {
|
||||
throw err
|
||||
}
|
||||
const appPtyId = toAppSshPtyId(this.targetId, ptyId)
|
||||
const appPtyId = toAppSshPtyId(this.targetId, ptyId, relayInstanceId)
|
||||
if (isSshPtyIdentityMismatchError(err)) {
|
||||
console.warn(
|
||||
`[ssh-relay-session] Ignoring stale PTY ${ptyId} for ${this.targetId} after relay identity mismatch: ${
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('migrateWorkspaceSessionSshTargetId', () => {
|
||||
const session = makeSession({
|
||||
tabsByWorktree: {
|
||||
'r1::/wt': [
|
||||
makeTab({ id: 'tab1', ptyId: `ssh:${OLD_ID}@@pty-3` }),
|
||||
makeTab({ id: 'tab1', ptyId: `ssh:${OLD_ID}@@relay-generation@@pty-3` }),
|
||||
makeTab({ id: 'tab2', ptyId: 'local-pty-7' }),
|
||||
makeTab({ id: 'tab3', ptyId: `ssh:other-target@@pty-1` })
|
||||
]
|
||||
@@ -44,11 +44,11 @@ describe('migrateWorkspaceSessionSshTargetId', () => {
|
||||
root: { type: 'leaf', leafId: 'leaf-1' },
|
||||
activeLeafId: 'leaf-1',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { 'leaf-1': `ssh:${OLD_ID}@@pty-3` }
|
||||
ptyIdsByLeafId: { 'leaf-1': `ssh:${OLD_ID}@@relay-generation@@pty-3` }
|
||||
}
|
||||
},
|
||||
remoteSessionIdsByTabId: {
|
||||
tab1: `ssh:${OLD_ID}@@pty-3`,
|
||||
tab1: `ssh:${OLD_ID}@@relay-generation@@pty-3`,
|
||||
tab3: `ssh:other-target@@pty-1`
|
||||
}
|
||||
})
|
||||
@@ -56,15 +56,15 @@ describe('migrateWorkspaceSessionSshTargetId', () => {
|
||||
expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true)
|
||||
|
||||
const tabs = session.tabsByWorktree['r1::/wt']
|
||||
expect(tabs[0].ptyId).toBe(`ssh:${NEW_ID}@@pty-3`)
|
||||
expect(tabs[0].ptyId).toBe(`ssh:${NEW_ID}@@relay-generation@@pty-3`)
|
||||
// Local and other-target pty ids must be untouched.
|
||||
expect(tabs[1].ptyId).toBe('local-pty-7')
|
||||
expect(tabs[2].ptyId).toBe(`ssh:other-target@@pty-1`)
|
||||
expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({
|
||||
'leaf-1': `ssh:${NEW_ID}@@pty-3`
|
||||
'leaf-1': `ssh:${NEW_ID}@@relay-generation@@pty-3`
|
||||
})
|
||||
expect(session.remoteSessionIdsByTabId).toEqual({
|
||||
tab1: `ssh:${NEW_ID}@@pty-3`,
|
||||
tab1: `ssh:${NEW_ID}@@relay-generation@@pty-3`,
|
||||
tab3: `ssh:other-target@@pty-1`
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ function rewriteSshPtyId(ptyId: string, oldTargetId: string, newTargetId: string
|
||||
if (!parsed || parsed.connectionId !== oldTargetId) {
|
||||
return null
|
||||
}
|
||||
return toAppSshPtyId(newTargetId, parsed.relayPtyId)
|
||||
return toAppSshPtyId(newTargetId, parsed.relayPtyId, parsed.relayInstanceId)
|
||||
}
|
||||
|
||||
function rewriteSshPtyIdRecordValues(
|
||||
|
||||
Reference in New Issue
Block a user