Skip to content

[Bug]: shouldReplaceTerminalTab retains ptyId: null placeholders indefinitely without TTL bound #21340

Description

@LesleyMurfin

[Bug]: shouldReplaceTerminalTab retains ptyId: null placeholders indefinitely without TTL bound

Target Repository: stablyai/orca
Subsystem: Client Store Reconciler / Tab Lifecycle (web-session-tabs-sync)
Target Files:

  • src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts (shouldReplaceTerminalTab)
  • src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts (applyPreparationBase)
    Related Upstream Issues:
  • #11800 (Unclosable null-PTY terminal ghosts retained after host topology advances or branches switch)
  • #18117 (Remote terminal tabs survive disconnect and leak in client tab roster)
  • #21236 (Paired client keeping mirrored terminal tab for days; auto-resumes dead Claude sessions)
    Related Upstream PRs:
  • 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: P0 (Permanent client state bloat, unclosable ghost tabs, remount loops, V8 heap OOM crashes)
    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 and SSH remote workspace environments, terminal tabs initiated on a web or desktop client (web-terminal-<uuid>) start life with ptyId: null while the remote host backend daemon allocates a PTY process and publishes its surface descriptor in the runtime snapshot.

During snapshot reconciliation, shouldReplaceTerminalTab() in src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts guards newly created remote tabs to prevent premature eviction before the host daemon has had time to allocate and report the backing PTY handle.

However, the reconciler enforces no upper bound, timeout, or time-to-live (TTL) policy on tabs with ptyId: null. If the remote PTY process fails to materialize—for example:

  1. An automated agent worktree is deleted before a shell finishes spawning,
  2. The user switches git branches while container/runtime provisioning stalls,
  3. A transient network interruption occurs during the initial spawn handshake, or
  4. The remote daemon encounters a spawn error or OS process limit (EAGAIN),

the provisional tab remains stuck with ptyId: null indefinitely. Because shouldReplaceTerminalTab() returns false, applyPreparationBase() permanently retains the placeholder in retainedTerminalTabs.

These dead entries are serialized into durable client storage (orca-data.json) under tabsByWorktree. Upon reconnect or application restart, the client reloads these non-functional entries as permanent, unclosable ghost tabs. In a production fleet audit across macOS and Windows clients connected to a 24/7 headless remote development server, 172 ghost tabs (ptyId: null) accumulated in local storage, triggering continuous remount loops (85+ loops), ViewTransition collisions (>2,700 InvalidStateError aborts), and multiple V8 heap Out-Of-Memory crashes at ~3.97 GB.


2. Distributed Lifecycle Context (6-Column Matrix)

Step / Lifecycle Phase Observed Defect Root Cause Mechanism Expected State Transition Target File & Symbol Status / Impact
Step 5: Reconcile / Prune Null-PTY placeholders persist indefinitely in tabsByWorktree shouldReplaceTerminalTab lacks a temporal bound on ptyId: null entries 30s grace period expires; placeholder tab is retired and tombstoned terminal-surfaces.ts (shouldReplaceTerminalTab) P0 Blocker: 172 ghost tabs accumulate; bloats orca-data.json to 84.5 KB
Step 5: Reconcile / Prune !shouldReplaceTerminalTab retains failed lazy-spawns forever Caller in apply-preparation-base.ts treats non-replacement as indefinite retention Expired placeholders are excluded from retainedTerminalTabs apply-preparation-base.ts (applyPreparationBase) P0 Blocker: Dead worktree tabs survive branch switches and worktree teardown
Step 3: Reconnect / Attach Reconnecting client renders unclosable tabs that trigger remount thrashing Stale web-terminal-* entries without PTYs enter continuous spawn-left-pane-unbound loops Reconciler cleanses orphaned placeholders prior to layout restoration apply-preparation-base.ts / TerminalPane P0 Blocker: 85 remounts/min; crashes Electron renderer with V8 heap OOM
Step 3: Reconnect / Attach Rapid remount churn causes View Transitions API collision storms Unclosable ghost tabs mount/unmount rapidly, aborting active document.startViewTransition() View transitions debounced or failed tabs quietly evicted without animation collisions terminal-surfaces.ts / UI Animation P1 Defect: 2,707 InvalidStateError unhandled rejections flood microtask queue
Step 2: Synchronize Secondary paired client waking from sleep retains outdated multi-host tab roster PR #21288 partitions session resumes by host but leaves client-side null-PTY mirrors intact Post-wake authoritative manifest prunes local tabs lacking server PTY handles PR #21288 / Gap 3 In Review: Resume keys partitioned; needs client-side mirror TTL pruning
Step 4: Close & Teardown Closing a ghost tab fails with selector_not_found and loops Host RPC rejects close on already-deleted worktree; client strips tombstone Idempotent close succeeds on missing worktrees; locked tombstones persist PR #21277 / Gap 2 In Review: PR #21277 prevents close crash; TTL bound prevents ghost creation

3. Code Evidence & Detailed Root Cause Analysis

3.1 The Guard Invariant in terminal-surfaces.ts

In src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts (lines 222–250):

export function shouldReplaceTerminalTab(
  tab: TerminalTab,
  environmentId: string,
  nextRemotePtyIds: ReadonlySet<string>,
  nextMirroredTerminalIds: ReadonlySet<string>,
  exactProvisionalHandoffs: ReadonlySet<string>
): boolean {
  if (exactProvisionalHandoffs.has(tab.id)) {
    // Why: agent kind is not session identity; retire only the provisional tab
    // whose request or structured response identifies this exact host surface.
    return true
  }
  if (isMirroredTerminalSurfaceId(tab.id)) {
    // Why: host snapshots are authoritative for mirrored tabs; replace old mirrors even when the next surface still awaits a stream handle, else parity drifts.
    return true
  }
  if (tab.pendingActivationSpawn && tab.ptyId === null && nextRemotePtyIds.size > 0) {
    return true
  }
  if (!isRuntimeTerminalTabForEnvironment(tab, environmentId)) {
    return false
  }
  // Why: web-created remote tabs use local UUIDs until the host publishes their surface; only retire them once their PTY appears in the snapshot.
  return (
    tab.ptyId !== null &&
    (nextRemotePtyIds.has(tab.ptyId) ||
      nextMirroredTerminalIds.has(toWebTerminalSurfaceTabId(tab.id)))
  )
}

3.2 Indefinite Retention in apply-preparation-base.ts

In src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts (lines 152–165):

  const retainedTerminalTabs = (
    reconcilesNonAgentTabs
      ? currentTerminalTabs.filter(
          (tab) =>
            !shouldReplaceTerminalTab(
              tab,
              environmentId,
              nextRemotePtyIds,
              nextMirroredTerminalIds,
              exactProvisionalHandoffs
            )
        )
      : currentTerminalTabs
  ).filter((tab) => !replacedTerminalIds.has(tab.id))

3.3 Root Cause Analysis

  1. Intended Invariant: When a user or agent opens a remote terminal, the desktop client generates a local UUID (web-terminal-<uuid>) with ptyId: null. The comment at line 244 documents the intention:

    "Why: web-created remote tabs use local UUIDs until the host publishes their surface; only retire them once their PTY appears in the snapshot."

  2. Missing Boundary Condition: The code assumes that the host will eventually publish the PTY surface. If the host fails to publish (worktree deleted, container provisioning aborted, process exit, network drop), tab.ptyId remains null.
  3. Boolean Inversion Trap:
    • At line 246, tab.ptyId !== null evaluates to false.
    • shouldReplaceTerminalTab() returns false.
    • In apply-preparation-base.ts, !shouldReplaceTerminalTab(...) evaluates to !false === true.
    • The dead placeholder is added to retainedTerminalTabs.
  4. Permanent Retention: Although TerminalTab records createdAt: number (defined in src/shared/terminal-tab-types.ts), neither shouldReplaceTerminalTab nor applyPreparationBase inspects createdAt or checks for elapsed time. As a result, a placeholder created 3 weeks ago receives the exact same indefinite protection as a placeholder created 3 milliseconds ago.

4. Operational Impact & Fleet Telemetry Evidence

Empirical audit telemetry gathered across developer workstations connected to a headless Linux development server (Headless Linux Server, IP 10.x.x.x) reveals severe state corruption directly traceable to unpruned ptyId: null placeholders:

