Skip to content

child_process: build the default env block in one native pass - #65325

Open
codebytere wants to merge 1 commit into
nodejs:mainfrom
codebytere:perf/child_process-default-env-pairs
Open

child_process: build the default env block in one native pass#65325
codebytere wants to merge 1 commit into
nodejs:mainfrom
codebytere:perf/child_process-default-env-pairs

Conversation

@codebytere

@codebytere codebytere commented Aug 16, 2026

Copy link
Copy Markdown
Member

spawn(), spawnSync() and everything built on them get ~20–24 % faster when no options.env is passed (the common
case), by not going through the process.env proxy once per variable to build the child's environment block.

child_process/child-process-params.js params=1 methodName='spawn'          ***    19.57 %  ±1.73%
child_process/child-process-params.js params=1 methodName='spawnSync'      ***    24.37 %  ±0.74%
child_process/child-process-params.js params=1 methodName='execFile'       ***    20.07 %  ±1.86%
child_process/child-process-params.js params=1 methodName='execFileSync'   ***    23.99 %  ±0.67%
child_process/child-process-params.js params=1 methodName='exec'           ***    17.41 %  ±1.74%
child_process/child-process-params.js params=1 methodName='execSync'       ***    24.03 %  ±0.61%
child_process/spawn-echo.js                                                ***    21.41 %  ±0.61%

(Linux x64, 183-variable environment - the effect scales with environment size; 30 runs.)

normalizeSpawnArguments() did { ...process.env } and walked the copy to build the KEY=value array for uv_spawn().
Spreading the proxy hits a query and a getter interceptor per variable, each a linear scan of the environment under the env
mutex plus an allocation - O(n²), and the largest JS-side cost of a spawn (~190 µs of 1.7 ms here).

This adds KVStore::Pairs() (RealEnvStore: one uv_os_environ() pass, skipping hidden =X: entries on Windows like
Enumerate() does; default: Enumerate() + Get()), exposes it as internalBinding('process_wrap').getEnvPairs(),
and uses it when no options.env is given and the permission model is off. Everything else keeps the existing path.

Notes:

  • Same variables and values, same mutex, no caching (a later spawn sees process.env changes). Windows keeps its
    case-insensitive de-duplication.
  • POSIX order is now environment-block order; before, integer-like names (a variable literally named 123) were hoisted to
    the front by object key ordering. That is the only observable difference for a normal environment.
  • Enumerable properties added to Object.prototype no longer leak into the default child environment. options.env
    objects keep their documented prototype-inclusive behavior.

Tests: test-child-process-default-env.js (new): the child sees exactly the parent's current environment (contents
and, on POSIX, order) via spawnSync, execFileSync and spawn, including variables added/deleted/emptied at runtime,
values with = and non-ASCII; a user env is passed through unmerged; a variable set after one spawn is seen by the next.


Disclosure: the code, test, measurements and this description were written by Claude Code, directed and reviewed by @codebytere.

@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. labels Aug 16, 2026
When spawn()/spawnSync() are called without options.env,
normalizeSpawnArguments() copied process.env with a spread and then
walked the copy to build the KEY=value array uv_spawn() takes.
Spreading the process.env proxy costs one enumerator callback plus a
query and a getter interceptor per variable, each doing a linear
getenv() scan and allocating; with a couple of hundred variables that
was the single largest JS-side cost of spawning a process.

Add KVStore::Pairs() (Enumerate() + Get() by default, one
uv_os_environ() pass for the real environment, skipping hidden
variables on Windows exactly like Enumerate() does), expose it as
process_wrap.getEnvPairs(), and use it for the default-environment
case. A user supplied options.env and the permission model case keep
the existing code. On Windows the same sort/first-wins-case-insensitive
filter is applied to the pairs. The variables copyProcessEnvToEnv()
propagates are part of the real environment by definition, and its
entries cannot contain null bytes, so those steps only remain on the
options.env path.

Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
@codebytere
codebytere force-pushed the perf/child_process-default-env-pairs branch from 40fcf9b to a53bee9 Compare August 16, 2026 15:51
@codebytere codebytere added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 16, 2026
@codecov

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.37931% with 27 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.09%. Comparing base (30bff4a) to head (a53bee9).
⚠️ Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
lib/child_process.js 80.00% 18 Missing and 1 partial ⚠️
src/node_env_var.cc 82.92% 1 Missing and 6 partials ⚠️
src/process_wrap.cc 88.88% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #65325      +/-   ##
==========================================
- Coverage   90.13%   90.09%   -0.04%     
==========================================
  Files         752      752              
  Lines      251568   251664      +96     
  Branches    47270    47282      +12     
==========================================
- Hits       226759   226748      -11     
- Misses      16168    16241      +73     
- Partials     8641     8675      +34     
Files with missing lines Coverage Δ
src/util.h 90.98% <ø> (ø)
src/process_wrap.cc 75.55% <88.88%> (+0.55%) ⬆️
src/node_env_var.cc 81.31% <82.92%> (-0.90%) ⬇️
lib/child_process.js 93.99% <80.00%> (-0.13%) ⬇️

... and 40 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codebytere codebytere removed the needs-ci PRs that need a full CI run. label Aug 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs related to general changes in the lib or src directory. request-ci Add this label to start a Jenkins CI on a PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants