Skip to content

fix(client): multi-primitive yield for write pacing (rAF + setTimeout + Worker)#85

Open
aakhter wants to merge 1 commit into
Ark0N:masterfrom
aakhter:fix/multi-primitive-yield
Open

fix(client): multi-primitive yield for write pacing (rAF + setTimeout + Worker)#85
aakhter wants to merge 1 commit into
Ark0N:masterfrom
aakhter:fix/multi-primitive-yield

Conversation

@aakhter
Copy link
Copy Markdown
Contributor

@aakhter aakhter commented May 15, 2026

Summary

When the data-pacing path (chunkedTerminalWrite + the deferred path of flushPendingWrites) schedules its next chunk via requestAnimationFrame alone, terminal output stalls indefinitely if rAF is starved. Three real-world Chromium scenarios reproduce this:

  1. Window is occluded (fully covered by another window, or on a monitor that has gone to sleep). rAF drops to ~0 Hz.
  2. Tab is idle-throttled (no user interaction for ~5 min). Chromium intensive-throttling clamps setTimeout to 1 Hz too — so the existing rAF-then-fall-back-to-setTimeout pattern doesn't help.
  3. Tab is in a background window. Both rAF and setTimeout slow to a crawl.

Symptom in all three: a long-running command's output appears to "freeze" mid-write. When the user refocuses the tab the queue drains in a burst.

Fix

Replace the rAF-only scheduling with a _safeYield(cb) helper that races three primitives in parallel:

  • requestAnimationFrame — primary, fires at compositor rate.
  • setTimeout(50) — fallback for visible-but-occluded windows.
  • Worker postMessage tick — fallback for idle-throttled and background tabs. Workers are not subject to main-thread throttling. (This is the React Scheduler trick.)

The first one to fire wins via a done guard; the others become no-ops.

The Worker is built lazily on first call (4 lines of inline JS via Blob URL). If Worker construction throws we silently fall back to the other two primitives.

Scope

Replaces 6 requestAnimationFrame callsites that participate in data pacing:

  • flushPendingWrites scheduling — live path (3 sites across the live-write and deferred-write helpers).
  • chunkedTerminalWrite — initial chunk, next-chunk loop, and the final finish-callback.

True animation use cases (the scroll loop in scrollToBottom, the fit-addon reflow) stay on plain requestAnimationFrame — they are correctly throttled when the user isn't looking, by design.

Test plan

  • Foreground tab: terminal output behaves identically (rAF wins, others no-op).
  • Cover the Codeman window with another window for 30s while a command is producing output → output continues (setTimeout(50) wins).
  • Move the Codeman tab to a background tab in the same window for 5+ minutes → after returning, the buffer is full, not empty (Worker tick won during throttling).
  • DevTools → Performance recorder shows no excess timer callbacks during normal foreground operation (the done guard prevents redundant work).
  • In a browser without Worker support (or where construction throws), _workerYield is silently disabled and the rest still works.

Files

  • src/web/public/terminal-ui.js (+70/-8)

🤖 Generated with Claude Code

When the data-pacing path (chunkedTerminalWrite + deferred path of
flushPendingWrites) schedules its next chunk via requestAnimationFrame
alone, terminal output stalls indefinitely if rAF is starved. Three
real-world scenarios reproduce this in Chromium:

1. Window is occluded (fully covered by another window, or on a
   monitor that has gone to sleep). rAF drops to ~0Hz.
2. Tab is idle-throttled (no user interaction for ~5 min). Chromium
   intensive-throttling clamps setTimeout to 1Hz too.
3. Tab is in a background window. Both rAF and setTimeout slow to a
   crawl.

Replace the rAF-only scheduling with a _safeYield helper that races
three primitives in parallel:

- requestAnimationFrame (primary, fires at compositor rate).
- setTimeout(50) (fallback for visible-but-occluded windows).
- Worker postMessage tick (fallback for idle-throttled and
  background tabs; Workers are not subject to main-thread throttling
  — this is the React Scheduler trick).

The first one to fire wins via a `done` guard; the others become
no-ops. The Worker is built lazily on first call (4 lines of inline
JS via Blob URL); if Worker construction throws we silently fall
back to the other two primitives.

Replaces 6 requestAnimationFrame callsites that participate in data
pacing:
- 3 flushPendingWrites scheduling sites (live + deferred paths).
- 3 chunkedTerminalWrite sites (initial chunk, next-chunk loop,
  final finish-callback).

True animation use cases (scroll loop in scrollToBottom, fit-addon
reflow) stay on plain requestAnimationFrame — they are correctly
throttled when the user is not looking, by design.

File: src/web/public/terminal-ui.js (+70/-8).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant