Skip to content

feat: add hermetic launcher as opt-in alternative to bash launcher - #3002

Merged
acozzette merged 89 commits into
mainfrom
new-launcher
Sep 25, 2026
Merged

acozzette merged 89 commits into
mainfrom
new-launcher

Conversation

@acozzette

@acozzette acozzette commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

This change introduces a new launcher based on hermetic_launcher. For now the bash launcher is still the default, but you can opt in to the new launcher with --@aspect_rules_js//js:hermetic_launcher.

This launcher consists of a small native binary produced with hermetic_launcher that execs a generated JS launcher. If possible, the JS launcher jumps straight to the main entry point. However, this is not possible when expected_exit_code is used, in which case we have to exec node a second time. We also have to re-exec node when certain environment variables or flags are embedded in the js_binary that have an effect at node startup.


Changes are visible to end-users: yes

  • Searched for relevant documentation and updated as needed: yes
  • Breaking change (forces users to change their own code or config): no
  • Suggested release notes appear below: yes

An experimental new launcher based on hermetic_launcher is now available: opt in by passing --@aspect_rules_js//js:hermetic_launcher.

Test plan

  • Covered by existing test cases
  • New test cases added

acozzette and others added 10 commits September 2, 2026 19:10
Three fixes to the hermetic launcher added in the previous commit:

- Drop the `NODE_V8_COVERAGE` export. #2993 removed these lines from
  `js_binary.sh.tpl` so that coverage is started from within node; the JS
  launcher was ported from a pre-#2993 base and still carried them. Output
  is byte-identical either way (node collects natively when the variable is
  preset), but the launcher sets it *after* chdir'ing to `BAZEL_BINDIR`,
  where `coverage.cjs` resolves `COVERAGE_DIR` against the execroot, so a
  relative `COVERAGE_DIR` would have landed profiles in the wrong place.

- Normalize `RUNFILES_MANIFEST_FILE` before testing its suffix, as
  `bash.bzl` does. On Windows Bazel hands out a backslash-separated path,
  which would not match `/MANIFEST` and would take the fatal branch.

- Accept a drive-letter or UNC prefix when deciding whether a
  `node_toolchain`'s `target_tool_path` is absolute. `startswith("/")`
  misses `C:\...`, which would then be embedded in the stub as the
  rlocation `_main/C:\...` and never resolve. The launcher itself already
  classified it correctly via `path.isAbsolute`.

The latter two are Windows-only paths, which this repo does not exercise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The JS launcher was ported from a branch based on #2984, so the bash
launcher changes that landed after it were missing:

- #2995 dropped the `JS_BINARY__BINDIR` fallback in
  `resolve_execroot_bin_path`. `BAZEL_BINDIR` is already required by then,
  so the fallback only masked a misconfiguration.

- #2991 made `NODE_DISABLE_COMPILE_CACHE=1` unconditional, leaving
  `bootstrap.cjs` to re-enable the cache when `NODE_COMPILE_CACHE` is set.
  The two forms agree on every combination the tests cover, but the
  conditional is the behavior #2991 deliberately replaced.

The other two changes in that window were already reflected: #2994 moved
chdir into `bootstrap.cjs` and #2984 moved coverage report generation into
a node exit listener, and the JS launcher does neither.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The JavaScript launcher is a transliteration of the bash one, but nothing
enforced that. A target gets one launcher per configuration, so every existing
both-launcher test skips one side and CI covers the other by running the whole
suite again with the flag on. That catches breakage but not drift: the three
unported bash-launcher changes fixed in the last two commits were invisible to
the suite, and were only found by hand-diffing js_binary.sh.tpl's history.

//js/private/test/launcher_sync builds one js_binary twice through a
configuration transition -- once with //js:hermetic_launcher off, once with it
on -- runs both, and diffs the state node ends up in: process.env, the cwd,
argv and execArgv. Comparing the launchers against each other rather than
against a golden means there is no snapshot to regenerate, nothing to keep
stable across the CI matrix, and the test fails on every leg rather than one.

The transition is on the rule, so it reaches the tool down the cfg = "exec"
edge, which does not reset Starlark build settings. Should that ever change,
both variants would silently be the bash launcher and the diff would pass for
the wrong reason, so the rule asserts on the launcher_js output group instead
of assuming.

Two divergences it found immediately, both fixed here:

- process.chdir() does not maintain PWD, so a program under the JS launcher saw
  a PWD pointing at the execroot while its cwd was the bindir -- and so did
  every child process it spawned. bootstrap.cjs already does this fixup after
  its own chdir, but only when chdir is set.

- The PATH prepend interpolated `${process.env.PATH}` unguarded, so with no
  PATH in the environment it put a directory literally named "undefined" on the
  path. Bash never hits this because it always has a PATH of its own.

Also adds //js/private/test:write_launcher_js to update-snapshots.sh, which
could not reach it before because it needs the flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two ways the generated launcher could differ from the bash one it transliterates,
both found by a review of the launcher work.

The paths substituted into js_binary.cjs.tpl -- the entry point, node, npm, the
two wrappers and node-patches -- were spliced raw into JavaScript string
literals. The bash launcher put them in double quotes, where neither a backslash
nor an apostrophe is special, but in JavaScript both are: a Bazel label may
contain an apostrophe, which ends the literal and makes the generated launcher a
syntax error, and a node_toolchain target_tool_path on Windows is a
backslash-separated path whose \n is read as a newline escape. Substitute them as
JSON literals bound to consts instead. //js/private/test/entry_point_quoting
covers the first case, which fails to build without this.

fixed_args lost their quoting at analysis time but were expanded at run time, so
the single quotes that told bash not to expand $VAR were gone by the time the
launcher looked. `fixed_args = ["'$HOME/x'"]` reached the program as $HOME/x
under the bash launcher and as the expanded path under this one. _shell_tokenize
now emits each token as a list of [text, expand] segments so that decision
survives. Both directions are covered by //js/private/test/fixed_args.

Backslash escapes are still not interpreted, so `\$VAR` remains a divergence;
that is deliberate, to keep a Windows-style path in a fixed_arg intact, and is
now documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
js_image_layer rewrites the launcher for hermeticity, and which file that is
depends on the launcher in use: with the bash launcher it is the executable
itself, with the hermetic one it is the .cjs in runfiles, since the executable is
a native stub with nothing patchable in it. The launcher_js output group is what
tells the two apart.

That group only exists if the rule that called js_binary_lib.create_launcher
republishes it. js_binary and js_run_devserver do; the custom rule this repo
documents as the example, js/private/test/create_launcher/custom_test.bzl, did
not. So with --@aspect_rules_js//js:hermetic_launcher set, a layer built over
such a rule took the bash branch and ran expand_template over the ELF stub, which
decodes its input as UTF-8: the stub came out 3951 replacement characters and
7839 bytes larger, still recognizable as an ELF because \x7fELF is ASCII, and
exec'd with "Exec format error". Nothing failed at build time -- expand_template
does not require its substitutions to match.

Fail at analysis instead of guessing, and name the four lines a custom rule
needs. All three create_launcher callers in this repo publish the group and every
js_image_layer here is over a js_binary, so nothing in tree trips the new check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@acozzette
acozzette marked this pull request as ready for review September 4, 2026 03:39
@acozzette
acozzette requested a review from jbedard September 4, 2026 03:40
Comment thread e2e/js_image_oci/src/BUILD.bazel Outdated
Comment thread docs/hermetic_launcher.md Outdated
Comment thread docs/hermetic_launcher.md Outdated
Comment thread docs/hermetic_launcher.md Outdated
Comment thread docs/hermetic_launcher.md Outdated
Comment thread docs/hermetic_launcher.md Outdated
Comment thread docs/hermetic_launcher.md Outdated
Comment thread js/private/test/entry_point_quoting/BUILD.bazel
Comment thread js/BUILD.bazel Outdated
Comment thread docs/hermetic_launcher.md
Comment thread js/private/js_binary.cjs.tpl
acozzette and others added 3 commits September 9, 2026 09:16
js_image_layer made the launcher hermetic by expand_template-ing over
'use strict' (JS launcher) or #!/usr/bin/env bash (bash launcher).
expand_template replaces every occurrence of a key, and both anchors are
strings a user can put in env or fixed_args, so such a value got patched
too: in bash a broken multi-line export that also clobbers BAZEL_BINDIR,
and in the .cjs an unterminated string literal that no longer parses.
Both failed silently at build time.

Both templates now carry a line reserved for the patch, and
js_image_layer keys off that instead. The bash launcher had the same
latent bug and is fixed alongside so the two launchers keep
corresponding.

Adds js/private/test/image:anchors_{use_strict,shebang}_test, which
reads the two former anchors back out of the shipped layer tar. It
matches either launcher's spelling of an env assignment, so one
expectation covers both configurations, and a corrupted value spans
lines and so goes missing rather than merely differing. Falsified under
both flag values: with the old anchors restored, each launcher's own
anchor is the failing one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread docs/hermetic_launcher.md Outdated
Comment thread js/private/node-bootstrap/util.cjs Outdated
Comment thread js/private/test/fixed_args/BUILD.bazel
Comment thread js/private/js_binary.bzl Outdated
@acozzette

Copy link
Copy Markdown
Contributor Author

I asked Claude to benchmark the overhead of the two launchers and it found the bash launcher's mean launch time to be 82.4 ms. The hermetic launcher with the in-process fast path was 58.2 ms and with the slow path (re-execing Node) it was 95.7 ms.

@jbedard

jbedard commented Sep 24, 2026

Copy link
Copy Markdown
Member

I asked Claude to benchmark the overhead of the two launchers and it found the bash launcher's mean launch time to be 82.4 ms. The hermetic launcher with the in-process fast path was 58.2 ms and with the slow path (re-execing Node) it was 95.7 ms.

So with modern node and not using the odd-features this is a net-win for startup overhead? 👍

acozzette and others added 8 commits September 24, 2026 11:23
js_image_layer called _launcher_js unconditionally, so it failed analysis for any binary
without a launcher_js output group even with the hermetic launcher off, which is the
default. A custom rule built on the public js_binary_lib.create_launcher that does not
republish the group built before this branch and stopped building on it, although the bash
launcher has no JavaScript launcher to republish in the first place.

The check now runs only under --@aspect_rules_js//js:use_hermetic_launcher, where every
create_launcher-derived target does have a JavaScript launcher, so an absent group really
does mean the rule forgot to republish it and sanitizing the native stub as if it were a
shell script would produce a broken image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bash launcher splices a fixed_arg into its own source text, so a value carrying a
shell metacharacter stops it from parsing. The hermetic launcher has no source text to
corrupt and hands each one to the program intact, which is the better answer, so those
cases now assert on it through a new hermetic_only mirror of bash_only.

An unterminated quote was the exception: the tokenizer opened a quoted run that never
closed, and the quote character was dropped, so `it's` arrived as `its`. A quote now opens
a run only when it has a partner later in the fixed_arg, and is otherwise one more
character, so `it's` and `a"b` arrive intact and join the group above.

target_compatible_with has to reach the js_run_binary as well as the assertions. These
values stop one launcher from running, so leaving the action to run under it would fail the
build even with its assertions skipped.

Two cases stay pinned to bash, where it is the one that is right: an unset variable, which
bash calls unbound and the hermetic launcher turns into an empty argument, and a failing
command substitution, which it splits in half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#3029 added macos_want_exit_code to the fixed_args cases that stop the bash launcher from
running, because macOS ships bash 3.2, which runs the launcher's EXIT trap with `$?` set to
0. Eight of those cases now assert argv under the hermetic launcher instead, where nothing
stops the launcher and the exit code is not what is being pinned, so they keep this
branch's version. unset_var_breaks_launcher is still a bash exit-code case and takes the
new value, with the reason moved into the section comment above it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
expandEnvRefs turned a name that is not set into the empty string, so a typo in a
fixed_arg, an env value or a node_option silently changed what the program was given. The
bash launcher runs under `set -o nounset`, which stops at the reference with
`VAR: unbound variable` and exits 1, and all three of those are spliced into double quotes
there, so the check belongs in expandEnvRefs rather than on the fixed_args path alone.

Unset and empty had to stop being the same thing to do this. `|| ''` covered both, while
nounset fires only on the unset one: bash expands a variable set to the empty string to it
without complaint.

The seven image listings record the size of util.cjs, so they move with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@acozzette
acozzette merged commit da44b3d into main Sep 25, 2026
235 checks passed
@acozzette
acozzette deleted the new-launcher branch September 25, 2026 17:20
acozzette added a commit that referenced this pull request Sep 25, 2026
main now carries the hermetic launcher (#3002), which also consumes
_shell_tokenize. The two sides had rewritten the same function, so the
conflicts were resolved in favor of this branch's tokenizer -- it is a
superset: it rejects the constructs that cannot survive the split, and the
stray-quote lookahead the hermetic launcher added is already part of it.

main's refactor of `envs` into (var, value, iff_not_set) triples shared by
both launchers is kept as is; only `{{fixed_args}}` comes from this branch.

The launcher-specific markers main put on the fixed_args cases mostly fall
away here, because the bash launcher now gets one double-quoted word per
argument and so agrees with the hermetic launcher on backslashes, braces,
tildes, globs and stray metacharacters. What is left is the expansion bash
still does inside double quotes and the hermetic launcher does not:
${V:-d}, ${V#p}, $((...)) and "$(...)" stay bash_only, with a hermetic_only
companion pinning the literal for the last one. The failing command
substitution is now refused at analysis time, so it moves to rejected_case.

Verified: //... 433 pass / 13 skip under bash, 430 pass / 16 skip under
--@aspect_rules_js//js:use_hermetic_launcher; examples 86/86 under both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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