Skip to content

fix(core): restart node with the required V8 flags - #113

Open
not-matthias wants to merge 5 commits into
mainfrom
cod-4038-node-introspection-restart
Open

not-matthias wants to merge 5 commits into
mainfrom
cod-4038-node-introspection-restart

Conversation

@not-matthias

@not-matthias not-matthias commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Restart node with the required V8 flags when the launcher bypassed the runner's wrapper

The runner passes the flags from getV8Flags() through a node script on
PATH. Launchers that start node by absolute path never consult it: Bazel's
rules_js runs its toolchain binary through its own shim, so tinybench and
benchmark.js measured without --no-opt, --predictable, the seed flags and
the GC flags. NODE_OPTIONS cannot carry them (node rejects several).

ensureV8Flags() now replaces the process at plugin load, before any benchmark
or instrument-hooks state exists. It appends only the flags missing from
process.execArgv and reuses the current argv, so launcher arguments such as
rules_js's --require .../register.cjs and --preserve-symlinks-main survive.
On Linux it execs /proc/self/exe rather than process.execPath, which
rules_js points at its shell shim; on macOS it uses process.execPath.

The restart runs in a small execv binding in the existing native addon, which
keeps the PID, environment and open descriptors. That matters because the
runner, perf and eBPF attach by PID, and cancellation signals the benchmark
process directly. The binding clears FD_CLOEXEC on stdio first, like node's
own process.execve, otherwise the restarted process writes to /dev/null.
A PID marker prevents a restart loop if the launcher strips the flags again;
worker threads, IPC children, unsupported platforms and an unbound addon keep
the previous warning behavior.

Instrument-hooks now initializes on first use instead of at module import, so
the discarded image cannot leave a half-initialized FIFO connection behind.

Manually verified against a rules_js 2.1.3 project (Bazel 7.4.1, Node 18.20.4)
with bazel run and bazel test: the restarted process keeps its PID, reports
the complete analysis flag set in execArgv, and stdout/stderr still reach the
test log. Outside Bazel, --test_env=CODSPEED_ENV --test_env=CODSPEED_RUNNER_MODE
is still required, since Bazel strips CODSPEED_* from tests; without it the
integration runs disabled.

Closes COD-4038

The runner mirror of getV8Flags() needs the same flag set: CodSpeedHQ/codspeed#598 adds --always-sparkplug, so the restart does not fire on the normal codspeed exec path.

@codspeed

codspeed Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Merging this PR will regress 3 benchmarks

4 warnings, 1 note

⚠️ Walltime results may be unreliable

Some benchmarks used the Walltime instrument outside CodSpeed Macro Runners, which can make the results inconsistent.

Learn more about Macro Runners

See affected benchmarks

⚠️ 13 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚠️ 1 benchmark with a significant change ran on different hardware

Its base and head results were measured on machines with a different CPU, core count or memory, so the change might not come from the code. Run base and head on the same runner type.

Learn more about Macro Runners

See affected benchmarks

⚠️ 12 benchmarks with a significant change ran in a different software environment

Their base and head results were measured with a different OS or toolchain, such as compiler, runtime or library versions, so the changes might come from this difference rather than from the code. Open an affected benchmark to see what changed.

See affected benchmarks

ℹ️ 16 benchmarks have no flamegraph

The flamegraph wasn't generated: most of the time was spent in runtime internals, which would leave nothing useful to analyze.

See affected benchmarks

⚡ 10 improved benchmarks
❌ 3 regressed benchmarks
✅ 257 untouched benchmarks
⏩ 56 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
❌ Memory wait 500ms 22.7 KB 28.7 KB -20.98%
❌ Walltime switch 1 72 ns 84 ns -14.29%
❌ Walltime switch 2 96 ns 108 ns -11.11%
⚡ CPU Simulation switch 1 651.7 µs 306.9 µs ×2.1
⚡ CPU Simulation recursive fibo 10 677.4 µs 334.2 µs ×2
⚡ CPU Simulation recursive fibo 10 676.4 µs 335.7 µs ×2
⚡ Walltime fibo darwin 9.1 ms 5.2 ms +75.32%
⚡ Memory two 2 KB 1.6 KB +24.75%
⚡ Walltime short body 2 µs 1.7 µs +13.79%
⚡ Walltime short body 2 µs 1.8 µs +13.01%
⚡ Walltime short body 2 µs 1.8 µs +11.56%
⚡ Walltime switch 2 120 ns 108 ns +11.11%
⚡ Walltime test_iterative_fibo_10 120 ns 108 ns +11.11%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing cod-4038-node-introspection-restart (02e3074) with main (9c93085)

