mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
fix: fence terminal session ownership
This commit is contained in:
@@ -19,5 +19,6 @@ describe('foreground-confirmation daemon protocol', () => {
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(33)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(34)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(35)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(36)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
|
||||
describe('daemon protocol version', () => {
|
||||
it('ships bounded history transfer after the 2031-unsubscribe fact', () => {
|
||||
expect(PROTOCOL_VERSION).toBe(36)
|
||||
expect(PROTOCOL_VERSION).toBe(37)
|
||||
expect(CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION).toBe(36)
|
||||
expect(ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION).toBe(35)
|
||||
expect(CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION).toBe(34)
|
||||
@@ -33,7 +33,7 @@ describe('daemon protocol version', () => {
|
||||
expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26)
|
||||
expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual(
|
||||
Array.from({ length: 35 }, (_, index) => index + 1)
|
||||
Array.from({ length: 36 }, (_, index) => index + 1)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export class DaemonPtyAdapterSubscriptionFanout {
|
||||
|
||||
constructor(
|
||||
private readonly adapters: readonly DaemonPtyAdapter[],
|
||||
onAdapterExit: (adapter: DaemonPtyAdapter, id: string) => void,
|
||||
onAdapterExit: (adapter: DaemonPtyAdapter, payload: DaemonPtyRouterExitEvent) => void,
|
||||
onAdapterIdentityChanged?: (adapter: DaemonPtyAdapter) => void
|
||||
) {
|
||||
for (const adapter of adapters) {
|
||||
@@ -21,7 +21,7 @@ export class DaemonPtyAdapterSubscriptionFanout {
|
||||
}
|
||||
}),
|
||||
adapter.onExit((payload) => {
|
||||
onAdapterExit(adapter, payload.id)
|
||||
onAdapterExit(adapter, payload)
|
||||
for (const listener of this.exitListeners) {
|
||||
listener(payload)
|
||||
}
|
||||
|
||||
@@ -300,6 +300,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
pendingClaimSpawnOperations: Set<typeof pendingClaim>
|
||||
sessionIncarnations: Map<string, string>
|
||||
}
|
||||
const originalIdentity = internals.lastAuthenticatedIdentity
|
||||
internals.lastAuthenticatedIdentity = {
|
||||
...internals.lastAuthenticatedIdentity,
|
||||
launchNonce: 'replacement-daemon'
|
||||
@@ -313,6 +314,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
expect(exits).toEqual([])
|
||||
expect(internals.activeSessionIds.has(sessionId)).toBe(true)
|
||||
expect(internals.sessionIncarnations.get(sessionId)).toBe(created.incarnationId)
|
||||
internals.lastAuthenticatedIdentity = originalIdentity
|
||||
internals.pendingClaimSpawnOperations.delete(pendingClaim)
|
||||
})
|
||||
|
||||
@@ -780,6 +782,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
client: DaemonClient
|
||||
lastAuthenticatedIdentity: { pid: number; startedAtMs: number; launchNonce: string }
|
||||
}
|
||||
const originalIdentity = internals.lastAuthenticatedIdentity
|
||||
internals.lastAuthenticatedIdentity = {
|
||||
pid: process.pid,
|
||||
startedAtMs: Date.now() + 10_000,
|
||||
@@ -804,6 +807,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
|
||||
expect(exits).toEqual([])
|
||||
expect(adapter.hasPty(spawned.id)).toBe(true)
|
||||
internals.lastAuthenticatedIdentity = originalIdentity
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -433,6 +433,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
|
||||
getTerminalOwnerIdentity(id: string): TerminalOwnerIdentity | null {
|
||||
if (!this.hasExactSessionAuthority(id)) {
|
||||
return null
|
||||
}
|
||||
return this.ownerIdentity(this.sessionIncarnations.get(id)) ?? null
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
this.ownerResolver = new DaemonSessionOwnerResolver(this.allAdapters(), this.sessionAdapters)
|
||||
this.subscriptions = new DaemonPtyAdapterSubscriptionFanout(
|
||||
this.allAdapters(),
|
||||
(adapter, id) => {
|
||||
this.ownerResolver.forgetRoute(id, adapter)
|
||||
(adapter, payload) => {
|
||||
this.ownerResolver.forgetRoute(payload.id, adapter, payload.incarnationId)
|
||||
},
|
||||
(adapter) => this.ownerResolver.invalidateProvider(adapter)
|
||||
)
|
||||
|
||||
@@ -1263,7 +1263,11 @@ export class DaemonServer {
|
||||
case 'write':
|
||||
try {
|
||||
this.lastInputAtBySessionId.set(request.payload.sessionId, performance.now())
|
||||
this.host.write(request.payload.sessionId, request.payload.data)
|
||||
this.host.write(
|
||||
request.payload.sessionId,
|
||||
request.payload.data,
|
||||
request.payload.expectedIncarnationId
|
||||
)
|
||||
} catch (err) {
|
||||
this.lastInputAtBySessionId.delete(request.payload.sessionId)
|
||||
if (err instanceof SessionNotFoundError) {
|
||||
@@ -1280,7 +1284,12 @@ export class DaemonServer {
|
||||
|
||||
case 'resize':
|
||||
try {
|
||||
this.host.resize(request.payload.sessionId, request.payload.cols, request.payload.rows)
|
||||
this.host.resize(
|
||||
request.payload.sessionId,
|
||||
request.payload.cols,
|
||||
request.payload.rows,
|
||||
request.payload.expectedIncarnationId
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof SessionNotFoundError) {
|
||||
this.sendExitEvent(
|
||||
|
||||
@@ -9,7 +9,6 @@ it('preserves A topology when compatible replacement B reports A absent', async
|
||||
sessionIncarnationId: 'session-a',
|
||||
logicalPtyId: 'logical-pty'
|
||||
})
|
||||
const ownerA = Object.freeze({ id: 'daemon-a', reachable: false })
|
||||
const replacementB = {
|
||||
listProcesses: vi.fn(async () => []),
|
||||
probePtyLiveness: vi.fn(async () => false)
|
||||
@@ -27,11 +26,10 @@ it('preserves A topology when compatible replacement B reports A absent', async
|
||||
const liveMarkerIo = ['agent-a-still-running']
|
||||
let freshShells = 0
|
||||
|
||||
const verdict = await (
|
||||
resolver as unknown as {
|
||||
probe: (id: string, expectedIncarnationId?: string) => Promise<boolean | null>
|
||||
}
|
||||
).probe(persistedOwner.logicalPtyId, persistedOwner.sessionIncarnationId)
|
||||
const verdict = await resolver.probe(
|
||||
persistedOwner.logicalPtyId,
|
||||
persistedOwner.sessionIncarnationId
|
||||
)
|
||||
if (verdict === false) {
|
||||
topology.savedRows = []
|
||||
topology.tabBindings = {}
|
||||
@@ -39,7 +37,6 @@ it('preserves A topology when compatible replacement B reports A absent', async
|
||||
freshShells += 1
|
||||
}
|
||||
|
||||
expect(ownerA.reachable).toBe(false)
|
||||
expect(verdict).toBeNull()
|
||||
expect(replacementB.probePtyLiveness).not.toHaveBeenCalled()
|
||||
expect(replacementB.listProcesses).not.toHaveBeenCalled()
|
||||
@@ -48,6 +45,6 @@ it('preserves A topology when compatible replacement B reports A absent', async
|
||||
tabBindings: { 'tab-1': 'logical-pty' },
|
||||
paneBindings: { 'tab-1:leaf-1': 'logical-pty' }
|
||||
})
|
||||
expect(liveMarkerIo).toEqual(['agent-a-still-running'])
|
||||
expect(liveMarkerIo).toHaveLength(1)
|
||||
expect(freshShells).toBe(0)
|
||||
})
|
||||
|
||||
@@ -40,7 +40,10 @@ export function resolveDaemonSessionOwnerInventory<T extends IPtyProvider>(
|
||||
(!ownerProviders || ownerProviders.has(exactProvider)) &&
|
||||
(inventory.complete || expectedIncarnationIsAuthoritative)
|
||||
? exactProvider
|
||||
: !expectedIncarnationIsAuthoritative && inventory.complete
|
||||
: !expectedIncarnationIsAuthoritative &&
|
||||
inventory.complete &&
|
||||
(!expectedOwnerIdentity ||
|
||||
(soleProvider !== undefined && ownerProviders?.has(soleProvider)))
|
||||
? soleProvider
|
||||
: undefined
|
||||
if (!provider) {
|
||||
|
||||
@@ -106,7 +106,7 @@ export class DaemonSessionOwnerResolver<T extends IPtyProvider> {
|
||||
}
|
||||
}
|
||||
if (routed && this.routes.get(opts.sessionId) === routed) {
|
||||
this.forgetRoute(opts.sessionId, routed)
|
||||
this.forgetRoute(opts.sessionId, routed, routedIncarnation)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,9 @@ export class DaemonSessionOwnerResolver<T extends IPtyProvider> {
|
||||
return null
|
||||
}
|
||||
const verdict = routed.probePtyLiveness
|
||||
? await routed.probePtyLiveness(sessionId, routedIncarnation, expectedOwnerIdentity)
|
||||
? await (expectedOwnerIdentity === undefined
|
||||
? routed.probePtyLiveness(sessionId, routedIncarnation)
|
||||
: routed.probePtyLiveness(sessionId, routedIncarnation, expectedOwnerIdentity))
|
||||
: (routed.hasPty?.(sessionId) ?? null)
|
||||
if (
|
||||
this.routes.get(sessionId) !== routed ||
|
||||
@@ -224,10 +226,13 @@ export class DaemonSessionOwnerResolver<T extends IPtyProvider> {
|
||||
this.routeIncarnations.set(sessionId, incarnationId)
|
||||
}
|
||||
|
||||
forgetRoute(sessionId: string, provider?: T): void {
|
||||
forgetRoute(sessionId: string, provider?: T, incarnationId?: string): void {
|
||||
if (provider && this.routes.get(sessionId) !== provider) {
|
||||
return
|
||||
}
|
||||
if (incarnationId !== undefined && this.routeIncarnations.get(sessionId) !== incarnationId) {
|
||||
return
|
||||
}
|
||||
this.routes.delete(sessionId)
|
||||
this.routeIncarnations.delete(sessionId)
|
||||
this.cachedInventory = null
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from './degraded-daemon-session-routing'
|
||||
import { DegradedDaemonFreshSpawnRouter } from './degraded-daemon-fresh-spawn-routing'
|
||||
import { DegradedDaemonOwnerRecovery } from './degraded-daemon-owner-recovery'
|
||||
import type { DaemonPtyRouterExitEvent } from './daemon-pty-router-events'
|
||||
|
||||
export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
readonly isDegraded = true
|
||||
@@ -31,7 +32,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
private ownerRecovery: DegradedDaemonOwnerRecovery
|
||||
private unsubscribers: (() => void)[] = []
|
||||
private dataListeners: ((payload: PtyDataEvent) => void)[] = []
|
||||
private exitListeners: ((payload: { id: string; code: number }) => void)[] = []
|
||||
private exitListeners: ((payload: DaemonPtyRouterExitEvent) => void)[] = []
|
||||
|
||||
constructor(opts: {
|
||||
current: DaemonPtyAdapter
|
||||
@@ -263,7 +264,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
return trackedUnsubscribe
|
||||
}
|
||||
|
||||
onExit(callback: (payload: { id: string; code: number }) => void): () => void {
|
||||
onExit(callback: (payload: DaemonPtyRouterExitEvent) => void): () => void {
|
||||
this.exitListeners.push(callback)
|
||||
return () => {
|
||||
const idx = this.exitListeners.indexOf(callback)
|
||||
@@ -313,11 +314,12 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
|
||||
fanoutCurrentDaemonSyntheticExits(code: number): void {
|
||||
for (const id of this.getCurrentDaemonSessionIds()) {
|
||||
const incarnationId = this.current.getTerminalOwnerIdentity?.(id)?.sessionIncarnationId
|
||||
this.sessionProviders.delete(id)
|
||||
// Why: restart kills listed sessions even when the adapter did not track them active.
|
||||
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
|
||||
for (const listener of [...this.exitListeners]) {
|
||||
listener({ id, code })
|
||||
listener({ id, code, ...(incarnationId ? { incarnationId } : {}) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,4 +112,21 @@ describe('TerminalHost incarnation fencing', () => {
|
||||
expect(initialClient.onData).toHaveBeenCalledWith('still-owned-by-current-client')
|
||||
expect(staleClient.onData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects stale writes and resizes before reaching a replacement session', async () => {
|
||||
const created = await host.createOrAttach({
|
||||
sessionId: 'session-1',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
expect(() => host.write('session-1', 'stale', 'old-incarnation')).toThrow()
|
||||
expect(subprocess.write).not.toHaveBeenCalled()
|
||||
expect(() => host.resize('session-1', 120, 40, 'old-incarnation')).toThrow()
|
||||
expect(subprocess.resize).not.toHaveBeenCalled()
|
||||
host.write('session-1', 'current', created.incarnationId)
|
||||
host.resize('session-1', 120, 40, created.incarnationId)
|
||||
expect(subprocess.write).toHaveBeenCalledWith('current')
|
||||
expect(subprocess.resize).toHaveBeenCalledWith(120, 40)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -138,16 +138,16 @@ export class TerminalHost {
|
||||
}
|
||||
}
|
||||
|
||||
write(sessionId: string, data: string): void {
|
||||
this.getAliveSession(sessionId).write(data)
|
||||
write(sessionId: string, data: string, expectedIncarnationId?: string): void {
|
||||
this.getAliveSession(sessionId, expectedIncarnationId).write(data)
|
||||
}
|
||||
|
||||
closeStartupQueryAuthority(sessionId: string): number {
|
||||
return this.getAliveSession(sessionId).closeStartupQueryAuthority()
|
||||
}
|
||||
|
||||
resize(sessionId: string, cols: number, rows: number): void {
|
||||
this.getAliveSession(sessionId).resize(cols, rows)
|
||||
resize(sessionId: string, cols: number, rows: number, expectedIncarnationId?: string): void {
|
||||
this.getAliveSession(sessionId, expectedIncarnationId).resize(cols, rows)
|
||||
}
|
||||
|
||||
// Why null-not-throw (unlike write/resize): pause/resume are best-effort hints against a session that may have exited.
|
||||
@@ -310,9 +310,13 @@ export class TerminalHost {
|
||||
this.killedTombstones.clear()
|
||||
}
|
||||
|
||||
private getAliveSession(sessionId: string): Session {
|
||||
private getAliveSession(sessionId: string, expectedIncarnationId?: string): Session {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || !session.isAlive) {
|
||||
if (
|
||||
!session ||
|
||||
!session.isAlive ||
|
||||
(expectedIncarnationId !== undefined && session.incarnationId !== expectedIncarnationId)
|
||||
) {
|
||||
throw new SessionNotFoundError(sessionId)
|
||||
}
|
||||
return session
|
||||
|
||||
@@ -394,7 +394,7 @@ describe('registerPtyHandlers', () => {
|
||||
id: 'pty-claimed-admission',
|
||||
incarnationId: 'unrelated-incarnation'
|
||||
})
|
||||
).toBe(true)
|
||||
).toBe(false)
|
||||
await expect(controller.spawn(request)).resolves.toMatchObject({
|
||||
id: 'pty-claimed-admission',
|
||||
agentSessionEnsure: { disposition: 'created' }
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
setLocalPtyProvider,
|
||||
unregisterSshPtyProvider
|
||||
} from './pty'
|
||||
import { TerminalSessionExitedError } from '../daemon/daemon-errors'
|
||||
|
||||
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
|
||||
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
|
||||
@@ -67,7 +68,7 @@ describe('registerPtyHandlers', () => {
|
||||
const freshPtyId = `ssh:${connectionId}@@fresh-relay-pty`
|
||||
const remoteSpawn = vi.fn(async (options: { attachOnly?: boolean; command?: string }) => {
|
||||
if (options.attachOnly) {
|
||||
throw new Error('PTY "dead-relay-pty" not found')
|
||||
throw new TerminalSessionExitedError(deadPtyId)
|
||||
}
|
||||
return { id: freshPtyId, incarnationId: 'inc-fresh-ssh-owner' }
|
||||
})
|
||||
|
||||
@@ -101,12 +101,12 @@ describe('stopping a PTY whose SSH provider is unregistered', () => {
|
||||
deletePtyOwnership('ssh-detached-pty')
|
||||
})
|
||||
|
||||
it('reports an unconfirmed exact stop instead of a fabricated teardown', async () => {
|
||||
it('preserves runtime ownership when an exact stop cannot reach its provider', async () => {
|
||||
setPtyOwnership('ssh-detached-stop', 'ssh-dropped')
|
||||
const { controller, runtime } = installController()
|
||||
|
||||
await expect(controller.stopAndWait('ssh-detached-stop')).resolves.toBe(false)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('ssh-detached-stop', -1, undefined)
|
||||
expect(runtime.onPtyExit).not.toHaveBeenCalled()
|
||||
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
|
||||
'ssh-detached-stop',
|
||||
expect.stringContaining('SSH')
|
||||
@@ -230,7 +230,7 @@ describe('stopping a PTY whose SSH provider is unregistered', () => {
|
||||
'relay disconnected during kill'
|
||||
)
|
||||
)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith(ptyId, -1, undefined)
|
||||
expect(runtime.onPtyExit).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
deletePtyOwnership(ptyId)
|
||||
unregisterSshPtyProvider(connectionId)
|
||||
|
||||
+40
-20
@@ -315,7 +315,11 @@ const ptyIncarnationById = new Map<string, string>()
|
||||
|
||||
export function isCurrentPtyExit(payload: { id: string; incarnationId?: string }): boolean {
|
||||
const current = ptyIncarnationById.get(payload.id)
|
||||
return payload.incarnationId !== undefined && (!current || payload.incarnationId === current)
|
||||
// Providers without incarnation support (notably local PTY) remain valid when
|
||||
// no incarnation was recorded; once one exists, an omitted token is ambiguous.
|
||||
return current === undefined
|
||||
? payload.incarnationId === undefined
|
||||
: payload.incarnationId === current
|
||||
}
|
||||
// Why: mobile clients must mirror desktop PTY geometry even before the renderer can provide an xterm snapshot (e.g. right after tab creation).
|
||||
const ptySizes = new Map<string, { cols: number; rows: number }>()
|
||||
@@ -842,13 +846,18 @@ type StablePaneSpawnContext = {
|
||||
onFreshSpawn?: (result: PtySpawnResult) => void
|
||||
}
|
||||
|
||||
function stablePanePersistenceFence(
|
||||
owner: StablePaneOwner | null
|
||||
): { ptyId: string; incarnationId?: string } | undefined {
|
||||
function stablePanePersistenceFence(owner: StablePaneOwner | null):
|
||||
| {
|
||||
ptyId: string
|
||||
incarnationId?: string
|
||||
ownerIdentity?: TerminalOwnerIdentity | null
|
||||
}
|
||||
| undefined {
|
||||
return owner?.hasPersistedBinding
|
||||
? {
|
||||
ptyId: owner.ptyId,
|
||||
...(owner.persistedIncarnationId ? { incarnationId: owner.persistedIncarnationId } : {})
|
||||
...(owner.persistedIncarnationId ? { incarnationId: owner.persistedIncarnationId } : {}),
|
||||
ownerIdentity: owner.ownerIdentity ?? null
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -2606,6 +2615,11 @@ export function registerPtyHandlers(
|
||||
ptyOwnership.delete(id)
|
||||
markClaudePtyExited(id)
|
||||
runtime?.onPtyExit(id, code, incarnationId, cause ? { cause } : undefined)
|
||||
sendPtyExitToRenderer({
|
||||
id,
|
||||
code,
|
||||
...(incarnationId ? { incarnationId } : {})
|
||||
})
|
||||
},
|
||||
onData: (id, data, timestamp, sequenceChars, transformed) =>
|
||||
runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed)
|
||||
@@ -4136,23 +4150,25 @@ export function registerPtyHandlers(
|
||||
acceptPtyDataForRenderer(payload, outputSeq)
|
||||
})
|
||||
localExitUnsub = localProvider.onExit((payload) => {
|
||||
// Fence local exits in configure().onExit before cleanup removes their incarnation.
|
||||
if (isLocalProvider) {
|
||||
return
|
||||
}
|
||||
if (!isCurrentPtyExit(payload)) {
|
||||
return
|
||||
}
|
||||
if (consumeSyntheticKillExit(payload.id)) {
|
||||
return
|
||||
}
|
||||
if (!isLocalProvider) {
|
||||
clearProviderPtyState(payload.id)
|
||||
ptyOwnership.delete(payload.id)
|
||||
markClaudePtyExited(payload.id)
|
||||
runtime?.onPtyExit(
|
||||
payload.id,
|
||||
payload.code,
|
||||
payload.incarnationId,
|
||||
payload.cause ? { cause: payload.cause } : undefined
|
||||
)
|
||||
}
|
||||
clearProviderPtyState(payload.id)
|
||||
ptyOwnership.delete(payload.id)
|
||||
markClaudePtyExited(payload.id)
|
||||
runtime?.onPtyExit(
|
||||
payload.id,
|
||||
payload.code,
|
||||
payload.incarnationId,
|
||||
payload.cause ? { cause: payload.cause } : undefined
|
||||
)
|
||||
// Why not the whole payload: the exit cause is a main-process fact for the
|
||||
// runtime's records; the renderer's pty:exit contract stays as it was.
|
||||
sendPtyExitToRenderer({
|
||||
@@ -5807,9 +5823,8 @@ export function registerPtyHandlers(
|
||||
provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId)
|
||||
} catch {
|
||||
if (connectionId) {
|
||||
// Why: the relay lease must still be tombstoned, but an absent SSH
|
||||
// provider is lost contact — the remote PTY is designed to survive it,
|
||||
// so nothing here observed an exit to report as a confirmed stop.
|
||||
// Why: an absent SSH provider is lost contact — the remote PTY is designed
|
||||
// to survive it, so nothing here observed an exit to report as a confirmed stop.
|
||||
runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON)
|
||||
}
|
||||
return false
|
||||
@@ -7890,7 +7905,12 @@ export function registerPtyHandlers(
|
||||
_event,
|
||||
args: { id: string; paneKey?: string; worktreeId?: string }
|
||||
): Promise<boolean | null> => {
|
||||
if (typeof args?.id !== 'string' || args.id.startsWith('remote:')) {
|
||||
if (
|
||||
typeof args?.id !== 'string' ||
|
||||
args.id.length === 0 ||
|
||||
args.id.length > 512 ||
|
||||
args.id.startsWith('remote:')
|
||||
) {
|
||||
// Why: same routing hazard pty:kill guards against — ptyOwnership never holds
|
||||
// a runtime terminal handle and parseAppSshPtyId ignores it, so the lookup
|
||||
// falls through to the local provider and its "not in my table" reads as an
|
||||
|
||||
@@ -137,9 +137,11 @@ export function extractSessionForTransfer(
|
||||
})
|
||||
)
|
||||
transferred.terminalPtyOwnersByPaneKey = Object.fromEntries(
|
||||
Object.entries(source.terminalPtyOwnersByPaneKey ?? {}).filter(([paneKey]) => {
|
||||
Object.entries(source.terminalPtyOwnersByPaneKey ?? {}).flatMap(([paneKey, owner]) => {
|
||||
const separator = paneKey.lastIndexOf(':')
|
||||
return separator > 0 && copiedTerminalTabIds.has(paneKey.slice(0, separator))
|
||||
? [[paneKey, structuredClone(owner)] as const]
|
||||
: []
|
||||
})
|
||||
)
|
||||
transferred.terminalSurfaceTombstonesByPaneKey = Object.fromEntries(
|
||||
|
||||
@@ -355,6 +355,21 @@ describe('Store', () => {
|
||||
expect(
|
||||
store.getWorkspaceSession().terminalPtyOwnersByPaneKey?.[`tab-owner:${TEST_LEAF_1}`]
|
||||
).toEqual(ownerIdentity)
|
||||
expect(
|
||||
store.persistPtyBinding({
|
||||
worktreeId: 'wt1',
|
||||
tabId: 'tab-owner',
|
||||
leafId: TEST_LEAF_1,
|
||||
ptyId: 'pty-owner',
|
||||
incarnationId: 'session-a',
|
||||
ownerIdentity: { ...ownerIdentity, ownerIncarnationId: 'daemon-b' },
|
||||
expectedBinding: {
|
||||
ptyId: 'pty-owner',
|
||||
incarnationId: 'session-a',
|
||||
ownerIdentity: { ...ownerIdentity, ownerIncarnationId: 'daemon-b' }
|
||||
}
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('reconciles only the incarnation of an unchanged durable PTY binding', async () => {
|
||||
|
||||
@@ -118,7 +118,10 @@ import {
|
||||
type RetiredNameRegistry
|
||||
} from '../../../shared/worktree/retired-name-registry'
|
||||
import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../../../shared/worktree/id'
|
||||
import type { TerminalOwnerIdentity } from '../../../shared/terminal-owner-identity'
|
||||
import {
|
||||
sameTerminalOwnerIdentity,
|
||||
type TerminalOwnerIdentity
|
||||
} from '../../../shared/terminal-owner-identity'
|
||||
import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner'
|
||||
import { isPathInsideOrEqual } from '../../../shared/cross-platform-path'
|
||||
import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands'
|
||||
@@ -3230,7 +3233,12 @@ export class Store {
|
||||
incarnationId?: string
|
||||
ownerIdentity?: TerminalOwnerIdentity
|
||||
startupCwd?: string
|
||||
expectedBinding?: { ptyId: string; incarnationId?: string }
|
||||
expectedBinding?: {
|
||||
ptyId: string
|
||||
incarnationId?: string
|
||||
/** Present (including null) to compare the persisted owner tuple. */
|
||||
ownerIdentity?: TerminalOwnerIdentity | null
|
||||
}
|
||||
expectedSourceBinding?: PtyBindingSourceExpectation
|
||||
/** Set by host-initiated creates, which have no renderer session writer behind them. */
|
||||
hostAdmittedMembership?: boolean
|
||||
@@ -3266,10 +3274,20 @@ export class Store {
|
||||
(candidate) => candidate.id === args.tabId && candidate.worktreeId === bindingWorktreeId
|
||||
)
|
||||
const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId]
|
||||
const expectedOwnerMatches =
|
||||
!('ownerIdentity' in args.expectedBinding) ||
|
||||
(args.expectedBinding.ownerIdentity === null
|
||||
? session.terminalPtyOwnersByPaneKey?.[paneKey] === undefined
|
||||
: sameTerminalOwnerIdentity(
|
||||
session.terminalPtyOwnersByPaneKey?.[paneKey],
|
||||
args.expectedBinding.ownerIdentity
|
||||
))
|
||||
if (
|
||||
!tab ||
|
||||
boundPtyId !== args.expectedBinding.ptyId ||
|
||||
session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== args.expectedBinding.incarnationId
|
||||
session.terminalPtyIncarnationsByPaneKey?.[paneKey] !==
|
||||
args.expectedBinding.incarnationId ||
|
||||
!expectedOwnerMatches
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -8,7 +8,26 @@ import {
|
||||
visitPtyProcessListingsInBatches
|
||||
} from './pty-process-list-admission'
|
||||
|
||||
const ownerIdentity = {
|
||||
executionHostId: 'local' as const,
|
||||
ownerKind: 'daemon' as const,
|
||||
ownerIncarnationId: 'daemon-a',
|
||||
sessionIncarnationId: 'session-a',
|
||||
protocolVersion: 37
|
||||
}
|
||||
|
||||
describe('PtyProcessListAdmission', () => {
|
||||
it('retains a validated terminal owner identity', () => {
|
||||
expect(
|
||||
new PtyProcessListAdmission().admit({
|
||||
id: 'pty-1',
|
||||
cwd: '/repo',
|
||||
title: 'shell',
|
||||
ownerIdentity
|
||||
})
|
||||
).toMatchObject({ ownerIdentity })
|
||||
})
|
||||
|
||||
it('strips unknown provider payloads from admitted process metadata', () => {
|
||||
const admission = new PtyProcessListAdmission()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { isAgentSessionOwnerBinding } from '../../shared/agent-session-host-auth
|
||||
import { MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES } from '../../shared/claimed-agent-pty-owner'
|
||||
import { cloneAgentSessionOwnerBinding } from '../../shared/claimed-agent-pty-owner-snapshot'
|
||||
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import { isTerminalOwnerIdentity } from '../../shared/terminal-owner-identity'
|
||||
import type { PtyProcessInfo } from './types'
|
||||
|
||||
export const MAX_AGGREGATED_PTY_PROCESS_LIST_ENTRIES = 4096
|
||||
@@ -35,6 +36,20 @@ function retainedOwnerBytes(owner: unknown, ptyId: string): number | null {
|
||||
].reduce((total, value) => total + Buffer.byteLength(value, 'utf8'), 0)
|
||||
}
|
||||
|
||||
function retainedTerminalOwnerIdentityBytes(owner: unknown): number | null {
|
||||
if (!isTerminalOwnerIdentity(owner)) {
|
||||
return null
|
||||
}
|
||||
return [
|
||||
owner.executionHostId,
|
||||
owner.ownerKind,
|
||||
owner.ownerIncarnationId,
|
||||
owner.sessionIncarnationId,
|
||||
String(owner.protocolVersion),
|
||||
...(owner.endpointRef ? [owner.endpointRef] : [])
|
||||
].reduce((total, value) => total + Buffer.byteLength(value, 'utf8'), 0)
|
||||
}
|
||||
|
||||
export class PtyProcessListAdmission {
|
||||
private entries = 0
|
||||
private retainedBytes = 0
|
||||
@@ -53,6 +68,10 @@ export class PtyProcessListAdmission {
|
||||
const terminalHandleBytes = retainedOptionalStringBytes(value.terminalHandle)
|
||||
const wslDistroBytes =
|
||||
value.wslDistro === null ? 0 : retainedOptionalStringBytes(value.wslDistro)
|
||||
const terminalOwnerIdentityBytes =
|
||||
value.ownerIdentity === undefined
|
||||
? 0
|
||||
: retainedTerminalOwnerIdentityBytes(value.ownerIdentity)
|
||||
if (
|
||||
idBytes === null ||
|
||||
cwdBytes === null ||
|
||||
@@ -60,6 +79,7 @@ export class PtyProcessListAdmission {
|
||||
worktreeIdBytes === null ||
|
||||
terminalHandleBytes === null ||
|
||||
wslDistroBytes === null ||
|
||||
terminalOwnerIdentityBytes === null ||
|
||||
(value.incarnationId !== undefined && !isPtyIncarnationId(value.incarnationId)) ||
|
||||
(value.agentSessionOwners !== undefined && !Array.isArray(value.agentSessionOwners))
|
||||
) {
|
||||
@@ -91,6 +111,7 @@ export class PtyProcessListAdmission {
|
||||
worktreeIdBytes +
|
||||
terminalHandleBytes +
|
||||
wslDistroBytes +
|
||||
terminalOwnerIdentityBytes +
|
||||
ownerBytes
|
||||
if (
|
||||
nextEntries > MAX_AGGREGATED_PTY_PROCESS_LIST_ENTRIES ||
|
||||
@@ -111,6 +132,7 @@ export class PtyProcessListAdmission {
|
||||
...(value.worktreeId !== undefined ? { worktreeId: value.worktreeId } : {}),
|
||||
...(value.terminalHandle !== undefined ? { terminalHandle: value.terminalHandle } : {}),
|
||||
...(value.wslDistro !== undefined ? { wslDistro: value.wslDistro } : {}),
|
||||
...(value.ownerIdentity !== undefined ? { ownerIdentity: { ...value.ownerIdentity } } : {}),
|
||||
...(normalizedOwners !== undefined ? { agentSessionOwners: normalizedOwners } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS = 5_000
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '../../shared/agent-session-host-authority'
|
||||
import type { PtySpawnResult } from './pty-spawn-result'
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS } from './ssh-agent-session-create-operation'
|
||||
import { SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS } from './ssh-agent-session-capability'
|
||||
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
|
||||
export type ClaimedSshSpawnValidation =
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
type PtySourceReceivingActivation
|
||||
} from '../../shared/pty-source-receiving-activation'
|
||||
import { validateClaimedSshSpawn } from './ssh-agent-session-claim-validation'
|
||||
|
||||
export const SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS = 5_000
|
||||
export { SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS } from './ssh-agent-session-capability'
|
||||
import { SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS } from './ssh-agent-session-capability'
|
||||
|
||||
export function assertSshAgentSessionCreateResult(
|
||||
result: unknown
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SshPtyProvider } from './ssh-pty-provider'
|
||||
import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors'
|
||||
import { TerminalSessionExitedError } from '../daemon/daemon-errors'
|
||||
import {
|
||||
TerminalSessionExitedError,
|
||||
TerminalSessionOwnerUnverifiedError
|
||||
} from '../daemon/daemon-errors'
|
||||
|
||||
describe('SSH PTY provider session reattach incarnation', () => {
|
||||
it('remembers the authoritative incarnation before a legacy exit arrives', async () => {
|
||||
|
||||
@@ -299,7 +299,13 @@ function rebaseOwnerBindings(
|
||||
session: WorkspaceSessionState,
|
||||
prior: WorkspaceSessionState
|
||||
): WorkspaceSessionState['terminalPtyOwnersByPaneKey'] {
|
||||
const allowed = new Set(Object.keys(rebaseIncarnationBindings(session, prior) ?? {}))
|
||||
const retainedIncarnations = rebaseIncarnationBindings(session, prior) ?? {}
|
||||
const merged = { ...session.terminalPtyOwnersByPaneKey, ...prior.terminalPtyOwnersByPaneKey }
|
||||
return Object.fromEntries(Object.entries(merged).filter(([paneKey]) => allowed.has(paneKey)))
|
||||
return Object.fromEntries(
|
||||
Object.entries(merged).filter(
|
||||
([paneKey, owner]) =>
|
||||
retainedIncarnations[paneKey] !== undefined &&
|
||||
owner.sessionIncarnationId === retainedIncarnations[paneKey]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('startup ordering', () => {
|
||||
expect(sweepEnd).toBeGreaterThan(sweepStart)
|
||||
expect(sweep).toContain('const provider = getDaemonProvider()')
|
||||
expect(sweep).toContain('if (!provider) {')
|
||||
expect(sweep).toContain('provider.probePtyLiveness(ptyId)')
|
||||
expect(sweep).toContain('provider.probePtyLiveness(ptyId, expectedIncarnationId)')
|
||||
})
|
||||
|
||||
it('bounds WSL reconciliation before serve RPC while leaving desktop startup independent', () => {
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('humanizeTerminalError', () => {
|
||||
it('replaces the pane-owner-unverified code with actionable copy', () => {
|
||||
const humanized = humanizeTerminalError('terminal_pane_owner_unverified')
|
||||
expect(humanized).not.toContain('terminal_pane_owner_unverified')
|
||||
expect(humanized).toContain('Reopen this pane to retry')
|
||||
expect(humanized).toContain('left the session and history untouched')
|
||||
})
|
||||
|
||||
it('humanizes an IPC-wrapped pane-owner-unverified error', () => {
|
||||
@@ -78,6 +78,15 @@ describe('humanizeTerminalError', () => {
|
||||
expect(humanizeTerminalError(wrapped)).not.toContain('terminal_pane_owner_unverified')
|
||||
})
|
||||
|
||||
it('hides the owner-conflict diagnostic from user-facing copy', () => {
|
||||
const humanized = humanizeTerminalError(
|
||||
"terminal_pane_owner_conflict Error invoking remote method 'pty:spawn': Error: terminal_pane_owner_unverified"
|
||||
)
|
||||
expect(humanized).not.toContain('terminal_pane_owner_conflict')
|
||||
expect(humanized).not.toContain('Error invoking remote method')
|
||||
expect(humanized).toContain('left the session and history untouched')
|
||||
})
|
||||
|
||||
it('leaves other errors untouched', () => {
|
||||
expect(humanizeTerminalError('Paste failed.')).toBe('Paste failed.')
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ServerOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { resolveClientEnvironmentFooter } from '@/lib/client-environment-info'
|
||||
import { hasClientEnvironmentFooter } from '../../../../shared/client-environment-info'
|
||||
@@ -17,7 +19,13 @@ const STALE_DAEMON_CWD_MARKERS = [
|
||||
'node-pty: daemon_cwd failed: ENOENT'
|
||||
]
|
||||
// Thrown by ipc/pty.ts when a persisted pane owner can't be proven alive or dead (STA-3536).
|
||||
const PANE_OWNER_UNVERIFIED_MARKER = 'terminal_pane_owner_unverified'
|
||||
const PANE_OWNER_UNVERIFIED_MARKERS = [
|
||||
'terminal_pane_owner_unverified',
|
||||
'terminal_pane_owner_conflict'
|
||||
]
|
||||
export function isPaneOwnerUnverifiedError(error: string): boolean {
|
||||
return PANE_OWNER_UNVERIFIED_MARKERS.some((marker) => error.includes(marker))
|
||||
}
|
||||
// Why one source: the test and replace forms must match the same token, and a lone /g regex carries
|
||||
// lastIndex state across .test() calls. Capture the leading boundary so replacement can restore it.
|
||||
const TERMINAL_HOST_GONE_SOURCE = '(^|[^a-z0-9_])terminal_host_gone(?=$|[^a-z0-9_])'
|
||||
@@ -93,16 +101,13 @@ function humanizeUnreattachableSession(error: string): string {
|
||||
|
||||
/** Swaps raw daemon-boundary codes for copy a user can act on. */
|
||||
export function humanizeTerminalError(error: string): string {
|
||||
let humanized = error
|
||||
if (humanized.includes(PANE_OWNER_UNVERIFIED_MARKER)) {
|
||||
humanized = humanized.replace(
|
||||
PANE_OWNER_UNVERIFIED_MARKER,
|
||||
translate(
|
||||
'auto.components.terminal.pane.TerminalErrorToast.7ee11bc0db',
|
||||
"Orca couldn't confirm whether this terminal's previous session is still running, so it left the session untouched. Reopen this pane to retry."
|
||||
)
|
||||
if (isPaneOwnerUnverifiedError(error)) {
|
||||
return translate(
|
||||
'auto.components.terminal.pane.TerminalErrorToast.7ee11bc0db',
|
||||
"Orca couldn't confirm whether this terminal's previous session is still running, so it left the session and history untouched."
|
||||
)
|
||||
}
|
||||
let humanized = error
|
||||
humanized = humanizeUnreattachableSession(humanized)
|
||||
if (!isExplainedTerminalError(humanized)) {
|
||||
return humanized
|
||||
@@ -128,16 +133,22 @@ export function humanizeTerminalError(error: string): string {
|
||||
export function TerminalErrorToast({
|
||||
error,
|
||||
onDismiss,
|
||||
onRestartDaemon
|
||||
onRestartDaemon,
|
||||
onRetry,
|
||||
terminalLabel = 'Terminal session'
|
||||
}: {
|
||||
error: string
|
||||
onDismiss: () => void
|
||||
onRestartDaemon?: () => void
|
||||
onRetry?: () => void
|
||||
terminalLabel?: string
|
||||
}): React.JSX.Element {
|
||||
const ssh = isSshError(error)
|
||||
const ownerUnavailable = isPaneOwnerUnverifiedError(error)
|
||||
const showDaemonRestart = !ssh && onRestartDaemon && shouldOfferDaemonRestart(error)
|
||||
const showRetry = ownerUnavailable && onRetry !== undefined
|
||||
// Restart cannot recover a session after its owning daemon exits.
|
||||
const showIssueLink = !ssh && !showDaemonRestart && !isExplainedTerminalError(error)
|
||||
const showIssueLink = !ssh && !showDaemonRestart && !showRetry && !isExplainedTerminalError(error)
|
||||
const displayError = humanizeTerminalError(error)
|
||||
const [environmentFooter, setEnvironmentFooter] = useState<{
|
||||
error: string
|
||||
@@ -162,6 +173,55 @@ export function TerminalErrorToast({
|
||||
|
||||
const footer = environmentFooter?.error === displayError ? environmentFooter.footer : ''
|
||||
|
||||
if (ownerUnavailable) {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-3 bottom-3 z-40 flex justify-center"
|
||||
data-terminal-owner-unavailable-banner
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto flex w-full max-w-xl items-center gap-3 rounded-md border border-border bg-card px-3 py-3 text-card-foreground shadow-xs"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-muted text-muted-foreground">
|
||||
<ServerOff className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="shrink-0 text-sm font-semibold">
|
||||
{translate(
|
||||
'auto.components.terminal.pane.TerminalErrorToast.ownerUnavailableTitle',
|
||||
'Terminal session unavailable'
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="truncate font-medium">{terminalLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.terminal.pane.TerminalErrorToast.ownerUnavailableDetail',
|
||||
"Orca couldn't confirm whether the previous session is still running. The tab and session are preserved; retry when the owner is available."
|
||||
)}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground [overflow-wrap:anywhere]">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Button className="shrink-0" size="sm" onClick={onRetry}>
|
||||
{translate(
|
||||
'auto.components.terminal.pane.TerminalErrorToast.retryConnection',
|
||||
'Retry connection'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -172,9 +232,12 @@ export function TerminalErrorToast({
|
||||
zIndex: 50,
|
||||
padding: '10px 14px',
|
||||
borderRadius: 6,
|
||||
background: ssh ? 'rgba(234, 179, 8, 0.12)' : 'rgba(220, 38, 38, 0.15)',
|
||||
border: ssh ? '1px solid rgba(234, 179, 8, 0.35)' : '1px solid rgba(220, 38, 38, 0.4)',
|
||||
color: ssh ? '#fde68a' : '#fca5a5',
|
||||
background: ssh || ownerUnavailable ? 'rgba(234, 179, 8, 0.12)' : 'rgba(220, 38, 38, 0.15)',
|
||||
border:
|
||||
ssh || ownerUnavailable
|
||||
? '1px solid rgba(234, 179, 8, 0.35)'
|
||||
: '1px solid rgba(220, 38, 38, 0.4)',
|
||||
color: ssh || ownerUnavailable ? '#fde68a' : '#fca5a5',
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
@@ -235,12 +298,34 @@ export function TerminalErrorToast({
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{showRetry ? (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
style={{
|
||||
marginLeft: 12,
|
||||
border: '1px solid rgba(252, 165, 165, 0.45)',
|
||||
borderRadius: 6,
|
||||
background: 'rgba(127, 29, 29, 0.35)',
|
||||
color: '#fecaca',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
padding: '4px 8px',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.terminal.pane.TerminalErrorToast.retryConnection',
|
||||
'Retry connection'
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: ssh ? '#fde68a' : '#fca5a5',
|
||||
color: ssh || ownerUnavailable ? '#fde68a' : '#fca5a5',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
padding: '0 0 0 8px',
|
||||
|
||||
@@ -59,7 +59,12 @@ import { resolveLeafCloseCopyKind } from '../terminal/terminal-close-copy-kind'
|
||||
import { RUNNING_CLOSE_PROBE_TIMEOUT_MS } from '../terminal/running-terminal-close-guard'
|
||||
import CodexRestartChip from '../CodexRestartChip'
|
||||
import { MobileDriverOverlay } from './MobileDriverOverlay'
|
||||
import { stripSshReconnectOwnedErrorLines, TerminalErrorToast } from './TerminalErrorToast'
|
||||
import {
|
||||
isPaneOwnerUnverifiedError,
|
||||
stripSshReconnectOwnedErrorLines,
|
||||
TerminalErrorToast
|
||||
} from './TerminalErrorToast'
|
||||
import { TERMINAL_PANE_OWNER_UNVERIFIED } from '../../../../shared/terminal-pane-owner-verdict'
|
||||
import { TerminalProcessExitOverlay } from './TerminalProcessExitOverlay'
|
||||
import { TerminalSessionStateSaveFailureDialog } from './TerminalSessionStateSaveFailureDialog'
|
||||
import TerminalContextMenu from './TerminalContextMenu'
|
||||
@@ -501,6 +506,10 @@ function TerminalPane(
|
||||
transport.notifyErrorSurfaceDismissed?.()
|
||||
}
|
||||
}, [])
|
||||
const retryTerminalConnection = useCallback(() => {
|
||||
setTerminalError(null)
|
||||
useAppStore.getState().remountTerminalTabForRecovery(tabId)
|
||||
}, [tabId])
|
||||
const onPtyRecoveryStateRef = useRef(
|
||||
(paneId: number, state: PtyTransportRecoveryState | null) => {
|
||||
setPtyRecoveryStatesByPaneId((previous) =>
|
||||
@@ -2923,6 +2932,32 @@ function TerminalPane(
|
||||
|
||||
const activePane = managerRef.current?.getActivePane()
|
||||
const managedPanes = managerRef.current?.getPanes() ?? []
|
||||
// A daemon handoff can leave the renderer waiting on a preserved PTY without
|
||||
// producing an IPC error. Surface the same explicit retry state instead of
|
||||
// leaving the user with an unexplained blank pane.
|
||||
useEffect(() => {
|
||||
const currentPanes = managerRef.current?.getPanes() ?? []
|
||||
if (!isActive || !isVisible || currentPanes.length === 0) {
|
||||
return
|
||||
}
|
||||
const savedPtyIds = Object.values(savedLayout.ptyIdsByLeafId ?? {}).filter(Boolean)
|
||||
if (savedPtyIds.length === 0) {
|
||||
return
|
||||
}
|
||||
const hasUnboundPane = currentPanes.some(
|
||||
(pane) => paneTransportsRef.current.get(pane.id)?.getPtyId() == null
|
||||
)
|
||||
if (!hasUnboundPane) {
|
||||
if (terminalError && isPaneOwnerUnverifiedError(terminalError)) {
|
||||
setTerminalError(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
setTerminalError((current) => current ?? TERMINAL_PANE_OWNER_UNVERIFIED)
|
||||
}, 1_500)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [isActive, isVisible, managerRef, paneCount, paneLayoutRevision, savedLayout, terminalError])
|
||||
const showSshReconnectOverlay = Boolean(
|
||||
isActive &&
|
||||
isVisible &&
|
||||
@@ -3076,6 +3111,8 @@ function TerminalPane(
|
||||
<TerminalErrorToast
|
||||
error={terminalError}
|
||||
onDismiss={dismissTerminalError}
|
||||
onRetry={isPaneOwnerUnverifiedError(terminalError) ? retryTerminalConnection : undefined}
|
||||
terminalLabel={terminalTab?.title ?? 'Terminal session'}
|
||||
onRestartDaemon={() => daemonActions.setPending('restart')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
+5
-8
@@ -741,17 +741,14 @@ describe('connectPanePty', () => {
|
||||
1,
|
||||
expect.objectContaining({ sessionId: staleSessionId })
|
||||
)
|
||||
expect(transport.connect).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.not.objectContaining({ sessionId: expect.any(String) })
|
||||
)
|
||||
expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', staleSessionId)
|
||||
expect(transport.connect).toHaveBeenCalledTimes(1)
|
||||
expect(deps.clearTabPtyId).not.toHaveBeenCalledWith('tab-1', staleSessionId)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('--- session restored ---'),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1)
|
||||
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(2, 'restored')
|
||||
expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey)
|
||||
expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
+5
-9
@@ -424,10 +424,8 @@ describe('connectPanePty', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('applies the fresh-shell reset when a spawn is answered with a cold-restore reattach', async () => {
|
||||
// Why: main can answer a *spawn* with an adopted session, so the reattach handler
|
||||
// is reachable by a second door that skips the restored-session path entirely. The
|
||||
// cold-restore signal has to survive that door too, or #12101 returns on it.
|
||||
it('does not spawn a replacement shell when reattach ownership is unverifiable', async () => {
|
||||
// Unknown ownership must not turn an adopted pane into a fresh shell.
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('tab-pty')
|
||||
let activePtyId = 'tab-pty'
|
||||
@@ -464,7 +462,7 @@ describe('connectPanePty', () => {
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
await flushAsyncTicks(30)
|
||||
|
||||
expect(transport.connect).toHaveBeenCalledTimes(2)
|
||||
expect(transport.connect).toHaveBeenCalledTimes(1)
|
||||
expect(transport.connect.mock.calls[0]?.[0]?.sessionId).toBe('tab-pty')
|
||||
expect(transport.connect.mock.calls[1]?.[0]?.sessionId).toBeUndefined()
|
||||
const writes = (pane.terminal.write as ReturnType<typeof vi.fn>).mock.calls.map(
|
||||
@@ -472,10 +470,8 @@ describe('connectPanePty', () => {
|
||||
)
|
||||
const output = writes.join('')
|
||||
const snapshotIndex = output.indexOf('\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ')
|
||||
const resetIndex = output.indexOf(POST_REPLAY_MODE_RESET)
|
||||
expect(snapshotIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(resetIndex).toBeGreaterThan(snapshotIndex)
|
||||
expect(writes).toContain(POST_REPLAY_MODE_RESET)
|
||||
expect(snapshotIndex).toBe(-1)
|
||||
expect(writes).not.toContain(POST_REPLAY_MODE_RESET)
|
||||
expect(writes).not.toContain(POST_REPLAY_LIVE_AGENT_REATTACH_RESET)
|
||||
})
|
||||
})
|
||||
|
||||
+1
-1
@@ -438,7 +438,7 @@ describe('connectPanePty', () => {
|
||||
connectPanePty(createPane(2) as never, createManager(2) as never, deps as never)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(transport.connect).toHaveBeenCalledTimes(2)
|
||||
expect(transport.connect).toHaveBeenCalledTimes(1)
|
||||
expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
|
||||
|
||||
@@ -74,7 +74,7 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi
|
||||
}
|
||||
|
||||
if (connectResult?.exitedBeforeAttach) {
|
||||
// Why: the transport already delivered the dead session's final frame + exit; treat as terminal state, not a failed reattach.
|
||||
// Ordinary terminal exits are final; do not replace them with a new shell.
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
+11
@@ -1,14 +1,25 @@
|
||||
import { requestTerminalPaneRecovery } from '../terminal-pane-recovery'
|
||||
import { TERMINAL_PANE_OWNER_UNVERIFIED } from '../../../../../shared/terminal-pane-owner-verdict'
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function recoverUnverifiableReattach(
|
||||
session: ConnectPanePtySession,
|
||||
ptyId: string | null | undefined
|
||||
): void {
|
||||
const reportOwnerUnverifiable = (): void => {
|
||||
session.deps?.onPtyErrorRef?.current?.(session.pane?.id ?? 0, TERMINAL_PANE_OWNER_UNVERIFIED)
|
||||
}
|
||||
if (session.directSshRetryAttempt) {
|
||||
reportOwnerUnverifiable()
|
||||
session.settleDirectSshPaneRetryAttempt(session.directSshRetryAttempt, 'failed')
|
||||
return
|
||||
}
|
||||
// Keep the pane mounted so the user gets an explicit retry action. An
|
||||
// automatic remount made an owner-routing failure look like a blank pane.
|
||||
if (session.deps?.onPtyErrorRef?.current) {
|
||||
reportOwnerUnverifiable()
|
||||
return
|
||||
}
|
||||
void requestTerminalPaneRecovery({
|
||||
tabId: session.deps.tabId,
|
||||
ptyId: ptyId ?? null,
|
||||
|
||||
@@ -123,6 +123,32 @@ describe('createIpcPtyTransport', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves a named TerminalSessionExitedError before IPC message extraction', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const error = new Error('transport closed')
|
||||
error.name = 'TerminalSessionExitedError'
|
||||
const spawnMock = vi.fn().mockRejectedValue(error)
|
||||
;(globalThis as { window: typeof window }).window = {
|
||||
...originalWindow,
|
||||
api: {
|
||||
...originalWindow?.api,
|
||||
pty: {
|
||||
...originalWindow?.api?.pty,
|
||||
spawn: spawnMock,
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
onData: vi.fn(() => () => {}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn(() => () => {})
|
||||
}
|
||||
}
|
||||
} as unknown as typeof window
|
||||
await expect(
|
||||
createIpcPtyTransport().connect({ url: '', sessionId: 'pty-dead', callbacks: {} })
|
||||
).resolves.toEqual({ id: 'pty-dead', exitedBeforeAttach: true })
|
||||
})
|
||||
|
||||
it('does not reinterpret an unexpected reattach failure as an ownership verdict', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const spawnMock = vi.fn().mockRejectedValue(new Error('unexpected provider failure'))
|
||||
|
||||
@@ -965,7 +965,7 @@ 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 && isTerminalSessionExited(msg)) {
|
||||
if (options.sessionId && (isTerminalSessionExited(err) || isTerminalSessionExited(msg))) {
|
||||
return {
|
||||
id: options.sessionId,
|
||||
exitedBeforeAttach: true
|
||||
|
||||
@@ -2930,7 +2930,10 @@
|
||||
"a7e2fd2699": "file an issue",
|
||||
"5c8ce20be6": "If this persists, please",
|
||||
"cc6d997c65": "Restart the terminal daemon from here to clear stale daemon state.",
|
||||
"7ee11bc0db": "Orca couldn't confirm whether this terminal's previous session is still running, so it left the session untouched. Reopen this pane to retry.",
|
||||
"7ee11bc0db": "Orca couldn't confirm whether this terminal's previous session is still running, so it left the session and history untouched.",
|
||||
"retryConnection": "Retry connection",
|
||||
"ownerUnavailableTitle": "Terminal session unavailable",
|
||||
"ownerUnavailableDetail": "Orca couldn't confirm whether the previous session is still running. The tab and session are preserved; retry when the owner is available.",
|
||||
"e16012e31e": "The terminal daemon that owned this session exited, so the session and its scrollback could not be recovered. Open a new terminal to continue.",
|
||||
"sessionUnavailable": "Orca couldn't reattach to this pane's terminal session on the host. Open a new terminal to continue."
|
||||
},
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"appId": "com.stablyai.orca",
|
||||
"stateSchemaVersion": 1,
|
||||
"readableStateSchemaVersions": [1],
|
||||
"daemonProtocolVersion": 36,
|
||||
"daemonProtocolVersion": 37,
|
||||
"attachableDaemonProtocolVersions": [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30, 31, 32, 33, 34, 35, 36
|
||||
27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = {
|
||||
appId: 'com.stablyai.orca',
|
||||
stateSchemaVersion: 1,
|
||||
readableStateSchemaVersions: [1],
|
||||
daemonProtocolVersion: 36,
|
||||
daemonProtocolVersion: 37,
|
||||
attachableDaemonProtocolVersions: [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30, 31, 32, 33, 34, 35, 36
|
||||
27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37
|
||||
]
|
||||
} as const
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
import { terminalOwnerIdentitySchema } from './terminal-owner-identity-schema'
|
||||
import { salvagedOptional, salvagingRecord } from './zod-salvage'
|
||||
|
||||
export const terminalPtyOwnersByPaneKeySchema = salvagedOptional(
|
||||
'terminalPtyOwnersByPaneKey',
|
||||
salvagingRecord(z.string(), terminalOwnerIdentitySchema)
|
||||
)
|
||||
@@ -19,6 +19,7 @@ describe('terminal owner identity', () => {
|
||||
expect(isTerminalOwnerIdentity(identity)).toBe(true)
|
||||
expect(isTerminalOwnerIdentity({ ...identity, ownerIncarnationId: '' })).toBe(false)
|
||||
expect(isTerminalOwnerIdentity({ ...identity, protocolVersion: 0 })).toBe(false)
|
||||
expect(isTerminalOwnerIdentity({ ...identity, ownerKind: 'unknown' })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not treat a replacement owner as the same authority', () => {
|
||||
|
||||
@@ -33,7 +33,12 @@ export function isTerminalOwnerIdentity(value: unknown): value is TerminalOwnerI
|
||||
return (
|
||||
typeof candidate.executionHostId === 'string' &&
|
||||
normalizeExecutionHostId(candidate.executionHostId) !== null &&
|
||||
typeof candidate.ownerKind === 'string' &&
|
||||
(candidate.ownerKind === 'daemon' ||
|
||||
candidate.ownerKind === 'ssh' ||
|
||||
candidate.ownerKind === 'wsl' ||
|
||||
candidate.ownerKind === 'relay' ||
|
||||
candidate.ownerKind === 'paired-runtime' ||
|
||||
candidate.ownerKind === 'local-direct') &&
|
||||
typeof candidate.ownerIncarnationId === 'string' &&
|
||||
candidate.ownerIncarnationId.length > 0 &&
|
||||
typeof candidate.sessionIncarnationId === 'string' &&
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
browserWorkspaceSchema
|
||||
} from './workspace-session-browser-schema'
|
||||
import { sleepingAgentSessionsByPaneKeySchema } from './workspace-session-sleeping-agents'
|
||||
import { terminalOwnerIdentitySchema } from './terminal-owner-identity-schema'
|
||||
import { terminalPtyOwnersByPaneKeySchema } from './terminal-owner-bindings-schema'
|
||||
import { salvagedField, salvagedOptional, salvagingArray, salvagingRecord } from './zod-salvage'
|
||||
|
||||
// ─── Terminal pane layout (recursive) ───────────────────────────────
|
||||
@@ -311,10 +311,7 @@ export const workspaceSessionStateSchema: z.ZodType<WorkspaceSessionState> = z.o
|
||||
'terminalPtyIncarnationsByPaneKey',
|
||||
salvagingRecord(z.string(), z.string().min(1).max(128))
|
||||
),
|
||||
terminalPtyOwnersByPaneKey: salvagedOptional(
|
||||
'terminalPtyOwnersByPaneKey',
|
||||
salvagingRecord(z.string(), terminalOwnerIdentitySchema)
|
||||
),
|
||||
terminalPtyOwnersByPaneKey: terminalPtyOwnersByPaneKeySchema,
|
||||
terminalTopologyRevisionByRepoId: salvagedOptional(
|
||||
'terminalTopologyRevisionByRepoId',
|
||||
salvagingRecord(z.string(), z.number().int().nonnegative())
|
||||
|
||||
Reference in New Issue
Block a user