Skip to content

[medium] perf(custom-command): run custom commands concurrently in-process before the render - #630

Open
elhoim wants to merge 3 commits into
sirmalloc:mainfrom
elhoim:perf/custom-command-spawn
Open

elhoim wants to merge 3 commits into
sirmalloc:mainfrom
elhoim:perf/custom-command-spawn

Conversation

@elhoim

@elhoim elhoim commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

BLUF

  • Priority: medium. This removes one helper runtime per custom-command widget from every render.
  • What: all custom commands now run concurrently in a prefetch phase, using async spawn in the main process. This replaces running them one after another during the render, where each run did spawnSync(process.execPath, ['-e', capture]) and then sh -c command.
  • Protection kept: in-process and fallback runs share one capture function, so both keep the same deadline, process-group SIGKILL, 1 MiB cap, and pipe destroy on delivery. A background child that keeps stdout open still cannot hang the render or keep the process alive.
  • Measured (node, 20 interleaved passes, host heavily loaded): CPU -11% / -27% / -34% and wall -14% / -33% / -40% for 1 / 3 / 5 commands. With 3 commands, execve calls go from 10 to 7.
  • Output unchanged on the benchmarked configs. The default TTL is not changed (see the suggestion below).

Details

  • custom-command-capture.ts: captureCustomCommand now takes a deliver(result) callback and no longer calls process.stdout.write and process.exit itself. The -e helper passes a callback that does exactly that, so the synchronous path behaves as before. The function is still self-contained for the release bundle.
  • custom-command.ts:
    • New runCustomCommandAsync, which uses the same memory and persistent cache logic, now factored into readCachedResult/storeResult. It never rejects, and a timeoutMs + 1000 backstop mirrors the old spawnSync timeout.
    • New prefetchCustomCommandsIfNeeded(lines, context), which runs every custom-command widget's request at once. Identical requests (same command, timeout, TTL and payload) run once per render and share the result.
    • runCustomCommand(request, prefetched?) returns the prefetched result when there is one. Otherwise it falls back to the existing synchronous helper path.
  • ccstatusline.ts: getTerminalWidth moves above the Promise.all, because the piped payload and the cache key depend on it. The prefetch is added next to the transcript, usage and service-status prefetches, and its results go on RenderContext.customCommandResults.
  • CustomCommand.tsx: a one-line change that passes context.customCommandResults to runCustomCommand. The request-building and maxWidth code is untouched.
  • Semantics:
    • A command that exits while a background child still holds stdout still returns its output at the deadline. This is the drain behaviour pinned by the existing background test.
    • One side effect under load: the helper's startup counted against the timeoutMs + 1000 backstop. On a loaded host the old path therefore sometimes rendered [Timeout] for a sleep 0.05 command, and the new path does not.

Suggestion (not in this PR)

customCommandCacheTtlSeconds still defaults to 0, so every repaint runs every command. A small default, like the 5 s used for gitCacheTtlSeconds, would remove most of the remaining cost. That is a UX decision, so it is left to you.

Measurements

Setup: bench2 round-robin fork/exec with CPU = user+sys including reaped children, node 24, all arms interleaved in one run. Config: model + N × sleep 0.05; echo xN custom commands at TTL 0, with a payload that has no transcript. ccbg = one normal command + (sleep 2 & ) ; echo early with timeout: 300. 20 passes. load1 during the run: min 51.2 / median 60.3 / max 69.8 on 6 cores. The host was heavily loaded, so absolute times are inflated; compare the ratios.

arm CPU med (ms) CPU p90 wall med (ms) wall p90
control node -e 0 101 148 874 1264
base, 1 cmd 1398 1566 10994 13311
patched, 1 cmd 1245 (-11%) 1377 9457 (-14%) 12241
base, 3 cmds 1812 2016 14500 17857
patched, 3 cmds 1329 (-27%) 1520 9666 (-33%) 13126
base, 5 cmds 2160 2303 17036 19073
patched, 5 cmds 1435 (-34%) 1608 10223 (-40%) 13425
base, bg-holder 1551 1720 12313 16544
patched, bg-holder 1297 (-16%) 1458 10893 (-12%) 13093
  • For 3 and 5 commands, the patched p90 is below the base median.
  • execve counts (strace): with 3 commands, 10 before (4 node, 3 sh, 3 sleep) and 7 after (1 node). With 5 commands, 11 after.
  • Output: stdout of the base and patched dist was hashed over repeated runs of each config. The patched output was the same on every run and matched base's majority output. Base sometimes showed [Timeout] under load, as noted above.

