Skip to content

Switching between porejcts and tabs away from a temrianl running a cli session can cause… #157

Closed

Description

@arul28

Description

switching between porejcts and tabs away from a temrianl running a cli session can cause the output to not show up, the cli session will still ru but the redering outpur seems to freeze

Steps to Reproduce

Not provided.

Expected Behavior

Not provided.

Actual Behavior

Not provided.

Environment

  • App: ADE Desktop
  • Model: anthropic/claude-opus-4-6

Activity

  1. arul28 commented on Apr 14, 2026

    @arul28
    OwnerAuthor

    Root cause

    In apps/desktop/src/renderer/components/terminals/TerminalView.tsx, the frame-write pipeline had two runtime.refs === 0 guards that conspired to strand buffered PTY output when the terminal was parked (i.e. the TerminalView had unmounted because the user switched tabs/projects, but the runtime was kept alive in runtimeCache because the PTY was still running):

    1. enqueueFrameWrite (line 487, old): if (runtime.refs === 0) return; — when data arrived while parked, the chunk got buffered into frameWriteChunks but no RAF was ever scheduled. The buffer grew silently until it hit MAX_FRAME_WRITE_BYTES (450 KB), after which the while loop dropped the oldest chunks (droppedChunks health counter incremented).
    2. scheduleFrameWriteFlush RAF callback (line 498, old): if (runtime.refs === 0) return; — even if a RAF had been scheduled before the user switched away and only fired after unmount, the callback bailed without flushing and without rescheduling, leaving flushRafId = null but with data still in the buffer.

    The remount path relied on a single scheduleFrameWriteFlush(runtime) call at line 945 to drain the buffer asynchronously. For short absences this worked fine — the buffered chunks would flush on the next frame after remount. But for TUI CLIs (Claude Code, Codex, Cursor, btop, vim, anything that redraws via ANSI escape sequences), two failure modes kicked in:

    • Long absences exceeded 450 KB of output → older chunks dropped → the remaining tail started mid-redraw, with screen-clear / cursor-positioning escapes missing from the front, so xterm composited the catch-up write onto stale screen state. To the user, that looks like the terminal "froze" at a weird intermediate frame even though the PTY is still emitting data.
    • If the pre-unmount RAF happened to fire during the parked window (race window of ~16 ms between unmount committing and the RAF callback running), that RAF cleared flushRafId without flushing and without rescheduling. The next enqueue bailed out at guard Codex/ade phase 4 5 #1 instead of scheduling a fresh RAF. Now nothing triggered a flush until the user returned.

    Fix

    apps/desktop/src/renderer/components/terminals/TerminalView.tsx:

    1. Extracted flushFrameWriteChunksSync(runtime) — shared flush helper used by both the RAF callback and the remount effect. It preserves the runtime.disposed check, bails on empty buffer, and joins+writes chunks in a try/catch.
    2. Removed the refs === 0 guard from enqueueFrameWrite — every chunk now schedules a flush RAF regardless of mount state.
    3. Removed the refs === 0 guard from the flush RAF — the RAF always calls flushFrameWriteChunksSync. xterm.Terminal.prototype.write only updates the internal parser buffer and viewport state; it is safe to call when the host element is detached from the visible DOM (the xterm renderer is decoupled and simply re-renders from the buffer when the host becomes visible again).
    4. Added a synchronous drain on the remount effect (line 947–957) — after re-parenting runtime.host into the new container, we now cancelAnimationFrame(runtime.flushRafId) and call flushFrameWriteChunksSync(runtime) directly. This belt-and-suspenders guarantees the user sees the latest terminal state immediately on switch-back, even if the parked-runtime RAF path was throttled (e.g. requestAnimationFrame can be paused by Chromium on detached/hidden subtrees under memory pressure).

    Net effect: output flows into xterm continuously, the internal scrollback stays accurate at all times, and TUI redraws compose correctly because no escape sequences are ever dropped from the front of the stream.

    Test update

    TerminalView.test.tsx had a test that specifically asserted the old buffer-and-flush-on-remount contract ("buffers PTY output while unmounted instead of writing into a parked runtime"). Rewrote it to reflect the new contract: PTY data that arrives while parked is written directly into the xterm runtime via the RAF path, and the subsequent remount does not re-flush the same data (i.e. no double-writes). Still 8/8 terminal tests passing.

    Edge cases audited

    • Rapid unmount/remount (mount → enqueue → RAF scheduled → unmount before RAF fires → remount): the remount's synchronous cancelAnimationFrame + flush path drains whatever the pending RAF would have flushed, preventing duplicate/missing writes.
    • PTY exit while parked (runtime gets exitCode, scheduleRuntimeDispose fires after EXITED_RUNTIME_KEEPALIVE_MS): unchanged — disposal still waits 8 s for a potential remount, and flushFrameWriteChunksSync no-ops on a disposed runtime.
    • Hydration race (data arrives before hydrationCompleted=true): unchanged — that path still routes into pendingHydrationChunks via the branch at line 821; enqueueFrameWrite is not called until hydration finishes.
    • Buffer overflow during bursts: with continuous RAF-driven flushing the buffer drains ~every 16 ms, so the MAX_FRAME_WRITE_BYTES ceiling is essentially unreachable in practice. The drop-oldest fallback is retained as a safety net for pathological bursts.
    • WebGL renderer while detached: doFit already short-circuits when !runtime.visible, so the renderer doesn't attempt to re-fit a 0×0 parked host. The WebGL canvas updates its internal buffer but only renders visibly once the host is reattached.
    • All TerminalView call sites benefit (WorkViewArea, ChatTerminalDrawer, ProcessMonitor) — the fix is in the shared runtime layer.

    Verified

    • npx vitest run src/renderer/components/terminals/ — 47/47 tests pass across 6 files (TerminalView.test.tsx, PackedSessionGrid.test.tsx, useWorkSessions.test.ts, WorkViewArea.test.tsx, etc.).
    • npx tsc --noEmit — clean.
    • Full vitest run — clean.

    🤖 Generated with Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions