Files
orca/src/main/daemon/session.ts
T
9b3ed3997e fix(daemon): resolve Unix shell fallback before node-pty spawn (#8306)
* fix: resolve daemon shell before spawning

* fix(daemon): reconcile shell-ready barrier with the resolved fallback shell

The adapter computes shellReadySupported from the preferred shell before
spawn. When the daemon falls back (e.g. to /bin/sh), the session would
queue startup commands for the full 15s ready-marker timeout and wrap
multiline prompts in bracketed-paste markers the fallback shell cannot
parse. Expose the spawned shell on SubprocessHandle and downgrade the
barrier and paste wrapping in TerminalHost when the actual shell cannot
emit the marker.

Also apply the Unix shell fallback after the relay env scrub so a
scrubbed SHELL cannot drop the corrected value, and cover the resolve-
before-launch-config ordering, the no-shell throw, and candidate dedup
with tests.

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

---------

Co-authored-by: Vitus Apollo <zhzvitus@gmail.com>
Co-authored-by: Orca <help@stably.ai>
2026-07-11 10:45:55 -07:00

716 lines
25 KiB
TypeScript

/* oxlint-disable max-lines */
import { HeadlessEmulator } from './headless-emulator'
import { isValidPtySize, normalizePtySize } from './daemon-pty-size'
import { PostReadyFlushGate } from './post-ready-flush-gate'
import {
createShellReadyScanState,
drainShellReadyHeldBytes,
scanForShellReady,
type ShellReadyScanState
} from '../shell-ready-marker-scanner'
import { isPowerShellProcess } from '../../shared/shell-process-detection'
import type { TuiAgent } from '../../shared/types'
import type {
PendingOutputRecord,
SessionState,
ShellReadyState,
TakePendingOutputResult,
TerminalSnapshot
} from './types'
const SHELL_READY_TIMEOUT_MS = 15_000
// Why: Codex startup skips marker-gated command delivery; this only bounds
// older daemon/local paths that still report shell-ready support for Codex.
export const CODEX_SHELL_READY_TIMEOUT_MS = 300
const KILL_TIMEOUT_MS = 5_000
// Why: pending records exist so the 5s checkpoint can persist increments
// instead of re-serializing the whole buffer. If no client drains them (main
// process gone, history disabled), memory must stay bounded — past the cap we
// drop the records and flag overflow so the next take falls back to one full
// snapshot, which subsumes everything dropped.
// Counted in UTF-16 code units (string .length), which tracks JS heap cost.
// Worst-case wire size for a full take is ~6x this (each control char
// JSON-escapes to six bytes) and must stay under NDJSON_MAX_LINE_BYTES (16MB).
const PENDING_OUTPUT_MAX_BYTES = 2 * 1024 * 1024
// Why: producer pause is requested over a fire-and-forget notification, so the
// matching resume can be lost (main crash, dropped socket). A lost resume must
// never wedge a shell: auto-resume after this window; a still-flooded main
// re-asserts the pause on its next watermark check.
export const PRODUCER_PAUSE_FAILSAFE_MS = 5_000
export type SubprocessHandle = {
pid: number
/** Live foreground process name of the PTY (node-pty's `.process`), e.g.
* 'claude' / 'codex' / 'zsh'. Null once the child has exited. */
getForegroundProcess(): string | null
/** Await process-table evidence captured after this confirmation request. */
confirmForegroundProcess?(): Promise<string | null>
/** True when shell launch args already delivered the startup command, so the
* terminal host must skip its stdin fallback write. */
startupCommandDeliveredInShellArgs?: boolean
/** Shell the subprocess actually spawned, after Unix/Windows fallbacks. The
* host reconciles the caller's shell-ready assumption against it so a
* fallback shell without a ready marker never gates startup commands. */
shellPath?: string
write(data: string): void
resize(cols: number, rows: number): void
/** Stop reading the PTY fd (node-pty pause()) so the kernel/ConPTY buffer
* fills and a flooding child blocks on write. Optional: handles that
* cannot pause simply omit it and flow control degrades to a no-op. */
pause?(): void
resume?(): void
/** Resync the native PTY's own screen state after a frontend clear.
* No-op except on Windows/ConPTY, where a stale ConPTY cursor row makes
* the next prompt repaint land below a blank gap. */
clear?(): void
kill(): void
forceKill(): void
signal(sig: string): void
onData(cb: (data: string) => void): void
onExit(cb: (code: number) => void): void
/** Release the native PTY handle via node-pty's own destroy() path.
* Idempotent. Safe to call after exit. Called by Session on every teardown
* path (natural exit, kill, force-kill, native throw, session dispose). */
dispose(): void
}
export type SessionOptions = {
sessionId: string
cols: number
rows: number
terminalHandle?: string
launchAgent?: TuiAgent
subprocess: SubprocessHandle
shellReadySupported: boolean
shellReadyTimeoutMs?: number
historySeed?: string
scrollback?: number
// Why: fired once the session reaches a terminal state (natural exit or
// kill-timeout force-dispose) so the owner (TerminalHost) can reap it —
// dispose the headless emulator and drop it from its session map. Without a
// reaper, dead sessions (and their ~5000-row scrollback emulators) accumulate
// for the lifetime of the long-lived daemon process.
onExit?: (code: number) => void
}
type AttachedClient = {
token: symbol
onData: (data: string) => void
onExit: (code: number) => void
}
export class Session {
readonly sessionId: string
readonly terminalHandle: string | null
readonly launchAgent: TuiAgent | null
private _state: SessionState = 'running'
private _shellState: ShellReadyState
private _exitCode: number | null = null
private _isTerminating = false
private _disposed = false
private emulator: HeadlessEmulator
private subprocess: SubprocessHandle
private readonly onSessionExit?: (code: number) => void
private attachedClients: AttachedClient[] = []
private preReadyStdinQueue: string[] = []
private shellReadyScanState: ShellReadyScanState | null = null
private shellReadyTimer: ReturnType<typeof setTimeout> | null = null
private killTimer: ReturnType<typeof setTimeout> | null = null
private postReadyFlushGate: PostReadyFlushGate
private pendingOutputRecords: PendingOutputRecord[] = []
private pendingOutputBytes = 0
private pendingOutputOverflowed = false
private pendingOutputSeq = 0
private outputSequence = 0
private producerPaused = false
private producerPauseFailsafeTimer: ReturnType<typeof setTimeout> | null = null
private readonly _historySeeded: boolean | undefined
constructor(opts: SessionOptions) {
this.sessionId = opts.sessionId
this.terminalHandle = opts.terminalHandle ?? null
this.launchAgent = opts.launchAgent ?? null
this.subprocess = opts.subprocess
this.onSessionExit = opts.onExit
const size = normalizePtySize(opts.cols, opts.rows)
this.emulator = new HeadlessEmulator({
cols: size.cols,
rows: size.rows,
scrollback: opts.scrollback
// No onData wiring: the daemon-side emulator must never reply to
// terminal query sequences. The renderer's xterm is the authoritative
// responder; any daemon reply races ahead via in-process parsing and
// clobbers the renderer's answer. See the comment in HeadlessEmulator.
})
// Why: recovery must precede listener registration; shells can emit their
// prompt synchronously as soon as onData subscribes.
this._historySeeded =
opts.historySeed === undefined ? undefined : this.emulator.writeSync(opts.historySeed)
if (opts.shellReadySupported) {
this._shellState = 'pending'
this.shellReadyScanState = createShellReadyScanState()
this.shellReadyTimer = setTimeout(() => {
this.onShellReadyTimeout()
}, opts.shellReadyTimeoutMs ?? SHELL_READY_TIMEOUT_MS)
} else {
this._shellState = 'unsupported'
}
this.postReadyFlushGate = new PostReadyFlushGate(() => this.flushPreReadyQueue())
this.subprocess.onData((data) => this.handleSubprocessData(data))
this.subprocess.onExit((code) => this.handleSubprocessExit(code))
}
get state(): SessionState {
return this._state
}
get shellState(): ShellReadyState {
return this._shellState
}
get historySeeded(): boolean | undefined {
return this._historySeeded
}
get exitCode(): number | null {
return this._exitCode
}
get isAlive(): boolean {
return this._state !== 'exited'
}
get isTerminating(): boolean {
return this._isTerminating
}
get pid(): number {
return this.subprocess.pid
}
write(data: string): void {
if (this._state === 'exited' || this._disposed) {
return
}
// Why: during the post-ready flush gate window (shellState is already
// 'ready' but the queue hasn't flushed yet) we must keep queuing. Writing
// directly would let fresh input race ahead of the buffered startup
// command, changing execution order.
if (this._shellState === 'pending' || this.postReadyFlushGate.isPending) {
this.preReadyStdinQueue.push(data)
return
}
this.subprocess.write(data)
}
resize(cols: number, rows: number): void {
if (this._state === 'exited' || this._disposed) {
return
}
if (!isValidPtySize(cols, rows)) {
return
}
this.emulator.resize(cols, rows)
// Why: the record stream must mirror the order operations were applied to
// the emulator, or cold-restore replay reflows at the wrong point.
this.recordPendingOutput({ kind: 'resize', cols, rows })
this.subprocess.resize(cols, rows)
}
/** Producer-side flow control: stop reading the PTY fd so the flooding
* child blocks on write (kernel backpressure). Arms the lost-resume
* failsafe; re-pausing re-arms it (main re-asserts during long floods). */
pauseProducer(): void {
if (this._state === 'exited' || this._disposed) {
return
}
this.producerPaused = true
this.subprocess.pause?.()
if (this.producerPauseFailsafeTimer) {
clearTimeout(this.producerPauseFailsafeTimer)
}
this.producerPauseFailsafeTimer = setTimeout(() => {
this.producerPauseFailsafeTimer = null
this.producerPaused = false
this.subprocess.resume?.()
}, PRODUCER_PAUSE_FAILSAFE_MS)
}
resumeProducer(): void {
this.releaseProducerPause({ resume: true })
}
private releaseProducerPause(opts: { resume: boolean }): void {
if (this.producerPauseFailsafeTimer) {
clearTimeout(this.producerPauseFailsafeTimer)
this.producerPauseFailsafeTimer = null
}
if (!this.producerPaused) {
return
}
this.producerPaused = false
if (opts.resume) {
this.subprocess.resume?.()
}
}
kill(): void {
if (this._state === 'exited' || this._isTerminating) {
return
}
this._isTerminating = true
// Why: a paused child can be blocked inside write(); resume before
// signalling so it can run signal handlers and actually exit.
this.releaseProducerPause({ resume: true })
this.subprocess.kill()
this.killTimer = setTimeout(() => {
if (this._state !== 'exited') {
this.forceDispose()
}
}, KILL_TIMEOUT_MS)
}
signal(sig: string): void {
if (this._state === 'exited') {
return
}
this.subprocess.signal(sig)
}
attachClient(client: { onData: (data: string) => void; onExit: (code: number) => void }): symbol {
const token = Symbol('attach')
this.attachedClients.push({ token, ...client })
return token
}
detachClient(token: symbol): void {
const idx = this.attachedClients.findIndex((c) => c.token === token)
if (idx !== -1) {
this.attachedClients.splice(idx, 1)
}
// Why: with no attached client, nobody will ever send resumePty — a
// paused shell would sit wedged until the failsafe. Resume eagerly.
if (this.attachedClients.length === 0) {
this.releaseProducerPause({ resume: true })
}
}
detachAllClients(): void {
this.attachedClients.length = 0
this.releaseProducerPause({ resume: true })
}
getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null {
if (this._disposed) {
return null
}
return { ...this.emulator.getSnapshot(opts), outputSequence: this.outputSequence }
}
getPartialEscapeTailAnsi(): string {
if (this._disposed) {
return ''
}
return this.emulator.partialEscapeTailAnsi
}
// Why: the size the PTY actually applied (emulator dims, which Session.resize
// advances atomically with the subprocess), so the renderer can detect a
// resize that was dropped here (exited/disposed/invalid) instead of trusting
// its own last-requested size. Null on a disposed session.
getAppliedSize(): { cols: number; rows: number } | null {
if (this._disposed) {
return null
}
return this.emulator.getAppliedSize()
}
/** Drains the records accumulated since the last take. Runs synchronously —
* when includeSnapshot is set, the serialize happens in the same turn so no
* PTY data can land between the drain and the snapshot (which would later
* be replayed twice on cold restore). */
takePendingOutput(
includeSnapshot: boolean,
opts: { teardownSnapshot?: boolean } = {}
): TakePendingOutputResult | null {
if (this._disposed) {
return null
}
const releasedHeldBytes =
includeSnapshot && opts.teardownSnapshot === true ? this.prepareForFinalSnapshot() : ''
const records = this.pendingOutputRecords
const overflowed = this.pendingOutputOverflowed
this.pendingOutputRecords = []
this.pendingOutputBytes = 0
this.pendingOutputOverflowed = false
this.pendingOutputSeq += 1
return {
records: includeSnapshot
? releasedHeldBytes
? [{ kind: 'output', data: releasedHeldBytes }]
: []
: records,
seq: this.pendingOutputSeq,
overflowed,
snapshot: includeSnapshot ? this.getSnapshot() : null
}
}
getCwd(): string | null {
return this.emulator.getCwd()
}
getForegroundProcess(): string | null {
return this.subprocess.getForegroundProcess()
}
async confirmForegroundProcess(): Promise<string | null> {
return this.subprocess.confirmForegroundProcess?.() ?? this.subprocess.getForegroundProcess()
}
clearScrollback(): void {
if (this._disposed) {
return
}
this.emulator.clearScrollback()
this.recordPendingOutput({ kind: 'clear' })
this.subprocess.clear?.()
this.#nudgePowerShellPromptRepaint()
}
/** Why: ConPTY's buffer clear cannot reach PSReadLine's cached cursor row,
* so PowerShell's first Enter after a clear would still repaint the prompt
* at the stale row, leaving a blank gap. A form feed (Ctrl+L) makes
* PSReadLine itself repaint at the true origin. Gated to a PowerShell
* foreground so a running command or TUI never gets a stray 0x0C, and to
* an empty prompt because PSReadLine repaints pending input at a stale
* cached row that ConPTY's fixed viewport doesn't track. */
#nudgePowerShellPromptRepaint(): void {
if (process.platform !== 'win32') {
return
}
// Why: before shell-ready, write() would queue the form feed behind the
// buffered startup command and deliver it at an arbitrary later moment,
// when the foreground/prompt gates below no longer hold. The nudge is
// cosmetic — skip it rather than defer it.
if (this._shellState === 'pending' || this.postReadyFlushGate.isPending) {
return
}
if (!isPowerShellProcess(this.subprocess.getForegroundProcess())) {
return
}
if (!this.emulator.isCursorOnEmptyPromptLine()) {
return
}
this.subprocess.write('\x0c')
}
prepareForFinalSnapshot(): string {
return this.releaseHeldShellReadyBytes()
}
dispose(): void {
if (this._disposed) {
return
}
// Why: captured BEFORE the `_state = 'exited'` flip below. This check
// guards the "dispose while kill() was already in flight" case — if true,
// the child hasn't reaped yet and we need to forceKill it here (the 5s
// killTimer is also about to be cleared by #teardownSubprocess). Do NOT
// move this capture below #teardownSubprocess or the `_state = 'exited'`
// assignment — #teardownSubprocess flips `_disposed` but the invariant
// depends on the PRE-flip value of `_state`.
const wasTerminating = this._isTerminating && this._state !== 'exited'
const clientsToNotify = wasTerminating ? this.attachedClients.slice() : []
if (wasTerminating) {
try {
this.subprocess.forceKill()
} catch {
/* child may already be gone */
}
this._exitCode = -1
this._isTerminating = false
}
this.#teardownSubprocess()
this._state = 'exited'
this.attachedClients = []
this.preReadyStdinQueue = []
this.postReadyFlushGate.clear()
this.emulator.dispose()
for (const client of clientsToNotify) {
client.onExit(-1)
}
}
/** Public: fd-release-only teardown for sessions that have ALREADY exited
* (state === 'exited') but are still retained in the host's map. Callers
* MUST NOT use this on live sessions — it skips SIGKILL.
*
* Why a separate method: after handleSubprocessExit fires, proc.pid refers
* to a child that has been reaped; on POSIX that pid is eligible for reuse
* and may now belong to an unrelated process. forceKillAndDisposeSubprocess
* would send SIGKILL to that recycled pid. This method only releases the
* PTY master fd via node-pty's destroy() (which is neutralized against the
* SIGHUP-to-pid hazard by the onExit handler in pty-subprocess.ts). */
disposeSubprocess(): void {
this.#teardownSubprocess()
this._state = 'exited'
}
/** Public: orderly-shutdown path used by TerminalHost.dispose() for sessions
* that are still live. Force-kills the child (SIGKILL is not ignorable),
* then releases the PTY master fd synchronously via node-pty's destroy().
* Bypasses the 5s KILL_TIMEOUT_MS fallback so daemon shutdown reaps
* stubborn children AND frees the ptmx fd on the same tick. Does NOT fan
* out onExit to attached clients — renderer reconnects cold after daemon
* exit. Callers MUST check isAlive first; see disposeSubprocess() for the
* already-exited case. */
forceKillAndDisposeSubprocess(): void {
// Why: forceKill before #teardownSubprocess. The helper's subprocess.dispose()
// neutralizes node-pty's proc.kill on POSIX (to kill the SIGHUP-to-recycled-pid
// hazard). subprocess.forceKill uses process.kill(pid, 'SIGKILL') directly
// (pty-subprocess.ts) — unaffected by the neutralization, because it does not
// go through proc.kill. SIGKILL is not ignorable; any child that would have
// survived the 5s timer is reaped immediately.
try {
this.subprocess.forceKill()
} catch {
/* swallow — child may already be gone */
}
this.#teardownSubprocess()
this._state = 'exited'
// Why: free the headless emulator's scrollback here too (this path skips
// dispose()). Matches forceDispose(); reaping just drops the map entry.
this.emulator.dispose()
}
/** Private: shared teardown helper called by dispose(), forceDispose(), and
* forceKillAndDisposeSubprocess(). Flips `_disposed`, clears pending timers,
* and forwards to subprocess.dispose() exactly once. Does NOT set `_state` —
* the caller owns the state transition AFTER capturing any invariants that
* depend on the pre-flip value (see the wasTerminating capture in dispose). */
#teardownSubprocess(): void {
if (this._disposed) {
return
}
this._disposed = true
// Why: never leave a paused fd behind on any teardown path — the handle's
// own dead-guard makes this a no-op when the child is already reaped.
this.releaseProducerPause({ resume: true })
if (this.killTimer) {
clearTimeout(this.killTimer)
this.killTimer = null
}
if (this.shellReadyTimer) {
clearTimeout(this.shellReadyTimer)
this.shellReadyTimer = null
}
this.shellReadyScanState = null
this.preReadyStdinQueue = []
this.postReadyFlushGate.clear()
try {
this.subprocess.dispose()
} catch (err) {
// Why: dispose() is documented never to throw, but if it does we must not
// prevent callers from completing their own cleanup (fanout, map removal).
console.warn('[Session] subprocess.dispose() threw:', err)
}
}
private recordPendingOutput(record: PendingOutputRecord): void {
if (this.pendingOutputOverflowed) {
return
}
const bytes = record.kind === 'output' ? record.data.length : 8
if (this.pendingOutputBytes + bytes > PENDING_OUTPUT_MAX_BYTES) {
this.pendingOutputRecords = []
this.pendingOutputBytes = 0
this.pendingOutputOverflowed = true
return
}
// Why: TUIs emit thousands of tiny chunks between checkpoint ticks;
// coalescing adjacent output keeps the take RPC and log frames compact.
// The 64KB segment cap bounds per-chunk string-append cost.
const last = this.pendingOutputRecords.at(-1)
if (record.kind === 'output' && last?.kind === 'output' && last.data.length < 64 * 1024) {
last.data += record.data
} else {
this.pendingOutputRecords.push(record)
}
this.pendingOutputBytes += bytes
}
private handleSubprocessData(data: string): void {
if (this._disposed) {
return
}
if (this._shellState === 'pending' && this.shellReadyScanState) {
const scanned = scanForShellReady(this.shellReadyScanState, data)
data = scanned.output
if (scanned.matched) {
this.transitionToReady(scanned.postMarkerBytesObserved)
}
} else {
this.postReadyFlushGate.notifyData()
}
this.emitSubprocessOutput(data)
}
private emitSubprocessOutput(data: string): void {
if (data.length === 0) {
return
}
// Why: daemon stream thinning can omit bytes before main sees them. The
// absolute count lets an authoritative snapshot cover those gaps while
// renderer reconciliation deduplicates any queued post-snapshot tail.
this.outputSequence += data.length
// Feed data to headless emulator for state tracking
this.emulator.write(data)
this.recordPendingOutput({ kind: 'output', data })
// Broadcast to attached clients
for (const client of this.attachedClients) {
client.onData(data)
}
}
private handleSubprocessExit(code: number): void {
if (this._disposed) {
return
}
this._exitCode = code
this._state = 'exited'
// Why resume:false — the child is reaped, so there is nothing to unblock;
// only the failsafe timer must not outlive the session.
this.releaseProducerPause({ resume: false })
this.releaseHeldShellReadyBytes()
if (this.killTimer) {
clearTimeout(this.killTimer)
this.killTimer = null
}
if (this.shellReadyTimer) {
clearTimeout(this.shellReadyTimer)
this.shellReadyTimer = null
}
this.postReadyFlushGate.clear()
// Why: release the ptmx fd on the natural-exit path. Without this, the
// node-pty wrapper's _socket stays alive until GC and the master fd leaks
// (see docs/fix-pty-fd-leak.md). Do NOT route through #teardownSubprocess:
// that helper flips `_disposed = true`, which would short-circuit the later
// Session.dispose() call from TerminalHost.reapSession (wired via onExit
// below) — skipping attachedClients/emulator/postReadyFlushGate cleanup.
// Call subprocess.dispose() directly inside try/catch.
try {
this.subprocess.dispose()
} catch {
/* swallow — must not prevent exit-code fanout below */
}
for (const client of this.attachedClients) {
client.onExit(code)
}
// Why: hand off to the owner's reaper so the emulator is disposed and the
// session dropped from the host map; otherwise dead sessions accumulate.
this.onSessionExit?.(code)
}
private releaseHeldShellReadyBytes(): string {
if (!this.shellReadyScanState) {
return ''
}
const heldBytes = drainShellReadyHeldBytes(this.shellReadyScanState)
this.shellReadyScanState = null
// Why: daemon scanning now runs before emulator/client fan-out so marker
// bytes can be stripped. If readiness never completes, preserve the
// previous behavior by releasing any held prefix before timeout or exit
// state changes discard it.
this.emitSubprocessOutput(heldBytes)
return heldBytes
}
private transitionToReady(postMarkerBytesObserved = false): void {
this._shellState = 'ready'
this.shellReadyScanState = null
if (this.shellReadyTimer) {
clearTimeout(this.shellReadyTimer)
this.shellReadyTimer = null
}
if (this.preReadyStdinQueue.length === 0) {
return
}
this.postReadyFlushGate.arm(postMarkerBytesObserved)
}
private onShellReadyTimeout(): void {
this.shellReadyTimer = null
if (this._shellState !== 'pending') {
return
}
this._shellState = 'timed_out'
this.releaseHeldShellReadyBytes()
this.flushPreReadyQueue()
}
private flushPreReadyQueue(): void {
const queued = this.preReadyStdinQueue
this.preReadyStdinQueue = []
for (const data of queued) {
this.subprocess.write(data)
}
}
private forceDispose(): void {
if (this._state === 'exited') {
return
}
// Why: forceKill BEFORE #teardownSubprocess. Order is load-bearing — the
// helper's subprocess.dispose() neutralizes proc.kill on POSIX (to defuse
// the SIGHUP-to-recycled-pid hazard inside node-pty). forceKill uses
// process.kill(pid, 'SIGKILL') directly and is unaffected by that
// neutralization. Must NOT flip `_disposed` here before #teardownSubprocess
// runs, or the helper would early-return and skip subprocess.dispose() —
// the ptmx fd would leak on every kill-timeout (this whole doc's target).
try {
this.subprocess.forceKill()
} catch {
/* already dead */
}
this._exitCode = -1
this._isTerminating = false
this.#teardownSubprocess()
this._state = 'exited'
const clients = this.attachedClients
this.attachedClients = []
this.preReadyStdinQueue = []
this.postReadyFlushGate.clear()
this.emulator.dispose()
for (const client of clients) {
client.onExit(-1)
}
// Why: reap from the host map on the kill-timeout path too (emulator already
// disposed above; reapSession's dispose() call is a no-op and just drops it).
this.onSessionExit?.(-1)
}
}