Skip to content

fix(signal): spawn lifecycle scripts detached to stop a duplicate SIGINT - #60

Open
itsybitsybootsy wants to merge 1 commit into
pnpm:mainfrom
itsybitsybootsy:fix-sigint-double-forward
Open

itsybitsybootsy wants to merge 1 commit into
pnpm:mainfrom
itsybitsybootsy:fix-sigint-double-forward

Conversation

@itsybitsybootsy

@itsybitsybootsy itsybitsybootsy commented Jul 17, 2026

Copy link
Copy Markdown

Fixes: pnpm/pnpm#7374

What's the problem

pnpm run <script> can kill the script before it finishes handling Ctrl-C. A terminal's SIGINT is delivered to the whole foreground process group at once, so the script (spawned in our process group) already gets it directly. procInterrupt() then forwards another SIGINT on top of that. A script using process.once('SIGINT', cleanup) (a very common pattern) has its handler consumed by the first signal and starts async cleanup; the second, redundant signal arrives with no handler left and Node kills it immediately, before cleanup finishes. This is what people are reporting as Kubernetes pods and CI jobs dying without running their shutdown code.

The fix

Spawn the script detached (POSIX only, process.platform !== 'win32'). This makes it a new process group and session leader, so a terminal's broadcast no longer reaches it directly, and procInterrupt()'s forward becomes the only way it ever learns about SIGINT. No more guessing whether to forward.

I tried a smaller fix first (skip the forward when process.stdin/stdout/stderr looked like a TTY) but it's wrong in both directions: an interactive session with a targeted kill -INT <pid> (an IDE's stop button does this) skips the forward and the script hangs forever, and a non-interactive wrapper that broadcasts to its own process group still gets the original bug. There's no way to tell from inside a bare SIGINT handler how a given signal was actually delivered, so the detached approach (removing the ambiguity instead of guessing at it) is the one I ended up with.

Once the script is its own process group leader, procInterrupt()/procKill() forward to that whole group (process.kill(-proc.pid, sig)) instead of just its pid. Caught this in my own testing: a compound command like sleep 5 && echo done runs sleep as its own process still in sh's group, and a pid-only forward never reaches it, so the chain ran to completion instead of stopping. This wasn't a problem before this PR, because the terminal's own broadcast used to reach sleep directly too. lib/spawn.js didn't expose the child's pid at all, so I added cooked.pid = raw.pid there.

Trade-offs, both real

  • A script killed by an external signal sent to its whole process group (some CI/container supervisors do this) no longer dies along with it, since it's no longer in that group. It's still fully killable through pnpm itself (Ctrl-C, SIGTERM to pnpm, the second-Ctrl-C force-kill), just not by something that bypasses pnpm and signals the group directly.
  • A script with no controlling terminal can't open('/dev/tty'). Some tools do that for a secure prompt even with stdin piped (sudo -S, an ssh passphrase prompt, gpg's pinentry-curses, git's askpass fallback). That will now fail with ENXIO where it worked before.

How I verified it

Added test/signal-group.js, three tests: a signal delivered to our own process group doesn't also reach the script directly anymore (a driver spawned detached: true so a real process.kill(0, 'SIGINT') doesn't touch the test runner's own process tree), a signal targeted only at our pid still gets forwarded, and a compound && command is actually interrupted rather than running to completion. All three fail on the current code and pass with the fix.

I was worried the detach would break scripts that read stdin interactively (SIGTTIN stopping a background process group reading the controlling terminal), so I checked it directly rather than assuming: a pty.fork()-based harness on both macOS and a real ubuntu-latest GitHub Actions run shows a detached child reading from an inherited pty completes normally, no stop. Node's detached also calls setsid(), and SIGTTIN only applies to a process's own controlling-terminal relationship, which a fresh session doesn't have, so reads on the inherited fd just work like a normal file descriptor.

