Files
orca/src/preload/api/pty-api.ts
T
Neil a907dd2ba2 fix(pty): key buffered pre-attach PTY exits on the incarnation, not a sequence fence (#17010)
* fix(pty): key buffered pre-attach exits on the PTY incarnation, not a clock

A restarted SSH relay renumbers PTYs from pty-1, so a fresh spawn is routinely
handed an id whose previous shell is still emitting a late exit. #16970 stopped
that exit blanking the new tab by dating every buffered record and dropping
anything older than the spawn request. That fence is a clock, so it cannot judge
a stale exit that arrives AFTER the request left — the residual risk #16970
documented.

Thread the incarnation main already puts on the pty:exit payload (and the
pty:spawn reply) through preload to the pre-handler buffer, so a buffered exit
names which lifetime of the id died. An exit disagreeing with the incarnation
now attaching is discarded whenever it arrived.

Only a positive disagreement discards: absence stays "unknown", never a
mismatch, so hosts that predate the field keep #16970's behaviour exactly. The
fence is retained for the two cases with no incarnation to compare — buffered
bytes (pty:data carries none) and unnamed exits.

No wire change: incarnationId was already published on the relay's pty.exit
notification and pty.spawn reply, and already forwarded over the in-process
pty:exit / pty:spawn IPC. Only the preload types and the renderer read it now.

* fix(pty): read the incarnation through the shared guard, not truthiness

A malformed incarnation is evidence of nothing, so it must read as "unknown"
rather than as a value that disagrees with every well-formed one — otherwise a
non-string on the payload would discard the very exits the buffer exists to
deliver. Route both the record and the comparison through the existing
isPtyIncarnationId guard.

* refactor(pty): name the bounded-map helper after what it does

It evicts the oldest entry when the map is full and the id is new; it reserves
nothing. Rename only — no behaviour change.

* fix(pty): key the buffered-exit STORAGE on the incarnation too, not just the check

Review caught a swallowed exit. Keying only the comparison on the incarnation
while the storage stayed one slot per pty id left the two races this buffer
exists for able to cancel each other out:

  1. the freshly spawned shell dies before the pane attaches -> its exit (X) is
     buffered;
  2. the relay flushes the previous owner's exit for the same recycled id (W),
     which OVERWRITES X in the single slot;
  3. the spawn reply names X, so the identity discard drops W -- the only
     record left.

registerExit then finds nothing and the pane binds to a PTY that is dead and
will never be reported dead: a hang instead of the blank tab #16970 fixed.

Store one record per lifetime, capped at 4 per id, so W can never evict X. A
duplicate exit for a lifetime replaces that lifetime's record rather than
crowding out another's; drain still delivers the newest survivor, preserving
the last-write-wins behaviour a single slot always had.

* fix(pty): filter buffered exits by lifetime inside the buffer, not at call sites

Review found the identity was enforced only where connectIpcPty calls the
discard, while preHandlerPtyExit has several other consumers. The severe one is
registerEagerPtyBuffer: both background launchers spawn directly and then drain
whatever is buffered for the returned id, so a relay-recycled id holding the
previous owner's exit tore a freshly launched agent session down seconds after
it started -- no fence, no admitPtyId, no identity check at all.

Move the rule into the buffer: every read goes through
admissiblePreHandlerPtyExits, so a record proven to belong to another lifetime
is unreachable by construction rather than because each caller remembered to
discard first. hasPreHandlerPtyExit/drainPreHandlerPtyExit take the asking
lifetime; registerEagerPtyBuffer and registerExit thread it through, and both
background launchers pass the incarnation their own spawn returned.

A reader that cannot name an incarnation still sees everything, which is the
honest answer -- it holds no evidence to discriminate with. That keeps the
pre-spawn fast path in connectIpcPty behaving exactly as it does today; see the
PR for the consumers this still does not cover.

* chore: drop unrelated formatter churn in reliability-gates.jsonc

Repo-wide oxfmt reindented pre-existing entries in a file this change never
touches. Keep the diff to the PTY incarnation work.
2026-08-28 04:17:36 -07:00

235 lines
10 KiB
TypeScript

import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from '../../shared/agent-session-resume'
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
import type { PtyListedSession } from '../../shared/pty-listed-session'
import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics'
import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker'
import type {
PtyRendererDeliveryHealthReply,
PtyRendererDeliveryStateReport
} from '../../shared/pty-renderer-delivery-health'
import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events'
import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts'
import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes'
import type { TuiAgent } from '../../shared/tui-agent'
import type { PtyManagementApi } from './pty-management-api'
export type PtyApi = {
spawn: (opts: {
cols: number
rows: number
cwd?: string
cwdFallback?: 'worktree'
env?: Record<string, string>
envToDelete?: string[]
command?: string
commandDelivery?: 'renderer' | 'provider'
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
connectionId?: string | null
worktreeId?: string
sessionId?: string
// Why: lets a single tab open in a different shell than the user's default.
shellOverride?: string
projectRuntime?: ProjectExecutionRuntimeResolution
terminalColorQueryReplies?: { foreground?: string; background?: string }
// Why: mark the PTY hidden before its first byte so the delivery gate owns spawn-time queries (terminal-query-authority.md §races).
initiallyHidden?: boolean
// Why: main sync-flushes the (worktreeId,tabId,leafId→ptyId) binding before pty:spawn returns to close a SIGKILL race (INVESTIGATION.md).
tabId?: string
leafId?: string
// Why: main fires `agent_started` only on spawn success, so launch metadata rides this field (telemetry-plan.md §Agent launch semantics).
telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind }
}) => Promise<{
id: string
/** Which lifetime of `id` this reply named; absent when the execution host predates the field. */
incarnationId?: string
launchAgent?: TuiAgent
launchConfig?: SleepingAgentLaunchConfig
snapshot?: string
snapshotCols?: number
snapshotRows?: number
snapshotPrefixAnsi?: string
snapshotFrameAnsi?: string
snapshotFrameRestoreAnsi?: string
snapshotKittyKeyboardFlags?: number
snapshotTerminalOwner?: 'shell'
snapshotSeq?: number
isReattach?: boolean
isAlternateScreen?: boolean
replay?: string
sessionExpired?: boolean
coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number }
startupCwdFallback?: { kind: 'worktree'; cwd: string }
agentResumeUnavailable?: true
}>
write: (id: string, data: string) => void
writeAccepted: (id: string, data: string) => Promise<boolean>
onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void
resize: (id: string, cols: number, rows: number) => void
claimViewport: (id: string, cols: number, rows: number) => void
reportGeometry: (id: string, cols: number, rows: number) => void
signal: (id: string, signal: string) => void
clearBuffer: (id: string) => void
kill: (id: string, opts?: { keepHistory?: boolean }) => Promise<void>
ackColdRestore: (id: string) => void
ackData: (id: string, charCount: number, processedChars?: number) => void
onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void) => () => void
respondDeliveryResync: (payload: {
requestId: number
processedCharsByPty: Record<string, number>
}) => void
/** Renderer-initiated delivery health/heal lane over invoke — reaches main
* even when every main→renderer push channel is dead (field wedge). */
reportRendererDeliveryState: (
report: PtyRendererDeliveryStateReport
) => Promise<PtyRendererDeliveryHealthReply>
/** Live pty:data listener count on the preload emitter (sync) — heal-time
* discriminator between a detached listener and a dead channel. */
getPtyDataListenerCount: () => number
/** One-shot signal that this page's pty:data dispatcher is registered, so
* main can release sends held during the load/reload boot window. */
rendererDispatcherReady: () => void
setActiveRendererPty: (id: string, active: boolean) => void
setRendererPtyVisible: (id: string, visible: boolean) => void
/** Hidden-delivery gate (Phase 4): hidden=true lets main drop renderer
* byte delivery after model ingestion; reveal restores from snapshots. */
setHiddenRendererPty: (id: string, hidden: boolean) => void
/** Ref-counted-on-the-renderer delivery-interest signal that suppresses
* the hidden-delivery gate while any raw-byte consumer is registered. */
setPtyDeliveryInterest: (id: string, interested: boolean) => void
/** View-attribute bridge (Phase 5 slice 2): app-global composed terminal
* appearance push backing main's hidden-PTY OSC/DSR color replies. */
publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void
hasChildProcesses: (id: string) => Promise<boolean>
getForegroundProcess: (id: string) => Promise<string | null>
inspectProcess: (id: string) => Promise<{
foregroundProcess: string | null
hasChildProcesses: boolean
unavailable?: true
}>
confirmForegroundProcess: (id: string) => Promise<string | null>
getCwd: (id: string) => Promise<string>
getSize: (id: string) => Promise<{ cols: number; rows: number } | null>
listSessions: () => Promise<PtyListedSession[]>
getAuthoritativeBufferSnapshotCapabilities?: (
ids: string[]
) => Promise<{ id: string; authoritative: boolean | null }[]>
hasPty: (id: string) => Promise<boolean | null>
getMainBufferSnapshot: (
id: string,
opts?: { scrollbackRows?: number }
) => Promise<{
data: string
frameRestoreAnsi?: string
cols: number
rows: number
cwd?: string | null
seq?: number
/** Start of main's pending renderer-delivery queue at snapshot time
* (equals `seq` when empty) — bounds the renderer's post-restore
* duplicate window. */
pendingDeliveryStartSeq?: number
source?: 'headless' | 'renderer'
alternateScreen?: boolean
/** Authoritative normal buffer paired with an alternate-screen frame. */
scrollbackAnsi?: string
/** Trailing incomplete escape the emulator ingested; the restorer must
* write it after its post-replay resets, last before live chunks. */
pendingEscapeTailAnsi?: string
/** Effective kitty flags the snapshot owner proved at `seq`. Absent means
* unknown; consumers must not turn that into a known `0`. */
kittyKeyboardFlags?: number
terminalOwner?: 'shell'
} | null>
getRendererDeliveryDebugSnapshot: () => Promise<{
pendingPtyCount: number
pendingChars: number
maxPendingCharsByPty: number
rendererInFlightPtyCount: number
rendererInFlightChars: number
maxRendererInFlightCharsByPty: number
activeRendererPtyCount: number
flushScheduled: boolean
peakPendingChars: number
peakMaxPendingCharsByPty: number
peakRendererInFlightChars: number
peakMaxRendererInFlightCharsByPty: number
ackGatedFlushSkipCount: number
hiddenDeliveryGatedPtyCount: number
hiddenDeliveryGatedVisiblePtyCount: number
hiddenDeliveryGatedActivePtyCount: number
deliveryInterestPtyCount: number
hiddenDeliveryDroppedChars: number
hiddenDeliveryDroppedChunks: number
pendingDroppedChars: number
diagnostics: PtyMainDeliveryDiagnostics
rendererLifecycleResetCount: number
lastLifecycleResetClearedChars: number
rendererPtyDispatcherReady: boolean
rendererDispatcherReadyForcedCount: number
}>
resetRendererDeliveryDebug: () => Promise<void>
onData: (
callback: (data: {
id: string
data: string
seq?: number
rawLength?: number
transformed?: boolean
background?: boolean
droppedOutput?: boolean
}) => void
) => () => void
onReplay: (callback: (data: { id: string; data: string }) => void) => () => void
/** Out-of-band main→renderer signal that renderer-bound bytes were
* dropped (hidden-delivery gate / pending cap); the pane restores from
* the model snapshot. Never delivered in-band on pty:data. */
onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void) => () => void
/** Batched derived side-effect facts for PTYs whose bytes transit local
* main. */
onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void) => () => void
/** Title-only replay snapshot for (re)attach; attention facts never replay. */
getSideEffectSnapshot: (id: string) => Promise<TerminalSideEffectBatch | null>
onExit: (
callback: (data: {
id: string
code: number
preserveRendererBinding?: boolean
/** Which lifetime of `id` died; absent when the execution host predates the field. */
incarnationId?: string
}) => void
) => () => void
onSpawned: (callback: (data: { id: string }) => void) => () => void
onSerializeBufferRequest: (
callback: (data: {
requestId: string
ptyId: string
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
}) => void
) => () => void
onClearBufferRequest: (callback: (data: { ptyId: string }) => void) => () => void
sendSerializedBuffer: (
requestId: string,
snapshot: {
data: string
cols: number
rows: number
seq?: number
lastTitle?: string
kittyKeyboardFlags?: number
} | null
) => void
declarePendingPaneSerializer: (paneKey: string) => Promise<number>
settlePaneSerializer: (paneKey: string, gen: number) => Promise<void>
clearPendingPaneSerializer: (paneKey: string, gen: number) => Promise<void>
reportRendererSerializerReady?: (ptyId: string) => Promise<void>
management: PtyManagementApi
}