Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci-workflows.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,6 @@ jobs:
# https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions#included-storage-and-minutes
smoke:
name: smoke (${{ matrix.workspace.path }}, ${{ matrix.os }})
if: github.ref_name == 'main' || contains(github.head_ref, 'macos') || contains(github.head_ref, 'windows')
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
Expand Down
1 change: 1 addition & 0 deletions MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ bazel_dep(name = "bazel_features", version = "1.41.0")
bazel_dep(name = "bazel_skylib", version = "1.5.0")
bazel_dep(name = "platforms", version = "1.0.0")
bazel_dep(name = "rules_nodejs", version = "6.7.3")
bazel_dep(name = "hermetic_launcher", version = "0.0.15")

# Changes ensured by rules_js:
# 3.2.2: https://github.com/bazel-contrib/bazel-lib/commit/cac2d7855949d1b222fa26888892fbbe1d31015d
Expand Down
162 changes: 162 additions & 0 deletions docs/hermetic_launcher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# The hermetic launcher

A `js_binary` is normally invoked through a generated bash script, which works
out where node, the fs patches and the entry point are, exports a set of
`JS_BINARY__*` variables, changes into the root of the output tree, and finally
execs node. That is a shell process and a few hundred lines of path resolution
on every invocation, which is noticeable when the `js_binary` is the tool of a
build action that runs thousands of times.

