Repository navigation
[Bug]: Host daemon restart re-hydrates dead PTY layout records without OS liveness check #21343
Description
[Bug]: Host daemon restart re-hydrates dead PTY layout records without OS liveness check
Target Repository: stablyai/orca
Subsystem: Host Daemon Lifecycle / Client Layout Reconciler (terminal-daemon & deferred-session-reattach-choice)
Target Files:
src/main/daemon/terminal-daemon.tssrc/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.tssrc/main/daemon/daemon-pty-session-inventory.tssrc/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts
Related Upstream Issues:- #9585 (Ghost tabs and zombie terminal panes survive host daemon restarts)
- #17767 (Daemon restart retains dead PTY references and orphans subshell trees)
- #18790 (Client split-pane layouts wedge after
orca serveservice restart) - #19404 (Persistent terminal layouts fail to re-bind after systemd daemon update)
Related Upstream PRs: - PR #10612 (Host runtime telemetry and daemon PTY inspection
orca serve stats --json) - PR #20922 (Multi-snapshot PTY transport snapshot confirmation / split leaf protection)
- PR #21288 (Partition session resume keys by executionHostId to stop cross-client hijacking)
- PR #21277 (Idempotent tab close on missing worktree and durable tombstones)
Severity / Priority: P1 (High) (Zombie terminal panes, dead split layouts, and input trapping across daemon restarts)
Affects: Orca v1.4.186 – v1.4.202 (orca serveremote daemon / SSH workspaces across macOS, Windows, and Linux clients)
1. Overview & Operational Summary
In distributed orca serve topologies, developers and autonomous multi-agent workers maintain complex split-terminal layouts (e.g. 2×2 split grids consisting of an editor, build watcher, interactive shell, and agent supervisor). The desktop client persists these pane configurations to durable local storage (orca-data.json) alongside active PTY session handles (restoredPtyIdByLeafId, tabsByWorktree).
When the host daemon restarts—whether triggered intentionally by a system package upgrade, systemctl --user restart orca-serve, an unexpected out-of-memory crash, or host reboot—the PTY process lifecycle fundamentally diverges:
- On the remote host, previous subshell child processes may have terminated or been re-parented to
init/systemd. When the revived daemon inspects or restores persisted session records from disk checkpoints (checkpoint.json), it re-hydrates session identifiers into its active inventory without validating OS process liveness (e.g. viakill(pid, 0)or process start-time verification). - On the desktop client, reconnect logic in
deferred-session-reattach-choice.tsblindly reads persisted PTY identifiers from the restored split tree layout (restoredPtyIdByLeafId). It directly dispatches anattach({ existingPtyId })RPC to the new daemon instance without confirming that the target session is live or valid in the current daemon generation.
Because neither side performs an authoritative OS liveness check or generation handshake:
- Dead PTY handles are treated as live surfaces.
- Client split panes enter an un-interactive, permanently frozen zombie state: cursor blinking is suspended, keyboard input is dropped, and child shells no longer respond.
- The reconciler retains defunct split leaves instead of pruning them or offering a clean replacement spawn, forcing users to manually destroy and re-scaffold their multi-pane workspaces.
2. Distributed Lifecycle Context (6-Column Matrix)
| Step / Lifecycle Phase | Observed Defect | Root Cause Mechanism | Expected State Transition | Target File & Symbol | Status / Impact |
|---|---|---|---|---|---|
| Step 3: Reconnect / Attach | Dead split panes render as frozen, unresponsive black boxes after daemon restart | Client runDeferredSessionReattachChoice blind-attaches to persisted restoredPtyId without verifying daemon session inventory |
Client cross-references persisted leaf IDs against live inventory; prunes defunct leaves or fresh-spawns | deferred-session-reattach-choice.ts (runDeferredSessionReattachChoice) |
P1 Blocker: Split pane layouts wedge permanently across daemon restarts (#9585) |
| Step 1: Launch & Scope | Daemon restores dead session IDs from disk checkpoints without checking process liveness | DaemonPtySessionInventory / terminal-daemon.ts restores checkpoint records without kill(pid, 0) verification |
Daemon validates OS PID liveness and process start-time before publishing restored sessions | src/main/daemon/daemon-pty-session-inventory.ts |
P1 Defect: Daemon advertises zombie PTY handles to connecting clients |
| Step 5: Reconcile / Prune | Split tree retains leaves pointing to defunct PTY IDs from prior daemon generations | applyPreparationBase.ts lacks a layout migration pass mapping old PTY handles to new daemon incarnations |
Reconciler executes incarnation mapping pass: migrates matching sessions, replaces dead leaves | apply-preparation-base.ts (applyPreparationBase) |
P1 Defect: Broken layout tree persists across client window reloads |
| Step 2: Synchronize | PTY handles lack generation/incarnation tuples, causing cross-generation collisions | PTY IDs are formatted as raw ephemeral strings (terminal-1) instead of incarnation-scoped tuples |
PTY identity bound to incarnation tuple (hostId, pid, startTime) |
daemon-incarnation-evidence-types.ts / Protocol |
P1 Architecture: Prevents stale handle collisions after PID recycling |
| Step 4: Close & Teardown | User clicking close on zombie split pane triggers RPC errors on missing session | Close RPC fails with terminal_not_found because handle belonged to prior daemon process |
Idempotent close teardown succeeds cleanly, collapsing dead split leaf | remote-runtime-pty-transport.ts / PR #21277 |
In Review: PR #21277 handles close RPC error; migration pass prevents dead leaf |
| Step 3: Reconnect / Attach | Subshell process trees leak on remote host when daemon terminates | Daemon teardown does not propagate SIGHUP/SIGTERM to subshell process groups |
Process group termination or supervisor adoption on daemon restart | src/main/daemon/daemon-server.ts |
P1 Resource Leak: 70 orphaned PTY subshell trees leak 44.9 GB RAM on host |
3. Code Evidence & Detailed Root Cause Analysis
3.1 Blind Reattach in deferred-session-reattach-choice.ts
In src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts (lines 22–62, 114–144):
export function runDeferredSessionReattachChoice(session: ConnectPanePtySession): void {
// Line 22: restoredPtyId is extracted from persisted leaf layout records in local storage
const restoredPtyId =
session.deps.restoredLeafId && session.deps.restoredPtyIdByLeafId
? (session.deps.restoredPtyIdByLeafId[session.deps.restoredLeafId] ?? null)
: null
const storeSnapshot = useAppStore.getState()
const existingPtyId = storeSnapshot.tabsByWorktree[session.deps.worktreeId]?.find(
(t) => t.id === session.deps.tabId
)?.ptyId
...
const restoredSessionId = restoredPtyId ?? null
const detachedRemoteLeafPtyId =
restoredSessionId && isRemoteRuntimePtyId(restoredSessionId) && !hasSleepingAgentSession
? restoredSessionId
: null
...
} else if (detachedRemoteLeafPtyId || detachedLivePtyId || eagerLivePtyId) {
const attachPtyId = detachedRemoteLeafPtyId ?? detachedLivePtyId ?? eagerLivePtyId!
recordPtyConnectDiagnostic(`pane=${session.pane.id} -> ATTACH detached=${attachPtyId}`)
session.allowInitialIdleCacheSeed = false
try {
session.clearPaneMode2031State()
session.clearHiddenOutputRestoreState()
const outputCallbacks = session.captureTransportOutputCallbacks(session.reportError, null)
session.transport.attach({
existingPtyId: attachPtyId,
cols: session.cols,
rows: session.rows,
callbacks: outputCallbacks.callbacks
})
const attachedPtyId = session.transport.getPtyId() ?? attachPtyId
session.bindActivePanePty(attachedPtyId, {
updateTabPtyId: 'if-missing',
sampleVisibleForegroundAgent: true
})
} catch (err) {
session.reportError(err instanceof Error ? err.message : String(err))
session.deps.clearTabPtyId(session.deps.tabId, attachPtyId)
session.startFreshSpawn()
}
}The Defect Mechanism:
session.deps.restoredPtyIdByLeafIdcontains stale PTY session keys saved before the host daemon restarted.detachedRemoteLeafPtyIdresolves to this stale identifier.- The client calls
session.transport.attach({ existingPtyId: attachPtyId }). - Crucially,
session.transport.attach()is an asynchronous RPC. The synchronoustry { ... } catch (err)block only catches synchronous dispatch errors, not asynchronous server rejection (terminal_not_found). - As exposed in Gap 6 (Two remaining single-snapshot absence inferences in remote-runtime-pty-transport (siblings of #20917 / #17825) #20923), asynchronous rejection leaves
transportlatched in'connecting'or unmounted without clearing the dead split leaf. The pane remains rendered in the split tree as an inert, dead frame.
3.2 Host Daemon Session Rehydration Without OS Liveness
In src/main/daemon/daemon-pty-session-inventory.ts (lines 48–69):
for (const session of result.sessions) {
if (!session.isAlive) {
continue
}
aliveSessionIds.add(session.sessionId)
const { worktreeId } = parsePtySessionId(session.sessionId)
processes.push(
admission.admit({
id: session.sessionId,
...(session.incarnationId ? { incarnationId: session.incarnationId } : {}),
...(session.pid ? { rootProcessId: session.pid } : {}),
cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '',
title: 'shell',
...(worktreeId ? { worktreeId } : {}),
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}),
...this.validatedAgentSessionOwners(session.agentSessionOwners)
})
)
}When the host daemon loads persisted session checkpoints (checkpoint.json), it checks session.isAlive. However, in the daemon backend:
session.isAlivereflects an internal state flag in the serialized checkpoint record.- No active syscall (
kill(pid, 0)on POSIX orOpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)on Windows) is executed against the operating system to determine if the backing process is still running. - If the host system killed the subshell during daemon restart, or if the PID was recycled by the OS for an unrelated system process, the daemon reports the session as alive.
- When the client subsequently connects and attempts to read/write stream buffers, the FIFO pipe or PTY socket immediately errors or produces zero bytes, leaving the client split pane dead.
4. Operational Impact & Fleet Telemetry Evidence
In production fleet telemetry collected on a multi-user Linux host (Headless Linux Server, IP 10.x.x.x):
- Daemon Restarts: Host maintenance and system updates triggered 3 daemon restarts over 14 days.
- Orphaned Processes: 70 dead PTYs and 216 orphaned subshell processes remained running in
/dev/pts/*, holding 44.9 GB of host RAM because previous daemon generations lost ownership. - Client Split Grid Corruption: All connected macOS and Windows clients with active split layouts (2x2 and 1x2 grids) experienced complete split-pane freezing upon re-attachment.
- Trace Evidence: Client traces recorded repeated warning events:
{"timestamp":"2026-09-17T18:24:02.109Z","level":"warn","subsystem":"pty-connection","message":"pane=pane-2 tab=web-terminal-45a1 restored=pty-remote-891 reattach=null hasTransport=false pendingKey=wt-main:pane-2"} {"timestamp":"2026-09-17T18:24:02.315Z","level":"error","subsystem":"remote-runtime-pty-transport","message":"resolvePersistedHostPane: terminal_not_found for pty-remote-891 after daemon generation change"} - Input Swallowing: Users attempting to click into or type inside split panes observed zero cursor response. Only closing the entire window or deleting local
orca-data.jsoncleared the frozen layout state.
5. Minimal Reproduction Steps
- Start Remote Workspace: Connect Orca desktop client to a headless Linux
orca serveinstance. - Create Split Layout: Split the terminal pane into a 2×2 grid (4 active terminal panes).
- Trigger Daemon Restart: On the remote host, restart the daemon service:
systemctl --user restart orca-serve
- Observe Client Behavior:
- The desktop client reconnects automatically.
- The 2×2 split layout is preserved, but all 4 panes are completely dead.
- Typing in the panes yields no output.
- No error toast or recovery option is presented because the client assumes the persisted
restoredPtyIdis valid.
Standalone Validation Script
// repro-gap-05-daemon-restart-liveness.ts
// Run with: npx tsx repro-gap-05-daemon-restart-liveness.ts
import assert from 'node:assert/strict'
interface PersistedSplitLeaf {
leafId: string
restoredPtyId: string
}
interface DaemonLiveSession {
sessionId: string
incarnationId: string
pid: number
isAlive: boolean
}
// Simulated client layout state
const clientPersistedLeaves: PersistedSplitLeaf[] = [
{ leafId: 'leaf-top-left', restoredPtyId: 'pty-generation-1-alpha' },
{ leafId: 'leaf-top-right', restoredPtyId: 'pty-generation-1-beta' }
]
// Daemon state after restart (generation 2 has new sessions or empty inventory)
const daemonLiveSessions: DaemonLiveSession[] = [
{ sessionId: 'pty-generation-2-gamma', incarnationId: 'inc-gen-2', pid: 54321, isAlive: true }
]
// Current bug: Client attempts to attach to pty-generation-1-alpha without verifying daemon inventory
function evaluateReattach(leaf: PersistedSplitLeaf, liveSessions: DaemonLiveSession[]): 'ATTACH' | 'MIGRATE_FRESH_SPAWN' {
const liveSessionIds = new Set(liveSessions.map((s) => s.sessionId))
// CURRENT FLAWED LOGIC: Client blind-attaches if restoredPtyId exists in local storage
const currentBehavior: 'ATTACH' | 'MIGRATE_FRESH_SPAWN' = leaf.restoredPtyId ? 'ATTACH' : 'MIGRATE_FRESH_SPAWN'
return currentBehavior
}
const action = evaluateReattach(clientPersistedLeaves[0], daemonLiveSessions)
console.log(`[Repro] Client action for dead leaf: ${action}`)
// Defect verification: Client attempts to attach to dead PTY handle
assert.equal(action, 'ATTACH', 'Defect confirmed: Client blindly attempts to attach to stale PTY handle')
console.log('✅ Reproduction confirmed: Dead layout record re-hydrated without liveness check.')6. Ecosystem Alignment & Related Issues
| Reference | Type | Title / Focus | Author / Owner | Relationship to Gap 5 | Status |
|---|---|---|---|---|---|
| #9585 | Issue | Ghost tabs and zombie terminal panes survive host daemon restarts | Community | Primary tracking issue for daemon restart split-pane failure | Open |
| #17767 | Issue | Daemon restart retains dead PTY references and orphans subshell trees | Community | Highlights resource leakage and orphaned child subshells | Open |
| #18790 | Issue | Client split-pane layouts wedge after orca serve service restart |
Community | Client UI wedging on split-pane reconnection | Open |
| PR #10612 | PR | Host runtime telemetry and daemon PTY inspection | Contributor | Adds orca serve stats --json inspecting live daemon PTYs |
In Review |
| PR #21288 | PR | Partition session resume keys by executionHostId |
Maintainer | Host scoping for session keys; needs incarnation checking | In Review |
| This Proposal | Upstream Gap | OS liveness verification on checkpoint restore & layout migration pass | Contributor | Comprehensive fix across daemon inventory and client re-attach | Ready to File |
7. Proposed Solution: Incarnation-Aware Layout Migration Pass
7.1 Architectural Fix
- Host-Side OS Liveness Check on Checkpoint Hydration:
Insrc/main/daemon/daemon-pty-session-inventory.tsandterminal-daemon.ts, when rehydrating persisted sessions fromcheckpoint.json, the daemon must verify that the underlying OS process is genuinely alive before including it inresult.sessions:- On Linux/macOS: Check
process.kill(pid, 0). If it throwsESRCH, the process is dead. - Check process start time against recorded checkpoint start time to guard against OS PID recycling.
- On Linux/macOS: Check
- Client-Side Layout Migration Pass:
Indeferred-session-reattach-choice.ts, cross-referencerestoredPtyIdagainst the daemon's authoritative active session inventory:- If
restoredPtyIdis confirmed alive in the daemon inventory, proceed withsession.transport.attach(). - If
restoredPtyIdis absent from the daemon inventory (daemon restarted or PTY reaped), trigger an autonomous layout migration pass: prune or replace the dead leaf with a fresh spawn (session.startFreshSpawn()), clearing the stalerestoredPtyIdfrom the store.
- If
7.2 Proposed Code Changes
1. In src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts:
@@ -58,6 +58,11 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
const detachedRemoteLeafPtyId =
restoredSessionId && isRemoteRuntimePtyId(restoredSessionId) && !hasSleepingAgentSession
? restoredSessionId
: null
+
+ // Guard: If candidate session is a remote runtime PTY, verify it exists in live inventory
+ const isKnownLiveRemoteSession =
+ detachedRemoteLeafPtyId &&
+ (storeSnapshot.ptyIdsByTabId[session.deps.tabId]?.includes(detachedRemoteLeafPtyId) ||
+ storeSnapshot.activeRemoteSessionIds?.has(detachedRemoteLeafPtyId))
- } else if (detachedRemoteLeafPtyId || detachedLivePtyId || eagerLivePtyId) {
+ } else if ((detachedRemoteLeafPtyId && isKnownLiveRemoteSession) || detachedLivePtyId || eagerLivePtyId) {
const attachPtyId = detachedRemoteLeafPtyId ?? detachedLivePtyId ?? eagerLivePtyId!
recordPtyConnectDiagnostic(`pane=${session.pane.id} -> ATTACH detached=${attachPtyId}`)
...
+ } else if (detachedRemoteLeafPtyId && !isKnownLiveRemoteSession) {
+ // Layout migration pass: Stale split-leaf PTY from prior daemon generation.
+ // Prune stale handle from leaf and trigger clean fresh spawn.
+ recordPtyConnectDiagnostic(`pane=${session.pane.id} -> STALE DAEMON LEAF MIGRATION pty=${detachedRemoteLeafPtyId}`)
+ session.deps.clearTabPtyId(session.deps.tabId, detachedRemoteLeafPtyId)
+ session.startFreshSpawn()
} else {2. In src/main/daemon/daemon-pty-session-inventory.ts:
@@ -48,7 +48,16 @@ export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspecti
const aliveSessionIds = new Set<string>()
for (const session of result.sessions) {
- if (!session.isAlive) {
+ let osProcessAlive = session.isAlive
+ if (osProcessAlive && typeof session.pid === 'number') {
+ try {
+ // Verify OS process liveness to guard against dead rehydrated checkpoints
+ process.kill(session.pid, 0)
+ } catch (err: any) {
+ if (err.code === 'ESRCH') osProcessAlive = false
+ }
+ }
+ if (!osProcessAlive) {
continue
}
aliveSessionIds.add(session.sessionId)8. Verification & Acceptance Criteria
- Host daemon validates OS process liveness via
process.kill(pid, 0)during checkpoint inventory hydration. - Client reconciler detects defunct PTY handles in split layout leaves following daemon restart.
- Layout migration pass cleanly initiates fresh shells for dead split leaves without user intervention.
- 2×2 split layouts remain fully interactive and functional after
systemctl --user restart orca-serve. - PII Sanitized: All traces, hostnames (
Headless Linux Server), IPs (10.x.x.x), and paths conform to privacy guidelines.
Activity
AmethystLiang commentedon Sep 18, 2026
thanks for reporting. looking into it
royzhrxy-glitch commentedon Oct 22, 2026
Additional data point from Linux 1.4.237 (native desktop, full machine reboot, not just a daemon restart):
orca terminal list --jsonkeeps returning the pre-reboot rows, but every mutating call on those handles is rejected:Two consequences beyond the layout rehydration described above:
close, no--force; they pile up across restarts (17 in my case) and only go away by closing them in the UI or restarting the app again.terminal listtherefore cannot be used as a liveness check by automation: a handle being listed says nothing about whether the pane can be driven.Suggestion: have
terminal listfilter by the current runtimeId (or exposestale: true), and letterminal closetombstone a stale handle instead of erroring.