Recover exported terminal handles from live PTYs (#7162)

* Recover exported terminal handles from live PTYs

* Guard terminal-handle adoption: first-wins, no collisions

Discovery adoption of ORCA_TERMINAL_HANDLE is now skipped when the pty
already has a handle bound this session (re-keying would strand waiters
registered under the old handle) or when the reported handle is already
bound to a different pty (provider-reported values are not trusted to be
collision-free). Also adds the relay why-comment and PtyProcessSummary
type alias from review feedback.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-02 23:06:32 -07:00
committed by GitHub
co-authored by Orca
parent bf6a39987c
commit fa94065a81
12 changed files with 228 additions and 21 deletions
+9 -3
View File
@@ -19,7 +19,12 @@ import {
type SessionInfo,
type TakePendingOutputResult
} from './types'
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
import type {
IPtyProvider,
PtyProcessInfo,
PtySpawnOptions,
PtySpawnResult
} from '../providers/types'
import { isShellProcess } from '../../shared/agent-detection'
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
@@ -517,7 +522,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
return { alive, killed }
}
async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
async listProcesses(): Promise<PtyProcessInfo[]> {
await this.ensureConnected()
const result = await this.client.request<ListSessionsResult>('listSessions', undefined)
return result.sessions
@@ -525,7 +530,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
.map((s) => ({
id: s.sessionId,
cwd: s.cwd ?? '',
title: 'shell'
title: 'shell',
...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {})
}))
}
+7 -2
View File
@@ -1,5 +1,10 @@
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
import type {
IPtyProvider,
PtyProcessInfo,
PtySpawnOptions,
PtySpawnResult
} from '../providers/types'
export class DaemonPtyRouter implements IPtyProvider {
private current: DaemonPtyAdapter
@@ -124,7 +129,7 @@ export class DaemonPtyRouter implements IPtyProvider {
await this.current.revive(state)
}
async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
async listProcesses(): Promise<PtyProcessInfo[]> {
// Why: runtime exact-stop/liveness flows must fail closed if any adapter
// cannot provide a trustworthy process list.
const results = await Promise.all(this.allAdapters().map((adapter) => adapter.listProcesses()))
@@ -1,5 +1,10 @@
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
import type {
IPtyProvider,
PtyProcessInfo,
PtySpawnOptions,
PtySpawnResult
} from '../providers/types'
type ManagedPtyProvider = IPtyProvider & {
disconnectOnly?: () => Promise<void>
@@ -135,7 +140,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
await this.fallback.revive(state)
}
async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
async listProcesses(): Promise<PtyProcessInfo[]> {
const results = await Promise.all(
this.allProviders().map((provider) => provider.listProcesses())
)
+3
View File
@@ -56,6 +56,7 @@ export type SessionOptions = {
sessionId: string
cols: number
rows: number
terminalHandle?: string
subprocess: SubprocessHandle
shellReadySupported: boolean
shellReadyTimeoutMs?: number
@@ -76,6 +77,7 @@ type AttachedClient = {
export class Session {
readonly sessionId: string
readonly terminalHandle: string | null
private _state: SessionState = 'running'
private _shellState: ShellReadyState
private _exitCode: number | null = null
@@ -97,6 +99,7 @@ export class Session {
constructor(opts: SessionOptions) {
this.sessionId = opts.sessionId
this.terminalHandle = opts.terminalHandle ?? null
this.subprocess = opts.subprocess
this.onSessionExit = opts.onExit
const size = normalizePtySize(opts.cols, opts.rows)
+2
View File
@@ -136,6 +136,7 @@ export class TerminalHost {
sessionId: opts.sessionId,
cols: size.cols,
rows: size.rows,
terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE,
subprocess,
shellReadySupported: opts.shellReadySupported ?? false,
// Why: reap the dead session (dispose emulator + drop from the map) the
@@ -301,6 +302,7 @@ export class TerminalHost {
state: session.state,
shellState: session.shellState,
isAlive: true,
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
pid: session.pid,
cwd: session.getCwd(),
cols: size?.cols ?? 0,
+1
View File
@@ -324,6 +324,7 @@ export type SessionInfo = {
state: SessionState
shellState: ShellReadyState
isAlive: boolean
terminalHandle?: string
pid: number | null
cwd: string | null
cols: number
+9 -3
View File
@@ -21,7 +21,7 @@ import {
updateHistFileForFallback,
logHistoryInjection
} from '../terminal-history'
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types'
import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types'
import {
ensureNodePtySpawnHelperExecutable,
validateWorkingDirectory,
@@ -67,6 +67,7 @@ let ptyCounter = 0
const ptyProcesses = new Map<string, pty.IPty>()
const ptyShellName = new Map<string, string>()
const ptyAgentForegroundContextPaths = new Map<string, string[]>()
const ptyTerminalHandle = new Map<string, string>()
// Why: node-pty's onData/onExit register native NAPI ThreadSafeFunction
// callbacks. If the PTY is killed without disposing these listeners, the
// stale callbacks survive into node::FreeEnvironment() where NAPI attempts
@@ -187,6 +188,7 @@ function clearPtyState(id: string): void {
ptyProcesses.delete(id)
ptyShellName.delete(id)
ptyAgentForegroundContextPaths.delete(id)
ptyTerminalHandle.delete(id)
ptyLoadGeneration.delete(id)
}
@@ -680,6 +682,9 @@ export class LocalPtyProvider implements IPtyProvider {
const proc = spawnResult.process
ptyProcesses.set(id, proc)
ptyShellName.set(id, getSpawnedShellName(shellPath))
if (finalEnv.ORCA_TERMINAL_HANDLE) {
ptyTerminalHandle.set(id, finalEnv.ORCA_TERMINAL_HANDLE)
}
ptyAgentForegroundContextPaths.set(
id,
getAgentForegroundContextPaths({ cwd: args.cwd, worktreeId: args.worktreeId })
@@ -945,11 +950,12 @@ export class LocalPtyProvider implements IPtyProvider {
/* re-spawning handles local revival */
}
async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
async listProcesses(): Promise<PtyProcessInfo[]> {
return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({
id,
cwd: '',
title: proc.process || ptyShellName.get(id) || 'shell'
title: proc.process || ptyShellName.get(id) || 'shell',
...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {})
}))
}
+3 -3
View File
@@ -1,5 +1,5 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types'
import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types'
import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id'
import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env'
@@ -254,9 +254,9 @@ export class SshPtyProvider implements IPtyProvider {
await this.mux.request('pty.revive', { state })
}
async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
async listProcesses(): Promise<PtyProcessInfo[]> {
const result = await this.mux.request('pty.listProcesses')
return (result as { id: string; cwd: string; title: string }[]).map((session) => ({
return (result as PtyProcessInfo[]).map((session) => ({
...session,
id: this.toAppPtyId(session.id)
}))
+9 -1
View File
@@ -99,6 +99,14 @@ export type PtySpawnResult = {
}
}
export type PtyProcessInfo = {
id: string
cwd: string
title: string
/** Trusted ORCA_TERMINAL_HANDLE exported into this PTY, when known. */
terminalHandle?: string
}
export type IPtyProvider = {
spawn(opts: PtySpawnOptions): Promise<PtySpawnResult>
attach(id: string): Promise<void>
@@ -128,7 +136,7 @@ export type IPtyProvider = {
getForegroundProcess(id: string): Promise<string | null>
serialize(ids: string[]): Promise<string>
revive(state: string): Promise<void>
listProcesses(): Promise<{ id: string; cwd: string; title: string }[]>
listProcesses(): Promise<PtyProcessInfo[]>
getDefaultShell(): Promise<string>
getProfiles(): Promise<{ name: string; path: string }[]>
onData(callback: (payload: { id: string; data: string }) => void): () => void
+106
View File
@@ -10497,6 +10497,112 @@ describe('OrcaRuntimeService', () => {
expect(read.tail).toEqual(['ready'])
})
it('recovers exported ORCA_TERMINAL_HANDLE from discovered live PTY sessions', async () => {
const runtime = new OrcaRuntimeService(store)
const writes: string[] = []
runtime.setPtyController({
write: (_ptyId, data) => {
writes.push(data)
return true
},
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [
{
id: 'pty-1',
cwd: TEST_WORKTREE_PATH,
title: 'claude',
terminalHandle: 'term_exported'
}
]
})
const listed = await runtime.listTerminals()
expect(listed.terminals[0]?.handle).toBe('term_exported')
runtime.onPtyData('pty-1', 'after restart\n', 100)
await expect(runtime.readTerminal('term_exported')).resolves.toMatchObject({
handle: 'term_exported',
tail: ['after restart']
})
await expect(
runtime.sendTerminal('term_exported', { text: 'still writable' })
).resolves.toMatchObject({
handle: 'term_exported',
accepted: true
})
expect(writes).toEqual(['still writable'])
})
it('does not adopt a discovered terminal handle already bound to another live PTY', async () => {
const runtime = new OrcaRuntimeService(store)
const writesByPty = new Map<string, string[]>()
runtime.setPtyController({
write: (ptyId, data) => {
writesByPty.set(ptyId, [...(writesByPty.get(ptyId) ?? []), data])
return true
},
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [
{
id: 'pty-victim',
cwd: TEST_WORKTREE_PATH,
title: 'claude',
terminalHandle: 'term_victim'
},
{
id: 'pty-imposter',
cwd: TEST_WORKTREE_PATH,
title: 'claude',
terminalHandle: 'term_victim'
}
]
})
const listed = await runtime.listTerminals()
const handles = listed.terminals.map((terminal) => terminal.handle)
expect(handles).toContain('term_victim')
expect(new Set(handles).size).toBe(handles.length)
await expect(
runtime.sendTerminal('term_victim', { text: 'for victim' })
).resolves.toMatchObject({ accepted: true })
expect(writesByPty.get('pty-victim')).toEqual(['for victim'])
expect(writesByPty.has('pty-imposter')).toBe(false)
})
it('keeps an already-bound terminal handle when discovery reports a different exported one', async () => {
const runtime = new OrcaRuntimeService(store)
const writes: string[] = []
runtime.setPtyController({
write: (_ptyId, data) => {
writes.push(data)
return true
},
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [
{
id: 'pty-1',
cwd: TEST_WORKTREE_PATH,
title: 'claude',
terminalHandle: 'term_from_env'
}
]
})
runtime.registerPreAllocatedHandleForPty('pty-1', 'term_already_bound')
const listed = await runtime.listTerminals()
expect(listed.terminals[0]?.handle).toBe('term_already_bound')
await expect(
runtime.sendTerminal('term_already_bound', { text: 'still routed' })
).resolves.toMatchObject({ accepted: true })
expect(writes).toEqual(['still routed'])
// the reported-but-not-adopted handle must not resolve to the live pty
await expect(runtime.readTerminal('term_from_env')).rejects.toThrow()
})
it('binds advertised URLs for renderer-restored PTYs that skip registerPty', () => {
const runtime = new OrcaRuntimeService(store)
+43 -2
View File
@@ -661,7 +661,7 @@ import { closeLocalWatcherForWorktreePath } from '../ipc/filesystem-watcher'
import { HeadlessEmulator, type HeadlessEmulatorOptions } from '../daemon/headless-emulator'
import { killAllProcessesForWorktree } from './worktree-teardown'
import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits'
import type { IFilesystemProvider, IPtyProvider } from '../providers/types'
import type { IFilesystemProvider, IPtyProvider, PtyProcessInfo } from '../providers/types'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
assertFolderWorkspacePathUsable,
@@ -1037,7 +1037,7 @@ type RuntimePtyController = {
hasChildProcesses?(ptyId: string): Promise<boolean>
clearBuffer?(ptyId: string): Promise<void>
resize?(ptyId: string, cols: number, rows: number): boolean
listProcesses?(): Promise<{ id: string; cwd: string; title: string }[]>
listProcesses?(): Promise<PtyProcessInfo[]>
serializeBuffer?(
ptyId: string,
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
@@ -5041,6 +5041,46 @@ export class OrcaRuntimeService {
}
}
private adoptControllerTerminalHandle(ptyId: string, handle: string | undefined): void {
const trimmed = handle?.trim()
if (!trimmed || !trimmed.startsWith('term_')) {
return
}
if (this.isTerminalHandleAdoptionBlocked(ptyId, trimmed)) {
return
}
// Why: after an app/runtime restart, the live PTY child still has its
// original ORCA_TERMINAL_HANDLE, but the runtime's in-memory map is gone.
this.registerPreAllocatedHandleForPty(ptyId, trimmed)
}
// Why: adoption is best-effort restart recovery and must be first-wins.
// Re-keying a pty that already has a handle this session would strand
// waiters registered under the old handle, and provider-reported values
// are not trusted to be collision-free — a handle bound to a different
// pty must never be stolen by a later report.
private isTerminalHandleAdoptionBlocked(ptyId: string, handle: string): boolean {
if (this.handleByPtyId.get(ptyId) ?? this.findHandleForPtyRecord(ptyId)) {
return true
}
for (const leaf of this.getLeavesForPty(ptyId)) {
const issued = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId))
if (issued && issued !== handle) {
return true
}
}
const existingRecord = this.handles.get(handle)
if (existingRecord && existingRecord.ptyId !== ptyId) {
return true
}
for (const [otherPtyId, otherHandle] of this.handleByPtyId) {
if (otherHandle === handle && otherPtyId !== ptyId) {
return true
}
}
return false
}
onPtySpawned(ptyId: string): void {
const pty = this.getOrCreatePtyWorktreeRecord(ptyId)
if (pty) {
@@ -17789,6 +17829,7 @@ export class OrcaRuntimeService {
const sessions = sessionsResult.value
const livePtyIds = new Set(sessions.map((session) => session.id))
for (const session of sessions) {
this.adoptControllerTerminalHandle(session.id, session.terminalHandle)
const worktreeId =
inferWorktreeIdFromPtyId(session.id) ??
findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd)
+29 -5
View File
@@ -62,6 +62,7 @@ type ManagedPty = {
paneKey?: string
tabId?: string
worktreeId?: string
terminalHandle?: string
startupCommand?: ManagedStartupCommand
}
@@ -156,6 +157,13 @@ function resolvePtyShellOverride(shellOverride: string): string {
return resolveWindowsGitBashShellPath(shellOverride) ?? shellOverride
}
type PtyProcessSummary = {
id: string
cwd: string
title: string
terminalHandle?: string
}
type SerializedPtyEntry = {
id: string
pid: number
@@ -165,6 +173,7 @@ type SerializedPtyEntry = {
paneKey?: string
tabId?: string
worktreeId?: string
terminalHandle?: string
}
export type PtyExitListener = (event: { id: string; paneKey?: string }) => void
@@ -539,6 +548,10 @@ export class PtyHandler {
// for overlay resolution; runtime-owned PTYs opt into relay delivery
// because no renderer TerminalPane exists to type the command.
const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined
// Why: kept so a restarted runtime can re-adopt this live PTY under its
// originally-exported handle (reported via listProcesses, survives revive).
const terminalHandle =
typeof env?.ORCA_TERMINAL_HANDLE === 'string' ? env.ORCA_TERMINAL_HANDLE : undefined
const command = typeof params.command === 'string' ? params.command : undefined
const terminalWindowsWslDistro =
typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null
@@ -589,6 +602,7 @@ export class PtyHandler {
paneKey,
tabId,
worktreeId,
...(terminalHandle ? { terminalHandle } : {}),
...(shouldProviderDeliverCommand
? {
startupCommand: {
@@ -819,12 +833,17 @@ export class PtyHandler {
return await getForegroundProcessName(managed.pty.pid, managed.pty.process || null)
}
private async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
const results: { id: string; cwd: string; title: string }[] = []
private async listProcesses(): Promise<PtyProcessSummary[]> {
const results: PtyProcessSummary[] = []
for (const [id, managed] of this.ptys) {
const title =
(await getForegroundProcessName(managed.pty.pid, managed.pty.process || null)) || 'shell'
results.push({ id, cwd: managed.initialCwd, title })
results.push({
id,
cwd: managed.initialCwd,
title,
...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {})
})
}
return results
}
@@ -846,7 +865,8 @@ export class PtyHandler {
cwd: managed.initialCwd,
paneKey: managed.paneKey,
tabId: managed.tabId,
worktreeId: managed.worktreeId
worktreeId: managed.worktreeId,
...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {})
})
}
return JSON.stringify(entries)
@@ -884,6 +904,9 @@ export class PtyHandler {
if (entry.worktreeId) {
revivedEnv.ORCA_WORKTREE_ID = entry.worktreeId
}
if (entry.terminalHandle) {
revivedEnv.ORCA_TERMINAL_HANDLE = entry.terminalHandle
}
const shell = resolveDefaultShell()
// Why: `command` is intentionally absent from this revive path because
// SerializedPtyEntry (see line 99) does not persist it — ManagedPty
@@ -914,7 +937,8 @@ export class PtyHandler {
buffered: '',
paneKey: entry.paneKey,
tabId: entry.tabId,
worktreeId: entry.worktreeId
worktreeId: entry.worktreeId,
...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {})
})
// Why: nextId starts at 1 and is only incremented by spawn(). Revived