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

未关闭
#67,163 2 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

维护者通常 1 天内回复

还没有人认领这个 Issue。

评估

难度
4/5
预计耗时
3-5 天
新手友好度
45/100
Issue 类型
缺陷
描述清晰度
基本清楚
活跃度
活跃

调研方向

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.

由索引模型根据 Issue 内容生成。

描述

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.

主要语言
JavaScript
星标
123k
派生
39.3k
平均合并
4 天 11 小时
30 天内合并 PR
237

环境准备

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

nodejs/node 的其他 Issue

查看 nodejs/node 的全部 Issue

相似的 Issue

更多 JavaScript Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。