diff --git a/.github/workflows/ci-workflows.yaml b/.github/workflows/ci-workflows.yaml index ff1348a10f..89cb13d769 100644 --- a/.github/workflows/ci-workflows.yaml +++ b/.github/workflows/ci-workflows.yaml @@ -179,6 +179,11 @@ jobs: version: '9.x', flags: '--bazel-flag=--test_tag_filters=-skip-on-bazel9 --bazel-flag=--@aspect_rules_js//js:use_execroot_entry_point=False', } + - { + id: 'bazel-9-hermetic-launcher', + version: '9.x', + flags: '--bazel-flag=--test_tag_filters=-skip-on-bazel9 --bazel-flag=--@aspect_rules_js//js:hermetic_launcher=True', + } exclude: # e2e/js_image_oci pulls in the `llvm` module, whose hermetic toolchain requires Bazel 8+. - { @@ -198,12 +203,21 @@ jobs: workspace: { slug: 'e2e-patch_from_repo' }, bazel: { id: 'bazel-9-no-execroot-entry-point' }, } + - { + workspace: { slug: 'e2e-patch_from_repo' }, + bazel: { id: 'bazel-9-hermetic-launcher' }, + } # e2e/repo_mapping renames aspect_rules_js, so a flag beginning with - # --@aspect_rules_js// (as used by the no-execroot-entry-point variant) doesn't resolve. + # --@aspect_rules_js// (as used by the no-execroot-entry-point and + # hermetic-launcher variants) doesn't resolve. - { workspace: { slug: 'e2e-repo_mapping' }, bazel: { id: 'bazel-9-no-execroot-entry-point' }, } + - { + workspace: { slug: 'e2e-repo_mapping' }, + bazel: { id: 'bazel-9-hermetic-launcher' }, + } env: USE_BAZEL_VERSION: ${{ matrix.bazel.version }} ASPECT_GH_PACKAGES_AUTH_TOKEN: ${{ secrets.ASPECT_GH_PACKAGES_AUTH_TOKEN }} @@ -251,11 +265,11 @@ jobs: if: matrix.workspace.path == '.' && matrix.bazel.id == 'bazel-7' run: aspect test --task-key=coverage-split-${{ matrix.workspace.slug }}-${{ matrix.bazel.id }} ${{ matrix.bazel.flags }} --bazel-flag=--collect_code_coverage --bazel-flag=--instrument_test_targets --bazel-flag=--nocache_test_results --bazel-flag=--experimental_split_coverage_postprocessing --bazel-flag=--experimental_fetch_all_coverage_outputs -- //js/private/test/coverage/... - # Skipped on the no-execroot-entry-point variant: test.sh scripts invoke plain - # `bazel` without matrix.bazel.flags, so that leg wouldn't exercise the flag — - # it would only duplicate the bazel-9 run. + # Skipped on the flag-flip variants: test.sh scripts invoke plain `bazel` + # without matrix.bazel.flags, so those legs wouldn't exercise the flag — they + # would only duplicate the bazel-9 run. - name: Optional ./test.sh - if: matrix.bazel.id != 'bazel-9-no-execroot-entry-point' + if: matrix.bazel.id != 'bazel-9-no-execroot-entry-point' && matrix.bazel.id != 'bazel-9-hermetic-launcher' working-directory: ${{ matrix.workspace.path }} env: ASPECT_RULES_JS_FROZEN_PNPM_LOCK: 1 diff --git a/.prettierignore b/.prettierignore index ae2c7c4e93..c1ee42588e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,3 +11,4 @@ min/ npm/private/test/vendored/ js/private/worker/worker.js js/private/worker/src/worker_protocol.ts +js/private/test/snapshots/launcher.cjs diff --git a/MODULE.bazel b/MODULE.bazel index 5a98db5168..101842e94c 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -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 diff --git a/docs/hermetic_launcher.md b/docs/hermetic_launcher.md new file mode 100644 index 0000000000..0f2f456ac5 --- /dev/null +++ b/docs/hermetic_launcher.md @@ -0,0 +1,109 @@ +# The hermetic launcher + +A `js_binary` is normally invoked through a generated bash script +(`js/private/js_binary.sh.tpl`), 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, and it cannot run at +all where there is no bash. + +The hermetic launcher is an experimental alternative, off by default: + +```sh +bazel build //... --@aspect_rules_js//js:hermetic_launcher +``` + +With the flag on, a `js_binary`'s executable is a small native binary stamped by +[hermetic_launcher](https://github.com/hermeticbuild/hermetic-launcher) which +does nothing but resolve its runfiles and `execve` node on a generated +JavaScript launcher, `_/.cjs`. No shell is involved. The flag +applies everywhere: `bazel run`, `bazel test` and `js_run_binary` all go through +it, and a target gets one launcher or the other, never both. + +This is the first step towards replacing the bash launcher outright. The +JavaScript launcher is deliberately an almost literal translation of the bash +one -- same order, same messages, same decisions -- so that the two can be read +side by side. `js/private/test/snapshots/launcher.sh` and +`js/private/test/snapshots/launcher.cjs` are checked-in expansions of both, kept +up to date by `//js/private/test:write_launcher` and +`//js/private/test:write_launcher_js`, and diffing them is how a change to +either is reviewed. + +## What is not implemented + +The JavaScript launcher does not implement stdout capture, stderr capture, exit +code capture or `silent_on_success`. It ignores `JS_BINARY__STDOUT_OUTPUT_FILE`, +`JS_BINARY__STDERR_OUTPUT_FILE`, `JS_BINARY__EXIT_CODE_OUTPUT_FILE` and +`JS_BINARY__SILENT_ON_SUCCESS` rather than honoring them. + +Nothing in rules_js asks the launcher for those anymore: `js_run_binary` +forwards `stdout`, `stderr`, `exit_code_out` and `silent_on_success` to +bazel-lib's `run_binary`, which captures through its own spawn wrapper -- a +process that outlives the program and can do the work they need once it has +exited. The launcher's implementation of them is legacy compatibility for code +outside rules_js that sets the variables by hand (#2955), and it stays in the +bash launcher. + +`expected_exit_code` _is_ implemented, since it is a `js_binary` attribute with +no other home. + +Everything else the bash launcher does is reproduced: the `--bazel-bindir` flag, +the execroot derivation, the `cd` into `BAZEL_BINDIR`, entry point / node / npm / +wrapper resolution, `node_options`, `fixed_args`, `JS_BINARY__FS_PATCH_ROOTS`, +coverage, the node wrapper on the `PATH`, the `JS_BINARY__*` per-target +constants, signal forwarding, and the debug and info logging. + +## How many processes it costs + +When there is no `expected_exit_code` (almost always) the launcher replaces +itself with node through `process.execve`, exactly as the bash launcher's `exec` +did, so no launcher process survives. It is still one more node startup than the +bash launcher paid, which is the price of this step; collapsing it is the point +of the next one. + +`process.execve` is POSIX-only and was added in node 22.15. On an older node, on +Windows, and whenever `expected_exit_code` is set, the launcher spawns node and +waits for it, forwarding `SIGTERM` and `SIGINT` -- the bash launcher's +fork-and-wait path. + +## Differences you may notice + +- **`fixed_args` are tokenized at analysis time.** The bash launcher spliced + them into `ALL_ARGS=(... "$@")`, so the shell word-split them and removed + quotes. `_shell_tokenize` in `js/private/js_binary.bzl` reproduces that + splitting; backslash escapes are deliberately not interpreted, so a + Windows-style path survives intact. That last point shows in one place: bash + passes `\$VAR` through literally where this launcher expands it. Use + `'$VAR'` for a literal `$`. +- **`$VAR` expansion in `env`, `node_options` and `fixed_args` is done by the + launcher, not a shell.** `$VAR` and `${VAR}` are expanded against the + environment as it is built up; command substitution is not reproduced, and the + result is not re-split on whitespace. A single-quoted segment of a `fixed_arg` + is left alone, as bash would have left it. +- **A custom rule built on `js_binary_lib.create_launcher` must republish + `launcher_js`.** That output group is how `js_image_layer` tells a + hermetic-launcher binary from a bash-launcher one: the two keep the values + that have to be rewritten for hermeticity in different files. + +## Keeping the two launchers in sync + +The JavaScript launcher is a transliteration of the bash one and has to stay that +way until it replaces it. The rest of the suite cannot check that: a target gets +one launcher per configuration, so every other 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 -- a bash-launcher change that was never ported +can potentially leave both launchers passing every test. + +`//js/private/test/launcher_sync` closes that gap. A configuration transition +builds one `js_binary` twice, once with the flag off and once with it on, runs +both, and diffs the state node ends up in: `process.env`, the working directory, +`argv` and `execArgv`. Because it compares the two launchers against each other +rather than against a recorded golden, it needs no snapshot to regenerate and it +fails on every CI leg rather than just the one with the flag on. + +## Status + +Windows is wired up untested: the repo's Windows smoke job only runs on `main`. + +CI runs the whole test suite against the flag on the `bazel-9-hermetic-launcher` +matrix leg. diff --git a/e2e/js_image_oci/src/BUILD.bazel b/e2e/js_image_oci/src/BUILD.bazel index 4f44ed8636..0fa6f03818 100644 --- a/e2e/js_image_oci/src/BUILD.bazel +++ b/e2e/js_image_oci/src/BUILD.bazel @@ -65,13 +65,12 @@ oci_image( # Since js_binary depends on bash we have to bring in a base image that has bash base = "@debian", # This is `/[js_image_layer 'root']/[package name of js_image_layer 'binary' target]/[name of js_image_layer 'binary' target]` - cmd = ["/app/src/bin"], - entrypoint = ["/usr/bin/bash"], + entrypoint = ["/app/src/bin"], tars = [ ":layers", ], visibility = ["//visibility:public"], - # This is `cmd` + `.runfiles/[workspace name]` + # This is `entrypoint` + `.runfiles/[workspace name]` workdir = "/app/src/bin.runfiles/_main", ) diff --git a/e2e/js_image_oci/src/test.yaml b/e2e/js_image_oci/src/test.yaml index 725c4c335b..6f7e18487f 100644 --- a/e2e/js_image_oci/src/test.yaml +++ b/e2e/js_image_oci/src/test.yaml @@ -2,8 +2,7 @@ schemaVersion: 2.0.0 commandTests: - name: 'smoke' - command: '/usr/bin/bash' - args: ['/app/src/bin'] + command: '/app/src/bin' expectedOutput: [ 'OS', @@ -23,8 +22,7 @@ commandTests: ' REPO NPM CHECK true', ] - name: 'smoke2' - command: '/usr/bin/bash' - args: ['/app/src/bin'] + command: '/app/src/bin' expectedOutput: [ 'OS', diff --git a/js/BUILD.bazel b/js/BUILD.bazel index e3495cf3c9..d45a4e2482 100644 --- a/js/BUILD.bazel +++ b/js/BUILD.bazel @@ -51,6 +51,21 @@ config_setting( visibility = ["//visibility:public"], ) +# This flag selects the experimental hermetic launcher: a native launcher binary +# stamped by hermetic_launcher which execs node on a generated JavaScript launcher, +# in place of the generated bash launcher script. See docs/hermetic_launcher.md. +bool_flag( + name = "hermetic_launcher", + build_setting_default = False, + visibility = ["//visibility:public"], +) + +config_setting( + name = "_hermetic_launcher_true", + flag_values = {"hermetic_launcher": "True"}, + visibility = ["//js/private/test:__subpackages__"], +) + bzl_library( name = "defs", srcs = ["defs.bzl"], diff --git a/js/private/BUILD.bazel b/js/private/BUILD.bazel index 42ed04c3d7..93bc19f6da 100644 --- a/js/private/BUILD.bazel +++ b/js/private/BUILD.bazel @@ -6,6 +6,7 @@ package(default_visibility = ["//visibility:public"]) exports_files([ "js_binary.sh.tpl", + "js_binary.cjs.tpl", "node_bin/node", "node_bin_windows/node.bat", "npm_bin/npm", @@ -49,7 +50,9 @@ bzl_library( "@bazel_lib//lib:paths", "@bazel_lib//lib:windows_utils", "@bazel_skylib//lib:dicts", + "@bazel_skylib//rules:common_settings", "@bazel_tools//tools/build_defs/repo:cache.bzl", + "@hermetic_launcher//launcher:lib_bzl", ], ) diff --git a/js/private/js_binary.bzl b/js/private/js_binary.bzl index 179ef93f86..91bc796511 100644 --- a/js/private/js_binary.bzl +++ b/js/private/js_binary.bzl @@ -4,6 +4,8 @@ load("@bazel_lib//lib:copy_to_bin.bzl", "COPY_FILE_TO_BIN_TOOLCHAINS") load("@bazel_lib//lib:directory_path.bzl", "DirectoryPathInfo") load("@bazel_lib//lib:expand_make_vars.bzl", "expand_locations", "expand_variables") load("@bazel_lib//lib:windows_utils.bzl", "create_windows_native_launcher_script") +load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo") +load("@hermetic_launcher//launcher:lib.bzl", hermetic_launcher = "launcher") load(":bash.bzl", "BASH_INITIALIZE_RUNFILES") load(":js_helpers.bzl", "LOG_LEVELS", "envs_for_log_level", "gather_files_from_js_infos", "gather_runfiles", "normalize_chdir") @@ -249,6 +251,15 @@ _ATTRS = { default = Label("//js/private:js_binary.sh.tpl"), allow_single_file = True, ), + "_launcher_js_template": attr.label( + default = Label("//js/private:js_binary.cjs.tpl"), + allow_single_file = True, + ), + # Selects the hermetic launcher over the bash launcher. See docs/hermetic_launcher.md. + "_hermetic_launcher": attr.label( + default = Label("//js:hermetic_launcher"), + providers = [BuildSettingInfo], + ), # Windows gets its own separate directory for node and npm wrappers. This # ensures that the bash scripts do not end up on the PATH when we build for # Windows. @@ -288,31 +299,139 @@ _ENV_SET = """export {var}={quoted_value}""" _ENV_SET_IFF_NOT_SET = """if [[ -z "${{{var}:-}}" ]]; then export {var}={quoted_value}; fi""" _NODE_OPTION = """JS_BINARY__NODE_OPTIONS+=(\"{value}\")""" +# The same three, in the JavaScript launcher's syntax. setEnv/setEnvIfUnset and +# addNodeOption are defined by js_binary.cjs.tpl. +_ENV_SET_JS = """setEnv({quoted_var}, {quoted_value})""" +_ENV_SET_IFF_NOT_SET_JS = """setEnvIfUnset({quoted_var}, {quoted_value})""" +_NODE_OPTION_JS = """addNodeOption({quoted_value})""" + +# Toolchains of the hermetic launcher, resolved here as Labels rather than used as the +# bare strings hermetic_launcher exposes: under --incompatible_auto_exec_groups a string +# toolchain type is resolved against the repository mapping of whichever module is being +# built, so a consumer that does not itself depend on hermetic_launcher cannot resolve +# the name. A Label is resolved against this file's own mapping at load time instead. +_FINALIZER_TOOLCHAIN_TYPE = Label(hermetic_launcher.finalizer_toolchain_type) +_TEMPLATE_TOOLCHAIN_TYPE = Label(hermetic_launcher.template_toolchain_type) + +# Stands in for the launcher on target platforms where hermetic_launcher is not supported. +_NO_LAUNCHER_PLACEHOLDER = """#!/bin/sh +echo "ERROR: {target}: no hermetic_launcher stub is registered for this target platform, so this js_binary has no launcher and cannot run. See https://github.com/hermeticbuild/hermetic-launcher for the supported platforms." >&2 +exit 1 +""" + def _expand_env_if_needed(ctx, value): if ctx.attr.expand_env: return " ".join([expand_variables(ctx, exp, attribute_name = "env") for exp in expand_locations(ctx, value, ctx.attr.data).split(" ")]) return value -def _bash_quote(value): +def _quote(value): + """Quotes a string for either launcher. + + JSON encoding produces a literal that is valid in both bash double quotes and + JavaScript, so both launchers use it. + """ return json.encode(value) +def _append_segment(segments, text, expand): + """Appends a non-empty (text, expand) segment to a fixed_arg token.""" + if text: + return segments + [[text, expand]] + return segments + +def _shell_tokenize(value): + """Splits a fixed_arg the way bash does when it is spliced into an array literal. + + The bash launcher builds `ALL_ARGS=({{fixed_args}} "$@")`, so each fixed_arg is + subject to word splitting and quote removal. `$(rootpaths ...)` expanding to several + paths relies on the splitting and a single-quoted arg relies on the quote removal; + both are covered by //js/private/test/fixed_args. The JavaScript launcher has no + shell, so the same splitting is done here. + + Quote removal alone would lose the one thing the quotes were there to say: bash + expands `$VAR` inside double quotes and outside quotes, but not inside single quotes, + and that decision has to survive to the launcher, which does the expansion at run + time. So each token is emitted as a list of (text, expand) segments rather than as a + plain string. + + Backslash escapes are deliberately not interpreted (bash would have), so a + Windows-style path in a fixed_arg survives intact. The one place that still shows is + `\\$VAR`, which bash passes through literally and this launcher expands; quote it + instead if you want a literal `$`. + + Args: + value: the fixed_arg to split + + Returns: + a list of argv entries, each a list of [text, expand] segments to concatenate + """ + tokens = [] + segments = [] + current = "" + expand = True + has_token = False + quote = None + + for ch in value.elems(): + if quote: + if ch == quote: + segments = _append_segment(segments, current, expand) + current = "" + expand = True + quote = None + else: + current += ch + elif ch == "'" or ch == "\"": + segments = _append_segment(segments, current, expand) + current = "" + quote = ch + expand = ch == "\"" + has_token = True + elif ch == " " or ch == "\t" or ch == "\n" or ch == "\r": + if has_token: + tokens.append(_append_segment(segments, current, expand)) + segments = [] + current = "" + expand = True + has_token = False + else: + current += ch + has_token = True + if has_token: + tokens.append(_append_segment(segments, current, expand)) + return tokens + def _generates_coverage_report(ctx): """Whether the launcher generates the lcov report in the test action. See #2901.""" return (hasattr(ctx.file, "_coverage_report") and ctx.attr.testonly and ctx.configuration.coverage_enabled) -def _bash_launcher(ctx, nodeinfo, entry_point_path, log_prefix_rule_set, log_prefix_rule, fixed_args, fixed_env, is_windows): +def _launcher_envs(ctx, fixed_env, is_windows): + """The environment the launcher sets, as (var, value, iff_not_set) triples. + + Shared by both launcher implementations so that the two cannot drift; each formats + the triples in its own syntax. Order is significant: a value may reference an earlier + one, which both launchers expand as they go. + + Args: + ctx: the rule context + fixed_env: environment supplied by the caller of create_launcher + is_windows: whether the target platform is Windows + + Returns: + an (envs, normalized_chdir) tuple + """ + # Explicitly disable node fs patches on Windows: # https://github.com/aspect-build/rules_js/issues/1137 if is_windows: fixed_env = dict(fixed_env, **{"JS_BINARY__PATCH_NODE_FS": "0"}) envs = [ - _ENV_SET.format(var = key, quoted_value = _bash_quote(_expand_env_if_needed(ctx, value))) + (key, _expand_env_if_needed(ctx, value), False) for key, value in fixed_env.items() ] + [ - _ENV_SET.format(var = key, quoted_value = _bash_quote(_expand_env_if_needed(ctx, value))) + (key, _expand_env_if_needed(ctx, value), False) for key, value in ctx.attr.env.items() ] @@ -323,10 +442,7 @@ def _bash_launcher(ctx, nodeinfo, entry_point_path, log_prefix_rule_set, log_pre "JS_BINARY__TARGET_CPU": "$(TARGET_CPU)", } for (key, value) in makevars.items(): - envs.append(_ENV_SET.format( - var = key, - quoted_value = _bash_quote(ctx.expand_make_variables("env", value, {})), - )) + envs.append((key, ctx.expand_make_variables("env", value, {}), False)) # Add rule context variables to the environment builtins = { @@ -343,45 +459,48 @@ def _bash_launcher(ctx, nodeinfo, entry_point_path, log_prefix_rule_set, log_pre if is_windows and not ctx.attr.enable_runfiles: builtins["JS_BINARY__NO_RUNFILES"] = "1" for (key, value) in builtins.items(): - envs.append(_ENV_SET.format(var = key, quoted_value = _bash_quote(value))) + envs.append((key, value, False)) if ctx.attr.patch_node_fs: # Set patch node fs API env if not already set to allow js_run_binary to override - envs.append(_ENV_SET_IFF_NOT_SET.format( - var = "JS_BINARY__PATCH_NODE_FS", - quoted_value = _bash_quote("1"), - )) + envs.append(("JS_BINARY__PATCH_NODE_FS", "1", True)) if ctx.attr.expected_exit_code: - envs.append(_ENV_SET.format( - var = "JS_BINARY__EXPECTED_EXIT_CODE", - quoted_value = _bash_quote(str(ctx.attr.expected_exit_code)), - )) + envs.append(("JS_BINARY__EXPECTED_EXIT_CODE", str(ctx.attr.expected_exit_code), False)) if ctx.attr.copy_data_to_bin: # Set an environment variable to flag that we have copied js_binary data to bin - envs.append(_ENV_SET.format(var = "JS_BINARY__COPY_DATA_TO_BIN", quoted_value = _bash_quote("1"))) + envs.append(("JS_BINARY__COPY_DATA_TO_BIN", "1", False)) normalized_chdir = "" if ctx.attr.chdir: # Set chdir env if not already set to allow js_run_binary to override normalized_chdir = normalize_chdir(_expand_env_if_needed(ctx, ctx.attr.chdir), ctx.label.repo_name) - envs.append(_ENV_SET_IFF_NOT_SET.format(var = "JS_BINARY__CHDIR", quoted_value = _bash_quote(normalized_chdir))) + envs.append(("JS_BINARY__CHDIR", normalized_chdir, True)) # Set log envs iff not already set to allow js_run_binary to override for env in envs_for_log_level(ctx.attr.log_level): - envs.append(_ENV_SET_IFF_NOT_SET.format(var = env, quoted_value = _bash_quote("1"))) + envs.append((env, "1", True)) - node_options = [] - for node_option in ctx.attr.node_options: - node_options.append(_NODE_OPTION.format(value = _expand_env_if_needed(ctx, node_option))) - if ctx.attr.preserve_symlinks_main and "--preserve-symlinks-main" not in node_options: - node_options.append(_NODE_OPTION.format(value = "--preserve-symlinks-main")) + if _generates_coverage_report(ctx): + envs.append(( + "JS_BINARY__COVERAGE_REPORT", + "/".join([ctx.workspace_name, ctx.file._coverage_report.short_path]), + False, + )) - if ctx.attr.expand_args: - fixed_args = [expand_variables(ctx, expand_locations(ctx, fixed_arg, ctx.attr.data)) for fixed_arg in fixed_args] + return envs, normalized_chdir +def _launcher_node_options(ctx): + """The node CLI options the launcher passes, in order.""" + node_options = [_expand_env_if_needed(ctx, node_option) for node_option in ctx.attr.node_options] + if ctx.attr.preserve_symlinks_main: + node_options.append("--preserve-symlinks-main") + return node_options + +def _launcher_paths(ctx, nodeinfo, is_windows): + """The toolchain paths both launchers bake in, and the files that back them.""" node_wrapper = ctx.file._node_wrapper_bat if is_windows else ctx.file._node_wrapper_sh toolchain_files = [node_wrapper] @@ -393,30 +512,40 @@ def _bash_launcher(ctx, nodeinfo, entry_point_path, log_prefix_rule_set, log_pre npm_wrapper_path = npm_wrapper.short_path toolchain_files.append(npm_wrapper) - node_path = nodeinfo.node.short_path if nodeinfo.node else nodeinfo.node_path - - if _generates_coverage_report(ctx): - envs.append(_ENV_SET.format( - var = "JS_BINARY__COVERAGE_REPORT", - quoted_value = _bash_quote("/".join([ctx.workspace_name, ctx.file._coverage_report.short_path])), - )) + return struct( + node_path = nodeinfo.node.short_path if nodeinfo.node else nodeinfo.node_path, + node_wrapper_path = node_wrapper.short_path, + npm_path = npm_path, + npm_wrapper_path = npm_wrapper_path, + toolchain_files = toolchain_files, + ) +def _bash_launcher(ctx, entry_point_path, log_prefix_rule_set, log_prefix_rule, fixed_args, envs, node_options, paths): launcher_subst = { "{{target_label}}": str(ctx.label), "{{template_label}}": str(ctx.attr._launcher_template.label), "{{entry_point_label}}": str(ctx.attr.entry_point.label), "{{entry_point_path}}": entry_point_path, - "{{envs}}": "\n".join(envs), + "{{envs}}": "\n".join([ + (_ENV_SET_IFF_NOT_SET if iff_not_set else _ENV_SET).format( + var = var, + quoted_value = _quote(value), + ) + for (var, value, iff_not_set) in envs + ]), "{{fixed_args}}": " ".join(fixed_args), "{{initialize_runfiles}}": BASH_INITIALIZE_RUNFILES, "{{log_prefix_rule_set}}": log_prefix_rule_set, "{{log_prefix_rule}}": log_prefix_rule, - "{{node_options}}": "\n".join(node_options), + "{{node_options}}": "\n".join([ + _NODE_OPTION.format(value = value) + for value in node_options + ]), "{{node_patches}}": ctx.file._node_patches.short_path, - "{{node_wrapper}}": node_wrapper.short_path, - "{{node}}": node_path, - "{{npm}}": npm_path, - "{{npm_wrapper}}": npm_wrapper_path, + "{{node_wrapper}}": paths.node_wrapper_path, + "{{node}}": paths.node_path, + "{{npm}}": paths.npm_path, + "{{npm_wrapper}}": paths.npm_wrapper_path, "{{workspace_name}}": ctx.workspace_name, } @@ -431,7 +560,142 @@ def _bash_launcher(ctx, nodeinfo, entry_point_path, log_prefix_rule_set, log_pre is_executable = True, ) - return launcher, toolchain_files, normalized_chdir + return launcher + +def _compile_stub(ctx, embedded_args, transformed_args, output_file): + """Stamps a launcher binary from the prebuilt template stub. + + This is `hermetic_launcher.compile_stub` reimplemented so the finalizer toolchain can + be named by Label; see the comment on _FINALIZER_TOOLCHAIN_TYPE. Drop this in favour + of the upstream helper once it takes Labels. + """ + template = ctx.toolchains[_TEMPLATE_TOOLCHAIN_TYPE].templatetoolchaininfo.template_exe + args = ctx.actions.args() + args.add("--template", template) + args.add("-o", output_file) + args.add_joined("--transform", transformed_args, join_with = ",") + args.add("--") + args.add_all(embedded_args) + ctx.actions.run( + outputs = [output_file], + executable = ctx.toolchains[_FINALIZER_TOOLCHAIN_TYPE].finalizer_info.finalizer, + arguments = [args], + inputs = [template], + toolchain = _FINALIZER_TOOLCHAIN_TYPE, + mnemonic = "JsLauncher", + progress_message = "Stamping launcher %{output}", + ) + +def _is_absolute_path(path): + """Whether a node_toolchain's target_tool_path names an absolute path. + + Mirrors the launcher's own path.isAbsolute() test, which on Windows also accepts a + drive-letter prefix. Getting this wrong would embed a path like `_main/C:/nodejs/node.exe` + in the stub, which resolves through the runfiles to nothing. + """ + if path.startswith("/") or path.startswith("\\"): + return True + + # A drive letter, e.g. `C:\nodejs\node.exe` or `C:/nodejs/node.exe`. + return len(path) > 2 and path[1] == ":" and path[2] in ["/", "\\"] + +def _js_launcher(ctx, nodeinfo, entry_point_path, log_prefix_rule_set, log_prefix_rule, fixed_args, envs, node_options, paths, is_windows): + """The hermetic launcher: a native stub that execs node on a generated JavaScript launcher. + + The stub can only execve, so everything the bash launcher did in shell is done by the + generated `.cjs` instead -- an almost literal translation of js_binary.sh.tpl, minus + the stdout/stderr/exit code capture and silent_on_success that js_run_binary no longer + asks the launcher for. See docs/hermetic_launcher.md. + + Returns: + an (executable, launcher_js) tuple + """ + launcher_js = ctx.actions.declare_file("{}_/{}.cjs".format(ctx.label.name, ctx.label.name)) + ctx.actions.expand_template( + template = ctx.file._launcher_js_template, + output = launcher_js, + substitutions = { + "{{target_label}}": str(ctx.label), + "{{template_label}}": str(ctx.attr._launcher_js_template.label), + "{{entry_point_label}}": str(ctx.attr.entry_point.label), + "{{entry_point_path}}": _quote(entry_point_path), + "{{envs}}": "\n".join([ + (_ENV_SET_IFF_NOT_SET_JS if iff_not_set else _ENV_SET_JS).format( + quoted_var = _quote(var), + quoted_value = _quote(value), + ) + for (var, value, iff_not_set) in envs + ]), + # Tokenized here rather than in the launcher, which has no shell to do the + # word splitting and quote removal the bash launcher got for free. + "{{fixed_args}}": json.encode([ + token + for fixed_arg in fixed_args + for token in _shell_tokenize(fixed_arg) + ]), + "{{log_prefix_rule_set}}": _quote(log_prefix_rule_set), + "{{log_prefix_rule}}": _quote(log_prefix_rule), + "{{node_options}}": "\n".join([ + _NODE_OPTION_JS.format(quoted_value = _quote(value)) + for value in node_options + ]), + "{{node_patches}}": _quote(ctx.file._node_patches.short_path), + "{{node_wrapper}}": _quote(paths.node_wrapper_path), + "{{node}}": _quote(paths.node_path), + "{{npm}}": _quote(paths.npm_path), + "{{npm_wrapper}}": _quote(paths.npm_wrapper_path), + "{{workspace_name}}": _quote(ctx.workspace_name), + }, + ) + + if not ctx.toolchains[_TEMPLATE_TOOLCHAIN_TYPE] or not ctx.toolchains[_FINALIZER_TOOLCHAIN_TYPE]: + launcher = ctx.actions.declare_file("{}_/{}".format(ctx.label.name, ctx.label.name)) + ctx.actions.write( + output = launcher, + content = _NO_LAUNCHER_PLACEHOLDER.format(target = ctx.label), + is_executable = True, + ) + return launcher, launcher_js + + # The stub embeds two arguments: node, and the JavaScript launcher it runs. Both are + # rlocation paths, which carry no output-tree configuration segment and so stay + # correct under path mapping. + if nodeinfo.node: + embedded_args, transformed_args = hermetic_launcher.args_from_entrypoint( + executable_file = nodeinfo.node, + ) + elif _is_absolute_path(paths.node_path): + # A node_toolchain may name a non-hermetic node by absolute path rather than + # provide a File. The stub passes absolute paths through untouched, so there is + # nothing for it to resolve. + embedded_args, transformed_args = [paths.node_path], [] + else: + # A relative node_path is relative to this workspace within the runfiles tree, + # matching how the launcher resolves it. + embedded_args, transformed_args = hermetic_launcher.append_raw_transformed_arg( + arg = "{}/{}".format(ctx.workspace_name, paths.node_path), + embedded_args = [], + transformed_args = [], + ) + embedded_args, transformed_args = hermetic_launcher.append_runfile( + file = launcher_js, + embedded_args = embedded_args, + transformed_args = transformed_args, + ) + + # args_from_entrypoint yields ints while the append helpers yield strings; ctx.args + # only takes strings. + transformed_args = [str(index) for index in transformed_args] + + # Windows dispatches on the file extension, so the stub needs the .exe suffix to be + # executable at all. + launcher = ctx.actions.declare_file("{}_/{}{}".format( + ctx.label.name, + ctx.label.name, + ".exe" if is_windows else "", + )) + _compile_stub(ctx, embedded_args, transformed_args, launcher) + return launcher, launcher_js def _create_launcher(ctx, log_prefix_rule_set, log_prefix_rule, fixed_args = [], fixed_env = {}): is_windows = ctx.target_platform_has_constraint(ctx.attr._windows_constraint[platform_common.ConstraintValueInfo]) @@ -453,11 +717,24 @@ def _create_launcher(ctx, log_prefix_rule_set, log_prefix_rule, fixed_args = [], entry_point = ctx.files.entry_point[0] entry_point_path = entry_point.short_path - bash_launcher, toolchain_files, chdir = _bash_launcher(ctx, nodeinfo, entry_point_path, log_prefix_rule_set, log_prefix_rule, fixed_args, fixed_env, is_windows) - launcher = create_windows_native_launcher_script(ctx, bash_launcher) if is_windows else bash_launcher + # Expanded here rather than in either launcher, so that both bake in the same arguments. + if ctx.attr.expand_args: + fixed_args = [expand_variables(ctx, expand_locations(ctx, fixed_arg, ctx.attr.data)) for fixed_arg in fixed_args] + + envs, chdir = _launcher_envs(ctx, fixed_env, is_windows) + node_options = _launcher_node_options(ctx) + paths = _launcher_paths(ctx, nodeinfo, is_windows) + + launcher_js = None + if ctx.attr._hermetic_launcher[BuildSettingInfo].value: + launcher, launcher_js = _js_launcher(ctx, nodeinfo, entry_point_path, log_prefix_rule_set, log_prefix_rule, fixed_args, envs, node_options, paths, is_windows) + launcher_files = [launcher, launcher_js] + else: + bash_launcher = _bash_launcher(ctx, entry_point_path, log_prefix_rule_set, log_prefix_rule, fixed_args, envs, node_options, paths) + launcher = create_windows_native_launcher_script(ctx, bash_launcher) if is_windows else bash_launcher + launcher_files = [bash_launcher] - launcher_files = [bash_launcher] - launcher_files.extend(toolchain_files) + launcher_files.extend(paths.toolchain_files) if nodeinfo.node: launcher_files.append(nodeinfo.node) @@ -504,6 +781,10 @@ def _create_launcher(ctx, log_prefix_rule_set, log_prefix_rule, fixed_args = [], runfiles = runfiles, data_runfiles = data_runfiles, chdir = chdir, + # The generated JavaScript launcher, empty when the bash launcher is in use. Shaped + # as a depset so that a rule built on create_launcher can republish it verbatim in a + # `launcher_js` output group, which is how js_image_layer tells the launchers apart. + launcher_js = depset([launcher_js] if launcher_js else []), ) def _js_binary_impl(ctx): @@ -581,6 +862,10 @@ def _js_binary_impl(ctx): # toolchain scaffolding. Consumed by js_run_binary when # use_execroot_entry_point is enabled. execroot_data_files = launcher.data_runfiles.files, + # The generated JavaScript launcher, empty unless the hermetic launcher is + # selected. Consumed by js_image_layer, which has to rewrite it, and by the + # launcher snapshot test. + launcher_js = launcher.launcher_js, ), ] @@ -626,6 +911,10 @@ js_binary_lib = struct( toolchains = [ # Optional: only referenced on Windows config_common.toolchain_type("@bazel_tools//tools/sh:toolchain_type", mandatory = False), + # Optional: only referenced when the hermetic launcher is selected, and absent + # for a target platform hermetic_launcher publishes no stub for. + config_common.toolchain_type(_FINALIZER_TOOLCHAIN_TYPE, mandatory = False), + config_common.toolchain_type(_TEMPLATE_TOOLCHAIN_TYPE, mandatory = False), "@rules_nodejs//nodejs:runtime_toolchain_type", ] + COPY_FILE_TO_BIN_TOOLCHAINS, ) diff --git a/js/private/js_binary.cjs.tpl b/js/private/js_binary.cjs.tpl new file mode 100644 index 0000000000..7fe05337e5 --- /dev/null +++ b/js/private/js_binary.cjs.tpl @@ -0,0 +1,681 @@ +// This JavaScript file is the launcher for the NodeJS JavaScript file +// entry point with the following bazel label: +// {{entry_point_label}} +// +// The launcher was generated to execute the js_binary target +// {{target_label}} +// +// The template used to generate this launcher is +// {{template_label}} + +'use strict' + +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +// ============================================================================== +// Values baked in at analysis time +// ============================================================================== + +const WORKSPACE_NAME = {{workspace_name}} +const ENTRY_POINT_PATH = {{entry_point_path}} +const NODE_PATH = {{node}} +const NPM_PATH = {{npm}} +const NPM_WRAPPER_PATH = {{npm_wrapper}} +const NODE_WRAPPER_PATH = {{node_wrapper}} +const NODE_PATCHES_PATH = {{node_patches}} +const LOG_PREFIX_RULE_SET = {{log_prefix_rule_set}} +const LOG_PREFIX_RULE = {{log_prefix_rule}} + +// ============================================================================== +// Helpers +// ============================================================================== + +const IS_WINDOWS = process.platform === 'win32' + +// Normalizes paths when running on Windows. +// +// Example: +// C:\Users\XUser\_bazel_XUser\7q7kkv32\execroot\A\b\C -> C:/Users/XUser/_bazel_XUser/7q7kkv32/execroot/A/b/C +// +// Only the separator changes. Node accepts forward slashes on Windows, so the separator +// rewrite is all that is needed and the comparisons below can stay written with '/'. +function normalizePath(p) { + if (!IS_WINDOWS) { + return p + } + return p.replace(/\\/g, '/') +} + +// process.cwd() reports the native separator on Windows, so it has to be +// normalized everywhere it is compared against or spliced into a path built with +// '/'. Not hoisted into a constant, because the launcher chdir()s further down. +function cwd() { + return normalizePath(process.cwd()) +} + +// The env values, node options, and fixed args below were spliced into +// double-quoted bash strings before this launcher was ported to JavaScript, so +// shell parameter expansion happened at launch time and users depend on it. For +// example, examples/stack_traces passes +// node_options = ["--require", "$$JS_BINARY__RUNFILES/$$JS_BINARY__WORKSPACE/..."]. +// Only $VAR / ${VAR} expansion is reproduced here; command substitution is not, +// and the result is not re-split on whitespace the way bash would have. +function expandEnvRefs(value) { + return value.replace( + /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g, + (_match, braced, bare) => process.env[braced || bare] || '' + ) +} + +function setEnv(name, value) { + process.env[name] = expandEnvRefs(value) +} + +function setEnvIfUnset(name, value) { + if (!process.env[name]) { + process.env[name] = expandEnvRefs(value) + } +} + +function isFile(p) { + try { + return fs.statSync(p).isFile() + } catch { + return false + } +} + +function isDirectory(p) { + try { + return fs.statSync(p).isDirectory() + } catch { + return false + } +} + +function isExecutable(p) { + try { + fs.accessSync(p, fs.constants.X_OK) + return true + } catch { + return false + } +} + +// ============================================================================== +// Environment +// ============================================================================== + +{{envs}} + +// ============================================================================== +// Handle --bazel-bindir flag +// ============================================================================== + +// If a --bazel-bindir flag is passed it must be the first two +// arguments. It is consumed by this launcher and used to set BAZEL_BINDIR, +// overriding any value already set in the environment. +const argv = process.argv.slice(2) +if (argv.length > 0 && argv[0] === '--bazel-bindir') { + if (argv.length < 2) { + fs.writeSync(2, 'ERROR: --bazel-bindir flag requires a value\n') + process.exit(1) + } + process.env.BAZEL_BINDIR = argv[1] + argv.splice(0, 2) +} + +// ============================================================================== +// Prepare logging +// ============================================================================== + +process.env.JS_BINARY__LOG_PREFIX = `${LOG_PREFIX_RULE_SET}[${LOG_PREFIX_RULE}]` + +// Emit a log line to stderr. +// +// We use fs.writeSync rather than console.error, so that the line is flushed before the +// execve() at the bottom replaces this process. +function logTo(level, message) { + const collapsed = message.trim().replace(/\s+/g, ' ') + fs.writeSync(2, `${level}: ${process.env.JS_BINARY__LOG_PREFIX}: ${collapsed}\n`) +} + +function logfFatal(message) { + if (process.env.JS_BINARY__LOG_FATAL) { + logTo('FATAL', message) + } +} + +function logfError(message) { + if (process.env.JS_BINARY__LOG_ERROR) { + logTo('ERROR', message) + } +} + +function logfInfo(message) { + if (process.env.JS_BINARY__LOG_INFO) { + logTo('INFO', message) + } +} + +function logfDebug(message) { + if (process.env.JS_BINARY__LOG_DEBUG) { + logTo('DEBUG', message) + } +} + +function resolveExecrootBinPath(shortPath) { + const bindir = process.env.BAZEL_BINDIR + if (shortPath.startsWith('../')) { + return `${process.env.JS_BINARY__EXECROOT}/${bindir}/external/${shortPath.slice(3)}` + } + return `${process.env.JS_BINARY__EXECROOT}/${bindir}/${shortPath}` +} + +function resolveExecrootSrcPath(shortPath) { + if (shortPath.startsWith('../')) { + return `${process.env.JS_BINARY__EXECROOT}/external/${shortPath.slice(3)}` + } + return `${process.env.JS_BINARY__EXECROOT}/${shortPath}` +} + +function exitWith(exitCode) { + logfDebug(`exit code: ${exitCode}`) + process.exit(exitCode) +} + +process.on('uncaughtException', (err) => { + logfFatal(String((err && err.message) || err)) + logfDebug(String((err && err.stack) || err)) + exitWith(1) +}) + +// Ends this process the way node ended, so that callers see a signal-terminated +// process rather than an interposed 128+N exit code. That is what they would +// have seen had this launcher been able to exec node instead of spawning it. +function reraiseSignal(signal, exitCode) { + logfDebug(`exit code: ${exitCode}`) + // Removing the last listener restores node's default disposition for the + // signal, so killing ourselves with it now terminates this process. + process.removeAllListeners('SIGTERM') + process.removeAllListeners('SIGINT') + process.kill(process.pid, signal) + // Only reached if the signal turned out not to be fatal after all. + process.exit(exitCode) +} + +// ============================================================================== +// Initialize RUNFILES environment variable +// ============================================================================== + +let runfiles = process.env.TEST_SRCDIR || process.env.RUNFILES_DIR +if (!runfiles && process.env.RUNFILES_MANIFEST_FILE) { + // Normalized before the suffix tests because on Windows Bazel hands out a + // backslash-separated path, which would not match '/MANIFEST'. + const manifest = normalizePath(process.env.RUNFILES_MANIFEST_FILE) + if (manifest.endsWith('.runfiles_manifest')) { + // Bazel puts the manifest besides the runfiles with the suffix + // .runfiles_manifest. For example, the runfiles directory is named + // my_binary.runfiles then the manifest is beside the runfiles directory + // and named my_binary.runfiles_manifest + runfiles = manifest.slice(0, -'_manifest'.length) + } else if (manifest.endsWith('/MANIFEST')) { + // Bazel for windows puts the manifest file named MANIFEST in the + // runfiles directory + runfiles = manifest.slice(0, -'/MANIFEST'.length) + } else { + logfFatal(`Unexpected RUNFILES_MANIFEST_FILE value ${manifest}`) + exitWith(1) + } +} +if (!runfiles) { + logfFatal('RUNFILES_DIR environment variable is not set') + exitWith(1) +} +runfiles = normalizePath(runfiles) +if (!path.isAbsolute(runfiles)) { + // Must be absolute: the runfiles path may be relative to the cwd, and we may + // be about to change directory. + runfiles = normalizePath(path.join(cwd(), runfiles)) +} +process.env.JS_BINARY__RUNFILES = runfiles +// Set RUNFILES_DIR if not already set so that tools such as @bazel/runfiles can +// locate runfiles. +process.env.RUNFILES_DIR = process.env.RUNFILES_DIR || runfiles + +// ============================================================================== +// Prepare to run main program +// ============================================================================== + +let bazelOutSegment +if (cwd().includes('/bazel-out/')) { + bazelOutSegment = '/bazel-out/' +} else if (cwd().includes('/BAZEL-~1/')) { + bazelOutSegment = '/BAZEL-~1/' +} else if (cwd().includes('/bazel-~1/')) { + bazelOutSegment = '/bazel-~1/' +} + +// When the cwd is a build action execroot the bindir hangs off it (BAZEL_BINDIR resolves from the +// cwd), so the cwd is the execroot even if its path contains a "bazel-out" segment (e.g. a matching +// output base). Otherwise scan the output tree for the execroot (runfiles, or a nested js_binary in +// the bindir). +if ( + bazelOutSegment && + (!process.env.BAZEL_BINDIR || + !isDirectory(path.join(cwd(), process.env.BAZEL_BINDIR))) +) { + if ( + process.env.JS_BINARY__USE_EXECROOT_ENTRY_POINT && + process.env.JS_BINARY__EXECROOT + ) { + logfDebug( + `inheriting JS_BINARY__EXECROOT ${process.env.JS_BINARY__EXECROOT} from parent js_binary process as JS_BINARY__USE_EXECROOT_ENTRY_POINT is set` + ) + } else { + // We are in runfiles and we don't yet know the execroot; strip from the last "bazel-out" segment + const index = cwd().lastIndexOf(bazelOutSegment) + if (index < 0) { + fs.writeSync( + 2, + `\nERROR: ${process.env.JS_BINARY__LOG_PREFIX}: No 'bazel-out' folder found in path '${cwd()}'\n` + ) + exitWith(1) + } + process.env.JS_BINARY__EXECROOT = cwd().slice(0, index) + } +} else { + if ( + process.env.JS_BINARY__USE_EXECROOT_ENTRY_POINT && + process.env.JS_BINARY__EXECROOT + ) { + logfDebug( + `inheriting JS_BINARY__EXECROOT ${process.env.JS_BINARY__EXECROOT} from parent js_binary process as JS_BINARY__USE_EXECROOT_ENTRY_POINT is set` + ) + } else { + // We are in execroot or in some other context all together such as a nodejs_image or a manually run js_binary + process.env.JS_BINARY__EXECROOT = cwd() + } + + if (!process.env.JS_BINARY__NO_CD_BINDIR) { + if (!process.env.BAZEL_BINDIR) { + logfFatal( + `BAZEL_BINDIR must be set in environment to the makevar $(BINDIR) in js_binary build actions (which +run in the execroot) so that build actions can change directories to always run out of the root of the Bazel output +tree. See https://docs.bazel.build/versions/main/be/make-variables.html#predefined_variables. This is automatically set +by 'js_run_binary' (https://github.com/aspect-build/rules_js/blob/main/docs/js_run_binary.md) which is the recommended +rule to use for using a js_binary as the tool of a build action. If you are invoking a js_binary directly from your own +custom rule implementation, use the 'js_binary_lib.run_binary_action' helper +(https://github.com/aspect-build/rules_js/blob/main/js/libs.bzl) instead of calling ctx.actions.run yourself so that +BAZEL_BINDIR is set correctly. If this is not a build action you can set the +BAZEL_BINDIR to '.' instead to supress this error. For more context on this design decision, please read the +aspect_rules_js README https://github.com/aspect-build/rules_js/tree/dbb5af0d2a9a2bb50e4cf4a96dbc582b27567155#running-nodejs-programs.` + ) + exitWith(1) + } + + // Since the process was launched in the execroot, we automatically change directory into the root of the + // output tree (which we expect to be set in BAZEL_BINDIR). See + // https://github.com/aspect-build/rules_js/tree/dbb5af0d2a9a2bb50e4cf4a96dbc582b27567155#running-nodejs-programs + // for more context on why we do this. + logfDebug( + `changing directory to BAZEL_BINDIR (root of Bazel output tree) ${process.env.BAZEL_BINDIR}` + ) + process.chdir(process.env.BAZEL_BINDIR) + process.env.PWD = process.cwd() + } +} + +if (process.env.JS_BINARY__USE_EXECROOT_ENTRY_POINT) { + if (!process.env.BAZEL_BINDIR) { + logfFatal( + 'Expected BAZEL_BINDIR to be set when JS_BINARY__USE_EXECROOT_ENTRY_POINT is set' + ) + exitWith(1) + } + if ( + !process.env.JS_BINARY__COPY_DATA_TO_BIN && + !process.env.JS_BINARY__ALLOW_EXECROOT_ENTRY_POINT_WITH_NO_COPY_DATA_TO_BIN + ) { + logfFatal( + `Expected js_binary copy_data_to_bin to be True when js_run_binary use_execroot_entry_point is True. +To disable this validation you can set allow_execroot_entry_point_with_no_copy_data_to_bin to True in js_run_binary` + ) + exitWith(1) + } +} + +if (process.env.JS_BINARY__NO_RUNFILES) { + if ( + !process.env.JS_BINARY__COPY_DATA_TO_BIN && + !process.env.JS_BINARY__ALLOW_EXECROOT_ENTRY_POINT_WITH_NO_COPY_DATA_TO_BIN + ) { + logfFatal( + `Expected js_binary copy_data_to_bin to be True when js_binary use_execroot_entry_point is True. +To disable this validation you can set allow_execroot_entry_point_with_no_copy_data_to_bin to True in js_run_binary` + ) + exitWith(1) + } +} + +let entryPoint +if ( + process.env.JS_BINARY__USE_EXECROOT_ENTRY_POINT || + process.env.JS_BINARY__NO_RUNFILES +) { + entryPoint = resolveExecrootBinPath(ENTRY_POINT_PATH) +} else { + entryPoint = `${process.env.JS_BINARY__RUNFILES}/${WORKSPACE_NAME}/${ENTRY_POINT_PATH}` +} +if (!isFile(entryPoint)) { + logfFatal(`the entry_point '${entryPoint}' not found`) + exitWith(1) +} + +const node = normalizePath(NODE_PATH) +if (path.isAbsolute(node)) { + // A user may specify an absolute path to node using target_tool_path in node_toolchain + process.env.JS_BINARY__NODE_BINARY = node +} else if (process.env.JS_BINARY__NO_RUNFILES) { + process.env.JS_BINARY__NODE_BINARY = resolveExecrootSrcPath(NODE_PATH) +} else { + process.env.JS_BINARY__NODE_BINARY = `${process.env.JS_BINARY__RUNFILES}/${WORKSPACE_NAME}/${NODE_PATH}` +} +if (!isFile(process.env.JS_BINARY__NODE_BINARY)) { + logfFatal(`node binary '${process.env.JS_BINARY__NODE_BINARY}' not found`) + exitWith(1) +} +if (!IS_WINDOWS && !isExecutable(process.env.JS_BINARY__NODE_BINARY)) { + logfFatal(`node binary '${process.env.JS_BINARY__NODE_BINARY}' is not executable`) + exitWith(1) +} + +let npmBinDir +if (NPM_PATH) { + const npmPath = normalizePath(NPM_PATH) + if (path.isAbsolute(npmPath)) { + // A user may specify an absolute path to npm using npm_path in node_toolchain + process.env.JS_BINARY__NPM_BINARY = npmPath + } else if (process.env.JS_BINARY__NO_RUNFILES) { + process.env.JS_BINARY__NPM_BINARY = resolveExecrootSrcPath(NPM_PATH) + } else { + process.env.JS_BINARY__NPM_BINARY = `${process.env.JS_BINARY__RUNFILES}/${WORKSPACE_NAME}/${NPM_PATH}` + } + if (!isFile(process.env.JS_BINARY__NPM_BINARY)) { + logfFatal(`npm binary '${process.env.JS_BINARY__NPM_BINARY}' not found`) + exitWith(1) + } + if (!IS_WINDOWS && !isExecutable(process.env.JS_BINARY__NPM_BINARY)) { + logfFatal(`npm binary '${process.env.JS_BINARY__NPM_BINARY}' is not executable`) + exitWith(1) + } + + let npmWrapper + if (process.env.JS_BINARY__NO_RUNFILES) { + npmWrapper = resolveExecrootSrcPath(NPM_WRAPPER_PATH) + } else { + npmWrapper = `${process.env.JS_BINARY__RUNFILES}/${WORKSPACE_NAME}/${NPM_WRAPPER_PATH}` + } + if (!isFile(npmWrapper)) { + logfFatal(`npm wrapper '${npmWrapper}' not found`) + exitWith(1) + } + if (!IS_WINDOWS && !isExecutable(npmWrapper)) { + logfFatal(`npm wrapper '${npmWrapper}' is not executable`) + exitWith(1) + } + npmBinDir = path.dirname(npmWrapper) +} + +if (process.env.JS_BINARY__NO_RUNFILES) { + process.env.JS_BINARY__NODE_WRAPPER = resolveExecrootSrcPath(NODE_WRAPPER_PATH) +} else { + process.env.JS_BINARY__NODE_WRAPPER = `${process.env.JS_BINARY__RUNFILES}/${WORKSPACE_NAME}/${NODE_WRAPPER_PATH}` +} +if (!isFile(process.env.JS_BINARY__NODE_WRAPPER)) { + logfFatal(`node wrapper '${process.env.JS_BINARY__NODE_WRAPPER}' not found`) + exitWith(1) +} +if (!IS_WINDOWS && !isExecutable(process.env.JS_BINARY__NODE_WRAPPER)) { + logfFatal(`node wrapper '${process.env.JS_BINARY__NODE_WRAPPER}' is not executable`) + exitWith(1) +} + +if (process.env.JS_BINARY__NO_RUNFILES) { + process.env.JS_BINARY__NODE_PATCHES = resolveExecrootSrcPath(NODE_PATCHES_PATH) +} else { + process.env.JS_BINARY__NODE_PATCHES = `${process.env.JS_BINARY__RUNFILES}/${WORKSPACE_NAME}/${NODE_PATCHES_PATH}` +} +if (!isFile(process.env.JS_BINARY__NODE_PATCHES)) { + logfFatal(`node patches '${process.env.JS_BINARY__NODE_PATCHES}' not found`) + exitWith(1) +} + +// Gather node options +const nodeOptions = [] +function addNodeOption(value) { + nodeOptions.push(expandEnvRefs(value)) +} +{{node_options}} + +// fixed_args were tokenized at analysis time, each token as a list of +// [text, expand] segments: bash removed the quotes but this launcher still has to +// know which of them were single quotes, since those are the ones whose $VAR the +// shell would not have expanded. Expansion itself happens now, at run time. +const FIXED_ARGS = {{fixed_args}}.map((segments) => + segments.map(([text, expand]) => (expand ? expandEnvRefs(text) : text)).join('') +) + +const args = [] +for (const arg of [...FIXED_ARGS, ...argv]) { + if (arg.startsWith('--node_options=')) { + // Let users pass through arguments to node itself + nodeOptions.push(arg.slice('--node_options='.length)) + } else { + // Remaining argv is collected to pass to the program + args.push(arg) + } +} + +// Configure JS_BINARY__FS_PATCH_ROOTS for node fs patches which are run via --require below. +// Don't override JS_BINARY__FS_PATCH_ROOTS if already set by an outer js_binary incase a js_binary such +// as js_run_deverser runs another js_binary tool. +if (!process.env.JS_BINARY__FS_PATCH_ROOTS) { + process.env.JS_BINARY__FS_PATCH_ROOTS = `${process.env.JS_BINARY__EXECROOT}:${process.env.JS_BINARY__RUNFILES}` +} + +// Disable Node's module compile cache by default (aspect-build/rules_js#2937). +// We will re-enable it at runtime if NODE_COMPILE_CACHE is set. +process.env.NODE_DISABLE_COMPILE_CACHE = '1' + +// Put the node wrapper directory and optionally the npm wrapper directory on the path so that +// child processes can find them. +const currentPath = process.env.PATH || '' +if (npmBinDir) { + process.env.PATH = `${npmBinDir}${path.delimiter}${currentPath}` +} +process.env.PATH = `${path.dirname(process.env.JS_BINARY__NODE_WRAPPER)}${path.delimiter}${process.env.PATH}` + +// Debug logs +if (process.env.JS_BINARY__LOG_DEBUG) { + logfDebug(`PATH ${process.env.PATH}`) + if (process.env.BAZEL_BINDIR) { + logfDebug(`BAZEL_BINDIR ${process.env.BAZEL_BINDIR}`) + } + if (process.env.BAZEL_BUILD_FILE_PATH) { + logfDebug(`BAZEL_BUILD_FILE_PATH ${process.env.BAZEL_BUILD_FILE_PATH}`) + } + if (process.env.BAZEL_COMPILATION_MODE) { + logfDebug(`BAZEL_COMPILATION_MODE ${process.env.BAZEL_COMPILATION_MODE}`) + } + if (process.env.BAZEL_INFO_FILE) { + logfDebug(`BAZEL_INFO_FILE ${process.env.BAZEL_INFO_FILE}`) + } + if (process.env.BAZEL_PACKAGE) { + logfDebug(`BAZEL_PACKAGE ${process.env.BAZEL_PACKAGE}`) + } + if (process.env.BAZEL_TARGET_CPU) { + logfDebug(`BAZEL_TARGET_CPU ${process.env.BAZEL_TARGET_CPU}`) + } + if (process.env.BAZEL_TARGET_NAME) { + logfDebug(`BAZEL_TARGET_NAME ${process.env.BAZEL_TARGET_NAME}`) + } + if (process.env.BAZEL_VERSION_FILE) { + logfDebug(`BAZEL_VERSION_FILE ${process.env.BAZEL_VERSION_FILE}`) + } + if (process.env.BAZEL_WORKSPACE) { + logfDebug(`BAZEL_WORKSPACE ${process.env.BAZEL_WORKSPACE}`) + } + logfDebug(`JS_BINARY__FS_PATCH_ROOTS ${process.env.JS_BINARY__FS_PATCH_ROOTS || ''}`) + logfDebug(`JS_BINARY__NODE_PATCHES ${process.env.JS_BINARY__NODE_PATCHES || ''}`) + logfDebug(`JS_BINARY__NODE_OPTIONS ${nodeOptions.join(' ')}`) + logfDebug(`JS_BINARY__BINDIR ${process.env.JS_BINARY__BINDIR || ''}`) + logfDebug(`JS_BINARY__BUILD_FILE_PATH ${process.env.JS_BINARY__BUILD_FILE_PATH || ''}`) + logfDebug(`JS_BINARY__COMPILATION_MODE ${process.env.JS_BINARY__COMPILATION_MODE || ''}`) + logfDebug(`JS_BINARY__NODE_BINARY ${process.env.JS_BINARY__NODE_BINARY || ''}`) + logfDebug(`JS_BINARY__NODE_WRAPPER ${process.env.JS_BINARY__NODE_WRAPPER || ''}`) + if (process.env.JS_BINARY__NPM_BINARY) { + logfDebug(`JS_BINARY__NPM_BINARY ${process.env.JS_BINARY__NPM_BINARY}`) + } + if (process.env.JS_BINARY__NO_RUNFILES) { + logfDebug(`JS_BINARY__NO_RUNFILES ${process.env.JS_BINARY__NO_RUNFILES}`) + } + logfDebug(`JS_BINARY__PACKAGE ${process.env.JS_BINARY__PACKAGE || ''}`) + logfDebug(`JS_BINARY__TARGET_CPU ${process.env.JS_BINARY__TARGET_CPU || ''}`) + logfDebug(`JS_BINARY__TARGET_NAME ${process.env.JS_BINARY__TARGET_NAME || ''}`) + logfDebug(`JS_BINARY__WORKSPACE ${process.env.JS_BINARY__WORKSPACE || ''}`) + logfDebug(`js_binary entry point ${entryPoint}`) + if (process.env.JS_BINARY__USE_EXECROOT_ENTRY_POINT) { + logfDebug( + `JS_BINARY__USE_EXECROOT_ENTRY_POINT ${process.env.JS_BINARY__USE_EXECROOT_ENTRY_POINT}` + ) + } +} + +// Info logs +if (process.env.JS_BINARY__LOG_INFO) { + if (process.env.BAZEL_TARGET) { + logfInfo(`BAZEL_TARGET ${process.env.BAZEL_TARGET}`) + } + logfInfo(`JS_BINARY__TARGET ${process.env.JS_BINARY__TARGET || ''}`) + logfInfo(`JS_BINARY__RUNFILES ${process.env.JS_BINARY__RUNFILES || ''}`) + logfInfo(`JS_BINARY__EXECROOT ${process.env.JS_BINARY__EXECROOT || ''}`) + logfInfo(`PWD ${cwd()}`) +} + +// ============================================================================== +// Run the main program +// ============================================================================== + +// We invoke node directly rather than through JS_BINARY__NODE_WRAPPER. This +// way we avoid spawning an extra bash process on every launch. The wrapper is +// still put on the PATH as `node` so that child processes get the patched +// runtime. + +const nodeArgs = [ + '--require', + process.env.JS_BINARY__NODE_PATCHES, + ...nodeOptions, + '--', + entryPoint, + ...args, +] + +if (process.env.JS_BINARY__LOG_INFO) { + logfInfo(['running', process.env.JS_BINARY__NODE_BINARY, ...nodeArgs].join(' ')) +} + +const expectedExitCode = process.env.JS_BINARY__EXPECTED_EXIT_CODE + +if (!expectedExitCode) { + // Nothing must run after node exits, so replace this process with node. + // Signals and terminal control are then delivered directly to node instead + // of being proxied through a child process, and no launcher process is left + // behind. + // + // process.execve is POSIX-only and was added in Node 22.15; when it is + // unavailable we fall through to spawning node below. + if (typeof process.execve === 'function') { + try { + process.execve( + process.env.JS_BINARY__NODE_BINARY, + [process.env.JS_BINARY__NODE_BINARY, ...nodeArgs], + { ...process.env } + ) + } catch (e) { + logfDebug(`process.execve failed (${e.message}); falling back to spawn`) + } + } +} + +// Reached when this launcher has to outlive the program: an expected exit code has to be +// compared against once the program is done, and a Node before 22.15, or any Node on +// Windows, has no process.execve to replace this process with. +const { spawn } = require('node:child_process') +const child = spawn(process.env.JS_BINARY__NODE_BINARY, nodeArgs, { + stdio: 'inherit', +}) + +// ============================================================================== +// Wait for program to finish +// ============================================================================== + +// Node does not forward termination signals to any child process, so the +// signals are trapped and forwarded manually. The handlers are removed on the +// first signal so that a second one terminates this launcher. +function forwardSignal(signal) { + return () => { + process.removeAllListeners('SIGTERM') + process.removeAllListeners('SIGINT') + try { + child.kill(signal) + } catch { + // the child already exited + } + } +} +process.on('SIGTERM', forwardSignal('SIGTERM')) +process.on('SIGINT', forwardSignal('SIGINT')) + +child.on('error', (err) => { + logfFatal( + `failed to spawn node binary '${process.env.JS_BINARY__NODE_BINARY}': ${err.message}` + ) + exitWith(127) +}) + +child.on('exit', (code, signal) => { + const result = + signal !== null && signal !== undefined + ? 128 + (os.constants.signals[signal] || 0) + : code + + // ============================================================================== + // Mop up after main program + // ============================================================================== + + if (expectedExitCode) { + if (String(result) !== String(expectedExitCode)) { + logfError( + `expected exit code to be '${expectedExitCode}', but got '${result}'` + ) + if (result === 0) { + // This exit code is handled specially by Bazel: + // https://github.com/bazelbuild/bazel/blob/486206012a664ecb20bdb196a681efc9a9825049/src/main/java/com/google/devtools/build/lib/util/ExitCode.java#L44 + const BAZEL_EXIT_TESTS_FAILED = 3 + exitWith(BAZEL_EXIT_TESTS_FAILED) + } + exitWith(result) + } else { + exitWith(0) + } + } + + if (signal) { + reraiseSignal(signal, result) + } else { + exitWith(result) + } +}) diff --git a/js/private/js_image_layer.bzl b/js/private/js_image_layer.bzl index af8a59e385..e29132b1a4 100644 --- a/js/private/js_image_layer.bzl +++ b/js/private/js_image_layer.bzl @@ -193,7 +193,50 @@ export BAZEL_BINDIR="." # patched by js_image_layer for hermeticity """ -def _write_laucher(ctx, real_binary): +# The JavaScript launcher's equivalent. +_JS_LAUNCHER_PREAMBLE = """\ +'use strict' + +// patched by js_image_layer for hermeticity +process.env.BAZEL_BINDIR = '.'""" + +def _launcher_js(binary): + """The generated JavaScript launcher of a js_binary, or None when it uses the bash launcher.""" + if OutputGroupInfo not in binary or not hasattr(binary[OutputGroupInfo], "launcher_js"): + fail("""{}: not a js_binary. + +The binary attribute of js_image_layer takes a js_binary, or a custom rule built on +js_binary_lib.create_launcher that republishes its launcher_js in an output group: + + OutputGroupInfo(launcher_js = launcher.launcher_js)""".format(binary.label)) + launchers = binary[OutputGroupInfo].launcher_js.to_list() + if not launchers: + # The bash launcher is in use, which is the js_binary's executable. + return None + if len(launchers) != 1: + fail("expected {} to have exactly one launcher_js file, got {}".format(binary.label, launchers)) + return launchers[0] + +def _write_js_launcher(ctx, launcher_js): + "Sanitizes the JavaScript launcher the way _write_launcher does the bash one." + launcher = ctx.actions.declare_file("%s_launcher.cjs" % ctx.label.name) + + substitutions = { + "'use strict'": _JS_LAUNCHER_PREAMBLE, + 'setEnv("JS_BINARY__BINDIR", "%s")' % launcher_js.root.path: 'setEnv("JS_BINARY__BINDIR", process.cwd())', + 'setEnv("JS_BINARY__TARGET_CPU", "%s")' % ctx.expand_make_variables("", "$(TARGET_CPU)", {}): 'setEnv("JS_BINARY__TARGET_CPU", os.machine())', + } + substitutions['setEnv("JS_BINARY__BINDIR", "%s")' % ctx.bin_dir.path] = 'setEnv("JS_BINARY__BINDIR", process.cwd())' + + ctx.actions.expand_template( + template = launcher_js, + output = launcher, + substitutions = substitutions, + is_executable = True, + ) + return launcher + +def _write_launcher(ctx, real_binary): "Creates a call-through shell entrypoint which sets BAZEL_BINDIR to '.' then immediately invokes the original entrypoint." launcher = ctx.actions.declare_file("%s_launcher" % ctx.label.name) @@ -341,7 +384,18 @@ def _js_image_layer_impl(ctx): binary_path = "./" + paths.join(ctx.attr.root.lstrip("./").lstrip("/"), binary_label.package, binary_label.name) runfiles_dir = binary_path + ".runfiles" - launcher = _write_laucher(ctx, binary_default_info.files_to_run.executable) + # The hermetic launcher's executable is a native binary with nothing in it to + # sanitize; the non-reproducible values live in the JavaScript launcher it runs, which + # is in the runfiles rather than at the image entry point. + launcher_js = _launcher_js(ctx.attr.binary[0]) + if launcher_js: + launcher = _write_js_launcher(ctx, launcher_js) + sanitized_original = launcher_js + entry_point_file = binary_default_info.files_to_run.executable + else: + launcher = _write_launcher(ctx, binary_default_info.files_to_run.executable) + sanitized_original = binary_default_info.files_to_run.executable + entry_point_file = launcher repo_mapping = _repo_mapping_manifest(binary_default_info.files_to_run) @@ -399,7 +453,7 @@ def _js_image_layer_impl(ctx): entries.add("{") entries.add_joined( - [binary_path, {"dest": launcher.path, "root": launcher.root.path}], + [binary_path, {"dest": entry_point_file.path, "root": entry_point_file.root.path}], join_with = ":", map_each = json.encode, ) @@ -412,8 +466,9 @@ def _js_image_layer_impl(ctx): ) entries.add(",") - # shell launcher generated by js_binary contains non-reproducible information swap it out with the sanitized one. - binary_path_under_runfiles = runfiles_dir + "/" + _to_rlocation_path(binary_default_info.files_to_run.executable, workspace_name) + # The launcher generated by js_binary contains non-reproducible information; swap it out + # with the sanitized one. + binary_path_under_runfiles = runfiles_dir + "/" + _to_rlocation_path(sanitized_original, workspace_name) entries.add_joined( [binary_path_under_runfiles, {"dest": launcher.path, "root": launcher.root.path}], join_with = ":", @@ -521,7 +576,10 @@ js_image_layer_lib = struct( mandatory = True, cfg = _js_image_layer_transition, executable = True, - doc = "Label to an js_binary target", + doc = """Label to a js_binary target. + + A custom rule built on `js_binary_lib.create_launcher` works too, as long as + it republishes `launcher_js` in an output group the way `js_binary` does.""", ), "root": attr.string( doc = "Path where the files from js_binary will reside in. eg: /apps/app1 or /app", diff --git a/js/private/js_run_devserver.bzl b/js/private/js_run_devserver.bzl index 635f874e5a..52fb87ee7b 100644 --- a/js/private/js_run_devserver.bzl +++ b/js/private/js_run_devserver.bzl @@ -108,6 +108,11 @@ def _js_run_devserver_impl(ctx): executable = launcher.executable, runfiles = runfiles, ), + OutputGroupInfo( + # The generated JavaScript launcher, empty unless the hermetic launcher is + # selected. js_image_layer needs this on anything built on create_launcher. + launcher_js = launcher.launcher_js, + ), ] js_run_devserver_lib = struct( diff --git a/js/private/test/BUILD.bazel b/js/private/test/BUILD.bazel index 8851960e92..7fa5875919 100644 --- a/js/private/test/BUILD.bazel +++ b/js/private/test/BUILD.bazel @@ -1,9 +1,9 @@ -load("@bazel_lib//lib:write_source_files.bzl", "write_source_files") -load("@bazel_lib_host//:defs.bzl", "host") load("@bazel_skylib//rules:write_file.bzl", "write_file") load("@rules_shell//shell:sh_test.bzl", "sh_test") load("//js:defs.bzl", "js_binary", "js_library", "js_test") load(":js_library_test.bzl", "js_library_test_suite") +load(":launcher_flags.bzl", "BASH_LAUNCHER_ONLY", "HERMETIC_LAUNCHER_ONLY") +load(":launcher_snapshot.bzl", "launcher_snapshot") load(":normalize_chdir_test.bzl", "normalize_chdir_tests") load(":run_environment_info_test.bzl", "run_environment_info_test_suite") @@ -23,24 +23,31 @@ js_binary( fixed_args = ["--my_arg"], ) -# Make sed replacements for consistency on different platform / Bazel version. -# The trailing sed pipeline normalizes bzlmod canonical repo separators ~~/~ -# (Bazel 7) to ++/+ (Bazel 8+) so the snapshot matches across versions. -genrule( - name = "shell_launcher_sed", +launcher_snapshot( + name = "write_launcher", + src = ":shellcheck_launcher", + out = "snapshots/launcher.sh", + target_compatible_with = BASH_LAUNCHER_ONLY, + target_cpu_format = "JS_BINARY__TARGET_CPU=\\\"{}\\\"", +) + +#################################################################################################### +# The same for the JavaScript launcher, which is what the js_binary runs when the hermetic +# launcher is selected with --@aspect_rules_js//js:hermetic_launcher. + +filegroup( + name = "shellcheck_launcher_js", srcs = [":shellcheck_launcher"], - outs = ["shellcheck_launcher_sed.sh"], - cmd = "cat $(execpath :shellcheck_launcher) | sed \"s#$(BINDIR)#bazel-out/k8-fastbuild/bin#\" | sed \"s#JS_BINARY__TARGET_CPU=\\\"$(TARGET_CPU)\\\"#JS_BINARY__TARGET_CPU=\\\"k8\\\"#\" | sed \"s#%s#linux_amd64#\" | sed \"s#\\\"%s\\\"#\\\"k8\\\"#\" | sed -E -e 's/~~/++/g' -e 's|([+][+][^/~]+)~([^/~]+)~([^/~]+)|\\1+\\2+\\3|g' -e 's|([+][+][^/~]+)~([^/~]+)|\\1+\\2|g' > $@" % ( - host.platform, - host.os, - ), + output_group = "launcher_js", + target_compatible_with = HERMETIC_LAUNCHER_ONLY, ) -write_source_files( - name = "write_launcher", - files = { - "snapshots/launcher.sh": ":shell_launcher_sed", - }, +launcher_snapshot( + name = "write_launcher_js", + src = ":shellcheck_launcher_js", + out = "snapshots/launcher.cjs", + target_compatible_with = HERMETIC_LAUNCHER_ONLY, + target_cpu_format = "setEnv(\\\"JS_BINARY__TARGET_CPU\\\", \\\"{}\\\")", ) # Drives the merger's branches directly. //js/private/test/coverage covers them diff --git a/js/private/test/create_launcher/custom_test.bzl b/js/private/test/create_launcher/custom_test.bzl index 0b4a4a2eef..863f5b5f76 100644 --- a/js/private/test/create_launcher/custom_test.bzl +++ b/js/private/test/create_launcher/custom_test.bzl @@ -37,6 +37,10 @@ def _custom_test_impl(ctx): executable = launcher.executable, runfiles = runfiles, ), + OutputGroupInfo( + # Republished so that js_image_layer can find the generated JavaScript launcher. + launcher_js = launcher.launcher_js, + ), ] _custom_test = rule( diff --git a/js/private/test/entry_point_quoting/BUILD.bazel b/js/private/test/entry_point_quoting/BUILD.bazel new file mode 100644 index 0000000000..f2d7f37d1e --- /dev/null +++ b/js/private/test/entry_point_quoting/BUILD.bazel @@ -0,0 +1,22 @@ +load("@bazel_lib//lib:testing.bzl", "assert_contains") +load("//js:defs.bzl", "js_binary", "js_run_binary") + +package(default_testonly = True) + +js_binary( + name = "quoted_bin", + entry_point = "it's ok.mjs", +) + +js_run_binary( + name = "run_quoted", + silent_on_success = False, + stdout = "quoted_out", + tool = ":quoted_bin", +) + +assert_contains( + name = "quoted_test", + actual = "quoted_out", + expected = "entry point with an apostrophe ran", +) diff --git a/js/private/test/entry_point_quoting/it's ok.mjs b/js/private/test/entry_point_quoting/it's ok.mjs new file mode 100644 index 0000000000..1a11a25b7c --- /dev/null +++ b/js/private/test/entry_point_quoting/it's ok.mjs @@ -0,0 +1 @@ +console.log('entry point with an apostrophe ran') diff --git a/js/private/test/fixed_args/BUILD.bazel b/js/private/test/fixed_args/BUILD.bazel index bf470497d3..125bc76447 100644 --- a/js/private/test/fixed_args/BUILD.bazel +++ b/js/private/test/fixed_args/BUILD.bazel @@ -89,3 +89,50 @@ assert_contains( actual = "locations_out_no_expand", expected = "$(rootpaths :test.txt)", ) + +# Quote removal happens at analysis time, but the presence of single quotes should still +# prevent $VAR expansion from happening at run time. +js_binary( + name = "single_quoted_var_bin", + entry_point = "fixed_args.mjs", + expand_args = False, + fixed_args = [ + "'$JS_BINARY__WORKSPACE/x'", + ], +) + +js_run_binary( + name = "run_single_quoted_var", + silent_on_success = False, + stdout = "single_quoted_var_out", + tool = ":single_quoted_var_bin", +) + +assert_contains( + name = "single_quoted_var_test", + actual = "single_quoted_var_out", + expected = "$JS_BINARY__WORKSPACE/x", +) + +# Without single quotes, the $VAR expansion does happen. +js_binary( + name = "unquoted_var_bin", + entry_point = "fixed_args.mjs", + expand_args = False, + fixed_args = [ + "$JS_BINARY__WORKSPACE/x", + ], +) + +js_run_binary( + name = "run_unquoted_var", + silent_on_success = False, + stdout = "unquoted_var_out", + tool = ":unquoted_var_bin", +) + +assert_contains( + name = "unquoted_var_test", + actual = "unquoted_var_out", + expected = "_main/x", +) diff --git a/js/private/test/image/BUILD.bazel b/js/private/test/image/BUILD.bazel index 87e6da722c..38663a7ad2 100644 --- a/js/private/test/image/BUILD.bazel +++ b/js/private/test/image/BUILD.bazel @@ -1,7 +1,8 @@ load("@bazel_lib//lib:transitions.bzl", "platform_transition_filegroup") load("@npm//:defs.bzl", "npm_link_all_packages") -load("//js:defs.bzl", "js_binary") +load("//js:defs.bzl", "js_binary", "js_image_layer") load(":asserts.bzl", "SKIP_ON_WINDOWS", "assert_checksum", "assert_js_image_layer_listings", "make_js_image_layer") +load(":not_js_binary_test.bzl", "fake_binary", "not_js_binary_test") package(default_testonly = True) @@ -141,3 +142,20 @@ platform_transition_filegroup( target_compatible_with = SKIP_ON_WINDOWS, target_platform = ":linux_arm64", ) + +fake_binary( + name = "not_js", + tags = ["manual"], +) + +js_image_layer( + name = "not_js_layer", + binary = ":not_js", + root = "/app", + tags = ["manual"], +) + +not_js_binary_test( + name = "not_js_binary_test", + target_under_test = ":not_js_layer", +) diff --git a/js/private/test/image/asserts.bzl b/js/private/test/image/asserts.bzl index de5fe49c1e..54f96707ad 100644 --- a/js/private/test/image/asserts.bzl +++ b/js/private/test/image/asserts.bzl @@ -2,6 +2,7 @@ load("@bazel_lib//lib:write_source_files.bzl", "write_source_file", "write_source_files") load("//js:defs.bzl", "js_image_layer") +load("//js/private/test:normalize.bzl", "REPO_SEPARATOR_NORMALIZE") # These fixtures build Linux OCI layers from non-portable inputs (non-ASCII filenames, bsdtar # listings) that don't resolve on a Windows host, so skip the whole image test tree on Windows. @@ -10,23 +11,21 @@ SKIP_ON_WINDOWS = select({ "//conditions:default": [], }) -# js_binary launcher scripts have unstable sizes across Bazel versions. -_UNSTABLE_SIZE_BASENAMES = ["bin", "bin2"] +# The js_binary targets these listings are taken over. Both adjustments below key off +# their launcher names, so a new fixture has to be added here too. +_JS_BINARY_BASENAMES = ["bin", "bin2"] # buildifier: disable=function-docstring def assert_tar_listing(name, actual, expected): - launcher_alt = "|".join(_UNSTABLE_SIZE_BASENAMES) + launcher_alt = "|".join(_JS_BINARY_BASENAMES) + # A launcher's size moves across Bazel versions, and the generated JavaScript launcher + # is present only with --@aspect_rules_js//js:hermetic_launcher; dropping it lets one + # golden listing cover both launchers. # `$$` escapes `$` for Bazel genrule cmd Make-variable expansion. - size_sanitize = "sed -E '/\\/({})$$/ s/[0-9]+ Jan/xxxxx Jan/'".format(launcher_alt) + launcher_sanitize = "sed -E -e '/\\/({0})$$/ s/[0-9]+ Jan/xxxxx Jan/' -e '/\\/({0})\\.cjs$$/d'".format(launcher_alt) - # Normalize bzlmod canonical repo separators: ~~/~ (Bazel 7) -> ++/+ (Bazel 8+). - repo_sep_normalize = ( - "sed -E -e 's/~~/++/g'" + - " -e 's|([+][+][^/~]+)~([^/~]+)~([^/~]+)|\\1+\\2+\\3|g'" + - " -e 's|([+][+][^/~]+)~([^/~]+)|\\1+\\2|g'" - ) - sanitize_cmd = "{} | {}".format(size_sanitize, repo_sep_normalize) + sanitize_cmd = "{} | {}".format(launcher_sanitize, REPO_SEPARATOR_NORMALIZE) actual_listing = "_{}_listing".format(name) native.genrule( diff --git a/js/private/test/image/not_js_binary_test.bzl b/js/private/test/image/not_js_binary_test.bzl new file mode 100644 index 0000000000..3269f8db2c --- /dev/null +++ b/js/private/test/image/not_js_binary_test.bzl @@ -0,0 +1,29 @@ +"""Asserts that js_image_layer rejects a binary it cannot classify. + +js_image_layer has to rewrite the launcher for hermeticity, and which file that is depends +on whether the js_binary was built with the bash launcher or the hermetic one. The +`launcher_js` output group is what tells them apart, so if this is missing then +js_image_layer should fail. +""" + +load("@bazel_skylib//lib:unittest.bzl", "analysistest", "asserts") + +def _fake_binary_impl(ctx): + executable = ctx.actions.declare_file("{}.sh".format(ctx.label.name)) + ctx.actions.write(executable, "#!/bin/sh\nexit 0\n", is_executable = True) + return [DefaultInfo(executable = executable)] + +fake_binary = rule( + implementation = _fake_binary_impl, + executable = True, +) + +def _not_js_binary_test_impl(ctx): + env = analysistest.begin(ctx) + asserts.expect_failure(env, "not a js_binary") + return analysistest.end(env) + +not_js_binary_test = analysistest.make( + _not_js_binary_test_impl, + expect_failure = True, +) diff --git a/js/private/test/js_binary_sh/BUILD.bazel b/js/private/test/js_binary_sh/BUILD.bazel index 3efd3aab15..8bae2167f3 100644 --- a/js/private/test/js_binary_sh/BUILD.bazel +++ b/js/private/test/js_binary_sh/BUILD.bazel @@ -2,6 +2,7 @@ load("@bazel_lib//lib:testing.bzl", "assert_contains") load("@bazel_skylib//rules:build_test.bzl", "build_test") load("@bazel_skylib//rules:write_file.bzl", "write_file") load("//js:defs.bzl", "js_binary", "js_run_binary") +load("//js/private/test:launcher_flags.bzl", "BASH_LAUNCHER_ONLY", "HERMETIC_LAUNCHER_ONLY") write_file( name = "write_one", @@ -196,10 +197,26 @@ assert_contains( expected = "[\"twx\"]", ) +# The generated JavaScript launcher, which is not a default output of the js_binary. +filegroup( + name = "env_json_launcher_js", + srcs = [":env_json"], + output_group = "launcher_js", + target_compatible_with = HERMETIC_LAUNCHER_ONLY, +) + assert_contains( name = "env_json_launcher_escaped", actual = ":env_json", expected = "export JSON_ENV=\"[\\\"twx\\\"]\"", + target_compatible_with = BASH_LAUNCHER_ONLY, +) + +assert_contains( + name = "env_json_launcher_js_escaped", + actual = ":env_json_launcher_js", + expected = "setEnv(\"JSON_ENV\", \"[\\\"twx\\\"]\")", + target_compatible_with = HERMETIC_LAUNCHER_ONLY, ) assert_contains( @@ -224,18 +241,42 @@ assert_contains( name = "env_json_obj_launcher_escaped", actual = ":env_json", expected = """ export JSON_OBJ="{\\\"allow\\\": [\\\"twx\\\"], \\\"deny\\\": []}" """.strip(), + target_compatible_with = BASH_LAUNCHER_ONLY, +) + +assert_contains( + name = "env_json_obj_launcher_js_escaped", + actual = ":env_json_launcher_js", + expected = """ setEnv("JSON_OBJ", "{\\\"allow\\\": [\\\"twx\\\"], \\\"deny\\\": []}") """.strip(), + target_compatible_with = HERMETIC_LAUNCHER_ONLY, ) assert_contains( name = "env_json_str_launcher_escaped", actual = ":env_json", expected = """ export JSON_STR="{\\\"note\\\": \\\"he said \\\\\\\"hi\\\\\\\"\\\"}" """.strip(), + target_compatible_with = BASH_LAUNCHER_ONLY, +) + +assert_contains( + name = "env_json_str_launcher_js_escaped", + actual = ":env_json_launcher_js", + expected = """ setEnv("JSON_STR", "{\\\"note\\\": \\\"he said \\\\\\\"hi\\\\\\\"\\\"}") """.strip(), + target_compatible_with = HERMETIC_LAUNCHER_ONLY, ) assert_contains( name = "env_json_encode_launcher_escaped", actual = ":env_json", expected = "export JSON_ENCODE=\"[\\\"twx\\\"]\"", + target_compatible_with = BASH_LAUNCHER_ONLY, +) + +assert_contains( + name = "env_json_encode_launcher_js_escaped", + actual = ":env_json_launcher_js", + expected = "setEnv(\"JSON_ENCODE\", \"[\\\"twx\\\"]\")", + target_compatible_with = HERMETIC_LAUNCHER_ONLY, ) #################################################################################################### diff --git a/js/private/test/launcher/BUILD.bazel b/js/private/test/launcher/BUILD.bazel index 850009dc0b..f7b11269b7 100644 --- a/js/private/test/launcher/BUILD.bazel +++ b/js/private/test/launcher/BUILD.bazel @@ -2,6 +2,7 @@ load("@bazel_lib//lib:diff_test.bzl", "diff_test") load("@bazel_lib//lib:testing.bzl", "assert_contains") load("@bazel_skylib//rules:write_file.bzl", "write_file") load("//js:defs.bzl", "js_binary", "js_run_binary", "js_test") +load("//js/private/test:launcher_flags.bzl", "BASH_LAUNCHER_ONLY") load(":direct_capture.bzl", "direct_capture") package(default_testonly = True) @@ -337,6 +338,7 @@ assert_contains( # A tool that writes to both streams and exits 0: the captures must land in the declared files. direct_capture( name = "direct_capture_streams", + target_compatible_with = BASH_LAUNCHER_ONLY, tool = ":stdout_stderr_bin", ) @@ -368,6 +370,7 @@ diff_test( # still succeeds and all three declared outputs appear. direct_capture( name = "direct_capture_exit", + target_compatible_with = BASH_LAUNCHER_ONLY, tool = ":exit_plain_bin", ) @@ -389,6 +392,7 @@ genrule( name = "direct_silent_run", outs = ["direct_silent_out.txt"], cmd = "JS_BINARY__SILENT_ON_SUCCESS=1 BAZEL_BINDIR=$(BINDIR) $(execpath :stdout_stderr_bin) > $@ 2>&1", + target_compatible_with = BASH_LAUNCHER_ONLY, tools = [":stdout_stderr_bin"], ) diff --git a/js/private/test/launcher_flags.bzl b/js/private/test/launcher_flags.bzl new file mode 100644 index 0000000000..57c882750f --- /dev/null +++ b/js/private/test/launcher_flags.bzl @@ -0,0 +1,16 @@ +"""target_compatible_with values for tests that only one js_binary launcher can run. + +A target gets one launcher per configuration, so a test of launcher-specific behavior has +to be skipped under the other one; CI covers both by running the whole suite twice. See +docs/hermetic_launcher.md. +""" + +BASH_LAUNCHER_ONLY = select({ + Label("//js:_hermetic_launcher_true"): ["@platforms//:incompatible"], + "//conditions:default": [], +}) + +HERMETIC_LAUNCHER_ONLY = select({ + Label("//js:_hermetic_launcher_true"): [], + "//conditions:default": ["@platforms//:incompatible"], +}) diff --git a/js/private/test/launcher_snapshot.bzl b/js/private/test/launcher_snapshot.bzl new file mode 100644 index 0000000000..fd6a7ab3bb --- /dev/null +++ b/js/private/test/launcher_snapshot.bzl @@ -0,0 +1,48 @@ +"""Writes a js_binary launcher to the source tree so that it is reviewed on every change. + +Both launchers get the same treatment: bake one out, normalize the values that move +between platforms and Bazel versions, and check the result in. Sharing the normalization +matters because it is the fiddly part -- the sed expressions carry two layers of quoting +-- and because a value that has to be normalized in one launcher has to be normalized in +the other. +""" + +load("@bazel_lib//lib:write_source_files.bzl", "write_source_files") +load("@bazel_lib_host//:defs.bzl", "host") +load(":normalize.bzl", "REPO_SEPARATOR_NORMALIZE") + +def launcher_snapshot(name, src, out, target_cpu_format, target_compatible_with): + """Normalizes a generated launcher and writes it to the source tree. + + Args: + name: name of the resulting write_source_files target. + src: the generated launcher to snapshot. + out: path of the checked-in snapshot, relative to this package. + target_cpu_format: how the launcher spells the JS_BINARY__TARGET_CPU assignment, + with `{}` where the cpu goes. One format rather than a search and a replacement + so the two cannot describe different assignments. + target_compatible_with: the launcher this snapshot exists for; see launcher_flags.bzl. + """ + sed = "_{}_sed".format(name) + native.genrule( + name = sed, + srcs = [src], + outs = ["{}.normalized".format(sed)], + cmd = " | ".join([ + "cat $(execpath {})".format(src), + 'sed "s#$(BINDIR)#bazel-out/k8-fastbuild/bin#"', + 'sed "s#{}#{}#"'.format( + target_cpu_format.format("$(TARGET_CPU)"), + target_cpu_format.format("k8"), + ), + 'sed "s#{}#linux_amd64#"'.format(host.platform), + 'sed "s#\\"{}\\"#\\"k8\\"#"'.format(host.os), + REPO_SEPARATOR_NORMALIZE, + ]) + " > $@", + target_compatible_with = target_compatible_with, + ) + + write_source_files( + name = name, + files = {out: sed}, + ) diff --git a/js/private/test/launcher_sync/BUILD.bazel b/js/private/test/launcher_sync/BUILD.bazel new file mode 100644 index 0000000000..658b037e98 --- /dev/null +++ b/js/private/test/launcher_sync/BUILD.bazel @@ -0,0 +1,26 @@ +load("@bazel_lib//lib:testing.bzl", "assert_contains") +load("//js:defs.bzl", "js_binary") +load(":launcher_sync.bzl", "launcher_sync_test") + +# Here we run the same js_binary under both the bash and JS launchers in one build, and diff +# the state node ends up in. This helps us ensure that the two launchers remain aligned. + +package(default_testonly = True) + +js_binary( + name = "state_bin", + entry_point = "dump_state.mjs", +) + +launcher_sync_test( + name = "state_test", + tool = ":state_bin", +) + +# A diff_test over two files passes just as happily when both are empty or when a filter has +# scrubbed them to nothing, so let's pin one line that has to be there. +assert_contains( + name = "state_dump_not_empty_test", + actual = ":state_test_bash", + expected = "env[JS_BINARY__RUNFILES]=", +) diff --git a/js/private/test/launcher_sync/dump_state.mjs b/js/private/test/launcher_sync/dump_state.mjs new file mode 100644 index 0000000000..5117c5ac0a --- /dev/null +++ b/js/private/test/launcher_sync/dump_state.mjs @@ -0,0 +1,75 @@ +// Dumps the state a js_binary launcher leaves node in, so that the bash launcher +// (js/private/js_binary.sh.tpl) and the JavaScript one (js/private/js_binary.cjs.tpl) can be +// compared directly. +// +// This runs after js/private/node-bootstrap/bootstrap.cjs, which is deliberate: bootstrap is +// common to both launchers, so what it does is the same on both sides, and what this prints is +// what a js_binary program actually observes. + +import * as fs from 'node:fs' +import * as path from 'node:path' + +// Dropped before comparison. Everything not listed here has to match, so keep this list short +// and say why for each entry. +const DROPPED = new Set([ + // Bash bookkeeping. The bash launcher is a bash process and the hermetic one is a native + // stub that execve()s node, so these are artifacts of the shell rather than launcher output. + '_', // bash exports the path of the command it is about to run + 'OLDPWD', // set by the `cd "$BAZEL_BINDIR"` in js_binary.sh.tpl + 'SHLVL', // incremented by every bash in the chain + // Set per action by Bazel, not by either launcher. + 'TMPDIR', + // Exported by the hermetic_launcher stub for legacy runfiles consumers before it hands off + // to the launcher. RUNFILES_DIR, which both launchers do set, stays compared. + 'JAVA_RUNFILES', + // This fixture's own plumbing; it names the output file, which differs per variant. + 'JS_LAUNCHER_SYNC_OUT', +]) + +// Each variant runs as its own action and so gets its own sandbox, and the launcher under test +// is built in its own configuration. Neither difference is launcher behavior, so both are +// replaced with tokens. +// +// The execroot is derived from the cwd rather than read from JS_BINARY__EXECROOT so that the +// value of JS_BINARY__EXECROOT stays genuinely compared: a launcher that computed it wrongly +// would leave an unsubstituted absolute path behind and fail the diff, rather than having its +// own mistake normalized away. +const cwd = process.cwd().replace(/\\/g, '/') +const bazelOut = cwd.lastIndexOf('/bazel-out/') +const execroot = bazelOut < 0 ? cwd : cwd.slice(0, bazelOut) + +function normalize(value) { + return ( + String(value) + .replace(/\\/g, '/') + .split(execroot) + .join('') + .replace(/bazel-out\/[^/]+\//g, 'bazel-out//') + ) +} + +const lines = [] +const emit = (key, value) => + lines.push(`${key}=${JSON.stringify(normalize(value))}`) + +emit('cwd', cwd) +process.argv.forEach((arg, i) => emit(`argv[${i}]`, arg)) +// Where node_options and the --require of node-patches land. +process.execArgv.forEach((arg, i) => emit(`execArgv[${i}]`, arg)) + +// Compared as a derived fact rather than by value: bash keeps a logical path across `cd` while +// process.cwd() is physical, so the two spellings can differ if any execroot component is a +// symlink even when both launchers are correct. What has to agree is that PWD is set and +// describes where we actually are. +lines.push(`pwd_is_cwd=${process.env.PWD === process.cwd()}`) + +for (const key of Object.keys(process.env).sort()) { + if (DROPPED.has(key) || key === 'PWD') continue + emit(`env[${key}]`, process.env[key]) +} + +// Resolved against the execroot because the launcher has left us in the bindir. +fs.writeFileSync( + path.join(execroot, process.env.JS_LAUNCHER_SYNC_OUT), + lines.join('\n') + '\n' +) diff --git a/js/private/test/launcher_sync/launcher_sync.bzl b/js/private/test/launcher_sync/launcher_sync.bzl new file mode 100644 index 0000000000..9c733ad6b1 --- /dev/null +++ b/js/private/test/launcher_sync/launcher_sync.bzl @@ -0,0 +1,102 @@ +"""Runs one js_binary under both launchers and diffs the state node ends up in. + +js_binary emits exactly one launcher per configuration (see `_create_launcher` in +js/private/js_binary.bzl), so the only way to get both into one `bazel test` is to build the +same target twice in two configurations that differ in //js:hermetic_launcher. That is what the +transition below does. + +The comparison is relative -- it asserts the two launchers agree, not that either matches a +recorded golden -- so it is unaffected by the Bazel version, the platform, the output base, or +the other flags the CI matrix flips. +""" + +load("@bazel_lib//lib:diff_test.bzl", "diff_test") +load("//js:libs.bzl", "js_binary_lib") + +def _hermetic_launcher_transition_impl(settings, attr): + # buildifier: disable=unused-variable + _ignore = (settings) + return {"//js:hermetic_launcher": attr.hermetic_launcher} + +_hermetic_launcher_transition = transition( + implementation = _hermetic_launcher_transition_impl, + inputs = [], + outputs = ["//js:hermetic_launcher"], +) + +def _dump_launcher_state_impl(ctx): + # The transition is on this rule, so it reaches the tool down a cfg = "exec" edge. The exec + # transition resets the platform and the mirrored --host_* options but not Starlark build + # settings, so the flag survives. If that ever stops being true both variants would quietly + # be the bash launcher and the diff would pass for the wrong reason, so check rather than + # assume: js_binary's launcher_js output group is non-empty exactly when the hermetic + # launcher was selected. + launcher_js = ctx.attr.tool[OutputGroupInfo].launcher_js.to_list() + if ctx.attr.hermetic_launcher and not launcher_js: + fail("{} was unexpectedly built without the hermetic launcher.".format(ctx.attr.tool.label)) + if not ctx.attr.hermetic_launcher and launcher_js: + fail("{} was built with the hermetic launcher, but the bash launcher was requested.".format(ctx.attr.tool.label)) + + out = ctx.actions.declare_file("{}.txt".format(ctx.label.name)) + + js_binary_lib.run_binary_action( + ctx = ctx, + executable = ctx.executable.tool, + outputs = [out], + mnemonic = "LauncherStateDump", + # ctx.actions.run replaces the action environment rather than extending it, so start + # from the default one. Without it the launcher would run with no PATH at all, which no + # real action does, and bash would silently substitute its own built-in default while + # node would not -- a difference in the rig rather than in the launchers. + env = dict(ctx.configuration.default_shell_env, JS_LAUNCHER_SYNC_OUT = out.path), + ) + + return [DefaultInfo(files = depset([out]))] + +_dump_launcher_state = rule( + doc = "Runs a js_binary under a chosen launcher and captures the state node starts in.", + implementation = _dump_launcher_state_impl, + cfg = _hermetic_launcher_transition, + attrs = { + "hermetic_launcher": attr.bool( + doc = "Which launcher to build the tool with.", + mandatory = True, + ), + "tool": attr.label( + doc = "The js_binary to run. Its entry point must be dump_state.mjs.", + mandatory = True, + executable = True, + cfg = "exec", + ), + # Still required by Bazel 7, which this repo supports. + "_allowlist_function_transition": attr.label( + default = "@bazel_tools//tools/allowlists/function_transition_allowlist", + ), + }, +) + +def launcher_sync_test(name, tool, **kwargs): + """Asserts that `tool` leaves node in the same state under both launchers. + + Args: + name: name of the resulting diff_test. + tool: a js_binary whose entry point is dump_state.mjs. + **kwargs: forwarded to diff_test. + """ + _dump_launcher_state( + name = "{}_bash".format(name), + tool = tool, + hermetic_launcher = False, + ) + _dump_launcher_state( + name = "{}_hermetic".format(name), + tool = tool, + hermetic_launcher = True, + ) + diff_test( + name = name, + file1 = "{}_bash".format(name), + file2 = "{}_hermetic".format(name), + failure_message = "The bash and JavaScript js_binary launchers no longer leave a js_binary in the same state. See js/private/test/launcher_sync/BUILD.bazel and docs/hermetic_launcher.md.", + **kwargs + ) diff --git a/js/private/test/normalize.bzl b/js/private/test/normalize.bzl new file mode 100644 index 0000000000..f216ff75e1 --- /dev/null +++ b/js/private/test/normalize.bzl @@ -0,0 +1,9 @@ +"""Shared normalization for checked-in golden files.""" + +# Canonical bzlmod repo separators changed from ~~/~ (Bazel 7) to ++/+ (Bazel 8+), so a +# golden that names one has to be rewritten to match whichever Bazel is running. +REPO_SEPARATOR_NORMALIZE = ( + "sed -E -e 's/~~/++/g'" + + " -e 's|([+][+][^/~]+)~([^/~]+)~([^/~]+)|\\1+\\2+\\3|g'" + + " -e 's|([+][+][^/~]+)~([^/~]+)|\\1+\\2|g'" +) diff --git a/js/private/test/snapshots/launcher.cjs b/js/private/test/snapshots/launcher.cjs new file mode 100644 index 0000000000..b4c4c4d841 --- /dev/null +++ b/js/private/test/snapshots/launcher.cjs @@ -0,0 +1,692 @@ +// This JavaScript file is the launcher for the NodeJS JavaScript file +// entry point with the following bazel label: +// @@//js/private/test:shellcheck.js +// +// The launcher was generated to execute the js_binary target +// @@//js/private/test:shellcheck_launcher +// +// The template used to generate this launcher is +// @@//js/private:js_binary.cjs.tpl + +'use strict' + +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +// ============================================================================== +// Values baked in at analysis time +// ============================================================================== + +const WORKSPACE_NAME = "_main" +const ENTRY_POINT_PATH = "js/private/test/shellcheck.js" +const NODE_PATH = "../rules_nodejs++node+nodejs_linux_amd64/bin/nodejs/bin/node" +const NPM_PATH = "" +const NPM_WRAPPER_PATH = "" +const NODE_WRAPPER_PATH = "js/private/node_bin/node" +const NODE_PATCHES_PATH = "js/private/node-bootstrap/bootstrap.cjs" +const LOG_PREFIX_RULE_SET = "aspect_rules_js" +const LOG_PREFIX_RULE = "js_binary" + +// ============================================================================== +// Helpers +// ============================================================================== + +const IS_WINDOWS = process.platform === 'win32' + +// Normalizes paths when running on Windows. +// +// Example: +// C:\Users\XUser\_bazel_XUser\7q7kkv32\execroot\A\b\C -> C:/Users/XUser/_bazel_XUser/7q7kkv32/execroot/A/b/C +// +// Only the separator changes. Node accepts forward slashes on Windows, so the separator +// rewrite is all that is needed and the comparisons below can stay written with '/'. +function normalizePath(p) { + if (!IS_WINDOWS) { + return p + } + return p.replace(/\\/g, '/') +} + +// process.cwd() reports the native separator on Windows, so it has to be +// normalized everywhere it is compared against or spliced into a path built with +// '/'. Not hoisted into a constant, because the launcher chdir()s further down. +function cwd() { + return normalizePath(process.cwd()) +} + +// The env values, node options, and fixed args below were spliced into +// double-quoted bash strings before this launcher was ported to JavaScript, so +// shell parameter expansion happened at launch time and users depend on it. For +// example, examples/stack_traces passes +// node_options = ["--require", "$$JS_BINARY__RUNFILES/$$JS_BINARY__WORKSPACE/..."]. +// Only $VAR / ${VAR} expansion is reproduced here; command substitution is not, +// and the result is not re-split on whitespace the way bash would have. +function expandEnvRefs(value) { + return value.replace( + /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g, + (_match, braced, bare) => process.env[braced || bare] || '' + ) +} + +function setEnv(name, value) { + process.env[name] = expandEnvRefs(value) +} + +function setEnvIfUnset(name, value) { + if (!process.env[name]) { + process.env[name] = expandEnvRefs(value) + } +} + +function isFile(p) { + try { + return fs.statSync(p).isFile() + } catch { + return false + } +} + +function isDirectory(p) { + try { + return fs.statSync(p).isDirectory() + } catch { + return false + } +} + +function isExecutable(p) { + try { + fs.accessSync(p, fs.constants.X_OK) + return true + } catch { + return false + } +} + +// ============================================================================== +// Environment +// ============================================================================== + +setEnv("JS_BINARY__BINDIR", "bazel-out/k8-fastbuild/bin") +setEnv("JS_BINARY__COMPILATION_MODE", "fastbuild") +setEnv("JS_BINARY__TARGET_CPU", "k8") +setEnv("JS_BINARY__BUILD_FILE_PATH", "js/private/test/BUILD.bazel") +setEnv("JS_BINARY__PACKAGE", "js/private/test") +setEnv("JS_BINARY__TARGET_NAME", "shellcheck_launcher") +setEnv("JS_BINARY__TARGET", "//js/private/test:shellcheck_launcher") +setEnv("JS_BINARY__WORKSPACE", "_main") +setEnvIfUnset("JS_BINARY__PATCH_NODE_FS", "1") +setEnv("JS_BINARY__COPY_DATA_TO_BIN", "1") +setEnvIfUnset("JS_BINARY__LOG_FATAL", "1") +setEnvIfUnset("JS_BINARY__LOG_ERROR", "1") + +// ============================================================================== +// Handle --bazel-bindir flag +// ============================================================================== + +// If a --bazel-bindir flag is passed it must be the first two +// arguments. It is consumed by this launcher and used to set BAZEL_BINDIR, +// overriding any value already set in the environment. +const argv = process.argv.slice(2) +if (argv.length > 0 && argv[0] === '--bazel-bindir') { + if (argv.length < 2) { + fs.writeSync(2, 'ERROR: --bazel-bindir flag requires a value\n') + process.exit(1) + } + process.env.BAZEL_BINDIR = argv[1] + argv.splice(0, 2) +} + +// ============================================================================== +// Prepare logging +// ============================================================================== + +process.env.JS_BINARY__LOG_PREFIX = `${LOG_PREFIX_RULE_SET}[${LOG_PREFIX_RULE}]` + +// Emit a log line to stderr. +// +// We use fs.writeSync rather than console.error, so that the line is flushed before the +// execve() at the bottom replaces this process. +function logTo(level, message) { + const collapsed = message.trim().replace(/\s+/g, ' ') + fs.writeSync(2, `${level}: ${process.env.JS_BINARY__LOG_PREFIX}: ${collapsed}\n`) +} + +function logfFatal(message) { + if (process.env.JS_BINARY__LOG_FATAL) { + logTo('FATAL', message) + } +} + +function logfError(message) { + if (process.env.JS_BINARY__LOG_ERROR) { + logTo('ERROR', message) + } +} + +function logfInfo(message) { + if (process.env.JS_BINARY__LOG_INFO) { + logTo('INFO', message) + } +} + +function logfDebug(message) { + if (process.env.JS_BINARY__LOG_DEBUG) { + logTo('DEBUG', message) + } +} + +function resolveExecrootBinPath(shortPath) { + const bindir = process.env.BAZEL_BINDIR + if (shortPath.startsWith('../')) { + return `${process.env.JS_BINARY__EXECROOT}/${bindir}/external/${shortPath.slice(3)}` + } + return `${process.env.JS_BINARY__EXECROOT}/${bindir}/${shortPath}` +} + +function resolveExecrootSrcPath(shortPath) { + if (shortPath.startsWith('../')) { + return `${process.env.JS_BINARY__EXECROOT}/external/${shortPath.slice(3)}` + } + return `${process.env.JS_BINARY__EXECROOT}/${shortPath}` +} + +function exitWith(exitCode) { + logfDebug(`exit code: ${exitCode}`) + process.exit(exitCode) +} + +process.on('uncaughtException', (err) => { + logfFatal(String((err && err.message) || err)) + logfDebug(String((err && err.stack) || err)) + exitWith(1) +}) + +// Ends this process the way node ended, so that callers see a signal-terminated +// process rather than an interposed 128+N exit code. That is what they would +// have seen had this launcher been able to exec node instead of spawning it. +function reraiseSignal(signal, exitCode) { + logfDebug(`exit code: ${exitCode}`) + // Removing the last listener restores node's default disposition for the + // signal, so killing ourselves with it now terminates this process. + process.removeAllListeners('SIGTERM') + process.removeAllListeners('SIGINT') + process.kill(process.pid, signal) + // Only reached if the signal turned out not to be fatal after all. + process.exit(exitCode) +} + +// ============================================================================== +// Initialize RUNFILES environment variable +// ============================================================================== + +let runfiles = process.env.TEST_SRCDIR || process.env.RUNFILES_DIR +if (!runfiles && process.env.RUNFILES_MANIFEST_FILE) { + // Normalized before the suffix tests because on Windows Bazel hands out a + // backslash-separated path, which would not match '/MANIFEST'. + const manifest = normalizePath(process.env.RUNFILES_MANIFEST_FILE) + if (manifest.endsWith('.runfiles_manifest')) { + // Bazel puts the manifest besides the runfiles with the suffix + // .runfiles_manifest. For example, the runfiles directory is named + // my_binary.runfiles then the manifest is beside the runfiles directory + // and named my_binary.runfiles_manifest + runfiles = manifest.slice(0, -'_manifest'.length) + } else if (manifest.endsWith('/MANIFEST')) { + // Bazel for windows puts the manifest file named MANIFEST in the + // runfiles directory + runfiles = manifest.slice(0, -'/MANIFEST'.length) + } else { + logfFatal(`Unexpected RUNFILES_MANIFEST_FILE value ${manifest}`) + exitWith(1) + } +} +if (!runfiles) { + logfFatal('RUNFILES_DIR environment variable is not set') + exitWith(1) +} +runfiles = normalizePath(runfiles) +if (!path.isAbsolute(runfiles)) { + // Must be absolute: the runfiles path may be relative to the cwd, and we may + // be about to change directory. + runfiles = normalizePath(path.join(cwd(), runfiles)) +} +process.env.JS_BINARY__RUNFILES = runfiles +// Set RUNFILES_DIR if not already set so that tools such as @bazel/runfiles can +// locate runfiles. +process.env.RUNFILES_DIR = process.env.RUNFILES_DIR || runfiles + +// ============================================================================== +// Prepare to run main program +// ============================================================================== + +let bazelOutSegment +if (cwd().includes('/bazel-out/')) { + bazelOutSegment = '/bazel-out/' +} else if (cwd().includes('/BAZEL-~1/')) { + bazelOutSegment = '/BAZEL-~1/' +} else if (cwd().includes('/bazel-~1/')) { + bazelOutSegment = '/bazel-~1/' +} + +// When the cwd is a build action execroot the bindir hangs off it (BAZEL_BINDIR resolves from the +// cwd), so the cwd is the execroot even if its path contains a "bazel-out" segment (e.g. a matching +// output base). Otherwise scan the output tree for the execroot (runfiles, or a nested js_binary in +// the bindir). +if ( + bazelOutSegment && + (!process.env.BAZEL_BINDIR || + !isDirectory(path.join(cwd(), process.env.BAZEL_BINDIR))) +) { + if ( + process.env.JS_BINARY__USE_EXECROOT_ENTRY_POINT && + process.env.JS_BINARY__EXECROOT + ) { + logfDebug( + `inheriting JS_BINARY__EXECROOT ${process.env.JS_BINARY__EXECROOT} from parent js_binary process as JS_BINARY__USE_EXECROOT_ENTRY_POINT is set` + ) + } else { + // We are in runfiles and we don't yet know the execroot; strip from the last "bazel-out" segment + const index = cwd().lastIndexOf(bazelOutSegment) + if (index < 0) { + fs.writeSync( + 2, + `\nERROR: ${process.env.JS_BINARY__LOG_PREFIX}: No 'bazel-out' folder found in path '${cwd()}'\n` + ) + exitWith(1) + } + process.env.JS_BINARY__EXECROOT = cwd().slice(0, index) + } +} else { + if ( + process.env.JS_BINARY__USE_EXECROOT_ENTRY_POINT && + process.env.JS_BINARY__EXECROOT + ) { + logfDebug( + `inheriting JS_BINARY__EXECROOT ${process.env.JS_BINARY__EXECROOT} from parent js_binary process as JS_BINARY__USE_EXECROOT_ENTRY_POINT is set` + ) + } else { + // We are in execroot or in some other context all together such as a nodejs_image or a manually run js_binary + process.env.JS_BINARY__EXECROOT = cwd() + } + + if (!process.env.JS_BINARY__NO_CD_BINDIR) { + if (!process.env.BAZEL_BINDIR) { + logfFatal( + `BAZEL_BINDIR must be set in environment to the makevar $(BINDIR) in js_binary build actions (which +run in the execroot) so that build actions can change directories to always run out of the root of the Bazel output +tree. See https://docs.bazel.build/versions/main/be/make-variables.html#predefined_variables. This is automatically set +by 'js_run_binary' (https://github.com/aspect-build/rules_js/blob/main/docs/js_run_binary.md) which is the recommended +rule to use for using a js_binary as the tool of a build action. If you are invoking a js_binary directly from your own +custom rule implementation, use the 'js_binary_lib.run_binary_action' helper +(https://github.com/aspect-build/rules_js/blob/main/js/libs.bzl) instead of calling ctx.actions.run yourself so that +BAZEL_BINDIR is set correctly. If this is not a build action you can set the +BAZEL_BINDIR to '.' instead to supress this error. For more context on this design decision, please read the +aspect_rules_js README https://github.com/aspect-build/rules_js/tree/dbb5af0d2a9a2bb50e4cf4a96dbc582b27567155#running-nodejs-programs.` + ) + exitWith(1) + } + + // Since the process was launched in the execroot, we automatically change directory into the root of the + // output tree (which we expect to be set in BAZEL_BINDIR). See + // https://github.com/aspect-build/rules_js/tree/dbb5af0d2a9a2bb50e4cf4a96dbc582b27567155#running-nodejs-programs + // for more context on why we do this. + logfDebug( + `changing directory to BAZEL_BINDIR (root of Bazel output tree) ${process.env.BAZEL_BINDIR}` + ) + process.chdir(process.env.BAZEL_BINDIR) + process.env.PWD = process.cwd() + } +} + +if (process.env.JS_BINARY__USE_EXECROOT_ENTRY_POINT) { + if (!process.env.BAZEL_BINDIR) { + logfFatal( + 'Expected BAZEL_BINDIR to be set when JS_BINARY__USE_EXECROOT_ENTRY_POINT is set' + ) + exitWith(1) + } + if ( + !process.env.JS_BINARY__COPY_DATA_TO_BIN && + !process.env.JS_BINARY__ALLOW_EXECROOT_ENTRY_POINT_WITH_NO_COPY_DATA_TO_BIN + ) { + logfFatal( + `Expected js_binary copy_data_to_bin to be True when js_run_binary use_execroot_entry_point is True. +To disable this validation you can set allow_execroot_entry_point_with_no_copy_data_to_bin to True in js_run_binary` + ) + exitWith(1) + } +} + +if (process.env.JS_BINARY__NO_RUNFILES) { + if ( + !process.env.JS_BINARY__COPY_DATA_TO_BIN && + !process.env.JS_BINARY__ALLOW_EXECROOT_ENTRY_POINT_WITH_NO_COPY_DATA_TO_BIN + ) { + logfFatal( + `Expected js_binary copy_data_to_bin to be True when js_binary use_execroot_entry_point is True. +To disable this validation you can set allow_execroot_entry_point_with_no_copy_data_to_bin to True in js_run_binary` + ) + exitWith(1) + } +} + +let entryPoint +if ( + process.env.JS_BINARY__USE_EXECROOT_ENTRY_POINT || + process.env.JS_BINARY__NO_RUNFILES +) { + entryPoint = resolveExecrootBinPath(ENTRY_POINT_PATH) +} else { + entryPoint = `${process.env.JS_BINARY__RUNFILES}/${WORKSPACE_NAME}/${ENTRY_POINT_PATH}` +} +if (!isFile(entryPoint)) { + logfFatal(`the entry_point '${entryPoint}' not found`) + exitWith(1) +} + +const node = normalizePath(NODE_PATH) +if (path.isAbsolute(node)) { + // A user may specify an absolute path to node using target_tool_path in node_toolchain + process.env.JS_BINARY__NODE_BINARY = node +} else if (process.env.JS_BINARY__NO_RUNFILES) { + process.env.JS_BINARY__NODE_BINARY = resolveExecrootSrcPath(NODE_PATH) +} else { + process.env.JS_BINARY__NODE_BINARY = `${process.env.JS_BINARY__RUNFILES}/${WORKSPACE_NAME}/${NODE_PATH}` +} +if (!isFile(process.env.JS_BINARY__NODE_BINARY)) { + logfFatal(`node binary '${process.env.JS_BINARY__NODE_BINARY}' not found`) + exitWith(1) +} +if (!IS_WINDOWS && !isExecutable(process.env.JS_BINARY__NODE_BINARY)) { + logfFatal(`node binary '${process.env.JS_BINARY__NODE_BINARY}' is not executable`) + exitWith(1) +} + +let npmBinDir +if (NPM_PATH) { + const npmPath = normalizePath(NPM_PATH) + if (path.isAbsolute(npmPath)) { + // A user may specify an absolute path to npm using npm_path in node_toolchain + process.env.JS_BINARY__NPM_BINARY = npmPath + } else if (process.env.JS_BINARY__NO_RUNFILES) { + process.env.JS_BINARY__NPM_BINARY = resolveExecrootSrcPath(NPM_PATH) + } else { + process.env.JS_BINARY__NPM_BINARY = `${process.env.JS_BINARY__RUNFILES}/${WORKSPACE_NAME}/${NPM_PATH}` + } + if (!isFile(process.env.JS_BINARY__NPM_BINARY)) { + logfFatal(`npm binary '${process.env.JS_BINARY__NPM_BINARY}' not found`) + exitWith(1) + } + if (!IS_WINDOWS && !isExecutable(process.env.JS_BINARY__NPM_BINARY)) { + logfFatal(`npm binary '${process.env.JS_BINARY__NPM_BINARY}' is not executable`) + exitWith(1) + } + + let npmWrapper + if (process.env.JS_BINARY__NO_RUNFILES) { + npmWrapper = resolveExecrootSrcPath(NPM_WRAPPER_PATH) + } else { + npmWrapper = `${process.env.JS_BINARY__RUNFILES}/${WORKSPACE_NAME}/${NPM_WRAPPER_PATH}` + } + if (!isFile(npmWrapper)) { + logfFatal(`npm wrapper '${npmWrapper}' not found`) + exitWith(1) + } + if (!IS_WINDOWS && !isExecutable(npmWrapper)) { + logfFatal(`npm wrapper '${npmWrapper}' is not executable`) + exitWith(1) + } + npmBinDir = path.dirname(npmWrapper) +} + +if (process.env.JS_BINARY__NO_RUNFILES) { + process.env.JS_BINARY__NODE_WRAPPER = resolveExecrootSrcPath(NODE_WRAPPER_PATH) +} else { + process.env.JS_BINARY__NODE_WRAPPER = `${process.env.JS_BINARY__RUNFILES}/${WORKSPACE_NAME}/${NODE_WRAPPER_PATH}` +} +if (!isFile(process.env.JS_BINARY__NODE_WRAPPER)) { + logfFatal(`node wrapper '${process.env.JS_BINARY__NODE_WRAPPER}' not found`) + exitWith(1) +} +if (!IS_WINDOWS && !isExecutable(process.env.JS_BINARY__NODE_WRAPPER)) { + logfFatal(`node wrapper '${process.env.JS_BINARY__NODE_WRAPPER}' is not executable`) + exitWith(1) +} + +if (process.env.JS_BINARY__NO_RUNFILES) { + process.env.JS_BINARY__NODE_PATCHES = resolveExecrootSrcPath(NODE_PATCHES_PATH) +} else { + process.env.JS_BINARY__NODE_PATCHES = `${process.env.JS_BINARY__RUNFILES}/${WORKSPACE_NAME}/${NODE_PATCHES_PATH}` +} +if (!isFile(process.env.JS_BINARY__NODE_PATCHES)) { + logfFatal(`node patches '${process.env.JS_BINARY__NODE_PATCHES}' not found`) + exitWith(1) +} + +// Gather node options +const nodeOptions = [] +function addNodeOption(value) { + nodeOptions.push(expandEnvRefs(value)) +} +addNodeOption("--preserve-symlinks-main") + +// fixed_args were tokenized at analysis time, each token as a list of +// [text, expand] segments: bash removed the quotes but this launcher still has to +// know which of them were single quotes, since those are the ones whose $VAR the +// shell would not have expanded. Expansion itself happens now, at run time. +const FIXED_ARGS = [[["--my_arg",true]]].map((segments) => + segments.map(([text, expand]) => (expand ? expandEnvRefs(text) : text)).join('') +) + +const args = [] +for (const arg of [...FIXED_ARGS, ...argv]) { + if (arg.startsWith('--node_options=')) { + // Let users pass through arguments to node itself + nodeOptions.push(arg.slice('--node_options='.length)) + } else { + // Remaining argv is collected to pass to the program + args.push(arg) + } +} + +// Configure JS_BINARY__FS_PATCH_ROOTS for node fs patches which are run via --require below. +// Don't override JS_BINARY__FS_PATCH_ROOTS if already set by an outer js_binary incase a js_binary such +// as js_run_deverser runs another js_binary tool. +if (!process.env.JS_BINARY__FS_PATCH_ROOTS) { + process.env.JS_BINARY__FS_PATCH_ROOTS = `${process.env.JS_BINARY__EXECROOT}:${process.env.JS_BINARY__RUNFILES}` +} + +// Disable Node's module compile cache by default (aspect-build/rules_js#2937). +// We will re-enable it at runtime if NODE_COMPILE_CACHE is set. +process.env.NODE_DISABLE_COMPILE_CACHE = '1' + +// Put the node wrapper directory and optionally the npm wrapper directory on the path so that +// child processes can find them. +const currentPath = process.env.PATH || '' +if (npmBinDir) { + process.env.PATH = `${npmBinDir}${path.delimiter}${currentPath}` +} +process.env.PATH = `${path.dirname(process.env.JS_BINARY__NODE_WRAPPER)}${path.delimiter}${process.env.PATH}` + +// Debug logs +if (process.env.JS_BINARY__LOG_DEBUG) { + logfDebug(`PATH ${process.env.PATH}`) + if (process.env.BAZEL_BINDIR) { + logfDebug(`BAZEL_BINDIR ${process.env.BAZEL_BINDIR}`) + } + if (process.env.BAZEL_BUILD_FILE_PATH) { + logfDebug(`BAZEL_BUILD_FILE_PATH ${process.env.BAZEL_BUILD_FILE_PATH}`) + } + if (process.env.BAZEL_COMPILATION_MODE) { + logfDebug(`BAZEL_COMPILATION_MODE ${process.env.BAZEL_COMPILATION_MODE}`) + } + if (process.env.BAZEL_INFO_FILE) { + logfDebug(`BAZEL_INFO_FILE ${process.env.BAZEL_INFO_FILE}`) + } + if (process.env.BAZEL_PACKAGE) { + logfDebug(`BAZEL_PACKAGE ${process.env.BAZEL_PACKAGE}`) + } + if (process.env.BAZEL_TARGET_CPU) { + logfDebug(`BAZEL_TARGET_CPU ${process.env.BAZEL_TARGET_CPU}`) + } + if (process.env.BAZEL_TARGET_NAME) { + logfDebug(`BAZEL_TARGET_NAME ${process.env.BAZEL_TARGET_NAME}`) + } + if (process.env.BAZEL_VERSION_FILE) { + logfDebug(`BAZEL_VERSION_FILE ${process.env.BAZEL_VERSION_FILE}`) + } + if (process.env.BAZEL_WORKSPACE) { + logfDebug(`BAZEL_WORKSPACE ${process.env.BAZEL_WORKSPACE}`) + } + logfDebug(`JS_BINARY__FS_PATCH_ROOTS ${process.env.JS_BINARY__FS_PATCH_ROOTS || ''}`) + logfDebug(`JS_BINARY__NODE_PATCHES ${process.env.JS_BINARY__NODE_PATCHES || ''}`) + logfDebug(`JS_BINARY__NODE_OPTIONS ${nodeOptions.join(' ')}`) + logfDebug(`JS_BINARY__BINDIR ${process.env.JS_BINARY__BINDIR || ''}`) + logfDebug(`JS_BINARY__BUILD_FILE_PATH ${process.env.JS_BINARY__BUILD_FILE_PATH || ''}`) + logfDebug(`JS_BINARY__COMPILATION_MODE ${process.env.JS_BINARY__COMPILATION_MODE || ''}`) + logfDebug(`JS_BINARY__NODE_BINARY ${process.env.JS_BINARY__NODE_BINARY || ''}`) + logfDebug(`JS_BINARY__NODE_WRAPPER ${process.env.JS_BINARY__NODE_WRAPPER || ''}`) + if (process.env.JS_BINARY__NPM_BINARY) { + logfDebug(`JS_BINARY__NPM_BINARY ${process.env.JS_BINARY__NPM_BINARY}`) + } + if (process.env.JS_BINARY__NO_RUNFILES) { + logfDebug(`JS_BINARY__NO_RUNFILES ${process.env.JS_BINARY__NO_RUNFILES}`) + } + logfDebug(`JS_BINARY__PACKAGE ${process.env.JS_BINARY__PACKAGE || ''}`) + logfDebug(`JS_BINARY__TARGET_CPU ${process.env.JS_BINARY__TARGET_CPU || ''}`) + logfDebug(`JS_BINARY__TARGET_NAME ${process.env.JS_BINARY__TARGET_NAME || ''}`) + logfDebug(`JS_BINARY__WORKSPACE ${process.env.JS_BINARY__WORKSPACE || ''}`) + logfDebug(`js_binary entry point ${entryPoint}`) + if (process.env.JS_BINARY__USE_EXECROOT_ENTRY_POINT) { + logfDebug( + `JS_BINARY__USE_EXECROOT_ENTRY_POINT ${process.env.JS_BINARY__USE_EXECROOT_ENTRY_POINT}` + ) + } +} + +// Info logs +if (process.env.JS_BINARY__LOG_INFO) { + if (process.env.BAZEL_TARGET) { + logfInfo(`BAZEL_TARGET ${process.env.BAZEL_TARGET}`) + } + logfInfo(`JS_BINARY__TARGET ${process.env.JS_BINARY__TARGET || ''}`) + logfInfo(`JS_BINARY__RUNFILES ${process.env.JS_BINARY__RUNFILES || ''}`) + logfInfo(`JS_BINARY__EXECROOT ${process.env.JS_BINARY__EXECROOT || ''}`) + logfInfo(`PWD ${cwd()}`) +} + +// ============================================================================== +// Run the main program +// ============================================================================== + +// We invoke node directly rather than through JS_BINARY__NODE_WRAPPER. This +// way we avoid spawning an extra bash process on every launch. The wrapper is +// still put on the PATH as `node` so that child processes get the patched +// runtime. + +const nodeArgs = [ + '--require', + process.env.JS_BINARY__NODE_PATCHES, + ...nodeOptions, + '--', + entryPoint, + ...args, +] + +if (process.env.JS_BINARY__LOG_INFO) { + logfInfo(['running', process.env.JS_BINARY__NODE_BINARY, ...nodeArgs].join(' ')) +} + +const expectedExitCode = process.env.JS_BINARY__EXPECTED_EXIT_CODE + +if (!expectedExitCode) { + // Nothing must run after node exits, so replace this process with node. + // Signals and terminal control are then delivered directly to node instead + // of being proxied through a child process, and no launcher process is left + // behind. + // + // process.execve is POSIX-only and was added in Node 22.15; when it is + // unavailable we fall through to spawning node below. + if (typeof process.execve === 'function') { + try { + process.execve( + process.env.JS_BINARY__NODE_BINARY, + [process.env.JS_BINARY__NODE_BINARY, ...nodeArgs], + { ...process.env } + ) + } catch (e) { + logfDebug(`process.execve failed (${e.message}); falling back to spawn`) + } + } +} + +// Reached when this launcher has to outlive the program: an expected exit code has to be +// compared against once the program is done, and a Node before 22.15, or any Node on +// Windows, has no process.execve to replace this process with. +const { spawn } = require('node:child_process') +const child = spawn(process.env.JS_BINARY__NODE_BINARY, nodeArgs, { + stdio: 'inherit', +}) + +// ============================================================================== +// Wait for program to finish +// ============================================================================== + +// Node does not forward termination signals to any child process, so the +// signals are trapped and forwarded manually. The handlers are removed on the +// first signal so that a second one terminates this launcher. +function forwardSignal(signal) { + return () => { + process.removeAllListeners('SIGTERM') + process.removeAllListeners('SIGINT') + try { + child.kill(signal) + } catch { + // the child already exited + } + } +} +process.on('SIGTERM', forwardSignal('SIGTERM')) +process.on('SIGINT', forwardSignal('SIGINT')) + +child.on('error', (err) => { + logfFatal( + `failed to spawn node binary '${process.env.JS_BINARY__NODE_BINARY}': ${err.message}` + ) + exitWith(127) +}) + +child.on('exit', (code, signal) => { + const result = + signal !== null && signal !== undefined + ? 128 + (os.constants.signals[signal] || 0) + : code + + // ============================================================================== + // Mop up after main program + // ============================================================================== + + if (expectedExitCode) { + if (String(result) !== String(expectedExitCode)) { + logfError( + `expected exit code to be '${expectedExitCode}', but got '${result}'` + ) + if (result === 0) { + // This exit code is handled specially by Bazel: + // https://github.com/bazelbuild/bazel/blob/486206012a664ecb20bdb196a681efc9a9825049/src/main/java/com/google/devtools/build/lib/util/ExitCode.java#L44 + const BAZEL_EXIT_TESTS_FAILED = 3 + exitWith(BAZEL_EXIT_TESTS_FAILED) + } + exitWith(result) + } else { + exitWith(0) + } + } + + if (signal) { + reraiseSignal(signal, result) + } else { + exitWith(result) + } +}) diff --git a/tools/update-snapshots.sh b/tools/update-snapshots.sh index 91e4d5ec05..db7d6a725b 100755 --- a/tools/update-snapshots.sh +++ b/tools/update-snapshots.sh @@ -88,8 +88,11 @@ print_category "ROOT TEST SNAPSHOTS" # npm/private/test - npm translation test snapshots run_target "$REPO_ROOT" "//npm/private/test:write_npm_translate_lock" "npm/private/test" -# js/private/test - js_binary launcher snapshot -run_target "$REPO_ROOT" "//js/private/test:write_launcher" "js/private/test" +# js/private/test - js_binary launcher snapshots. The JavaScript launcher is only generated +# when the hermetic launcher is selected, so its snapshot needs the flag. +run_target "$REPO_ROOT" "//js/private/test:write_launcher" "js/private/test (bash launcher)" +run_target "$REPO_ROOT" "//js/private/test:write_launcher_js" "js/private/test (JS launcher)" \ + "--@aspect_rules_js//js:hermetic_launcher=True" # js/private/test/image - js_image_layer test snapshots run_target "$REPO_ROOT" "//js/private/test/image:checksum_test_test" "js/private/test/image checksum"