Environment / Host Client Platform Total Serialized Tabs Ghost Tabs (ptyId: null) Active Live PTYs Ghost Rate
Remote Host (Headless Linux Server) Linux 6.8 (systemd) N/A (daemon memory) 0 (headless) 77 PTYs (70 leaked) N/A
Workstation Alpha macOS (Darwin x64) 128 tabs 98 ghost tabs 30 active PTYs 76.6% ghost rate
Workstation Beta Windows 11 (WSL2) 194 tabs 74 ghost tabs 1 active PTY 38.1% ghost rate
Combined Client Telemetry Cross-platform 322 total tabs 172 ghost tabs 31 active PTYs 53.4% ghost rate

4.1 Forensic Trace Evidence: Remount Loops & Render Crashes

Client traces captured in %APPDATA%\orca\logs\main.trace.ndjson and ~/Library/Logs/orca/:

  1. 85 Continuous Remount Loops (spawn-left-pane-unbound):
    When the desktop client re-attaches to a remote workspace containing unclosable null-PTY tabs, TerminalPane attempts to bind a PTY to the ghost tab. Because no remote handle exists, the mount fails, triggering an unmount/remount cycle every 15 seconds:
    {"timestamp":"2026-09-17T18:22:11.412Z","level":"warn","subsystem":"terminal-pane","message":"spawn-left-pane-unbound: leaf pty missing, requesting remount","tabId":"web-terminal-8b66908a-0f5d-4f82-9d15-70920a8189bc","worktreeId":"wt-agent-01"}
    
  2. 2,707 View Transitions API Collision Aborts:
    The uncontrolled 15-second remount thrashing triggered thousands of overlapping document.startViewTransition() calls. Because Chromium aborts any active transition when a new one starts, the microtask queue was flooded with InvalidStateError exceptions.
  3. Fatal V8 Heap Exhaustion (Exit Code 5):
    On macOS, four separate fatal crashes occurred where the Electron renderer hit its V8 heap ceiling (3,948 MB, 3,883 MB, and 3,972 MB) due to leaked DOM nodes and uncollected xterm scrollback buffers retained by the 98 ghost tabs.

4.2 Sanitized Sample Persisted Ghost Tab State

Captured from ~/.config/orca/orca-data.json:

[
  {
    "host": "runtime:e4c99064-1ef1-4d4f-beaf-ffe234af4a8a",
    "worktree": "242edab8-f9f4-4645-bd51-5db7ce49009c::~/orca/workspaces/project/Chief-of-Staff",
    "id": "web-terminal-8b66908a-0f5d-4f82-9d15-70920a8189bc",
    "ptyId": null,
    "title": "Chief of Staff [successor]",
    "createdAt": 1723298546806,
    "sortOrder": 4
  },
  {
    "host": "runtime:e4c99064-1ef1-4d4f-beaf-ffe234af4a8a",
    "worktree": "242edab8-f9f4-4645-bd51-5db7ce49009c::~/orca/workspaces/project/Chief-of-Staff",
    "id": "web-terminal-2e531cc9-afa8-4f0b-adc5-3b8fd2698ccf",
    "ptyId": null,
    "title": "Chief of Staff",
    "createdAt": 1723298547112,
    "sortOrder": 5
  }
]

These entries persisted across restarts for weeks after ~/orca/workspaces/project/Chief-of-Staff was deleted from the host.


5. Minimal Reproduction Script

The defect can be verified with this standalone reproduction script:

// repro-gap-02-null-pty-ttl.ts
// Run with: npx tsx repro-gap-02-null-pty-ttl.ts
import assert from 'node:assert/strict'

interface MinimalTerminalTab {
  id: string
  ptyId: string | null
  createdAt: number
  worktreeId: string
  pendingActivationSpawn?: boolean
}

// Exact logic from src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts
function shouldReplaceTerminalTabCurrent(
  tab: MinimalTerminalTab,
  environmentId: string,
  nextRemotePtyIds: ReadonlySet<string>,
  nextMirroredTerminalIds: ReadonlySet<string>,
  exactProvisionalHandoffs: ReadonlySet<string>
): boolean {
  if (exactProvisionalHandoffs.has(tab.id)) return true
  if (tab.id.startsWith('mirrored-terminal-')) return true
  if (tab.pendingActivationSpawn && tab.ptyId === null && nextRemotePtyIds.size > 0) return true

  // For runtime terminal tab in this environment:
  return (
    tab.ptyId !== null &&
    (nextRemotePtyIds.has(tab.ptyId) ||
      nextMirroredTerminalIds.has(tab.id))
  )
}

// Simulate an orphaned tab created 30 minutes ago whose host PTY never spawned
const now = Date.now()
const thirtyMinutesAgo = now - 30 * 60 * 1000

const orphanedGhostTab: MinimalTerminalTab = {
  id: 'web-terminal-orphan-1234',
  ptyId: null,
  createdAt: thirtyMinutesAgo,
  worktreeId: 'worktree-vanished'
}

const activeRemotePtys = new Set<string>(['pty-host-live-1'])
const mirroredIds = new Set<string>()
const provisionalHandoffs = new Set<string>()

const shouldReplace = shouldReplaceTerminalTabCurrent(
  orphanedGhostTab,
  'runtime:remote-env',
  activeRemotePtys,
  mirroredIds,
  provisionalHandoffs
)

// In apply-preparation-base.ts:
// retainedTerminalTabs = currentTerminalTabs.filter(tab => !shouldReplaceTerminalTab(...))
const isRetained = !shouldReplace

console.log(`[Repro] Tab age: 30 minutes, ptyId: null`)
console.log(`[Repro] shouldReplaceTerminalTab returned: ${shouldReplace}`)
console.log(`[Repro] Is tab retained in client state? ${isRetained}`)

// BUG: Even though the tab is 30 minutes old and has no PTY, it is retained indefinitely!
assert.equal(isRetained, true, 'Defect Confirmed: Orphaned null-PTY tab retained indefinitely.')
console.log('✅ Reproduction confirmed: Null-PTY tab survives indefinitely without TTL bound.')

6. Ecosystem Alignment & Related Issues

Reference Type Title / Focus Author / Owner Relationship to Gap 2 Status
#11800 Issue Unclosable null-PTY terminal ghosts retained after host topology advances Community Direct symptom: null-PTY entries survive branch/topology changes Open
#18117 Issue Remote terminal tabs survive disconnect and leak in client tab roster Community Tracks tab accumulation surviving network disconnect/reconnect Open
#21236 Issue Paired client keeping mirrored terminal tab for days; auto-resumes dead Claude sessions Community Paired desktop waking up auto-resumes stale sessions from ghost tabs Open
PR #21288 PR Partition session resume keys by executionHostId Maintainer (@nwparker) Scopes resume keys to host, but does not prune client-side null-PTY entries In Review
PR #21277 PR Idempotent tab close on missing worktree and durable tombstones Contributor Makes manual close safe; does not provide autonomous reconciler eviction In Review
This Proposal Upstream Gap 30s TTL grace period bound on null-PTY placeholders in reconciler Contributor Automates eviction of stale null-PTY placeholders without user intervention Ready to File

7. Proposed Solution: 30-Second TTL Grace Period Bound

7.1 Architectural Fix

  1. Define Explicit TTL Constant: Introduce NULL_PTY_PLACEHOLDER_TTL_MS = 30_000 (30 seconds) in terminal-surfaces.ts. This allows legitimate lazy-spawn operations ample time to connect and receive a PTY handle across high-latency or slow-spawning SSH connections.
  2. Plumb now Timestamp: Pass now: number = Date.now() into shouldReplaceTerminalTab. In applyPreparationBase, const now = Date.now() is already computed and can be forwarded directly.
  3. Autonomous Eviction Guard: If tab.ptyId === null and (now - tab.createdAt) > NULL_PTY_PLACEHOLDER_TTL_MS, shouldReplaceTerminalTab() returns true.
  4. Result: The expired placeholder tab is filtered out of retainedTerminalTabs and cleanly evicted from tabsByWorktree during the normal reconcile tick, preventing persistent ghost tab accumulation.

7.2 Proposed Code Changes

1. In src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts:

+export const NULL_PTY_PLACEHOLDER_TTL_MS = 30_000

 export function shouldReplaceTerminalTab(
   tab: TerminalTab,
   environmentId: string,
   nextRemotePtyIds: ReadonlySet<string>,
   nextMirroredTerminalIds: ReadonlySet<string>,
-  exactProvisionalHandoffs: ReadonlySet<string>
+  exactProvisionalHandoffs: ReadonlySet<string>,
+  now: number = Date.now()
 ): boolean {
   if (exactProvisionalHandoffs.has(tab.id)) {
     // Why: agent kind is not session identity; retire only the provisional tab
     // whose request or structured response identifies this exact host surface.
     return true
   }
   if (isMirroredTerminalSurfaceId(tab.id)) {
     // Why: host snapshots are authoritative for mirrored tabs; replace old mirrors even when the next surface still awaits a stream handle, else parity drifts.
     return true
   }
   if (tab.pendingActivationSpawn && tab.ptyId === null && nextRemotePtyIds.size > 0) {
     return true
   }
   if (!isRuntimeTerminalTabForEnvironment(tab, environmentId)) {
     return false
   }
+  // Why: web-created remote tabs get a 30s grace period to receive their host PTY.
+  // Beyond 30s, an unallocated placeholder is an orphaned ghost tab and must be retired.
+  if (
+    tab.ptyId === null &&
+    typeof tab.createdAt === 'number' &&
+    now - tab.createdAt > NULL_PTY_PLACEHOLDER_TTL_MS
+  ) {
+    return true
+  }
   // Why: web-created remote tabs use local UUIDs until the host publishes their surface; only retire them once their PTY appears in the snapshot.
   return (
     tab.ptyId !== null &&
     (nextRemotePtyIds.has(tab.ptyId) ||
       nextMirroredTerminalIds.has(toWebTerminalSurfaceTabId(tab.id)))
   )
 }

2. In src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts:

   const retainedTerminalTabs = (
     reconcilesNonAgentTabs
       ? currentTerminalTabs.filter(
           (tab) =>
             !shouldReplaceTerminalTab(
               tab,
               environmentId,
               nextRemotePtyIds,
               nextMirroredTerminalIds,
-              exactProvisionalHandoffs
+              exactProvisionalHandoffs,
+              now
             )
         )
       : currentTerminalTabs
   ).filter((tab) => !replacedTerminalIds.has(tab.id))

8. Verification & Test Plan

8.1 Unit Tests for shouldReplaceTerminalTab

In src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.test.ts:

describe('shouldReplaceTerminalTab - Null-PTY TTL bound', () => {
  const envId = 'runtime:remote-host-1'
  const emptySet = new Set<string>()

  it('retains a freshly created tab with ptyId: null within the 30s grace period', () => {
    const now = 1_000_000
    const freshTab: TerminalTab = {
      id: 'web-terminal-fresh-1',
      ptyId: null,
      createdAt: now - 15_000, // 15 seconds old (< 30s TTL)
      worktreeId: 'wt-1',
      title: 'Terminal 1',
      sortOrder: 0,
      customTitle: null,
      color: null
    }

    const replace = shouldReplaceTerminalTab(
      freshTab,
      envId,
      emptySet,
      emptySet,
      emptySet,
      now
    )

    // Defends invariant: In-flight lazy spawns are protected within grace period
    expect(replace).toBe(false)
  })

  it('evicts a tab with ptyId: null exceeding the 30s grace period', () => {
    const now = 1_000_000
    const expiredTab: TerminalTab = {
      id: 'web-terminal-stale-2',
      ptyId: null,
      createdAt: now - 31_000, // 31 seconds old (> 30s TTL)
      worktreeId: 'wt-1',
      title: 'Terminal 2',
      sortOrder: 0,
      customTitle: null,
      color: null
    }

    const replace = shouldReplaceTerminalTab(
      expiredTab,
      envId,
      emptySet,
      emptySet,
      emptySet,
      now
    )

    // Defends invariant: Stale null-PTY placeholders are retired
    expect(replace).toBe(true)
  })
})

9. Submission Checklist

Activity

  1. AmethystLiang commented on Sep 18, 2026

    @AmethystLiang
    Contributor

    A fix is being actively worked on. Target roll out date is early next week

  2. added
    bugSomething isn't working
    orca-remote-serverRemote Orca Server / orca serve / paired remote runtime
    on Sep 19, 2026
  3. added theissue type on Sep 19, 2026
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