diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index cd203933e4f..21c677324c8 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3586,7 +3586,8 @@ { "file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts", "assertions": [ - "a rejected teardown kill forwards exact PTY identity to main and later renderer lifecycle does not duplicate the retry" + "a rejected teardown kill forwards exact PTY identity to main and later renderer lifecycle does not duplicate the retry", + "a rejected generation-qualified daemon admission clears its same-id pre-handler bytes and exit before a later successful spawn" ] }, { @@ -3670,6 +3671,8 @@ "an older alive readback cannot erase a newer deferred replacement-generation exit", "a synchronous generation-qualified exit rejects the dead spawn result before callers can republish ownership", "100 failed admissions with unknown readback and no published owner release every exit-fence map", + "same-id spawn admissions serialize while different session ids remain concurrent", + "100 native spawn failures publish no owner and retain zero queue or fence entries", "authoritative list absence clears active ownership and every exit-fence map" ] }, @@ -3688,7 +3691,7 @@ "one immutable protocol-v23 generation crosses create, list, size, and exit and changes on same-id replacement", "100 synchronous buffered-output and exit subscriptions leave zero daemon stream routes or host sessions", "failed attach rollback restores only a still-active prior stream-route owner", - "two overlapping failed attaches retain zero routes while an older failure cannot dislodge a newer successful route" + "two overlapping failed attaches recover the nearest still-active live route while an older failure cannot dislodge a newer successful route" ] }, { @@ -4230,6 +4233,24 @@ "result": "passed", "durationSeconds": 13.21, "summary": "1455 tests passed: synchronous pre-reply exit rejected the dead spawn result, 100 ownerless unknown exits released every fence map, overlapping failed routes retained zero owners, and an older failure preserved the newer successful route." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/session.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/daemon-session-exit-fence.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-terminal-close-retry-owner.test.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 19.94, + "summary": "1458 tests passed: same-id admissions serialized, different ids remained concurrent, 100 native spawn failures retained zero queue/fence owners, and overlapping route failures recovered the live predecessor." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "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", + "result": "passed", + "durationSeconds": 8.1, + "summary": "220 tests passed including rejected-admission pre-handler cleanup, Windows relay patch parity, renderer teardown settlement, purge retry ownership, and VM cleanup propagation." } ], "runtimeBudget": { @@ -4242,7 +4263,7 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "The built relay artifact harness records the vanilla npm dependency at close=0, native-kill=0, deferred=1 and the shipped patched artifact at close=1, native-kill=1, deferred=0. Accepted local, daemon, and relay kills now remain provider-visible until onExit or conservative OS liveness proves physical exit; Windows retries stay signal-free and only native throws reissue the one-shot close. Same-id exits that race daemon or fallback replacement spawn admission are deferred, then suppressed only when the exact child provider proves the replacement live; overlapping same-id spawn is rejected before provider work can overwrite that proof. Injected daemon native failures propagate without reaping the live session, while ESRCH after accepted or throwing native close reaps the exact local/relay owner even when node-pty omits onExit. Whole-process daemon and relay drains retain listeners and retry ownership until exit proof or the bounded process-exit deadline, and an older 5-second graceful timer is replaced by the shutdown cadence. RPC drain rejection is contained before explicit detached-process exit. Stale relay spawns retain one timer beyond the eighth failure, graceful fallback throws stay below relay-global uncaughtException, and a standalone cold 51-request relay admission deterministically creates 50 live PTYs plus one cap rejection through one shared addon load. Protocol-v23 generations plus epoch-bound admission publication reject old exits, stale absence, and post-clear completion; authoritative absence releases every map. Stream routes are published before subscription, invalidated by their own exit/failure even when not current, and restored on failed attach only while the predecessor remains active; 100 synchronous output/exit cycles and two overlapping failures retain zero routes or sessions, while an older failure cannot dislodge a newer successful route. Replacement-generation exits are queued before cross-socket readback while admission is active, older alive readbacks cannot erase them, and authoritative reconciliation rejects a dead pre-reply spawn result before caller ownership can be republished. One hundred failed admissions with unknown readback and no published owner release every fence map. The former 65-id renderer oracle proved the oldest still-live owner was dropped; the exact 1,024-id tombstone oracle evicts only the oldest id, recovers its successor, and probes none of 100 unrelated later registrations. A disconnected SSH provider retains its lease without a synthetic exit; 50 concurrent accepted SSH owners share one provider inventory, bulk teardown preserves relay generation and pane identity in the durable owner, and target removal preserves metadata unless every PTY is exit-proved before the grace-zero relay is force-stopped. A real worktree-removal preflight deterministically rejects a late matching-host admission while allowing an unrelated host removal; fallback cleanup no longer erases state after unproved shutdown." + "evidence": "The built relay artifact harness records the vanilla npm dependency at close=0, native-kill=0, deferred=1 and the shipped patched artifact at close=1, native-kill=1, deferred=0. Accepted local, daemon, and relay kills now remain provider-visible until onExit or conservative OS liveness proves physical exit; Windows retries stay signal-free and only native throws reissue the one-shot close. Same-id exits that race daemon or fallback replacement spawn admission are deferred, then suppressed only when the exact child provider proves the replacement live; overlapping same-id spawn is rejected before provider work can overwrite that proof. Injected daemon native failures propagate without reaping the live session, while ESRCH after accepted or throwing native close reaps the exact local/relay owner even when node-pty omits onExit. Whole-process daemon and relay drains retain listeners and retry ownership until exit proof or the bounded process-exit deadline, and an older 5-second graceful timer is replaced by the shutdown cadence. RPC drain rejection is contained before explicit detached-process exit. Stale relay spawns retain one timer beyond the eighth failure, graceful fallback throws stay below relay-global uncaughtException, and a standalone cold 51-request relay admission deterministically creates 50 live PTYs plus one cap rejection through one shared addon load. Protocol-v23 generations plus epoch-bound admission publication reject old exits, stale absence, and post-clear completion; authoritative absence releases every map. Stream routes are published before subscription, invalidated by their own exit/failure even when not current, and failed overlap walks its provisional chain to the nearest still-active live owner; 100 synchronous output/exit cycles retain zero routes or sessions, while an older failure cannot dislodge a newer successful route. Same-id adapter admissions serialize before provider work while different ids remain concurrent. Replacement-generation exits are queued before cross-socket readback while admission is active, older alive readbacks cannot erase them, and authoritative reconciliation rejects a dead pre-reply spawn result before caller ownership can be republished. One hundred failed admissions with either unknown exit proof or native spawn rejection release every queue and fence map. Renderer connect recognizes that explicit admission rejection and discards only the dead generation's same-id pre-handler bytes and exit before retry. The former 65-id renderer oracle proved the oldest still-live owner was dropped; the exact 1,024-id tombstone oracle evicts only the oldest id, recovers its successor, and probes none of 100 unrelated later registrations. A disconnected SSH provider retains its lease without a synthetic exit; 50 concurrent accepted SSH owners share one provider inventory, bulk teardown preserves relay generation and pane identity in the durable owner, and target removal preserves metadata unless every PTY is exit-proved before the grace-zero relay is force-stopped. A real worktree-removal preflight deterministically rejects a late matching-host admission while allowing an unrelated host removal; fallback cleanup no longer erases state after unproved shutdown." }, "performanceBudget": { "required": true, diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index d71d1584f72..ec7cd42c7e1 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -10,6 +10,7 @@ import { HeadlessEmulator } from './headless-emulator' import { getHistorySessionDirName } from './history-paths' import type { HistoryReader } from './history-reader' import type { SubprocessHandle } from './session' +import type { TerminalHost } from './terminal-host' import type * as DaemonHealthModule from './daemon-health' import { getDaemonSocketPath } from './daemon-spawner' @@ -103,10 +104,12 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } | null let subprocessDataOnSubscribe: string | undefined let subprocessExitOnSubscribe: number | undefined + let subprocessSpawnError: Error | undefined beforeEach(async () => { subprocessDataOnSubscribe = undefined subprocessExitOnSubscribe = undefined + subprocessSpawnError = undefined dir = createTestDir() socketPath = getDaemonSocketPath(dir) tokenPath = join(dir, 'test.token') @@ -115,6 +118,9 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { socketPath, tokenPath, spawnSubprocess: (opts) => { + if (subprocessSpawnError) { + throw subprocessSpawnError + } lastSpawnOpts = opts lastSubprocess = createMockSubprocess(subprocessDataOnSubscribe, subprocessExitOnSubscribe) return lastSubprocess @@ -167,6 +173,90 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(internals.exitFence.pendingExits.size).toBe(0) }) + it('serializes same-id admissions before a later failure can overlap ownership', async () => { + const daemon = server as unknown as { host: TerminalHost } + const originalCreateOrAttach = daemon.host.createOrAttach.bind(daemon.host) + let releaseFirst!: () => void + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + const createOrAttach = vi + .spyOn(daemon.host, 'createOrAttach') + .mockImplementationOnce(async (options) => { + await firstGate + return originalCreateOrAttach(options) + }) + .mockRejectedValueOnce(new Error('later admission failed')) + + const first = adapter.spawn({ sessionId: 'serialized-admission', cols: 80, rows: 24 }) + await waitFor(() => createOrAttach.mock.calls.length === 1) + const second = adapter.spawn({ sessionId: 'serialized-admission', cols: 80, rows: 24 }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(createOrAttach).toHaveBeenCalledTimes(1) + + releaseFirst() + await expect(first).resolves.toMatchObject({ id: 'serialized-admission' }) + await expect(second).rejects.toThrow('later admission failed') + + const internals = adapter as unknown as { + sessionAdmissionTails: Map> + } + expect(createOrAttach).toHaveBeenCalledTimes(2) + expect(internals.sessionAdmissionTails.size).toBe(0) + expect(adapter.hasPty('serialized-admission')).toBe(true) + }) + + it('keeps different session admissions concurrent', async () => { + const daemon = server as unknown as { host: TerminalHost } + const originalCreateOrAttach = daemon.host.createOrAttach.bind(daemon.host) + let releaseBoth!: () => void + const gate = new Promise((resolve) => { + releaseBoth = resolve + }) + const createOrAttach = vi + .spyOn(daemon.host, 'createOrAttach') + .mockImplementation(async (options) => { + await gate + return originalCreateOrAttach(options) + }) + + const first = adapter.spawn({ sessionId: 'parallel-a', cols: 80, rows: 24 }) + const second = adapter.spawn({ sessionId: 'parallel-b', cols: 80, rows: 24 }) + await waitFor(() => createOrAttach.mock.calls.length === 2) + + releaseBoth() + await expect(Promise.all([first, second])).resolves.toHaveLength(2) + const internals = adapter as unknown as { + sessionAdmissionTails: Map> + } + expect(internals.sessionAdmissionTails.size).toBe(0) + }) + + it('releases fence and queue state after 100 ownerless spawn failures', async () => { + subprocessSpawnError = new Error('native spawn failed') + + for (let index = 0; index < 100; index++) { + await expect( + adapter.spawn({ sessionId: `ownerless-failure-${index}`, cols: 80, rows: 24 }) + ).rejects.toThrow('native spawn failed') + } + + const internals = adapter as unknown as { + activeSessionIds: Set + sessionAdmissionTails: Map> + exitFence: Record< + 'revisions' | 'admissions' | 'sessionGenerations' | 'pendingExits', + Map + > + } + expect(internals.activeSessionIds.size).toBe(0) + expect(internals.sessionAdmissionTails.size).toBe(0) + expect(internals.exitFence.revisions.size).toBe(0) + expect(internals.exitFence.admissions.size).toBe(0) + expect(internals.exitFence.sessionGenerations.size).toBe(0) + expect(internals.exitFence.pendingExits.size).toBe(0) + }) + it('fails closed when a preserved legacy session has no observable pane identity', async () => { const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 21 }) const internals = legacy as unknown as { diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index ed4df71f82e..6a8650435b9 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -36,6 +36,7 @@ import type { import { isShellProcess } from '../../shared/agent-detection' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' +import { DAEMON_PTY_EXITED_DURING_ADMISSION } from '../../shared/constants' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import { assertPtyPaneIdentity, @@ -149,6 +150,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // connect; the daemon's 5s failsafe covers the window in between. private producerResumesOwedOnReconnect = new Set() private exitFence = new DaemonSessionExitFence() + private sessionAdmissionTails = new Map>() private static CHECKPOINT_INTERVAL_MS = 5_000 // Why: a streaming session (build logs, `yes`) re-triggers a full multi-MB // snapshot checkpoint on every 5s tick via pending-buffer overflow or the @@ -198,6 +200,15 @@ export class DaemonPtyAdapter implements IPtyProvider { async spawn(opts: PtySpawnOptions): Promise { const sessionId = opts.sessionId ?? mintPtySessionId(opts.worktreeId) + return this.withSerializedSessionAdmission(sessionId, () => + this.spawnAfterAdmissionQueue(opts, sessionId) + ) + } + + private async spawnAfterAdmissionQueue( + opts: PtySpawnOptions, + sessionId: string + ): Promise { this.assertSpawnAllowed(sessionId, opts) if (opts.isNewSession) { await this.replaceUnhealthyMacResolverDaemonBeforeNewPty() @@ -497,6 +508,10 @@ export class DaemonPtyAdapter implements IPtyProvider { } async attach(id: string): Promise { + return this.withSerializedSessionAdmission(id, () => this.attachAfterAdmissionQueue(id)) + } + + private async attachAfterAdmissionQueue(id: string): Promise { const admission = this.exitFence.beginAdmission(id) try { await this.ensureConnected() @@ -1579,7 +1594,31 @@ export class DaemonPtyAdapter implements IPtyProvider { admission.complete() const exited = await this.reconcilePendingExitAfterAdmission(sessionId) if (rejectIfExited && exited) { - throw new Error(`Daemon PTY exited during admission for "${sessionId}"`) + throw new Error(`${DAEMON_PTY_EXITED_DURING_ADMISSION} for "${sessionId}"`) + } + if (!rejectIfExited && !this.activeSessionIds.has(sessionId)) { + this.exitFence.forget(sessionId) + } + } + + private async withSerializedSessionAdmission( + sessionId: string, + action: () => Promise + ): Promise { + const previous = this.sessionAdmissionTails.get(sessionId) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + this.sessionAdmissionTails.set(sessionId, current) + await previous + try { + return await action() + } finally { + release() + if (this.sessionAdmissionTails.get(sessionId) === current) { + this.sessionAdmissionTails.delete(sessionId) + } } } } diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index 78f888cf497..4062b4e2dd3 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -238,6 +238,12 @@ describe('DaemonServer', () => { spawnSubprocess: () => createMockSubprocess() }) const daemon = server as unknown as DaemonServerPrivate + await daemon.routeRequest('previous-client', { + id: 'previous-request', + type: 'createOrAttach', + payload: { sessionId: 'overlapping-failure', cols: 80, rows: 24 } + }) + expect(daemon.host.listSessions()).toHaveLength(1) let rejectOlder!: (reason: unknown) => void let rejectNewer!: (reason: unknown) => void const olderResult = new Promise((_resolve, reject) => { @@ -270,8 +276,11 @@ describe('DaemonServer', () => { rejectNewer(new Error('newer failed')) await expect(newerRequest).rejects.toThrow('newer failed') - expect(daemon.streamRouteBySessionId.has('overlapping-failure')).toBe(false) - expect(daemon.host.listSessions()).toHaveLength(0) + expect(daemon.streamRouteBySessionId.get('overlapping-failure')).toMatchObject({ + clientId: 'previous-client', + active: true + }) + expect(daemon.host.listSessions()).toHaveLength(1) }) it('keeps a newer successful route when an overlapping older attach fails', async () => { diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 40e81a352d0..56c84af4412 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -57,6 +57,7 @@ type ConnectedClient = { type SessionStreamRoute = { clientId: string active: boolean + previous?: SessionStreamRoute } export class DaemonServer { @@ -351,8 +352,12 @@ export class DaemonServer { switch (request.type) { case 'createOrAttach': { const p = request.payload - const streamRoute: SessionStreamRoute = { clientId, active: true } const previousStreamRoute = this.streamRouteBySessionId.get(p.sessionId) + const streamRoute: SessionStreamRoute = { + clientId, + active: true, + ...(previousStreamRoute ? { previous: previousStreamRoute } : {}) + } // Why before host subscription: node-pty may flush data and exit // synchronously, so routing ownership must exist before callbacks run. this.streamRouteBySessionId.set(p.sessionId, streamRoute) @@ -422,14 +427,16 @@ export class DaemonServer { // Why identity-fenced rollback: a failed older attach must not // erase a newer concurrent route for the reused session id. if (this.streamRouteBySessionId.get(p.sessionId) === streamRoute) { - if (previousStreamRoute?.active) { - this.streamRouteBySessionId.set(p.sessionId, previousStreamRoute) + const activePredecessor = this.findActiveStreamRoute(streamRoute.previous) + if (activePredecessor) { + this.streamRouteBySessionId.set(p.sessionId, activePredecessor) } else { this.streamRouteBySessionId.delete(p.sessionId) } } throw error }) + this.retireStreamRoutePredecessors(streamRoute) // Why an attach-time marker: the adapter resyncs the background set on // a fresh connection, which can precede this attach — main's scan // suppression must still start at the head of the new stream. @@ -649,6 +656,27 @@ export class DaemonServer { } } + private findActiveStreamRoute( + route: SessionStreamRoute | undefined + ): SessionStreamRoute | undefined { + let candidate = route + while (candidate && !candidate.active) { + candidate = candidate.previous + } + return candidate + } + + private retireStreamRoutePredecessors(route: SessionStreamRoute): void { + let predecessor = route.previous + route.previous = undefined + while (predecessor) { + predecessor.active = false + const next = predecessor.previous + predecessor.previous = undefined + predecessor = next + } + } + private sendExitEvent( client: ConnectedClient | undefined, sessionId: string, diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 607687820a6..97596bfba84 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -114,6 +114,39 @@ describe('createIpcPtyTransport', () => { expect(write).not.toHaveBeenCalled() }) + it('does not drain a rejected admission exit into a later same-id spawn', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + spawn + .mockImplementationOnce(async () => { + onData?.({ id: 'same-id', data: 'dead generation output' }) + onExit?.({ id: 'same-id', code: 17 }) + throw new Error('Daemon PTY exited during admission for "same-id"') + }) + .mockResolvedValueOnce({ id: 'same-id' }) + const onExitCallback = vi.fn() + const onDataCallback = vi.fn() + const transport = createIpcPtyTransport({}) + + await transport.connect({ + url: '', + sessionId: 'same-id', + callbacks: { onExit: onExitCallback, onData: onDataCallback } + }) + expect(transport.isConnected()).toBe(false) + + await transport.connect({ + url: '', + sessionId: 'same-id', + callbacks: { onExit: onExitCallback, onData: onDataCallback } + }) + + expect(transport.isConnected()).toBe(true) + expect(transport.getPtyId()).toBe('same-id') + expect(onExitCallback).not.toHaveBeenCalled() + expect(onDataCallback).not.toHaveBeenCalledWith('dead generation output') + }) + it('ignores a stale exit for a previous PTY after reconnecting the same transport', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = window.api.pty.spawn as unknown as ReturnType diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 34fd9a61a0a..790ece864eb 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -23,6 +23,7 @@ import { getEagerPtyBufferHandle } from './pty-dispatcher' import { + clearPreHandlerPtyState, drainPreHandlerPtyData, drainPreHandlerPtyExit, reconcilePreHandlerPtyExitAfterOverflow @@ -43,6 +44,7 @@ import { extractIpcErrorMessage } from '@/lib/ipc-error' import { isTuiAgent } from '../../../../shared/tui-agent-config' import { killPtyRetainingRetryOwnership } from '@/lib/pty-kill-retry-ownership' import { makePaneKey } from '../../../../shared/stable-pane-id' +import { DAEMON_PTY_EXITED_DURING_ADMISSION } from '../../../../shared/constants' // Re-export public API so existing consumers keep working. export { @@ -806,6 +808,11 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return spawnResult.id } catch (err) { const msg = extractIpcErrorMessage(err, err instanceof Error ? err.message : String(err)) + if (options.sessionId && msg.includes(DAEMON_PTY_EXITED_DURING_ADMISSION)) { + // Why: this exit belongs to the rejected generation; retaining its + // bytes or exit would tear down a later successful same-id spawn. + clearPreHandlerPtyState(options.sessionId) + } if ( connectionId && options.sessionId && diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7166cb56a29..7552045a9ee 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -70,6 +70,10 @@ export const ORCA_BROWSER_BLANK_URL = 'data:text/html,' // custom Error fields. Keep this stable token shared across main/renderer. export const SSH_TERMINATE_RECONNECT_REQUIRED = 'SSH_TERMINATE_RECONNECT_REQUIRED' +// Why: Electron invoke errors preserve message text, not custom Error fields; +// renderer reconnect must discard the exited admission's same-id byte cache. +export const DAEMON_PTY_EXITED_DURING_ADMISSION = 'Daemon PTY exited during admission' + export const BROWSER_FAMILY_LABELS: Record = { chrome: 'Google Chrome', chromium: 'Chromium',