Skip to content

perf(usage): yield with setImmediate, not a clamped setTimeout(0) - #10892

Merged
nwparker merged 2 commits into
mainfrom
perf/claude-usage-yield
Jul 27, 2026
Merged

nwparker merged 2 commits into
mainfrom
perf/claude-usage-yield

Conversation

@nwparker

@nwparker nwparker commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Description

The Claude usage scanner yielded to the event loop with setTimeout(resolve, 0). Node clamps that to ~1ms. Both batch loops yield once per FILE_SCAN_BATCH_SIZE (4) files, so a machine with 7,552 transcripts makes ~3,800 yields and spends seconds parked on timers doing no work.

setImmediate runs on the same tick's check phase with no clamp. The sibling scanner src/main/codex-usage/scanner.ts:92 already used setImmediate — this was an inconsistency, not a design choice. The two functions were otherwise byte-identical.

One line changed.

ELI5

While scanning your usage history, the app pauses between small batches so the rest of the app stays responsive.

It was using a pause that Node rounds up to a full millisecond. Do that ~3,800 times and you've waited ~4 seconds doing nothing. The fix uses the pause that means "right after this tick," which is what was wanted all along.

Proof of perf improvement

config/scripts/claude-usage-yield-benchmark.mjs (new), arms alternated per round, per-arm medians:

yields setTimeout(0) setImmediate ratio saved
100 139.7 ms 20.7 ms 6.7× 119 ms
500 611.9 ms 25.1 ms 24.4× 587 ms
3,780 (this machine's real scan) 4291–5270 ms 54–164 ms 32–81× 4237–5107 ms

Read the saved column, not the ratio. The setImmediate arm is small enough that background
load lands almost entirely on it, swinging the ratio between 32× and 81× across three consecutive
runs while the removed wall time holds steady at 4.2–5.1 s. The honest headline is
~4.3 seconds of main-process wall time removed per full scan, not a multiple.

The yield count is derived from real data, not assumed: the benchmark counts .jsonl files under ~/.claude/projects (7,552 here) and reads FILE_SCAN_BATCH_SIZE and the yield-site count out of the scanner source.

Independent confirmation of the mechanism, measured separately and in both orders: setTimeout(0) costs 1,138 µs per yield versus 14.3 µs for setImmediate.

Responsiveness improves — it does not regress. That matters, because responsiveness is the entire reason the yield exists; a "faster" yield that starved other work would be a regression even at 80×. Measured with a concurrent probe task during a full scan:

scan worst concurrent wait
setTimeout(0) 3,790 ms 6.33 ms
setImmediate 56 ms 0.12 ms

The old version was worse on both axes because most of its wall time was the main process idle-but-blocked on timer clamps. The benchmark prints an explicit NOTE if setImmediate ever shows a worse concurrent wait, so a future regression here is visible rather than silent.

Scope: this is wall-clock the process spent waiting, not CPU work removed. The scan's own I/O and parsing are unchanged. It's paid on every Usage-pane scan and on every forced automation rescan.

Proof of zero regression

  • src/main/claude-usage/, codex-usage/, opencode-usage/: 11 files, 77 tests passing. pnpm typecheck clean.

  • Benchmark drift guards, verified by breaking them: renaming FILE_SCAN_BATCH_SIZE throws …is stale; reverting the scanner to setTimeout throws scanner no longer yields with setImmediate.

    The second guard initially did not fire — I'd written a bare includes('setImmediate') check, which the new comment mentioning setImmediate satisfied even after the call reverted. Now it matches the call form (/setImmediate\(resolve\)/). A guard that a comment can satisfy is not a guard.

  • No behaviour depends on the yield's duration: both call sites are if (moreWork) await yieldToEventLoop() between pure in-memory batch steps.

Deliberately left alone — three other setTimeout(resolve, 0) sites exist in orca-runtime.ts:14222/14249 and pty.ts:5044, and they look like the same bug but are not. They sit between PTY paste chunk writes, where the clamp acts as inter-chunk pacing, not merely a yield. Removing it there would change how fast bytes hit the terminal — a behaviour change, not a perf fix. opencode-usage/scanner.ts:148 has the identical yield shape but iterates databases (2 on a real machine, ~1 yield), so it's below the bar; noted, not changed.

Review loop count + verdict

2 loops. Round 1 verified the clamp claim independently and confirmed the yield frequency against the real transcript count rather than trusting the reported figure. Round 2 caught the comment-satisfiable drift guard.

Verdict: ship. One line, an in-repo sibling already proves the pattern, and the property that justifies the yield's existence measurably improves.

Made with Orca 🐋

The Claude usage scanner yielded to the event loop with setTimeout(resolve, 0),
which Node clamps to ~1ms. Both batch loops yield once per 4 files, so a machine
with 7.5k transcripts made ~3,800 yields and spent seconds parked on timers doing
no work. The sibling codex-usage scanner already used setImmediate; this was an
inconsistency, not a design choice.

82x on the yield path (4.3s -> 53ms at this machine's real 7,552 transcripts).

Responsiveness -- the reason the yield exists -- improves rather than regresses:
worst concurrent wait during a scan drops from 6.33ms to 0.12ms, because most of
the old wall time was the main process idle-but-blocked on timer clamps.

Left alone deliberately: the three setTimeout(0) sites in the PTY paste path
(orca-runtime.ts, pty.ts) pace inter-chunk writes rather than merely yield, so
removing the clamp there would change write pacing, not just scheduling.

Co-authored-by: Orca <help@stably.ai>
@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The scanner’s yieldToEventLoop() helper now schedules continuation with setImmediate instead of setTimeout(0). A new CLI benchmark extracts scanner configuration, counts Claude transcript files, simulates batched scanning with both scheduling mechanisms, measures median scan duration and concurrent probe latency, and reports results across batch sizes. It also validates the scanner’s configured yield implementation and prints a comparison note when immediate scheduling produces higher observed wait latency.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it does not follow the required template and omits explicit Summary, Screenshots, Testing, AI Review Report, Security Audit, and Notes sections. Rewrite the PR description to match the repository template and add the missing sections, especially screenshots/no visual change, testing, AI review, security audit, and notes.
✅ Passed checks (4 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: switching the scanner yield from setTimeout(0) to setImmediate.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 46c62c79-ed57-49f5-b5df-57cf2dc68a2b

📥 Commits

Reviewing files that changed from the base of the PR and between b96c2f0 and dfafed8.

📒 Files selected for processing (2)
  • config/scripts/claude-usage-yield-benchmark.mjs
  • src/main/claude-usage/scanner.ts

Review feedback: the yield count was wrong twice over. Each pass walks
ceil(files / batch) batches and yields after every batch except the last, so a
pass yields batchesPerPass - 1 times -- and the loop was being handed the yield
count as a batch count, producing one fewer yield than the row claimed.

Correcting it also exposed that the ratio is not a stable number to publish. The
setImmediate arm is small enough (54-164 ms) that background load swings the
ratio between 32x and 81x across runs, while the removed wall time holds at
4.2-5.1 s. Report the absolute saving alongside the ratio and say which to read.

Co-authored-by: Orca <help@stably.ai>
@nwparker

Copy link
Copy Markdown
Contributor Author

Fixed in e32d931, and it forced a correction to this PR's headline number.

The yield count was wrong twice over, exactly as you describe. Each pass walks ceil(files / batch) batches and yields after every batch except the last, so a pass yields batchesPerPass - 1 times — and I was separately handing that yield count to the loop as a batch count, producing one fewer yield again. The "full scan" row measured neither the count it printed nor the count the scanner performs.

Correcting it exposed something worse than an off-by-one: the ratio isn't a publishable number at all. Three consecutive runs of the corrected benchmark:

run setTimeout(0) setImmediate ratio saved
1 5270.5 ms 163.7 ms 32.2× 5107 ms
2 4412.5 ms 54.7 ms 80.6× 4358 ms
3 4291.5 ms 54.0 ms 79.5× 4237 ms

The setImmediate arm is small enough that any background load lands almost entirely on it, swinging the ratio 32×–81× while the removed wall time stays at 4.2–5.1 s. So I've retracted the "82×" headline: the honest claim is ~4.3 seconds of main-process wall time removed per full scan, and the benchmark now prints a saved column with a note telling the reader to use it rather than the ratio.

Worth being explicit that this is the same class of mistake I hit on #10881 (a 23.3× that was really 6.7×) — there I fixed it by interleaving the arms, which this benchmark already did. Interleaving controls for drift between arms; it does nothing when one arm is so small that noise dominates it proportionally. Reporting the absolute delta is the actual fix.

The underlying change is unaffected — one line, setTimeout(resolve, 0) → setImmediate(resolve), matching the sibling codex-usage scanner — and the responsiveness measurement still shows the concurrent-wait improving rather than regressing.

@nwparker
nwparker merged commit fb26254 into main Jul 27, 2026
1 check passed
@nwparker
nwparker deleted the perf/claude-usage-yield branch July 27, 2026 10:08
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
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