child_process: spawns take ~60 ms longer when the Node process was started via a shebang (macOS, v26.13.0 regression)

Aperta
#67,163 2 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
4/5
Tempo stimato
3-5 giorni
Idoneità per principianti
45/100
Tipo di issue
Bug
Chiarezza
Abbastanza chiara
Stato di attività
Attiva
Stack tecnologico
javascript, node.js

Direzione di ricerca

Start by running the supplied reproduction on v26.12.0 and v26.13.0, comparing shebang-launched and directly launched processes. Read the child_process and libuv process-spawning/child-exit-notification paths mentioned in the report, then investigate what changed in the v26.12.0–v26.13.0 range. Done when shebang-launched processes and their descendants no longer incur the reported per-spawn delay.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

Version

v26.13.0 (regressed; v26.12.0 and v26.5.0 are fine)

Platform
Darwin bitbox.local 25.4.0 Darwin Kernel Version 25.4.0: Thu Mar 19 19:33:25 PDT 2026; root:xnu-12377.101.15~1/RELEASE_ARM64_T6041 arm64 arm Darwin
Subsystem

child_process (libuv process spawning)

What steps will reproduce the bug?

[This writeup is by Claude so I can't 100% vouch for it but it seems right to me. At the very least the reproduction script is legit: it clearly shows the difference between v26.13.0 and v26.12.0 on my machine. I am not an expert at node internals or any macOS issues that might be causing this just for me but everything in this report seems to match what I observed as Claude helped me debug this problem.]

Node binaries are the official nodejs.org arm64 tarballs (installed with
mise, but the issue is independent of the installer; the node binaries'
code signatures are identical between the fast and slow versions, see
below).

# A script whose interpreter is node, spawning a trivial child 100 times.
cat > /tmp/sb <<'EOF'
#!/usr/bin/env node
const { execFile } = require("child_process");
const N = 100; let i = 0; const t = Date.now();
(function next() {
  if (i++ === N) { console.log(((Date.now() - t) / N).toFixed(1), "ms per spawn", process.version); return; }
  execFile("git", ["--version"], next);
})();
EOF
chmod +x /tmp/sb

/tmp/sb          # kernel starts the process via the shebang -> node
node /tmp/sb     # same script, node started directly
How often does it reproduce? Is there a required condition?

Every time. Results on the same machine, same minute (#!<abs path to node>
scripts so each version is the interpreter; git --version is the child):

node binary started via shebang started as node script
v26.5.0 3.3 ms per spawn 3.2 ms
v26.12.0 3.2 ms per spawn —
v26.13.0 55.5 ms per spawn 3.3 ms

Three consecutive rounds of the v26.13.0 case: 61.8 / 61.9 / 58.4 ms via
shebang, 3.4 / 3.4 / 3.3 ms started directly. /usr/bin/env node typed at
the shell (not through the kernel's #! handling) is also fast, so the
condition is specifically that the kernel launched the process as a script
interpreter.

The required condition is only how the root of the process tree was
launched — the slowness is inherited by descendants:

  • A #!/usr/bin/env node script that spawnSyncs a second node which
    runs the benchmark: the second node is slow too (61.7 ms).
  • A #!/bin/bash script that does exec node … (so the process that is
    now node was created via a shebang with bash as the interpreter) is slow
    as well, and so are the node children it spawns. The same bash script
    run as bash script is fast.
  • Started directly, node spawning node spawning children: fast (3.5 ms).

Things that make no difference (each tested): environment variables
(replaying the full environment of the slow case into a directly-started
node is fast); PATH length (35 entries vs 2 — the failed posix_spawn PATH
probes are not the cost); which git (Apple's /usr/bin/git stub,
Homebrew's, by absolute path); nice/priority (normal in both); fsync in
the child (a child that does no I/O, git --version, shows it).

What is the expected behavior? Why is that the expected behavior?

A spawn should cost the same few milliseconds whether or not the node
process was started by the kernel as a script interpreter, as it does in
v26.12.0 and earlier.

What do you see instead?

~55–90 ms per child_process spawn, every spawn, for the life of the
process and all its node descendants.

Where the time goes: the child itself is not slow and process creation is
not slow — the parent is late noticing the exit. Instrumented in a real
workload (an Express app's git store, ~850 execFile("git", …) calls per
test run), with timestamps at execFile(), the child's spawn event, and
the completion callback, plus GIT_TRACE_PERFORMANCE=1 for git's own
clock:

directly started tree shebang-started tree
execFile → spawn event 0.6–1.0 ms 0.8–1.0 ms
git's own elapsed (GIT_TRACE_PERFORMANCE) ~2.5 ms ~2.5 ms
execFile → callback 6.8 ms p50 / 8.0 ms p90 92.7 ms p50 / 160 ms p90

So ~85 ms elapses between the child exiting and the parent's callback.
The per-command p50s are identical for every git subcommand (config,
rev-parse, hash-object, …), consistent with a fixed per-spawn delay
rather than work.

Additional information

Real-world impact. Almost everything that launches node in a dev
workflow does it through a shebang: npm itself (bin/npm →
npm-cli.js, #!/usr/bin/env node; version managers' bash wrappers are
shebang scripts too), npx, and every node_modules/.bin/* shim. So
npm test on v26.13.0 on macOS runs any suite that spawns processes with
this tax on every spawn, while node --test … typed directly does not.
Our gate's git-heavy tests went from ~300 ms to 5–9 s per test (10–50×),
and two tests with real-world deadlines (a 1 s push timeout; "a Save
returns within 10 s") failed. It took two days to find because nothing
run "by hand" reproduces it.

Not a code-signing / Gatekeeper difference. codesign -dv --verbose=2
on the two binaries: both flags=0x10000(runtime),
TeamIdentifier=HX7739G8FX, Runtime Version=15.5.0, same entitlements.
macOS's syspolicyd does log a violates validation category policy
line for the short-lived children that posix_spawn PATH probing creates,
but that happens equally in the fast and slow cases and the PATH-length
test shows it is not the cost.

Suspicion. Something in the process-spawning or child-exit-notification
path changed between v26.12.0 and v26.13.0 (libuv upgrade?) and behaves
differently when the process's original exec was the kernel's interpreter
path (imgact_shell) — e.g. a check that looks at the process's
executable path / argv[0] / P_* flags, or a kqueue/EVFILT_PROC vs
SIGCHLD difference where the shebang-launched process has a different
state. The bisect range is exactly v26.12.0 → v26.13.0; happy to test
nightlies or a patch.

Workarounds we use. Pin macOS hosts to v26.12.0; launch scripts with
an explicit interpreter (bash script, node script, node …/npm/bin/npm-cli.js run …) so no process in the tree is shebang-started.

Lingua principale
JavaScript
Stelle
123k
Fork
39.3k
Merge medio
4g 11h
PR unite (30g)
237

Preparare l'ambiente

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di nodejs/node

Tutte le issue di nodejs/node

Issue simili

Altre issue su JavaScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.