Skip to content

perf: reduce per-repaint CPU under concurrent sessions - #2

Merged
axisrow merged 3 commits into
mainfrom
ao/ccstatusline-2/root
Sep 25, 2026
Merged

axisrow merged 3 commits into
mainfrom
ao/ccstatusline-2/root

Conversation

@axisrow

@axisrow axisrow commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Summary

Fixes the per-repaint CPU cost that multiplies under concurrent Claude Code sessions (context: upstream issue sirmalloc#397 — every statusline repaint spawns a fresh node process, so fixed per-process costs scale with session count). Two shared-path fixes, no new dependencies:

  • src/utils/terminal.ts — the macOS/BSD terminal-width ancestor walk probes each ancestor with a single ps -o ppid= -o tty= -p PID spawn instead of two spawns (ps -o ppid= + ps -o tty=). The walk still covers ancestor generations 1–8 (process.ppid starts it for free) and reads each generation's TTY before terminating on its PPID, matching the old walk's coverage and precedence. Two separate -o flags rather than a comma list: FreeBSD's ps parser treats everything after the first = as a single header, which would collapse the comma form into one bogus column.
  • src/utils/usage-fetch.ts, src/utils/claude-service-status.ts — https-proxy-agent (and its proxy-chunk module graph) is now loaded via dynamic import only when a proxy is actually configured. Previously it was compiled into every startup of every render invocation.

Behavior notes (deliberate, reviewed in-thread)

  • Generation coverage and precedence match the old two-spawn walk: ancestors 1–8, and a TTY found on a row is honored even when that same row's PPID is terminal (a PPID of 0 does not hide a valid width).
  • Known narrowing vs the old sequence: if the combined ps lookup fails outright, the walk stops there. The old two-call flow could still continue upward using the separately obtained PPID. This required synthetic failure fixtures to observe.
  • Stty/tput fallbacks, memoization, and all cache semantics are unchanged.

Benchmark

Synthetic 13 MB transcript, 4 parallel workers × 20 renders via stdin, isolated HOME, built dist on Node. Initial measurements on the author's host: CPU per render 0.34 s → 0.26 s (−24%), p50 373 ms → 224 ms. These were not reproduced by independent verification on a shared development host, which measured CPU 268.8 → 247.9 ms per render (−7.8%) and p50 283.7 → 239.9 ms on the same workload.

What independent verification confirms structurally:

  • One ps spawn per ancestor instead of two (2 → 1 with an inherited caller TTY; 16 → 8 when no TTY is found through the chain).
  • The proxy chunk is deferred: without proxy variables the agent is never constructed, with HTTPS_PROXY set the request resolves through HttpsProxyAgent, and an invalid proxy URL degrades safely. These failure/agent paths were confirmed on real Node 14.21.3 as well as Node 25 and Bun.
  • compileForInternalLoader per render: 42.45 ms → 35.91 ms mean (8 × 500 µs-sampled profiles) — improved, but the initially claimed 40.7 → 7.5 ms was not reproduced.
  • Residual no-proxy undici startup load comes from the eager Yoga/widget-editor route (an upstream perf: keep the TUI off the status line render path sirmalloc/ccstatusline#575 artifact), not from the proxy change; deferring that path is a separate follow-up.

Tests

  • bun test: 2361 pass / 0 fail (terminal width mocks updated to the single-ps two-flag format; a regression test pins that a terminal PPID does not hide the same row's width; new proxy-agent wiring tests for the status page fetch: agent attached when HTTPS_PROXY is set, absent otherwise)
  • bun run lint: clean (tsc --noEmit + eslint, no rule suppressions)
  • Node 14+ / dual-runtime (Bun + Node) compatibility preserved; no dependency changes

Risks / follow-ups

  • Transcript scan remains O(file) per render; an incremental-scan cache is a possible follow-up but is a larger behavioral change.
  • Eager Yoga/widget-editor startup graph (source of the remaining no-proxy undici load) is a separate follow-up.
  • Pre-existing distribution blocker (documented during verification): current Bun builds do not down-convert ??= syntax, so the bundled dist does not actually run on Node 14 despite the target version; unrelated to this PR's changes.

Related: sirmalloc#397 (upstream issue only — no upstream changes in this PR).

🤖 Generated with Claude Code

With several concurrent Claude Code sessions every statusline invocation
is a fresh process, so per-render fixed costs multiply by session count.

- terminal: probe each ancestor with a single `ps -o ppid=,tty=` call
  instead of two spawns per ancestor; the walk runs per render on macOS
  where numeric widths are deliberately not persisted
- usage-fetch, claude-service-status: load https-proxy-agent (and its
  transitive graph) on demand, only when a proxy is actually configured

Benchmark (13MB transcript, 4 parallel workers x 20 renders via stdin):
27.2 -> 20.6 CPU-seconds total, 0.34 -> 0.26 CPU-s per render (-24%),
wall 7.5s -> 5.6s. node --cpu-prof shows the undici/proxy graph gone
from startup (compileForInternalLoader 40.7ms -> 7.5ms per render).

Co-Authored-By: Claude Code <noreply@anthropic.com>

@axisrow axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Verdict: Approve — not requesting changes.

The two per-repaint cost reductions are correct and well scoped; CI (build, lint + type check, tests) is green on the head commit.

Checked:

  • terminal.ts: the merged ps -o ppid=,tty= output parses cleanly (trim + whitespace split handles the right-aligned ppid column; ?, ?? and empty tty map to null; ppid=0 breaks the walk without extra spawns). Still execFileSync with an args array — no shell, no new injection surface.
  • Error handling survives the async refactor: both options builders wrap the dynamic import in try/catch and cannot reject (the proxy-url getters are pure env reads), so the promise chain in fetchStatusPagePath and the pre-awaited options in fetchFromUsageApi have no unhandled-rejection path, and null options still short-circuit to the same results as before.
  • Node 14 dual-runtime: the import type is fully erased, await import from CJS works on Node >= 12.17, and the dependency stays declared in package.json (no manifest change).
  • The base-options + path override plumbing in fetchStatusPagePath is behaviorally equivalent to the old inline construction.

Two minor non-blocking notes as inline comments.

Comment thread src/utils/terminal.ts
Comment thread src/utils/claude-service-status.ts
…wiring

- terminal: check the ancestor TTY before reassigning pid so each loop
  iteration reads as "one ps answer, one pid"; no call-order change
- claude-service-status: cover the lazy proxy-agent branch with tests
  (agent attached when HTTPS_PROXY is set, absent otherwise), matching
  the usage-fetch proxy coverage

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ation

- ps: use two -o flags instead of the comma list; FreeBSD's parser treats
  everything after the first '=' as one header, which collapsed the
  combined form into a single bogus column and dropped the probe to the
  tput fallback. Still one spawn per ancestor.
- walk: start at process.ppid and read each generation's TTY before
  terminating on its PPID, restoring the old two-spawn walk's generation
  coverage (1..8) and keeping a dead-end PPID from hiding a valid width
  on the same ps row. Known narrowing vs the old sequence: if the combined
  lookup fails entirely, the walk stops (the old two-call flow could still
  continue on the separately obtained PPID).

Co-Authored-By: Claude Code <noreply@anthropic.com>

@axisrow axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Verdict: Approve — not requesting changes. Follow-up on the earlier review (id 5314558675): both minor findings are addressed, and the extra hardening is correct.

  • Walk parity restored: starting from process.ppid (a free read) covers ancestor generations 1-8 exactly like the original two-spawn walk, and reading the current generation TTY before breaking on a terminal PPID is a strict improvement over main — a PPID of 0 no longer hides a width sitting on the same ps row. Covered by a dedicated test.
  • The two -o flags form is the right portable choice: on FreeBSD the header-replacement = consumes the rest of the -o argument, so the comma list would collapse into one bogus column; two flags produce the same whitespace-split row on macOS and Linux, so the parsing path is unchanged.
  • New proxy tests in claude-service-status.test.ts lock in both branches of the lazy-import wiring (agent attached with HTTPS_PROXY set, absent without) with proper env restore in afterEach.
  • CI green (build, lint + type check, tests) on head 0fc829c; the PR description was updated to match the final ps format and walk behavior.

Nothing further blocking.

@axisrow
axisrow merged commit 177686f into main Sep 25, 2026
6 checks passed
axisrow added a commit that referenced this pull request Sep 25, 2026
The fork's main had PR #2's pre-review revision (177686f); this brings in
the final revision from ao/ccstatusline-2/root: per-repaint subprocess and
module-graph cost cut (sirmalloc#397), the width-walk invariant clarification, and
the BSD-safe two-flag ps format with restored walk parity (0fc829c).

Conflicted on src/utils/terminal.ts: kept the TTY-probe-before-break
ordering from the final revision and dropped the obsolete duplicate check
after the parentPid break.

Co-Authored-By: Claude Code <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