The hermetic launcher is an alternative: a small native binary, stamped per
target by [hermetic_launcher](https://github.com/hermeticbuild/hermetic-launcher),
which does nothing but `execve` node with a fixed set of arguments baked into it.
No shell is involved.

`js_run_binary` uses it automatically for a target when it can determine that
doing so behaves identically. Nothing else does yet; `bazel run` and `bazel test`
still go through the bash launcher.

## What replaces the launcher script

The launcher binary can only `execve`. Everything the script did before reaching
node is instead done by `js/private/node-bootstrap/launcher.cjs`, which node
loads with `--require` before the entry point. Running inside node, before any
user code, it can do almost everything the shell did:

- consume the `--bazel-bindir` flag that `js_run_binary` passes, so that it does
not reach the program as a positional argument
- change into the bin directory, preserving the "everything runs from the root
of the output tree" contract
- derive the execroot and the runfiles root, and set `JS_BINARY__FS_PATCH_ROOTS`
so that the fs patches apply
- put the node wrapper on the `PATH` and set `JS_BINARY__NODE_BINARY`,
`JS_BINARY__NODE_WRAPPER` and `JS_BINARY__NODE_PATCHES`, so that a child
process which shells out to `node` still gets the patched runtime
- honour `JS_BINARY__CHDIR`

Two kinds of thing are out of reach. Node CLI flags have to be baked into the
launcher, because node has already parsed its options by the time a preload
runs; only `--preserve-symlinks-main` is. And the per-target constants the
script bakes in -- `JS_BINARY__WORKSPACE`, `JS_BINARY__TARGET`,
`JS_BINARY__PACKAGE`, `JS_BINARY__BUILD_FILE_PATH`,
`JS_BINARY__COMPILATION_MODE`, `JS_BINARY__TARGET_CPU` and `JS_BINARY__BINDIR`
-- are not set at all, since there is no channel to carry them. A program that
reads one of those will see `undefined`.

## When it is used

Both the `js_binary` and the `js_run_binary` have to qualify.

A `js_binary` is disqualified by `chdir`, `env`, `node_options`,
`expected_exit_code` or `include_npm`, by targeting Windows, or by setting
`copy_data_to_bin = False` -- without that the entry point is never copied to the
bindir, and the execroot mode below has nothing to run.
`patch_node_fs` is not a disqualifier: `js_run_binary` always passes it through the
action environment.

`fixed_args` are disqualifying only when they need a shell. The launcher can carry
an argument verbatim, and it can resolve one through its runfiles, so the documented
`fixed_args = ["--config", "$$RUNFILES_DIR/$(rlocationpath :config)"]` idiom is
supported: `$(rlocationpath ...)` is expanded at analysis time, and the launcher
resolves what remains to the same absolute path the shell would have produced from
`$RUNFILES_DIR`. A `fixed_arg` containing any other `$`, or spelled
`--bazel-bindir`, is a disqualifier. Since `fixed_args` become embedded arguments
they also consume the launcher's ten argument slots, five of which are already
taken; a target with too many falls back to the bash launcher.

A `js_run_binary` is disqualified by a `--node_options=` entry in `args`. Its `env` is not
consulted at all.

`stdout`, `stderr`, `exit_code_out` and `silent_on_success` are not disqualifiers, even
though all four need work after the program exits and the launcher only `execve`s.
`js_run_binary` forwards them to `run_binary`, which captures through its `spawn_binary`
wrapper -- a process that outlives the program and can do that work. This costs nothing:
the launcher script only forks rather than `exec`s because of these same features, so the
wrapper's fork replaces the script's.

Setting the matching `JS_BINARY__*` variable through `env` by hand is the one way to ask
for something the launcher genuinely cannot do, since only the script implements those.
It is not treated as a disqualifier: it means going out of the way to reach for a private
variable in place of the attribute that exists for it, and `launcher.cjs` refuses to run
at all when it sees one, naming the variable. The variables that do have an
implementation here -- `JS_BINARY__CHDIR`, `JS_BINARY__NO_CD_BINDIR`,
`JS_BINARY__LOG_*`, `JS_BINARY__USE_EXECROOT_ENTRY_POINT` -- are honoured either way.

`log_level` is not a disqualifier on either side, because it only selects how much
diagnostic output is printed. `js_run_binary` passes `JS_BINARY__LOG_*` through the
action environment, so a level set there reaches `launcher.cjs` and `bootstrap.cjs`
unchanged. What differs is the detail: the launcher script's info and debug output
dumps the `PATH`, the `BAZEL_*` and `JS_BINARY__*` values it computed and the node
command line, and none of that is printed when the script does not run. A `log_level`
set on the `js_binary` itself has no channel to the stub and is not applied at all.

A coverage-enabled test is not a disqualifier either. The lcov report is generated from a
node exit hook rather than by the launcher script after node is gone, so nothing is left
that needs a process outliving the program. What remains is `NODE_V8_COVERAGE`, which node
reads once as it opens its V8 coverage connection during startup -- before any `--require`
preload runs, so `launcher.cjs` cannot turn coverage on for the process it is in. It
starts node again with the variable set instead, in place through `process.execve` where
node has it. Only the first node in the tree does that; every child inherits the variable
and opens its own connection. The path of the report generator has no channel from the
rule either, so `launcher.cjs` derives it from its own path in the runfiles, the same way
it finds the node wrapper.

`use_execroot_entry_point` is not a disqualifier either, though it is the one place where
the launcher has to do real work the stub cannot express. The two modes differ in which
copy of the entry point node runs, and therefore in the directory node walks up from to
resolve the program's own `require`s:

| mode | main module | resolution root |
| --- | --- | --- |
| `False` | `$RUNFILES/<repo>/<short_path>` | the tool's runfiles tree |
| `True` | `$EXECROOT/$BAZEL_BINDIR/<short_path>` | the target-configuration bin tree, where the action's `srcs` and outputs also live |

Only the runfiles form can be baked into the launcher binary: its single argument
transformation resolves against the runfiles root, and the bindir is the *target*
configuration's, which a `js_binary` analyzed for the exec platform does not know. So
`launcher.cjs` composes the execroot form at startup instead, exactly as the launcher
script does, and then redirects node's main module to it. It needs no extra embedded
argument to do so: an rlocation path and a short path differ only in their first segment,
so the short path is recovered from the runfiles path already in `argv[1]`. That inversion
needs the name of the main repository, which under bzlmod -- the only mode rules_js
supports -- is always `_main`.

Redirecting the main is what moves the resolution root, so it has to happen before node
loads it. For a CommonJS main that is a `Module._resolveFilename` hook; for an ES module it
is a `module.registerHooks` resolve hook, and on a node too old to have that
(before 22.15) the launcher re-executes node on the right file rather than run the wrong
copy. Both hooks are installed, so nothing has to predict which loader node will choose.
Only the process the build action launched does any of this -- a child that re-enters node
was handed its own script, and the launcher script would not have touched that either.

See [use_execroot_entry_point.md](use_execroot_entry_point.md) for what the two modes are
for.

Anything that does not qualify keeps using the bash launcher, with no error.

## Finding out what a target got

The action's executable path differs, so `aquery` answers the question directly
and cannot be out of date:

```sh
bazel aquery --output=textproto //your:target | grep -A2 'JsRunBinary'
```

For the `js_binary` side, every target publishes its verdict in an output group,
which is written only when asked for:

```sh
bazel build //... --output_groups=hermetic_launcher_report
find -L bazel-out -name hermetic_launcher_report.txt | xargs cat
```

Each line is the target label followed by `eligible`, `unavailable` (no prebuilt
stub for this platform, or an embedded argument over the launcher's 256-byte
limit), or `blocked:` and a comma-separated list of reason codes. Summing the
last column over a whole repository shows what is holding adoption back:

```sh
find -L bazel-out -name hermetic_launcher_report.txt | xargs cat |
sed 's/^[^ ]* //' | sort | uniq -c | sort -rn
```
22 changes: 22 additions & 0 deletions e2e/path_mapping/js_run_binary_path_mapping_check/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,25 @@ js_run_binary(
tags = ["manual"],
tool = ":check",
)

# Same again, but configured so the js_run_binary gate selects the hermetic launcher:
# silent_on_success off, and the entry point resolved through runfiles. check.mjs's two
# assertions -- that BAZEL_BINDIR is the path-mapped bindir, and that --bazel-bindir did
# not leak into argv -- are exactly what launcher.cjs has to get right in place of the
# launcher script, and `chdir` exercises the one JS_BINARY__* variable it honours.
js_run_binary(
name = "js_run_binary_path_mapping_check_hermetic",
outs = ["out_hermetic.txt"],
args = ["out_hermetic.txt"],
chdir = package_name(),
mnemonic = "JsRunBinaryPathMappingCheckHermetic",
set_legacy_environment_variables = False,
silent_on_success = False,
tool = ":check",
use_execroot_entry_point = False,
)

build_test(
name = "js_run_binary_path_mapping_check_hermetic_test",
targets = [":js_run_binary_path_mapping_check_hermetic"],
)
30 changes: 30 additions & 0 deletions e2e/path_mapping/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,33 @@ if [ "$cache_hit3" != "true" ]; then
fi

echo "PASS: js_run_binary action controlled by --@aspect_rules_js//js:set_legacy_environment_variables=False was cache-shared across -c fastbuild and -c opt"

# Same as above for js_run_binary_path_mapping_check_hermetic, which is configured so
# that js_run_binary selects the hermetic launcher. There the --bazel-bindir flag is
# consumed by the launcher.cjs preload rather than by the launcher script, so this is
# what proves that path is path-mapping-safe too.
exec_log4="$scratch/exec_log4.json"

bazel build -c fastbuild //js_run_binary_path_mapping_check:js_run_binary_path_mapping_check_hermetic \
--disk_cache="$disk_cache" \
--action_env="JS_RUN_BINARY_PATH_MAPPING_CHECK_HERMETIC_INVALIDATE=$invalidate"

bazel build -c opt //js_run_binary_path_mapping_check:js_run_binary_path_mapping_check_hermetic \
--disk_cache="$disk_cache" \
--action_env="JS_RUN_BINARY_PATH_MAPPING_CHECK_HERMETIC_INVALIDATE=$invalidate" \
--execution_log_json_file="$exec_log4"

matches4="$(jq -s '[.[] | select(.mnemonic == "JsRunBinaryPathMappingCheckHermetic")]' "$exec_log4")"
count4="$(echo "$matches4" | jq 'length')"
if [ "$count4" -eq 0 ]; then
echo "FAIL: no JsRunBinaryPathMappingCheckHermetic entry found in the -c opt execution log" >&2
exit 1
fi

cache_hit4="$(echo "$matches4" | jq -r '.[0].cacheHit')"
if [ "$cache_hit4" != "true" ]; then
echo "FAIL: hermetic launcher js_run_binary action was re-executed under -c opt (cacheHit=$cache_hit4); path mapping did not share the cache entry from -c fastbuild" >&2
exit 1
fi

echo "PASS: js_run_binary action using the hermetic launcher was cache-shared across -c fastbuild and -c opt"
7 changes: 7 additions & 0 deletions js/private/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,15 @@ bzl_library(
"@bazel_lib//lib:windows_utils",
"@bazel_skylib//lib:dicts",
"@bazel_tools//tools/build_defs/repo:cache.bzl",
"@hermetic_launcher//launcher:lib_bzl",
],
)

bzl_library(
name = "hermetic_tool",
srcs = ["hermetic_tool.bzl"],
)

bzl_library(
name = "js_helpers",
srcs = ["js_helpers.bzl"],
Expand Down Expand Up @@ -80,6 +86,7 @@ bzl_library(
name = "js_run_binary",
srcs = ["js_run_binary.bzl"],
deps = [
":hermetic_tool",
":js_helpers",
":js_info_files",
":js_library",
Expand Down
13 changes: 9 additions & 4 deletions js/private/coverage/bundle/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,21 @@ rollup_bin.rollup(
":node_modules/c8",
],
outs = ["bundle.js"],
# Only the config path needs `$RUNFILES_DIR` expanded; the rest are ordinary `args`,
# which follow `fixed_args`, so rollup sees the same command line either way and the
# hermetic launcher's embedded argument budget is not spent on them.
args = [
"--format",
"cjs",
"--file",
"bundle.js",
],
chdir = package_name(),
data = [":config"],
fixed_args = [
"c8.js",
"--config",
"$$RUNFILES_DIR/$(rlocationpath :config)",
"--format",
"cjs",
"--file",
"bundle.js",
],
silent_on_success = False,
visibility = ["//js/private/coverage:__pkg__"],
Expand Down
13 changes: 9 additions & 4 deletions js/private/devserver/src/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,21 @@ rollup_bin.rollup(
"//js/private/watch",
],
outs = ["bundle.mjs"],
# Only the config path needs `$RUNFILES_DIR` expanded; the rest are ordinary `args`,
# which follow `fixed_args`, so rollup sees the same command line either way and the
# hermetic launcher's embedded argument budget is not spent on them.
args = [
"--format",
"es",
"--file",
"bundle.mjs",
],
chdir = package_name(),
data = [":config"],
fixed_args = [
"js_run_devserver.mjs",
"--config",
"$$RUNFILES_DIR/$(rlocationpath :config)",
"--format",
"es",
"--file",
"bundle.mjs",
],
silent_on_success = False,
visibility = ["//js/private/devserver:__pkg__"],
Expand Down
73 changes: 73 additions & 0 deletions js/private/hermetic_tool.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""A js_binary tool that runs through the hermetic launcher where that is possible.

`js_run_binary` runs its tool through bazel-lib's `run_binary`, which uses the tool
target's `DefaultInfo.executable` -- the bash launcher script. Swapping in the native
launcher therefore means handing `run_binary` a different tool target, which is what
this rule is.

It also solves where the launcher finds its runfiles. The launcher resolves its
embedded rlocation paths against `$RUNFILES_DIR` or a `<executable>.runfiles` tree
adjacent to itself, and `RUNFILES_DIR` cannot be set in the action environment because
environment values are never path-mapped. Bazel materializes a runfiles tree next to
whatever a target declares as its executable, so a symlink owned by this rule gets one.
"""

def _hermetic_tool_impl(ctx):
binary = ctx.attr.binary
default_info = binary[DefaultInfo]

launchers = []
if OutputGroupInfo in binary and hasattr(binary[OutputGroupInfo], "hermetic_launcher"):
launchers = binary[OutputGroupInfo].hermetic_launcher.to_list()

extra_runfiles = []
if launchers:
target_file = launchers[0]

# Not part of the js_binary's own runfiles, so that a target which never uses a
# hermetic launcher does not carry it. Add it here, where it is needed.
extra_runfiles.append(ctx.file._hermetic_bootstrap)
else:
# No launcher for this target: it has a blocker, or hermetic_launcher publishes
# no stub for this platform. Fall back to the bash launcher, which is what the
# tool would have been used as anyway.
target_file = default_info.files_to_run.executable

# Windows dispatches on the file extension, so the symlink has to keep the one the
# file it points at has: the js_binary's executable there is a .bat wrapper, and a
# copy of it named without the extension is not executable at all (CreateProcessW
# fails with error 193, "not a valid Win32 application").
name = ctx.label.name
if target_file.extension:
name += "." + target_file.extension
executable = ctx.actions.declare_file(name)
ctx.actions.symlink(
output = executable,
target_file = target_file,
is_executable = True,
)

return [DefaultInfo(
files = depset([executable]),
executable = executable,
runfiles = default_info.default_runfiles.merge(
ctx.runfiles(files = extra_runfiles),
),
)]

hermetic_tool = rule(
doc = "Wraps a `js_binary` so that it runs through its hermetic launcher when it has one.",
implementation = _hermetic_tool_impl,
attrs = {
"binary": attr.label(
doc = "The `js_binary` to wrap.",
mandatory = True,
providers = [DefaultInfo],
),
"_hermetic_bootstrap": attr.label(
allow_single_file = True,
default = Label("@aspect_rules_js//js/private/node-bootstrap:launcher.cjs"),
),
},
executable = True,
)
Loading
Loading