node --test test/*.js: 9/9 pass, no new failures. npx standard: clean.

Summary by CodeRabbit

  • Bug Fixes

    • Improved interrupt handling for lifecycle scripts, including commands that launch multiple child processes.
    • Ensured graceful cleanup completes when stopping scripts with Ctrl-C.
    • Prevented interrupted, chained commands from continuing until their normal completion.
  • New Features

    • Exposed the running process ID through the command execution interface.

A terminal's Ctrl-C delivers SIGINT to the whole foreground process
group at once, so a lifecycle script (spawned in our process group)
already receives it directly. procInterrupt() then forwards another
SIGINT on top of that. A script whose own handler only runs once (the
common process.once('SIGINT', cleanup) pattern) gets killed by the
redundant second signal before it can finish a graceful shutdown.

Spawn the script detached instead. On POSIX this makes it a new
process group and session leader, so the terminal's broadcast no
longer reaches it directly and procInterrupt()'s forward becomes the
only way it's delivered.

Once the script is its own process group leader, forward the signal
to that whole group instead of just its pid. A compound command like
`sleep 5 && echo done` runs sleep as its own process, and forwarding
to only the `sh` pid never reaches it, so the chain ran to completion
instead of stopping. This wasn't a problem before, because the
terminal's own broadcast used to reach sleep directly too.

This has two costs. A script killed by an external process-group-wide
signal (rather than through pnpm) no longer dies incidentally with
it, since it is no longer in that group. And a script with no
controlling terminal can't open /dev/tty, which some tools use for a
secure prompt (sudo -S, ssh, gpg, git's askpass fallback) even with
stdin piped.

Fixes: pnpm/pnpm#7374
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5a8bcd5f-d4ca-4f30-b05a-024f0183765c

📥 Commits

Reviewing files that changed from the base of the PR and between 9e2ac78 and 5e679e4.

📒 Files selected for processing (7)
  • index.js
  • lib/spawn.js
  • test/fixtures/compound-sigint/package.json
  • test/fixtures/graceful-sigint/child.js
  • test/fixtures/graceful-sigint/drive-group-signal.mjs
  • test/fixtures/graceful-sigint/package.json
  • test/signal-group.js
📜 Recent review details
🧰 Additional context used
🪛 ast-grep (0.44.1)
lib/spawn.js

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawn as _spawn } from 'child_process'
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process)

test/signal-group.js

[warning] 63-63: Avoid require with non-literal values
Context: require(path.join(fixture, 'package.json'))
Note: [CWE-829] Inclusion of Functionality from Untrusted Control Sphere (dynamic require).

(detect-non-literal-require)


[warning] 89-89: Avoid require with non-literal values
Context: require(path.join(fixture, 'package.json'))
Note: [CWE-829] Inclusion of Functionality from Untrusted Control Sphere (dynamic require).

(detect-non-literal-require)


[warning] 3-3: Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawnSync } from 'child_process'
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process)

🔇 Additional comments (8)
index.js (2)

233-237: LGTM!


338-358: LGTM!

lib/spawn.js (1)

55-55: LGTM!

test/fixtures/graceful-sigint/child.js (1)

1-9: LGTM!

test/fixtures/graceful-sigint/package.json (1)

1-7: LGTM!

test/fixtures/graceful-sigint/drive-group-signal.mjs (1)

1-51: LGTM!

test/fixtures/compound-sigint/package.json (1)

1-7: LGTM!

test/signal-group.js (1)

1-101: LGTM!


📝 Walkthrough

Walkthrough

Lifecycle scripts now use detached process groups on non-Windows platforms. Signal handling targets the group when possible, while spawned-process PIDs are exposed and new fixtures test graceful cleanup and compound-command interruption.

Changes

Lifecycle signal handling

Layer / File(s) Summary
Detached process-group signal routing
index.js, lib/spawn.js
Lifecycle child processes are detached on non-Windows platforms, the spawned PID is exposed, and termination signals target the process group with fallback to the child process.
Signal propagation fixtures and tests
test/fixtures/graceful-sigint/*, test/fixtures/compound-sigint/package.json, test/signal-group.js
Fixtures and tests cover graceful group-delivered SIGINT, PID-targeted forwarding, and interruption of chained shell commands.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Lifecycle
  participant SpawnedShell
  participant ProcessGroup
  participant ScriptChild
  Lifecycle->>SpawnedShell: Start detached lifecycle script
  SpawnedShell->>ProcessGroup: Create process group
  Lifecycle->>ProcessGroup: Send SIGINT
  ProcessGroup->>ScriptChild: Deliver signal
  ScriptChild-->>Lifecycle: Complete cleanup and exit
Loading

Poem

I’m a rabbit with signals to chase,
Through a process-group burrowed space.
Ctrl-C rings clear,
Cleanup hops near,
And chained commands slow their pace.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: detaching lifecycle scripts to prevent duplicate SIGINT delivery.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

'pnpm run' kills child processes prematurely upon SIGINT

1 participant