Open in CodSpeed

Footnotes

  1. 56 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@not-matthias
not-matthias force-pushed the cod-4038-node-introspection-restart branch from 66b266e to f19b2b3 Compare October 30, 2026 17:41
Comment thread .github/workflows/ci.yml Fixed
Importing the native module used to create the instrument-hooks instance, which opens the runner FIFO and writes environment data. Create it on the first hook call instead, so loading the module has no instrumentation side effects.
Launchers that start node by absolute path, such as Bazel's rules_js, never run the runner's node wrapper on PATH. tinybench and benchmark.js then measured without the flags getV8Flags() requires: --no-opt, --predictable, the seed flags, and the GC flags.

ensureV8Flags() now replaces the process before any benchmark or instrument-hooks state exists, inserting only the missing flags before the current execArgv so user flags keep precedence, as with the runner's wrapper. The restart reuses the running executable: /proc/self/exe on Linux because rules_js replaces process.execPath with its shell shim, which would re-add --require. A native execv binding keeps the PID, environment and open descriptors, so cancellation and the runner FIFO are unaffected; it clears FD_CLOEXEC on stdio like process.execve, otherwise the restarted process writes to /dev/null. The PID marker stops a restart loop when the launcher strips the flags again. Worker threads, IPC children, unsupported platforms, unbound addons and a failed exec keep the previous warning behavior instead of aborting the benchmark.

Closes COD-4038
The existing jobs reach node through PATH, so the runner wrapper always
injects the V8 flags and the plugin's restart never runs. Launch node by
absolute path, as Bazel's rules_js does, in simulation, walltime and
memory mode, and fail when the plugin reports missing flags or a failed
restart.

Refs COD-4038
@not-matthias
not-matthias force-pushed the cod-4038-node-introspection-restart branch from f19b2b3 to 3a489b4 Compare October 30, 2026 17:44
@not-matthias
not-matthias marked this pull request as ready for review October 30, 2026 17:45
@greptile-apps

greptile-apps Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] The restart changes startup behavior in the two benchmark plugin workflows.

Fix stdin entry points before merging; they can exit successfully without running any benchmarks.

Fix All in Claude CodeFindings

  1. P1 Piped benchmarks do not run ▶
Fix with agent prompt
### Issue 1
packages/core/src/introspection.ts:150-155
When a benchmark is piped into Node (`node -` or Node with no script argument), Node consumes stdin before the plugin loads. If required flags are missing, this restart reuses stdin at EOF without preserving the script text. The restarted process runs an empty script and can exit successfully without running any benchmarks.

Skip the restart for stdin entry points and keep the warning behavior, or preserve and replay their source.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR restarts Node when the benchmark launcher omits required V8 flags.

  • Benchmark plugins restart Node with any missing V8 flags.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Plugin loads] --> B{Runner enabled and addon bound?}
  B -- No --> C[Continue without restart]
  B -- Yes --> D{Required flags missing?}
  D -- No --> C
  D -- Yes --> E{Safe context and not restarted?}
  E -- No --> C
  E -- Yes --> F[Replace Node with added flags]
  F --> G[Replay entry arguments]
  G --> H{Entry came from stdin?}
  H -- No --> I[Reload benchmark script]
  H -- Yes --> J[Read EOF and run no benchmarks]
Loading

Reviews (1) · Last reviewed commit: "ci: run the examples without the runner ..." · Reviewed by Greptile

Comment thread packages/core/src/introspection.ts
A script piped into node (`node -`, or node without a script argument) is consumed before the plugin loads. The restarted process then read an empty stdin and exited successfully without running any benchmark. Keep the process and its missing-flags warning in that case. `-e`/`-p` code is part of execArgv and still restarts.
@not-matthias

Copy link
Copy Markdown
Member Author

@GuillaumeLagrange lmk if you want to discuss the approach. i didn't find any real alternatives other than letting users manually pass the v8 flags which isn't really ideal imo. but considering the implementation adds complexity, we might want to consider it.

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.

2 participants