Skip to content

fix(cli): interrupt running commands cleanly on Ctrl+C#1370

Open
Jeppe Fredsgaard Blaabjerg (jfblaa) wants to merge 2 commits into
v1.xfrom
jfblaa/cli-interrupt-clean-exit
Open

fix(cli): interrupt running commands cleanly on Ctrl+C#1370
Jeppe Fredsgaard Blaabjerg (jfblaa) wants to merge 2 commits into
v1.xfrom
jfblaa/cli-interrupt-clean-exit

Conversation

@jfblaa

@jfblaa Jeppe Fredsgaard Blaabjerg (jfblaa) commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

What

The launcher (bin/cli.js) spawns the real CLI as a child process with inherited stdio. Previously the launcher was torn down by SIGINT before that child finished, so on Ctrl+C the child's final status could print after the shell prompt had already returned — and the child could be left running in the background.

This makes the launcher wait for the child to handle the interrupt itself and then mirror its exit, so output stays ordered.

How

  • On the first SIGINT/SIGTERM, stay alive and let the child (which shares our process group and receives the signal directly) run its own teardown; exit by mirroring the child's real exit.
  • The wait is bounded: if the child outlives a short grace period (SHUTDOWN_GRACE_MS, 3s), or on a second Ctrl+C, SIGKILL it and exit with the conventional 128 + signum code.
  • The grace timer is unref'd, so a child that exits on its own is mirrored with no added latency — the cap only applies to a slow or wedged child.

Notes

  • Applies to all commands, not any one in particular.
  • Independent change; no dependency on other repos.
  • No version bump / changelog entry — internal interrupt-handling plumbing, left for the next release to fold in.

Note

Low Risk
Launcher-only process/signal plumbing in bin/cli.js; no auth, data, or business-logic changes, with bounded fallback via SIGKILL.

Overview
The CLI launcher (bin/cli.js) now coordinates shutdown when you hit Ctrl+C or send SIGTERM, instead of exiting before the spawned CLI child finishes.

On the first signal it stays alive so the child (same process group, inherited stdio) can tear down and print final output in order; the parent then mirrors the child’s exit. If the child doesn’t exit within 3s, or you signal again, it SIGKILLs the child and exits with 130 (SIGINT) or 143 (SIGTERM). The grace timer is unref’d so a normal fast exit isn’t delayed.

When the child exits on a signal, the launcher removes its own handlers before re-raising so the parent actually terminates instead of swallowing the signal.

Reviewed by Cursor Bugbot for commit 010d858. Configure here.

The launcher (bin/cli.js) spawns the real CLI as a child with inherited
stdio. Previously it was torn down by SIGINT before that child finished,
so the child's final status printed after the shell prompt had already
returned (and the child could be left running in the background).

Wait briefly for the child to handle the interrupt itself and mirror its
exit, so output stays ordered. The wait is bounded: if the child outlives
a short grace period, or on a second Ctrl+C, SIGKILL it and exit with the
conventional 128+signum code. The grace timer is unref'd, so a child that
exits on its own is mirrored with no added latency.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is ON. A cloud agent has been kicked off to fix the reported issue.

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 010d858. Configure here.

Comment thread bin/cli.js Outdated
// Mirror a signal death. Drop our own handlers first so the re-raise actually
// terminates us instead of being swallowed by onSigint/onSigterm above.
process.removeListener('SIGINT', onSigint)
process.removeListener('SIGTERM', onSigterm)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Wrong parent exit after signal kill

Medium Severity

When the child dies with a signal, the launcher re-raises it via process.kill instead of calling process.exit with 128 + signum. Because the new handlers keep the parent alive through the first interrupt, await spawnPromise can finish while process.exitCode is still 1, so the shell may report exit code 1 rather than 130/143 after Ctrl+C.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 010d858. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — fixed in 65534b2.

The on('exit') signal branch no longer re-raises via process.kill(process.pid, signalName). As you noted, with our SIGINT/SIGTERM handlers installed the re-raise raced await spawnPromise resolving and could let the process fall through to its default process.exitCode = 1, so Ctrl+C could report 1 instead of 130/143.

It now exits explicitly and deterministically with the conventional 128 + signum code (resolved via os.constants.signals[signalName]), matching the grace/second-signal path. Verified: a child that dies from SIGINT now yields parent exit 130 (previously 1).

Addresses review feedback: re-raising the child's signal on the launcher
raced against `await spawnPromise` resolving and could leave the default
`process.exitCode` of 1, so Ctrl+C could report exit 1 instead of 130.
Exit explicitly with the conventional 128+signum code (resolved from
os.constants.signals) in the on('exit') signal branch instead.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant