Skip to content

statusline: killing the CLI orphans the native binary (PPID 1), which keeps running at full CPU #1596

Closed as not planned

Description

@devinsays

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.2

The 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 ccusage processes 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:

  1. 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.
  2. SIGKILL can't be caught, so the native side could also detect orphaning: exit when getppid() == 1, or watch for parent exit directly (kqueue/NOTE_EXIT on macOS, PR_SET_PDEATHSIG on Linux). More involved, but it's what fully closes the hole.
  3. Remove the launcher process entirely by pointing bin at 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

  1. pullfrog commented on Aug 12, 2026

    @pullfrog
    Contributor

    Assessment

    This issue is actionable and the report is consistent with the current source. apps/ccusage/src/cli.js:162-180 starts the native binary with spawn(..., { stdio: 'inherit' }) but registers no parent signal handlers or child cleanup. runCli only 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 npm bin entry confirms that every published ccusage invocation 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 SIGKILL reproduction is also important: JavaScript cannot handle SIGKILL, so signal forwarding alone fixes normal catchable cancellation but cannot guarantee cleanup after an uncatchable launcher kill.

    Proposed Plan

    1. Fix normal launcher cancellation. Update 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.
    2. Define the hard-kill boundary. Add a focused design check for SIGKILL and 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 assume getppid() == 1 globally, because the native binary can also be invoked directly and should not exit merely because its invoking shell ended.
    3. Add launcher lifecycle tests. Extend apps/ccusage/src/cli.test.ts with deterministic tests using a mocked child process and signal source, verifying each supported forwarded signal, listener cleanup on both exit and error, 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.
    4. Validate packaging and behavior. Run 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)
    • Inspect issue details, comments, learnings, and applicable repository guidance
    • Trace statusline launcher and native process lifecycle code
    • Assess actionability and write an implementation plan or analysis comment

    Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Implement plan ➔ | View workflow run | via Pullfrog | Using GPT Luna (free via Pullfrog for OSS) | 𝕏

  2. github-actions commented on Aug 12, 2026

    @github-actions
    Contributor

    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 replies lgtm, your future issues and PRs will stay open.

    See CONTRIBUTING.md.

  3. added
    bugSomething isn't working
    majorMerged PR requires a major version bump
    on Aug 12, 2026
  4. ryoppippi commented on Aug 27, 2026

    @ryoppippi
    Member

    @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.

  5. pullfrog commented on Aug 27, 2026

    @pullfrog
    Contributor

    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.

    Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | View workflow run | via Pullfrog | Using GPT Luna (free via Pullfrog for OSS) | 𝕏

  6. github-actions commented on Aug 28, 2026

    @github-actions
    Contributor

    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.

  7. ryoppippi commented on Aug 31, 2026

    @ryoppippi
    Member

    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() == 1 workaround would incorrectly terminate direct invocations. Please open a focused follow-up if the project adopts a native parent-death policy.

  8. ryoppippi commented on Aug 31, 2026

    @ryoppippi
    Member

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingmajorMerged PR requires a major version bumppriority:highHigh user impact or an important regression.triage:excludedExcluded from the current maintenance scope.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions