Skip to content

fix: deterministic lockfile and process reaping for lifecycle scripts#9790

Open
AmariahAK wants to merge 1 commit into
npm:latestfrom
AmariahAK:latest
Open

fix: deterministic lockfile and process reaping for lifecycle scripts#9790
AmariahAK wants to merge 1 commit into
npm:latestfrom
AmariahAK:latest

Conversation

@AmariahAK

Copy link
Copy Markdown

Description

What

Two distinct bugs in the npm lifecycle that cause non-deterministic installs and ghost processes:

  1. Silent lockfile mutations: package-lock.json hash changes between identical npm install runs with no changes to package.json, because peerOptional dependency edges are unconditionally re-resolved from the registry.
  2. Ghost processes (inadequate reaping): After npm run start and Ctrl+C, lingering node/webpack processes hold ports (EADDRINUSE). The signal manager only kills direct children — grandchildren survive. Install-time lifecycle scripts running with stdio: 'pipe' are never tracked by the signal manager, so interrupting npm install during the build phase also orphans subprocesses.

Where

Lockfile integrityworkspaces/arborist/lib/arborist/build-ideal-tree.js in three locations:

Location Line Old gate
#initTree() — virtual tree edge validation ~489 this.options.save === false
#problemEdges() — edge problem detection ~1400 this.options.save !== false
PlaceDep visitor — node re-queuing ~1182 this.options.save || (...)

Process reapingnode_modules/@npmcli/run-script/lib/ in three files:

File Line Problem
signal-manager.js 13 proc.kill(signal) only kills direct child
run-script-pkg.js 77 signalManager.add() gated on stdio === 'inherit'
make-spawn-args.js 41 No detached option in spawn

How

Lockfile integrity: Replaced this.options.save with this.#requestedTreeMutation in all three locations. #requestedTreeMutation is true only when the user explicitly asks for changes (--add, --rm, --update, npm audit fix). For a bare npm install with no arguments, it's false, and the code trusts the lockfile rather than re-resolving peerOptional edges.

Process reaping:

  • signal-manager.js: Changed handleSignal to call process.kill(-proc.pid, signal) which targets the entire process group, with try/catch fallback to proc.kill(signal) for platforms or edge cases where process-group signalling is unsupported.
  • make-spawn-args.js: Added detached: true to spawn options when stdio === 'inherit', so the child becomes its own process-group leader (required for -pid to work).
  • run-script-pkg.js: Removed the stdio === 'inherit' gate on signalManager.add() so that all subprocesses — including install-time lifecycle scripts running with stdio: 'pipe' — are tracked and terminated when signals arrive.

Why this method over others

Lockfile: #requestedTreeMutation already existed in the codebase and was wired to --add, --rm, and --update. It precisely captures "did the user ask for changes?" — which is what should gate re-resolution — versus save which captures "should the lockfile be written?". The fix is a one-term removal from three conditions, reusing existing infrastructure with no new state.

Process reaping: The detached + -pid pattern is the standard Unix mechanism for tree-kill. detached: true creates a new process group with the child as leader, and process.kill(-pid, signal) atomically terminates every process in that group — the shell, webpack-dev-server, and all its workers. The try/catch fallback preserves existing behavior when no process group exists (e.g. on Windows). Alternatives considered: tree-kill libraries (adds a dependency), taskkill /T (Windows-only), iterating /proc (non-portable, racy).

How to test

Lockfile stability:

rm -rf node_modules package-lock.json
npm ci
cp package-lock.json package-lock.bak
npm install                              # no changes to package.json
diff package-lock.json package-lock.bak  # should be empty

Process reaping:

npm run start &
sleep 3
kill -INT %1                             # Ctrl+C
ps aux | grep -E "webpack|node"          # no lingering processes
npm run start                            # no EADDRINUSE

Idempotent install:

npm install && sha256sum package-lock.json > hash1
npm install && sha256sum package-lock.json > hash2
diff hash1 hash2                         # should be empty

References

Fixes: #9776

Lockfile integrity (arborist): Replaced this.options.save with
this.#requestedTreeMutation in three locations that gated peerOptional
edge re-resolution. Previously, npm install (save !== false) would
unconditionally treat invalid peerOptional edges as problems and
re-resolve them from the registry, mutating package-lock.json even when
no changes were made to package.json. Now only explicit tree mutations
(--add, --rm, --update, npm audit fix) trigger re-resolution.

Process reaping (@npmcli/run-script): Changed signal-manager.js to use
process.kill(-pid, signal) to kill the entire process group rooted at
the child, rather than proc.kill(signal) which only killed the direct
child and left grandchildren (e.g. webpack-dev-server workers) as ghost
processes holding ports. Added detached: true to spawn options when
stdio === 'inherit' so the child becomes a process-group leader.
Removed the stdio === 'inherit' gate on signalManager.add() so that
install-time lifecycle scripts (stdio: 'pipe') are also tracked and
terminated when signals interrupt the build phase.

Fixes: npm#9776

Co-authored-by: atlarix-agent <agent@atlarix.dev>
@AmariahAK
AmariahAK requested review from a team as code owners July 22, 2026 14:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant