Skip to content

[Bug]: Host daemon restart re-hydrates dead PTY layout records without OS liveness check #21343

Description

@LesleyMurfin

[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.ts
  • src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts
  • src/main/daemon/daemon-pty-session-inventory.ts
  • src/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 serve service 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 serve remote 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:

  1. 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. via kill(pid, 0) or process start-time verification).
  2. On the desktop client, reconnect logic in deferred-session-reattach-choice.ts blindly reads persisted PTY identifiers from the restored split tree layout (restoredPtyIdByLeafId). It directly dispatches an attach({ 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:

  1. session.deps.restoredPtyIdByLeafId contains stale PTY session keys saved before the host daemon restarted.
  2. detachedRemoteLeafPtyId resolves to this stale identifier.
  3. The client calls session.transport.attach({ existingPtyId: attachPtyId }).
  4. Crucially, session.transport.attach() is an asynchronous RPC. The synchronous try { ... } catch (err) block only catches synchronous dispatch errors, not asynchronous server rejection (terminal_not_found).
  5. As exposed in Gap 6 (Two remaining single-snapshot absence inferences in remote-runtime-pty-transport (siblings of #20917 / #17825) #20923), asynchronous rejection leaves transport latched 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:

  1. session.isAlive reflects an internal state flag in the serialized checkpoint record.
  2. No active syscall (kill(pid, 0) on POSIX or OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) on Windows) is executed against the operating system to determine if the backing process is still running.
  3. 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.
  4. 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.json cleared the frozen layout state.

5. Minimal Reproduction Steps

  1. Start Remote Workspace: Connect Orca desktop client to a headless Linux orca serve instance.
  2. Create Split Layout: Split the terminal pane into a 2×2 grid (4 active terminal panes).
  3. Trigger Daemon Restart: On the remote host, restart the daemon service:
    systemctl --user restart orca-serve
  4. 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 restoredPtyId is 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

  1. Host-Side OS Liveness Check on Checkpoint Hydration:
    In src/main/daemon/daemon-pty-session-inventory.ts and terminal-daemon.ts, when rehydrating persisted sessions from checkpoint.json, the daemon must verify that the underlying OS process is genuinely alive before including it in result.sessions:
    • On Linux/macOS: Check process.kill(pid, 0). If it throws ESRCH, the process is dead.
    • Check process start time against recorded checkpoint start time to guard against OS PID recycling.
  2. Client-Side Layout Migration Pass:
    In deferred-session-reattach-choice.ts, cross-reference restoredPtyId against the daemon's authoritative active session inventory:
    • If restoredPtyId is confirmed alive in the daemon inventory, proceed with session.transport.attach().
    • If restoredPtyId is 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 stale restoredPtyId from the store.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingorca-remote-serverRemote Orca Server / orca serve / paired remote runtimeterminal

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions