579 lines
19 KiB
TypeScript
579 lines
19 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 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
|
|
|
|
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
|
|
/** True when shell launch args already delivered the startup command, so the
|
|
* terminal host must skip its stdin fallback write. */
|
|
startupCommandDeliveredInShellArgs?: boolean
|
|
write(data: string): void
|
|
resize(cols: number, rows: number): 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
|
|
subprocess: SubprocessHandle
|
|
shellReadySupported: boolean
|
|
shellReadyTimeoutMs?: number
|
|
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
|
|
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
|
|
|
|
constructor(opts: SessionOptions) {
|
|
this.sessionId = opts.sessionId
|
|
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.
|
|
})
|
|
|
|
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 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)
|
|
}
|
|
|
|
kill(): void {
|
|
if (this._state === 'exited' || this._isTerminating) {
|
|
return
|
|
}
|
|
this._isTerminating = 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)
|
|
}
|
|
}
|
|
|
|
detachAllClients(): void {
|
|
this.attachedClients.length = 0
|
|
}
|
|
|
|
getSnapshot(): TerminalSnapshot | null {
|
|
if (this._disposed) {
|
|
return null
|
|
}
|
|
return this.emulator.getSnapshot()
|
|
}
|
|
|
|
// 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.emulator.getSnapshot() : null
|
|
}
|
|
}
|
|
|
|
getCwd(): string | null {
|
|
return this.emulator.getCwd()
|
|
}
|
|
|
|
getForegroundProcess(): string | null {
|
|
return this.subprocess.getForegroundProcess()
|
|
}
|
|
|
|
clearScrollback(): void {
|
|
if (this._disposed) {
|
|
return
|
|
}
|
|
this.emulator.clearScrollback()
|
|
this.recordPendingOutput({ kind: 'clear' })
|
|
}
|
|
|
|
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
|
|
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
|
|
}
|
|
|
|
// 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'
|
|
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)
|
|
}
|
|
}
|