You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Switching between porejcts and tabs away from a temrianl running a cli session can cause… #157
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
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):
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).
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.
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.
Removed the refs === 0 guard from enqueueFrameWrite — every chunk now schedules a flush RAF regardless of mount state.
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).
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.).
Activity
arul28 commentedon Apr 14, 2026
Root cause
In
apps/desktop/src/renderer/components/terminals/TerminalView.tsx, the frame-write pipeline had tworuntime.refs === 0guards that conspired to strand buffered PTY output when the terminal was parked (i.e. theTerminalViewhad unmounted because the user switched tabs/projects, but the runtime was kept alive inruntimeCachebecause the PTY was still running):enqueueFrameWrite(line 487, old):if (runtime.refs === 0) return;— when data arrived while parked, the chunk got buffered intoframeWriteChunksbut no RAF was ever scheduled. The buffer grew silently until it hitMAX_FRAME_WRITE_BYTES(450 KB), after which thewhileloop dropped the oldest chunks (droppedChunkshealth counter incremented).scheduleFrameWriteFlushRAF 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, leavingflushRafId = nullbut 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:flushRafIdwithout 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:flushFrameWriteChunksSync(runtime)— shared flush helper used by both the RAF callback and the remount effect. It preserves theruntime.disposedcheck, bails on empty buffer, and joins+writes chunks in atry/catch.refs === 0guard fromenqueueFrameWrite— every chunk now schedules a flush RAF regardless of mount state.refs === 0guard from the flush RAF — the RAF always callsflushFrameWriteChunksSync.xterm.Terminal.prototype.writeonly 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).runtime.hostinto the new container, we nowcancelAnimationFrame(runtime.flushRafId)and callflushFrameWriteChunksSync(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.requestAnimationFramecan 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.tsxhad 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
cancelAnimationFrame + flushpath drains whatever the pending RAF would have flushed, preventing duplicate/missing writes.exitCode,scheduleRuntimeDisposefires afterEXITED_RUNTIME_KEEPALIVE_MS): unchanged — disposal still waits 8 s for a potential remount, andflushFrameWriteChunksSyncno-ops on a disposed runtime.hydrationCompleted=true): unchanged — that path still routes intopendingHydrationChunksvia the branch at line 821;enqueueFrameWriteis not called until hydration finishes.MAX_FRAME_WRITE_BYTESceiling is essentially unreachable in practice. The drop-oldest fallback is retained as a safety net for pathological bursts.doFitalready 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.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.vitest run— clean.🤖 Generated with Claude Code