Skip to content

Skip the shell for simple scripts (~2x faster), fix Windows PATH joining - #3

Merged
dawsbot merged 2 commits into
mainfrom
perf/direct-exec
Jun 12, 2026
Merged

Skip the shell for simple scripts (~2x faster), fix Windows PATH joining#3
dawsbot merged 2 commits into
mainfrom
perf/direct-exec

Conversation

@dawsbot

@dawsbot dawsbot commented Jun 12, 2026

Copy link
Copy Markdown
Owner

What

Profiling showed that nr's own work is only ~2ms. About 70% of the runtime of nr test was spawning /bin/sh (which is bash on macOS, ~4ms of startup).

Direct-exec fast path. Scripts with no shell metacharacters and no leading VAR=value assignment are now exec'd directly via execvp semantics instead of through sh -c. This covers the typical case (vitest, tsc -p ., eslint src). Anything with operators, quotes, globs, or env prefixes falls back to the shell exactly as before, and a failed direct exec also falls through to sh so users keep its familiar "command not found" diagnostics.

A free correctness win on this path: extra arguments are passed as real argv entries, so nr argcount "two words" arrives as one argument instead of being re-split by the shell.

Windows PATH bug fix. node_modules/.bin paths were joined with : on all platforms, so the cmd /C branch received a broken PATH. Now uses std::env::join_paths, which picks the right separator per platform.

Micro-optimizations.

  • package.json is read directly inside the ancestor walk (one syscall instead of stat then open, and removes the TOCTOU window)
  • Parsing uses serde_json::from_slice into borrowed Cow<str> scripts, eliminating the separate UTF-8 validation pass and per-script allocations
  • env::args_os replaces env::args, so non-UTF-8 arguments no longer panic

Numbers

Median of 30 runs on this machine (Apple Silicon, subprocess harness with ~3ms constant spawn overhead):

before after
nr test (shell-free script) 9.0ms 4.7ms

README benchmark table regenerated: headline speedup vs pnpm went from 28x to 31x, and nr widened its lead over bun (8ms vs 10ms in that harness). Note the benchmark's execSync wrapper adds a ~7ms constant to every runner, so the real-world improvement is larger than the table suggests.

Tests

Four new cases in test.sh:

  • quoted extra args preserved as a single argument (written first, failed on the old binary with argc=2)
  • scripts with && still run through the shell
  • leading FOO=bar env assignments still run through the shell
  • node_modules/.bin shims resolve on the direct-exec path

All 11 tests pass. The test fixture script was changed from echo 'test passed' to echo test passed (identical output) so the benchmark exercises the fast path.

🤖 Generated with Claude Code

dawsbot and others added 2 commits June 12, 2026 11:24
Scripts without shell metacharacters (the common case: vitest, tsc -p .,
eslint src) are now exec'd directly instead of through sh -c, cutting
~4ms of shell startup. Extra args are passed as real argv entries on
this path, so quoted arguments with spaces are no longer re-split.
Scripts with metacharacters or leading VAR=value assignments still go
through the shell, with exec-failure fallback to sh for its
diagnostics.

Also:
- Join PATH with std::env::join_paths so the Windows branch gets ";"
  separators instead of ":"
- Read package.json directly in the ancestor walk (one syscall instead
  of stat+open, removes the TOCTOU window)
- Parse with from_slice into borrowed Cow strings (no per-script
  allocations or separate UTF-8 pass)
- Use args_os so non-UTF-8 arguments no longer panic

Benchmark on this machine: nr test drops from ~9.0ms to ~4.7ms median.
README table regenerated (28x -> 31x headline).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The old harness wrapped every runner in Node's execSync, which spawns
/bin/sh around each measured command. That added a flat ~7ms to every
runner and drowned out the differences between the fast ones. hyperfine
with --shell=none execs the runners directly, all in one invocation
with warmup runs and statistical output.

Headline on this machine: nr measures 3.7ms median (was reported as
8ms), 2x faster than bun and 66x faster than pnpm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dawsbot
dawsbot merged commit 3badffe into main Jun 12, 2026
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.

1 participant