Repository navigation
Description
Thanks for releasing this package! It's been really useful. I use ccusage statusline to show context and token usage in my Claude Code status line while agents are running, mainly for the 1M-context detection.
This week I noticed my UI getting very laggy whenever I had 10+ agents going. I traced it to orphaned ccusage processes piling up: 119 of them, nearly all spinning at full CPU. It looks like the cause is still in the latest release, so here's what I found.
Summary
The ccusage npm bin is a Node launcher that spawns the platform native binary with spawn(command, args, { stdio: 'inherit' }) and registers only error and exit handlers. No termination signal is forwarded to the child, and nothing kills the child if the launcher dies. Killing ccusage therefore leaves the native binary running, reparented to PID 1, until its scan finishes.
This hits ccusage statusline hardest, because Claude Code's status line contract cancels in-flight invocations by design:
Claude Code debounces updates at 300ms, so rapid changes batch together and your script runs once after the changes stop. If a new update triggers while your script is still running, Claude Code cancels the in-flight script.
So every cancelled render can leak one native process that keeps running at full CPU.
Reproduction
macOS 14.6 (Darwin 23.6.0), arm64, Node v25.8.2, ccusage 20.0.6:
$ echo '{"session_id":"t","cwd":"'$HOME'","workspace":{"current_dir":"'$HOME'"}}' | ccusage statusline &
$ WRAPPER=$!; sleep 2; CHILD=$(pgrep -P $WRAPPER | head -1)
$ kill -9 $WRAPPER; sleep 2; ps -o pid=,ppid=,pcpu= -p $CHILD
17778 1 802.2The launcher is gone; the native binary survives, is reparented to PID 1, and is consuming 802% CPU.
Impact in normal use
Running ~10 concurrent Claude Code sessions, each with ccusage statusline in its status line:
- 119
ccusageprocesses alive, 111 of them orphaned at PPID 1 - 742% combined CPU on a 10-core M-series, 0% idle
- Load average 358 (1m) / 824 (15m); the whole desktop became unusable
It is self-amplifying: ~/.claude/projects here is 775 MB across 1053 JSONL files, so a single statusline invocation runs for several seconds at ~200% CPU. Orphans accumulate faster than they retire, and each new render adds one. (Adjacent to #1245: caching would cut the scan cost, though the orphaning is what turns cost into saturation.)
I should be clear that the multi-session setup is on the heavy end, and I don't think this would be visible at all with one or two sessions and a smaller history. The mechanism seems worth fixing regardless, since it's silent when it happens.
Root cause
apps/ccusage/src/cli.js, createNativeSpawner(), lines 162-180 of the published 20.0.19 source:
function createNativeSpawner() {
return async (command, args) =>
new Promise((resolve) => {
const child = spawn(command, args, { stdio: 'inherit' });
child.on('error', (error) => { resolve({ error, signal: null, status: null }); });
child.on('exit', (status, signal) => { resolve({ signal, status }); });
});
}There is no process.on('SIGTERM' | 'SIGINT' | 'SIGHUP') forwarding and no cleanup path for the child. The process.kill(process.pid, result.signal) on line 208 runs only after the child has already exited, re-raising the signal on the launcher, which is the opposite direction.
I checked the published 20.0.19 tarball as well, so this looks present in the latest release and not only in the 20.0.6 I hit it on. Apologies if I've misread the intent of that code; it's possible the wrapper is meant to be short-lived in a way I'm not seeing.
Possible directions
Listed roughly in order of how much they change; you'll have a much better sense than I do of which fits the project:
- Forward termination signals in the launcher (
SIGINT,SIGTERM,SIGHUP,SIGQUIT) to the child, removing the listeners once it exits. Smallest change, and it covers the common cancellation path. SIGKILLcan't be caught, so the native side could also detect orphaning: exit whengetppid() == 1, or watch for parent exit directly (kqueue/NOTE_EXITon macOS,PR_SET_PDEATHSIGon Linux). More involved, but it's what fully closes the hole.- Remove the launcher process entirely by pointing
binat the platform package's own binary. Then there's no wrapper to kill and no child to orphan, and signal semantics are just the OS default. Biggest change to how the package is published, and I may well be missing a reason the wrapper is needed.
I'm happy to open a PR for (1), or (2) if you'd prefer to go straight at the root, whichever you'd rather have. And glad to run any diagnostics on my setup if the numbers above would be more useful with more detail. Thanks again for maintaining this.
Activity
pullfrog commentedon Aug 12, 2026
Assessment
This issue is actionable and the report is consistent with the current source.
apps/ccusage/src/cli.js:162-180starts the native binary withspawn(..., { stdio: 'inherit' })but registers no parent signal handlers or child cleanup.runClionly re-sends the child’s signal after the child has already exited (apps/ccusage/src/cli.js:202-210), so a signal delivered to the Node launcher does not reach the native process. The npmbinentry confirms that every publishedccusageinvocation passes through this launcher. The statusline cancellation scenario makes this a credible resource leak rather than an unusual manual process-management case.The issue is currently closed by the automatic new-contributor workflow, but it is worth reopening for implementation. The
SIGKILLreproduction is also important: JavaScript cannot handleSIGKILL, so signal forwarding alone fixes normal catchable cancellation but cannot guarantee cleanup after an uncatchable launcher kill.Proposed Plan
createNativeSpawner()to forward the relevant catchable termination signals from the Node launcher to the native child, covering the statusline cancellation path and interactive termination. Remove all installed listeners when the child exits or emits an error, and preserve the existing exit status and signal propagation without recursively handling the same signal.SIGKILLand other parent-death cases. If the project requires the exact reproduction to be impossible, add parent-death handling in the native binary with platform-specific implementations or another packaging design that eliminates the long-lived wrapper. Do not assumegetppid() == 1globally, because the native binary can also be invoked directly and should not exit merely because its invoking shell ended.apps/ccusage/src/cli.test.tswith deterministic tests using a mocked child process and signal source, verifying each supported forwarded signal, listener cleanup on bothexitanderror, and preservation of child signal/status results. Add a subprocess-level regression test where practical to verify that a terminated launcher does not leave the child running for the normal cancellation path.just test-node,just typecheck, and the package build/publint checks. Exercise the statusline command through the supported npm launcher on at least one Unix platform, and explicitly document the remaining limitation if hard-kill protection is not included in the first fix.No user-facing option or command changes are needed, so documentation changes should be limited to any explicit cancellation guarantee the maintainers choose to document in the statusline guide.
Task list (3/3 completed)
@v0or keep the SHA fresh with Dependabot | Implement plan ➔ | View workflow run | via Pullfrog | UsingGPT Luna(free via Pullfrog for OSS) | 𝕏github-actions commentedon Aug 12, 2026
This issue was auto-closed. Issues from new contributors are auto-closed by default.
Maintainers review auto-closed issues and reopen worthwhile ones. Issues that do not meet the quality bar in CONTRIBUTING.md may not be reopened or receive a reply.
Keep the issue short, concrete, and written in your own voice.
If a maintainer replies
lgtmi, your future issues will stay open. If a maintainer replieslgtm, your future issues and PRs will stay open.See CONTRIBUTING.md.
ryoppippi commentedon Aug 27, 2026
@pullfrog Triage this issue using the repository standing issue-triage instructions. Inspect the full issue body, comments, current main branch, and related issues or pull requests. Choose exactly one priority label. If the issue is clearly unresolved and is priority:critical or priority:high with safe and well-scoped requirements, implement it and open a focused pull request. Otherwise, only triage it. Keep the explanation short and use simple English.
pullfrog commentedon Aug 27, 2026
Priority: high. The current launcher still leaves the native child running when statusline cancels it, and related reports show serious CPU and process growth. I will fix forwarding for normal termination signals; SIGKILL needs separate native or packaging work.
@v0or keep the SHA fresh with Dependabot | View workflow run | via Pullfrog | UsingGPT Luna(free via Pullfrog for OSS) | 𝕏github-actions commentedon Aug 28, 2026
Pullfrog triage: priority:high
A maintainer explicitly requested an implementation attempt. The report is valid, repository-scoped, and high impact: the current launcher does not forward termination signals to its native child, so statusline cancellation can leak CPU-heavy processes. The preferred fix remains unresolved because signal forwarding does not cover SIGKILL and native parent-death handling or packaging changes need maintainer direction.
Decision: kept open; Pullfrog will attempt a focused implementation PR.
ryoppippi commentedon Aug 31, 2026
Closing as not planned for this issue after #1667 fixed the normal catchable-signal path. The remaining SIGKILL/parent-death case cannot be handled by the Node launcher and needs a separate platform-specific native or packaging design for macOS, Linux, and Windows. A portable
getppid() == 1workaround would incorrectly terminate direct invocations. Please open a focused follow-up if the project adopts a native parent-death policy.ryoppippi commentedon Aug 31, 2026
Historical audit: this discussion was auto-closed by the legacy contributor gate. That closure did not assess technical importance.
Audit result: excluded from the current maintenance scope. This request depends on an unsupported external source, unavailable schema or fixture, platform or release infrastructure the project cannot reliably maintain, an operational service, or an unsafe or underspecified design. It is not a candidate for automatic reopening or implementation.