Checks

  • bun run lint (tsc + eslint): clean.
  • bun run build: OK.
  • bun test:
    • New tests:
      • The out-of-process capture suite now runs every mode through both the sync and async APIs under bun and node.
      • New prefetch cases: concurrency (3 × sleep 0.5 in under 1.4 s), dedupe of identical requests, and a background job holding stdout. For that last case the result comes at the deadline, the probe process does not stay alive afterwards (checked via a lingered exit metric), and the background job survives.
      • Widget tests check that a prefetched result is used without spawning, and that a miss falls back to the synchronous path.
    • Custom-command and widget unit tests: 50/50 pass.
    • Flakes, stated honestly: load1 was about 55-65 on 6 cores during these runs.
      • The timing-sensitive process tests (200-1000 ms deadlines that include a runtime cold start) fail often here, on both paths. Unmodified main failed 10 of its 24 existing process tests in the same conditions. On this branch the unchanged sync path failed 15/24, the new async path 11/24, and prefetch 2/6. Every failure was a [Timeout] instead of the expected result, or a timing bound missed.
      • A run with every deadline and sleep scaled up ×5-10 behaved the same on both paths. A manual check with 3-4 s deadlines showed the process tree killed on timeout, the background output returned, and no lingering process, on both paths under node and bun.
      • Also failing on this host, in areas this PR does not touch: the known fetchUsageData error handling 5 s timeouts and several TUI menu tests.
  • Overlap with our other PRs:

🤖 Generated with Claude Code

…ore the render

Each custom-command widget ran synchronously during the render, and each
run started a second node/bun runtime (spawnSync(process.execPath, ['-e',
capture])) that then spawned `sh -c command`. The helper exists so the
synchronous renderer can bound stdout, enforce the deadline, kill the
process group and not hang on a background descendant that keeps the stdout
pipe open (sirmalloc#539). With the default customCommandCacheTtlSeconds of 0, N
widgets meant N extra runtimes started one after another on every repaint.

Run every custom command in a prefetch phase instead, alongside the
transcript, usage and service-status prefetches, with async spawn in the
main process. The capture function is unchanged apart from handing its
result to a callback, so the in-process path and the helper share one
implementation of the deadline, the process-group SIGKILL, the output cap
and the destroy-the-pipe-on-delivery protection. A timeoutMs + 1000
backstop mirrors the spawnSync timeout. The widget passes the prefetched
results from the render context to runCustomCommand, which falls back
to the synchronous helper
path for anything the prefetch did not cover. Identical requests in one
render run once.

Measured with bench2 (20 interleaved passes, node, config of model + N x
`sleep 0.05; echo x`, host load1 51-70 on 6 cores), CPU/wall medians:
  1 command:  1398/10994 ms -> 1245/9457 ms  (-11% CPU, -14% wall)
  3 commands: 1812/14500 ms -> 1329/9666 ms  (-27% CPU, -33% wall)
  5 commands: 2160/17036 ms -> 1435/10223 ms (-34% CPU, -40% wall)
  1 command + 1 that backgrounds a child holding stdout (timeout 300 ms):
              1551/12313 ms -> 1297/10893 ms (-16% CPU, -12% wall)
execve count for 3 commands drops from 10 to 7 (no helper runtimes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
whycantfindaname pushed a commit to whycantfindaname/ccstatusline that referenced this pull request Oct 1, 2026
Merged from the jason/beta4-local-fixes Trellis build (compat-repair base):
bounded stdin reads in shared hooks (sirmalloc#590), research dispatch may write the
task research dir (sirmalloc#634), scoped archive commits (sirmalloc#622, sirmalloc#630), list filter
traversal (sirmalloc#631), remove-subtask link check (sirmalloc#632), hooks.local.json ignore
(sirmalloc#633).
elhoim and others added 2 commits October 7, 2026 22:24
The background job in the test writer now lives 3s (from main), so the
prefetch test's fixed 1.3s sleep ran out before the job wrote its file.
Poll for the file with waitForFile, as the capture test already does.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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