#!/usr/bin/env bash # wasmld: wasm-ld wrapper. Produces statically linked .wasm modules suitable # for execution by HardwareJS. The user binary has: # - exported _start # - imported memory (HardwareJS supplies the WebAssembly.Memory) # - imported "linux.syscall" (and friends) # # TWO ARMS (HWJS-3, docs/HWJS-3-USERLAND-DESIGN.md §7): # # ── LEGACY ARM (WASMLD_HWJS=0, the default until the D1 targets migrate) ── # # D1-FORK.b: after linking, the module is run through Binaryen's Asyncify # pass with the host syscall import declared as the suspend point: # # wasm-opt -O2 --asyncify --pass-arg=asyncify-imports@linux.syscall # # This makes EVERY linux.syscall callsite an unwind/rewind point, which is # what real fork() needs: the parent's call stack is unwound at the fork # syscall, snapshotted, copied to the child, and both are rewound (parent # returns the child pid, child returns 0). Whole-program scope (no # asyncify-only-list) guarantees an arbitrary syscall-site is coverable. # The instrumentation is INERT when the asyncify state is NONE (normal # syscalls just call through), so non-forking binaries are unaffected # beyond size/throughput. See linux-wasm/Documentation/wasm/fork-model.rst # and the proof in linux-wasm/tools/d1-fork-spike/. # # Escape hatch: WASMLD_ASYNCIFY=0 links without the pass (pre-D1-FORK # behaviour) for A/B comparison. # # D1-BASH.b3 (R-D1BASH-W2): WASMLD_FPCAST_EMU=1 (default 0, OPT-IN per-build) # prepends Binaryen's --fpcast-emu (Function Pointer Cast Emulation) to the # wasm-opt invocation. This routes indirect calls through generated # signature-adapter thunks so a K&R-untyped-function-pointer call (bash's # `typedef int (*Function)()` unwind_protect cleanups) no longer trips wasm's # strict call_indirect type check. It is GLOBAL (rewrites the indirect-call # table to one uber-signature) and carries thunk/table-rewrite cost, so it is # OFF by default and enabled ONLY for binaries that need it (bash) — NOT the # kernel and NOT cast-clean binaries. --fpcast-emu runs BEFORE -O2 --asyncify in # the same wasm-opt invocation (proven to compose: linux-wasm/tools/ # d1-bash-fpcast-spike, 9/9). See ARCHITECTURE.md §15.3 R-D1BASH-W2. # # ── CORO ARM (WASMLD_HWJS=1 — the brintium substrate; HWJS-3) ── # # Whole-program vantage (design §1; the shape the toolchain's own whole-program # corpus scan established): wasmcc emitted every TU as BITCODE; this # wrapper merges the full program closure into ONE LLVM module — # # 1. llvm-link the bitcode roots (program objects + crt1_musl_hwjs.bc + # hwjs_user_rt.bc), # 2. archive-pull the pool (bitcode archive members + the musl bitcode # pool) with `llvm-link -only-needed`, ITERATED TO A FIXPOINT — the # scan's F71 lesson verbatim: -only-needed is a single left-to-right # pass, so a member that defines a symbol needed only by a LATER pull # is dropped; each iteration pulls what the previous result newly # references (converges in 2-3 passes), # 3. ONE pinned-clang compile of the merged module with the # HwjsCoroutinize plugin (PipelineStartEP), under the FULL cc-wasm.sh # guard suite — chimera guard, SUMMARY presence, protocol_forced=1, # parked_flag_forced=1 + parked_abi=2, DRIVE-CONTRACT drive_abi=3 # (flat, the DEFAULT) or drive_abi=2 (nested, under HWJS_CORO_DRIVE_NESTED), # REFUSE enforcement (sole benign class: alwaysinline-suspender- # residual real_uses=0; the weak-empty-default census class is # RETIRED at membership zero — any refusal here is a FRESH # stop-and-report), UNPUBLISHED-SUSPENDER visibility=external == 0 # (structural at whole-program vantage; checked anyway), and the # classify-only leak check, # 4. final wasm-ld with the native-stage objects (wasm_thread.k7.o, # hwjs_user_thunks.o, compiler-rt builtins). # # The Binaryen stage is GONE on this arm: NO wasm-opt, NO --asyncify, NO # --fpcast-emu (native fpcast rides the plugin; the kernel EH-guard's # reasoning applies — a coro-bearing module must never reach Binaryen). # WASMLD_FPCAST_EMU=1 alongside WASMLD_HWJS=1 is refused loudly. set -euo pipefail # brintium's canonical link driver. Paths derive from this checkout ($HWJS_HOME) # plus a small env interface; a downstream distro exports these to point the # SAME driver at its own build tree (see docs/BUILDING.md). No machine-specific # absolute path is baked in. HWJS_HOME="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # An installed toolchain ships libexec/brint-tool and may have no node (the # wasm32 guest cannot run one); a source checkout does not build the helper # there. Read from the tree on every exec, never from the environment. Only the # steps of a plain link take it: pool selection, the hwjs.abi read, the import # witness and the consult export. BRINT_TOOL="$HWJS_HOME/libexec/brint-tool" BRINT_TOOL_INSTALLED=0 [[ -x "$BRINT_TOOL" ]] && BRINT_TOOL_INSTALLED=1 # An installed toolchain may also carry the hwjs passes inside its clang and # opt, linked in as LLVM static extensions, with no plugin .so anywhere (the # wasm32 guest cannot dlopen one). Its installer records that in # libexec/hwjs-passes-linked.stamp: one line, commit=, the format of a plugin's .stamp. Read from the # tree on every exec, like the helper above. In this mode the link requires, # snapshots and stamp-checks no plugin, asks the tools for each pass by name # (hwjs_pass_load), and holds the passes' PLUGIN-PROVENANCE attestation to the # stamp's commit. HWJS_LINKED_STAMP="$HWJS_HOME/libexec/hwjs-passes-linked.stamp" HWJS_PASSES_LINKED=0 [[ -e "$HWJS_LINKED_STAMP" ]] && HWJS_PASSES_LINKED=1 # shellcheck source=libexec/find-llvm.sh source "$HWJS_HOME/libexec/find-llvm.sh" # Base for build artifacts the LINK consumes (the libc bitcode pool, the # startfiles, the builtins): the sysroot tree, a directory of this checkout. # The default was the checkout ROOT, from when those artifacts were produced # by a separate repository that always exported this variable — so the default # was never exercised and pointed one level above them. bin/brintld resolves # and exports it for every real link. HWJS_TOOLS_DIR="${HWJS_TOOLS_DIR:-${BRINTIUM_SYSROOT:-$HWJS_HOME/sysroot}}" # ── ONE HOME FOR THE DERIVED PASS PLUGINS ──────────────────────────────────── # # THE DEFECT THIS REPLACES. The plugins are derived artifacts of THIS checkout, # and they used to have two homes with independent freshness: this checkout's # own cmake tree, and a build tree that a consuming super-repo installed them # into. Which one a given link used depended on whether its particular caller # remembered to export WASMLD_HWJS_*_PLUGIN. That is a per-consumer convention, # and it failed the way conventions fail: one consumer shim exported none of # them, another exported two of three (witnessed: a libc.so link that died # PLUGIN-PROVENANCE mismatch on HwjsEHPreLower ONLY), and the refusal message # named a remedy — "rebuild it" — that refreshed the home the link was not # using, so an operator could run the stated fix, see it report success, and # get the identical failure. Four separate efforts paid for that. # # THE RULE. This repository has exactly one command that produces plugins # (toolchain/build.sh) and exactly one directory it produces them into, so # there is exactly one home and nothing to choose between: # # $HWJS_HOME/pass/coroutinize/build # # NOT $HWJS_TOOLS_DIR/build, which is where the libc bitcode pool, the # startfiles and the builtins archive come from. That tree is produced by the # libc build; nothing in it produces plugins. Carrying it as a second candidate # would be the same two-ages defect in its most degenerate form — one of the # two ages being "never built", so a link would refuse naming a directory no # command in this repository fills. # # There is no fallback and no second candidate: a missing plugin refuses and # names this path and the command that fills it. Explicit WASMLD_HWJS_*_PLUGIN # still wins everywhere, which is the supported way to point one link at an # experimental plugin without moving the home. HWJS_PLUGIN_HOME="$HWJS_HOME/pass/coroutinize/build" # The remedy that refreshes THAT home, quoted verbatim in every refusal below # so a message can never name a fix that refreshes something else. HWJS_PLUGIN_REMEDY="$HWJS_HOME/toolchain/build.sh (builds all four plugins into that directory and refuses if any of them is missing afterwards)" # Every LLVM tool resolves from the pinned from-source build (override the bin # dir with WASMLD_HWJS_LLVM_BIN). Binaryen (wasm-opt/wasm-dis) is NOT part of # LLVM and is gone entirely — the coro arm never ran it, and the legacy Asyncify # arm that needed it is retired (see the bottom of file). HWJS_LLVM_BIN="$(hwjs_llvm_bin "$HWJS_HOME/libexec")" \ || { echo "wasmld: FATAL: pinned LLVM not found (set WASMLD_HWJS_LLVM_BIN, or build it per toolchain/llvm-from-source.md)." >&2; exit 1; } WASM_LD="$HWJS_LLVM_BIN/wasm-ld" # Every compile this driver runs is a complete command line. An installed clang # can carry a config file holding the defaults of a per-file compile, and the # on-target toolchain's has -flto=full, so that `clang -c` writes bitcode. Read # here, it makes the merged-module compile write bitcode where the link takes # an object. HWJS_CLANG=( "$HWJS_LLVM_BIN/clang" --no-default-config ) # Defaults; users can override via env or extra args. INIT_PAGES="${WASMLD_INITIAL_PAGES:-256}" # 256 * 64 KiB = 16 MiB (holds the shadow stack + data + heap; the coro arm raises it again below for the .bss arenas) MAX_PAGES="${WASMLD_MAX_PAGES:-16384}" # 1 GiB max # FLAT RESUME DRIVE (drive_abi=3) is the CANONICAL userspace default (HWJS-3 # close): validated fast + correct + arbitrary depth with O(1) NATIVE/shadow # stack (recursion lives in the linear-memory frame arena, hwjs_user_rt.c §9). # Opt back to the nested drive (drive_abi=2, native nesting per level) with # HWJS_CORO_DRIVE_NESTED=1 (the same knob the flat-drive RED gate uses), or the # back-compat HWJS_CORO_DRIVE_FLAT=0. This selection is shared verbatim with # wasmcc (it picks the matching flat/nested crt) — keep them in sync. The KERNEL # is UNAFFECTED: it links via cc-wasm.sh, not this wrapper. # VALUE-keyed drive knobs (issue linux-in-the-browser#41): accepted values are 1 (on), # 0 (off), or unset/empty (default). The pass (HwjsCoroutinize envKnobBool) enforces the # same contract; refusing HERE too puts the refusal at the earliest vantage (before a # whole merge+link runs) and keeps the driver's own !=0 tests honest (a garbage value # must never silently select either drive). for _dk in HWJS_CORO_DRIVE_FLAT HWJS_CORO_DRIVE_NESTED; do case "${!_dk:-}" in ''|0|1) ;; *) echo "wasmld: FATAL: $_dk='${!_dk}' — this knob is VALUE-keyed (1 = on, 0 = off, unset/empty = default); refusing to guess (linux-in-the-browser#41)." >&2 exit 1 ;; esac done if [[ "${HWJS_CORO_DRIVE_NESTED:-0}" != "0" ]]; then HWJS_DRIVE_FLAT=0 else HWJS_DRIVE_FLAT="${HWJS_CORO_DRIVE_FLAT:-1}" # default 1 = flat fi # Shadow stack. The flat drive is O(1) native stack, so the userspace link no # longer needs the old 8 MiB deep-recursion STOPGAP (that covered the nested # drive's per-level native nesting). Flat default = 1 MiB (ample for the O(1) # driver loop + musl's non-coro native call chains; flat depth 1000/5000 lives # in the arena, not here). The nested opt-out keeps the 8 MiB (it still nests # natively per level). Env override (WASMLD_STACK) always wins; a too-small # initial-memory for the chosen stack is a LOUD wasm-ld "initial memory too # small" error, never a silent truncation. if [[ "$HWJS_DRIVE_FLAT" != "0" ]]; then STACK="${WASMLD_STACK:-1048576}" # 1 MiB (flat: O(1) native stack) else STACK="${WASMLD_STACK:-8388608}" # 8 MiB (nested opt-out: native deep recursion) fi ASYNCIFY="${WASMLD_ASYNCIFY:-1}" # 1 = run the Asyncify pass (D1-FORK; legacy arm only) FPCAST_EMU="${WASMLD_FPCAST_EMU:-0}" # 1 = prepend --fpcast-emu (D1-BASH; opt-in, bash only, legacy arm only) HWJS="${WASMLD_HWJS:-0}" # 1 = the brintium coro arm (HWJS-3) # DYNAMIC-MAIN model (HWJS-5 STEP 2, ADR-0004): a dlopen-using program is a # dynamic MAIN that IMPORTS the shared __indirect_function_table (env), so a # dlopen'd PIC side module (-shared) shares the SAME table — dlsym returns a # shared-table index the main call_indirects. ADDITIVE: default 0 keeps the # self-contained static image (bash and friends own their internal table). The # main itself is NOT PIC (only side modules are -shared/PIC); it just sources # its table from env instead of defining+exporting one. Memory is already # host-owned (--import-memory), so a host-owned table is the symmetric choice. DYNAMIC_MAIN="${WASMLD_DYNAMIC_MAIN:-0}" # 1 = dlopen-capable dynamic main (--import-table) # DYNAMIC-MODULE records (HWJS-5 F117): a dynamic module (the dlopen-capable # main + every dlopen'd .so) is built with HWJS_CORO_DYNAMIC_MODULE=1 so the # plugin emits the per-module F117 register ctors (__hwjs_suspends_register / # __hwjs_capsummary_register / __hwjs_fpcast_register, as applicable) + the weak # @__hwjs_module_id the host loader binds per instance, and switches open-world # consults to the runtime kernel registry (resolve -> proceed; unresolved -> # __hwjs_e8_refuse loud). OFF (default) = static/closed-world (bash/kernel/ # corpus): no records, the closed-world consult. Opt-in; never set for static. DYNAMIC_MODULE="${WASMLD_DYNAMIC_MODULE:-0}" # 1 = emit F117 per-module records # -shared SIDE-MODULE arm (HWJS-5 M1): the production path for building a # suspend-capable dlopen'd .so — PIC bitcode inputs, merged into ONE module, # ONE pass run under the dynamic-module env (F117 ctors + F118/F119/F121 # seams + flat drive + the single hwjs.abi manifest), then # `wasm-ld --experimental-pic -shared`. Detected from the command line; # implies WASMLD_DYNAMIC_MODULE=1 (a .so IS a dynamic module by definition). IS_SHARED=0 for _a in "$@"; do [[ "$_a" == "-shared" || "$_a" == "--shared" ]] && IS_SHARED=1 done # BRINTOS-1734-SHARED-BUILTINS: every -shared link needs the compiler-rt builtins TU, # not just the # libc.so producer — a side module's soft-float/wide-int calls are created during # CODEGEN, so they cannot bind to libc.so (which defines them but does not export # them: its surface is the explicit glibc Versions allowlist). if [[ $IS_SHARED == 1 ]]; then HWJS_LIBC_BUILTINS_BC="${WASMLD_HWJS_LIBC_BUILTINS_BC:-$HWJS_TOOLS_DIR/build/builtins-wasm32-pic.bc}" [[ -f "$HWJS_LIBC_BUILTINS_BC" ]] || { echo "wasmld: FATAL (-shared): no compiler-rt builtins TU at $HWJS_LIBC_BUILTINS_BC. A -shared link runs NO import-surface audit, so a missing TU would ship env.__addtf3-class imports SILENTLY and the loader would refuse the consumer main at dlopen. Build it ('make -C distros/common builtins-pic') or set WASMLD_HWJS_LIBC_BUILTINS_BC. (BRINTOS-1734)" >&2 exit 1 } fi if [[ $IS_SHARED == 1 && "$DYNAMIC_MODULE" == "0" ]]; then echo "wasmld: NOTE (-shared): implying WASMLD_DYNAMIC_MODULE=1 — a -shared side module is a dynamic module by definition (F117 records + the hwjs.abi manifest)." >&2 DYNAMIC_MODULE=1 fi # ── Dynamic-libc arms (PLAN-DYNAMIC-LIBC). # WASMLD_LIBC_SO=1 — the libc.so PRODUCER: a refinement of the -shared arm that (a) full-links the # WHOLE PIC bitcode pool ($WASMLD_HWJS_LIBC_BC_PIC) + the S2 TLS accessor TU (no need-only pull — # the closure IS the whole libc), (b) forwards HWJS_CORO_PUBLISH_SUSPENDERS so the ONE merged pass # run PUBLISHES the per-export suspend/native partition + stamps its sha256 into an hwjs.abi v2 # manifest, (c) cross-verifies the published verdicts against the final .so's name section, and # (d) installs the partition at WASMLD_LIBC_SO_PARTITION_OUT (required). # WASMLD_LIBC_IMPORT=1 — the MAIN consumer (mirrors the R4 WASMLD_RT_IMPORT shape): no musl pool pull # (libc refs become imports resolved from libc.so, module #1, at load); the merged-module codegen # flips -fno-pic → -fPIC and the final link swaps --allow-undefined → # --unresolved-symbols=import-dynamic (S5 mechanism (a′): undefined DATA becomes loader-provided # mutable GOT.mem. globals on a FIXED-LAYOUT main — no -pie, layout/exec/fork contract # unchanged); the published partition (WASMLD_HWJS_XMOD_SUSPENDERS= — a DELIBERATE lift of # the "misplaced request" guard below) seeds the import-suspend partition; the witness sanctions # env fns / GOT.* BY NAME from lists DERIVED from the built libc.so (WASMLD_LIBC_SO_PATH, required). LIBC_SO="${WASMLD_LIBC_SO:-0}" # 1 = libc.so producer refinement of -shared LIBC_IMPORT="${WASMLD_LIBC_IMPORT:-0}" # 1 = libc.so-importing dynamic MAIN # WASMLD_RT_SO=1 — the kernel-provided runtime module producer (PLAN-THREADS # §4 step 8): the ONE-TU hwjs_user_rt.c -shared link, in its status-channel # twin spelling (HWJS_STATUS_CHANNEL=1 [+ HWJS_RT_LANES]). An abi-4 caller # detects an imported leaf's park by the CHANNEL RAISE the leaf's own # emission performs — a flat-built runtime under an abi-4 main silently # transits every leaf park (set-over-set at the next park; witnessed live # on the first lane-stamped dynamic thr1). This arm satisfies the Track M # partition mandate by PUBLISHING the runtime's own partition # (WASMLD_RT_SO_PARTITION_OUT, required) — the honest per-op suspend # record — without the libc.so producer's pool/export machinery. RT_SO="${WASMLD_RT_SO:-0}" # Set by the -shared TLS census when an ordinary side module owns thread_local # state; never taken from the environment. SIDE_TLS=0 if [[ "$RT_SO" == "1" ]]; then if [[ $IS_SHARED != 1 ]]; then echo "wasmld: FATAL: WASMLD_RT_SO=1 without -shared — the runtime-module producer is a refinement of the -shared side-module arm (PLAN-THREADS §4 step 8)." >&2 exit 1 fi if [[ "$LIBC_SO" == "1" || "$LIBC_IMPORT" == "1" ]]; then echo "wasmld: FATAL: WASMLD_RT_SO=1 combined with WASMLD_LIBC_SO/WASMLD_LIBC_IMPORT — one link cannot be the runtime producer AND a libc producer/consumer." >&2 exit 1 fi if [[ -z "${WASMLD_RT_SO_PARTITION_OUT:-}" ]]; then echo "wasmld: FATAL: WASMLD_RT_SO=1 requires WASMLD_RT_SO_PARTITION_OUT= — the published per-op suspender partition is the mandatory Track M record on every status-channel dynamic link (I3)." >&2 exit 1 fi fi if [[ "$LIBC_SO" == "1" && "$LIBC_IMPORT" == "1" ]]; then echo "wasmld: FATAL: WASMLD_LIBC_SO=1 and WASMLD_LIBC_IMPORT=1 are BOTH set — one link cannot be the libc.so producer AND a libc.so-importing main (PLAN-DYNAMIC-LIBC)." >&2 exit 1 fi # WASMLD_SO_SUSPENDERS_OUT (section (10) below) is refused here, before the # link it would otherwise cost. A -r partial link publishes nothing and ignores it. _so_susp_reloc=0 for _a in "$@"; do [[ "$_a" == "-r" || "$_a" == "--relocatable" ]] && _so_susp_reloc=1 done if [[ -n "${WASMLD_SO_SUSPENDERS_OUT:-}" && $_so_susp_reloc != 1 ]]; then if [[ $IS_SHARED != 1 ]]; then echo "wasmld: FATAL: WASMLD_SO_SUSPENDERS_OUT on a link that is not -shared — a main publishes its coroutine exports through WASMLD_DYNMAIN_SUSPENDERS_OUT (BRINTOS-2323)." >&2 exit 1 fi if [[ "$LIBC_SO" == "1" || "$RT_SO" == "1" ]]; then echo "wasmld: FATAL: WASMLD_SO_SUSPENDERS_OUT on the libc.so or runtime producer link — those publish a verdict partition (WASMLD_LIBC_SO_PARTITION_OUT, WASMLD_RT_SO_PARTITION_OUT), not a names list." >&2 exit 1 fi fi if [[ "$LIBC_SO" == "1" && $IS_SHARED != 1 ]]; then echo "wasmld: FATAL: WASMLD_LIBC_SO=1 without -shared — the libc.so producer is a refinement of the -shared side-module arm (PIC whole-closure dylib), never a main link (PLAN-DYNAMIC-LIBC §3.1)." >&2 exit 1 fi if [[ "$LIBC_IMPORT" == "1" ]]; then if [[ $IS_SHARED == 1 ]]; then echo "wasmld: FATAL: WASMLD_LIBC_IMPORT=1 on a -shared link — a side module's libc imports bind through the loader's needed-graph, not through the main consumer arm (PLAN-DYNAMIC-LIBC; use WASMLD_HWJS_XMOD_PARTITION_LIBC for a third .so consuming libc.so's published partition)." >&2 exit 1 fi if [[ "$DYNAMIC_MAIN" == "0" ]]; then echo "wasmld: FATAL: WASMLD_LIBC_IMPORT=1 requires WASMLD_DYNAMIC_MAIN=1 — a libc.so-importing main is a dynamic main by definition (imports the shared table; the loader binds module #1). Mirror of the WASMLD_RT_IMPORT guard (wasmcc)." >&2 exit 1 fi if [[ "$DYNAMIC_MODULE" == "0" ]]; then echo "wasmld: NOTE (WASMLD_LIBC_IMPORT): implying WASMLD_DYNAMIC_MODULE=1 — a libc.so-importing main is a dynamic module (F117 records + hwjs.abi manifest + the partition-keyed import-suspend seam)." >&2 DYNAMIC_MODULE=1 fi fi # WASMLD_NEEDED_LIBS=[:...] — the NON-LIBC half of the needed-list # (PLAN-DYNAMIC-LIBC §3.4 D4, generalised). Each path names an already-built side # module this main records a needed-entry for; the loader finds it by the # digest-qualified name on the kernel's search path and instantiates it before the # main, then binds the main's env fn imports from its exports. libc.so keeps its own # dedicated arm: it is module #1 by construction and carries the partition digest # lockstep no other library has, so it is never spelled through this knob. NEEDED_LIBS_RAW="${WASMLD_NEEDED_LIBS:-}" if [[ -n "$NEEDED_LIBS_RAW" && "$LIBC_IMPORT" != "1" ]]; then echo "wasmld: FATAL: WASMLD_NEEDED_LIBS is set on a link that is not the dynamic-main consumer arm (WASMLD_LIBC_IMPORT=1) — the needed-list rides ONE carrier appended by that arm, and an image that does not import libc.so is one the loader walks no graph for." >&2 exit 1 fi # BRINTOS-884: whether `-lNAME` may resolve to lib.so. # # ON for the dynamic-main consumer arm AND for a -shared side module. A static # link cannot consume a .so at all (wasm-ld refuses one by name), so it stays # off there and `-lNAME` keeps resolving to lib.a exactly as before, # which is what makes this change inert on the static distro. # # The -shared arm RESOLVES but does not RECORD: a side module carries no needed # list of its own (§3.4 single-carrier), so resolve_dash_l DROPS the hit and # leaves the symbol undefined for the loader to bind. Without the arm in this # gate that branch is unreachable, and a -shared link against a CONVERTED # dependency — whose .a no longer exists — dies on the .a search instead. # # WASMLD_DASH_L_STATIC=1 forces the old behaviour back on the dynamic arm — the # `-Bstatic` of this driver, for a caller that wants the archive even where a # shared object is on the path. It is an escape hatch, not a build posture: a # main that takes the archive carries the library's bytes and de-duplicates # nothing, which is the whole point of the migration. DASH_L_PREFERS_SHARED=0 # BRINTOS-1415 fixed a library consuming another library BY PATH; -lNAME was # never covered, because this gate excluded the -shared arm entirely. Once a # dependency converts, its .a is gone, so `-ltalloc` on a -shared link found # nothing and libtevent died. Both arms may now RESOLVE a .so; what they DO # with it differs, below. if [[ ( "$LIBC_IMPORT" == "1" || $IS_SHARED == 1 ) && "${WASMLD_DASH_L_STATIC:-0}" != "1" ]]; then DASH_L_PREFERS_SHARED=1 fi # Coro-arm toolchain surfaces (HWJS-3; same pin discipline as cc-wasm.sh). # HWJS_LLVM_BIN is defined at the top of the file (the WASM_LD default # rides it too). HWJS_NM/HWJS_AR default into the toolchains build, never # emsdk; their explicit override knobs still win. HWJS_PLUGIN="${WASMLD_HWJS_PLUGIN:-$HWJS_PLUGIN_HOME/HwjsCoroutinize.so}" # Track E pre-lowering plugin (PLAN-CXX-STATUS-CHANNEL §5.1; STATUS-CHANNEL-CONTRACT §3/§5.2): # HwjsEHPreLower — invoke → call + status check, landingpad/cleanup → ordinary blocks, # resume → return-UNWINDING. Loaded ONLY when the status-channel world is requested # (HWJS_STATUS_CHANNEL=1, or the pass's own HWJS_EH_PRELOWER=1 force-on); with both # unset/0 the plugin is not loaded; native EH is discarded before coroutinizing. HWJS_PRELOWER_PLUGIN="${WASMLD_HWJS_PRELOWER_PLUGIN:-$HWJS_PLUGIN_HOME/HwjsEHPreLower.so}" # Park-exit return-attribute truth plugin (the operator-new unreachable class, # 2026-07-23): HwjsRetAttrTruth — strips frontend RETURN-VALUE attrs # (noundef/nonnull/noalias/dereferenceable*/align/range/nofpclass) from # converted (presplitcoroutine) functions + their direct call sites, loaded # AFTER HwjsCoroutinize so it sees the converted module at PipelineStartEP # before default. Without it, a converted function whose frontend stamped # noundef+nonnull on the return (libc++abi's _Znwm) has its freeze(poison) # park exit folded to `unreachable` by the O2 pipeline — any park inside it # traps the lane (measured: the musl-dynamic thrstress churn trap). Loaded # under the SAME condition as the pre-lowering (the status-channel world is # where park exits return freeze(poison)); byte-identical otherwise. HWJS_RETTRUTH_PLUGIN="${WASMLD_HWJS_RETTRUTH_PLUGIN:-$HWJS_PLUGIN_HOME/HwjsRetAttrTruth.so}" HWJS_LIBC_BC="${WASMLD_HWJS_LIBC_BC:-$HWJS_TOOLS_DIR/build/musl-bc}" # COMPILE triple of the program bitcode (BrintOS hwjs vendor). wasmcc/wasmcc-glibc # export this so the helper bitcode wasmld synthesizes below (abi_keepalive.c, # main_bridge.c) — and the ONE plugin compile of the merged module — is stamped # with the SAME triple the program TUs carry. Matching triples make llvm-link (and # the plugin compile of merged.bc) see a single triple, so the "Linking two modules # of different target triples" warning never fires. Default = the musl compile # triple; the glibc arm exports wasm32-hwjs-linux-gnu. (The final wasm-ld stage # takes no --target — a wasm object's OS field is irrelevant to the linker.) if [[ $IS_SHARED == 1 ]]; then # A side module is libc-less PIC code: default to the plain LINK triple # (the fixture recipe's --target=wasm32-hwjs-unknown). A caller that # compiled its TUs under a -linux triple (e.g. through wasmcc, which # exports WASMLD_HWJS_COMPILE_TARGET) keeps its own triple. HWJS_COMPILE_TARGET="${WASMLD_HWJS_COMPILE_TARGET:-wasm32-hwjs-unknown}" else HWJS_COMPILE_TARGET="${WASMLD_HWJS_COMPILE_TARGET:-wasm32-hwjs-linux-musl}" fi # NON-PIC MERGED IMAGE. The whole-program main image wasmld emits is ALWAYS # non-PIC (see the DYNAMIC-MAIN note below: "the main itself is NOT PIC; only # dlopen'd side modules are -shared/PIC"). This is independent of the per-TU PIC # level of the inputs — the musl pool is non-PIC and the glibc pool is PIC IR, but # BOTH link into a non-PIC main. It also MUST be non-PIC because the crt/glibc # reference linker-synthesized bounds (__init_array_start/end, __fini_array_*) # that are undefined at this compile: non-PIC emits absolute relocs wasm-ld can # resolve, whereas PIC emits R_WASM_MEMORY_ADDR_REL_SLEB against those undefined # symbols and the link fails. The *-linux compile triple defaults to PIC, so force # it off here for the synthesized helpers + the merged-module codegen. HWJS_PIC_ARGS=( -fno-pic -fno-PIE ) # Dynamic-libc consumer arm (S5 mechanism (a′), PLAN-DYNAMIC-LIBC §3.4): the ONE merged-module plugin compile # flips to -fPIC so genuinely-undefined DATA lowers to GOT-based access (loader-provided mutable # GOT.mem. globals under --unresolved-symbols=import-dynamic below) while main-defined data rides # lld's internalized-GOT path. The main stays FIXED-LAYOUT (no -pie at link): layout/exec-header/brk/ # fork contract unchanged. The __init_array_start REL_SLEB concern in the comment above did not # reproduce on the musl shape (S5 §4 — those symbols are defined at the final link); the glibc arm # re-audits it. if [[ "$LIBC_IMPORT" == "1" ]]; then HWJS_PIC_ARGS=( -fPIC ) fi # ── The final link_coro undefined-symbol policy ────────────────────────────────────────────── # # --allow-undefined IS NOT ONE POLICY, IT IS TWO. lld spells it out (Driver.cpp:865-870): # # // Legacy --allow-undefined flag which is equivalent to # // --unresolve-symbols=ignore + --import-undefined # # and those two halves cover DISJOINT symbol classes, with opposite consequences: # # --import-undefined turns an undefined FUNCTION into an env. import. The # symbol is then VISIBLE in the finished module's import section, # and the PHANTOM-IMPORT witness below judges every one of them # against the sanctioned surface. A guest that references a # function nobody defines therefore already fails the LINK. # --unresolved-symbols=ignore additionally swallows undefined DATA. lld emits an # i32.const 0 for the address and says nothing. Nothing is added # to the import section, so NO post-link witness can see it — # the module is indistinguishable from one where the author # genuinely wrote a null pointer. This is the class that shipped # `cpuinfo_op` bound to address 0 in vmlinux and took the machine # down through a call_indirect on a null ->start. # # SO THE STATIC ARM TAKES THE FIRST HALF ONLY. Undefined data becomes `wasm-ld: error: : # undefined symbol: ` — refused at the link, named, on the arm that ships. Everything else is # bit-for-bit what it was: an undefined function still imports, a WEAK undefined datum still # resolves to 0 (the ELF semantics a guest author expects from `extern __attribute__((weak))`, which # --unresolved-symbols=import-dynamic would instead have broken with a REL-reloc error). # # THE CONFTEST-PROBE FLOW IS UNAFFECTED, AND THAT IS A MEASURED CLAIM, NOT A HOPE. autoconf probes # feature availability by LINKING a program against a declared-but-maybe-absent symbol and reading # the exit status. Those probes are function probes (AC_CHECK_FUNCS / AC_CHECK_LIB / AC_SEARCH_LIBS # all emit `char f(); ... f();`), and their fate is UNCHANGED here: they still link to an env import # and are still refused by the phantom-import witness, which is a non-zero exit exactly as before. # The handful of DATA probes autoconf does emit (tzname, sys_siglist, program_invocation_name…) were # answered "yes, and the address is 0" under the old flag — a FALSE YES that made the package # compile a reference to address 0 into its shipped binary. They now answer "no" and the package # takes its fallback. There is no probe-vs-image discriminator here because there is nothing to # discriminate: a link that binds a datum to 0 is wrong in a probe and wrong in an image, and the # probe's own contract (non-zero exit = feature absent) is satisfied by the refusal. # # THE ONE THING --import-undefined DOES NOT COVER: an undefined wasm GLOBAL (addrspace(1)). lld's # allowUndefined() tests `isa(sym) && ctx.arg.importUndefined`, so a global is # neither imported nor ignored — it is a hard error. But an undefined global is in the SAME # visibility class as an undefined function (it becomes env.:global in the import section and # the phantom-import witness judges it), not in the invisible class we are closing. The pass creates # exactly this shape — EHPreLowerPass's `__hwjs_status_channel_addr`, an external i32 addrspace(1) # GlobalVariable — and it CANNOT declare its own import at the definition site the way # hwjs_user_rt.c's linux.syscall does, because clang's import_module/import_name attributes are # Function-only and WebAssemblyAsmPrinter emits them only for Functions. So wasmld re-derives the # sanction from the LINK INPUTS' OWN SYMBOL TABLES at every link — undefined symbols of Type GLOBAL, # read out of the objects being linked (derive_undef_global_allow below) — and hands lld that list # via --allow-undefined-file=. No name is written down anywhere; a new host global is sanctioned by # existing, and a DATA symbol can never enter the set because it is a different Type. # # The consumer arm keeps --unresolved-symbols=import-dynamic — S5's exact spelling: there, undefined # data BECOMES a GOT.mem import (loud at load, witness-fatal at link) because that arm is -fPIC and # a loader really is going to bind it. NEVER append one flag to the other: with both # --allow-undefined and --unresolved-symbols=* present --allow-undefined unconditionally wins # regardless of order (lld Driver.cpp:865-870) — the exclusivity assertion before the final link # refuses that line. CORO_UNDEF_FLAG=( --import-undefined ) [[ "$LIBC_IMPORT" == "1" ]] && CORO_UNDEF_FLAG=( --unresolved-symbols=import-dynamic ) # RED KNOB — the ONE token that puts the address-0 data bind back, for the gate that proves this # refusal is load-bearing. A gate whose subject row merely says "the link failed" cannot tell a # working tripwire from a broken toolchain; the RED row re-links the SAME object with the SAME # driver under this token and requires it to SUCCEED, and requires the produced module to carry NO # import for the missing datum — the invisibility that is the whole defect. It is arm-owned (the # caller-supplied spelling stays a refusal), it is spelled with RED in its name so no build script # can reach for it as a workaround, and every link that consults it says so on stderr, so an image # built under it is never mistaken for a normally-linked one. # Gate: distros/linux-6.12-musl-busybox/tools/run-guestundef-gate.mjs. if [[ "${WASMLD_RED_ALLOW_UNDEFINED_DATA:-0}" == "1" ]]; then CORO_UNDEF_FLAG=( --allow-undefined ) echo "wasmld: NOTE (RED WASMLD_RED_ALLOW_UNDEFINED_DATA=1): undefined-symbol policy forced back to --allow-undefined — undefined DATA will bind SILENTLY to address 0 and emit no import. This is the gate's RED arm, never a build posture." >&2 fi HWJS_NM="${WASMLD_HWJS_NM:-$HWJS_LLVM_BIN/llvm-nm}" # reads pinned-22 bitcode symbols (scan-proven) HWJS_AR="${WASMLD_HWJS_AR:-$HWJS_LLVM_BIN/llvm-ar}" HWJS_OBJDUMP="${WASMLD_HWJS_OBJDUMP:-$HWJS_LLVM_BIN/llvm-objdump}" # PREEMPTIBILITY native-input loop scan HWJS_READOBJ="${WASMLD_HWJS_READOBJ:-$HWJS_LLVM_BIN/llvm-readobj}" # undefined-GLOBAL sanction derivation # Internal variable-size LLVM commands must not put an archive's member list # back into exec's bounded argv block. This is the GNU response encoding used # by brint-tool's SymbolResponseFile: quote every argument, escaping only '\' # and '"'. Bash %q is NOT LLVM's response syntax. These internal tools use the # POSIX GNU tokenizer; wasm-ld's caller-selected quoting policy is separate. # No helper subprocess receives the expanded list. Keep one ordered tool run # (not chunked links, which change member/override semantics), and preserve its # failure status. Callers retain their existing diagnostics and post-gates. run_llvm_bulk() { local _bulk_tool="$1" _bulk_rsp _bulk_arg _bulk_status=0 shift _bulk_rsp="$(mktemp "${WORK:-${TMPDIR:-/tmp}}/brintld-llvm.XXXXXX")" || { echo "brintld: cannot create internal LLVM response file for '$_bulk_tool'" >&2 return 1 } if ! { for _bulk_arg in "$@"; do # LLVM's GNU tokenizer drops empty quoted tokens. None of these internal # filename/option lists permits one: refuse instead of silently omitting it. if [[ -z "$_bulk_arg" ]]; then echo "brintld: empty internal LLVM argument for '$_bulk_tool'" >&2 _bulk_status=1 break fi _bulk_arg="${_bulk_arg//\\/\\\\}" _bulk_arg="${_bulk_arg//\"/\\\"}" printf '"%s"\n' "$_bulk_arg" || { _bulk_status=1; break; } done [[ $_bulk_status == 0 ]] } > "$_bulk_rsp"; then echo "brintld: cannot encode internal LLVM response file for '$_bulk_tool'" >&2 _bulk_status=1 fi if [[ $_bulk_status == 0 ]]; then if "$_bulk_tool" "@$_bulk_rsp"; then :; else _bulk_status=$?; fi fi if ! rm -f -- "$_bulk_rsp"; then echo "brintld: cannot remove internal LLVM response file '$_bulk_rsp'" >&2 [[ $_bulk_status != 0 ]] || _bulk_status=1 fi return "$_bulk_status" } # End internal LLVM response transport. # derive_undef_global_allow # # Writes the newline-separated names of every symbol that is UNDEFINED and of Type GLOBAL in the # objects/archives on this link line. That set — and nothing else — is what --allow-undefined-file= # sanctions, so the static arm's undefined-symbol policy stays "functions import, globals import, # data is a link error" without a single symbol name living in this file. # # Read from the INPUTS, not from a policy: the same argv the linker is about to consume, at the same # moment, so a set that drifted would have to drift from the objects themselves. A name that is # undefined-DATA anywhere on the line is REFUSED rather than sanctioned even if some other object # declares it a global — a kind collision is exactly the ambiguity this must not resolve silently. derive_undef_global_allow() { local out="$1"; shift local -a objs=() local a for a in "$@"; do [[ "$a" == -* ]] && continue [[ -f "$a" ]] || continue case "$a" in *.o|*.a|*.obj) objs+=( "$a" ) ;; esac done : > "$out" [[ ${#objs[@]} -gt 0 ]] || return 0 [[ -x "$HWJS_READOBJ" ]] \ || fatal "the undefined-GLOBAL sanction is DERIVED from the link inputs' symbol tables, and llvm-readobj ('$HWJS_READOBJ') is not executable — so the set cannot be derived. Refusing rather than falling back to --allow-undefined, which would silently re-open the address-0 data bind (point WASMLD_HWJS_READOBJ at the pinned llvm-readobj)." local syms syms="$(run_llvm_bulk "$HWJS_READOBJ" --symbols "${objs[@]}" 2>/dev/null)" \ || fatal "llvm-readobj could not read the symbol tables of the final link inputs [${objs[*]}], so the undefined-GLOBAL sanction cannot be derived. Refusing rather than guessing." # One record per `Symbol { … }` block: Name, Type, and whether UNDEFINED appears in its Flags. local pairs pairs="$(awk ' /^[[:space:]]*Symbol \{/ { name=""; type=""; undef=0; next } /^[[:space:]]*Name:/ { name=$2; next } /^[[:space:]]*Type:/ { type=$2; next } /UNDEFINED/ { undef=1; next } /^[[:space:]]*\}/ { if (name != "" && undef) print type "\t" name; name=""; type=""; undef=0 } ' <<<"$syms" | LC_ALL=C sort -u)" local globals datas collide globals="$(awk -F'\t' '$1=="GLOBAL"{print $2}' <<<"$pairs" | LC_ALL=C sort -u)" datas="$(awk -F'\t' '$1=="DATA"{print $2}' <<<"$pairs" | LC_ALL=C sort -u)" if [[ -n "$globals" && -n "$datas" ]]; then collide="$(LC_ALL=C comm -12 <(printf '%s\n' "$globals") <(printf '%s\n' "$datas") || true)" [[ -z "$collide" ]] \ || fatal "KIND COLLISION on the final link line: [$(tr '\n' ' ' <<<"$collide")] is undefined as BOTH a wasm GLOBAL and a DATA symbol across the inputs. Sanctioning it as a global would let the data reference bind to address 0 under cover of the global's import; refusing it as data would break the global's host binding. Neither is decidable here, so this is a refusal: make the two references agree on what the symbol is." fi [[ -z "$globals" ]] || printf '%s\n' "$globals" > "$out" } # Find the -o (or -o) the caller requested so we can # link to a temporary and post-process into the final path. OUT="" args=("$@") for ((i = 0; i < ${#args[@]}; i++)); do a="${args[$i]}" if [[ "$a" == "-o" ]]; then OUT="${args[$((i + 1))]}" break elif [[ "$a" == -o* ]]; then OUT="${a#-o}" break fi done # BRINTOS-WORKAROUND: brintos/hwjs-cc#64 revisit — this block IS the fix and lives on emc-dev, so it is not removed when the issue closes: when #64 closes, check that main answers both probes and drop this commit at the next emc-dev rebase. # Answer the two GNU-ld probes autoconf/libtool put to $LD. Neither is a link: # libtool builds shared libraries with `$CC -shared` and only asks the linker # what it IS. Left unanswered, _LT_LINKER_SHLIBS falls into its NON-GNU `linux*` # arm, whose only inner case is tcc, so it sets nothing at all -- archive_cmds # stays EMPTY while ld_shlibs keeps its initialised `yes`. libtool then reports # shared-library support, writes the .so version symlinks, and never runs a # command to create the file they name. That silence is the whole of hwjs-cc#64. # # -v / --version must carry "GNU" -> with_gnu_ld=yes # --help must carry ": supported targets: ... elf" -> ld_shlibs stays yes # # BOTH, or the GNU arm's own `$LD --help` gate sets ld_shlibs=no and there is # still no library -- loudly this time, but still none. # # The version phrase is upstream lld's own: it prints "(compatible with GNU # linkers)" for exactly this reason, and this fork's LLVM substitutes its # repository URL for it. The targets line is a compatibility declaration for # autoconf's host table, not a claim to emit ELF -- this linker emits wasm, and # the ELF-spelled options libtool generates once it believes us are consumed in # the argument loop below. # # Probes only: a real link always carries -o, and this never fires on one. if [[ -z "$OUT" ]]; then for a in "$@"; do case "$a" in -v|--version) _gnu_ld_v="$("$WASM_LD" --version 2>/dev/null | head -1)" echo "${_gnu_ld_v:-LLD} (compatible with GNU linkers)" exit 0 ;; --help) "$WASM_LD" --help echo "wasmld: supported targets: elf32-wasm32" exit 0 ;; esac done fi # Force-exported process ABI. _start + __wasm32_child_return + the asyncify/ # args buffers were always here; the tls/stack/entry/signal half was added at # D1-BASH.c (blocker 2). All come from wasm_thread.S / crt1_musl.c, which are # linked into EVERY image, so each --export always resolves and survives # --gc-sections. Forcing them here makes the HardwareJS process ABI a LINKER # INVARIANT instead of a per-target opt-in: the spawn handler dispatches # __wasm32_user_entry, and fork-resume needs {get,set}_tls_base so the child # can restore __tls_base (without it musl __post_Fork's __pthread_self() derefs # a near-null pointer and the child OOBs before any user code). bash's # generated link bypassed the tools-Makefile per-target --export list, dropping # these and crashing every bash fork (subshell/pipe/&) in the child. The # tools-Makefile's 7x per-target repetition can later collapse onto this default. link() { "$WASM_LD" \ --no-entry \ --export=_start \ --export=__wasm32_child_return \ --export=__wasm32_asyncify_buf_addr \ --export=__wasm32_asyncify_buf_size \ --export=__wasm32_args_buf_addr \ --export=__wasm32_args_buf_size \ --export=__wasm32_get_tls_base \ --export=__wasm32_set_tls_base \ --export=__wasm32_get_stack_pointer \ --export=__wasm32_set_stack_pointer \ --export=__wasm32_user_entry \ --export=__wasm32_set_signal_region \ --export=__heap_base \ --export=__data_end \ --export=__wasm_call_ctors \ --allow-undefined \ --import-memory=env,user_memory \ --shared-memory \ --initial-memory=$((INIT_PAGES * 65536)) \ --max-memory=$((MAX_PAGES * 65536)) \ -z stack-size=$STACK \ --gc-sections \ "$@" } # Coro-arm final link: the asyncify buffer accessors no longer exist # (compiled out of the coro crt — they would be --export hard errors), and # the hwjs drive/witness surface is force-exported instead: # - hwjs_resume/done/destroy (hwjs_user_thunks.o) + __hwjs_take_parked # (rt TU; also auto-exported via export_name — listed for invariance): # the worker.mjs pump's drive ops (design §4), # - __hwjs_parked/__hwjs_parked_owner (DATA → address-valued globals): # the pump's adopt-once read (the kernelWorker top-loop pattern), # - __hwjs_park_reason/__hwjs_park_result (DATA): the fork/exec service # protocol slots (design §3), # - arena diagnostics (DATA): the terminal report + the depth-gate # high-water evidence, # - __hwjs_promise_i32 export-if-defined: pass-emitted reader, present # only when some natural i32 return rides a promise slot. # §3.5 (PLAN-DYNAMIC-LIBC): the force-export block is a FILE-LEVEL ARRAY so the # WASMLD_LIBC_IMPORT=1 arm can run the force-export OWNER AUDIT over it and substitute the audited # list (owner=main kept; owner=libc.so dropped — the host looks those up on module #1; owner=rt # dropped under WASMLD_RT_IMPORT — module #0; a symbol NO side owns is a loud refusal at the audit). # --export-if-defined entries pass through unaudited (if-defined cannot mis-fire). CORO_EXPORT_SURFACE=( --export=_start --export=__wasm32_args_buf_addr --export=__wasm32_args_buf_size --export=__wasm32_get_tls_base --export=__wasm32_set_tls_base --export=__wasm32_get_tls_size --export=__wasm32_get_tls_align --export-if-defined=__wasm_init_tls --export=__wasm32_get_stack_pointer --export=__wasm32_set_stack_pointer # PLAN-THREADS §4 step 5: the per-instance user-RT lane id (the cpu_id.S # userland twin in wasm_thread.S, linked into every coro image via the # startfiles). HARD exports like the tls/sp setters beside them: the # pinned wasm-ld's --export-if-defined does not surface these # native-object definitions (observed on the first lane-stamped link), # and a stale pre-lane wasm_thread object should be a LOUD link error # here, not a silently lane-incapable image. --export=__wasm32_set_lane_id --export=__wasm32_get_lane_id --export=__hwjs_lane_id --export=__wasm32_user_entry --export=__wasm32_set_signal_region --export=__heap_base --export=__data_end --export=__wasm_call_ctors --export-if-defined=__wasm32_ctors_run_in_start --export=hwjs_resume --export=hwjs_done --export=hwjs_destroy --export=__hwjs_take_parked --export=__hwjs_parked --export=__hwjs_parked_owner --export=__hwjs_park_reason --export=__hwjs_park_result --export=__hwjs_frame_live --export=__hwjs_frame_high_water --export=__hwjs_frame_allocs --export-if-defined=__hwjs_progress_violations # The undelivered-violation witness. HardwareJS reads these DATA symbols from # the image's exports at a terminal trap (violationWitness.mjs); the laned # pair in a laned RT, the flat pair otherwise. They also have to be DCE roots # below, or the internalize pre-pass makes them local before this can bind. --export-if-defined=__hwjs_violation_pending_lanes --export-if-defined=__hwjs_violation_buf_lanes --export-if-defined=__hwjs_violation_pending --export-if-defined=__hwjs_violation_buf # The two pass-emitted BUDGET handlers: the M6 startup sanity trap and the # safepoint expiry arm. CoroutinizePass emits each as a WEAK default the port # may override, so the main DEFINES both -- and nothing exported them. The # kernel-embedded runtime IMPORTS both (env.__hwjs_budget_uninitialized, # env.__hwjs_budget_not_refilled) and the loader binds a runtime import from # the MAIN's exports, so an unexported handler aborts the load the first time # the budget arm is reached: "rt import env. called before the main # provided it". Measured on a dynamic bash under the released HardwareJS # 0.1.0-dev.3 -- adding these surfaced both, 30 exports -> 32 (BRINTOS-2814). --export-if-defined=__hwjs_budget_uninitialized --export-if-defined=__hwjs_budget_not_refilled --export-if-defined=__hwjs_promise_i32 --export-if-defined=__wasm32_child_return # drive_abi=4 §4 host terminus surface (STATUS-CHANNEL-CONTRACT §4 last # paragraph, the HWJS_DRIVE_DONE_LIVE dispatch; §10 "export whitelists"). # EVERY image shape needs these two, static included — the host's DONE # recipient reads the channel word/payload and calls the hook when a chain # completes under the driver with a live outcome: # __hwjs_status_channel the per-thread channel BLOCK (data; defined # only in RT-carrying abi-4 images — if-defined # keeps abi<=3 images byte-identical). The RT ops # self-export via HWJS_RT_OP export_name; a DATA # symbol has no such attribute, hence this entry. # __hwjs_uncaught_unwinding the terminus hook. The RT's WEAK fallback # carries export_name, but when libc++abi's # STRONG definition wins the link the weak def's # export attribute is dropped with it — so a C++ # image (exactly the one that needs terminate # fidelity) lost the export without this entry. --export-if-defined=__hwjs_status_channel --export-if-defined=__hwjs_uncaught_unwinding # Fork stop-the-world handshake surface (PLAN-THREADS §3 I1(b), step 7: # fork under threads). DATA words the forking lane's host servicer and # every sibling lane's pump coordinate through (Atomics over the shared # linear memory); defined only in a LANED RT (HWJS_RT_LANES>1) — # if-defined keeps single-lane images byte-identical. The companion op # __hwjs_fork_child_collapse self-exports via HWJS_RT_OP export_name. --export-if-defined=__hwjs_fork_stw --export-if-defined=__hwjs_fork_stw_seq --export-if-defined=__hwjs_fork_stw_ack --export-if-defined=__hwjs_fork_stw_inslice ) CORO_EXPORTS_AUDITED=() # The WideSlot promise-reader family (__hwjs_promise_a), DERIVED from the merged closure by # the §3.5 owner audit rather than hand-listed beside its scalar siblings __hwjs_promise_i32/i64: # CoroutinizePass emits one reader per distinct ALIGNMENT a wide return uses, so the set is open and # a fixed list is a list that goes stale silently. Empty on every arm that does not run the audit, so # a static image stays byte-identical. CORO_EXPORTS_DERIVED=() # Set by the §3.5 force-export OWNER AUDIT when it actually ran (the libc.so-consumer arm and the # runtime-import arm). link_coro substitutes the audited list on exactly that condition — never on # "the array happens to be non-empty", which would silently ship an empty surface if the audit were # ever skipped. EXPORT_AUDIT_RAN=0 link_coro() { # DYNAMIC-MAIN (HWJS-5 STEP 2): source __indirect_function_table from env so a # dlopen'd PIC side module shares it (host-owned, like memory). Empty = the # default self-contained static image (the main defines+owns its own table). local dyn_table="" local -a dyn_exports=() if [[ "$DYNAMIC_MAIN" != "0" ]]; then dyn_table="--import-table" # DYNAMIC-MAIN .so->main runtime binding (HWJS-5 M2/M3): a dlopen'd PIC side # module imports the shared coro runtime + SjLj marker ABI + registrar ops # from env; the host wires the .so's env.* imports to the MAIN's exports (the # one instance owning the frame arena / parked slot / SjLj table). So the # main must EXPORT every op a .so may import. export-if-defined: each is # present only when some TU pulls it in (harmless no-op otherwise), so the # set is a superset of {answer,suspder,ljmod,forkmod,e8mod}.so's union. # frame arena + parked : __hwjs_frame_alloc/free/publish, set/clear_parked # promise/return-slot : __hwjs_promise_i32 (also listed below) # per-module registrars : __hwjs_register_suspends/_fpcast_adapter/_capsummary # SjLj marker ABI (M3-lj): hwjs_lj_arm_from + the record/match/select/value/ # clear/unwound family + hwjs_sj_record # flat xfer ops : __hwjs_xfer_descend/park/return/take # fork PARK LEAF (M3-fork): _Fork (+ fork wrapper) — bound to .so's env.fork # The registry CONSULTS (__hwjs_e8_refuse / capsummary lookup) are the MAIN's # IMPORTS the host sources from the kernel registry, not exports — omitted. # HWJS-5 M1 additions: # __stack_pointer : the process shadow-stack pointer GLOBAL (hard # --export, not if-defined: wasm-ld synthesizes it # for every non-relocatable link; legal because # +mutable-globals is in target_features). The # export is the mechanism by which the host hands # the process sp Global to a dlopen'd .so (a PIC # side module imports env.__stack_pointer). # __hwjs_ds_region_abi_v2: the DS-region version sentinel — a region-using # .so carries an UNDEFINED reference to it (the # never-silent v1/v2 pairing check); the host binds # it from the main's runtime. # __hwjs_promise_i64 : the i64 natural-return promise reader (the i32 # twin was already exported below); a .so whose # cascade reads an i64 promise imports it. # __hwjs_noreturn_completed: the F108 noreturn-breach witness — a .so with # a converted noreturn pump imports it. dyn_exports=( --export=__stack_pointer --export-if-defined=__hwjs_frame_alloc --export-if-defined=__hwjs_frame_free --export-if-defined=__hwjs_frame_publish --export-if-defined=__hwjs_set_parked --export-if-defined=__hwjs_clear_parked --export-if-defined=__hwjs_register_suspends --export-if-defined=__hwjs_register_fpcast_adapter --export-if-defined=__hwjs_register_capsummary --export-if-defined=__hwjs_ds_region_abi_v2 --export-if-defined=__hwjs_promise_i64 --export-if-defined=__hwjs_noreturn_completed --export-if-defined=hwjs_lj_arm_from --export-if-defined=hwjs_sj_record --export-if-defined=hwjs_lj_match --export-if-defined=hwjs_lj_selector --export-if-defined=hwjs_lj_value --export-if-defined=hwjs_lj_clear --export-if-defined=hwjs_lj_unwound --export-if-defined=__hwjs_xfer_descend --export-if-defined=__hwjs_xfer_park --export-if-defined=__hwjs_xfer_return --export-if-defined=__hwjs_xfer_take --export-if-defined=_Fork --export-if-defined=fork # drive_abi=4 status-channel surface (STATUS-CHANNEL-CONTRACT §10: # "New ops join DLOPEN_MAIN_OWNED_OPS and the export whitelists"). # export-if-defined: present only in RT-carrying images; today's abi≤3 # closures that lack them are byte-identical. All modules in a process # must address the MAIN's one channel block (§1) — these exports are # how a dlopen'd .so's env.__hwjs_status_* imports bind to it. --export-if-defined=__hwjs_status_channel --export-if-defined=__hwjs_status_park_exit --export-if-defined=__hwjs_status_baton --export-if-defined=__hwjs_status_clear --export-if-defined=__hwjs_status_adopt_top --export-if-defined=__hwjs_drive_adopt_parked --export-if-defined=__hwjs_status_raise_unwind --export-if-defined=__hwjs_status_raise_longjmp --export-if-defined=__hwjs_status_sj_register --export-if-defined=__hwjs_status_violation --export-if-defined=__hwjs_status_longjmp_lost # §4 terminus hook (2026-07-21, the uncaught-exit-0 fix): the EH # pre-lowering's process-exit-sink checks call it, so a check-emitting # .so (libc.so's own exit path) imports it; the main's definition — # libc++abi's strong std::terminate route in a C++ image, the RT's # loud weak fallback otherwise — must be the one every module binds. --export-if-defined=__hwjs_uncaught_unwinding ) # WASMLD_DYN_EXPORT_LIBC (HWJS-5 M1): space-separated symbol names a # fixture/distro wants surfaced from the MAIN for .so's to import (e.g. # "nanosleep read" so a side module's libc calls bind to the main's musl). # Each becomes --export-if-defined= (if-defined: the main only # exports what its closure actually carries — a misspelled name is then # visible as the .so's unresolved env import, the loud open-world path). # Loudly NOTEd so a widened export surface is always on the record. if [[ -n "${WASMLD_DYN_EXPORT_LIBC:-}" ]]; then echo "wasmld: NOTE (WASMLD_DYN_EXPORT_LIBC): exporting libc symbol(s) [${WASMLD_DYN_EXPORT_LIBC}] from the dynamic main for .so imports." >&2 for _dyn_sym in ${WASMLD_DYN_EXPORT_LIBC}; do dyn_exports+=( "--export-if-defined=${_dyn_sym}" ) done fi fi # BOOTSTRAP TLS export ABI (host pre-ctor __tls_base materialisation): # worker.mjs allocates a bootstrap TLS block in the per-image brk window and # calls __wasm_init_tls(block) BEFORE __wasm_call_ctors, so glibc ctors that # touch __thread state pre-main (malloc's `tcache`, a .tdata pointer reloc) # run against a real, materialised __tls_base instead of 0 (which smashes # heap metadata). __wasm32_get_tls_size/align size the block; __wasm_init_tls # is the lld-synthesized helper that sets __tls_base AND materialises the # .tdata template (memory.init + .tdata pointer relocs). __wasm_init_tls is # emitted only for a TLS image, so it is export-if-defined (a TLS-less image # would otherwise --export-error). A musl image built before this change does # NOT export these, so the worker's early-init is skipped for it and musl # takes its unchanged _start-time self-init path (no regression). local -a coro_exports=( "${CORO_EXPORT_SURFACE[@]}" ) if [[ "$EXPORT_AUDIT_RAN" == "1" ]]; then coro_exports=( "${CORO_EXPORTS_AUDITED[@]}" ) fi coro_exports+=( ${CORO_EXPORTS_DERIVED[@]+"${CORO_EXPORTS_DERIVED[@]}"} ) # --import-undefined arm only: re-derive the undefined-GLOBAL sanction from THESE inputs (see the # policy note at CORO_UNDEF_FLAG). Derived inside the link function, off the very argv about to be # linked, so the memory-auto-resize RE-link derives it again rather than reusing a stale set. local -a undef_flags=( "${CORO_UNDEF_FLAG[@]}" ) if [[ "$LIBC_IMPORT" != "1" ]]; then local _uga="$WORK/undef-globals.allow" derive_undef_global_allow "$_uga" "$@" undef_flags+=( "--allow-undefined-file=$_uga" ) echo "wasmld: NOTE (UNDEFINED-SYMBOL POLICY): ${CORO_UNDEF_FLAG[*]} (undefined DATA is a LINK ERROR, not an address-0 bind — UNLESS the RED knob is on, which says so above); undefined wasm GLOBALs sanctioned from the inputs' own symbol tables: [$(tr '\n' ' ' < "$_uga")]." >&2 fi "$WASM_LD" \ $dyn_table \ "${dyn_exports[@]}" \ --no-entry \ "${coro_exports[@]}" \ "${undef_flags[@]}" \ --error-limit=0 \ --import-memory=env,user_memory \ --shared-memory \ --initial-memory=$((INIT_PAGES * 65536)) \ --max-memory=$((MAX_PAGES * 65536)) \ -z stack-size=$STACK \ --gc-sections \ "$@" } # -shared SIDE-MODULE final link (HWJS-5 M1). Flag audit (each verified against # the pinned wasm-ld + the d1-dlopen fixture shapes): # --experimental-pic REQUIRED: -shared refuses to link without it. # -shared the point of the arm (PIC dylib: env.__memory_base/ # __table_base imports, __wasm_apply_data_relocs). # --no-entry a .so has no _start (harmless under -shared, explicit). # --import-table redundant under -shared PIC (the table import is # implied) but kept EXPLICIT: the shared # __indirect_function_table is the load-bearing dlsym # identity contract (ADR-0004), not an accident of PIC. # --shared-memory marks the env.memory import SHARED: the host memory # is the process SharedArrayBuffer, and a non-shared # memory import refuses to instantiate against it. # --no-check-features the merged.o's feature section need not match # wasm-ld's shared-memory expectations (atomics without # the full TLS feature set); feature policing here is # noise — the fixture recipe carries it for the same # reason. # --allow-undefined REQUIRED: the .so's libc/runtime refs are env imports # the host binds to the main's exports at dlopen. # --export-if-defined=__wasm_call_ctors the host loader's runCtors() # dispatch (the F117 registrars run through it; the # pinned brintos wasm-ld does NOT auto-export it). # --export-if-defined=__wasm_apply_data_relocs data-slot modules need the # loader to apply their PIC data relocations. # Caller-specified --export= flags arrive via LINK_FLAGS. link_shared() { "$WASM_LD" \ --experimental-pic \ -shared \ --no-entry \ --import-table \ --shared-memory \ --no-check-features \ --allow-undefined \ --export-if-defined=__wasm_call_ctors \ --export-if-defined=__wasm_apply_data_relocs \ "$@" } # libc_pool_glob ARRAY DIR: append DIR/*.bc to ARRAY in C collation. Bash sorts # a glob by the caller's LC_COLLATE and the member pull is first-provider-wins, # so the pool order has to be a function of the member names alone: otherwise # the locale picks which of two twin members joins the link, and the same # inputs give two images. libc_pool_glob() { local LC_ALL=C local -n _lpg_out="$1" _lpg_out+=( "$2"/*.bc ) } fatal() { echo "wasmld: FATAL (HWJS-3 coro arm): $*" >&2 exit 1 } file_kind() { # bc | wasm | archive | thin | other (by magic; extensions lie under autotools) local magic magic="$(head -c 8 "$1" | od -An -tx1 | tr -d ' \n')" case "$magic" in 4243c0de*) echo bc ;; 0061736d*) echo wasm ;; 213c617263683e0a) echo archive ;; # "!\n" 213c7468696e3e0a) echo thin ;; # "!\n" -- an ar archive of path references *) echo other ;; esac } # BRINTOS-1445: is $1 a GNU ld LINKER SCRIPT, decided by CONTENT? # # GNU ld accepts a linker script as a POSITIONAL input -- a non-object # operand is parsed as its command language -- and libtool passes one that # way: attr 2.5.1 emits `-Wl,$(top_srcdir)/libattr/libattr.lds` with NO # option in front of it, beside the `--version-script,exports` the ELF-only # filter handles (BRINTOS-2364: that spelling is now FORWARDED, this one is # still consumed -- see the note at the consume site). There is no option token to key on, so the path # falls through to classify_input, which reads magic, gets `other`, and # refuses the link. # # BY CONTENT, NEVER BY NAME -- the same discipline the side-module .so drop # uses: readline names bitcode object files `readline.so`, `vi_mode.so` ..., # so an extension test drops real inputs. Anything carrying an object magic # is rejected here before the grammar is even looked at, and a binary is # rejected on the NUL test, so only a text file that actually parses as ld # command language is claimed. Everything else still reaches classify_input # and still refuses loudly -- this narrows the refusal, it does not silence it. _hwjs_is_linker_script() { local f="$1" body [[ -f "$f" && -r "$f" ]] || return 1 # An object of any kind is not a script, whatever it is called. case "$(file_kind "$f")" in bc|wasm|archive|thin) return 1 ;; esac # Must be TEXT. A NUL byte in the first 8 KiB means binary. Tested through # od because a NUL cannot survive as a shell argument -- `grep $'\x00'` # passes an EMPTY pattern, which matches every file and would claim them all. head -c 8192 "$f" | od -An -tx1 | LC_ALL=C grep -qw 00 && return 1 body="$(LC_ALL=C sed -e 's:/\*[^*]*\*/::g' "$f" 2>/dev/null)" || return 1 # A symbol-version script: `"name@VER" = target;` -- attr's shape. printf '%s\n' "$body" \ | LC_ALL=C grep -qE '"[A-Za-z_][A-Za-z0-9_]*@+[A-Za-z0-9_.]+"[[:space:]]*=[[:space:]]*[A-Za-z_]' \ && return 0 # ... or a visibility list. printf '%s\n' "$body" | LC_ALL=C grep -qE '(^|[[:space:]{])(global|local)[[:space:]]*:' && return 0 # ... or a script proper: a top-level ld directive introducing a block/list. printf '%s\n' "$body" \ | LC_ALL=C grep -qE '(^|[[:space:]])(VERSION|SECTIONS|MEMORY|PHDRS|GROUP|INPUT|OUTPUT_FORMAT|OUTPUT_ARCH|ENTRY|STARTUP|TARGET)[[:space:]]*[({]' \ && return 0 return 1 } # MERGED-COMPILE FAILURE DECODE # Names what ended a failed merged-module compile when clang itself could not. # A compile killed by a signal prints no reason, so "the reason is dumped above" # sends the reader through a pass log that does not contain one (hwjs-cc#48). # Prints one sentence and returns 0 when it recognises the ending, else returns 1. merged_compile_failure_reason() { local rc="$1" err="$2" sig name if [[ "$rc" -gt 128 && "$rc" -le 192 ]]; then sig=$(( rc - 128 )) name="$(kill -l "$sig" 2>/dev/null || echo "$sig")" if [[ "$name" == KILL ]]; then echo "clang was killed by SIGKILL (rc=$rc) and printed no reason: on a link this size that is the kernel OOM killer or a cgroup memory limit, not a pass REFUSE. Check the host journal for an oom-kill of clang." else echo "clang was killed by SIG$name (rc=$rc) and printed no reason of its own." fi return 0 fi if grep -q 'LLVM ERROR: out of memory' "$err" 2>/dev/null; then echo "clang ran out of memory (rc=$rc, 'LLVM ERROR: out of memory'): an allocation failed under the host's memory or an address-space limit, not a pass REFUSE." return 0 fi local crash crash="$(grep -m1 -o 'clang frontend command failed due to signal[^(]*' "$err" 2>/dev/null || true)" if [[ -n "$crash" ]]; then echo "the compiler crashed (rc=$rc, '${crash% }'), not a pass REFUSE; the stack dump is in the pass log above." return 0 fi return 1 } # ============================================================ # WHOLE-PROGRAM LINK ADMISSION CONTROL — the merged-module compile # ============================================================ # The ONE pinned-clang compile of the merged module is a single process holding # the whole program's IR, and it is where a distro build's memory peak is spent. # Measured over 7922 tasks of one image build by each task's largest child, EVERY # task above 4 GB was one of these compiles and the largest reached 11.34 GB, # while the largest of the 7538 that were not reached 3.50 GB. Nothing upstream of # it can bound how many run at once — a build system sizes its own task # parallelism, a recipe's `make -j` starts links underneath that, and the real # concurrency is the product, which no scheduler can see. The OOM then lands on # whichever link happens to be largest rather than on the build that caused it. # # So the links count themselves. Slot files under a host-global directory; an # entrant takes as many as its own link is charged, under a short exclusive lock, # and waits when the host has no room for it. Three properties follow that no # scheduling-side mechanism has: # # * It counts the LINKS, not the tasks that start them, so it holds however # deeply a recipe nests its own parallelism. # * Release is the kernel's. An flock(2) is dropped when the fd closes, # INCLUDING on SIGKILL, so an OOM-killed link cannot strand a slot and there # is nothing to reap. # * The budget is recomputed at every acquisition, so it shrinks as the host # fills and grows back as it empties. A build-time thread count cannot: it is # fixed for the life of the run. # # EACH LINK IS CHARGED WHAT ITS OWN MODULE COSTS, not what the largest module in # the corpus costs. Pricing every entrant at the worst entrant makes a large host # admit as few links as a small one: charging a flat 12 GB, a 125 GB / 32-core # host admitted eight, and one image build then logged 10991 slot-waits against # 7674 links — p90 187 s, p99 488 s, max 808 s, 4605 waits over a minute — while # its load average sat at 9-13 of 32. The threads were asleep in here, not # computing, and 79% of admissions found every slot held. # # The estimator is the merged module itself: merged.bc is complete on disk at the # moment of acquisition, so its size is known for free, and the compile that reads # it scales with it. Measured by sampling /proc VmHWM of 246 real links from 28 # packages against the merged.bc each was reading: # # merged.bc peak RSS GB per MB package # 2.4 MB 0.33 GB 0.136 pngcheck — one tiny binary # 5.7 MB 1.96 GB 0.346 gsl # 8.9 MB 2.02 GB 0.227 e2fsprogs # 11.3 MB 2.91 GB 0.257 tcpdump # 14.3 MB 4.22 GB 0.295 sqlite3 # 19.3 MB 7.04 GB 0.365 ctags # 20.7 MB 6.46 GB 0.312 openssh # # The relation is NOT linear, and that is the whole reason a single price per # megabyte was never going to work: fitted over those 246 links, # # peak_GB = 0.0906 * bc_MB ^ 1.430 # # GB-per-MB nearly triples across the measured range, so a price calibrated on # small links under-charges big ones, and one calibrated on big links keeps # over-charging the small ones — which is the state this replaces. Extrapolated to # 30 MB the law gives 11.7 GB, against the 11.34 GB the buildstats corpus measured # for openssl by an entirely different instrument, so the extrapolation past the # sampled range is trusted as far as the cap and no further. # # The charge is therefore a QUADRATIC UPPER ENVELOPE of that law, in integer # arithmetic a shell can do: # # charge_GB = ceil( bc_MB * (bc_MB + SHOULDER) / DIVISOR ), floored, capped # # It under-charges none of the 246. Its margin is 1.53x at the tightest (gsl, # 1.96 GB against 3 charged), 2.12x at the tenth percentile and 2.91x at the # median, for a mean charge of 2.04 GB against the flat 12 — six times cheaper. # # THAT MARGIN IS NOT PADDING, and the number was moved twice by measurement rather # than by taste. The same package at the same module size peaks 23% apart between # runs (util-linux, 6.17 MB -> 1.389 GB and 6.29 MB -> 1.714 GB), so an envelope # that merely touched the observed peaks would be under the next run of the very # link it was fitted to. Two successive settings were rejected for sitting inside # that noise — one at 1.17x on the 6-7 MB band, one at 1.02x on gsl, each of which # looked fine until the corpus grew. The achievable minima are quantised (charges # are whole GB), so the practical choices were 1.02x, 1.53x and 1.70x, and 1.53x is # the cheapest that clears the noise floor. # # The envelope reaches the cap at 16 MB, where the law says 4.9 GB — so the # heaviest links go on paying the corpus worst case, exactly as they did before. # The win is not at the top: it is that a 2 MB link stops being priced like an # 11 GB one. # # The cap is only as safe as the corpus it came from. It is set AT the measured # high-water mark, so a module heavier than anything in that corpus would be # under-charged — the same exposure the flat price had, and the reason a new # heaviest link owes a fresh sweep (scripts/link-bc-rss.py, with # scripts/link-replay.sh to generate the load) rather than a re-reading of this # comment. # # ADMISSION IS FIFO, and that is not decoration. A budget shared between unequal # entrants starves the expensive ones: a link needing twelve slots never wins a # race against links needing one, and a build that arrives second never overtakes # a build already holding the directory. Each waiter therefore takes a ticket — # itself an flock'd file, so a killed waiter's ticket dies with it — and may only # take slots that no OLDER live waiter still needs. Fairness between builds falls # out of the same rule, because ticket order is arrival order across every account # on the host. # # The wait cannot deadlock: a waiter holds no slot, so there is no hold-and-wait # cycle; the queue is served in arrival order; and a host with nothing running # admits the entrant whatever it is charged, or a machine smaller than one link # would never link at all. # # WASMLD_LINK_SLOTS=N uniform mode: every link is charged one slot, N of # them, skipping the derivation entirely. 0 turns the # admission control off. This is the deterministic # bound test/link-slot-semaphore.sh drives. # WASMLD_LINK_SLOT_GB cap on ONE link's charge (default 12 — the 11.34 GB # high-water mark rounded up). # WASMLD_LINK_MIN_GB floor on one link's charge (default 1). # WASMLD_LINK_SHOULDER_MB the envelope's linear term (default 12) and # WASMLD_LINK_DIVISOR its scale (default 40). Together they are the fit # above; move them only with a fresh sweep, and read # the whole corpus's margin distribution when you do, # not just the links that motivated the change. # WASMLD_LINK_RESERVE_GB GB left to the rest of the host. Default is a tenth # of MemTotal, floored at 4: it is the NEIGHBOUR's # breathing room as much as ours, and a constant sized # for a 31 GB host leaves a 125 GB one the same 4 GB # while admitting many times the links. # WASMLD_LINK_BUDGET_MAX_GB absolute ceiling on the derived budget; unset by # default, i.e. the memory arithmetic decides. This # replaces WASMLD_LINK_SLOTS_MAX, which counted LINKS # — a second ceiling in a different currency is what # made a large host admit eight. # WASMLD_LINK_SEM_FIFO=0 drop the queue and admit greedily. # WASMLD_LINK_SEM_DIR slot directory (default /var/tmp/brintos-wasmld.sem) # WASMLD_LINK_SEM_TIMEOUT seconds to wait before proceeding anyway; 0 (the # default) waits, which is safe for the reason above # WASMLD_LINK_SEM_MEMINFO an alternate /proc/meminfo, so the derivation can be # driven against a host of a known size LINK_SEM_DIR="${WASMLD_LINK_SEM_DIR:-/var/tmp/brintos-wasmld.sem}" LINK_SLOT_GB="${WASMLD_LINK_SLOT_GB:-12}" LINK_MIN_GB="${WASMLD_LINK_MIN_GB:-1}" LINK_SHOULDER_MB="${WASMLD_LINK_SHOULDER_MB:-12}" LINK_DIVISOR="${WASMLD_LINK_DIVISOR:-40}" LINK_SEM_FDS=() LINK_SEM_TKT="" LINK_SEM_TKT_FD="" LINK_SEM_TKT_SEQ="" link_sem_note() { echo "wasmld: NOTE (link-sem): $*" >&2; } link_sem_meminfo_gb() { awk -v k="$1:" '$1 == k { print int($2 / 1048576); found=1; exit } END { if (!found) print -1 }' \ "${WASMLD_LINK_SEM_MEMINFO:-/proc/meminfo}" 2>/dev/null || echo -1 } # GB left to the rest of the host. SCALED, because this number is what a # NEIGHBOUR gets to run in: a flat 4 GB is the whole non-build half of a 31 GB # laptop and a rounding error on a 125 GB shared host, where admission has many # times the links to leave room for. link_sem_reserve_gb() { local total="$1" r [ -n "${WASMLD_LINK_RESERVE_GB:-}" ] && { echo "$WASMLD_LINK_RESERVE_GB"; return; } r=$(( total / 10 )) [ "$r" -ge 4 ] || r=4 echo "$r" } # What one link is charged, in GB, from the merged module it is about to compile. # Echoes " ". The FLOOR applies when the file cannot be sized at # all, so an unreadable merged.bc is charged like a small link rather than like # nothing. link_sem_charge_gb() { local bc="${1:-}" bytes mb gb bytes=$(stat -c%s "$bc" 2>/dev/null || echo 0) case "${bytes:-x}" in ''|*[!0-9]*) bytes=0 ;; esac mb=$(( bytes / 1048576 )) # Rounded UP: a module one byte into the next GB of charge pays for it. gb=$(( (mb * (mb + LINK_SHOULDER_MB) + LINK_DIVISOR - 1) / LINK_DIVISOR )) [ "$gb" -ge "$LINK_MIN_GB" ] || gb="$LINK_MIN_GB" [ "$gb" -le "$LINK_SLOT_GB" ] || gb="$LINK_SLOT_GB" echo "$gb $mb" } # The host's whole budget in GB, given how much is already held. # # TWO bounds, and the smaller wins, because neither is sufficient alone: # # * What MemAvailable says is free right now, plus what the held slots already # account for. This is the half that sees a co-tenant — another team's build, # a VM, an engineer's scratch tree — none of which will ever take a slot. # * What the host could EVER hold, from MemTotal. This is the half that survives # the growth lag: a link admitted a second ago has allocated almost nothing # yet, so MemAvailable still reads its budget as free and the next entrant # would be sized against memory that is already spoken for. Charging the SLOTS # instead of believing the reading is what stops that. # # Echoes the budget, or 0 when there is no memory figure to derive one from (a host # without /proc/meminfo): no figure is not a reason to serialize every link on a # developer's machine, so the caller turns the mechanism off rather than guess. link_sem_budget_gb() { local held="$1" total avail reserve b_static b_free b total=$(link_sem_meminfo_gb MemTotal) avail=$(link_sem_meminfo_gb MemAvailable) { [ "$total" -gt 0 ] && [ "$avail" -gt 0 ]; } 2>/dev/null || { echo 0; return; } reserve=$(link_sem_reserve_gb "$total") b_static=$(( total - reserve )) b_free=$(( avail - reserve )) [ "$b_free" -ge 0 ] || b_free=0 b=$(( held + b_free )) [ "$b" -le "$b_static" ] || b="$b_static" [ "$b" -ge 0 ] || b=0 if [ -n "${WASMLD_LINK_BUDGET_MAX_GB:-}" ] && [ "$b" -gt "$WASMLD_LINK_BUDGET_MAX_GB" ]; then b="$WASMLD_LINK_BUDGET_MAX_GB" fi echo "$b" } # " ..." over the whole slot universe. # # The universe is the DIRECTORY, never a computed count: two accounts deriving # different budgets would otherwise scan different numbers of files, and the one # that scanned fewer would not see the other's holders at all. # # flock(2) locks are per open file description, so our own held slots are counted # by this too — which is what makes the count usable from inside a holder. A slot # that cannot be OPENED is counted HELD: these directories are shared by every # account on the host, and a slot file another account created under a restrictive # umask is one nobody else can lock. Reading that as free is the one failure mode # that would leave the mechanism silently unbounded, so it errs the other way — # and the caller turns the semaphore off outright when NO slot is usable, rather # than wait forever on locks it can never take. link_sem_scan() { local f fd held=0 usable=0 free="" # Numerically, not in glob order, so the LOWEST-numbered slots are always # preferred. That matters while a host still carries builds running the previous # flat-charge driver, which only ever looks at slot.1 through slot.8: taking # 1,2,3... makes our holdings visible inside its window, where glob order # (slot.1, slot.10, slot.100) would hide them and let it admit against memory we # have already claimed. for f in $(printf '%s\n' "$LINK_SEM_DIR"/slot.* | sort -t. -k2,2n); do [ -e "$f" ] || continue # Tested before it is opened, and opened with NO redirection of its own: a # `2>/dev/null` on an `exec` redirection is applied to the SHELL, not to the # exec, so silencing the open here would silence every line this driver # printed for the rest of its life. if [ ! -w "$f" ] || ! exec {fd}>>"$f"; then held=$(( held + 1 )) continue fi usable=$(( usable + 1 )) if flock -n "$fd" 2>/dev/null; then free="$free $f" else held=$(( held + 1 )) fi exec {fd}>&- done echo "$held $usable$free" } # GB still owed to waiters that arrived BEFORE us — the reservation that makes # admission fair. A ticket whose flock can be taken is a DEAD waiter: its holder # was killed, so it reserves nothing and is unlinked where this account may. link_sem_reserved_older_gb() { local mine="$1" t fd sum=0 stale=0 want base for t in "$LINK_SEM_DIR"/queue/t.*; do [ -e "$t" ] || continue base="${t##*/}" [[ "$base" < "t.$mine" ]] || continue # Read-only, and no `[ -w ]` pre-test. The queue is a sticky world-writable # directory shared by every account, bash opens `>>` with O_CREAT, and # fs.protected_regular refuses that on another account's file there however # permissive the file's own mode — while `[ -w ]` is access(2) and answers # yes. So the write-open skipped a foreign waiter's reservation whole, # leaving the bound unenforced across accounts, which is the one case it # exists for. flock(2) is advisory and mode-independent, so a read fd still # tells a live waiter from a dead one. exec {fd}<"$t" || continue if flock -n "$fd" 2>/dev/null; then exec {fd}>&- # A dead waiter reserves nothing. The unlink is best-effort — the sticky # bit refuses another account's ticket — so count what it refuses, or the # leak is invisible in the direction that reads as healthy. rm -f "$t" 2>/dev/null || stale=$(( stale + 1 )) continue fi exec {fd}>&- want=$(head -c 8 "$t" 2>/dev/null | tr -dc '0-9') sum=$(( sum + ${want:-0} )) done # Two fields, because this runs in a command substitution and a variable set # here would not survive it — the same subshell trap link_sem_ticket_take # documents. The second is the tickets a dead waiter left that this account is # not allowed to unlink; they cost nothing but an empty file, and saying so is # what keeps the leak from being invisible. echo "$sum $stale" } # Our place in the queue: a file named for the microsecond we arrived, holding an # flock for as long as we wait and carrying what we are waiting FOR, so an older # waiter's need is visible to everyone deciding whether to go ahead of it. # # It sets LINK_SEM_TKT_SEQ rather than echoing it, and that is load-bearing: a # command substitution is a SUBSHELL, so a ticket taken inside one would have its # fd closed the instant the substitution finished. The kernel would drop the lock, # every other entrant would read the ticket as a dead waiter reserving nothing, and # the queue would degrade to greedy admission while still looking present — which # is precisely what test case 11 caught. link_sem_ticket_take() { local gb="$1" seq [ "${WASMLD_LINK_SEM_FIFO:-1}" = 0 ] && return 1 seq="${EPOCHREALTIME:-}"; seq="${seq/./}" [ -n "$seq" ] || seq=$(date +%s%6N 2>/dev/null) || return 1 LINK_SEM_TKT="$LINK_SEM_DIR/queue/t.$seq.$$" ( umask 0000; : >>"$LINK_SEM_TKT" ) 2>/dev/null || { LINK_SEM_TKT=""; return 1; } exec {LINK_SEM_TKT_FD}>>"$LINK_SEM_TKT" || { LINK_SEM_TKT=""; return 1; } # Non-blocking, on our own ticket, so it must be free — and if it somehow is # not, the queue is unusable and greedy admission beats hanging on it. flock -n "$LINK_SEM_TKT_FD" 2>/dev/null || { link_sem_ticket_drop; return 1; } printf '%s' "$gb" >>"$LINK_SEM_TKT" 2>/dev/null || true LINK_SEM_TKT_SEQ="$seq.$$" } link_sem_ticket_drop() { local fd if [ -n "$LINK_SEM_TKT_FD" ]; then fd="$LINK_SEM_TKT_FD"; exec {fd}>&-; LINK_SEM_TKT_FD=""; fi if [ -n "$LINK_SEM_TKT" ]; then rm -f "$LINK_SEM_TKT" 2>/dev/null || true; LINK_SEM_TKT=""; fi LINK_SEM_TKT_SEQ="" return 0 } link_sem_acquire() { local bc="${1:-}" fixed="${WASMLD_LINK_SLOTS:-}" timeout="${WASMLD_LINK_SEM_TIMEOUT:-0}" local waited=0 announce=0 nap lockfd held usable free slots reserved stale=0 budget local charge mb took=0 f fd i [ "$fixed" = 0 ] && return 0 if ! command -v flock >/dev/null 2>&1; then link_sem_note "no flock(2) on this host — the merged-module compile runs unadmitted" return 0 fi mkdir -p "$LINK_SEM_DIR/queue" 2>/dev/null || { link_sem_note "$LINK_SEM_DIR is not creatable — the merged-module compile runs unadmitted" return 0 } # Sticky, so no account can delete another's slot or ticket; and every file is # made world-writable AT CREATION, which is the only moment its mode is set. A # slot first created under umask 022 is one no other account can ever lock. chmod 1777 "$LINK_SEM_DIR" "$LINK_SEM_DIR/queue" 2>/dev/null || true if [ -n "$fixed" ]; then # Uniform mode: N links, one slot each, no derivation at all — which is what # makes it a deterministic bound to test against. charge=1; mb=-1; budget="$fixed" else read -r charge mb <<<"$(link_sem_charge_gb "$bc")" budget=$(link_sem_budget_gb 0) fi # Enough 1 GB slot files for the whole budget, and never fewer than this # entrant's charge, so a single link larger than the budget can still be # admitted on an idle host. slots=$(( budget > charge ? budget : charge )) ( umask 0000 : >>"$LINK_SEM_DIR/.lock" for i in $(seq 1 "$slots"); do : >>"$LINK_SEM_DIR/slot.$i"; done ) 2>/dev/null || true while :; do lockfd="" # Counting and taking are ONE critical section: two links entering in the same # instant must not both read the same free-slot set and both take the last of # it. The lock is never held across the sleep below. if [ -w "$LINK_SEM_DIR/.lock" ] && exec {lockfd}>>"$LINK_SEM_DIR/.lock"; then flock "$lockfd" 2>/dev/null || true fi read -r held usable free <<<"$(link_sem_scan)" if [ "$usable" = 0 ]; then [ -n "$lockfd" ] && exec {lockfd}>&- link_sem_ticket_drop link_sem_note "no slot under $LINK_SEM_DIR is lockable by this account — the merged-module compile runs unadmitted" return 0 fi [ -n "$fixed" ] || budget=$(link_sem_budget_gb "$held") if [ -z "$fixed" ] && [ "$budget" = 0 ]; then [ -n "$lockfd" ] && exec {lockfd}>&- link_sem_ticket_drop link_sem_note "no MemAvailable figure to derive a budget from — the merged-module compile runs unadmitted" return 0 fi # Taken inside the critical section, so our arrival order is the same order # every other entrant reads. Called plainly, never in a substitution — see # link_sem_ticket_take. [ -n "$LINK_SEM_TKT_SEQ" ] || link_sem_ticket_take "$charge" || LINK_SEM_TKT_SEQ="-" reserved=0; stale=0 [ "$LINK_SEM_TKT_SEQ" != "-" ] \ && read -r reserved stale <<<"$(link_sem_reserved_older_gb "$LINK_SEM_TKT_SEQ")" # An idle host admits whatever it is charged — a machine smaller than one link # must still be able to link — but ONLY when nobody arrived before us. Without # that second condition the escape hatch defeats the queue: the moment a holder # releases, the cheap links that arrived last find an empty host and take it, # and the expensive link that has been waiting is passed over again. Otherwise # the charge has to fit under the budget once every older waiter has been left # the room it is waiting for. if { [ "$held" = 0 ] && [ "$reserved" = 0 ]; } \ || [ $(( held + reserved + charge )) -le "$budget" ]; then took=0 for f in $free; do [ "$took" -lt "$charge" ] || break if exec {fd}>>"$f" && flock -n "$fd" 2>/dev/null; then LINK_SEM_FDS+=("$fd"); took=$(( took + 1 )) elif [ -n "${fd:-}" ]; then exec {fd}>&- fi done if [ "$took" -lt "$charge" ]; then # Hold nothing while waiting, or two entrants can each end up holding half # of what they need with neither able to proceed. link_sem_release_slots took=0 fi fi [ -n "$lockfd" ] && exec {lockfd}>&- if [ "$took" -gt 0 ]; then link_sem_ticket_drop link_sem_note "charged ${charge}G$([ "$mb" -ge 0 ] && echo " (merged.bc ${mb}M)"), $(( held + charge ))/${budget}G of whole-program link held on this host${fixed:+ (N pinned)}$([ "$waited" -gt 0 ] && echo ", waited ${waited}s")$([ "${stale:-0}" -gt 0 ] && echo ", ${stale} stale queue ticket(s) this account may not clear")" return 0 fi if [ "$timeout" != 0 ] && [ "$waited" -ge "$timeout" ]; then link_sem_ticket_drop link_sem_note "waited ${waited}s for ${charge}G of a ${budget}G budget — proceeding UNADMITTED (WASMLD_LINK_SEM_TIMEOUT=$timeout)" return 0 fi # Announced on the first wait and then once a minute: a link queued behind # another link and a link that has hung look identical from a build log, and # this is the line that tells them apart. Not `waited % 60`: the nap is # jittered, so the elapsed total steps over any fixed multiple. if [ "$waited" -ge "$announce" ]; then link_sem_note "waiting for ${charge}G — ${held}G of ${budget}G held on this host, ${reserved}G reserved for earlier waiters, ${waited}s elapsed" announce=$(( waited + 60 )) fi # Jittered, so a batch of links released together does not re-collide. nap=$(( (RANDOM % 5) + 3 )) sleep "$nap" waited=$(( waited + nap )) done } link_sem_release_slots() { local fd for fd in ${LINK_SEM_FDS+"${LINK_SEM_FDS[@]}"}; do exec {fd}>&-; done LINK_SEM_FDS=() } link_sem_release() { link_sem_release_slots link_sem_ticket_drop } # ============================================================ # RELOCATABLE LINK (-r / --relocatable) — ticket #7 # ============================================================ # Autotools/libtool convenience merges and Yocto's combine-objects steps # emit `$LD -r a.o b.o -o combined.o`: a PARTIAL link whose output re-enters # a later link line. This is orthogonal to both arms — the coroutinize pass # runs at the FINAL link (whole-program vantage), so a partial link must # simply combine inputs while PRESERVING their substrate kind: # * all-bitcode inputs -> llvm-link merge; the output is ONE bitcode # object (under the requested -o name) that a later coro link # classifies by magic as a root — whole-TU IR still reaches the pass. # * all-native inputs -> a real `wasm-ld -r` with ONLY -r-compatible # flags (none of the final-link memory/export/gc flags, which wasm-ld # rejects alongside -r; and NO startfiles — wasmcc skips its injection # for -r). # * mixed -> the half-substrate chimera refusal (merging # would hide native members inside what looks like one object). # Archives are refused on a partial link: GNU -r archive semantics are # member-selective, which llvm-link cannot honor — pass the member objects. is_reloc=0 for a in "${args[@]}"; do case "$a" in -r|--relocatable) is_reloc=1 ;; esac done if [[ $is_reloc == 1 ]]; then [[ -n "$OUT" ]] || fatal "-r (relocatable link) requires -o ." RELOC_BC=() RELOC_NATIVE=() skip_next=0 for ((i = 0; i < ${#args[@]}; i++)); do a="${args[$i]}" if [[ $skip_next == 1 ]]; then skip_next=0; continue; fi case "$a" in -o) skip_next=1; continue ;; -o*|-r|--relocatable) continue ;; -L) skip_next=1; continue ;; -L*) continue ;; -l*) fatal "-l on a relocatable (-r) link is not supported: GNU -r archive semantics are member-selective; pass the member objects explicitly." ;; -*) continue ;; # flags are moot on a combine step; inputs decide esac if [[ -f "$a" ]]; then case "$(file_kind "$a")" in bc) RELOC_BC+=("$a") ;; wasm) RELOC_NATIVE+=("$a") ;; archive|thin) fatal "archive '$a' on a relocatable (-r) link: GNU -r pulls archives member-selectively, which the bitcode merge cannot honor. Extract and pass the member objects." ;; other) fatal "input '$a' on the -r link is neither bitcode nor a wasm object." ;; esac else fatal "input '$a' on the -r link does not exist." fi done [[ ${#RELOC_BC[@]} -gt 0 || ${#RELOC_NATIVE[@]} -gt 0 ]] \ || fatal "-r link has no inputs." if [[ ${#RELOC_BC[@]} -gt 0 && ${#RELOC_NATIVE[@]} -gt 0 ]]; then fatal "-r link mixes bitcode and native inputs — a half-substrate chimera: the combined object would hide native code inside what a later coro link classifies as one input. Combine each kind separately (bitcode: ${RELOC_BC[*]}; native: ${RELOC_NATIVE[*]})." fi if [[ ${#RELOC_BC[@]} -gt 0 ]]; then run_llvm_bulk "$HWJS_LLVM_BIN/llvm-link" "${RELOC_BC[@]}" -o "$OUT" \ || fatal "llvm-link of the -r bitcode inputs failed." else # Native partial link: bare wasm-ld -r. NONE of the final-link flags # (they are -r-incompatible or meaningless before the final image). "$WASM_LD" -r "${RELOC_NATIVE[@]}" -o "$OUT" \ || fatal "wasm-ld -r of the native inputs failed." fi exit 0 fi # ============================================================ # CORO ARM (WASMLD_HWJS=1) # ============================================================ if [[ "$HWJS" == "1" && -n "$OUT" ]]; then # Coro images carry the static frame-arena seg0 (2 MiB; the rest of the # frame arena grows on demand via SYS_mmap, hwjs_user_rt.c §9) + the 4 MiB # DS-region arena in .bss, PLUS the shadow stack (1 MiB flat default / 8 MiB # nested opt-out, above), PLUS data + heap headroom. # # HWJS-A-5.a: the declared --initial-memory becomes the image's declared # `env.user_memory` import MINIMUM, which the WASM runtime forces every # per-process SAB up to at instantiation. The old generous 512 pages (32 MiB) # therefore pinned a 32 MiB-per-process floor regardless of real usage. With # host-side grow-on-demand (kernelSpawnHandler grow-to-fit + mmap grow), the # initial only needs to cover the static layout (data + the .bss arenas + # stack ≈ 122 pages for bash); the heap/frame-arena grow from there. 130 # pages (8.125 MiB = bash's ~122-page floor + a small margin) is the A-5 # default; an explicit WASMLD_INITIAL_PAGES still wins, and a too-small value # is a LOUD wasm-ld "initial memory too small" error (never-silent), so an # image with a larger static layout fails loudly rather than truncating. INIT_PAGES="${WASMLD_INITIAL_PAGES:-130}" # ── Chimera guard (cc-wasm.sh discipline, link-stage spelling): a coro # link without the plugin would silently produce an untransformed # binary whose first fork parks into nowhere. Refuse loudly. [[ $HWJS_PASSES_LINKED == 1 || -f "$HWJS_PLUGIN" ]] || fatal "WASMLD_HWJS=1 but the HwjsCoroutinize plugin is missing at $HWJS_PLUGIN (the plugin home $HWJS_PLUGIN_HOME) — refresh that home: $HWJS_PLUGIN_REMEDY. Or set WASMLD_HWJS_PLUGIN. Refusing to link an untransformed coro image." # PLUGIN INTEGRITY (the WASMLD_HWJS_PIN_FILE sha-pin system is RETIRED). # WASMLD_HWJS_SRC_DIR names the checkout the loaded plugins must have been # built from; the merged-module pass run below must self-attest # PLUGIN-PROVENANCE commit= dirty=0. A stale artifact # (the checkout moved, the plugins were not rebuilt), a dirty-tree build, or # an attestation-less plugin refuses the link. The checkout itself must be # CLEAN, because HEAD is not an honest expectation over a dirty tree. # # THE EXPECTATION IS DERIVED, NOT ANNOUNCED. Which checkout a plugin here was # built from is not a fact a caller knows better than this script does: the # one home above is inside this checkout, and toolchain/build.sh — the only # thing that fills it — builds from the repository that contains this very # file. Leaving it to each consumer to export produced exactly what the # plugin path did: one driver armed the check and its sibling did not, so # whether an artifact was provenance-checked at all depended on which command # line reached the link. # # ARMED BY DEFAULT. This used to arm only when a super-repo had staged the # plugins into its own build tree, and stay off otherwise, because "otherwise" # meant the pass author's working tree. With one home that distinction no # longer exists, and defaulting to off would mean the shipped toolchain never # checks the thing this mechanism exists for. So it is on, and the loop that # deliberately builds plugins from a dirty tree turns it off the documented # way — WASMLD_HWJS_SRC_DIR set and EMPTY (the `-` default below, not `:-`). # That is a decision an operator now makes visibly instead of one a default # made for them. HWJS_SRC_DIR="${WASMLD_HWJS_SRC_DIR-$HWJS_HOME}" HWJS_EXPECT_COMMIT="" # What the expectation was read from, for the refusals below. HWJS_EXPECT_FROM="the brintium checkout at $HWJS_SRC_DIR" HWJS_EXPECT_BY="WASMLD_HWJS_SRC_DIR enforcement" # hwjs_pass_load # Appends to what makes the tool run one hwjs pass over the merged # module: the plugin to load, or in the linked mode the pass's name, which is # eh-prelower, coroutinize, retattr-truth, descriptor-emit or split. A linked # tool registers a pass only when it is asked for by name, so the passes a # link runs are the same set in both modes: the ones this function is called # for. Their ORDER is not: plugins run in command-line order, linked passes # in the order the tool was built with (hwjs_linked_order_check). hwjs_pass_load() { local -n _hpl_args="$1" if [[ $HWJS_PASSES_LINKED == 1 ]]; then case "$2" in clang) _hpl_args+=( -mllvm "-hwjs-link-pass=$3" ) ;; opt) _hpl_args+=( "-hwjs-link-pass=$3" ) ;; esac else case "$2" in clang) _hpl_args+=( "-fpass-plugin=$4" ) ;; opt) _hpl_args+=( "-load-pass-plugin=$4" ) ;; esac fi } # Defined unconditionally (it is a no-op with no expectation to check # against) so the -shared arm's HwjsDescriptorEmit can call it too, at the # point where that plugin is resolved. hwjs_plugin_stamp_check() { # local so="$1" name="$2" stamp [[ -n "$HWJS_EXPECT_COMMIT" && -f "$so.stamp" ]] || return 0 stamp="$(cat "$so.stamp" 2>/dev/null || true)" [[ "$stamp" == "commit=$HWJS_EXPECT_COMMIT" ]] || fatal \ "PLUGIN IS STALE ($name): $so was built from [$stamp] but the brintium checkout at $HWJS_SRC_DIR is on commit=$HWJS_EXPECT_COMMIT. The plugins are derived artifacts of that checkout and its HEAD is the only pin, so ADVANCING IT INVALIDATES THEM BY IDENTITY even when the new commit changed no pass source. Refresh the home this link resolves ($HWJS_PLUGIN_HOME): $HWJS_PLUGIN_REMEDY. Refused here, on the stamp, rather than after the merged-module compile — the same verdict, minutes earlier." } # hwjs_linked_order_check ... # Under clang, linked passes run in the order the tool was built with, and # this driver cannot set it. In the plugin mode the command line sets it: the # pre-lowering before the coroutinizer, the return-attribute pass after it. # Each pass prints its PLUGIN-PROVENANCE line as it starts, so the order they # ran in is the order of those lines. Echoes what it found out of order and # returns 1. hwjs_linked_order_check() { local log="$1" prev_tag="" prev_n=0 tag n shift for tag in "$@"; do n="$(grep -a -n -m1 -F "[$tag] PLUGIN-PROVENANCE" "$log" | cut -d: -f1)" [[ "$n" =~ ^[0-9]+$ ]] || { echo "[$tag] printed no PLUGIN-PROVENANCE line"; return 1; } (( n > prev_n )) || { echo "[$tag] started at line $n of the pass log, before [$prev_tag] at line $prev_n"; return 1; } prev_tag="$tag"; prev_n="$n" done } if [[ $HWJS_PASSES_LINKED == 1 ]]; then # No checkout and no git: the stamp is the expectation. It cannot be turned # off, so WASMLD_HWJS_SRC_DIR is not read, and a plugin named by hand is # refused rather than loaded beside the same pass in the tool. for _pv in WASMLD_HWJS_PLUGIN WASMLD_HWJS_PRELOWER_PLUGIN WASMLD_HWJS_RETTRUTH_PLUGIN \ WASMLD_HWJS_DEMIT_PLUGIN WASMLD_HWJS_SPLIT_PLUGIN; do [[ -z "${!_pv:-}" ]] \ || fatal "$_pv=${!_pv} is set, but this toolchain's passes are linked into its clang and opt ($HWJS_LINKED_STAMP is present). A plugin cannot be loaded beside them. Unset it, or link with a toolchain that loads plugins." done _stamp="$(cat "$HWJS_LINKED_STAMP" 2>/dev/null || true)" [[ "$_stamp" =~ ^commit=([0-9a-f]{40})$ ]] \ || fatal "$HWJS_LINKED_STAMP must hold exactly one line, commit=<40 hex>, the brintium commit this toolchain's linked-in passes were built from; it holds [$_stamp]. Without it their provenance cannot be checked. Refusing." HWJS_EXPECT_COMMIT="${BASH_REMATCH[1]}" HWJS_EXPECT_FROM="the installed toolchain's $HWJS_LINKED_STAMP" HWJS_EXPECT_BY="the enforcement of $HWJS_LINKED_STAMP" HWJS_PLUGIN_HOME="$HWJS_LLVM_BIN, whose clang and opt carry the passes" HWJS_PLUGIN_REMEDY="reinstall the toolchain, so that its clang, its opt and $HWJS_LINKED_STAMP come from one build" # The bytes a pass runs from are the tool's. The refusals name it. HWJS_PLUGIN="$HWJS_LLVM_BIN/clang" HWJS_PRELOWER_PLUGIN="$HWJS_PLUGIN" HWJS_RETTRUTH_PLUGIN="$HWJS_PLUGIN" elif [[ -n "$HWJS_SRC_DIR" ]]; then HWJS_EXPECT_COMMIT="$(git -C "$HWJS_SRC_DIR" rev-parse HEAD 2>/dev/null || true)" [[ -n "$HWJS_EXPECT_COMMIT" ]] \ || fatal "WASMLD_HWJS_SRC_DIR=$HWJS_SRC_DIR is not a readable git checkout — the plugin's provenance cannot be checked. Refusing." [[ -z "$(git -C "$HWJS_SRC_DIR" status --porcelain 2>/dev/null || echo dirty)" ]] \ || fatal "the brintium checkout at $HWJS_SRC_DIR is DIRTY — HEAD does not describe the source, so the plugin's provenance cannot be an honest expectation. Commit or stash it (a modified or untracked file in a vendored submodule counts, and so does an uncommitted change to a pass source). To link from a working tree on purpose — the pass-development loop — set WASMLD_HWJS_SRC_DIR to the empty string, which says so out loud instead of leaving it to a default. Refusing." # ── EARLY IDENTITY CHECK, on the stamp, before anything expensive. # # The authoritative provenance verdict is still the one below: the pass's # OWN attestation, read out of the run that actually happened. Nothing here # weakens it, and nothing here can pass a link the attestation would refuse. # # What it buys is WHEN. The attestation is only available after the merged # -O2 compile, which is minutes in; before this, the way a stale staged # plugin announced itself was a refusal at the end of a long link — or, # worse, at the end of a long link inside a gate run somebody had already # spent. The installer records the identity it installed in .stamp, so # the same question — "were these bytes built from the commit this checkout # is on?" — is answerable in one file read, and the answer is the same one. # # A MISSING STAMP IS NOT A FAILURE. Only the super-repo installer writes # stamps; a standalone R&D build has none, and neither does an explicitly # overridden WASMLD_HWJS_*_PLUGIN. Those runs simply keep the historical # behaviour and are decided by the attestation below. This fires only where # there IS a recorded identity and it disagrees — which is exactly the pin # bump nobody re-staged. hwjs_plugin_stamp_check "$HWJS_PLUGIN" HwjsCoroutinize hwjs_plugin_stamp_check "$HWJS_PRELOWER_PLUGIN" HwjsEHPreLower hwjs_plugin_stamp_check "$HWJS_RETTRUTH_PLUGIN" HwjsRetAttrTruth fi [[ -x "$HWJS_LLVM_BIN/clang" && -x "$HWJS_LLVM_BIN/llvm-link" ]] || fatal "pinned LLVM toolchain missing at $HWJS_LLVM_BIN (the pass needs the LLVM-22 pin; set WASMLD_HWJS_LLVM_BIN)." [[ "$FPCAST_EMU" != "1" ]] || fatal "WASMLD_FPCAST_EMU=1 with WASMLD_HWJS=1 — the coro arm never runs Binaryen (native fpcast rides the plugin; a coro-bearing module must not reach wasm-opt). Drop one of the two knobs." # The musl pool is a MAIN-link ingredient; a -shared side module links NO # libc (its libc refs are env imports the host binds to the main's exports). # Dynamic-libc arm (WASMLD_LIBC_IMPORT=1): a libc.so-importing main pulls NO pool either — # its libc refs are imports resolved from libc.so (module #1) at load. if [[ $IS_SHARED != 1 && "$LIBC_IMPORT" != "1" ]]; then compgen -G "$HWJS_LIBC_BC/*.bc" > /dev/null || fatal "musl bitcode pool empty/missing at $HWJS_LIBC_BC (run 'make -C sysroot musl-bc' or set WASMLD_HWJS_LIBC_BC)." fi # ── Dynamic-libc arm preconditions (checked here, before any expensive work). if [[ "$LIBC_SO" == "1" ]]; then HWJS_LIBC_BC_PIC="${WASMLD_HWJS_LIBC_BC_PIC:-$HWJS_TOOLS_DIR/build/musl-bc-pic}" HWJS_TLS_ACCESS_BC="${WASMLD_HWJS_TLS_ACCESS_BC:-$HWJS_TOOLS_DIR/build/wasm_tls_access.pic.bc}" compgen -G "$HWJS_LIBC_BC_PIC/*.bc" > /dev/null \ || fatal "WASMLD_LIBC_SO=1: PIC bitcode pool empty/missing at $HWJS_LIBC_BC_PIC (run 'make -C sysroot musl-bc-pic' or set WASMLD_HWJS_LIBC_BC_PIC). The producer full-links the WHOLE PIC pool — the non-PIC pool stays refused on -shared (the S1 RED arm)." if [[ -d "$HWJS_LIBC_BC" ]]; then _pic_real="$(cd "$HWJS_LIBC_BC_PIC" && pwd)" _nonpic_real="$(cd "$HWJS_LIBC_BC" && pwd)" [[ "$_pic_real" != "$_nonpic_real" ]] \ || fatal "WASMLD_LIBC_SO=1: the PIC pool dir ($HWJS_LIBC_BC_PIC) IS the non-PIC pool dir ($HWJS_LIBC_BC) — non-PIC members produce R_WASM_TABLE_INDEX_REL_SLEB refusals under PIC side-module codegen; point WASMLD_HWJS_LIBC_BC_PIC at the dedicated -fPIC rebuild (PLAN-DYNAMIC-LIBC §3.1)." fi [[ -f "$HWJS_TLS_ACCESS_BC" ]] \ || fatal "WASMLD_LIBC_SO=1: the S2 TLS accessor TU is missing at $HWJS_TLS_ACCESS_BC (bitcode builtin getter + inline-asm local setter; built by 'make -C sysroot wasm-tls-access-pic' or set WASMLD_HWJS_TLS_ACCESS_BC). libc.so without it puts a late-bound hop on every errno/stdio-lock access (S2 §4 — refused on shape)." # S1 native-TU disposition (compiler-rt class): the REAL musl closure leaves the # soft-float/wide-int builtins (__addtf3 family, __mul{s,d,t}c3, __multi3, fp_mode shims) # UNDEFINED — on MAIN links they ride libclang_rt.builtins-wasm32.a natively AFTER -lc, which # the -shared all-native refusal (correctly) keeps out. The producer joins them as ONE hidden- # visibility PIC bitcode member (the sysroot build's `make builtins-pic`) so libc.so is self- # contained. OPTIONAL input (a toy closure has no long-double paths); the post-link # IMPORT-SURFACE AUDIT below is the soundness gate — a missing/incomplete builtins TU is a # LINK fatal there, never a load-time LinkError on a live machine. : "${HWJS_LIBC_BUILTINS_BC:=${WASMLD_HWJS_LIBC_BUILTINS_BC:-$HWJS_TOOLS_DIR/build/builtins-wasm32-pic.bc}}" [[ -n "${WASMLD_LIBC_SO_PARTITION_OUT:-}" ]] \ || fatal "WASMLD_LIBC_SO=1 requires WASMLD_LIBC_SO_PARTITION_OUT= — the published per-export suspender partition is the producer's PRIMARY artifact (every main link consumes it; PLAN-DYNAMIC-LIBC §3.2). An unpublished libc.so is unusable by construction." fi if [[ "$LIBC_IMPORT" == "1" ]]; then [[ -n "${WASMLD_LIBC_SO_PATH:-}" && -f "${WASMLD_LIBC_SO_PATH:-}" ]] \ || fatal "WASMLD_LIBC_IMPORT=1 requires WASMLD_LIBC_SO_PATH= — the witness allowlists (env fns, GOT.mem/GOT.func) and the needed-list digest are DERIVED from the built artifact, never hand-maintained (PLAN-DYNAMIC-LIBC §3.6/S5 (i))." [[ -n "${WASMLD_HWJS_XMOD_SUSPENDERS:-}" && -f "${WASMLD_HWJS_XMOD_SUSPENDERS:-}" ]] \ || fatal "WASMLD_LIBC_IMPORT=1 requires WASMLD_HWJS_XMOD_SUSPENDERS= — without the per-export suspend/native verdicts the import-suspend seed mis-partitions in both directions (GAP-B; PLAN-DYNAMIC-LIBC §3.2)." fi # ── TRACK M — MANDATORY PARTITIONS on dynamic links under the status channel # (PLAN-CXX-STATUS-CHANNEL §7, I3 armor). HWJS_STATUS_CHANNEL is the pass's value-keyed # drive_abi=4 knob and flows through this driver into the merged pass run; under it every # module is CHECK-EMITTING and the partition's per-symbol nothrow lane is the surface the # I3 mixing refusals are decided on — so a dynamic link with NO partition surface refuses # HERE, before any expensive work (the pass repeats the refusal at its own vantage, # TRACK-M PARTITION-MANDATORY). Knob unset/0 keeps every legacy arm byte-identical. case "${HWJS_STATUS_CHANNEL:-}" in ""|0|1) : ;; *) fatal "HWJS_STATUS_CHANNEL='${HWJS_STATUS_CHANNEL}' — this knob is VALUE-keyed: 1 (on), 0 (off), or unset/empty (default). Refusing to guess (the pass's envKnobBool enforces the same; issue linux-in-the-browser#41)." ;; esac # ── PLAN-THREADS-SINGLE-MEMORY §4 step 2 — LANE-AWARE CHANNEL ADDRESSING. # HWJS_RT_LANES is the lane COUNT (the same spelling the RT compile uses): # unset/0/1 = single-lane absolute addressing (byte-identical, the default # path); 2..32 = the passes emit every channel access through the # per-instance wasm global __hwjs_status_channel_addr and the hwjs.abi # manifest stamps flags bit5 (lane_addressed). Refusals at the earliest # vantage, mirroring the pass's own laneChannelLanes(): garbage/out-of-range # values, and lanes>1 without the status channel (lane addressing is a # property OF the channel). The post-link CHANNEL-ADDRESSING lockstep check # and the lanes-bit acceptance live further down. HWJS_LANE_ADDR=0 case "${HWJS_RT_LANES:-}" in ""|0|1) : ;; *) [[ "${HWJS_RT_LANES}" =~ ^[0-9]+$ ]] && (( HWJS_RT_LANES >= 2 && HWJS_RT_LANES <= 32 )) \ || fatal "HWJS_RT_LANES='${HWJS_RT_LANES}' — this knob is the lane COUNT: an integer 0..32 (unset/0/1 = single-lane absolute addressing; 2..32 = lane-aware channel addressing; the RT's u32 occupancy bitmap caps at 32). Refusing to guess (PLAN-THREADS-SINGLE-MEMORY §4 step 2; the pass enforces the same)." [[ "${HWJS_STATUS_CHANNEL:-0}" == "1" ]] \ || fatal "HWJS_RT_LANES>1 without HWJS_STATUS_CHANNEL=1 — lane-aware addressing is a property of the status channel (drive_abi=4): with the channel off there is no emitted channel access to lane-address. Set both or neither (PLAN-THREADS-SINGLE-MEMORY §4 step 2)." HWJS_LANE_ADDR=1 ;; esac if [[ "${HWJS_STATUS_CHANNEL:-0}" == "1" && "$DYNAMIC_MODULE" != "0" ]]; then if [[ $IS_SHARED == 1 ]]; then [[ "$LIBC_SO" == "1" || "$RT_SO" == "1" || -n "${WASMLD_HWJS_XMOD_PARTITION_LIBC:-}" ]] \ || fatal "HWJS_STATUS_CHANNEL=1 on a -shared dynamic link that neither PUBLISHES a partition (WASMLD_LIBC_SO=1 + WASMLD_LIBC_SO_PARTITION_OUT, or the runtime producer WASMLD_RT_SO=1 + WASMLD_RT_SO_PARTITION_OUT) nor CONSUMES libc.so's published one (WASMLD_HWJS_XMOD_PARTITION_LIBC) — under the status channel partition manifests are MANDATORY on every dynamic link: without the per-symbol verdict+nothrow lane the check-emitting mixing refusal (I3) is undecidable and a throw could transit an uninstrumented frame SILENTLY (PLAN-CXX-STATUS-CHANNEL §7). A partition-less .so build must drop HWJS_STATUS_CHANNEL." else [[ "$LIBC_IMPORT" == "1" ]] \ || fatal "HWJS_STATUS_CHANNEL=1 on a dynamic MAIN link that does not consume libc.so's published partition (WASMLD_LIBC_IMPORT=1) — under the status channel partition manifests are MANDATORY on every dynamic link (I3: the check-emitting/nothrow mixing refusals are decided on the partition's verdict lane; PLAN-CXX-STATUS-CHANNEL §7). Link the main through the dynamic-libc consumer arm, or drop HWJS_STATUS_CHANNEL." fi fi # ── TRACK E — HwjsEHPreLower wiring (PLAN-CXX-STATUS-CHANNEL §5.1; STATUS-CHANNEL-CONTRACT # §3/§5.2/§5.3). The pre-lowering pass must run BEFORE HwjsCoroutinize in the ONE merged # plugin compile: both register at PipelineStartEP, and start-EP callbacks run in plugin # LOAD order, so ordering is expressed by putting its -fpass-plugin FIRST on the command # line (the EHPreLowerPass header + the eh_prelower.mjs COMPOSITION arm pin exactly this). # Knob discipline mirrors the pass's own preLowerActive(): HWJS_EH_PRELOWER=1 forces on, # =0 forces off, unset follows HWJS_STATUS_CHANNEL. The plugin is LOADED whenever either # knob is 1 — including the STATUS_CHANNEL=1 + EH_PRELOWER=0 contradiction, where the # loaded pass itself refuses an invoke-bearing module (the I3 silent-transit refusal must # fire at the pass's vantage, which requires the plugin to be present). With both unset/0 # the no-EH preparation below removes native unwind edges before coroutinizing. case "${HWJS_EH_PRELOWER:-}" in ""|0|1) : ;; *) fatal "HWJS_EH_PRELOWER='${HWJS_EH_PRELOWER}' — this knob is VALUE-keyed: 1 (on), 0 (off), or unset/empty (follow HWJS_STATUS_CHANNEL). Refusing to guess (the pass's envKnobBool enforces the same; issue linux-in-the-browser#41)." ;; esac EH_PRELOWER_LOAD=0 # load the plugin into the merged compile EH_PRELOWER_ACTIVE=0 # the pass will actually lower (mirror of preLowerActive()) if [[ "${HWJS_STATUS_CHANNEL:-0}" == "1" || "${HWJS_EH_PRELOWER:-0}" == "1" ]]; then EH_PRELOWER_LOAD=1 fi if [[ -z "${HWJS_EH_PRELOWER:-}" ]]; then [[ "${HWJS_STATUS_CHANNEL:-0}" == "1" ]] && EH_PRELOWER_ACTIVE=1 else [[ "$HWJS_EH_PRELOWER" == "1" ]] && EH_PRELOWER_ACTIVE=1 fi # ── CONFIG ATTESTATION (D1-BASH, 2026-06-13). The PLUGIN-PROVENANCE # stamp attests plugin IDENTITY (commit + dirty), but NOT the active # CONFIGURATION: a dirty=0 pin-matched plugin running under a fix- # DISABLING RED knob produces a stale-behavior binary that is SILENT # today (F20 was loud only by luck — it disabled a refusal floor that # happened to hit a never-silent guard; a knob that disabled a # CORRECTNESS fix would ship a WRONG binary, not refuse). The pin # discipline therefore must extend from identity to config. These knobs # disable a fix / re-expose a retired floor / force a pre-fix mis-lower; # NONE may be set for a production coro build. The sole escape hatch is # WASMLD_HWJS_ALLOW_RED_KNOBS=1 — the deliberate negative/non-vacuity # arms (e.g. the F93 collapse repro) which MUST name it explicitly, so a # stale config is visible at this vantage before it produces a phantom # finding or a wrong binary. (The classify-only/boundary/drive sweep # knobs are already env -u'd at the plugin compile below; this is the # correctness-fix-disabling class, which must be LOUD, not silently # unset, so a leak is named — the same leakage class that stranded a # stale WASMLD_HWJS in a shell this round.) # DELIBERATE NON-MEMBERS (documented so the omission is a decision, not an oversight): # HWJS_CORO_BUDGET_SECONDS / HWJS_CORO_BUDGET_WORK_UNITS (issue #16) are OPS knobs, not RED # knobs — a budget changes WHEN the pass gives up (loud BUDGET REFUSE vs silent multi-hour # spin), never WHAT it emits: a raised budget on a legitimately huge module produces the # byte-identical artifact, so there is no stale-behavior/wrong-code class for this # attestation to catch. Raising a budget is legitimate production ops and must not require # the WASMLD_HWJS_ALLOW_RED_KNOBS acknowledgement. Same reasoning excludes # HWJS_CORO_NO_CALLEE_FATE_MEMO (the issue-#16 escape-walk memo's non-vacuity arm) and # HWJS_E8_NO_ROUND_MEMO (F162, brintos/hwjs-cc#1 — the e8-registry round-scoped memo's # non-vacuity arm): the memos are COST fixes — output byte-identical on/off wherever the walk # has depth headroom (coro_budget.mjs FIX-EQUIV; e8_round_memo.mjs EQUIV), and on # depth-pressure SCC shapes the OFF arm can only be MORE CONSERVATIVE (the memo replaces # depth-bail artifacts with completed proofs, one-directionally — e8_round_memo.mjs # DIVERGENCE) — so disabling either can only slow a build to a loud budget refuse or # conservatively refuse/pessimize, NEVER ship a wrong binary (no wrong-code class for the # attestation to catch). HWJS_RED_KNOBS=( HWJS_STATUS_NO_ABI4_ACCEPT # STATUS-CHANNEL-CONTRACT §10: restores the pre-acceptance fail-closed drive_abi=4 refusal (non-vacuity arm for the abi-4 acceptance; can only refuse, never mis-link) HWJS_STATUS_NO_STASH_COLLISION # STATUS-CHANNEL-CONTRACT §3 cleanup-context collision variant: restores the pre-fix routing — a LONGJMP arriving at a stash-context call site (longjmp out of a destructor running during unwind) transits instead of aborting, SILENTLY abandoning the stashed exception and skipping the remaining cleanups (non-vacuity arm: eh_prelower.mjs collision arms) HWJS_STATUS_NO_EXIT_TERMINUS # STATUS-CHANNEL-CONTRACT §4 process-exit-sink terminus: restores the pre-fix hole — an UNWINDING that no frame consumed rides past libc's nounwind exit(main(...)) seam and the process dies by SYS_exit_group(0): an uncaught C++ exception SILENTLY REPORTS SUCCESS (non-vacuity arm: the uncaught-terminus machine gate's red fixture) HWJS_CORO_NO_VARARG_LIFT # criterion v1: the F80 vararg refusal floor (no morph/lift) HWJS_CORO_NO_CRASHSHAPE_LIFT # F20/drop-F92: re-exposes the suspend-before-va_start refusal (morph-skip) HWJS_NO_VARARG_FMT HWJS_NO_VARARG_DYNFMT HWJS_NO_HOSTABI_BOUNCE # disables the host-syscall-write-edge bounce (getcwd-class) HWJS_NO_BOUNCE_NAMED # disables the named host-write bounce HWJS_CORO_NO_ALIAS_EDGE_LIFT # F22/drop: re-exposes the getcwd Q4 read-before-park weak-alias-follow refusal (non-vacuity arm) HWJS_CORO_SJLJ_NO_UNWIND_PATH_CHECK # disables the never-silent 2b-unwind guard HWJS_CORO_F93_SAMEFRAME_COLLAPSE # forces the pre-F93 2a mis-lower (the spin) HWJS_CORO_NO_SJLJ_2C # F21/drop-F93: re-exposes the multi-jmp_buf 2c landing-frame refusal (non-vacuity arm) HWJS_CORO_NO_RAW_SYSCALL_IDENT # F23/drop-F94: re-couples raw-syscall identity to the park set (getcwd refuses at prod boundary) HWJS_DS_NO_SUSPENDARG_LIVE # F24/drop-F95: disables suspend-arg liveness (string_list_dollar_star re-refuses via the floor) HWJS_SJ_NO_SSA_REPAIR # F25/drop-F96: disables SSA-repair of cross-frame splits (dominance-invalid output -> named invalid-output-ssa refusal) HWJS_CORO_NO_MEMEFFECT_CLEAR # F160 (brintos/hwjs-cc#3): disables the memory-effect lie strip (default since F160; the F133 exclusion is WITHDRAWN) -> a G_GNUC_CONST/pure transitively-suspending callee (GLib g_strerror; gnulib btoc32) keeps memory(none)/memory(read), the post-pass optimizer folds the caller's post-call parked-check + DCEs the binding-take/DESCEND, the callee's ramp parks with nobody consuming, and the next park dies "set-over-set: parked slot not empty" (the mcview startup trap; handle-as-value corruption underneath) (non-vacuity arms: pure_suspend_loop.mjs, run-hwjscc3-gstrerror-gate.mjs). (HWJS_CORO_MEMEFFECT_CLEAR, the old F133 opt-in, is now the default and a no-op — deliberately NOT a RED knob.) HWJS_CORO_NO_GLIBC_LONGJMP # F129: disables glibc __longjmp recognition -> the glibc SjLj pair fails to lower (non-vacuity arm: sj_glibc.mjs) HWJS_CORO_NO_SYSCALL_BUF_ESCAPE # F130: disables the syscall-arg-buffer escape lift -> glibc open_verify/read_alias_file refuse vla/...-opaque-sink (non-vacuity arm: syscall_buf_escape.mjs) HWJS_CORO_NO_SIGPROCMASK_REGION_SINK # F165: disables the __wasm32_rt_sigprocmask_region terminal-sink entry in the F130 walk -> the sigprocmask-bridge funnel (glibc 3d199d918e: nr==14 args routed through the region maintainer inside __wasm32_syscall_filtered/_resched) becomes a non-forwarding use and the WHOLE F130 customer family re-refuses at once; read_alias_file sits on bash's setjmp unwind path, so the glibc libc.so producer link goes build-fatal (non-vacuity arm: syscall_buf_escape.mjs) HWJS_CORO_NO_MNTENT_BUF_ESCAPE # F134: disables the word-at-a-time aligned-read escape lift -> glibc __setmntent (fopen->__strchrnul alignment ptrtoint) refuses vla/...-opaque-sink (non-vacuity arm: mntent_buf_escape.mjs) HWJS_CORO_NO_INVOKE_CALLEE_SUSPEND # F131: disables invoke-form callee-suspend normal-edge cascade -> glibc __pthread_once_slow invoke refuses loudly (non-vacuity arm: invoke_cs.mjs) HWJS_CORO_NO_FPCAST_ADAPTER # F132: disables the fpcast signature-adapter -> glibc exit-handler call_indirect cast traps (non-vacuity arm: fpcast_exit.mjs) HWJS_CORO_NO_QSORT_FPCAST # F135: disables the arg-threaded local-struct comparator discovery (Path C) -> glibc qsort 2-arg-through-3-arg comparator traps at bash.msort_with_tmp (non-vacuity arm: fpcast_qsort.mjs) HWJS_CORO_NO_ATEXIT_FPCAST # F136: disables the atexit→__cxa_atexit wrapper-chain registration resolution -> glibc close_stdout (and any atexit-registered handler) is not a static cxa candidate, relegated to the kernel-only/weak-null consult -> the loud abort, and a SUSPENDING handler never resumes itself (the coreutils __run_exit_handlers wall) (non-vacuity arm: fpcast_cxa_wrapper.mjs) HWJS_CORO_NO_OPENWORLD_REG_FPCAST # F164: disables the open-world registration-linkage trigger widening (dynamic modules only) -> a dynamic module's reg-helper dispatch site with no in-module sig-incompatible candidate stays a RAW call_indirect, and a cross-module marshal-needing target (the glibc dynamic main's atexit(close_stdout) Fv through libc.so's Fvii ef_cxa site) traps "null function or signature mismatch" at exit (witnessed: /bin/cat flip bring-up 2026-07-18) HWJS_CORO_NO_CREADER_NOCAPTURE # F137: disables the C pure-reader nocapture recognition (mbrtowc/mbrtoc32/memcmp/strcmp/strlen/...) -> a VLA filename/path buffer read by glibc's INTERNAL reader (gconv-step indirect dispatch / word-at-a-time ptrtoint) refuses vla/dynamic-alloca-address-escape-opaque-sink, re-blocking ls gobble_file / cp copy_internal / df find_mount_point / re_protect (and the F74 setjmp-2b consequence) (non-vacuity arm: creader_nocapture.mjs) WASMLD_HWJS_SYMBOL_GRANULAR_PULL # ticket #8: restores the retired symbol-granular `-only-needed` pool fixpoint -> a pool member imported across two fixpoint passes duplicates its internal (static) globals (encodingTable + encodingTable.616; Tcl_CreateEncoding dispatching through the never-initialized copy) (non-vacuity arm: test/pool-member-granularity.sh) WASMLD_HWJS_POOL_PARTIAL_OVERLAP_SKIP # issue linux-in-the-browser#49: restores the retired skip-the-whole-member partial-overlap dedup -> a needed member that strongly re-defines ONE already-provided symbol is skipped whole and the symbols only IT provides go phantom (tcl's cross-configure compat strtoul.o in libtcl8.6.a made the selector skip musl strtol.o, orphaning strtol/strtoull into env.* imports; the fixed selector pulls the member with the colliding symbols demoted to internal linkage) (non-vacuity arm: test/pool-partial-overlap-pull.sh) HWJS_CORO_NO_CONSTINIT_FPCAST # F141 (site ticket #10 gap 1): disables the constant-initializer ops-table field harvest -> a fn placed in a struct field by a static table literal (Tcl tclStringHashKeyType) is wild-only, a sig-mismatched dispatch through the const table is left a raw call_indirect (silent trap) (non-vacuity arm: fpcast_constinit.mjs) HWJS_CORO_NO_I8GEP_FPCAST # F142 (site ticket #10 gap 2): disables the opaque-pointer i8 byte-GEP field-identity recovery -> a -O2-canonicalized `gep i8, @struct_global, ` store/dispatch (Tcl encodingTable createProc) is wild-only, the sig-mismatched dispatch is left a raw call_indirect (silent trap) (non-vacuity arm: fpcast_i8gep.mjs) HWJS_CORO_NO_GVROOT_FPCAST # F143 (superproject issue #22): disables the GV-rooted precise fpcast evidence -> a GV-rooted dispatch falls back to the (struct type, offset) key, which llvm-link's struct-type unification pollutes across DISTINCT C types (Tcl_ObjType/AuxDataType) -> false field-evidenced REFUSE -> FATAL at this gate's whole-program REFUSE enforcement (tclsh unlinkable) (non-vacuity arm: fpcast_typemerge.mjs) HWJS_CORO_NO_OBJFIELD_FPCAST # F167 (git 2.55.0 wasm32 link): disables the CONCRETE-OBJECT fpcast evidence key -> the merged (struct type, offset) STORE union returns inside the object-rooted path and alloca-rooted dispatches fall back to the merged-type path, so llvm-link's struct-type unification again attributes DISTINCT C types' members to one key -> false field-evidenced REFUSE -> FATAL at this gate's whole-program REFUSE enforcement (git unlinkable: ref_iterator_vtable{ptr*3}==xdemitcb_t and fetch_negotiator{ptr*7}==transport_vtable) (non-vacuity arm: fpcast_objfield.mjs) HWJS_CORO_NO_PRINTF_NOCAPTURE # F138: disables the printf-family nocapture buffer recognition (printfFamilyDestArg + the glibc-internal-named %s vararg path) -> a VLA "where" buffer WRITTEN by glibc's INTERNAL __sprintf (the destination) / READ by __fprintf (a %s vararg) through the opaque _IO_str_jumps / FILE-vtable indirect dispatch refuses vla/dynamic-alloca-address-escape-opaque-sink, re-blocking glibc malloc/mtrace tr_where (the bash relink wall; and the F74 setjmp-2b consequence) (non-vacuity arm: printf_nocapture.mjs) HWJS_CORO_NO_GLOB_ALTDIRFUNC # F144 (superproject issue #24 leaf 1): disables the glob ALTDIRFUNC stat-dispatch contract -> glibc __glob/glob_in_dir's VLA path buffers reaching the field-shaped pglob->gl_stat/gl_lstat dispatch (unblessable by the e8 field consult: no caller ever stores that field) refuse vla/dynamic-alloca-address-escape-opaque-sink, re-blocking iproute2 ip on the glibc arm (non-vacuity arm: glob_altdirfunc.mjs) HWJS_CORO_NO_LOOKUPKEY_NOCAPTURE # F145 (superproject issue #24 leaf 2): disables the POSIX by-name lookup-key nocapture recognition (get{pw,gr,sp}nam[_r]) -> glibc __glob's GLOB_TILDE user_name VLA refuses at __getpwnam_r's NSS module fn-ptr dispatch (not field-shaped, coarse wasm-sig meet fails), re-blocking iproute2 ip on the glibc arm (non-vacuity arm: nss_lookupkey.mjs) HWJS_CORO_NO_DEEPWEB_DEPTH # F146 (superproject issue #25 shape 1): restores the legacy escape-walk descent horizon (10) -> GMP's mpn TMP_ALLOC scratch VLAs re-refuse vla/dynamic-alloca-address-escape-opaque-sink mid-web (the depth-0 fail-safe fires inside the Q1 fixpoint node evaluations on the deep mutually-recursive toom*/basecase chains), re-blocking coreutils expr/factor (non-vacuity arm: deepweb_depth.mjs) HWJS_CORO_NO_RECRET_PASSTHROUGH # F147 (superproject issue #25 shape 2): restores the pre-F147 terminal Opaque fold for a returned-arg edge inside the Q1 fixpoint -> a recursion returning its advanced output-cursor argument (GMP mpn_dc_get_str, glibc _itoa — the recursive-formatter idiom) re-refuses its caller's TMP VLA at every depth, re-blocking __gmpz_get_str/__gmpf_get_str (non-vacuity arm: recret_passthrough.mjs) HWJS_CORO_NO_GCD_SUBDIV_HOOK # F148 (superproject issue #36): disables the gcd_subdiv_step bounded hook-table meet -> GMP's GCD-web TMP_ALLOC scratch re-refuses vla/dynamic-alloca-address-escape-opaque-sink at __gmpz_gcd/__gmpz_gcdext/__gmpn_gcd/__gmpn_gcdext (the hook-parameter indirect dispatch is an opaque sink again), re-blocking alloca-backed GMP for coreutils expr/factor (the #31 --enable-alloca=malloc-reentrant workaround class) (non-vacuity arm: gcd_subdiv_hook.mjs) HWJS_CORO_NO_ST_FOREACH_PAYLOAD # F153 (superproject issue #59): disables the walk-driver integer-laundered callback-payload pairing walk -> oniguruma's disable_noname_group_capture re-refuses vla/dynamic-alloca-address-escape-opaque-sink (the GroupNumMap scratch VLA laundered through st.c's st_data_t integer into onig_st_foreach's param-dispatched callback is an opaque sink again), re-blocking jq 1.7.1 (bundled oniguruma) (non-vacuity arm: st_foreach_payload.mjs) HWJS_CORO_NO_FWRITEX_WRITEFIELD # F149 (superproject issue #36 leaf 2): disables the musl __fwritex FILE write-slot dispatch contract -> ANY VLA handed to musl fwrite re-refuses vla/dynamic-alloca-address-escape-opaque-sink at the f->write field dispatch (the __stdio_write iov stash poisons the e8 field meet), re-blocking mpz_out_str-shaped callers (__gmpz_out_str -> expr/factor with alloca-backed GMP TMP_ALLOC) (non-vacuity arm: fwritex_writefield.mjs) HWJS_CORO_NO_XMOD_NORETURN_STRIP # F150 (superproject issue #46): disables the imported-decl noreturn strip in dynamic-module builds -> a .so's call to the imported exit-family main export keeps the noreturn lie, O2 erases the F85 pump, the artifact traps `unreachable` at the exit ramp's initial-suspend return and the exit STATUS NEVER REACHES THE KERNEL (the dlopen M4 Tcl_Exit hang) (non-vacuity arm: xmod_noreturn.mjs) HWJS_CORO_NO_XMOD_SIGCOMPAT_CONSULT # F152 (superproject issue #40): disables the sig-compatible open-world dispatch consult in side modules -> a cross-module target whose registration ctors under-ran (stripped start / partial registration) or whose module was dlclosed dispatches SILENTLY through the plain call_indirect instead of refusing loudly (the exact vacuous-RED-arm shape the d1 gates documented) (non-vacuity arm: xmod_sigcompat_consult.mjs) HWJS_CORO_NO_XMOD_CONSULT_MARSHAL # F173 (superproject issue #186): disables the consult's marshal verdict on dynamic mains -> a live dlopen'd target registered under the site sig minus its last param (a one-parameter CPython METH_NOARGS method dispatched as meth(self, NULL)) proceeds raw on host verdict 2 and the engine's call_indirect signature check traps (non-vacuity arm: xmod_consult_marshal.mjs) HWJS_CORO_NO_FNPTR_CELL # F154 (superproject issue #64 leaf 1): disables the closed fn-ptr cell dispatch enumeration -> openssl's OPENSSL_cleanse `static volatile memset_t memset_func = memset` secure-wipe funnel is an opaque sink again, so bn_mod_exp_mont_fixed_top's powerbuf VLA re-refuses vla/dynamic-alloca-address-escape-opaque-sink, re-blocking libcrypto (the bn_exp.c `#ifdef alloca -> #if 0` workaround class) (non-vacuity arm: fnptr_cell.mjs) HWJS_CORO_NO_FNPTR_CELL_DECL # F171 (hwjs-cc#15, the DYNAMIC-libc arm of F154): declines every DECLARATION leaf of a closed fn-ptr cell, so its capture summary is never consulted -> under dynamic libc the OPENSSL_cleanse cell holds an IMPORTED memset, the leaf poisons the meet, and bn_mod_exp_mont_fixed_top's powerbuf VLA re-refuses vla/dynamic-alloca-address-escape-opaque-sink, taking every libcrypto consumer (python3, nmap) with it. NOT a restoration of the pre-F171 program: the enumeration half of F171 is deliberately outside this knob (it closes a hole where a dropped declaration leaf let a mixed cell bless a value set larger than the one it scanned), so a mixed cell refuses here where pre-F171 it lowered (non-vacuity arm: fnptr_cell.mjs, GREEN-decl) HWJS_CORO_NO_HASHCB_TABLE # F155 (superproject issue #64 leaf 2): disables the enumerable-installer hash-callback dispatch -> nettle's void(ctx,length,buffer) contract dispatched through fn-ptr params (pbkdf2) / const ops tables (hmac) is an opaque sink again, so nettle_pbkdf2/nettle_hmac_set_key/nettle_hmac_digest re-refuse vla/dynamic-alloca-address-escape-opaque-sink, re-blocking nettle 3.9.1 (non-vacuity arm: hashcb_table.mjs) HWJS_CORO_NO_SHIFTAMT_TERMINAL # F156 (superproject issue #64 leaf 3): disables the shift-amount terminal in the F134 aligned-read walk -> nettle's memxor MERGE (`(s1>>shl)|(s0< openssl's MOD_EXP_CTIME_ALIGN(`buf + (64 - (addr & 63))`) is an opaque sink again, so bn_mod_exp_mont_fixed_top's powerbuf VLA re-refuses vla/dynamic-alloca-address-escape-opaque-sink (the residual leaf under F154; both are needed to retire the bn_exp.c workaround) (non-vacuity arm: alignup_gep.mjs) HWJS_CORO_NO_WORDCOPY_READDRESS # F159 (brintos/hwjs-cc#2): disables the integer-laundered word-copy readdressing recogniser -> the glibc memcpy/_wordcopy idiom (ptrtoint both args, raw-inttoptr byte loops, address-integers as int args into the defined _wordcopy_fwd_* helpers) is an opaque sink again, so a bundled old-glibc msort_r (GLib 2.56 gqsort.c) re-refuses vla/dynamic-alloca-address-escape-opaque-sink and any glibc-carrying qsort/msort user fatals at the whole-program link (non-vacuity arm: wordcopy_readdress.mjs) HWJS_NO_PTRDIFF_CANON # F163 (brintos/hwjs-cc#6): restores the Sub-only pointer-difference matcher -> -O1/-O2-canonicalized diffs (`p - q - 1` = add(ptrtoint, xor(ptrtoint, -1)); the reassociated jhash tail) are opaque sinks again, so GNU make's variable_expand_string/strcache hash keys re-refuse vla/dynamic-alloca-address-escape-opaque-sink and the whole-program link FATALs (non-vacuity arm: f163_make_shapes.mjs) HWJS_NO_E8_REG_HELPER # F163: disables registration-helper field attribution (hash_init's stored-only fn-ptr params) -> the hooks fall into the e8 WILD bucket (non-vacuity arm: f163_make_shapes.mjs) HWJS_NO_E8_ALIAS_NAMEUSE # F163: alias uses count as address-taken again -> musl's weak-alias-only functions (feof/fflush/...) are force-WILDed and their capturing masks poison every same-psig W meet (the make hash_find_slot wall) (non-vacuity arm: f163_make_shapes.mjs) HWJS_NO_PARAM_CALLBACK_DEVIRT # F163: disables param-rooted callback devirt (union of actuals over the call sites of a non-address-taken enclosing fn) -> make ar_scan's member callback / the bundled glob errfunc are opaque sinks again (non-vacuity arm: f163_make_shapes.mjs) HWJS_NO_E8_FIELD_FATE # F163: disables the field-shaped FATE consult (the exact-candidate classifyEscape meet, Memory allowed) + intra-element array attribution + the special-global/spill-slot/select attribution refinements -> make's function_table dispatch and dir.c's GLOB_ALTDIRFUNC hooks re-refuse (non-vacuity arm: f163_make_shapes.mjs) HWJS_CORO_NO_SORT_SITE_CMP # F163: disables the sort-family site-paired comparator contract -> a dyn-alloca base handed to qsort/qsort_r with a constant comparator is an opaque sink again (musl wrapper_cmp's context-cast dispatch is invisible to every static resolver), so make ar_glob re-refuses. Since F172 rides on this clearance, killing it re-refuses BOTH the defined and the declared sort shape (non-vacuity arm: f163_make_shapes.mjs, shape_sort_site + shape_sort_decl) HWJS_CORO_NO_SORT_DECL # F172 (BRINTOS-1815): restores F163's `!Callee->isDeclaration()` gate -> when the libc sort is an IMPORT rather than a definition in the merged module (the WASMLD_LIBC_IMPORT dynamic-libc arm), a dyn-alloca base handed to it refuses again. The declaration is the TRUSTED member of the pair (the linker binds the real libc sort; the site's own comparator is still walked), so this knob exhibits a refusal the pass has no sound reason to emit (non-vacuity arm: f163_make_shapes.mjs, shape_sort_decl, whose in-file control shape_sort_site is unaffected by it) HWJS_CORO_NO_FPCAST_ARM_PROVENANCE # F161 (brintos/hwjs-cc#4): lets the fpcast evidence walks traverse the devirt's own synthetic arm calls -> lowering glibc's __run_exit_handlers/msort sites manufactures false cross-package evidence (@__exit_funcs rooting an app dispatch field), a previously-sound site re-triggers at the classification re-collect, and the FPCAST PHASE-ORDER VIOLATION fatals the whole-program static link (the xz lzmadec / openssl wall; non-vacuity arm: fpcast_arm_provenance.mjs) HWJS_CORO_NO_STRTOL_BUF_ESCAPE # #106 (linux-in-the-browser#106): removes the C string->number parser family (strtol/strtoul/strtod/strtoimax genus + atoi genus) from the capture summary -> a VLA parse buffer handed to the imported (dynamic-libc, may-suspend) strtoul re-refuses vla/dynamic-alloca-address-escape-opaque-sink, re-blocking iproute2 bstrtoul/tc_dump_action/u32_parse_eopt, findutils list_file, patch rpl_setenv, tar _help HWJS_CORO_NO_MEM_BUF_ESCAPE # #106 (linux-in-the-browser#106, mem* arm): removes the C bulk-memory family (memset/memcpy/memmove/memccpy/mempcpy + chk/alias variants, bzero/bcopy genus, memcmp genus, memmem) from the capture summary -> a VLA/alloca dst handed to the imported (dynamic-libc, may-suspend) memset re-refuses vla/dynamic-alloca-address-escape-opaque-sink (memcpy/memmove dst may still be recovered by the TLI annotateLibFuncs returned-arg where getLibFunc matches), re-blocking iproute2 tc_dump_action, tar _help, and the field pipeline's patch rpl_setenv / findutils list_file HWJS_CORO_NO_STRTOK_BUF_ESCAPE # B1 (memory-contract part B, the `char buf[n]` reaching libc arm): removes the in-place string-surgery family (strtok/strtok_r/__strtok_r memoryMask + delim/save_ptr nocapture; strsep/__strsep/__strsep_g slot nocapture; basename/__basename/__xpg_basename/dirname interiorRet) from the capture summary -> a strdupa/VLA buffer handed to the imported (dynamic-libc, may-suspend) strtok or basename re-refuses vla/dynamic-alloca-address-escape-opaque-sink, re-blocking iproute2 parse_vni_filter, bond_parse_opt (x2), parse_seg6local_flavors and bpf_load_common on the DYNAMIC arm — the exact class that forced the guest-source strdupa->strdup workaround (iproute2 baaa53b7) before this arm landed HWJS_CORO_NO_FORTIFY_BUF_ESCAPE # #106 (linux-in-the-browser#106, fortify arm, third sink): removes the glibc _FORTIFY_SOURCE twins of already-tabled entries (__open_2/__openat_2/__openat64_2/__open64_2/__readlink_chk/__readlinkat_chk/__realpath_chk from the E4 host-ABI table; __strcpy_chk/__strcat_chk/__strncpy_chk/__strncat_chk/__stpcpy_chk/__stpncpy_chk from the capture summary) -> a strdupa/alloca PATH flowing through gnulib's *at fallbacks (rpl_fchmodat/rpl_fchownat -> openat_proc_name) into the imported (dynamic-glibc, may-suspend) fortified spellings re-refuses vla/dynamic-alloca-address-escape-opaque-sink, re-blocking coreutils cp (make_dir_parents_private + re_protect — the last CU_STATIC_RETAINED main) HWJS_CORO_NO_INDIRECTBR_EXPAND # F164 (brintos/hwjs-cc#24): disables the pre-split indirectbr/blockaddress expansion -> a coroutinized function's address-taken blocks are deleted by CoroSplit and every blockaddress degrades to LLVM's dummy inttoptr(1), so a direct-threaded interpreter's computed goto always lands on the first successor: oniguruma's match_at falls into CASE_OP(END) and every match is a silent zero-length match with unset captures (onig testc 118/785 on target vs 785/785 native) HWJS_CORO_NO_SYSCALL_ARG_DERIV # F168 (the glibc __wasm32_syscall_resched funnel wall): drops the F130 integer-DERIVATION closure (predicate icmp/switch/br and integer arith/phi/select/trunc-zext-sext uses become terminal refusals again) -> the port's own syscall funnel rewrites its a1 slot (`and %a1,127` for the futex op, `or %a1,0x40000000`, the forwarding phi) so EVERY glibc VLA reaching a syscall through __open64_nocancel/__libc_open64/_IO_new_fopen re-refuses vla/dynamic-alloca-address-escape-opaque-sink, and the loop-bearing frames among them (write_gmon, ttyslot, ruserpass) go class=OPEN so the glibc libc.so links verdict=scoped (non-vacuity arm: syscall_buf_escape.mjs) HWJS_CORO_NO_ERRFAMILY_FORMAT # F169: disables the err(3)/error(3) diagnostic printf-format family -> a VLA handed to warn/warnx/err/errx/error as a `%s` vararg is a vararg-overflow opaque sink again, so glibc ruserpass's ~/.netrc VLA re-refuses vla/dynamic-alloca-address-escape-opaque-sink and the frame goes class=OPEN (non-vacuity arm: nss_lookupkey.mjs) HWJS_CORO_NO_LOOKUPBUF_MEMORY # F170: disables the reentrant-lookup RESULT-BUFFER Memory fate -> the `char *buffer` of get{pw,gr,sp,sg}{nam,uid,gid,ent}_r falls back to the fail-safe at the NSS module fn-ptr dispatch, so glibc __getpw and ruserok2_sa's alloca(_SC_GETPW_R_SIZE_MAX) scratch re-refuses vla/dynamic-alloca-address-escape-opaque-sink (non-vacuity arm: nss_lookupkey.mjs) HWJS_CORO_NO_DS_HEAP_SINK # F173 (brintos/hwjs-cc#15): disables the DS heap sink -> a dynamic alloca whose address reaches an opaque sink (an import without a summary, an indirect call, a laundered integer) is REFUSED vla/dynamic-alloca-address-escape-opaque-sink again instead of living on the malloc heap, re-blocking every consumer the ~100 downstream heap-allocation workarounds exist for (non-vacuity arm: ds_heap_sink.mjs) HWJS_CORO_NO_XMOD_UNCLAIMED_SUSPEND_SEED # linux-in-the-browser#184 arm D (BRINTOS-2608): restores the pre-fix fall-through in which a -shared link seeds NATIVE every import that NO classifying surface claims -- not libc.so's published partition, not the needed-entry seed (BRINTOS-1634), not the sibling seed (BRINTOS-2689). Such an import has, by construction, a provider the linker cannot see (an underlink, which an ELF build tolerates silently), so with the fix disabled a call into a sibling whose provider is off the link line is unbracketed again: the callee parks under a native caller and the next park in that lane traps -- `hwjs-user-rt VIOLATION: orphan at clear` / `set-over-set`, rc=132. Re-blocks libevent_extra -> libevent_core (101 unresolved imports; 78-libevent regress-misc, BRINTOS-2748) and libreadline -> tgetent in libtinfo (BRINTOS-2685). A VERDICT is never touched by either arm: only ABSENCE from every surface reaches the default, so libc.so's `native` verdicts stay native with the knob set or unset HWJS_CORO_NO_XMOD_SIBLING_NATIVE_CLAIM # BRINTOS-2877: arm D stops honouring the sibling seed's NATIVE half -> every native export of a -shared link's sibling library is unclaimed again and seeded MAY-SUSPEND, so the call into it is bracketed although the callee never parks and the descend traps: `xfer_descend on a non-live child frame handle` (80-libmpc: libmpc -> libmpfr, 72 of 92 red). The coroutine half of the sibling seed (BRINTOS-2689) and every verdict are untouched by either arm ) if [[ "${WASMLD_HWJS_ALLOW_RED_KNOBS:-0}" != "1" ]]; then for _k in "${HWJS_RED_KNOBS[@]}"; do if [[ -n "${!_k:-}" ]]; then fatal "fix-disabling RED knob '$_k=${!_k}' is set for a PRODUCTION coro build. The plugin's PLUGIN-PROVENANCE attests identity (commit + dirty) but not config; a RED knob makes a provenance-matched plugin emit stale behavior. Unset it. If this is a DELIBERATE negative/non-vacuity test (e.g. the F93 collapse repro), set WASMLD_HWJS_ALLOW_RED_KNOBS=1 to acknowledge it." fi done else for _k in "${HWJS_RED_KNOBS[@]}"; do [[ -n "${!_k:-}" ]] && echo "wasmld: NOTE (RED knob ACKNOWLEDGED via WASMLD_HWJS_ALLOW_RED_KNOBS): $_k=${!_k} — this is a deliberate non-production build." >&2 done fi WORK="$(mktemp -d "${TMPDIR:-/tmp}/wasmld-hwjs.XXXXXX")" [[ -n "${WASMLD_HWJS_KEEP:-}" ]] && echo "wasmld: [keep] WORK=$WORK" >&2 || trap 'rm -rf "$WORK"' EXIT # ── PLUGIN SNAPSHOT (TOCTOU close, 2026-07-03). The plugin compile that # dlopen's the .so runs MINUTES after the checks above (after the # whole-program llvm-link merge/fixpoint below). A concurrent plugin # rebuild (build-hwjs-plugin.sh's atomic install) swapping the shared .so # in that window once made the PLUGIN-PROVENANCE gate refuse a link whose # earlier check had passed (transient mixed-artifact refusal on a green # tree). Copy the .so into THIS link's private WORK dir and dlopen the # snapshot — check and use are on the same immutable bytes; the # provenance attestation of THESE bytes is verified after the pass run. # The RESOLVED paths are kept for the diagnostics below: from here on # HWJS_PLUGIN names this link's private snapshot, and a refusal that told an # operator to refresh a file under /tmp would be the same wrong-remedy defect # in a new spelling. HWJS_PLUGIN_RESOLVED="$HWJS_PLUGIN" HWJS_PRELOWER_RESOLVED="$HWJS_PRELOWER_PLUGIN" HWJS_RETTRUTH_RESOLVED="$HWJS_RETTRUTH_PLUGIN" # In the linked mode there is nothing to snapshot: HWJS_PLUGIN, HWJS_PRELOWER, # HWJS_RETTRUTH, HWJS_DEMIT and HWJS_SPLIT name the tool, and stay non-empty # exactly where the pass runs. if [[ $HWJS_PASSES_LINKED != 1 ]]; then cp -f "$HWJS_PLUGIN" "$WORK/HwjsCoroutinize.snapshot.so" HWJS_PLUGIN="$WORK/HwjsCoroutinize.snapshot.so" fi # Track E pre-lowering plugin — same snapshot discipline (check and use on the # same immutable bytes). Only when the status-channel/pre-lower knobs ask for it. HWJS_PRELOWER="" if [[ $EH_PRELOWER_LOAD == 1 && $HWJS_PASSES_LINKED == 1 ]]; then HWJS_PRELOWER="$HWJS_PRELOWER_PLUGIN" elif [[ $EH_PRELOWER_LOAD == 1 ]]; then [[ -f "$HWJS_PRELOWER_PLUGIN" ]] \ || fatal "the status-channel/pre-lower knobs are set (HWJS_STATUS_CHANNEL=${HWJS_STATUS_CHANNEL:-} HWJS_EH_PRELOWER=${HWJS_EH_PRELOWER:-}) but the HwjsEHPreLower plugin is missing at $HWJS_PRELOWER_PLUGIN — that is the plugin home $HWJS_PLUGIN_HOME, and the ONE thing that refreshes it is: $HWJS_PLUGIN_REMEDY. (Or set WASMLD_HWJS_PRELOWER_PLUGIN.) Linking WITHOUT the pre-lowering under the status channel would let un-lowered invokes into the abi-4 world (the I3 silent-transit hazard) — refusing instead." cp -f "$HWJS_PRELOWER_PLUGIN" "$WORK/HwjsEHPreLower.snapshot.so" HWJS_PRELOWER="$WORK/HwjsEHPreLower.snapshot.so" fi # Return-attribute truth plugin — same snapshot discipline, same load # condition as the pre-lowering (the status-channel world). A status-channel # link WITHOUT it would re-arm the operator-new park-exit unreachable # (freeze(poison) under frontend noundef+nonnull) — refuse instead. HWJS_RETTRUTH="" if [[ $EH_PRELOWER_LOAD == 1 && $HWJS_PASSES_LINKED == 1 ]]; then HWJS_RETTRUTH="$HWJS_RETTRUTH_PLUGIN" elif [[ $EH_PRELOWER_LOAD == 1 ]]; then [[ -f "$HWJS_RETTRUTH_PLUGIN" ]] \ || fatal "the status-channel knobs are set (HWJS_STATUS_CHANNEL=${HWJS_STATUS_CHANNEL:-}) but the HwjsRetAttrTruth plugin is missing at $HWJS_RETTRUTH_PLUGIN — that is the plugin home $HWJS_PLUGIN_HOME, and the ONE thing that refreshes it is: $HWJS_PLUGIN_REMEDY. (Or set WASMLD_HWJS_RETTRUTH_PLUGIN.) Linking WITHOUT it re-arms the converted-function park-exit unreachable (frontend return-value attrs make the freeze(poison) exit UB-fold — the measured operator-new churn trap) — refusing instead." cp -f "$HWJS_RETTRUTH_PLUGIN" "$WORK/HwjsRetAttrTruth.snapshot.so" HWJS_RETTRUTH="$WORK/HwjsRetAttrTruth.snapshot.so" fi # ── CONSUMER INPUT SNAPSHOT (TOCTOU close, 2026-07-16 — same class as the plugin # snapshot above). The consumer arm derives MANY facts from libc.so and the published # partition (pass seed + digest, owner audit, witness allowlists, digest lockstep, # needed-list shas), and the merged clang -O2 between the pass consuming the partition and # the post-link checks takes MINUTES. Re-reading the STAGED paths at each site let a # producer rebuild that replaces BOTH files mid-link pass every check while the main # carries STALE verdicts (partition-N-vs-libc.so-M skew, undetectable by every carrier). # Snapshot both into THIS link's private WORK dir; every derivation below reads ONLY the # snapshots, so the pass-consumed bytes and the stamped/checked bytes are the same # immutable pair by construction. The post-link consumed-digest READBACK (the main's own # hwjs.abi v2 field 9) additionally proves it end-to-end. LIBC_SO_SNAPSHOT="" LIBC_PARTITION_CONSUMED="" if [[ "$LIBC_IMPORT" == "1" ]]; then cp -f "$WASMLD_LIBC_SO_PATH" "$WORK/libc.so.snapshot" \ || fatal "WASMLD_LIBC_IMPORT: cannot snapshot '$WASMLD_LIBC_SO_PATH' into the link's WORK dir." cp -f "$WASMLD_HWJS_XMOD_SUSPENDERS" "$WORK/partition.consumed" \ || fatal "WASMLD_LIBC_IMPORT: cannot snapshot the published partition '$WASMLD_HWJS_XMOD_SUSPENDERS'." LIBC_SO_SNAPSHOT="$WORK/libc.so.snapshot" LIBC_PARTITION_CONSUMED="$WORK/partition.consumed" fi # ── NEEDED-LIBRARY SNAPSHOTS (the non-libc half of the needed-graph). Same TOCTOU # discipline as libc.so above: the digest recorded in the needed-list, the export # surface the witness sanctions, and the bytes the loader is eventually handed have # to be ONE immutable generation, so every derivation below reads the snapshot and # never the staged path. The recipe that installs the library must install THESE # bytes under the recorded digest-qualified name — content addressing is the whole # of what binds the two ends together. # The per-library snapshot + classify + name derivation, as a FUNCTION rather # than a loop body. BRINTOS-884 adds a SECOND producer of needed entries — a # `-lNAME` that resolves to a shared object — and that one is discovered later, # during the command-line partition below, so it cannot be folded into the list # this loop walks. Two call sites deriving a digest, a soname and an export # surface from two copies of this logic is exactly the skew the single-carrier # rule exists to prevent; one function called twice is not. # # $1 = path to the library. $2 = a short origin label, quoted into every # diagnostic so a refusal names the knob or the flag it came from. NEEDED_SO_SNAPSHOTS=() NEEDED_SO_NAMES=() _n_i=0 needed_so_record() { local _nl="$1" _norigin="$2" _nreal _nsnap _nkind _nbase _nsoname _nsha _nseen # BRINTOS-1442: the name is derived from the REAL file, never from the path # handed in. Every producer of a needed entry probes for the unversioned # lib.so, and that is the dev SYMLINK -- a name no rootfs carries. The # installer stages . beside the real library and the # packaging provider map registers the same real basename, so an entry named # after the link resolves to nothing at load AND to no provider at package # time, while its digest matches perfectly. Resolved BEFORE the snapshot, so # the recorded name and the recorded bytes come from one file rather than from # the live tree and a copy of something else. A resolution failure is a loud # stop: falling back to the unresolved basename would silently re-create the # defect for the one case that is already broken. # Resolution is also the EXISTENCE check, so there is one answer to "is this a # library" rather than two that can disagree: a missing file, a dangling link # and a link loop all arrive here, and none of them may fall back to the given # path -- that fallback is the defect this is fixing, re-created for exactly # the case that is already broken. command -v node >/dev/null 2>&1 \ || fatal "$_norigin names '$_nl', but node is not on PATH -- this function resolves the library's real name and classifies it with node, so it cannot record anything without one. (Every other node site in this driver says so by name; these two did not.)" # stderr is NOT merged into the variable and NOT captured to a file. Merging # puts a node deprecation warning in front of the path and the failure then # surfaces against the snapshot; capturing to a file makes an unwritable WORK # (ENOSPC, a read-only TMPDIR) read as "this library does not resolve". Left # alone, node's reason reaches wasmld's own stderr exactly as it is. _nreal="$(node -e 'process.stdout.write(require("fs").realpathSync(process.argv[1]))' "$_nl")" \ || fatal "$_norigin names '$_nl', which does not resolve to a real file -- missing, a dangling symlink, or a link loop (node's reason above). The needed entry IS the on-target file name, so a name derived from an unresolvable path is a lookup that cannot succeed." [[ -f "$_nreal" ]] || fatal "$_norigin names '$_nl', which resolves to '$_nreal' -- not a regular file. A needed entry names a library the loader opens." _nsnap="$WORK/needed-$_n_i.so.snapshot" cp -f "$_nreal" "$_nsnap" || fatal "$_norigin: cannot snapshot '$_nl' (real file '$_nreal') into the link's WORK dir." # A needed entry must be a SIDE MODULE. file_kind classifies by magic alone, so a # main (or an archive member) handed here would be recorded as a library the loader # then cannot place — refused by shape here rather than discovered at load. _nkind="$(node -e ' const fs = require("fs"); const b = fs.readFileSync(process.argv[1]); if (b.length < 8 || b[0] !== 0 || b[1] !== 0x61 || b[2] !== 0x73 || b[3] !== 0x6d) { console.error("not a wasm module (bad magic)"); process.exit(1); } let p = 8, dylink = false; const leb = () => { let r = 0, s = 0; for (;;) { const x = b[p++]; r |= (x & 127) << s; if (!(x & 128)) return r >>> 0; s += 7; } }; while (p < b.length) { const id = b[p++]; const sz = leb(); const end = p + sz; if (id === 0) { const nl = leb(); const nm = b.slice(p, p + nl).toString(); if (nm === "dylink.0") dylink = true; } p = end; } console.log(dylink ? "side" : "main"); ' "$_nsnap")" || fatal "$_norigin: cannot classify '$_nl' (node's reason above)." [[ "$_nkind" == "side" ]] \ || fatal "$_norigin names '$_nl', which carries no dylink.0 section — that is a MAIN image, not a side module. A needed entry is placed by the loader like any .so, so recording a main there fails at load, not at link." # SONAME = the RESOLVED file's basename with any digest qualifier already on it # removed, so naming a staged libfoo.so. re-derives libfoo.so rather # than nesting a second digest. The result IS the on-target file name the # kernel searches for. _nbase="$(basename "$_nreal")" _nsoname="$(sed -E 's/\.[0-9a-f]{64}$//' <<<"$_nbase")" case "$_nsoname" in *.so|*.so.*) : ;; *) fatal "$_norigin names '$_nl', whose basename '$_nsoname' is not a shared-object name (expected lib.so[.]) — the needed entry IS the on-target file name, so an arbitrary name is a lookup that cannot succeed." ;; esac case "$_nsoname" in *[\"\\]*|*[[:space:]]*|*/*) fatal "$_norigin names '$_nl', whose basename carries a quote, backslash, whitespace or slash — the needed-list is JSON and the entry is one path component; refusing rather than emitting an unparseable or path-traversing name." ;; esac case "$_nsoname" in libc.so*) fatal "$_norigin names '$_nl' — libc.so is module #1 and is recorded by the WASMLD_LIBC_IMPORT arm from WASMLD_LIBC_SO_PATH, which also carries the partition digest lockstep. Two carriers for one module is the skew class the single-carrier rule exists for." ;; esac _nsha="$(sha256sum "$_nsnap" | awk '{print $1}')" # BRINTOS-884: a name already recorded is DROPPED, not recorded twice. # Real link lines repeat `-lfoo` — it is how autotools resolves circular # archive dependencies (`-lfoo -lbar -lfoo`) — and each mention reaches # here. The loader refuses a needed list carrying one name twice rather # than instantiating the library twice, so recording the repeat would # build, without complaint, a binary that cannot be loaded: an exec-time # failure on target for a link line nobody would look at twice. Dropping # it is also the correct semantic and not merely a guard — a repeated # `-lfoo` is a no-op for a shared library in GNU ld too, the library # already being named once. Compared on the DERIVED name, so the same # bytes reached by two different paths dedup as well. for _nseen in ${NEEDED_SO_NAMES[@]+"${NEEDED_SO_NAMES[@]}"}; do if [[ "$_nseen" == "$_nsoname.$_nsha" ]]; then echo "wasmld: NOTE ($_norigin): $_nsoname.${_nsha:0:16}… is already in the needed-list — dropping the repeat (a needed name may appear once; the loader refuses a list that carries one twice)." >&2 rm -f "$_nsnap" return 0 fi done NEEDED_SO_SNAPSHOTS+=( "$_nsnap" ) NEEDED_SO_NAMES+=( "$_nsoname.$_nsha" ) echo "wasmld: NOTE ($_norigin): needed entry $_nsoname.${_nsha:0:16}… derived from '$_nl' — the loader binds this main's env fn imports from its exports, after libc.so's." >&2 _n_i=$(( _n_i + 1 )) } if [[ -n "$NEEDED_LIBS_RAW" ]]; then IFS=':' read -r -a _needed_paths <<<"$NEEDED_LIBS_RAW" for _nl in ${_needed_paths[@]+"${_needed_paths[@]}"}; do [[ -z "$_nl" ]] && continue needed_so_record "$_nl" "WASMLD_NEEDED_LIBS" done fi # ── -shared extra pass: HwjsDescriptorEmit (the D-7 fork reloc records — # the fixture recipe ran it per-TU alongside the coroutinizer; the merged # run keeps it so a dlopen'd .so's coroutine frames stay fork-relocatable). # Snapshotted for the same TOCTOU reason as the coroutinizer above. HWJS_DEMIT="" if [[ $IS_SHARED == 1 && $HWJS_PASSES_LINKED == 1 ]]; then HWJS_DEMIT="$HWJS_LLVM_BIN/clang" HWJS_DEMIT_RESOLVED="$HWJS_DEMIT" elif [[ $IS_SHARED == 1 ]]; then HWJS_DEMIT="${WASMLD_HWJS_DEMIT_PLUGIN:-$HWJS_PLUGIN_HOME/HwjsDescriptorEmit.so}" [[ -f "$HWJS_DEMIT" ]] || fatal "-shared side-module link needs the HwjsDescriptorEmit plugin (fork reloc records), missing at $HWJS_DEMIT (the plugin home $HWJS_PLUGIN_HOME) — refresh that home: $HWJS_PLUGIN_REMEDY. Or set WASMLD_HWJS_DEMIT_PLUGIN." hwjs_plugin_stamp_check "$HWJS_DEMIT" HwjsDescriptorEmit HWJS_DEMIT_RESOLVED="$HWJS_DEMIT" cp -f "$HWJS_DEMIT" "$WORK/HwjsDescriptorEmit.snapshot.so" HWJS_DEMIT="$WORK/HwjsDescriptorEmit.snapshot.so" fi # ── SPLIT AFTER THE PASS (BRINTOS-2764, hwjs-cc#38). The merged compile below # holds the whole program's IR and is this driver's memory peak: tshark's is # 56.9 GiB, which no 32 GB host can serve. WASMLD_HWJS_SPLIT_N above 1 runs the # hwjs passes under `opt` instead, splits the module they produced into N # pieces, compiles each piece with no plugin, and rejoins the objects — the # pass still sees the whole program, only codegen is partitioned. tshark at # N=8 peaks at 14.6 GiB. # # The count is a knob and is never inferred from module size. A size threshold # would compile the same sources two ways on two hosts, and in Yocto that is # invisible to the task signature — two hosts sharing sstate would ship # different binaries under one hash. As an environment variable the recipe # sets, it is part of that signature. # # Executable links only: HwjsDescriptorEmit registers at OptimizerLast, so # under a split it would run once per piece over a partial module. HWJS_SPLIT_N="${WASMLD_HWJS_SPLIT_N:-1}" [[ "$HWJS_SPLIT_N" =~ ^[0-9]+$ ]] \ || fatal "WASMLD_HWJS_SPLIT_N must be a piece count, got '$HWJS_SPLIT_N'." HWJS_SPLIT="" if [[ $HWJS_SPLIT_N -gt 1 && $IS_SHARED == 1 ]]; then echo "wasmld: NOTE: WASMLD_HWJS_SPLIT_N=$HWJS_SPLIT_N ignored on this -shared link — the descriptor-emit pass runs at OptimizerLast and cannot see a partial module." >&2 HWJS_SPLIT_N=1 fi if [[ $HWJS_SPLIT_N -gt 1 && $HWJS_PASSES_LINKED == 1 ]]; then HWJS_SPLIT="$HWJS_LLVM_BIN/opt" [[ -x "$HWJS_SPLIT" ]] \ || fatal "WASMLD_HWJS_SPLIT_N=$HWJS_SPLIT_N drives the hwjs passes through opt, missing at $HWJS_SPLIT (the LLVM-22 pin; set WASMLD_HWJS_LLVM_BIN)." elif [[ $HWJS_SPLIT_N -gt 1 ]]; then HWJS_SPLIT="${WASMLD_HWJS_SPLIT_PLUGIN:-$HWJS_PLUGIN_HOME/HwjsSplit.so}" [[ -f "$HWJS_SPLIT" ]] || fatal "WASMLD_HWJS_SPLIT_N=$HWJS_SPLIT_N needs the HwjsSplit plugin, missing at $HWJS_SPLIT (the plugin home $HWJS_PLUGIN_HOME) — refresh that home: $HWJS_PLUGIN_REMEDY. Or set WASMLD_HWJS_SPLIT_PLUGIN." hwjs_plugin_stamp_check "$HWJS_SPLIT" HwjsSplit cp -f "$HWJS_SPLIT" "$WORK/HwjsSplit.snapshot.so" HWJS_SPLIT="$WORK/HwjsSplit.snapshot.so" # opt drives phase 1, and the startup tool check covers only clang and # llvm-link because nothing before this needed it. [[ -x "$HWJS_LLVM_BIN/opt" ]] \ || fatal "WASMLD_HWJS_SPLIT_N=$HWJS_SPLIT_N drives the hwjs passes through opt, missing at $HWJS_LLVM_BIN/opt (the LLVM-22 pin; set WASMLD_HWJS_LLVM_BIN)." fi # ── Partition the command line: bitcode roots vs native-stage inputs vs # flags. Existing-file positionals are inputs; -o/-o is consumed (we # link to OUT via link_coro). -lNAME is resolved against the accumulated # -L search path to a concrete /libNAME.a and classified by file # kind like any positional archive (ticket #5: autoconf AC_CHECK_LIB # emits `-L -lz`, and refusing every -l made all library-existence # probes false-FAIL). An unresolvable -lNAME still FATALs loudly — the # kind of a name-only library is undecidable, and a silently-dropped # libc-adjacent archive is the failure mode this arm refuses. BC_ROOTS=() WHOLE_ARCHIVE=0 NATIVE_INPUTS=() POOL=() LINK_FLAGS=() # classify_input : route one existing input file by magic into # BC_ROOTS / NATIVE_INPUTS / POOL (archives explode; bitcode members join # the pool, fully-native archives stay final-stage inputs, MIXED archives # are a half-substrate chimera and refuse). uniquifies the archive # extraction dir. Shared by positional inputs and resolved -lNAME archives. classify_input() { local in="$1" tag="$2" kind kind="$(file_kind "$in")" case "$kind" in bc) BC_ROOTS+=("$in") ;; wasm) NATIVE_INPUTS+=("$in") ;; archive|thin) local adir="$WORK/ar$tag" mkdir -p "$adir" # Resolve the archive to an ABSOLUTE path BEFORE the extraction # subshell cd's into $adir — otherwise a relative archive arg with a # directory component (autotools passes e.g. 'src/libver.a') has its # dirname re-resolved against $adir (which has no 'src/'), the inner # `cd` fails, pwd is empty, and llvm-ar tries to load '/libver.a'. local a_abs a_abs="$(cd "$(dirname "$in")" && pwd)/$(basename "$in")" # THIN ARCHIVE: `ar csrDT` -- meson's spelling for every UNINSTALLED # static_library(), so any meson project that links a test or helper # library into a program hands one over -- is a member table of path # references and no member bytes. `llvm-ar x` refuses it ("extracting # from a thin archive is not supported"); `llvm-ar t` prints each # member's path resolved against the archive's own directory, absolute # here because $a_abs is. So the archive is materialised FAT in $WORK # from exactly that list, in table order, and the arms below read the # twin: same member names (ar keys on basename, which is how a thin # archive acquires duplicated names too), same order, same dup # handling. `q` appends rather than `r`-replaces, so same-named members # survive into the twin. A table llvm-ar cannot read, and a member the # table names that the tree no longer holds, are each a refusal naming # the archive -- never an archive that quietly contributes nothing. # BRINTOS-WORKAROUND: brintos/hwjs-cc#67 revisit — this block IS the fix and lives on emc-dev, so it is not removed when the issue closes: when #67 closes, check that main reads a thin archive and drop it at the next emc-dev rebase. local native_in="$in" if [[ $kind == thin ]]; then local fat="$WORK/ar$tag.fat.a" table m_path table="$("$HWJS_AR" t "$a_abs")" \ || fatal "thin archive '$in': llvm-ar cannot read its member table." local members=() while IFS= read -r m_path; do [[ -n "$m_path" ]] || continue [[ -f "$m_path" ]] || fatal "thin archive '$in' names member '$m_path', which does not exist." members+=("$m_path") done <<<"$table" run_llvm_bulk "$HWJS_AR" qcD "$fat" ${members[@]+"${members[@]}"} \ || fatal "thin archive '$in': llvm-ar could not materialise its ${#members[@]} member(s) as '$fat'." a_abs="$fat"; native_in="$fat" fi (cd "$adir" && "$HWJS_AR" x "$a_abs") # DUPLICATE MEMBER NAMES (ticket #13): `ar x` extracts same-named members # OVER each other — last instance wins — so an archive carrying two # members both named X86.cpp.o (libclangCodeGen.a: Targets/X86.cpp.o vs # TargetBuiltins/X86.cpp.o) contributes only ONE to the POOL and the # dropped twin's symbols silently surface as phantom env imports at the # end of the link. The pool-pull selector never sees the lost member, so # the fix belongs HERE: detect duplicated names in the member table and # re-extract EVERY instance via the ar COUNT modifier (`xN `), # uniquified as dup. (extension preserved so file_kind still # classifies each instance); the plain-named last-wins copy is removed # (keeping it would pool the final instance TWICE). Instance order # follows `ar t` order, so relative member order is preserved in the # uniquified names; non-duplicated members keep the bulk-extract path # byte-for-byte. local dup_name n_inst i while IFS= read -r dup_name; do [[ -n "$dup_name" ]] || continue n_inst="$("$HWJS_AR" t "$a_abs" | grep -cxF -- "$dup_name")" rm -f "$adir/$dup_name" for ((i = 1; i <= n_inst; i++)); do (cd "$adir" \ && "$HWJS_AR" xN "$i" "$a_abs" "$dup_name" \ && mv "$dup_name" "dup${i}.${dup_name}") \ || fatal "archive '$in': failed to extract instance $i of duplicated member '$dup_name' (llvm-ar xN)" done done < <("$HWJS_AR" t "$a_abs" | sort | uniq -d) local has_bc=0 has_native=0 m while IFS= read -r -d '' m; do case "$(file_kind "$m")" in bc) has_bc=1 # --whole-archive: every member is an INPUT, not a candidate # for the need-only pull. GNU ld and wasm-ld both define the # flag that way, and a constructor-registry archive (an # extension set whose only interface is __attribute__ # ((constructor))) is unreachable without it -- nothing # references those members, so the pool selector never pulls # them and the program starts with an empty registry. if [[ $WHOLE_ARCHIVE == 1 ]]; then BC_ROOTS+=("$m"); else POOL+=("$m"); fi ;; *) has_native=1 ;; esac done < <(find "$adir" -type f -print0 | LC_ALL=C sort -z) if [[ $has_bc == 1 && $has_native == 1 ]]; then fatal "archive '$in' mixes bitcode and native members — a half-substrate chimera. Rebuild it uniformly (WASMLD_HWJS=1 for bitcode, =0 for native)." fi [[ $has_native == 1 ]] && NATIVE_INPUTS+=("$native_in") ;; other) fatal "input '$in' is neither bitcode, wasm object, nor archive." ;; esac return 0 } # ── -L search-path pre-collection (GNU-ld semantics: every -L applies to # every -l regardless of relative order, so collect the full set first). # Both `-Ldir` and `-L dir` spellings; WACKY_LIBC_DIR (the musl build's # lib dir, where the empty compat stubs live) joins the tail. LDIRS=() _prev_was_L=0 for a in "${args[@]}"; do if [[ $_prev_was_L == 1 ]]; then LDIRS+=("$a"); _prev_was_L=0; continue; fi case "$a" in -L) _prev_was_L=1 ;; -L?*) LDIRS+=("${a#-L}") ;; esac done [[ -n "${WACKY_LIBC_DIR:-}" && -d "${WACKY_LIBC_DIR:-}" ]] && LDIRS+=("$WACKY_LIBC_DIR") # Libraries musl folds into libc itself (musl installs them as EMPTY .a # compat stubs): satisfied by the bitcode pool, so the flag is consumed # without a file. -lc is the same contract (the pool IS our libc). musl_integrated_lib() { case "$1" in c|m|pthread|rt|dl|util|xnet|resolv|crypt) return 0 ;; *) return 1 ;; esac } # resolve_dash_l : the -lNAME contract. musl-integrated names # are satisfied by the bitcode pool (consumed, no file); anything else # resolves to the first /libNAME.a on the -L search path and is # classified like a positional archive; unresolvable refuses loudly. resolve_dash_l() { local libname="$1" tag="$2" lib_found="" so_found="" _d if musl_integrated_lib "$libname"; then return 0 # the musl bitcode pool at $HWJS_LIBC_BC provides it fi # GNU-ld search semantics, per DIRECTORY rather than per suffix: each -L # entry is probed for lib.so and then lib.a, and the first # directory that answers wins. A sysroot earlier on the path therefore # shadows a later one whichever kind it holds, which is what a caller # ordering its -L flags is expressing. # DASH_L_SHARED_WASM_ONLY: by CONTENT -- a staged lib.so can be an ld # script or a host ELF, and needed_so_record fatals on one. Not on a -shared # link, which drops the hit instead of recording it: there is nothing to # refuse, and refusing would fail a link that used to succeed. for _d in "${LDIRS[@]}"; do if [[ $DASH_L_PREFERS_SHARED == 1 && -f "$_d/lib${libname}.so" \ && ( $IS_SHARED == 1 || "$(file_kind "$_d/lib${libname}.so")" == wasm ) ]]; then so_found="$_d/lib${libname}.so"; break fi if [[ -f "$_d/lib${libname}.a" ]]; then lib_found="$_d/lib${libname}.a"; break; fi done # A SHARED hit is not an input to this link at all — it is a needed-graph # entry. Handing the .so to wasm-ld asks it to absorb a dynamic object # statically, which it refuses by name ("attempted static link of dynamic # object"); what the dynamic-main arm wants instead is the symbol left # undefined, so --unresolved-symbols=import-dynamic turns it into an env # import, and the library recorded in wasm.linux.needed for the loader to # place and bind. That is precisely what WASMLD_NEEDED_LIBS already does, # so `-lNAME` resolving to a shared object routes to the same recorder and # returns WITHOUT classifying an input. if [[ -n "$so_found" ]]; then # A -shared SIDE MODULE does not bind its dependencies: the MAIN that # dlopens both carries the needed graph, and a .so has no needed list of # its own (§3.4 single-carrier). So DROP it here rather than record it -- # the symbols stay undefined and become env imports the loader binds. # Same disposition the by-path arm already gives a finished .so # (hwjs-cc-side-module-so-drop, BRINTOS-1415); this is the -lNAME twin. if [[ $IS_SHARED == 1 ]]; then echo "wasmld: NOTE (-l${libname}): resolved to $so_found on a -shared link — DROPPED; the main that loads this side module binds it (BRINTOS-1415 -lNAME twin)." >&2 return 0 fi needed_so_record "$so_found" "-l${libname}" return 0 fi if [[ -z "$lib_found" ]]; then fatal "-l${libname} not found: no lib${libname}$( [[ $DASH_L_PREFERS_SHARED == 1 ]] && printf '.so nor lib%s' "${libname}" ).a in the -L search path [${LDIRS[*]:-}]. The coro arm resolves -lNAME to a concrete file so its kind is decidable; a missing library is a loud stop, never a silent drop. (-lc and the musl-integrated names are satisfied by the bitcode pool at $HWJS_LIBC_BC.)" fi classify_input "$lib_found" "$tag" } hwjs_stub_def() { [[ -n "${1:-}" ]] || return 0 printf '; wasm32: no import library exists on this target; this file is\n; created empty so a build system that installs it still finds it.\n' \ > "$1" 2>/dev/null || true } skip_next=0 for ((i = 0; i < ${#args[@]}; i++)); do a="${args[$i]}" if [[ $skip_next == 1 ]]; then skip_next=0; continue; fi case "$a" in -o) skip_next=1; continue ;; -o*) continue ;; # GNU-ld archive-group markers (BusyBox's scripts/trylink wraps its object # + lib list in `--start-group`/`--end-group`; also the short `-(`/`-)`). # wasm-ld does not accept them ("unknown argument") and they are MOOT on # the coro arm anyway: the bitcode roots are full-linked and the pool is # pulled to a fixpoint (no GNU left-to-right archive-grouping semantics). # Drop them. (--start-lib/--end-lib are a DIFFERENT, wasm-ld-supported # feature and are intentionally left untouched, falling through to -*.) --start-group|--end-group|-\(|-\)) continue ;; # --whole-archive / --no-whole-archive scope the archive semantics for # every archive that follows. Before this they matched no arm here, # fell through the generic -*) case into LINK_FLAGS, and reached the # FINAL wasm-ld stage -- long after classify_input had already exploded # each bitcode archive into the need-only pool, so there was no archive # left for them to act on. They neither worked nor refused. # BRINTOS-WORKAROUND: brintos/hwjs-cc#90 revisit - ours until the # single-dash arm lands upstream beside b56ade5. BOTH SPELLINGS, for the # same reason the elf-only-link-options arm above gives: GNU ld accepts # single- and double-dash alike, and a tree whose configure recorded its # probe result verbatim emits the single-dash form on every later link. # Measured on a -shared link from a convenience archive: the double-dash # form yields 223,437 bytes, the single-dash form a silently EMPTY 790. --whole-archive|-whole-archive) WHOLE_ARCHIVE=1; continue ;; --no-whole-archive|-no-whole-archive) WHOLE_ARCHIVE=0; continue ;; # --warn-common is another GNU-ld-only diagnostic (BusyBox's trylink adds # it via INFO_OPTS on the final link); wasm-ld rejects it. It only affects # warnings, never output — drop it. (--verbose, trylink's other INFO_OPT, # has no argument and IS supported by wasm-ld; it falls through to -*.) --warn-common|--no-warn-common) continue ;; # -Map writes a diagnostic link map. trylink passes it as `-Wl,-Map,FILE`, # which clang splits into TWO args (`-Map` then `FILE`). The flag matches # -* and is harmless, but the SEPARATE map-path arg is not a flag — once the # .map already exists from a prior link, the positional-input scan below # `[[ -f "$a" ]]` classifies it as `other` and FATALs ("neither bitcode, # wasm object, nor archive"). The map is purely informational (busybox never # reads it), so drop BOTH the flag and its filename. (-Map=FILE joined form # falls through to -*; wasm-ld accepts it, so it is left untouched.) -Map) skip_next=1; continue ;; # BRINTOS-2364 (marker: VERSION-SCRIPT-EXPORT-VISIBILITY): --version-script is # NOT part of the symbol-versioning # surface as far as THIS linker is concerned, and consuming it was wrong. # A GNU version script says two things, and only the first is meaningless # here: it assigns versions (collapsed on wasm, correctly ignored), and it # declares EXPORT VISIBILITY through global:/local:. wasm-ld implements the # second -- `--version-script= ... only the global:/local: # export-visibility semantics are honored to control which symbols a shared # module exports` (wasm-ld --help, the LLVM-22 pin) -- so dropping the flag # does not remove a no-op, it discards the module's declared export surface # and leaves EVERY default-visibility definition exported. # # That is not cosmetic on this arm. A -shared module routes its own WEAK / # linkonce_odr default-visibility definitions through env so the process may # interpose them, and a dlopen'd module resolves env against rt + libc.so + # main only -- never against itself. So each such symbol the process does not # provide is an unresolved import and the load fails ELIBBAD. C modules never # showed it (their only preemptible symbols are the __hwjs_* ops the loader # binds by hand); the first C++ side module did, on operator new/delete, # __libcpp_verbose_abort and its own __hash_table::__do_rehash instantiations # -- 23 symbols it DEFINES and exports. nbdkit's filters.syms already said # `global: filter_init; *_debug_*; local: *;`, which is exactly the right # answer, and this filter threw it away. # # Measured on nbdkit-stats-filter.so, one variable, both arms re-run: # consumed -> 63 exported fns / 88 env fn imports / 23 unbindable C++ imports; # forwarded -> 6 exported fns / 48 env fn imports / 0. The 6 are filter_init, # the two wasm-ld synthetics and the hwjs runtime ops, which survive `local: *` # because wasmld passes them as explicit --export= and an explicit export wins. # # The other three export-visibility spellings stay consumed: wasm-ld's --help # lists none of --dynamic-list, --retain-symbols-file or --exclude-libs, so # forwarding them would only turn a silent drop into an unknown-argument # refusal. -version-script) LINK_FLAGS+=( "--version-script=${args[$((i + 1))]}" ); skip_next=1; continue ;; --version-script) LINK_FLAGS+=( "--version-script=${args[$((i + 1))]}" ); skip_next=1; continue ;; -version-script=*|--version-script=*) LINK_FLAGS+=( "--version-script=${a#*=}" ); continue ;; # BRINTOS-WORKAROUND: brintos/hwjs-cc#59 revisit - pin-gated: this patch IS the fix, git am'ed onto the submodule at the base gitlink, so it goes when BRINTOS_SUPER_SRCREV advances past it, not when the issue closes. # ELF-only link options, which libtool/waf/autotools emit on essentially # every library link. None has a representation in the wasm object format: # the side-module ABI has no symbol versioning, no soname field and no # runtime search path. Consume them, WITH their operand where they take # one, rather than letting the operand fall through to classify_input, # which takes a version script for an input file and refuses the link on # it. Same treatment as -Map above. # # BOTH SPELLINGS, deliberately. GNU ld accepts single- and double-dash for # all of these, and the single-dash form is the one that actually arrives. # waf records its own probe result verbatim, so a tree whose configure # answered yes to the -Wl,-no-undefined probe writes the single-dash form # into EXTRA_LDFLAGS and every later link carries it; a double-dash-only # arm leaves that link dying on an unknown argument. That is exactly how # libtalloc 2.4.2 fails at the 1e9164bb baseline - BRINTOS-807. # # -h/-soname are the SAME option in GNU ld, and waf emits the -h spelling: # libtalloc 2.4.2 links with `-h libtalloc.so.2`, wasm-ld rejects -h, and the # operand then falls through to classify_input as an input file -- two errors, # neither naming the soname. Filtering -soname without -h catches only the # spelling autotools happens to use. # # -no-undefined: the undefined-symbol policy is ARM-OWNED here, so drop it # rather than let a libtool line encode a policy it cannot win. # # Two families the first cut missed, both reached only once a package # actually links a SHARED library - with BRINTOS_PACKAGE_SHARED off no # archive_cmds is ever expanded, so these had simply never met a linker. # --output-def / --out-implib name a MinGW import-library artifact. Each # takes a filename, and dropping the flag without its operand leaves the # name to fall through as a positional input: wasm-ld then reports # "cannot open libsodium-26.def" one line after the unknown argument. # --output-def gets its own arm because consuming it is not enough: a # .def is a declared build artifact, and libsodium and libtasn1 both # list it as defexec_DATA, so a link that swallows the option without # writing the file moves the failure from the link to `make install`. # There is no import library on wasm and nothing to describe, so the # file is created empty but for a line saying so. # --undefined-version / --no-undefined-version / --default-symver carry # no export-visibility semantics -- unlike --version-script, which is # forwarded above -- so nothing is lost by consuming them. -dynamic-list|--dynamic-list|-retain-symbols-file|--retain-symbols-file|-exclude-libs|--exclude-libs|-rpath|--rpath|-rpath-link|--rpath-link|-soname|--soname|-h|--h|-out-implib|--out-implib|-z) skip_next=1; continue ;; -output-def|--output-def) if (( i + 1 < ${#args[@]} )); then hwjs_stub_def "${args[$((i + 1))]}"; fi skip_next=1; continue ;; -output-def=*|--output-def=*) hwjs_stub_def "${a#*=}"; continue ;; -dynamic-list=*|--dynamic-list=*|-retain-symbols-file=*|--retain-symbols-file=*|-exclude-libs=*|--exclude-libs=*|-rpath=*|--rpath=*|-rpath-link=*|--rpath-link=*|-soname=*|--soname=*|-out-implib=*|--out-implib=*|-z?*) continue ;; -no-undefined|--no-undefined|-as-needed|--as-needed|-no-as-needed|--no-as-needed|-Bsymbolic|--Bsymbolic|-Bsymbolic-functions|--Bsymbolic-functions|-no-copy-dt-needed-entries|--no-copy-dt-needed-entries|-enable-new-dtags|--enable-new-dtags|-disable-new-dtags|--disable-new-dtags|-undefined-version|--undefined-version|-no-undefined-version|--no-undefined-version|-default-symver|--default-symver|-shared-libgcc|-static-libgcc) continue ;; # -lNAME: resolve against the -L search path (collected above) to a # concrete archive and classify it by file kind — exactly as if the # caller had passed the path (ticket #5). Search order = the -L order, # first hit wins (GNU-ld semantics). Only static archives exist on # this target, so only libNAME.a is probed; a shared-library request # cannot be silently satisfied. musl-integrated names (-lc -lm # -lpthread ...) are satisfied by the bitcode pool: consumed, no file. # Unresolvable = loud FATAL naming the search path, never a drop. -l?*) resolve_dash_l "${a#-l}" "l$i" continue ;; # GNU ld also accepts the separate-arg spelling `-l namespec`. -l) (( i + 1 < ${#args[@]} )) || fatal "bare '-l' with no library name on the link line." resolve_dash_l "${args[$((i + 1))]}" "l$i" skip_next=1 continue ;; -L) skip_next=1; continue ;; # search dir; pre-collected above -L*) continue ;; # -shared is CONSUMED (IS_SHARED was detected above): the -shared arm # spells its own final wasm-ld line (--experimental-pic -shared ...). -shared|--shared) continue ;; -*) LINK_FLAGS+=("$a"); continue ;; esac if [[ -f "$a" ]]; then # BRINTOS-1445: a POSITIONAL linker script -- GNU ld parses a non-object # operand as its command language, and libtool passes one that way. # # BRINTOS-2364 KNOWN GAP: where the script's content is a version script # (attr's libattr.lds is), the OPTION spelling is now forwarded and this # one is still consumed, so the same file restricts a module's exports # through --version-script and does not restrict them positionally. It is # left consumed deliberately: a positional script may also describe # section/memory layout, which wasm-ld cannot use at all, and telling the # two apart by content is a separate change. Consume it. if _hwjs_is_linker_script "$a"; then echo "wasmld: NOTE (positional linker script): consuming '$a' — the wasm object format has no representation for symbol versioning or section layout (BRINTOS-1445)." >&2 continue fi classify_input "$a" "$i" else LINK_FLAGS+=("$a") fi done # ── -shared input discipline (HWJS-5 M1; checked BEFORE the bitcode-roots # floor so a native-only -shared line gets THIS refusal, not the generic # one): a side module's inputs must ALL be bitcode. A native wasm object # cannot join the ONE merged-module pass run — its code would ship # un-coroutinized (the §8b class), and any `hwjs.abi` custom section it # carries would CONCATENATE with the merged record at wasm-ld and corrupt # the SINGLE-RECORD manifest invariant. if [[ $IS_SHARED == 1 && ${#NATIVE_INPUTS[@]} -gt 0 ]]; then fatal "-shared side-module link received NATIVE wasm input(s) [${NATIVE_INPUTS[*]}] — every side-module TU must arrive as BITCODE so the single merged-module pass run covers it (and so no input can smuggle a second hwjs.abi custom section past the single-record manifest invariant). Compile with the pinned clang -emit-llvm (or wasmcc -c under WASMLD_HWJS=1)." fi # ── -shared LIBC-POOL-MEMBER refusal (superproject issue #52 secondary # finding 1): a libc pool member CANNOT ride a -shared side-module link. # Pool members are compiled assuming they live INSIDE the main libc # closure: glibc members carry dso_local-keyed address-takes of libc # internals (e.g. stdlib_qsort.bc's pthread-cleanup handlers), which under # the PIC side-module codegen become R_WASM_TABLE_INDEX_REL_SLEB # relocations against UNDEFINED symbols (__pthread_cleanup_pop) — wasm-ld # refuses those with a cryptic relocation error far from the cause (plus # stray libgcc personality refs nothing on wasm defines). A .so's libc # calls must stay env IMPORTS bound to the MAIN at dlopen (ADR-0004); # refuse the misplaced member HERE, by name, with the reason. Detection is # path-keyed on the configured pool dir ($HWJS_LIBC_BC — the same dir the # main arm pulls); the wasm-ld failure wrap below backstops a member # arriving from an unconfigured path. if [[ $IS_SHARED == 1 && -d "$HWJS_LIBC_BC" ]]; then _pool_real="$(cd "$HWJS_LIBC_BC" && pwd)" for _in in "${BC_ROOTS[@]}" "${POOL[@]}"; do _in_real="$(cd "$(dirname "$_in")" && pwd)/$(basename "$_in")" case "$_in_real" in "$_pool_real"/*) fatal "-shared side-module link received LIBC POOL MEMBER '$_in' (from the pool at $_pool_real) — pool members assume they live inside the main libc closure (dso_local-keyed address-takes of libc internals like __pthread_cleanup_pop; libgcc personality refs) and produce cryptic R_WASM_TABLE_INDEX_REL_SLEB relocation refusals under the PIC side-module codegen. A .so's libc references must stay env IMPORTS bound to the MAIN at dlopen (surface them with WASMLD_DYN_EXPORT_LIBC on the main's link). If you genuinely need the code .so-side, use a self-contained PIC TU with no libc references (issue linux-in-the-browser#52)." ;; esac done fi # ── libc.so PRODUCER (WASMLD_LIBC_SO=1): ALL-MEMBERS closure. The whole PIC pool + the S2 accessor TU # join the bitcode ROOTS (full-linked at the one llvm-link below) — deliberately BYPASSING the # member-granular pool_pull: the closure is the whole libc, and weak/strong resolves at full-link # granularity (the archive-pull's 2b strong-override becomes a post-merge ASSERTION below). # Archive-derived POOL members (if any) fold into the same full link for the same reason. # The non-PIC pool-member refusal above ran FIRST and stays intact (the S1 RED arm: this is a # distinct arm, not a loosening). if [[ "$LIBC_SO" == "1" ]]; then # ── ALL-MEMBERS ARCHIVE-DEDUP. A real ld builds libc.so from an ARCHIVE: # duplicate strong defs are resolved by first-member-wins (later members # are simply never pulled). The producer's ALL-MEMBERS fold instead hands # llvm-link EVERY member, so a symbol strong-defined by N members becomes # "symbol multiply defined" (glibc compiles a few shared TUs into several # subdirs — iconv/intl/nss all get hash-string.c; malloc/ + misc/ both get # malloc-hugepages.c — so N byte-equivalent members each strong-define the # same name). Reconcile it the archive way: among members that collide on # a strong global symbol, KEEP one and DROP the members whose ENTIRE # strong-global set is already claimed by the kept member (a pure # per-subdir duplicate). A collision between members with DIFFERENT strong # sets is a GENUINE redefinition (two different definitions of one name — # the allocator-redefinition class) and is a LOUD refusal, never a silent # pick. (musl's pool has no strong-strong collisions, so this is a no-op # there: the collision set is empty and the loop keeps every member.) _pool_members=() libc_pool_glob _pool_members "$HWJS_LIBC_BC_PIC" run_llvm_bulk "$HWJS_NM" -A --defined-only "${_pool_members[@]}" 2>/dev/null \ | awk '{t=$3; if (t ~ /^[A-Z]$/ && t!="U" && t!="V" && t!="W"){p=$1; sub(/:$/,"",p); print p"\t"$4}}' \ > "$WORK/pool.strongdefs" _dedup_coll="$(cut -f2 "$WORK/pool.strongdefs" | LC_ALL=C sort | uniq -d)" if [[ -z "$_dedup_coll" ]]; then BC_ROOTS+=( "${_pool_members[@]}" "$HWJS_TLS_ACCESS_BC" ) else declare -A _dyn_msig=() _dyn_claimed=() _dyn_keptsig=() _dyn_iscoll=() while IFS= read -r _cs; do [[ -n "$_cs" ]] && _dyn_iscoll["$_cs"]=1; done <<<"$_dedup_coll" while IFS=$'\t' read -r _mem _sym; do _dyn_msig["$_mem"]+="$_sym " done < <(LC_ALL=C sort "$WORK/pool.strongdefs") _pool_keep=(); _pool_drop=() for _m in "${_pool_members[@]}"; do _sig="${_dyn_msig[$_m]:-}" if [[ -z "$_sig" ]]; then _pool_keep+=( "$_m" ); continue; fi _hascoll=0 for _s in $_sig; do [[ -n "${_dyn_iscoll[$_s]:-}" ]] && { _hascoll=1; break; }; done if [[ $_hascoll == 0 ]]; then _pool_keep+=( "$_m" ); continue; fi _allclaimed=1; _anyclaimed=0 for _s in $_sig; do if [[ -n "${_dyn_claimed[$_s]:-}" ]]; then _anyclaimed=1; else _allclaimed=0; fi done if [[ $_anyclaimed == 0 ]]; then _pool_keep+=( "$_m" ) for _s in $_sig; do _dyn_claimed["$_s"]="$_m"; done _dyn_keptsig["$_sig"]=1 elif [[ $_allclaimed == 1 && -n "${_dyn_keptsig[$_sig]:-}" ]]; then _pool_drop+=( "$_m" ) # pure per-subdir duplicate (identical strong set already kept) else printf 'wasmld: redefining member %s (strong defs: %s)\n' "$_m" "$_sig" >&2 fatal "WASMLD_LIBC_SO: the ALL-MEMBERS glibc pool link carries a GENUINE strong-symbol redefinition — member '$_m' strong-defines name(s) already defined by a DIFFERENT member with a non-identical strong set (partial overlap), i.e. two distinct definitions of one symbol (the allocator/interposer-redefinition class), not a byte-equivalent per-subdir duplicate. llvm-link would 'multiply define'; the archive-dedup refuses to silently pick one. Resolve at the pool membership contract (build-glibc-libc.sh)." fi done echo "wasmld: NOTE (WASMLD_LIBC_SO archive-dedup): dropped ${#_pool_drop[@]} pure per-subdir duplicate member(s) [$(printf '%s ' "${_pool_drop[@]##*/}")] colliding on strong symbol(s) [$(printf '%s ' $_dedup_coll)] — first-member-wins, the archive-pull semantics the ALL-MEMBERS fold replaces. Kept ${#_pool_keep[@]} of ${#_pool_members[@]}." >&2 BC_ROOTS+=( "${_pool_keep[@]}" "$HWJS_TLS_ACCESS_BC" ) fi # ── PUBLICATION SEED (first-fork finding; the wasmcc WASMLD_RT_IMPORT # injection's PRODUCER twin — see libexec/brintcc-musl "PUBLICATION SEED"). The libc.so # closure, like every rt-import main, excludes the runtime TU — and with it the # hwjs.suspends.v1 publication of the ONE userland park leaf __hwjs_user_park # (published on its DEFINITION in arch/wasm32/vdso/hwjs_user_rt.c). musl's # plain `extern void __hwjs_user_park(unsigned)` decls (_Fork/execve/dlopen/ # __syscall_cp/__wasm_signal_dispatch) then stop seeding, their callers lower # NATIVE, and the imported leaf's flat ramp is called-and-discarded: the fork/ # exec/dlopen park is silently LOST (reason never set, __hwjs_park_take_result # returns the default) and the orphaned publish trips the next bracket's # clear_parked — the F151 orphan-at-clear class, witnessed on the REAL musl # libc.so's first fork (pid 1, _Fork.resume). The seed TU re-publishes the # annotated PROTOTYPE so the F83 published-decl edge routes every caller as a # CALLEE-SUSPEND descend into the imported coroutine (GOT.func identity — the # loader's deferGotFunc/patch path), exactly how rt-import MAINS route it. # RED (gate-only): HWJS_RT_IMPORT_NO_SEED=1 skips the injection here too (one # knob, both injection sites); production refuses it without the # WASMLD_HWJS_ALLOW_RED_KNOBS acknowledgement (config-attestation class). if [[ "${HWJS_RT_IMPORT_NO_SEED:-0}" != "0" ]]; then if [[ "${WASMLD_HWJS_ALLOW_RED_KNOBS:-0}" != "1" ]]; then fatal "fix-disabling RED knob HWJS_RT_IMPORT_NO_SEED=${HWJS_RT_IMPORT_NO_SEED} is set for a PRODUCTION libc.so producer link — without the publication-seed TU musl's _Fork/execve/dlopen/__syscall_cp park-leaf callers lower NATIVE inside libc.so and every fork/exec/blocking park is silently lost (the F151 orphan-at-clear trap). Unset it, or set WASMLD_HWJS_ALLOW_RED_KNOBS=1 for a deliberate non-vacuity arm." fi echo "wasmld: NOTE (HWJS_RT_IMPORT_NO_SEED, RED arm ACKNOWLEDGED): SKIPPING the producer publication-seed TU — the pre-fix park-under-native shape. Non-production." >&2 else cat > "$WORK/libc_so_pubseed.c" <<'PUBSEED' /* libc.so producer publication seed — GENERATED by wasmld (WASMLD_LIBC_SO=1). * Annotated prototype for the rt callable surface's ONE suspending leaf, * re-published closure-side because the runtime TU (its publishing home, * arch/wasm32/vdso/hwjs_user_rt.c) is module #0 — never part of this merge. * See the injection comment in libexec/brintld + the twin in libexec/brintcc-musl. */ __attribute__((annotate("hwjs.suspends.v1"))) extern void __hwjs_user_park(unsigned int reason); __attribute__((used)) static void __hwjs_libc_so_pubseed(void) { __hwjs_user_park(0u); /* reason 0 = NONE/breath; never executed */ } PUBSEED if "${HWJS_CLANG[@]}" --target="$HWJS_COMPILE_TARGET" -O2 \ -fPIC -fvisibility=default \ -matomics -mbulk-memory -mmutable-globals -msign-ext \ -emit-llvm -c "$WORK/libc_so_pubseed.c" -o "$WORK/libc_so_pubseed.bc" 2>"$WORK/libc_so_pubseed.err"; then BC_ROOTS+=( "$WORK/libc_so_pubseed.bc" ) echo "wasmld: NOTE (WASMLD_LIBC_SO): INJECTED the publication-seed TU — hwjs.suspends.v1 prototype for __hwjs_user_park, restoring the excluded runtime TU's cross-TU suspend seed on the producer closure (RED: HWJS_RT_IMPORT_NO_SEED=1)." >&2 else cat "$WORK/libc_so_pubseed.err" >&2 fatal "WASMLD_LIBC_SO: the publication-seed TU failed to compile to bitcode (error above) — an unseeded producer link would lower every musl park-leaf caller NATIVE (lost forks), so refusing rather than linking the failure class in." fi fi if [[ -f "$HWJS_LIBC_BUILTINS_BC" ]]; then BC_ROOTS+=( "$HWJS_LIBC_BUILTINS_BC" ) echo "wasmld: NOTE (WASMLD_LIBC_SO): joining compiler-rt builtins TU $HWJS_LIBC_BUILTINS_BC (hidden-visibility PIC bitcode; the S1 native-TU disposition for the soft-float/wide-int class)." >&2 else echo "wasmld: NOTE (WASMLD_LIBC_SO): no compiler-rt builtins TU at $HWJS_LIBC_BUILTINS_BC — legal only for a closure with no long-double/complex/int128 paths (a toy fixture); the post-link IMPORT-SURFACE AUDIT refuses any residue." >&2 fi if [[ ${#POOL[@]} -gt 0 ]]; then echo "wasmld: NOTE (WASMLD_LIBC_SO): folding ${#POOL[@]} archive member(s) into the ALL-MEMBERS full link (no need-only pull on the producer arm)." >&2 BC_ROOTS+=( "${POOL[@]}" ) POOL=() fi # ── crt/dynamic BOUNDARY-DEFINITION seed (WASMLD_LIBC_SO; PLAN-DYNAMIC-LIBC # G3 "third blocker", after F132 + the DS-sentinel guard). glibc's csu # (csu/libc-start.c) and dynamic-linker code reference a CLOSED set of # linker-synthesized / ld.so-owned DATA symbols as dso_local (hidden): the # ELF init/preinit/fini array delimiters, the r_debug rendezvous object, # ld.so's _end heap marker, the rseq TLS offset/size, and the signal/errno # string tables. A NON-PIC main link resolves those undefined refs to 0 # (the "NON-PIC MERGED IMAGE" note at the top of this file); but the # -shared PIC producer lowers each to an R_WASM_MEMORY_ADDR_REL_SLEB reloc, # which wasm-ld CANNOT apply against an UNDEFINED symbol ("relocation # R_WASM_MEMORY_ADDR_REL_SLEB is not supported against an undefined symbol") # -> the final-link wall. REL_SLEB is a fixed-layout reloc, not a GOT # slot, so these cannot ride as GOT.mem imports (unlike the RT §11 GOT.func # surface the DS-sentinel guard exempts): they need REAL defs in the merged # closure. Define them HERE, inert, reproducing the main link's # resolve-to-empty semantics: # * init/preinit/fini arrays -> EMPTY (end aliases start => 0 entries); # a wasm .so runs ctors/dtors via __wasm_call_ctors + the loader, never # these ELF arrays, so glibc's call_init finds them empty (correct). # * _r_debug_extended / _end -> inert (ld.so-owned; the hwjs loader uses # neither glibc's r_debug protocol nor its minimal-malloc _end base, # which is a dead path once the real allocator is present). # * _rseq_offset / _rseq_size -> 0 == glibc's "rseq not registered" # state (no rseq syscall under wasm) -- the CORRECT disabled value. # * __sys_siglist/__sys_sigabbrev/_sys_errlist_internal[_len] -> EMPTY: # the wasm32 pool ships no generated siglist/errlist TU, so strsignal/ # strerror degrade to "Unknown signal/error N" exactly as the # undefined->0 main link already does (a DOCUMENTED residual; restore # full fidelity by adding the generated tables to the pool via # build-glibc-libc.sh -- out of this producer arm's scope). # NARROW by construction: only names ACTUALLY undefined in the merged # closure AND on this closed allowlist are defined (musl's closure # references none -> injects nothing). Any OTHER hidden-data REL_SLEB # undefined stays a hard wasm-ld refusal, named by the attributed wrap at # the final link. Every def is WEAK, so a real pool definition -- should # one ever land -- WINS (never a silent shadow of genuine data). _lso_bnd_allow="$(printf '%s\n' \ __init_array_start __preinit_array_start __fini_array_start \ _r_debug_extended _end _rseq_offset _rseq_size \ __sys_siglist __sys_sigabbrev _sys_errlist_internal _sys_errlist_internal_len)" _lso_bnd_undef="$(run_llvm_bulk "$HWJS_NM" --undefined-only "${BC_ROOTS[@]}" 2>/dev/null | awk '{print $NF}' | LC_ALL=C sort -u)" # HWJS-LOCALE-COMM: comm merges under the AMBIENT locale while both operands are # LC_ALL=C-sorted. Under a UTF-8 locale (bitbake exports LC_ALL=en_US.UTF-8 into # every task) glibc's collation ignores the leading underscores at the primary # level, so `_end` sorts before `__fini_array_start` for comm and after it for the # inputs: the merge desynchronises, comm reports "input is not in sorted order" on # stderr and returns an EMPTY intersection. The seed below then injects nothing and # the -shared final link refuses on the very symbols this block exists to define. _lso_bnd_need="$(LC_ALL=C comm -12 <(printf '%s\n' "$_lso_bnd_allow" | LC_ALL=C sort -u) \ <(printf '%s\n' "$_lso_bnd_undef") | grep -E '.' || true)" if [[ -n "$_lso_bnd_need" ]]; then _bnd_has() { printf '%s\n' "$_lso_bnd_need" | grep -qxF "$1"; } { cat <<'CRTBOUNDS_HDR' /* libc.so producer crt/dynamic BOUNDARY-DEFINITION seed — GENERATED by wasmld * (WASMLD_LIBC_SO=1). Inert weak/hidden defs for the linker-synthesized / * ld.so-owned DATA symbols glibc references dso_local, so the -shared PIC * producer's R_WASM_MEMORY_ADDR_REL_SLEB relocs resolve (they cannot ride as * GOT imports). See the injection comment in libexec/brintld. */ #define HWJS_EMPTY_ARRAY(name) \ __attribute__((weak, used, visibility("hidden"))) \ void (*const name##_start[1])(void) = { 0 }; \ extern void (*const name##_end[1])(void) \ __attribute__((weak, visibility("hidden"), alias(#name "_start"))); CRTBOUNDS_HDR _bnd_has __init_array_start && echo 'HWJS_EMPTY_ARRAY(__init_array)' _bnd_has __preinit_array_start && echo 'HWJS_EMPTY_ARRAY(__preinit_array)' _bnd_has __fini_array_start && echo 'HWJS_EMPTY_ARRAY(__fini_array)' _bnd_has _r_debug_extended && echo '__attribute__((weak, used, visibility("hidden"))) char _r_debug_extended[64];' _bnd_has _end && echo '__attribute__((weak, used, visibility("hidden"))) int _end;' _bnd_has _rseq_offset && echo '__attribute__((weak, used, visibility("hidden"))) int _rseq_offset;' _bnd_has _rseq_size && echo '__attribute__((weak, used, visibility("hidden"))) int _rseq_size;' _bnd_has __sys_siglist && echo '__attribute__((weak, used, visibility("hidden"))) const void *__sys_siglist[65];' _bnd_has __sys_sigabbrev && echo '__attribute__((weak, used, visibility("hidden"))) const void *__sys_sigabbrev[65];' _bnd_has _sys_errlist_internal && echo '__attribute__((weak, used, visibility("hidden"))) const void *_sys_errlist_internal[1];' _bnd_has _sys_errlist_internal_len && echo '__attribute__((weak, used, visibility("hidden"))) int _sys_errlist_internal_len;' } > "$WORK/libc_so_crtbounds.c" if "${HWJS_CLANG[@]}" --target="$HWJS_COMPILE_TARGET" -O2 \ -fPIC -fvisibility=default \ -matomics -mbulk-memory -mmutable-globals -msign-ext \ -emit-llvm -c "$WORK/libc_so_crtbounds.c" -o "$WORK/libc_so_crtbounds.bc" 2>"$WORK/libc_so_crtbounds.err"; then BC_ROOTS+=( "$WORK/libc_so_crtbounds.bc" ) printf 'wasmld: NOTE (WASMLD_LIBC_SO): INJECTED the crt/dynamic boundary-definition TU — inert weak defs for hidden REL_SLEB DATA symbol(s) undefined in the closure (a -shared PIC producer cannot leave them undefined; the non-PIC main resolves them to 0): %s\n' $_lso_bnd_need >&2 if printf '%s\n' "$_lso_bnd_need" | grep -qE '^(__sys_siglist|__sys_sigabbrev|_sys_errlist_internal|_sys_errlist_internal_len)$'; then echo "wasmld: NOTE: the signal/errno string table(s) above are stubbed EMPTY (pool ships no generated siglist/errlist TU) — strsignal/strerror degrade to \"Unknown signal/error N\", as the undefined->0 main link already does. Restore fidelity by adding the tables to the pool (build-glibc-libc.sh)." >&2 fi else cat "$WORK/libc_so_crtbounds.err" >&2 fatal "WASMLD_LIBC_SO: the crt/dynamic boundary-definition TU failed to compile (error above) — without it the -shared producer's R_WASM_MEMORY_ADDR_REL_SLEB relocs against [$_lso_bnd_need] stay undefined and wasm-ld refuses the final link." fi fi echo "wasmld: NOTE (WASMLD_LIBC_SO): libc.so producer — ALL-MEMBERS link of $HWJS_LIBC_BC_PIC ($(ls "$HWJS_LIBC_BC_PIC"/*.bc | wc -l) members) + S2 accessor TU $HWJS_TLS_ACCESS_BC (PLAN-DYNAMIC-LIBC §3.1)." >&2 fi [[ ${#BC_ROOTS[@]} -gt 0 ]] || fatal "no bitcode roots on the link line — were the objects compiled with WASMLD_HWJS=1 wasmcc? (all positional inputs were native)." # ── HWJS-A-2 (R-K12-3): host-called-ONLY ABI roots for the pool pull. # The merged-module fixpoint (step 2) uses `llvm-link -only-needed`, which # imports a pool symbol ONLY if something already in the closure references # it. The per-Worker signal-region setter __wasm32_set_signal_region lives # in the musl pool (src/signal/wasm32/__wasm_signal_dispatch.c) but is # called EXCLUSIVELY by the host (worker.mjs after _start, to wire the # wasm_signal_pending_region into musl's cooperative dispatch trampoline) — # NOTHING inside the user closure references it. So -only-needed drops it, # the final wasm-ld --export=__wasm32_set_signal_region can't resurrect an # already-deleted definition, exp.__wasm32_set_signal_region is undefined in # the Worker, __wasm_signal_pending_off stays 0, and the WHOLE user-side # signal-dispatch path (handlers + default actions + the W4 sigconsume # kernel-pending reconcile) is a silent no-op — a Ctrl-C/SIGINT to the # process is delivered kernel-side but never run user-side (EINTR storm). # The tls/stack/entry accessors do NOT hit this because they are defined in # the crt/entry ROOTS (full-linked at step 1); only the pool-resident signal # ABI is exposed. Fix: synthesize a `used` keepalive root that direct-calls # the setter/getter (direct call — NOT address-taken, so the coro pass never # treats them as conservative-indirect and never rewrites their host ABI), # making them "needed" for the -only-needed pull and kept through DCE. KEEPALIVE_BC="" # Dynamic-libc arm (WASMLD_LIBC_IMPORT=1): the keepalive stays OUT — __wasm32_set_signal_region's DEFINITION is a # pool member that moves into libc.so; a keepalive here would leave a phantom env import (the host # looks the setter up on module #1 instead; §3.5 owner audit records owner=libc.so and drops the # main-side --export). if [[ $IS_SHARED != 1 && "$LIBC_IMPORT" != "1" ]]; then # MAIN-image root only: a .so has no signal-region ABI (host binds the main's) cat > "$WORK/abi_keepalive.c" <<'EOF' extern void __wasm32_set_signal_region(unsigned long off); extern unsigned long __wasm32_get_signal_region(void); /* Never called at runtime (the host calls the accessors directly); `used` * pins this through DCE, and its direct calls force the pool definitions to * survive -only-needed so the final --export resolves. */ __attribute__((used, visibility("default"))) void __wasm32_abi_keepalive(void) { __wasm32_set_signal_region(__wasm32_get_signal_region()); } EOF if "${HWJS_CLANG[@]}" --target="$HWJS_COMPILE_TARGET" -O0 \ "${HWJS_PIC_ARGS[@]}" \ -matomics -mbulk-memory -mmutable-globals -msign-ext \ -ffunction-sections -fdata-sections -fvisibility=default \ -emit-llvm -c "$WORK/abi_keepalive.c" -o "$WORK/abi_keepalive.bc" 2>"$WORK/keepalive.err"; then KEEPALIVE_BC="$WORK/abi_keepalive.bc" BC_ROOTS+=("$KEEPALIVE_BC") else cat "$WORK/keepalive.err" >&2 fatal "failed to compile the signal-region ABI keepalive root (needed so -only-needed keeps __wasm32_set_signal_region)." fi fi # ── 2-arg main bridge (clang wasm main-signature lowering). For the wasm # target clang RENAMES `int main(int argc, char **argv)` to __main_argc_argv # (the 0-arg form becomes __main_void); a 3-arg `main(argc,argv,envp)` — like # bash's — is NOT recognised and stays `main`. Our musl crt1 calls # __libc_start_main(main, …) by name, so a 2-arg-main program (coreutils, # busybox, …) leaves `main` UNDEFINED -> it becomes an `env.main` import and # the real (renamed) main is never driven: the program is an inert shell. # Synthesize a tiny 3-arg `main` root that forwards to __main_argc_argv (the # wasm-ld __main_void equivalent, but matching our crt's `main` contract). # CONDITIONAL: only when __main_argc_argv is defined but main is NOT, so # bash (defines main) and reactor modules (neither) are never touched. # (-shared: skipped entirely — a side module has no crt and no main contract.) roots_have_main=0; roots_have_argcargv=0 [[ $IS_SHARED == 1 ]] && roots_have_main=1 # sentinel: disables the pool scan + the bridge below for _r in "${BC_ROOTS[@]}"; do _syms="$("$HWJS_NM" --defined-only "$_r" 2>/dev/null | awk '{print $NF}')" grep -qx 'main' <<<"$_syms" && roots_have_main=1 grep -qx '__main_argc_argv' <<<"$_syms" && roots_have_argcargv=1 done # BusyBox shape (HWJS-A-3): unlike autoconf programs (bash/coreutils) whose # main is a ROOT .o, BusyBox's 2-arg main (__main_argc_argv) + the applet_main[] # dispatch table live in a program ARCHIVE (libbb/lib.a -> POOL), pulled # -only-needed. With main in the pool and NOTHING referencing it (the crt # references `main`, which clang renamed away), the pull never seeds it and the # merged module is an inert crt-only shell (the applets are never pulled). So # when no ROOT carries main/argcargv, consult the POOL too: a pool-defined # __main_argc_argv (main undefined everywhere) still warrants the bridge — the # synthesized `main` references __main_argc_argv, which seeds the -only-needed # pull from the program member outward. This POOL scan runs ONLY in the # no-root-main case, so the autoconf path (roots_have_argcargv==1) is byte-for- # byte unchanged and bash/coreutils never reach it. pool_have_main=0; pool_have_argcargv=0 if [[ $roots_have_main == 0 && $roots_have_argcargv == 0 && ${#POOL[@]} -gt 0 ]]; then _psyms="$(run_llvm_bulk "$HWJS_NM" --defined-only "${POOL[@]}" 2>/dev/null | awk '{print $NF}' | sort -u)" grep -qx 'main' <<<"$_psyms" && pool_have_main=1 grep -qx '__main_argc_argv' <<<"$_psyms" && pool_have_argcargv=1 fi if [[ ( $roots_have_argcargv == 1 || $pool_have_argcargv == 1 ) \ && $roots_have_main == 0 && $pool_have_main == 0 ]]; then cat > "$WORK/main_bridge.c" <<'EOF' /* clang lowered this program's `int main(int,char**)` to __main_argc_argv; * the musl crt1 calls __libc_start_main(main,…) by name. Bridge the two. */ extern int __main_argc_argv(int argc, char **argv); int main(int argc, char **argv, char **envp) { (void) envp; return __main_argc_argv(argc, argv); } EOF if "${HWJS_CLANG[@]}" --target="$HWJS_COMPILE_TARGET" -O2 \ "${HWJS_PIC_ARGS[@]}" \ -matomics -mbulk-memory -mmutable-globals -msign-ext \ -ffunction-sections -fdata-sections -fvisibility=default \ -emit-llvm -c "$WORK/main_bridge.c" -o "$WORK/main_bridge.bc" 2>"$WORK/main_bridge.err"; then BC_ROOTS+=("$WORK/main_bridge.bc") else cat "$WORK/main_bridge.err" >&2 fatal "failed to compile the 2-arg main bridge root (main -> __main_argc_argv)." fi fi # musl pool LAST (musl-authoritative dedup order: program archives first, # so a program-local definition wins the pull; a genuine duplicate-def # conflict fails the llvm-link loudly — stop-and-report, never a quiet # pick). -shared: NO libc pool — a side module's libc refs stay env imports # the host binds to the main image at dlopen (ADR-0004 open world). # Dynamic-libc arm (WASMLD_LIBC_IMPORT=1): NO pool either — the main's libc refs become # imports resolved from libc.so (module #1); -lc and the musl-integrated # names were consumed above and are satisfied by libc.so now, not the pool. if [[ $IS_SHARED != 1 && "$LIBC_IMPORT" != "1" ]]; then libc_pool_glob POOL "$HWJS_LIBC_BC" fi # BRINTOS-1734-SHARED-BUILTINS: join the compiler-rt builtins TU on the ORDINARY # -shared arm too. # It must be a BC_ROOT and not an archive: roots are full-linked below, whereas an # archive would explode into POOL and be pulled -only-needed — and the builtin CALLS # are created during CODEGEN, after the merge, so a need-only pull sees no reference # and takes nothing (measured: re2's bbappend records exactly that failure). if [[ $IS_SHARED == 1 && "$LIBC_SO" != "1" && -f "$HWJS_LIBC_BUILTINS_BC" ]]; then BC_ROOTS+=( "$HWJS_LIBC_BUILTINS_BC" ) echo "wasmld: NOTE (-shared): joining compiler-rt builtins TU $HWJS_LIBC_BUILTINS_BC (hidden-visibility PIC bitcode; resolves the soft-float/wide-int class module-locally instead of emitting env.* imports nothing can bind)." >&2 fi # ── (1) Merge the roots (full-linked: every root symbol survives). run_llvm_bulk "$HWJS_LLVM_BIN/llvm-link" "${BC_ROOTS[@]}" -o "$WORK/merged.bc" \ || fatal "llvm-link of the bitcode roots failed." # ── (2) Archive pull — MEMBER-granular (ticket #8). The retired # `llvm-link -only-needed` fixpoint was SYMBOL-granular: a pool member # whose functions were imported across DIFFERENT fixpoint passes dragged # in a FRESH copy of the member's internal (static) globals each time — # llvm-link renames the collision (encodingTable + encodingTable.616) and # the image ships with TWO half-initialized copies of what the C program # declared as ONE object. First witnessed as Tcl 8.6's # Tcl_CreateEncoding dispatching through the never-initialized copy of # its static hash table (the "null function or function signature # mismatch ... .resume" runtime trap). Real linkers never had this bug # because archive extraction is MEMBER-granular: a member is selected iff # it defines a currently-undefined strong symbol, and selection brings # the WHOLE member exactly once. wasmld-pool-select.mjs implements that # selection (left-to-right pool order, first provider wins — the # musl-authoritative order above — iterated to a fixpoint like a # start/end-group bracket); the selected members then FULL-link into the # merge in ONE llvm-link, so every internal global exists exactly once. # A genuine duplicate strong def between selected members still fails # the llvm-link loudly — same contract as before, now with GNU-ld # member semantics. Selection needs node (a toolchain dependency: # wasmrun, the import witness), or brint-tool on an installed toolchain; # absence is a loud refusal below. if [[ "$BRINT_TOOL_INSTALLED" == 1 ]]; then POOL_SELECT=( "$BRINT_TOOL" pool-select ) else POOL_SELECT=( "$HWJS_HOME/libexec/wasmld-pool-select.mjs" ) [[ -f "${POOL_SELECT[0]}" ]] \ || fatal "pool-member selector missing at ${POOL_SELECT[0]} — the member-granular archive pull cannot run. Refusing." command -v node >/dev/null 2>&1 \ || fatal "the member-granular archive pull needs node (a toolchain dependency; libexec/brintrun and the import witness already require it). Refusing." POOL_SELECT=( node "${POOL_SELECT[@]}" ) fi printf '%s\n' "${POOL[@]}" > "$WORK/pool.list" pool_pull() { # $1 = label for the failure message if [[ "${WASMLD_HWJS_SYMBOL_GRANULAR_PULL:-0}" != "0" ]]; then # RED arm (fix-disabler, refused for production by the RED-knob gate # above): the retired symbol-granular fixpoint, kept ONLY so # test/pool-member-granularity.sh can prove the member-granular fix # is non-vacuous (a member imported across two passes duplicates its # internal globals and the image dispatches through the # never-initialized copy). local _pass before after for _pass in 1 2 3 4 5; do before=$("$HWJS_NM" --defined-only "$WORK/merged.bc" 2>/dev/null | wc -l) run_llvm_bulk "$HWJS_LLVM_BIN/llvm-link" "$WORK/merged.bc" -only-needed "${POOL[@]}" \ -o "$WORK/merged.next.bc" 2>"$WORK/link.err" \ || { cat "$WORK/link.err" >&2; fatal "symbol-granular archive pull llvm-link failed ($1 — RED arm; see above)."; } mv "$WORK/merged.next.bc" "$WORK/merged.bc" after=$("$HWJS_NM" --defined-only "$WORK/merged.bc" 2>/dev/null | wc -l) [[ "$before" == "$after" ]] && break done return 0 fi # merged.def carries `KIND NAME` (the selector needs strong-vs-weak to # dedup partial-overlap twins without disqualifying weak aliases). "$HWJS_NM" --defined-only "$WORK/merged.bc" 2>/dev/null \ | awk 'NF >= 2 { print $(NF-1), $NF }' | sort -u > "$WORK/merged.def" # Undefs include WEAK undefs ('w') — a deliberate departure from GNU # archive semantics (where weak undefs never extract a member). On # this target wasm-ld REFUSES data/TLS relocations against undefined # symbols ("relocation R_WASM_MEMORY_ADDR_TLS_SLEB cannot be used # against an undefined symbol"), so glibc's weak-extern pull-avoidance # idiom (_nl_current_LC_* / NL_CURRENT_INDIRECT: weak refs that on ELF # benignly resolve to 0 when unpulled) MUST be satisfied whenever a # provider exists. A weak undef with no provider stays unresolved and # is judged at the phantom-import witness like before. "$HWJS_NM" "$WORK/merged.bc" 2>/dev/null \ | awk '$1 == "U" || $1 == "w" { print $2 }' | sort -u > "$WORK/merged.undef" "${POOL_SELECT[@]}" "$HWJS_NM" "$WORK/merged.def" "$WORK/merged.undef" "$WORK/pool.list" \ > "$WORK/pool.selected" \ || fatal "pool-member selection failed ($1; selector output above). An undecidable member pull is a refusal, never a guess." if [[ -s "$WORK/pool.selected" ]]; then # Selector lines are either a plain member path (pulled whole) or # `\t` — a PARTIAL-OVERLAP pull (issue # linux-in-the-browser#49): the member is the provider of a symbol # nothing else supplies, but ALSO strongly re-defines the listed # symbols an earlier provider already won. DEMOTE those symbols to # internal linkage before the link (opt -passes=internalize with a # keep-list of the member's OTHER global defs), so the earlier # definition keeps winning at symbol granularity while the member # still joins whole exactly once (member granularity for internal # state). The pre-#49 skip-the-whole-member behavior orphaned the # sole-provider symbols into phantom env.* imports (tcl's compat # strtoul.o vs musl strtol.o) and survives only behind the RED knob # WASMLD_HWJS_POOL_PARTIAL_OVERLAP_SKIP (non-vacuity arm: # test/pool-partial-overlap-pull.sh). local -a _sel=() local _line _path _drop _dsym _demote_n=0 while IFS= read -r _line; do _path="${_line%%$'\t'*}" if [[ "$_line" == *$'\t'* ]]; then _drop="${_line#*$'\t'}" _demote_n=$((_demote_n + 1)) local _dem="$WORK/pool.demoted.$_demote_n.bc" # keep-list = the member's global defs MINUS the demoted names # (internalize KEEPS the listed API public and internalizes the # rest, so the list must be the complement). "$HWJS_NM" --defined-only "$_path" 2>/dev/null \ | awk 'NF >= 2 && $(NF-1) ~ /^[A-Z]$/ { print $NF }' | sort -u > "$WORK/pool.demoted.$_demote_n.all" tr ',' '\n' <<<"$_drop" | sort -u > "$WORK/pool.demoted.$_demote_n.drop" comm -23 "$WORK/pool.demoted.$_demote_n.all" "$WORK/pool.demoted.$_demote_n.drop" \ > "$WORK/pool.demoted.$_demote_n.keep" "$HWJS_LLVM_BIN/opt" -passes=internalize \ -internalize-public-api-file="$WORK/pool.demoted.$_demote_n.keep" \ "$_path" -o "$_dem" 2>"$WORK/link.err" \ || { cat "$WORK/link.err" >&2; fatal "partial-overlap demotion failed ($1): opt -passes=internalize on pool member '$_path' (demoting [$_drop]). Refusing."; } # GATE (non-vacuous): every demoted symbol MUST now be internal # (lowercase nm kind) in the demoted copy — a demotion that did # not bind would re-collide at the llvm-link as a duplicate # strong definition, or worse, silently shadow the first # provider. Fail loud here with the member + symbol named. local _dem_syms _dem_syms="$("$HWJS_NM" "$_dem" 2>/dev/null || true)" while IFS= read -r _dsym; do [[ -z "$_dsym" ]] && continue grep -qE "[[:space:]][a-z] ${_dsym}$" <<<"$_dem_syms" \ || fatal "partial-overlap demotion did NOT bind ($1): '$_dsym' is still a global definition in the demoted copy of pool member '$_path'. Refusing rather than shipping a duplicate/shadowed definition." done < "$WORK/pool.demoted.$_demote_n.drop" echo "wasmld: NOTE: pool member '$_path' pulled with [$_drop] demoted to internal linkage — an earlier provider already strongly defines them (first-provider-wins at symbol granularity, issue linux-in-the-browser#49)." >&2 _sel+=("$_dem") else _sel+=("$_path") fi done < "$WORK/pool.selected" run_llvm_bulk "$HWJS_LLVM_BIN/llvm-link" "$WORK/merged.bc" "${_sel[@]}" \ -o "$WORK/merged.next.bc" 2>"$WORK/link.err" \ || { cat "$WORK/link.err" >&2; fatal "member-granular archive pull llvm-link failed ($1: duplicate strong definition between selected members, or version skew — see above)."; } mv "$WORK/merged.next.bc" "$WORK/merged.bc" fi } # -shared with no archive inputs has an EMPTY pool — nothing to pull (the # main arm's pool always carries the musl bitcode and stays unconditional). if [[ ${#POOL[@]} -gt 0 ]]; then pool_pull "initial pull" fi # ── (2b) STRONG-OVERRIDE of musl's exit-hook + stdio-handle weak dummies. musl # exit.c defines weak_alias(dummy, __funcs_on_exit) and weak_alias(dummy, # __stdio_exit), and fflush.c/__stdio_exit.c/pthread_create.c each define a weak # `static FILE *dummy = 0` aliased to __stdout_used / __stderr_used / __stdin_used, # so a minimal program links; atexit.c, __stdio_exit.c, and stdout.c/stderr.c/ # stdin.c carry the STRONG defs (__stdout_used = &__stdout_FILE, etc.) that a real # (archive-member-granular) linker substitutes once atexit / stdio is used. # But -only-needed is SYMBOL-granular: it treats the weak dummy as "satisfied" # and never imports the strong override even though the providing member IS # pulled (stdout.c is pulled for __stdout_FILE / `stdout`, atexit.c for __towrite). # Two distinct one-line-piped-output regressions result, BOTH "only the first # line, the one the initial line-buffer default flushed before __stdout_write # detected the pipe and set lbf=-1, survives; the FULLY-buffered tail is dropped": # * __funcs_on_exit / __stdio_exit no-op -> the exit-time stdio flush + atexit # close_stdout handlers silently do nothing (matters for coreutils, which # flushes via an atexit close_stdout). # * __stdout_used / __stderr_used / __stdin_used stuck at the null dummy -> the # `if (__stdout_used) fflush(__stdout_used)` arm of fflush(NULL) is dead, so # busybox's fflush_all()==fflush(NULL) (e.g. `seq N | cat`, `ls / | cat`, # `ls /bin | wc -l`) never flushes stdout at all, AND __stdio_exit's # close_file(__stdout_used) is a close_file(NULL) no-op. (coreutils dodges # this by fflush'ing the explicit `stdout` pointer, never fflush(NULL).) # Re-bind each symbol still on the dummy to its strong pool provider (llvm-link # --override), then re-pull so the strong defs' fresh references (fflush, __ofl_*, # the handler table, __stdout_FILE's write/seek vtable) resolve. # OPT-OUT (WASMLD_HWJS_NO_EXIT_HOOK_OVERRIDE=1): a program that uses NO # buffered stdio output (e.g. the a2-session-init /init wrapper, which writes # only via raw write(2) syscalls and then execve's bash) has nothing for the # exit-time flush to do, yet force-pulling the STRONG __stdio_exit drags in the # full stdio teardown — including __stdio_seek, whose off_t(i64) return is read # back through a cascade-emitted __hwjs_promise_i64 reader. In a tiny closure # with no other i64-returning coroutinized function the pass never DEFINES that # reader (anyNat64 stays false), so it survives as an `env.__hwjs_promise_i64` # IMPORT the host does not provide and the process Worker fails to instantiate. # The flush fix is meaningless for a no-stdio program, so allow it to opt out; # default (unset) keeps the override for bash/coreutils/busybox where it matters. # NOTE: __funcs_on_exit/__stdio_exit are TEXT (T) defs; __std{out,err,in}_used are # DATA (D) globals — so both the provider scan and the post-gate accept [TD]. OVR=() OVR_SYMS=() if [[ "${WASMLD_HWJS_NO_EXIT_HOOK_OVERRIDE:-0}" != "0" ]]; then echo "wasmld: NOTE (WASMLD_HWJS_NO_EXIT_HOOK_OVERRIDE=1): skipping the exit-hook strong-override (no-stdio program; avoids the unresolved env.__hwjs_promise_i64 import)." >&2 fi merged_syms="$("$HWJS_NM" "$WORK/merged.bc" 2>/dev/null || true)" for _sym in __funcs_on_exit __stdio_exit __stdout_used __stderr_used __stdin_used; do [[ "${WASMLD_HWJS_NO_EXIT_HOOK_OVERRIDE:-0}" != "0" ]] && break # libc.so producer: the ALL-MEMBERS full link resolves weak/strong at combine time (strong mallocng/ # atexit/stdio defs override the weak dummies inside ONE llvm-link) — the pull-shaped override is # meaningless there; the [TD]-strong POST-GATE below still runs as an assertion (PLAN-DYNAMIC-LIBC §3.1). [[ "$LIBC_SO" == "1" ]] && break # weak (dummy) def of the hook/handle still bound? (grep on a captured string, # NOT a pipe — `nm | grep -q` trips set -o pipefail via grep's early-close # SIGPIPE). if grep -qE "[[:space:]][wW] ${_sym}$" <<<"$merged_syms"; then _ovr_pool=(); libc_pool_glob _ovr_pool "$HWJS_LIBC_BC" for _bc in "${_ovr_pool[@]}"; do _bc_syms="$("$HWJS_NM" --defined-only "$_bc" 2>/dev/null || true)" # T = the exit-hook functions; D = the stdio-handle globals. if grep -qE "[[:space:]][TD] ${_sym}$" <<<"$_bc_syms"; then OVR+=(--override "$_bc"); OVR_SYMS+=("$_sym"); break fi done fi done if [[ ${#OVR[@]} -gt 0 ]]; then run_llvm_bulk "$HWJS_LLVM_BIN/llvm-link" "$WORK/merged.bc" "${OVR[@]}" \ -o "$WORK/merged.next.bc" 2>"$WORK/link.err" \ || { cat "$WORK/link.err" >&2; fatal "exit-hook strong-override llvm-link failed (see above)."; } mv "$WORK/merged.next.bc" "$WORK/merged.bc" # The strong defs bring fresh references (fflush, __ofl_*, the handler # table, __stdout_FILE's write/seek vtable) — resolve them with the same # member-granular pull (ticket #8: never the symbol-granular fixpoint). pool_pull "post-override pull" # GATE (non-vacuous): a symbol we set out to override MUST now be strong (T for # the exit-hook functions, D for the stdio-handle globals). If it is still weak # the strong def did not bind and the exit-time stdio flush / atexit handlers / # fflush(NULL) stdout arm would silently no-op again -> fully-buffered piped # stdout is dropped (the `ls -1 / | cat` / `seq N | cat` one-line regression). # Fail loud, never ship the no-op. post_syms="$("$HWJS_NM" "$WORK/merged.bc" 2>/dev/null || true)" for _sym in "${OVR_SYMS[@]}"; do grep -qE "[[:space:]][TD] ${_sym}$" <<<"$post_syms" \ || fatal "exit-hook/stdio-handle '${_sym}' is STILL a weak dummy after strong-override (the override did not bind). Shipping would silently drop fully-buffered exit output. Stop-and-report." done fi # ── libc.so producer [TD]-strong POST-GATE (PLAN-DYNAMIC-LIBC §3.1): the archive-pull's weak/strong # patch-ups are COMBINE-TIME properties of the ALL-MEMBERS link — assert they held. Any of the # exit-hook/stdio-handle symbols PRESENT in the merged libc closure must be a STRONG [TD] def; # a surviving weak dummy would ship a libc.so whose exit-time flush silently no-ops for EVERY main. # (Symbols absent entirely — a toy/fixture closure — are legitimately out of scope.) if [[ "$LIBC_SO" == "1" ]]; then _dyn_syms="$("$HWJS_NM" "$WORK/merged.bc" 2>/dev/null || true)" for _sym in __funcs_on_exit __stdio_exit __stdout_used __stderr_used __stdin_used; do if grep -qE "[[:space:]][A-Za-z] ${_sym}$" <<<"$_dyn_syms"; then grep -qE "[[:space:]][TD] ${_sym}$" <<<"$_dyn_syms" \ || fatal "WASMLD_LIBC_SO: '${_sym}' is present but NOT a strong [TD] definition after the ALL-MEMBERS full link — the weak dummy won the combine, so libc.so would ship a no-op exit-time stdio flush for every main (the one-line-piped-output class, distro-wide). Stop-and-report (PLAN-DYNAMIC-LIBC §3.1)." fi done fi # ── (2c) STATIC-LOADER DCE pre-pass (HWJS-B-1 gate B-1.e; OPT-IN, glibc arm). # glibc's static start path statically references a web of ELF dynamic-loader + # locale internals (dl-load/dl-deps, the gconv-module dlopen path, intl/localealias) # that are UNREACHABLE at run time in a static wasm binary but whose VLA-address- # escapes-to-syscall shapes the coroutinize pass REFUSES (vla/dynamic-alloca- # address-escape-opaque-sink). Internalize the merged module to the host/runtime # ABI roots (exactly the link_coro --export set + the host entry + ctors — every # symbol the host or runtime can call) and globaldce the provably-dead remainder # BEFORE the pass runs, so dead loader code never reaches (or refuses in) the pass. # llvm.global_ctors keeps the ctor closure; anything not reachable from the roots # is genuinely dead. Never-silent: a GENUINELY-reachable loader/locale call is KEPT # and still refuses loudly in the pass (as without this step) — DCE removes only # the unreachable. GATED on WASMLD_HWJS_DCE_MERGE=1; musl's coro link never sets it # and is byte-for-byte unchanged (verified: a musl link with the knob unset takes # the original path). if [[ "${WASMLD_HWJS_DCE_MERGE:-0}" != "0" ]]; then # NOTE the syscall-ABI helpers (__wasm32_syscall6 / _ret / _errval): on the # glibc arm the raw syscall PSEUDO stubs are NATIVE objects linked at the # final wasm-ld stage (after this bitcode DCE), so nothing in the BITCODE # closure references these helpers and globaldce would drop them, leaving the # native stubs' calls undefined (-> env imports). Pin them as roots so the # bitcode definitions survive for the native stage (same class as the # signal-region keepalive). # drive_abi=4 status-channel surface (STATUS-CHANNEL-CONTRACT §10): the # channel BLOCK (data) + the op roster must survive this pre-pass, for two # distinct reasons, both witnessed classes: # (a) EXPORT identity — the dynamic-main --export-if-defined roster # below binds these names on the FINAL wasm-ld line; an internalized # symbol makes that a SILENT no-op (the issue #52 class), shipping a # main whose dlopen'd .so's env.__hwjs_status_* imports cannot bind # the one channel block (§1: all modules address the SAME block — # the R1-glibc split-brain was this identity broken in-module; # a59e1da fixed the pass-side lookup, THIS keeps the link-side # export surface real). # (b) LIVENESS — this pre-pass runs BEFORE the coroutinize/EH-prelower # plugin compile, but those passes EMIT the references (park_exit / # baton / clear / sj_register / violation / longjmp_lost are called # only from pass-generated code): at DCE time they look dead, # globaldce deletes the RT definitions, and the later # getOrInsertFunction mints bare declarations that final-link as # phantom env imports. # Missing names are tolerated by internalize (abi≤3 RTs without the # surface are unchanged). _status_channel_roots="__hwjs_status_channel,__hwjs_status_park_exit,__hwjs_status_baton,__hwjs_status_clear,__hwjs_status_adopt_top,__hwjs_drive_adopt_parked,__hwjs_status_raise_unwind,__hwjs_status_raise_longjmp,__hwjs_status_sj_register,__hwjs_status_violation,__hwjs_status_longjmp_lost,__hwjs_uncaught_unwinding" dce_roots="_start,__wasm32_user_entry,__wasm32_abi_keepalive,__wasm32_set_signal_region,__wasm32_get_signal_region,__wasm32_syscall6,__wasm32_syscall_ret,__wasm32_syscall_errval,__wasm32_args_buf_addr,__wasm32_args_buf_size,__wasm32_get_tls_base,__wasm32_set_tls_base,__wasm32_get_stack_pointer,__wasm32_set_stack_pointer,__wasm_call_ctors,__wasm32_ctors_run_in_start,__wasm32_child_return,hwjs_resume,hwjs_done,hwjs_destroy,__hwjs_take_parked,__hwjs_drive,__hwjs_drive_resume,__hwjs_drive_done_status,__hwjs_set_parked,__hwjs_clear_parked,__hwjs_parked,__hwjs_parked_owner,__hwjs_park_reason,__hwjs_park_result,__hwjs_frame_live,__hwjs_frame_high_water,__hwjs_frame_allocs,__hwjs_progress_violations,__hwjs_violation_pending_lanes,__hwjs_violation_buf_lanes,__hwjs_violation_pending,__hwjs_violation_buf,__hwjs_promise_i32,__hwjs_ds_region_abi_v2,__hwjs_fork_stw,__hwjs_fork_stw_seq,__hwjs_fork_stw_ack,__hwjs_fork_stw_inslice,__hwjs_fork_child_collapse,${_status_channel_roots}${WASMLD_HWJS_DCE_EXTRA_ROOTS:+,$WASMLD_HWJS_DCE_EXTRA_ROOTS}" # ── DYN_EXPORT_LIBC × DCE cross-check, PRE (superproject issue #52 # secondary finding 2): record which WASMLD_DYN_EXPORT_LIBC symbols are # GLOBAL definitions in the merged module BEFORE the internalize pre-pass. # A requested export that is defined here but loses external linkage below # would make the final --export-if-defined a SILENT no-op (witnessed: # 144/144 surface symbols unexported on the glibc arm, caught only by the # downstream partition check) — refuse that loudly at THIS link instead. _dynexp_defined=() if [[ "$DYNAMIC_MAIN" != "0" && -n "${WASMLD_DYN_EXPORT_LIBC:-}" ]]; then _pre_dce_syms="$("$HWJS_NM" --defined-only "$WORK/merged.bc" 2>/dev/null || true)" for _dyn_sym in ${WASMLD_DYN_EXPORT_LIBC}; do # uppercase nm kind = external (global) definition; lowercase = already internal. grep -qE "[[:space:]][A-Z] ${_dyn_sym}$" <<<"$_pre_dce_syms" \ && _dynexp_defined+=("$_dyn_sym") done fi "$HWJS_LLVM_BIN/opt" -passes=internalize -internalize-public-api-list="$dce_roots" \ "$WORK/merged.bc" -o "$WORK/merged.int.bc" 2>"$WORK/dce.err" \ && "$HWJS_LLVM_BIN/opt" -passes=globaldce "$WORK/merged.int.bc" -o "$WORK/merged.dce.bc" 2>>"$WORK/dce.err" \ || { cat "$WORK/dce.err" >&2; fatal "WASMLD_HWJS_DCE_MERGE pre-pass internalize+globaldce failed (see above)."; } mv "$WORK/merged.dce.bc" "$WORK/merged.bc" echo "wasmld: NOTE (WASMLD_HWJS_DCE_MERGE=1): pruned runtime-dead code from the merged module before the coroutinize pass (glibc static-loader decouple, B-1.e)." >&2 # ── DYN_EXPORT_LIBC × DCE cross-check, POST: every requested export that # WAS a global definition pre-internalize must STILL be one — otherwise # the internalize pre-pass ate its external linkage (and globaldce may # have deleted it outright) before --export-if-defined could bind, and # the .so-facing surface ships silently empty. Never-silent: refuse with # the fix named (the symbols belong in WASMLD_HWJS_DCE_EXTRA_ROOTS, which # joins the internalize keep-list above). if [[ ${#_dynexp_defined[@]} -gt 0 ]]; then _post_dce_syms="$("$HWJS_NM" --defined-only "$WORK/merged.bc" 2>/dev/null || true)" _dynexp_eaten=() for _dyn_sym in "${_dynexp_defined[@]}"; do grep -qE "[[:space:]][A-Z] ${_dyn_sym}$" <<<"$_post_dce_syms" \ || _dynexp_eaten+=("$_dyn_sym") done if [[ ${#_dynexp_eaten[@]} -gt 0 ]]; then fatal "WASMLD_DYN_EXPORT_LIBC symbol(s) [${_dynexp_eaten[*]}] were GLOBAL definitions in the merged module but the WASMLD_HWJS_DCE_MERGE internalize pre-pass stripped their external linkage BEFORE --export-if-defined could bind — the .so-facing export surface would ship SILENTLY EMPTY (the exact issue linux-in-the-browser#52 finding: 144/144 surface symbols unexported on the glibc arm). Add them to WASMLD_HWJS_DCE_EXTRA_ROOTS so the internalize keep-list preserves them, e.g. WASMLD_HWJS_DCE_EXTRA_ROOTS=\"${_dynexp_eaten[*]// /,}\" (comma-separated)." fi echo "wasmld: NOTE (DYN_EXPORT_LIBC x DCE cross-check): all ${#_dynexp_defined[@]} defined WASMLD_DYN_EXPORT_LIBC symbol(s) kept external linkage through the DCE pre-pass (issue linux-in-the-browser#52)." >&2 fi fi # Begin no-EH preparation. WebAssembly's unwindless backend lowers invokes # and deletes unreachable cleanup blocks. Do that BEFORE coroutine splitting: # a suspension in an otherwise-dead cleanup creates a resume-entry successor, # making its `resume` reachable even after the backend removes the invoke. # The glibc pthread_once cleanup exposed this: native LLVM silently reached # llvm_unreachable UB, while the same compiler hosted in Wasm trapped in ISel. # EH_PRELOWER_LOAD (not ACTIVE) preserves the status-channel=1/prelower=0 # contradiction for the prelower pass's existing loud refusal. C++ and # explicitly enabled EH keep their status-channel lowering unchanged. if [[ $EH_PRELOWER_LOAD == 0 ]]; then "$HWJS_LLVM_BIN/opt" -passes='function(lower-invoke,unreachableblockelim),verify' \ "$WORK/merged.bc" -o "$WORK/merged.no-eh.bc" 2>"$WORK/no-eh.err" \ || { cat "$WORK/no-eh.err" >&2; fatal "no-EH preparation before coroutinize failed (see above)."; } mv "$WORK/merged.no-eh.bc" "$WORK/merged.bc" \ || fatal "cannot publish the no-EH prepared merged module" echo "wasmld: NOTE (no-EH): lowered native invokes and removed unreachable cleanup blocks before coroutinize." >&2 fi # End no-EH preparation. # ── (3) The ONE plugin compile, under the cc-wasm.sh guard suite. # Boundary stays the pass default (hwjs_suspend — design §2): the coro # leaf parks via the hwjs_suspend.park import; linux.syscall stays a # plain synchronous import. Stale sweep knobs are unset explicitly so a # leaked HWJS_CORO_CLASSIFY_ONLY/HWJS_CORO_BOUNDARY cannot silently # produce an untransformed or wrongly-seeded image. hwjs_err="$WORK/plugin.err" # Pass-run env, spelled per arm. The pass's drive knobs are VALUE-keyed # (issue linux-in-the-browser#41: flatDriveRequested() reads the value, so # FLAT=0 is the nested opt-out and garbage values refuse loudly) — both the # main arm's FLAT=$HWJS_DRIVE_FLAT spelling and the -shared arm's NESTED=1 # spelling now select the same mode; the -shared arm keeps its explicit # NESTED=1 spelling (the shape the abi_manifest RED-1 gate pins). # HWJS_CORO_MERGED_MODULE=1 marks THIS (wasmld's one merged-module run) as # the context in which the pass may emit the per-module hwjs.abi manifest # custom section — per-TU clang -fpass-plugin compiles never set it, which # is what makes the SINGLE-RECORD manifest invariant hold by construction # (lld concatenates same-named custom sections across input objects). hwjs_env=( -u HWJS_CORO_CLASSIFY_ONLY -u HWJS_CORO_BOUNDARY -u HWJS_CORO_DRIVE_TO_COMPLETION -u HWJS_PARKED_NO_IDENTITY ) # F152 (issue linux-in-the-browser#40): HWJS_CORO_SIDE_MODULE marks THIS merged run as a `-shared` # side module — the pass emits the sig-compatible open-world dispatch consult # (__hwjs_xmod_dispatch_check env import) under it. Set exclusively by this arm switch (never by # callers). ORDERING: env(1) parses `-u` options only BEFORE the first VAR=VALUE assignment, so # every -u must precede every assignment here. # F152-MAIN (issue linux-in-the-browser#52): a dlopen-capable DYNAMIC MAIN (WASMLD_DYNAMIC_MAIN) # gets the SAME consult via HWJS_CORO_DYNAMIC_MAIN=1 — a .so hands its callback fp to a main-side # libc API (qsort/bsearch/atexit/signal) and the MAIN's open-world fp dispatch then holds a # .so-side target; without the consult that dispatch was closed-world-lowered and trapped # `unreachable` (the glibc msort_with_tmp comparator finding). The dynamic-main import witness # sanctions exactly this one env fn import; a STATIC main (DYNAMIC_MAIN=0) stays codegen-neutral # and -u's a leaked value. if [[ $IS_SHARED == 1 ]]; then if [[ "$HWJS_DRIVE_FLAT" != "0" ]]; then hwjs_env+=( -u HWJS_CORO_DYNAMIC_MAIN -u HWJS_CORO_DRIVE_NESTED HWJS_CORO_SIDE_MODULE=1 HWJS_CORO_DRIVE_FLAT=1 ) else hwjs_env+=( -u HWJS_CORO_DYNAMIC_MAIN -u HWJS_CORO_DRIVE_FLAT HWJS_CORO_SIDE_MODULE=1 HWJS_CORO_DRIVE_NESTED=1 ) fi elif [[ "$DYNAMIC_MAIN" != "0" ]]; then hwjs_env+=( -u HWJS_CORO_SIDE_MODULE -u HWJS_CORO_DRIVE_NESTED HWJS_CORO_DYNAMIC_MAIN=1 HWJS_CORO_DRIVE_FLAT="$HWJS_DRIVE_FLAT" ) else hwjs_env+=( -u HWJS_CORO_SIDE_MODULE -u HWJS_CORO_DYNAMIC_MAIN -u HWJS_CORO_DRIVE_NESTED HWJS_CORO_DRIVE_FLAT="$HWJS_DRIVE_FLAT" ) fi hwjs_env+=( HWJS_SUSPEND_PROTOCOL=1 HWJS_FORCE_PARKED_FLAG=1 ) # HWJS_CORO_MERGED_MODULE marks THIS run as the one merged-module pass run, and is what makes the # single-record manifest invariant hold: per-TU `clang -fpass-plugin` compiles never come through # wasmld and so never set it. It is therefore set on EVERY coro-arm link, including static mains. # # It used to ride the DYNAMIC_MODULE conditional below, which wasmld only takes for -shared and # dynamic-main links. The consequence was that bash, busybox, coreutils, the daemon and every # thread fixture — all of the guest compute — carried no hwjs.abi manifest at all, so every # artifact-side check over the manifest was silently vacuous on exactly those images. Hoisting it # is what lets the PREEMPTIBILITY check below judge a static main. DYNAMIC_MODULE still gates the # dynamic-module CODEGEN (and now also flags bit3, which is stamped from it rather than hardcoded). hwjs_env+=( HWJS_CORO_MERGED_MODULE=1 ) [[ "$DYNAMIC_MODULE" != "0" ]] && hwjs_env+=( HWJS_CORO_DYNAMIC_MODULE=1 ) # ── F151 (superproject issue #44): the BINDING-AWARE xmod import-suspend partition. # WASMLD_HWJS_XMOD_SUSPENDERS= (whitespace-separated import names whose main-side binding IS a # coroutine — the main pass's own verdict, e.g. the tcl rig's suspenders.txt read off the linked main's # name section) is forwarded to the pass as HWJS_CORO_XMOD_SUSPENDERS at THIS one merged-module run, # REPLACING the wasm-import-module attribute keying for the GAP-B import-suspend seed (which # mis-partitions real libraries in both directions — see the gate xmod_seed_partition.mjs). -shared-only: # a MAIN image's env fn imports are refused outright (dynamic-MAIN witness), so a partition there is a # misplaced request. Never-silent: the raw pass-level knob leaking through the driver would silently # narrow whatever link happens to run — refuse it by name and require the WASMLD_HWJS spelling. for _rawk in HWJS_CORO_XMOD_SUSPENDERS HWJS_CORO_XMOD_PARTITION HWJS_CORO_PUBLISH_SUSPENDERS HWJS_CORO_XMOD_UNWIND_WAIVE HWJS_CORO_XMOD_SIBLING_NATIVES; do if [[ -n "${!_rawk:-}" ]]; then fatal "$_rawk is set in wasmld's environment — the pass-level knob must not leak through the driver (a leaked partition/publish/waiver would silently re-partition whatever link happens to run). Spell the request through the WASMLD_* surface: WASMLD_HWJS_XMOD_SUSPENDERS / WASMLD_HWJS_XMOD_PARTITION_LIBC / WASMLD_LIBC_SO=1 + WASMLD_LIBC_SO_PARTITION_OUT / WASMLD_LIBC_SO_UNWIND_WAIVE, and HWJS_CORO_XMOD_SIBLING_NATIVES is derived from WASMLD_SHARED_SIBLING_LIBS (issue linux-in-the-browser#44; PLAN-DYNAMIC-LIBC §3.2; BRINTOS-2877)." fi done # BRINTOS-2689: the pass takes ONE names-only knob (HWJS_CORO_XMOD_SUSPENDERS) # and THREE surfaces can now contribute to it on a single link — the caller's # binding partition (below), the needed-entry seed, and the sibling seed. Each # used to assign the knob directly, so on any link that had two of them the # later `env VAR=` simply won and the earlier surface vanished — no message, # no refusal, a narrower seed than either contributor asked for. Collect them # instead and forward ONCE, below. XMOD_SUSPENDER_FILES=() if [[ -n "${WASMLD_HWJS_XMOD_SUSPENDERS:-}" ]]; then if [[ "$LIBC_IMPORT" == "1" ]]; then # Dynamic-libc MAIN CONSUMER — the documented, DELIBERATE lift of the "misplaced request" refusal below # (PLAN-DYNAMIC-LIBC): the direction inverts — libc.so PUBLISHED its per-export # partition once; every main consumes it. The file is the verdict-carrying PUBLISHED format # (` ` lines), forwarded on the pass's verdict-aware knob (the # legacy names-only knob cannot express native/refused, which the absent-import and conflict # refusals key on). F150's exit-strip keys off the same partition (main imports exit FROM # libc.so); the consumed file's sha256 is stamped into this main's hwjs.abi v2 manifest and # cross-checked against libc.so's recorded digest post-link (digest lockstep). [[ -f "$WASMLD_HWJS_XMOD_SUSPENDERS" ]] \ || fatal "WASMLD_HWJS_XMOD_SUSPENDERS='$WASMLD_HWJS_XMOD_SUSPENDERS' does not exist — the libc.so PUBLISHED partition must be built before any consumer main links (PLAN-DYNAMIC-LIBC §3.2)." echo "wasmld: NOTE (WASMLD_LIBC_IMPORT): consuming libc.so's PUBLISHED suspender partition '$WASMLD_HWJS_XMOD_SUSPENDERS' on a MAIN link — the deliberate direction-inversion of the F151 guard (PLAN-DYNAMIC-LIBC)." >&2 # The pass reads the WORK-dir SNAPSHOT (TOCTOU close): the bytes it consumes are the # bytes every post-link check hashes, regardless of concurrent producer rebuilds. hwjs_env+=( HWJS_CORO_XMOD_PARTITION="$LIBC_PARTITION_CONSUMED" ) else [[ $IS_SHARED == 1 ]] \ || fatal "WASMLD_HWJS_XMOD_SUSPENDERS is only meaningful on the -shared side-module arm (the partition narrows a .so's import-suspend seed by its future main's binding classes) or on a WASMLD_LIBC_IMPORT=1 main (the published-partition consumer); this is neither (issue linux-in-the-browser#44)." [[ -f "$WASMLD_HWJS_XMOD_SUSPENDERS" ]] \ || fatal "WASMLD_HWJS_XMOD_SUSPENDERS='$WASMLD_HWJS_XMOD_SUSPENDERS' does not exist — the binding partition file must be generated before the .so link (issue linux-in-the-browser#44)." XMOD_SUSPENDER_FILES+=( "$WASMLD_HWJS_XMOD_SUSPENDERS" ) fi fi # ── The needed entry's IMPORT-SUSPEND SEED (BRINTOS-1634). Recording a package # library in the needed-list above makes the loader bind this main's env fn # imports from that library's exports — it does NOT tell the pass that any of # those bindings SUSPEND. On this arm a partition is always active (libc.so's # published one, forwarded below), and an ACTIVE partition is the whole verdict: # an import it does not list is seeded NATIVE. libc.so's partition describes # libc.so's exports and nothing else, so every needed-package-library export fell # through as native — the main called it with no descend bracket, the library's # own park set the parked slot under that unbracketed frame, and the next park # tripped `set-over-set: parked slot not empty` (bzip2 -t -> BZ2_bzRead). # The seed the main needs is exactly "the needed library's exports that are # coroutines", which the .so already records: a ramp `f` is a coroutine iff the # module carries `f.resume`. Derive it from the SNAPSHOTS (the same bytes the # digest, the witness and the loader see) and hand it to the pass on the # names-only knob, which UNIONS it with the published partition and refuses any # name the two surfaces class differently (§3.2). # NEVER-SILENT: a needed .so with no name section cannot be classified, and # guessing either way re-creates one of the two mis-partition traps — refuse. # An empty derivation is legitimate (a library with no suspending export) and # leaves the knob UNSET, because the pass refuses an empty list. # RED arm: WASMLD_NEEDED_LIBS_NO_SUSPEND_SEED=1 restores the pre-fix seed. # ── The coroutine-export derivation, used by BOTH import-suspend seeds: the # needed-entry seed below (BRINTOS-1634 — a MAIN's needed .so) and the sibling # seed on the -shared arm (BRINTOS-2689 — a side module's sibling .so). ONE # implementation on purpose: the rule is a property of the module format — a # ramp `f` is a coroutine iff the module carries `f.resume` in its NAME # section, never the export table, which carries no `.resume` entry at all and # answers 0 for every module on this arm — and a second copy of it would drift # in the mis-partition direction, silently. # The clone has two spellings there. wasm-ld writes the name section # demangled, and LLVM's demangler prints a clone suffix in parentheses: the # resume clone of `_ZN7acommon10new_configEv` is `acommon::new_config() # (.resume)`. A C name, or a link with --no-demangle, gives `f.resume`. # Matching only `f.resume` seeded every C++ coroutine export NATIVE, and its # caller called it unbracketed (BRINTOS-2980: libaspell's new_config, an # `orphan at clear` in aspell's main). The (9) derivation and the libc.so # cross-verify take the same two spellings. # $1 = the .so to classify $2 = file the names are APPENDED to # $3 = file node's stderr is captured in # $4 = optional: file the NATIVE function exports are appended to (the # sibling seed's claimed half, BRINTOS-2877; the needed-entry seed omits it) # That stderr is captured rather than merged: a NODE_OPTIONS/Experimental # warning would otherwise be appended verbatim as a suspender NAME. Returns # node's exit status — the CALLER decides what a failure means, because the two # arms answer that differently (see each call site). _hwjs_coro_export_names() { node -e ' const fs = require("fs"); const b = fs.readFileSync(process.argv[1]); let p = 8, expfns = [], names = null; function u32() { let r = 0, s = 0, x; do { x = b[p++]; r |= (x & 0x7f) << s; s += 7; } while (x & 0x80); return r >>> 0; } function str() { const n = u32(), s = b.toString("utf8", p, p + n); p += n; return s; } while (p < b.length) { const id = b[p++], len = u32(), end = p + len; if (id === 7) { const n = u32(); for (let i = 0; i < n; i++) { const nm = str(); const k = b[p++]; const idx = u32(); if (k === 0) expfns.push([nm, idx]); } } else if (id === 0) { const save = p, sec = str(); if (sec === "name") { while (p < end) { const sub = b[p++], sl = u32(), se = p + sl; if (sub === 1) { names = new Map(); const n = u32(); for (let i = 0; i < n; i++) { const idx = u32(); names.set(idx, str()); } p = se; break; } p = se; } } p = save; } p = end; } if (!names) { console.error("no name section"); process.exit(2); } const have = new Set(names.values()); const coro = (f) => have.has(f + ".resume") || have.has(f + " (.resume)"); const out = [], nat = []; for (const [nm, idx] of expfns) { const inner = names.get(idx); if ((inner && coro(inner)) || coro(nm)) out.push(nm); else nat.push(nm); } console.log(out.join("\n")); if (process.argv[2]) fs.appendFileSync(process.argv[2], nat.join("\n") + "\n"); ' "$1" ${4:+"$4"} >> "$2" 2>"$3" } NEEDED_SUSPENDERS_FILE="" if [[ ${NEEDED_SO_SNAPSHOTS[0]+x} == x && -z "${WASMLD_NEEDED_LIBS_NO_SUSPEND_SEED:-}" ]]; then _nsusp="$WORK/needed-suspenders.txt" _nsuspraw="$WORK/needed-suspenders.raw" _nsusperr="$WORK/needed-suspenders.err" : > "$_nsuspraw" for _nsnap in "${NEEDED_SO_SNAPSHOTS[@]}"; do if ! _hwjs_coro_export_names "$_nsnap" "$_nsuspraw" "$_nsusperr"; then [[ -s "$_nsusperr" ]] && cat "$_nsusperr" >&2 fatal "WASMLD_NEEDED_LIBS: cannot derive the import-suspend seed from '$_nsnap' (node's reason above) — a needed .so whose coroutine exports cannot be classified would be seeded NATIVE wholesale, which is exactly the set-over-set orphan this seed exists to prevent. Build the library with its name section intact (BRINTOS-1634)." fi # stderr is NOT merged into the derivation: node warning text (NODE_OPTIONS, # an ExperimentalWarning) would otherwise be appended verbatim as a # suspender NAME. The same rule, and the same reason, as needed_so_record # above. A warning on a SUCCESSFUL run is surfaced, not swallowed. if [[ -s "$_nsusperr" ]]; then echo "wasmld: NOTE (WASMLD_NEEDED_LIBS): node wrote to stderr while classifying '$_nsnap' (kept OUT of the seed):" >&2 cat "$_nsusperr" >&2 fi : > "$_nsusperr" done # One pass that does three things, each of which must not be silent: # drop the reserved namespaces the pass hard-refuses (__hwjs_/hwjs_/llvm. # env./GOT. — a runtime op must never be seeded through the partition), and # dedup across libraries FIRST-WINS in needed-list order, which is the order # the loader itself binds in, so the seed carries the verdict of the module # that will actually provide the symbol rather than of a later namesake. awk 'NF && $0 !~ /^(__hwjs_|hwjs_|llvm\.|env\.|GOT\.)/ && !seen[$0]++' \ "$_nsuspraw" > "$_nsusp" _nraw=$(awk 'NF' "$_nsuspraw" | wc -l) _nkept=$(wc -l < "$_nsusp") if [[ -s "$_nsusp" ]]; then NEEDED_SUSPENDERS_FILE="$_nsusp" echo "wasmld: NOTE (WASMLD_NEEDED_LIBS): import-suspend seed — $_nkept coroutine export(s) of $_nraw across ${#NEEDED_SO_SNAPSHOTS[@]} needed library/libraries seeded MAY-SUSPEND, so this main brackets the calls the loader binds to them; $(( _nraw - _nkept )) dropped as reserved-namespace or as a lower-precedence duplicate (BRINTOS-1634)." >&2 else echo "wasmld: NOTE (WASMLD_NEEDED_LIBS): import-suspend seed — no needed library exports a seedable coroutine ($_nraw candidate(s) before filtering); the seed stays UNSET and every needed import keeps libc.so's partition verdict (BRINTOS-1634)." >&2 fi fi # ── The needed-library half of the import-suspend seed (BRINTOS-1634), derived # from the needed .so snapshots above. It rides the names-only knob alongside # libc.so's published partition on the verdict knob: the pass unions the two # suspend surfaces and refuses any name they class differently, which is what # makes a package library's coroutine export visible to its consumer main # without touching the digest lockstep the published partition is under. if [[ -n "$NEEDED_SUSPENDERS_FILE" ]]; then XMOD_SUSPENDER_FILES+=( "$NEEDED_SUSPENDERS_FILE" ) fi # ── The SIBLING half of the import-suspend seed, on the -shared arm # (BRINTOS-2689). The needed-entry seed above covers a MAIN calling into a # package library. Nothing covered a package LIBRARY calling into a sibling # package library, and the two are the same defect one tier down. # # On a -shared link libc.so's published partition is active (the third-.so # consumer arm, WASMLD_HWJS_XMOD_PARTITION_LIBC) and an ACTIVE partition is # the WHOLE verdict: an import it does not list is seeded NATIVE. That # partition describes libc.so's exports and nothing else, so a sibling # library's coroutine exports fell through native — the caller emitted no # descend bracket, the callee parked anyway, and the orphaned handle tripped # the next bracketed call in that lane ("orphan at clear: parked slot not # empty at bracket entry"). Measured on core-image-brintos-emcraft, glibc, # BRINTOS_PACKAGE_SHARED=1: libwebpmux.so imports 55 env functions, 18 of them # coroutine exports of libwebp.so, and not one WebP*/VP8* name was classed on # that link — all 26 of its descend brackets were over libc callees. # # The .so paths arrive from the driver, which already resolves them (both the # -lNAME and the by-path arm) and then drops them: a side module carries no # needed list of its own (§3.4 single-carrier), so they are NOT recorded here # either. This seed changes what the PASS is told, never what the module # records. WASMLD_SHARED_SIBLING_LIBS is a driver->linker knob: no recipe sets # it, and it is refused by name on any other arm, so the overloading that made # a recipe-level fix impossible (WASMLD_HWJS_XMOD_SUSPENDERS means one thing on # a main and another on a .so) is not repeated. # # RED arm: WASMLD_SHARED_SIBLING_NO_SUSPEND_SEED=1 restores the pre-fix seed. if [[ -n "${WASMLD_SHARED_SIBLING_LIBS:-}" && $IS_SHARED != 1 ]]; then fatal "WASMLD_SHARED_SIBLING_LIBS is only meaningful on the -shared side-module arm (it names the sibling libraries THIS .so calls into, so their coroutine exports can be seeded may-suspend); a MAIN spells the same request WASMLD_NEEDED_LIBS, which also records the needed-list entry (BRINTOS-2689)." fi if [[ $IS_SHARED == 1 && -n "${WASMLD_SHARED_SIBLING_LIBS:-}" \ && "$LIBC_SO" != "1" && "$RT_SO" != "1" \ && -z "${WASMLD_SHARED_SIBLING_NO_SUSPEND_SEED:-}" ]]; then _ssusp="$WORK/sibling-suspenders.txt" _ssuspraw="$WORK/sibling-suspenders.raw" _ssusperr="$WORK/sibling-suspenders.err" _ssnat="$WORK/sibling-natives.txt" _ssnatraw="$WORK/sibling-natives.raw" _ssverd="$WORK/sibling-verdicts.raw" : > "$_ssuspraw" : > "$_ssnatraw" : > "$_ssverd" _sslibs=0 _ssunreadable=0 IFS=':' read -r -a _ssos <<< "$WASMLD_SHARED_SIBLING_LIBS" for _sso in ${_ssos[@]+"${_ssos[@]}"}; do [[ -n "$_sso" ]] || continue _sslibs=$((_sslibs + 1)) if [[ ! -f "$_sso" ]]; then # Same disposition as an unclassifiable module below, for the same reason: the # driver resolved this path moments ago, so its absence means a concurrent # relink of the sibling, and refusing would fail a link that succeeds today. echo "wasmld: WARNING (-shared sibling seed): '$_sso' is no longer a file — the driver resolved it on this link line, so it was removed or relinked underneath this link; its coroutine exports stay seeded NATIVE (BRINTOS-2689)." >&2 _ssunreadable=$((_ssunreadable + 1)) continue fi : > "$_ssuspraw.one" : > "$_ssnatraw.one" if ! _hwjs_coro_export_names "$_sso" "$_ssuspraw.one" "$_ssusperr" "$_ssnatraw.one"; then # WARNING, not fatal — and the asymmetry with the needed-entry seed above # is deliberate. There, a .so that cannot be classified is one the MAIN # records a needed entry for, and the link is new work that has never # succeeded; refusing costs nothing and guessing seeds a whole library # native. Here the sibling is a dependency of a link that succeeds TODAY, # unseeded, and the only condition that reaches this arm is a module # built without its name section. Refusing would turn that into a hard # build failure for a state that is not new, so this arm reports the # library by name and leaves it exactly as unseeded as it is now. [[ -s "$_ssusperr" ]] && cat "$_ssusperr" >&2 echo "wasmld: WARNING (-shared sibling seed): cannot classify sibling '$_sso' (node's reason above) — its coroutine exports stay seeded NATIVE, so a call from this module into one of them emits no descend bracket and the callee's park orphans a slice. Build that library with its name section intact (BRINTOS-2689)." >&2 _ssunreadable=$((_ssunreadable + 1)) : > "$_ssusperr" continue fi # One ordered record of every sibling's verdict per name, so both halves # below can take the FIRST sibling's verdict in link order (BRINTOS-2877). cat "$_ssuspraw.one" >> "$_ssuspraw" cat "$_ssnatraw.one" >> "$_ssnatraw" awk 'NF { print "S " $0 }' "$_ssuspraw.one" >> "$_ssverd" awk 'NF { print "N " $0 }' "$_ssnatraw.one" >> "$_ssverd" if [[ -s "$_ssusperr" ]]; then echo "wasmld: NOTE (-shared sibling seed): node wrote to stderr while classifying '$_sso' (kept OUT of the seed):" >&2 cat "$_ssusperr" >&2 fi : > "$_ssusperr" done # Same three jobs as the needed-entry seed's filter, for the same reasons: # drop the reserved namespaces the pass hard-refuses (a runtime op must never # be seeded through the partition), and dedup FIRST-WINS in link order. The # first sibling to export a name decides it for BOTH halves: a name an earlier # sibling exports natively is not a suspender because a later one exports it # as a coroutine (BRINTOS-2877) -- bracketing a native callee traps too. _ssfirst='NF == 2 && $2 !~ /^(__hwjs_|hwjs_|llvm\.|env\.|GOT\.)/ && !($2 in seen) { seen[$2] = 1; if ($1 == k) print $2 }' awk -v k=S "$_ssfirst" "$_ssverd" > "$_ssusp" _ssraw=$(awk 'NF' "$_ssuspraw" | wc -l) _sskept=$(wc -l < "$_ssusp") if [[ -s "$_ssusp" ]]; then XMOD_SUSPENDER_FILES+=( "$_ssusp" ) # `if`, not `&&`: the AND-list form is the shape this file already warns about # under `set -e`, and this one sits in the middle of the arm rather than at the # end of it -- not worth deciding by exemption rules. _ssunread_note="" if [[ $_ssunreadable -gt 0 ]]; then _ssunread_note="; $_ssunreadable librar(y/ies) could not be classified and stay NATIVE" fi echo "wasmld: NOTE (-shared sibling seed): $_sskept coroutine export(s) of $_ssraw across $_sslibs sibling librar(y/ies) seeded MAY-SUSPEND, so this side module brackets the calls the loader binds to them; $(( _ssraw - _sskept )) dropped as reserved-namespace or as a lower-precedence duplicate${_ssunread_note} (BRINTOS-2689)." >&2 else echo "wasmld: NOTE (-shared sibling seed): no sibling library exports a seedable coroutine ($_ssraw candidate(s) before filtering across $_sslibs librar(y/ies)); the seed contributes nothing and every sibling import keeps libc.so's partition verdict (BRINTOS-2689)." >&2 fi # The NATIVE half (BRINTOS-2877). Since #184 arm D an import no surface # classifies is seeded MAY-SUSPEND, on the premise that its provider is off # the link line. A sibling's native exports ARE on the link line, but the # half above hands the pass only the coroutines, so every call into them was # bracketed and the first one trapped (`xfer_descend on a non-live child # frame handle`: libmpc -> libmpfr). Hand the pass the rest as claimed, by # the same first-in-link-order rule as the suspenders above. awk -v k=N "$_ssfirst" "$_ssverd" > "$_ssnat" if [[ -s "$_ssnat" ]]; then hwjs_env+=( HWJS_CORO_XMOD_SIBLING_NATIVES="$_ssnat" ) echo "wasmld: NOTE (-shared sibling seed): $(wc -l < "$_ssnat") native export(s) across $_sslibs sibling librar(y/ies) claimed NATIVE, so arm D leaves the calls into them unbracketed (BRINTOS-2877)." >&2 fi fi # ── Forward the names-only knob ONCE, from every surface that contributed # (BRINTOS-2689). With a single contributor this hands the pass that # contributor's own file, byte for byte, so every link that predates the # sibling seed is unchanged. With more than one it unions them, which is what # the pass does with this knob and the verdict partition anyway; the union is # de-duplicated because the pass refuses an empty list and reads a repeated # name as no different from a single one. if [[ ${#XMOD_SUSPENDER_FILES[@]} -eq 1 ]]; then hwjs_env+=( HWJS_CORO_XMOD_SUSPENDERS="${XMOD_SUSPENDER_FILES[0]}" ) elif [[ ${#XMOD_SUSPENDER_FILES[@]} -gt 1 ]]; then _xsunion="$WORK/xmod-suspenders.union.txt" cat "${XMOD_SUSPENDER_FILES[@]}" | awk 'NF && !seen[$0]++' > "$_xsunion" echo "wasmld: NOTE: unioned ${#XMOD_SUSPENDER_FILES[@]} names-only suspender surface(s) [${XMOD_SUSPENDER_FILES[*]}] into '$_xsunion' ($(wc -l < "$_xsunion") name(s)) — the pass takes one such knob, and before this every surface but the last was silently dropped (BRINTOS-2689)." >&2 hwjs_env+=( HWJS_CORO_XMOD_SUSPENDERS="$_xsunion" ) fi # ── Third-.so CONSUMER arm (PLAN-DYNAMIC-LIBC §3.2 (iii)): a THIRD .so whose libc imports now bind # to libc.so consumes the CONCATENATION of libc.so's published partition (this knob) and the # main-derived partition (WASMLD_HWJS_XMOD_SUSPENDERS above). The pass unions the suspend surfaces # and REFUSES any name present in both with differing verdicts; libc.so's suspender-digest is # stamped into this .so's hwjs.abi v2 record for the dlopen-path skew refusal (loader pin = L2). if [[ -n "${WASMLD_HWJS_XMOD_PARTITION_LIBC:-}" ]]; then [[ $IS_SHARED == 1 ]] \ || fatal "WASMLD_HWJS_XMOD_PARTITION_LIBC is only meaningful on a -shared THIRD-.so link (the third-.so consumer arm); a main spells the same request WASMLD_LIBC_IMPORT=1 + WASMLD_HWJS_XMOD_SUSPENDERS (PLAN-DYNAMIC-LIBC §3.2)." [[ "$LIBC_SO" != "1" ]] \ || fatal "WASMLD_HWJS_XMOD_PARTITION_LIBC on the libc.so PRODUCER link — the producer consumes no partition (it publishes one); contradictory request (PLAN-DYNAMIC-LIBC §3.2)." [[ -f "$WASMLD_HWJS_XMOD_PARTITION_LIBC" ]] \ || fatal "WASMLD_HWJS_XMOD_PARTITION_LIBC='$WASMLD_HWJS_XMOD_PARTITION_LIBC' does not exist — libc.so's published partition must be built before a consumer .so links (PLAN-DYNAMIC-LIBC §3.2)." hwjs_env+=( HWJS_CORO_XMOD_PARTITION="$WASMLD_HWJS_XMOD_PARTITION_LIBC" ) fi # ── libc.so PRODUCER: the pass PUBLISHES the partition at the ONE merged run (into WORK; installed at # WASMLD_LIBC_SO_PARTITION_OUT only after the post-link name-section cross-verify passes), and the # named cross-module-unwind waiver rides WASMLD_LIBC_SO_UNWIND_WAIVE (loudly NOTEd — a waiver is an audit-trail event). if [[ "$LIBC_SO" == "1" ]]; then hwjs_env+=( HWJS_CORO_PUBLISH_SUSPENDERS="$WORK/partition.pub" ) if [[ -n "${WASMLD_LIBC_SO_UNWIND_WAIVE:-}" ]]; then echo "wasmld: NOTE (WASMLD_LIBC_SO_UNWIND_WAIVE): waiving cross-module unwind-path refusal for [${WASMLD_LIBC_SO_UNWIND_WAIVE}] — each name is published verdict=refused so consumers/loader can refuse the binding instead (PLAN-DYNAMIC-LIBC §3.3)." >&2 hwjs_env+=( HWJS_CORO_XMOD_UNWIND_WAIVE="$WASMLD_LIBC_SO_UNWIND_WAIVE" ) fi fi # ── runtime-module PRODUCER (WASMLD_RT_SO=1): publish the runtime's own # partition at the merged run — same Track M record, none of the libc.so # pool/export machinery (the closure is the ONE hwjs_user_rt TU). if [[ "$RT_SO" == "1" ]]; then hwjs_env+=( HWJS_CORO_PUBLISH_SUSPENDERS="$WORK/partition.pub" ) fi # Codegen model + plugin set per arm: the MAIN image is non-PIC (see # HWJS_PIC_ARGS above); a -shared side module is PIC with default # visibility (LOAD-BEARING: hidden visibility would resolve address-takes # module-locally and drop the GOT.func. imports the host loader binds # for cross-module coroutine identity) and additionally runs the # HwjsDescriptorEmit pass (fork reloc records). hwjs_cg=( "${HWJS_PIC_ARGS[@]}" ) # Plugin order is EXECUTION order at PipelineStartEP (callbacks run in plugin # load order = command-line order): HwjsEHPreLower MUST precede HwjsCoroutinize # so coroutinize sees an invoke-free module (plan §0.4; EHPreLowerPass header; # the eh_prelower.mjs COMPOSITION arm). Empty when the knobs are off — the # plugin command line is unchanged; no-EH preparation has already run above. hwjs_plugins=() [[ -n "$HWJS_PRELOWER" ]] && hwjs_pass_load hwjs_plugins clang eh-prelower "$HWJS_PRELOWER" hwjs_pass_load hwjs_plugins clang coroutinize "$HWJS_PLUGIN" # AFTER coroutinize (PipelineStartEP callbacks run in load order): the # return-attribute truth pass must see the CONVERTED module — the # presplitcoroutine set — before default can fold a freeze(poison) # park exit under frontend noundef+nonnull into unreachable. [[ -n "${HWJS_RETTRUTH:-}" ]] && hwjs_pass_load hwjs_plugins clang retattr-truth "$HWJS_RETTRUTH" # The hwjs-cc#5 GVN/MemDep battery. GVNPass is the dominant cost of a # coroutinized package link -- 1305.5 s of a 38:44 ctags link, 56.8% -- because # the pass explodes the CFG and MemDep's non-local walk is superlinear in it. # Disabling non-local GVN and load-PRE, and capping the MemDep block walk, takes # GVN to 8.5 s and the link to 20:16 for +1.5% object size and no runtime cost # resolvable from zero. Unsanctioned upstream (hwjs-cc#20); drop this hunk if the # structural fix lands. hwjs_tuning=( -mllvm -enable-gvn-memdep=false -mllvm -enable-load-pre=false -mllvm -memdep-block-number-limit=32 ) if [[ $IS_SHARED == 1 ]]; then hwjs_cg=( -fPIC ) hwjs_pass_load hwjs_plugins clang descriptor-emit "$HWJS_DEMIT" fi # The whole program's IR is resident from here to the release below — up to # 11.34 GB measured over one image build's 7922 tasks, and this driver's entire # memory peak. Everything after the release is text processing over the pass # log. merged.bc is final by now, which is what lets the admission control # price THIS link rather than the largest one in the corpus. # ── hwjs.abi MANIFEST SHAPE ───────────────────────────────────────────────── # Echoes " " for a wasm # binary, image or object. # # The FIELD COUNT is what detects a second record, not the section count. lld # MERGES same-named custom sections into one rather than keeping two, so a # concatenation is invisible to a count of sections and shows up only as the # payload growing by another record's worth of LEB fields. Measured on this # ticket: joining an object that carries a second hwjs.abi onto the split # object still reports exactly one section, and a check that counted sections # would have been structurally incapable of firing. hwjs_abi_manifest_shape() { # node -e ' const fs = require("fs"); const b = fs.readFileSync(process.argv[1]); let p = 8, sections = 0, bytes = 0, payload = null; function leb() { let r = 0, s = 0; for (;;) { const x = b[p++]; r |= (x & 127) << s; if (!(x & 128)) return r >>> 0; s += 7; } } while (p < b.length) { const id = b[p++]; const sz = leb(); const end = p + sz; if (id === 0) { const nl = leb(); const name = b.slice(p, p + nl).toString(); p += nl; if (name === "hwjs.abi") { sections++; bytes += end - p; if (!payload) payload = b.slice(p, end); } } p = end; } /* Dynamic-libc arms: LEB fields may be u64 (the suspender-partition digest) — BigInt-safe field walk. */ let version = 0, nfields = 0, driveAbi = 0, flags = 0; if (payload) { let q = 0; try { while (q < payload.length) { let r = 0n, s = 0n; for (;;) { if (q >= payload.length) throw new Error("truncated LEB"); const x = BigInt(payload[q++]); r |= (x & 127n) << s; if (!(x & 128n)) break; s += 7n; } if (nfields === 0) version = Number(r); else if (nfields === 1) driveAbi = Number(r); /* field 2 = drive_abi (version-keyed acceptance, STATUS-CHANNEL-CONTRACT §10) */ else if (nfields === 7) flags = Number(r); /* field 8 = flags (bit4 check_emitting, bit5 lane_addressed) */ nfields++; } } catch (e) { nfields = -1; } } console.log(`${sections} ${version} ${nfields} ${bytes} ${driveAbi} ${flags}`); ' "$1" } # hwjs_abi_record_verdict : "none" | "one" | "many". One record is 8 LEB # fields (v1) or 10 (v2); anything else in a present section is a concatenation. # Measured shapes, on this ticket's artifacts: # monolithic merged.o 1 2 10 19 3 79 -> one # split-and-joined merged.o 1 2 10 19 3 79 -> one # the same, joined with an # object carrying a 2nd record 1 2 31 40 3 79 -> many # The SECTION count is 1 in all three, which is why it is not the discriminant. hwjs_abi_record_verdict() { # local sections nfields rest read -r sections _ nfields rest <<<"$1" case "$sections" in 0) echo none; return 0 ;; 1) [[ "$nfields" == 8 || "$nfields" == 10 ]] && { echo one; return 0; } ;; esac echo many } link_sem_acquire "$WORK/merged.bc" set +e HWJS_PIECE_ERRS=() HWJS_SPLIT_PIECES=0 if [[ $HWJS_SPLIT_N -gt 1 ]]; then # Phase 1: the hwjs passes under opt, then the split. The four passes ahead # of them are clang's OWN -O2 pipeline prefix — without them opt's input is # not the IR the plugins see under clang. With them it is: every summary line # the pass prints is byte-identical between the two arms (coroutinized=, # OUTPUT-VALIDITY, FPCAST-OUTLINE-SUMMARY, ABI-MANIFEST). # SPACE. A monolithic compile holds merged.bc and merged.o in $WORK, about # 1 GB for tshark. A split holds merged.bc, then ALL N pieces at once (phase # 1 writes them before phase 2 compiles any, and each is deleted only once # compiled), then the piece objects, then the joined one — about 2.5 GB for # the same link, near seven times merged.bc. $WORK lives under TMPDIR, which # on some hosts is a small tmpfs, and an ENOSPC in the middle of phase 2 # reads as a compiler failure. State the precondition instead. _hwjs_bc_mb=$(( $(stat -c %s "$WORK/merged.bc") / 1048576 )) _hwjs_free_mb=$(df -Pm "$WORK" | awk 'NR==2 {print $4}') _hwjs_need_mb=$(( _hwjs_bc_mb * 8 )) [[ ${_hwjs_free_mb:-0} -ge $_hwjs_need_mb ]] \ || fatal "split-after-pass needs about ${_hwjs_need_mb} MB free under $WORK (merged.bc is ${_hwjs_bc_mb} MB and the split holds it, all $HWJS_SPLIT_N pieces, their objects and the joined object at once) but ${_hwjs_free_mb:-0} MB are free. \$WORK is under TMPDIR, which is a small tmpfs on some hosts. Point TMPDIR at a filesystem with room, or lower WASMLD_HWJS_SPLIT_N — but do not export TMPDIR into a build that also uses it for other things." hwjs_opt_loads=(); hwjs_opt_passes=() [[ -n "$HWJS_PRELOWER" ]] && { hwjs_pass_load hwjs_opt_loads opt eh-prelower "$HWJS_PRELOWER"; hwjs_opt_passes+=( hwjs-eh-prelower ); } hwjs_pass_load hwjs_opt_loads opt coroutinize "$HWJS_PLUGIN"; hwjs_opt_passes+=( hwjs-coroutinize ) [[ -n "${HWJS_RETTRUTH:-}" ]] && { hwjs_pass_load hwjs_opt_loads opt retattr-truth "$HWJS_RETTRUTH"; hwjs_opt_passes+=( hwjs-retattr-truth ); } hwjs_pass_load hwjs_opt_loads opt split "$HWJS_SPLIT"; hwjs_opt_passes+=( hwjs-split ) env "${hwjs_env[@]}" \ HWJS_SPLIT_N="$HWJS_SPLIT_N" HWJS_SPLIT_PREFIX="$WORK/piece" \ "$HWJS_LLVM_BIN/opt" \ "${hwjs_opt_loads[@]}" \ -passes="memprof-remove-attributes,annotation2metadata,forceattrs,declare-to-assign,$(IFS=,; echo "${hwjs_opt_passes[*]}")" \ -disable-output "$WORK/merged.bc" 2>"$hwjs_err" rc=$? # How many pieces there ACTUALLY are is read back, not assumed. SplitModule # partitions what is there: a module with fewer functions than the requested # count, or one whose partition collapses, yields fewer, and a loop that # trusted the request would then compile a piece that was never written. if [[ $rc -eq 0 ]]; then HWJS_SPLIT_PIECES=$(sed -n 's/.*\[hwjs-split\] SPLIT-SUMMARY pieces=\([0-9][0-9]*\).*/\1/p' "$hwjs_err" | tail -1) [[ "$HWJS_SPLIT_PIECES" =~ ^[1-9][0-9]*$ ]] \ || fatal "split-after-pass: the pass run printed no '[hwjs-split] SPLIT-SUMMARY pieces=' line, so how many pieces it wrote is unknown and the compile below would be over a guessed set. Either the splitter predates the summary line or phase 1 did not reach it; $hwjs_err has the run." fi # Phase 2: one piece at a time, no plugin — the pass has already run, and a # piece must NOT see it again. Serial on purpose: admission control priced # ONE compile and its slot is an inherited fd, not a per-process claim, so # parallel pieces would spend a budget nothing is holding. hwjs_piece_objs=() if [[ $rc -eq 0 ]]; then for ((hwjs_i = 0; hwjs_i < HWJS_SPLIT_PIECES; hwjs_i++)); do env "${hwjs_env[@]}" \ "${HWJS_CLANG[@]}" \ --target="$HWJS_COMPILE_TARGET" \ -O2 \ "${hwjs_cg[@]}" \ -matomics -mbulk-memory -mmutable-globals -msign-ext \ -mnontrapping-fptoint \ -ffunction-sections -fdata-sections \ -fvisibility=default \ "${hwjs_tuning[@]}" \ -c "$WORK/piece$hwjs_i.bc" -o "$WORK/piece$hwjs_i.o" 2>"$WORK/piece$hwjs_i.err" rc=$? HWJS_PIECE_ERRS+=( "$WORK/piece$hwjs_i.err" ) # A failing piece's diagnostics go into the pass log as well, so every # rc!=0 path below reads the same text it would have read from a # monolithic compile — the failure-reason classifier included. [[ $rc -eq 0 ]] || { cat "$WORK/piece$hwjs_i.err" >> "$hwjs_err"; cat "$WORK/piece$hwjs_i.err" >&2; break; } # No plugin is loaded here, so in the plugin mode no pass can run. A # linked tool carries the passes and runs one only when asked by name: # hold it to that, since a pass over a piece would transform it twice. if [[ $HWJS_PASSES_LINKED == 1 ]] && grep -a -q 'PLUGIN-PROVENANCE' "$WORK/piece$hwjs_i.err"; then fatal "split-after-pass: $HWJS_LLVM_BIN/clang ran a linked-in pass over piece $hwjs_i, which was compiled with no -hwjs-link-pass: $(grep -a -m1 'PLUGIN-PROVENANCE' "$WORK/piece$hwjs_i.err"). The passes have already run over the whole module; a tool that runs them unasked cannot be used for this link." fi hwjs_piece_objs+=( "$WORK/piece$hwjs_i.o" ) rm -f "$WORK/piece$hwjs_i.bc" done fi # Join. Every consumer below this point — the nm and objdump reads, the # manifest checks, the final link — takes one object and is unchanged. if [[ $rc -eq 0 ]]; then "$HWJS_LLVM_BIN/wasm-ld" -r "${hwjs_piece_objs[@]}" -o "$WORK/merged.o" 2>>"$hwjs_err" rc=$? fi # The manifest is named metadata, so CloneModule copies it into every piece; # the splitter erases it from all but piece 0 and this is the check that it # did. It counts the manifest's LEB FIELDS, not its sections: lld MERGES # same-named custom sections, so two records arrive as one section whose # payload is twice as long, and a section count can never see it. Under the # monolithic arm the invariant held by construction — one compile, # HWJS_CORO_MERGED_MODULE=1 set once — and nothing on an executable link # checks it at all: the single-record check at the final link is inside the # -shared arm, and the preemptibility parse reads field 8 of the first # record without ever counting them. if [[ $rc -eq 0 ]]; then command -v node >/dev/null 2>&1 \ || fatal "split-after-pass: node unavailable — cannot verify that the joined object carries ONE hwjs.abi record. Refusing rather than silently skipping the check that the split makes necessary." _abi_shape="$(hwjs_abi_manifest_shape "$WORK/merged.o")" \ || fatal "split-after-pass: could not parse the joined object's custom sections for the hwjs.abi manifest check. Refusing." case "$(hwjs_abi_record_verdict "$_abi_shape")" in one) : ;; none) echo "wasmld: NOTE (split): the joined object carries no hwjs.abi manifest — this link's pass emitted none." >&2 ;; *) fatal "split-after-pass: the joined merged.o's hwjs.abi manifest is not ONE record: 'sections version nfields bytes drive_abi flags' = '$_abi_shape', and one record is 8 fields (v1) or 10 (v2). lld concatenates same-named custom sections, so a piece that kept its copy of the manifest metadata arrives here as one section holding several records, and the host loader would audit the image against whichever it read first. $HWJS_SPLIT_PIECES pieces produced this. Refusing." ;; esac fi else env "${hwjs_env[@]}" \ "${HWJS_CLANG[@]}" \ --target="$HWJS_COMPILE_TARGET" \ -O2 \ "${hwjs_cg[@]}" \ -matomics -mbulk-memory -mmutable-globals -msign-ext \ -mnontrapping-fptoint \ -ffunction-sections -fdata-sections \ -fvisibility=default \ "${hwjs_plugins[@]}" \ "${hwjs_tuning[@]}" \ -c "$WORK/merged.bc" -o "$WORK/merged.o" 2>"$hwjs_err" rc=$? fi set -e link_sem_release # ── Pass-output presentation (ticket #12). The pass's per-item INFO # classes — COROUTINIZE fn= (one per transformed function), ALIAS-EDGE # (one per in-TU alias call, repeated per caller), UNPUBLISHED-SUSPENDER # (one long line per alias/external, identical musl-internal set on # every userspace link) — dominated build logs (~10+ identical lines per # link; a full coreutils build re-prints the same musl aliases hundreds # of times). Never-silent means the CONTENT must stay visible, not the # REPETITION: by default each class is condensed to one summary line # carrying the count and the unique names, and every OTHER pass line # (SUMMARY, REFUSE, DRIVE-CONTRACT, PROVENANCE, publication pins, # errors) passes through verbatim. WASMLD_HWJS_VERBOSE_PASS=1 restores # the full dump, and a FAILED pass run (rc != 0) always dumps everything # — failure diagnostics are never condensed. The guard suite below greps # the FILE, not this presentation, so enforcement is unaffected. if [[ $rc -ne 0 || "${WASMLD_HWJS_VERBOSE_PASS:-0}" != "0" ]]; then cat "$hwjs_err" >&2 else awk ' /\[hwjs-coro\] COROUTINIZE fn=/ { n_co++; next } /\[hwjs-eh-prelower\] fn=/ { n_ehp++; next } /\[hwjs-coro\] FLAT-RAMP fn=/ { n_fr++; next } /\[hwjs-coro\] FLAT-DESCEND fn=/ { n_fd++; next } /\[hwjs-coro\] ALIAS-EDGE / { n_ae++ if (match($0, /alias=[^ ]+/)) ae[substr($0, RSTART+6, RLENGTH-6)] = 1 next } /\[hwjs-coro\] UNPUBLISHED-SUSPENDER / { n_ua++ if (match($0, /alias=[^ ]+/)) ua[substr($0, RSTART+6, RLENGTH-6)] = 1 else if (match($0, /fn=[^ ]+/)) ua[substr($0, RSTART+3, RLENGTH-3)] = 1 next } { print } END { if (n_co) printf "[hwjs-coro] (condensed) COROUTINIZE x%d — per-fn lines squelched; WASMLD_HWJS_VERBOSE_PASS=1 for the full list\n", n_co if (n_ehp) printf "[hwjs-eh-prelower] (condensed) per-fn EH lowering x%d — per-fn lines squelched; the SUMMARY line below/above carries the census; WASMLD_HWJS_VERBOSE_PASS=1 for the full list\n", n_ehp if (n_fr + n_fd) printf "[hwjs-coro] (condensed) FLAT-RAMP x%d, FLAT-DESCEND x%d (drive_abi=3 lowering detail; WASMLD_HWJS_VERBOSE_PASS=1 for per-site lines)\n", n_fr, n_fd if (n_ae) { line = "" for (k in ae) line = line (line == "" ? "" : " ") k printf "[hwjs-coro] (condensed) ALIAS-EDGE x%d via alias(es): %s — the F89 in-TU alias-call shape, classified as direct edges; WASMLD_HWJS_VERBOSE_PASS=1 for per-caller lines\n", n_ae, line } if (n_ua) { line = "" for (k in ua) line = line (line == "" ? "" : " ") k printf "[hwjs-coro] (condensed) UNPUBLISHED-SUSPENDER x%d: %s — benign at whole-program vantage (design §1: publication is the cross-TU protocol this build shape does not need); the native-stage intersection witness below still enforces the real §8b hole. WASMLD_HWJS_VERBOSE_PASS=1 for full lines\n", n_ua, line } } ' "$hwjs_err" >&2 fi # ── PLUGIN-PROVENANCE enforcement (submodule-derived model, 2026-07-03; # same gate as cc-wasm.sh): when WASMLD_HWJS_SRC_DIR is set, the pass # run's self-attested commit must equal that checkout's HEAD (resolved # above, clean-tree-enforced) and dirty must be 0. # # D1-BASH (2026-06-13): this verification runs on EVERY pass-run, # INCLUDING the rc!=0 abort path. The plugin emits PLUGIN-PROVENANCE at # pass-end (SUMMARY time); a backend abort (a never-silent REFUSE raised # via report_fatal_error) terminates clang BEFORE that line, so the old # ordering (rc-check short-circuits, provenance checked only after) # let an aborting pass-run escape identity attestation entirely — the # F20 bash build aborted with NO provenance line and we could not tell # whether the aborting plugin was the expected artifact under the # expected config. We now: (1) if a provenance line EXISTS it must # match (any rc); (2) an rc==0 run with NO provenance line under # enforcement is refused (an attestation-less plugin cannot be checked); # (3) if rc!=0 and NO provenance line exists, say so explicitly — the # run's identity is UNATTESTED, which is itself a reportable state, # not a silent "compile failed". # Each plugin's attestation is matched under ITS OWN tag ([hwjs-coro] / # [hwjs-eh-prelower]) — a bare 'PLUGIN-PROVENANCE commit=...' grep would let # one plugin's fresh line satisfy the other plugin's staleness (two plugins # emit the line since the pre-lowering gained the same stamp discipline). prov_line="$(grep -m1 '\[hwjs-coro\] PLUGIN-PROVENANCE' "$hwjs_err" || true)" if [[ -n "$HWJS_EXPECT_COMMIT" ]]; then if [[ -n "$prov_line" ]] && ! grep -q "\[hwjs-coro\] PLUGIN-PROVENANCE commit=$HWJS_EXPECT_COMMIT dirty=0" "$hwjs_err"; then fatal "PLUGIN-PROVENANCE mismatch: expected commit=$HWJS_EXPECT_COMMIT dirty=0 ($HWJS_EXPECT_FROM); got: $prov_line. The plugin is a derived artifact of that checkout (its HEAD is the only pin). THE BYTES THIS LINK LOADED CAME FROM $HWJS_PLUGIN_RESOLVED (the plugin home $HWJS_PLUGIN_HOME), so that is the home to refresh, and this is what refreshes it: $HWJS_PLUGIN_REMEDY. Refusing a stale/mixed artifact at the consumer's vantage." fi if [[ $rc -eq 0 && -z "$prov_line" ]]; then fatal "PLUGIN-PROVENANCE absent: the pass run emitted no attestation but $HWJS_EXPECT_BY is active — an attestation-less (pre-F89 / non-CMake) plugin cannot be provenance-checked. The bytes came from $HWJS_PLUGIN_RESOLVED (the plugin home $HWJS_PLUGIN_HOME); refresh THAT home: $HWJS_PLUGIN_REMEDY. Refusing." fi # HwjsEHPreLower (loaded only under the status-channel/pre-lower knobs) # carries the SAME contract: it emits its attestation at run() START # (mode=on AND mode=off), so a loaded pre-lowering plugin that attests a # different commit — or none — refuses here at the consumer's vantage, # exactly like the coroutinize plugin. (A stale pre-lowering is the same # silent-miscompile class: it decides every emitted status check.) if [[ ${EH_PRELOWER_LOAD:-0} == 1 ]]; then ehp_prov_line="$(grep -m1 '\[hwjs-eh-prelower\] PLUGIN-PROVENANCE' "$hwjs_err" || true)" if [[ -n "$ehp_prov_line" ]] && ! grep -q "\[hwjs-eh-prelower\] PLUGIN-PROVENANCE commit=$HWJS_EXPECT_COMMIT dirty=0" "$hwjs_err"; then fatal "PLUGIN-PROVENANCE mismatch (HwjsEHPreLower): expected commit=$HWJS_EXPECT_COMMIT dirty=0 ($HWJS_EXPECT_FROM); got: $ehp_prov_line. The bytes this link loaded came from $HWJS_PRELOWER_RESOLVED (the plugin home $HWJS_PLUGIN_HOME); refresh THAT home: $HWJS_PLUGIN_REMEDY. Refusing a stale/mixed pre-lowering at the consumer's vantage." fi if [[ $rc -eq 0 && -z "$ehp_prov_line" ]]; then fatal "PLUGIN-PROVENANCE absent (HwjsEHPreLower): the pre-lowering plugin was loaded but emitted no attestation under $HWJS_EXPECT_BY — a pre-attestation or non-CMake build cannot be provenance-checked. The bytes came from $HWJS_PRELOWER_RESOLVED (the plugin home $HWJS_PLUGIN_HOME); refresh THAT home: $HWJS_PLUGIN_REMEDY. Refusing." fi fi # HwjsRetAttrTruth (loaded under the same status-channel condition): # same contract, own tag. A stale return-attribute truth pass is the # same silent-miscompile class — it decides whether every converted # function's park exit survives the O2 pipeline. if [[ -n "${HWJS_RETTRUTH:-}" ]]; then rat_prov_line="$(grep -m1 '\[hwjs-retattr-truth\] PLUGIN-PROVENANCE' "$hwjs_err" || true)" if [[ -n "$rat_prov_line" ]] && ! grep -q "\[hwjs-retattr-truth\] PLUGIN-PROVENANCE commit=$HWJS_EXPECT_COMMIT dirty=0" "$hwjs_err"; then fatal "PLUGIN-PROVENANCE mismatch (HwjsRetAttrTruth): expected commit=$HWJS_EXPECT_COMMIT dirty=0 ($HWJS_EXPECT_FROM); got: $rat_prov_line. The bytes this link loaded came from $HWJS_RETTRUTH_RESOLVED (the plugin home $HWJS_PLUGIN_HOME); refresh THAT home: $HWJS_PLUGIN_REMEDY. Refusing a stale/mixed artifact at the consumer's vantage." fi if [[ $rc -eq 0 && -z "$rat_prov_line" ]]; then fatal "PLUGIN-PROVENANCE absent (HwjsRetAttrTruth): the plugin was loaded but emitted no attestation under $HWJS_EXPECT_BY. The bytes came from $HWJS_RETTRUTH_RESOLVED (the plugin home $HWJS_PLUGIN_HOME); refresh THAT home: $HWJS_PLUGIN_REMEDY. Refusing." fi fi # HwjsSplit (the codegen partitioner, BRINTOS-2764). Same contract, own # tag. It does not transform the program, but a stale splitter is the one # that stops erasing the ABI manifest from the pieces it clones, and the # joined object then carries several — so it attests like the rest. if [[ $HWJS_SPLIT_N -gt 1 ]]; then spl_prov_line="$(grep -m1 '\[hwjs-split\] PLUGIN-PROVENANCE' "$hwjs_err" || true)" if [[ -n "$spl_prov_line" ]] && ! grep -q "\[hwjs-split\] PLUGIN-PROVENANCE commit=$HWJS_EXPECT_COMMIT dirty=0" "$hwjs_err"; then fatal "PLUGIN-PROVENANCE mismatch (HwjsSplit): expected commit=$HWJS_EXPECT_COMMIT dirty=0 ($HWJS_EXPECT_FROM); got: $spl_prov_line. The plugin is a derived artifact of that checkout; rebuild it there (toolchain/build.sh) rather than linking against a stale one." fi if [[ $rc -eq 0 && -z "$spl_prov_line" ]]; then fatal "PLUGIN-PROVENANCE absent (HwjsSplit): WASMLD_HWJS_SPLIT_N=$HWJS_SPLIT_N loaded the splitter but it emitted no attestation under $HWJS_EXPECT_BY — an attestation-less plugin cannot be provenance-checked. Rebuild it from $HWJS_SRC_DIR." fi fi # HwjsDescriptorEmit (the -shared arm's fork reloc records). It gained the # same attestation on 2026-08-04 and is enforced here for the same reason # the other three are: it is loaded into this same one merged-module # compile, and a stale one emits descriptors a fork-time relocator will # read back as the layout of a DIFFERENT build — which is not a build # error anywhere, it is a wrong fork at runtime. if [[ -n "${HWJS_DEMIT_RESOLVED:-}" ]]; then demit_prov_line="$(grep -m1 '\[hwjs-descemit\] PLUGIN-PROVENANCE' "$hwjs_err" || true)" if [[ -n "$demit_prov_line" ]] && ! grep -q "\[hwjs-descemit\] PLUGIN-PROVENANCE commit=$HWJS_EXPECT_COMMIT dirty=0" "$hwjs_err"; then fatal "PLUGIN-PROVENANCE mismatch (HwjsDescriptorEmit): expected commit=$HWJS_EXPECT_COMMIT dirty=0 ($HWJS_EXPECT_FROM); got: $demit_prov_line. The bytes this link loaded came from $HWJS_DEMIT_RESOLVED (the plugin home $HWJS_PLUGIN_HOME); refresh THAT home: $HWJS_PLUGIN_REMEDY. Refusing a stale/mixed descriptor emitter at the consumer's vantage." fi if [[ $rc -eq 0 && -z "$demit_prov_line" ]]; then fatal "PLUGIN-PROVENANCE absent (HwjsDescriptorEmit): the descriptor emitter was loaded but emitted no attestation under $HWJS_EXPECT_BY — a pre-2026-08-04 build of that plugin cannot be provenance-checked. The bytes came from $HWJS_DEMIT_RESOLVED (the plugin home $HWJS_PLUGIN_HOME); refresh THAT home: $HWJS_PLUGIN_REMEDY. Refusing." fi fi fi if [[ $rc -ne 0 ]]; then if rc_reason="$(merged_compile_failure_reason "$rc" "$hwjs_err")"; then fatal "pinned-clang plugin compile of the merged module failed: $rc_reason merged.bc was $(( $(stat -c %s "$WORK/merged.bc" 2>/dev/null || echo 0) / 1048576 )) MB; identity $([[ -n "$prov_line" ]] && echo "ATTESTED [$prov_line]" || echo UNATTESTED)." fi if [[ -n "$prov_line" ]]; then fatal "pinned-clang plugin compile of the merged module failed (rc=$rc); identity ATTESTED [$prov_line]. The REFUSE/error reason is dumped above — a never-silent stop-and-report from the pinned plugin under the pinned config (RED-knob gate above already passed)." else fatal "pinned-clang plugin compile of the merged module failed (rc=$rc) BEFORE emitting PLUGIN-PROVENANCE/SUMMARY — the run's plugin identity is UNATTESTED (the abort preceded the end-of-pass attestation). The error reason is dumped above; the pre-merge RED-knob gate passed and the plugin bytes were snapshotted, but treat any conclusion drawn from this run as provisional until a clean run attests it." fi fi # Under opt the -passes list is the order in both modes; under clang only the # plugin mode's command line is. if [[ $HWJS_PASSES_LINKED == 1 && $HWJS_SPLIT_N -le 1 && -n "$HWJS_PRELOWER" ]]; then _order="$(hwjs_linked_order_check "$hwjs_err" hwjs-eh-prelower hwjs-coro hwjs-retattr-truth)" \ || fatal "the passes linked into $HWJS_LLVM_BIN/clang ran in the wrong order: $_order. The pre-lowering must run before the coroutinizer and the return-attribute pass after it; a linked tool runs them in the order it was built with. Refusing the image." fi # Guard suite — ported from cc-wasm.sh verbatim in spirit (HWJS-1/2): grep -q '\[hwjs-coro\] SUMMARY' "$hwjs_err" \ || fatal "the HwjsCoroutinize pass emitted no SUMMARY — it did NOT run on the merged module. Refusing a silently-uninstrumented image." # Track E: when the pre-lowering is ACTIVE it must attest it ran AND that zero EH # constructs survived (the pass's own POST-VERIFY aborts on residue; this vantage # additionally catches a plugin that silently did not run at all — the exact class # the coroutinize SUMMARY guard above exists for). I3: an image built under the # status channel with un-lowered invokes is a silent-transit miscompile. if [[ $EH_PRELOWER_ACTIVE == 1 ]]; then grep -q '\[hwjs-eh-prelower\] SUMMARY mode=on .*residual: invokes=0 landingpads=0 resumes=0 personalities=0 typeids=0' "$hwjs_err" \ || fatal "the EH pre-lowering is ACTIVE (HWJS_STATUS_CHANNEL=${HWJS_STATUS_CHANNEL:-} HWJS_EH_PRELOWER=${HWJS_EH_PRELOWER:-}) but the HwjsEHPreLower pass emitted no 'SUMMARY mode=on ... residual: invokes=0 ...' — it did NOT run on the merged module (stale/wrong plugin, or a load-order regression). An un-pre-lowered module entering the status-channel lowering is the I3 silent-transit hazard (PLAN-CXX-STATUS-CHANNEL §7/§9 R1). Refusing." fi # Return-attribute truth: when loaded it must attest it RAN (fns=0 is a # legitimate verdict — a module whose converted functions carried no lying # return attrs — but silence is a plugin that did not run: the park-exit # unreachable class would be silently re-armed). if [[ -n "${HWJS_RETTRUTH:-}" ]]; then grep -q '\[hwjs-retattr-truth\] SUMMARY fns=' "$hwjs_err" \ || fatal "the HwjsRetAttrTruth plugin was loaded but emitted no SUMMARY — it did NOT run on the merged module (stale/wrong plugin, or a load-order regression). A status-channel image without the return-attribute truth pass can carry unreachable park exits in converted functions with frontend return attrs (the measured operator-new churn trap). Refusing." fi grep -q 'SUSPEND-PUBLICATION .*protocol_forced=1' "$hwjs_err" \ || fatal "suspend protocol NOT armed (no 'SUSPEND-PUBLICATION ... protocol_forced=1') — the never-silent detector is dormant. Check HWJS_SUSPEND_PROTOCOL and the plugin build." grep -q 'PARKED-FLAG .*parked_flag_forced=1' "$hwjs_err" \ || fatal "parked-flag side channel NOT forced (no 'PARKED-FLAG ... parked_flag_forced=1') — the image is blind to the orphaned-handle witness, or the plugin predates gate 71. Refusing." grep -q 'PARKED-FLAG .*parked_abi=2' "$hwjs_err" \ || fatal "parked-flag ABI is not v2 (no 'parked_abi=2') — hwjs_user_rt.c implements the v2 identity ops; a v1 emission links the same symbols at different arity and silently defeats take-side validation. Refusing." # DRIVE-CONTRACT (HWJS-3 close: FLAT is the default). Accept the contract that # MATCHES the requested drive mode ($HWJS_DRIVE_FLAT) and refuse a mismatch as # never-silent — both a leaked flat AND a silent nested downgrade are wrong # (the crt wasmcc injected is mode-matched; a contract that disagrees with it # is a broken image, not a tolerable variant). Still refuse the genuinely- # unsafe v1 / run-to-completion loop (it absorbs yields silently). # - flat default (HWJS_DRIVE_FLAT=1): require drive_abi=3; drive_abi=2 here # is a silent downgrade (the flat crt's _start drives drive_abi=3 verdicts # the nested codegen never emits) -> refuse. # - nested opt-out (HWJS_DRIVE_FLAT=0): require drive_abi=2; drive_abi=3 is a # leaked flat emission under the nested crt -> refuse. # - status channel (HWJS_STATUS_CHANNEL=1, STATUS-CHANNEL-CONTRACT §10): # require drive_abi=4; a 2/3 contract under the knob means the plugin did # not honor it (stale plugin / env skew) -> refuse. Conversely a # drive_abi=4 contract WITHOUT the knob is a leaked status-channel # emission under a legacy crt/expectation -> refuse (the crt/terminus # wasmcc injected is knob-matched; a disagreeing contract is a broken # image). RED arm: HWJS_STATUS_NO_ABI4_ACCEPT (roster-listed) restores # the pre-acceptance fail-closed refusal, proving the abi-4 acceptance is # load-bearing rather than a silent widening. # - anything else (drive_abi=1 / no abi token) -> refuse. _abi4_accept=1 [[ -n "${HWJS_STATUS_NO_ABI4_ACCEPT:-}" ]] && _abi4_accept=0 if grep -q 'DRIVE-CONTRACT' "$hwjs_err"; then if [[ "${HWJS_STATUS_CHANNEL:-0}" == "1" && "$_abi4_accept" == "1" ]]; then grep -q 'DRIVE-CONTRACT .*drive_abi=4' "$hwjs_err" \ || { grep 'DRIVE-CONTRACT' "$hwjs_err" >&2 || true; \ fatal "HWJS_STATUS_CHANNEL=1 but the drive contract is not drive_abi=4 (status channel) — the plugin did not honor the status-channel knob (stale plugin, or HWJS_CORO_DRIVE_NESTED leaked into the merged compile env). Refusing (a legacy-abi image under the status-channel crt/terminus is a broken image)."; } elif grep -q 'DRIVE-CONTRACT .*drive_abi=4' "$hwjs_err"; then grep 'DRIVE-CONTRACT' "$hwjs_err" >&2 || true if [[ "$_abi4_accept" == "0" ]]; then fatal "drive contract is drive_abi=4 (status channel) but abi-4 acceptance is RED-disabled (HWJS_STATUS_NO_ABI4_ACCEPT) — the pre-acceptance fail-closed posture. Refusing." else fatal "drive contract is drive_abi=4 (status channel) but HWJS_STATUS_CHANNEL is not 1 at this driver — a status-channel emission leaked into a legacy link (driver/plugin env skew: the legacy crt's _start cannot terminate the channel; the §4 termini would never run). Refusing." fi elif grep -q 'DRIVE-CONTRACT .*drive_abi=2' "$hwjs_err"; then [[ "$HWJS_DRIVE_FLAT" == "0" ]] \ || fatal "drive contract is drive_abi=2 (nested) but the FLAT drive is the active default (HWJS_DRIVE_FLAT=1) — a silent downgrade: the flat crt's _start drives drive_abi=3 verdicts the nested codegen never emits. The plugin did not honor flat (stale plugin? leaked HWJS_CORO_DRIVE_NESTED?). Set HWJS_CORO_DRIVE_NESTED=1 to build nested deliberately, or fix the plugin. Refusing." elif grep -q 'DRIVE-CONTRACT .*drive_abi=3' "$hwjs_err"; then [[ "$HWJS_DRIVE_FLAT" != "0" ]] \ || fatal "drive contract is drive_abi=3 (flat trampoline) but nested was requested (HWJS_CORO_DRIVE_NESTED=1 / HWJS_CORO_DRIVE_FLAT=0) — an unexpected/leaked flat emission under the nested crt. Rebuild without the nested knob (flat is the default), or fix the plugin. Refusing." else grep 'DRIVE-CONTRACT' "$hwjs_err" >&2 || true fatal "drive contract is neither drive_abi=2 (nested park-per-iteration) nor drive_abi=3 (flat trampoline) nor drive_abi=4 (status channel) — a run-to-completion / v1 drive loop absorbs yields silently. HWJS_CORO_DRIVE_TO_COMPLETION leaked, or the plugin emits v1. Refusing." fi elif ! grep -q 'SUMMARY coroutinized=0 ' "$hwjs_err"; then fatal "the merged module coroutinized functions but printed no DRIVE-CONTRACT — pre-gates-73-76 plugin (v1 run-to-completion drive loops). Rebuild the plugin. Refusing." fi # ── CHANNEL-ADDRESSING LOCKSTEP (PLAN-THREADS-SINGLE-MEMORY §4 step 2; the # in-toolchain I3 armor). HwjsCoroutinize and HwjsEHPreLower emit the SAME # channel loads and each attests its addressing mode per module run # (`CHANNEL-ADDRESSING pass=

mode=`). A skew — # one pass reading the absolute (lane-0) block while the other reads the # per-instance address global — is silent transit INSIDE one module (a C++ # handler dispatch consulting a DIFFERENT lane's word than the raise # wrote); the modes must agree, and must agree with THIS driver's knob. # RED arm: HWJS_EH_NO_LANE_ADDR forces the pre-lowering absolute under the # lanes knob — this check is what makes that breach a refusal, not a ship. if grep -q 'CHANNEL-ADDRESSING' "$hwjs_err"; then _ca_modes="$(grep -o 'CHANNEL-ADDRESSING pass=[a-z-]* mode=[a-z-]*' "$hwjs_err" | awk '{print $3}' | sort -u)" [[ "$(wc -l <<<"$_ca_modes")" == "1" ]] \ || { grep 'CHANNEL-ADDRESSING' "$hwjs_err" >&2 || true; \ fatal "CHANNEL-ADDRESSING SKEW — the two passes attest DIFFERENT channel addressing modes on ONE merged module (lines above): checks emitted by one pass would read a DIFFERENT lane's status word than the other's under threads (the I3-2 silent-transit hazard INSIDE one module; PLAN-THREADS-SINGLE-MEMORY §4 step 2). Rebuild without the skew-inducing knob (HWJS_EH_NO_LANE_ADDR is test-only). Refusing."; } if [[ "${HWJS_LANE_ADDR:-0}" == "1" ]]; then [[ "$_ca_modes" == "mode=lane-global" ]] \ || fatal "HWJS_RT_LANES=${HWJS_RT_LANES:-} requests lane-aware channel addressing but the pass attests '$_ca_modes' — a stale plugin (predating step 2) would ship absolute lane-0 checks inside a lane-stamped image. Rebuild the plugin. Refusing." else [[ "$_ca_modes" == "mode=absolute" ]] \ || fatal "the pass attests lane-global channel addressing but this driver's HWJS_RT_LANES knob is unset/1 — a lane-addressed emission leaked into a single-lane link (env skew into the merged compile). Refusing." fi elif [[ "${HWJS_LANE_ADDR:-0}" == "1" ]] && grep -q 'DRIVE-CONTRACT .*drive_abi=4' "$hwjs_err"; then fatal "HWJS_RT_LANES=${HWJS_RT_LANES:-} requests lane-aware channel addressing but the abi-4 pass run printed no CHANNEL-ADDRESSING attestation — pre-step-2 plugin (absolute lane-0 checks). Rebuild the plugin. Refusing." fi # BUDGET-KEYING pin (F86, manager directive 2026-06-11; userland twin of # the cc-wasm.sh pin): printed only when a safepoint was placed (budget # surface exists). When present it must be the landed keying — # instance-global storage in a per-instance wasm GLOBAL; the JS pump # binds the initial budget via __hwjs_budget_set BEFORE ctors/_start. # When HWJS_CORO_SAFEPOINTS is set the line is mandatory (a safepoints # build from a pre-F86 plugin carries the retired shared-data budget). if grep -q 'BUDGET-KEYING' "$hwjs_err"; then grep -q 'BUDGET-KEYING keying=instance-global budget_storage=wasm-global' "$hwjs_err" \ || fatal "budget keying is not instance-global/wasm-global (F86) — a linear-memory or lane-array budget is per-execution-context state in cross-instance-visible storage; the retired pre-F86 shape. Refusing." elif [[ "${HWJS_CORO_SAFEPOINTS:-1}" != "0" ]]; then # Checkpoints are armed on every link that does not turn them off, so the line is mandatory on # every such link. (It was keyed on the knob being SET, back when being set was the exception.) fatal "checkpoints are armed for this link but the pass printed no BUDGET-KEYING line — pre-F86 plugin (shared-data budget). Rebuild the plugin (toolchain/build.sh). Refusing." fi # ── EFFECT-FACT-GUARD attestation (2026-08-02). Replaces the withdrawn # whole-program-safepoints-under-the-status-channel refusal that used to live # in the pass (CoroutinizePass.cpp, see the WITHDRAWN block there for the full # history). That refusal's predicate was "a defect exists in another # repository", which nothing can compute, so it outlived its cause by weeks # while breaking three rigs, and it was disarmed by merely SETTING # HWJS_CORO_SAFEPOINT_ONLY to anything at all. # # The defect was an effect-fact lie — `returned` surviving onto a coroutine # ramp — laundered into the frame by the §8 status-channel spill sink, which # is the only consumer of the poisoned register. It is fixed, and the CLASS is # caught by llvm's coro::verifyNoEffectFactsOnSplitRamps: module-level, from # CoroCleanup (the only vantage that sees what ships), Refuse for the # classified liars, Report for anything a future LLVM adds. # # So the predicate that IS computable, and the one enforced here: the merged # module wasmld just compiled must have been ATTESTED by that guard. The line # is emitted unconditionally by an armed guard, clean modules included, so its # ABSENCE means exactly one of two things and both are refusals — the LLVM has # no split-ramp effect-fact guard (stale toolchain), or the guard was disarmed # with HWJS_STATUS_CLASS_GUARD=0. §8 is live on every drive_abi=4 link, with # or without safepoints, so this is keyed on the STATUS CHANNEL and not on the # safepoint knob that merely amplified the original failure ~1000x. # # RED (gate non-vacuity, and the only way to rebuild the T2 reproducer): # HWJS_STATUS_CLASS_GUARD=0 removes the attestation and this gate must refuse. # HWJS_CORO_SP_STATUS_UNGUARDED=1 is the documented bypass, and is now what it # always claimed to be — the knob that builds the deliberately-corrupting # binary. Never in a shipping build. # ── EFFECT-FACT ATTESTATION GATE (BRINTOS-2764) ─────────────────────────────── # The split-ramp effect-fact attestation is printed by CODEGEN, not by the pass # plugin, so WHERE it is depends on how the module was compiled. Monolithic: one # line in the pass log. Split: one line per piece, in that piece's own log, and # none at all in the pass log. # # That difference is why this is a function with a count rather than a grep. Over # N concatenated piece logs, `grep -q '... refusals=0'` is satisfied by ONE clean # piece, so the gate quietly becomes an existential — it would pass a link in # which seven pieces of eight went unaudited, and nothing would error to say so. # Every piece must attest, and every attestation must be clean. # # RED (gate non-vacuity): call it with a log set one short, or with one log whose # line says refusals=1, and it must refuse. test/split-after-pass-guards.sh. hwjs_effect_fact_gate() { # ... local want="$1"; shift local att clean # /dev/null never matches and keeps grep off stdin if the set is empty. att=$(grep -l '\[hwjs-coro\] EFFECT-FACT-GUARD ' /dev/null "$@" 2>/dev/null | wc -l) clean=$(grep -l '\[hwjs-coro\] EFFECT-FACT-GUARD .* refusals=0 ' /dev/null "$@" 2>/dev/null | wc -l) if [[ $att -eq $want && $clean -eq $want ]]; then return 0 fi if [[ $att -eq $want ]]; then grep -h '\[hwjs-coro\] EFFECT-FACT-GUARD ' /dev/null "$@" >&2 || true fatal "the split-ramp effect-fact class guard attested a NON-ZERO refusal count on $((want - clean)) of $want pieces (lines above) — an attribute that promises something by the time the callee returns survived onto a coroutine ramp, and under the status channel the §8 spill sink launders exactly that into the frame slot the resume clone trusts. This is the T2 miscompile class. Refusing." fi if [[ -n "${HWJS_CORO_SP_STATUS_UNGUARDED:-}" ]]; then echo "wasmld: WARNING (EFFECT-FACT-GUARD): building a drive_abi=4 image with NO split-ramp effect-fact attestation on $((want - att)) of $want pieces, because HWJS_CORO_SP_STATUS_UNGUARDED=1 was set. This is the T2 RED-arm bypass and the image may be deliberately miscompiled. Never ship it." >&2 return 0 fi fatal "drive_abi=4 (status channel) and only $att of $want split pieces printed a '[hwjs-coro] EFFECT-FACT-GUARD' attestation. A piece that codegens without attesting is unaudited, and one unaudited piece is enough for the T2 miscompile. Same causes as the monolithic arm — HWJS_STATUS_CLASS_GUARD=0, or a libLLVM predating coro::verifyNoEffectFactsOnSplitRamps — plus one this arm adds: a piece whose compile printed nothing because it failed before codegen. Refusing rather than shipping an unaudited status-channel image." } # hwjs_effect_fact_gate_end if grep -q 'DRIVE-CONTRACT .*drive_abi=4' "$hwjs_err"; then # Split: the attestation is one line per piece, in that piece's own log, and # must be read as a universal — see hwjs_effect_fact_gate. if [[ $HWJS_SPLIT_N -gt 1 ]]; then hwjs_effect_fact_gate "$HWJS_SPLIT_PIECES" ${HWJS_PIECE_ERRS[@]+"${HWJS_PIECE_ERRS[@]}"} elif grep -q '\[hwjs-coro\] EFFECT-FACT-GUARD ' "$hwjs_err"; then grep -q '\[hwjs-coro\] EFFECT-FACT-GUARD .* refusals=0 ' "$hwjs_err" \ || { grep '\[hwjs-coro\] EFFECT-FACT-GUARD ' "$hwjs_err" >&2 || true; \ fatal "the split-ramp effect-fact class guard attested a NON-ZERO refusal count on the merged module (line above) — an attribute that promises something by the time the callee returns survived onto a coroutine ramp, and under the status channel the §8 spill sink launders exactly that into the frame slot the resume clone trusts. This is the T2 miscompile class. Refusing."; } elif [[ -n "${HWJS_CORO_SP_STATUS_UNGUARDED:-}" ]]; then echo "wasmld: WARNING (EFFECT-FACT-GUARD): building a drive_abi=4 image with NO split-ramp effect-fact attestation, because HWJS_CORO_SP_STATUS_UNGUARDED=1 was set. This is the T2 RED-arm bypass and the image may be deliberately miscompiled. Never ship it." >&2 else fatal "drive_abi=4 (status channel) but the LLVM that compiled the merged module printed no '[hwjs-coro] EFFECT-FACT-GUARD' attestation. §8 status-channel spill sinking is live on this image, and §8 is the first and only consumer of a register poisoned by an effect-fact lie on a coroutine ramp (the T2 miscompile: \`returned\` on a ramp, wild-writing sqlite-owned heap). The class guard that proves the absence of such lies is coro::verifyNoEffectFactsOnSplitRamps in the pinned llvm-project fork; it attests every module it audits. No line means either (a) HWJS_STATUS_CLASS_GUARD=0 disarmed it — do not do that on a shipping link — or (b) this toolchain's libLLVM predates the guard: rebuild toolchains/llvm-project at the pinned commit. Refusing rather than shipping an unaudited status-channel image. Bypass for the deliberately-corrupting T2 reproducer only: HWJS_CORO_SP_STATUS_UNGUARDED=1." fi fi # REFUSE enforcement (HWJS-2; sole benign class alwaysinline-suspender- # residual real_uses=0; the weak-empty-default census class is RETIRED at # membership zero — any refusal here is a FRESH stop-and-report). # Match the REAL refusal marker `REFUSE fn=` (CoroutinizePass emits # `[hwjs-coro] REFUSE fn=... reason=...`), NOT the bare substring `REFUSE`: # benign DS-*-LOWER notices name their RED knob (e.g. `RED: HWJS_FRAMEADDR_REFUSE`), # where `REFUSE` is part of the knob name and must not trip this never-silent gate. # F163 (brintos/hwjs-cc#6): NOT a pipe into `grep -q` — the old # `grep 'REFUSE fn=' | grep -qv ` form let `grep -q`'s early close # SIGPIPE the left grep once the refusal text spanned more than one stdio # buffer, and `set -o pipefail` then evaluated the WHOLE condition false — # the never-silent gate silently SKIPPED on exactly the links with many # refusals (GNU make shipped 16 refusals as a "successful" link and died # at run time in set-over-set). Capture, filter, then test — no early-close # reader anywhere. Gate: test/refuse-gate-sigpipe.sh (RED = the old pipe # shape demonstrably skipping on the same input). refuse_lines="$(grep 'REFUSE fn=' "$hwjs_err" || true)" refuse_nonbenign="$(grep -v 'reason=alwaysinline-suspender-residual.*real_uses=0' <<<"$refuse_lines" || true)" # libc.so PRODUCER EXEMPTION (WASMLD_LIBC_SO=1 only; PLAN-DYNAMIC-LIBC §3.3): a refused fn whose # name a HUMAN put in WASMLD_LIBC_SO_UNWIND_WAIVE is legal in libc.so — the pass audited it on # the cross-module unwind path (XMOD-UNWIND-WAIVER line above is the proof; the pass only waives names # from that env), PUBLISHED verdict=refused in the partition + refused_exports in the hwjs.abi # manifest, and every consumer/loader refuses the binding instead. The whole-program rationale # of this gate ("every caller is in-module") does not hold on a -shared producer; without this # exemption the plan's named-waiver escape is dead code (the G-D3W arm). Never-silent: each # exempted name is NOTEd here, the waiver itself was NOTEd at arm setup, and any refused fn NOT # named in the waiver still hits the fatal below. Pre-dynamic-libc arms (LIBC_SO!=1) are byte-identical. if [[ "$LIBC_SO" == "1" && -n "$refuse_nonbenign" ]]; then while IFS= read -r _wvline; do _wvname="${_wvline#*XMOD-UNWIND-WAIVER fn=}"; _wvname="${_wvname%% *}" [[ -n "$_wvname" ]] || continue _wvkept="$(grep -v "REFUSE fn=${_wvname} " <<<"$refuse_nonbenign" || true)" if [[ "$_wvkept" != "$refuse_nonbenign" ]]; then echo "wasmld: NOTE (WASMLD_LIBC_SO): refused fn '${_wvname}' is human-waived (WASMLD_LIBC_SO_UNWIND_WAIVE) — published verdict=refused (partition + hwjs.abi refused_exports); consumers and the loader refuse the binding (PLAN-DYNAMIC-LIBC §3.3). Exempting it from the whole-program refused-def fatal (a -shared producer is not whole-program vantage)." >&2 refuse_nonbenign="$_wvkept" fi done < <(grep -a 'XMOD-UNWIND-WAIVER fn=' "$hwjs_err" || true) fi if [[ -n "$refuse_nonbenign" ]]; then printf '%s\n' "$refuse_nonbenign" >&2 fatal "the coroutinize pass REFUSED function(s) in the merged module (non-benign class, lines above). At whole-program vantage every caller is in-module, so a refused def is a live ABI chimera. Stop-and-report." fi # OBLIGATION-PUBLISH-ALL, userland spelling. The kernel's counter # (unpublished_cross_tu external-keyed == 0) is a PER-TU obligation: an # unpublished external suspender means some OTHER TU's callers will not # seed — the §8b hole. At whole-program vantage there IS no other TU: # the pass still prints UNPUBLISHED-SUSPENDER for every coroutinized fn # that keeps external linkage (visibility is linkage-keyed and the merge # preserves it), and the printed consequence ("per-TU callers will not # seed") is unreachable by construction — publication is the cross-TU # protocol this build shape exists to not need (design §1). The hole # that DOES survive the merge is a NATIVE-STAGE caller of a coroutinized # external (its call would consume the ramp's return slot as a value: # the exact §8b shape). So the userland witness is an INTERSECTION # check, mechanical and loud: no native-stage input may reference any # coroutinized symbol. (wasm_thread.S calls only _start [export_name- # exempt, never coroutinized]; the thunks/builtins are leaves — green by # construction, but checked every link, not audited once.) # F87 alias arm, merged-module spelling. The kernel pin # (unpublished_alias=0, coverage-keyed) lives in cc-wasm.sh where the # external pin lives; HERE the whole-program design logic of the # external class applies UNCHANGED to aliases: in-module callers # THROUGH an alias are seeded (the pass strips GlobalAlias at call # sites — musl's __libc_start_init/__libc_exit_fini shapes fired this # arm's first hard-zero draft and ARE seeded in-module), so the # surviving hole is a NATIVE-STAGE caller of the ALIAS NAME, which # the intersection witness below would MISS if keyed on function # names alone (coroutinized.txt carried the aliasee's name; the # native undefined symbol is the alias's). Enforcement: fold every # suspender-alias NAME into the intersection witness — a native input # referencing one is the same §8b fatal as referencing the aliasee. # The census stays loud (NOTE line) so the alias work list is visible # on every link, never silent. if grep -q 'unpublished_alias=[1-9]' "$hwjs_err"; then # One line, all pairs (ticket #12): the work list stays visible on every # link (never silent), without the per-alias line-per-link repetition. alias_pairs="$(grep -o 'UNPUBLISHED-SUSPENDER alias=[^ ]* aliasee=[^ ]*' "$hwjs_err" \ | sed 's/UNPUBLISHED-SUSPENDER alias=//; s/ aliasee=/->/' | sort -u | tr '\n' ' ' || true)" echo "wasmld: NOTE (F87 alias census): suspender-alias(es): ${alias_pairs}" >&2 fi # NOTE the `|| true` on each grep: under set -e/pipefail a merge with # ZERO alias lines (or, pathologically, zero coroutinized functions) # made the group exit nonzero and killed the wrapper SILENTLY after a # successful compile — first hit by the exec-child image (no # fork/execve caller -> no alias census lines), 2026-06-11. An empty # witness list is a legal state, not an error. { grep -o 'COROUTINIZE fn=[^ ]*' "$hwjs_err" | sed 's/^COROUTINIZE fn=//' || true grep -o 'UNPUBLISHED-SUSPENDER alias=[^ ]*' "$hwjs_err" | sed 's/^UNPUBLISHED-SUSPENDER alias=//' || true } | sort -u > "$WORK/coroutinized.txt" if [[ -s "$WORK/coroutinized.txt" && ${#NATIVE_INPUTS[@]} -gt 0 ]]; then for nat in "${NATIVE_INPUTS[@]}"; do # F163: same SIGPIPE-free discipline as the REFUSE gate above — the old `comm | grep -q .` # let the reader's early close pipefail the whole condition and skip the §8b check. _nat_hits="$("$HWJS_NM" --undefined-only "$nat" 2>/dev/null | awk '{print $NF}' | sort -u | \ comm -12 - "$WORK/coroutinized.txt" || true)" if [[ -n "$_nat_hits" ]]; then sed "s|^|wasmld: $nat -> |" <<<"$_nat_hits" >&2 fatal "native-stage input '$nat' calls coroutinized function(s) (lines above) — the §8b hole live: a native caller consumes the ramp's frame handle as the return value. The caller must move into the bitcode closure (or be bracketed). Stop-and-report." fi done fi grep -q 'SUMMARY .*mode=classify-only' "$hwjs_err" \ && fatal "the pass ran CLASSIFY-ONLY on the merged module — the image is untransformed and would link silently. A stale HWJS_CORO_CLASSIFY_ONLY reached the pass despite the unset." # ── -shared-only pass-run guards (HWJS-5 M1): if [[ $IS_SHARED == 1 ]]; then # TLS CENSUS. Every module lld links defines its OWN __tls_base — a -shared # module never imports it — and TLS access lowers to local-exec against that # private global (global.get __tls_base + a link-time offset into this # module's own .tdata/.tbss). lld's start function points the global at the # module's data base on the Instance that first initialises the memory, and # nothing else ever writes it: every later Instance over the same memory (a # fork child, a new thread, a migrated task) reads 0 there. The loader # binds those Instances; this link's job is to attest what it built. The # pass attests the merged module's TLS facts on the TLS-CENSUS line # (thread_local globals + llvm.threadlocal.address, the complete IR-level # TLS surface); the line's PRESENCE is itself required, so a plugin that # cannot attest the census refuses. # # Three arms own TLS differently: # libc.so (WASMLD_LIBC_SO=1) — the process thread-pointer owner: glibc's # own TLS, sized by the exported __wasm32_get_tls_size/align, bound to # the pthread block by the host (spike G2, the §9.4 bump). # the runtime module (WASMLD_RT_SO=1) — may not own TLS: the host binds no # block for module #0. # an ordinary side module — owns a PRIVATE block per thread, unrelated to # the thread pointer: its base is its own global, so it cannot alias # libc.so's TLS or the main's. It exports the four symbols the loader # binds it through (below) and may not export a thread_local itself. grep -q 'TLS-CENSUS tls_globals=' "$hwjs_err" \ || fatal "-shared: the pass run printed no TLS-CENSUS line — this plugin predates the hwjs.abi manifest work and cannot attest the side module's TLS (the loader binds a .so's per-thread TLS block from that census and the manifest bit). Rebuild the plugin." if ! grep -q 'TLS-CENSUS tls_globals=0 ' "$hwjs_err"; then if [[ "$LIBC_SO" == "1" ]]; then # G2 TLS-CENSUS LIFT. Cross-check the manifest ACTUALLY stamped # uses_tls=1: a census line without the manifest bit would ship a # libc.so whose hwjs.abi lies to the loader's §9.3 uses_tls pin — the # never-silent skew class. (The pass sets flags bit1 from the same # llvm.threadlocal.address scan that feeds the census — a divergence # means a mismatched/pre-G2 plugin.) grep -q 'ABI-MANIFEST section=hwjs.abi .*uses_tls=1' "$hwjs_err" \ || { grep -E 'TLS-CENSUS|ABI-MANIFEST' "$hwjs_err" >&2 || true; \ fatal "WASMLD_LIBC_SO: the merged closure's TLS-CENSUS names thread_local globals but the hwjs.abi manifest did NOT stamp uses_tls=1 (lines above) — census/manifest SKEW would ship a libc.so whose manifest lies to the loader's §9.3 uses_tls check. Rebuild the plugin (pre-G2 vintage). Refusing the artifact."; } grep 'TLS-CENSUS' "$hwjs_err" | sed 's/^/wasmld: /' >&2 || true echo "wasmld: NOTE (G2 TLS-CENSUS LIFT): libc.so is the sanctioned module-#1 TLS owner — ACCEPTING IR TLS (census above; manifest uses_tls=1). The loader MUST size libc.so's bootstrap TLS block via the exported __wasm32_get_tls_size/__wasm32_get_tls_align and bind/dual-write __tls_base (hwjs-rt-abi §12/§9.3)." >&2 elif [[ "$RT_SO" == "1" ]]; then grep 'TLS-CENSUS' "$hwjs_err" >&2 || true fatal "WASMLD_RT_SO: the runtime module's merged closure USES TLS (census line above names the thread_local globals) — the host binds no TLS block for module #0, so its thread_local state would be unbound on every Instance after the first. Remove the thread_local state from the runtime module." else grep -q 'ABI-MANIFEST section=hwjs.abi .*uses_tls=1' "$hwjs_err" \ || { grep -E 'TLS-CENSUS|ABI-MANIFEST' "$hwjs_err" >&2 || true; \ fatal "-shared: the merged side module's TLS-CENSUS names thread_local globals but the hwjs.abi manifest did NOT stamp uses_tls=1 (lines above) — the loader decides whether to bind this module's TLS from that bit, so a skewed manifest would leave its __tls_base unbound. Rebuild the plugin. Refusing the artifact."; } grep 'TLS-CENSUS' "$hwjs_err" | sed 's/^/wasmld: /' >&2 || true echo "wasmld: NOTE (side-module TLS): the merged side module owns thread_local state (census above; manifest uses_tls=1) — exporting __tls_base/__tls_size/__tls_align/__wasm_init_tls for the loader's per-thread bind; a thread_local may not itself be exported (checked on the final bytes)." >&2 SIDE_TLS=1 fi fi # SINGLE-MANIFEST emission: the merged run must have emitted the # hwjs.abi record (the manifest is how the host loader audits ABI skew). grep -q 'ABI-MANIFEST section=hwjs.abi' "$hwjs_err" \ || fatal "-shared: the pass run emitted no ABI-MANIFEST — the .so would carry no hwjs.abi custom section for the host loader's ABI-skew check. Rebuild the plugin (pre-M1?)." fi # ── (4) Final native link (no Binaryen, ever, on this arm). # # INITIAL-MEMORY AUTO-SIZE (ticket #9). The declared --initial-memory is # the image's env.user_memory import MINIMUM (the A-5 note above), so the # default is sized for the COMMON static layout (130 pages) — but a # big-.bss binary (bzip2recover: 9.3 MB of static arrays) legitimately # needs more, and wasm-ld is the single authority on the real layout # ("initial memory too small, N bytes needed"). Rather than duplicating # its layout math, consume that authoritative number: when the caller did # NOT pin WASMLD_INITIAL_PAGES, re-link ONCE at exactly the needed size # (+16 pages so the heap does not grow on the first malloc), with a loud # NOTE naming both numbers. An EXPLICIT WASMLD_INITIAL_PAGES stays a hard # contract: too small remains the loud wasm-ld error, never a silent # override (that pin is how per-process memory floors are enforced). # ── FLAG EXCLUSIVITY (PLAN-DYNAMIC-LIBC §3.4 (ii), S5 §0): with BOTH --allow-undefined and # --unresolved-symbols=import-dynamic on one line, --allow-undefined silently WINS regardless of # order (lld Driver.cpp:865-870) — every undefined datum then binds to the NULL page (RED-1). The # undefined-symbol policy is ARM-OWNED: refuse caller-supplied spellings that could combine. # SPELLING/CHANNEL COMPLETENESS (2026-07-16 review): the pinned lld's Options.td declares these # options with the F/J/Eq classes, which accept BOTH '--' and '-' prefixes (plus the Separate # '-allow-undefined-file FILE' form), and wasm-ld expands @response-files AFTER this wrapper has # run — so a single-dash spelling or an '@rsp' carrying the flag slipped the old '--'-only case # patterns and silently suppressed every GOT.mem import (the exact RED-1 bind this guard exists # to refuse; CMake/ninja emit @rsp link lines routinely). Guard the FULL post-@-expansion argv # with prefix-normalized matches. Expansion here is read-only (the un-expanded args still go to # wasm-ld); naive whitespace tokenization can only over-match into a LOUD refusal, never under. _dyn_scan_flags=() _dyn_expand_into_scan() { # $1 = arg, $2 = depth local a="$1" d="$2" rf t if [[ "$a" == @?* ]]; then rf="${a#@}" if [[ -f "$rf" ]]; then (( d >= 8 )) && fatal "response-file expansion deeper than 8 levels at '@$rf' — refusing to scan an unboundedly nested @rsp chain (flag-exclusivity guard)." # tokenize on whitespace; strip simple quoting (lld rsp quoting is richer, but a stripped # token can only make the guard match MORE — fail-loud, never fail-open). while IFS= read -r -d '' t; do [[ -n "$t" ]] && _dyn_expand_into_scan "$t" $((d + 1)) done < <(tr -s ' \t\r\n' '\0' < "$rf" | tr -d '"'"'") return 0 fi # a non-existent @file reaches wasm-ld verbatim and fails loudly there. fi _dyn_scan_flags+=("$a") } for _lf in "${LINK_FLAGS[@]}"; do _dyn_expand_into_scan "$_lf" 0 done for _lf in "${_dyn_scan_flags[@]}"; do case "$_lf" in --allow-undefined|-allow-undefined|--allow-undefined-file|--allow-undefined-file=*|-allow-undefined-file|-allow-undefined-file=*) # EVERY coro arm, not just the consumer. --allow-undefined is # --import-undefined + --unresolved-symbols=ignore, and the `ignore` half is precisely the # address-0 data bind both arms now refuse (see the CORO_UNDEF_FLAG note). One caller-side # spelling silently restores it — on the consumer arm by out-ranking # --unresolved-symbols=import-dynamic (lld Driver.cpp:865-870), on the static arm by # widening --import-undefined back over the DATA class. The caller's own # --allow-undefined-file= would additionally overwrite the DERIVED undefined-GLOBAL sanction # with a hand-written list — the shape this mechanism exists to not have. fatal "caller-supplied '$_lf' on the coro final link line (post-@-expansion argv) — the undefined-symbol policy is ARM-OWNED and this flag re-opens the silent address-0 data bind: --allow-undefined is --import-undefined PLUS --unresolved-symbols=ignore (lld Driver.cpp:865-870), and the `ignore` half is what let \`cpuinfo_op\` link as an i32.const 0. The static arm passes --import-undefined (undefined functions import, undefined DATA is a link error) and derives its undefined-GLOBAL sanction from the inputs' symbol tables; the WASMLD_LIBC_IMPORT=1 arm passes --unresolved-symbols=import-dynamic. Drop the caller's flag; a genuinely host-bound FUNCTION import is spelled WASMLD_ALLOW_ENV_IMPORTS=." ;; --import-undefined|-import-undefined) fatal "caller-supplied '$_lf' on the coro final link line (post-@-expansion argv) — the undefined-symbol policy is ARM-OWNED. The flag is already the static arm's own, and accepting a caller's copy makes the line's policy depend on argv order rather than on the arm. Drop it." ;; --unresolved-symbols|--unresolved-symbols=*|-unresolved-symbols|-unresolved-symbols=*) fatal "caller-supplied '$_lf' on the final link line (post-@-expansion argv) — the undefined-symbol policy is ARM-OWNED (--import-undefined on the static arm; --unresolved-symbols=import-dynamic exactly on WASMLD_LIBC_IMPORT=1, where the witness makes it sound). Mixing spellings resolves silently in --allow-undefined's favor (lld Driver.cpp:865-870); spell the request through the arm knobs (PLAN-DYNAMIC-LIBC §3.4 (ii))." ;; esac done # ── §3.5 force-export OWNER AUDIT (consumer arm): for every hard --export in the link_coro # surface, record owner = main | both | libc.so | rt. main/both keep the --export; libc.so/rt are # DROPPED (the loader looks them up on module #1/#0 — first confirmed libc.so-owned case: # __wasm32_set_signal_region, host-called post-_start); a symbol NO side owns is a loud refusal — # a wrong-side lookup at load would be silent. The table ships as $OUT.export-owners (the loader's # lookup table input). # # WHY THE RT-IMPORT ARM RUNS IT TOO (2026-08-04). The audit was gated on WASMLD_LIBC_IMPORT=1 # alone, so a WASMLD_RT_IMPORT=1 main WITHOUT a libc.so (the tcl dlopen stub) kept the full hard # --export list — including the eight runtime-owned names module #0 owns and this image does not # define. lld's requiredExports check (Writer.cpp:1741-1749) reports a missing --export only when # unresolvedSymbols is ReportError/Warn, and --allow-undefined set it to IGNORE, so those eight # exports were SILENTLY dropped from the image and the promise the surface exists to make — "the # HardwareJS process ABI is a linker invariant, not a per-target opt-in" — was quietly false for # that binary. Dropping --unresolved-symbols=ignore turned the silence into eight link errors, # which is how this was found. The fix is not to re-silence it: an export a DIFFERENT module owns # must be dropped BY OWNER, which is exactly what this audit computes. if [[ "$LIBC_IMPORT" == "1" || "${WASMLD_RT_IMPORT:-0}" == "1" ]]; then _main_defs="$(run_llvm_bulk "$HWJS_NM" --defined-only "$WORK/merged.bc" ${NATIVE_INPUTS[@]+"${NATIVE_INPUTS[@]}"} 2>/dev/null | awk '{print $NF}' | sort -u)" _libc_exports="" if [[ "$LIBC_IMPORT" == "1" ]]; then _libc_exports="$(node -e ' const fs = require("fs"); const m = new WebAssembly.Module(fs.readFileSync(process.argv[1])); for (const e of WebAssembly.Module.exports(m)) console.log(e.kind + " " + e.name); ' "$LIBC_SO_SNAPSHOT" 2>&1)" \ || { echo "$_libc_exports" >&2; fatal "WASMLD_LIBC_IMPORT: cannot read the export surface of '$WASMLD_LIBC_SO_PATH' (WORK snapshot) — the owner audit and the witness allowlists DERIVE from the built libc.so (never hand-maintained; PLAN-DYNAMIC-LIBC §3.6)."; } fi # Linker-synthesized symbols wasm-ld itself defines on a main link (owner=main by construction). _synth_owned=" __heap_base __data_end __wasm_call_ctors __wasm_init_tls __stack_pointer __tls_base " CORO_EXPORTS_AUDITED=() EXPORT_AUDIT_RAN=1 : > "$WORK/export-owners" for _ex in "${CORO_EXPORT_SURFACE[@]}"; do case "$_ex" in --export=*) _sym="${_ex#--export=}" ;; *) CORO_EXPORTS_AUDITED+=( "$_ex" ); continue ;; # --export-if-defined: cannot mis-fire esac _owner="" if grep -qx "$_sym" <<<"$_main_defs" || [[ "$_synth_owned" == *" $_sym "* ]]; then _owner="main" grep -qE "^(function|global) ${_sym}$" <<<"$_libc_exports" && _owner="both" CORO_EXPORTS_AUDITED+=( "$_ex" ) elif grep -qE "^(function|global) ${_sym}$" <<<"$_libc_exports"; then _owner="libc.so" elif [[ "${WASMLD_RT_IMPORT:-0}" == "1" && ( "$_sym" == __hwjs_* || "$_sym" == hwjs_* ) ]]; then _owner="rt" else fatal "force-export OWNER AUDIT: '$_sym' is owned by NO side — not defined in the merged main closure/native inputs, not exported by libc.so (${WASMLD_LIBC_SO_PATH:-}), and not a kernel-runtime name under WASMLD_RT_IMPORT (=${WASMLD_RT_IMPORT:-0}). A wrong-side lookup at load would be silent; refusing at link (PLAN-DYNAMIC-LIBC §3.5)." fi printf '%s %s\n' "$_sym" "$_owner" >> "$WORK/export-owners" done # The WideSlot readers the kernel runtime binds from the main (see CORO_EXPORTS_DERIVED). # --export-if-defined, so the entries pass the audit unaudited by the same rule as the rest of # the if-defined surface: an if-defined export cannot mis-fire on a symbol the main lacks. CORO_EXPORTS_DERIVED=() while read -r _psym; do CORO_EXPORTS_DERIVED+=( "--export-if-defined=$_psym" ) done < <(grep -E '^__hwjs_promise_a[0-9]+$' <<<"$_main_defs" | sort -u) [[ ${#CORO_EXPORTS_DERIVED[@]} -eq 0 ]] \ || echo "wasmld: NOTE (WideSlot promise readers): exporting ${#CORO_EXPORTS_DERIVED[@]} reader(s) derived from the merged closure [${CORO_EXPORTS_DERIVED[*]#--export-if-defined=}] -- the kernel runtime imports whichever its cascade reads and the loader binds them from the main." >&2 echo "wasmld: NOTE (owner audit): force-export owners recorded (main=$(grep -c ' main$' "$WORK/export-owners" || true) both=$(grep -c ' both$' "$WORK/export-owners" || true) libc.so=$(grep -c ' libc.so$' "$WORK/export-owners" || true)[dropped] rt=$(grep -c ' rt$' "$WORK/export-owners" || true)[dropped]) -> $OUT.export-owners" >&2 fi finallink_err="$WORK/finallink.err" if [[ $IS_SHARED == 1 ]]; then # ── -shared final link: PIC dylib (no memory sizing — the .so lives in # the MAIN's memory; no crt, no export superset — the caller's --export # flags arrive via LINK_FLAGS). NATIVE_INPUTS is empty (refused above). # Side-module TLS export surface. lld synthesizes __tls_size, __tls_align # and __wasm_init_tls only when something references them, so the hard # exports are what makes them exist; the loader needs all four to give a # new thread its own block and to re-bind a replayed Instance. if [[ "$SIDE_TLS" == "1" ]]; then LINK_FLAGS+=( --export=__tls_base --export=__tls_size --export=__tls_align --export=__wasm_init_tls ) fi # libc.so PRODUCER export surface (S2-owed, PLAN-DYNAMIC-LIBC): # --export=__tls_base — lld exports the synthetic mutable global when explicitly asked # (Writer.cpp:806-816); the S2 §3 identity design (per-module physical, process-scope # logical, host dual-write) rides this one flag; # --export=__libc_start_main — the ONE crossing at the start seam (S2 §6.1), added whenever # the merged closure DEFINES it (the real libc always; a toy fixture may not); # WASMLD_LIBC_SO_EXPORTS — the crossing-data surface (S5 §1: stdin/out/err, environ group, # optind family, in6addr_any, daylight group for musl), spelled by the rig that owns the # closure; each is a HARD --export (a missing name is a loud wasm-ld error, never # if-defined silence). if [[ "$LIBC_SO" == "1" ]]; then LINK_FLAGS+=( --export=__tls_base ) _dyn_defs="$("$HWJS_NM" --defined-only "$WORK/merged.bc" 2>/dev/null | awk '{print $NF}' | sort -u)" if grep -qx "__libc_start_main" <<<"$_dyn_defs"; then LINK_FLAGS+=( --export=__libc_start_main ) else echo "wasmld: NOTE (WASMLD_LIBC_SO): merged closure defines no __libc_start_main — start-seam export skipped (legal only for a toy/fixture closure; the real libc.so always carries it, S2 §6.1)." >&2 fi if [[ -n "${WASMLD_LIBC_SO_EXPORTS:-}" ]]; then echo "wasmld: NOTE (WASMLD_LIBC_SO): exporting crossing surface [${WASMLD_LIBC_SO_EXPORTS}] (hard --export each)." >&2 for _dyne in ${WASMLD_LIBC_SO_EXPORTS}; do LINK_FLAGS+=( "--export=${_dyne}" ) done fi # ── G2 §4.1: export the TLS-template SIZING ABI — the module-#1 TLS # owner's contract with the loader (it sizes libc.so's bootstrap TLS # block via these before libc.so's ctors, then binds/dual-writes # __tls_base). if-defined because the sizing accessors come from the # glibc native-as-bitcode shim (a TLS-less toy closure defines none) and # __wasm_init_tls is lld-synthesized only for a TLS-bearing image. The # 29 thread_local names themselves stay hidden and UNexported (asserted # below + post-link) — only the sizing ABI + the §12 __tls_base global # cross the seam. for _tlsx in __wasm32_get_tls_size __wasm32_get_tls_align \ __wasm32_get_tls_base __wasm32_set_tls_base __wasm_init_tls; do LINK_FLAGS+=( "--export-if-defined=${_tlsx}" ) done # ── R6 EXPORT-PROMOTION AT SCALE (glibc arm; spike G1 "R6 export # promotion"). glibc seals its PUBLIC ABI `hidden` in the pool (unlike # musl's default-visibility pool), so a bare -shared auto-exports NOTHING # of it — the ~10^3 public names must be promoted EXPLICITLY. # WASMLD_LIBC_SO_EXPORTS_FILE is the derived public-name SUPERSET (glibc # Versions union, one name per line). We INTERSECT it with the merged # closure's DEFINED symbols (authoritative — only what this libc.so # actually defines is --export'd, so every promoted name is known-present: # a hard --export, never if-defined silence) and REFUSE loudly if any # thread_local (the pass's own TLS-CENSUS set) or __GI_ internal alias # would be promoted. if [[ -n "${WASMLD_LIBC_SO_EXPORTS_FILE:-}" ]]; then [[ -f "$WASMLD_LIBC_SO_EXPORTS_FILE" ]] \ || fatal "WASMLD_LIBC_SO_EXPORTS_FILE=$WASMLD_LIBC_SO_EXPORTS_FILE does not exist — the R6 public-ABI export superset (glibc Versions union) is a required producer input on the glibc export-promotion arm." # the pass's OWN authoritative thread_local census (names=[...]) — the # never-silent guard against a superset generator that failed to # exclude a TLS name (a hard --export of a hidden thread_local would # force-export a meaningless TP-relative wasm global). _dyn_tls_names="$(sed -n 's/.*TLS-CENSUS tls_globals=[0-9]* names=\[\(.*\)\].*/\1/p' "$hwjs_err" | tr ' ' '\n' | { grep -E '^[A-Za-z_]' || true; } | LC_ALL=C sort -u)" LC_ALL=C sort -u "$WASMLD_LIBC_SO_EXPORTS_FILE" > "$WORK/exports.superset" # re-sort the closure's defined set under the SAME LC_ALL=C collation # (it was sorted in the caller's locale above) so comm's sorted-order # contract holds — a locale skew silently drops real exports. printf '%s\n' "$_dyn_defs" | LC_ALL=C sort -u > "$WORK/exports.defined" _dyn_promote="$(LC_ALL=C comm -12 "$WORK/exports.superset" "$WORK/exports.defined")" _dyn_leak_tls="$(LC_ALL=C comm -12 <(printf '%s\n' "$_dyn_promote") <(printf '%s\n' "$_dyn_tls_names") 2>/dev/null)" _dyn_leak_gi="$(printf '%s\n' "$_dyn_promote" | grep -E '^__GI_' || true)" if [[ -n "$_dyn_leak_tls" || -n "$_dyn_leak_gi" ]]; then [[ -n "$_dyn_leak_tls" ]] && printf 'wasmld: promoted thread_local (must stay hidden/TP-relative): %s\n' $_dyn_leak_tls >&2 [[ -n "$_dyn_leak_gi" ]] && printf 'wasmld: promoted __GI_ internal alias: %s\n' $_dyn_leak_gi >&2 fatal "WASMLD_LIBC_SO: the R6 export superset ($WASMLD_LIBC_SO_EXPORTS_FILE) intersects the closure at thread_local and/or __GI_ name(s) named above — force-exporting a hidden thread_local (a TP-relative internal, meaningless as a wasm global export) or a __GI_ PLT-avoidance alias is a broken export surface. Exclude them from the superset generator (they are libc-internal, not the public ABI). Refusing." fi _dyn_promote_n="$(printf '%s\n' "$_dyn_promote" | grep -c . || true)" _dyn_superset_n="$(grep -c . "$WORK/exports.superset" || true)" echo "wasmld: NOTE (WASMLD_LIBC_SO R6): promoting $_dyn_promote_n public name(s) to the export surface (Versions superset $_dyn_superset_n ∩ closure-defined; $((_dyn_superset_n - _dyn_promote_n)) superset names absent from this wasm32 build — legitimately not built). thread_local + __GI_ excluded." >&2 while IFS= read -r _dynp; do [[ -n "$_dynp" ]] && LINK_FLAGS+=( "--export=${_dynp}" ) done <<<"$_dyn_promote" fi fi # ── libc.so PRODUCER address-taken-UNDEFINED pre-link GUARD (WASMLD_LIBC_SO # arm; spike docs/plans/spikes/G1-glibc-pic-pool.md "Residual risks" 1). # A function whose ADDRESS is taken while it is UNDEFINED in the merged # closure crashes the pinned wasm-ld in ElemSection::writeBody() -> # FunctionSymbol::getFunctionIndex() — a SIGSEGV, NOT the clean # R_WASM_TABLE_INDEX_REL_SLEB error the post-link wrap below catches — # while building the __indirect_function_table element segment. The # producer arm refuses native inputs (above), so glibc's native TLS # accessors + syscall stubs (obj/csu/wasm-thread.o, libglibc_native.a) # are UNDEFINED unless rebuilt as PIC bitcode; a census found EXACTLY ONE # address-taken among them (__execve, taken by __spawni). Diagnose it # HERE, by name, before the crash: intersect merged.o's TABLE_INDEX # relocation targets (the address-taken set) with its undefined function # symbols. merged.o is the coroutinized wasm object about to enter # wasm-ld — the exact input that segfaults. Never a segfault; always a # named refusal. if [[ "$LIBC_SO" == "1" ]]; then _lso_undef="$("$HWJS_NM" --undefined-only "$WORK/merged.o" 2>/dev/null \ | awk '{print $NF}' | sort -u)" _lso_ataken="$("$HWJS_LLVM_BIN/llvm-objdump" -r "$WORK/merged.o" 2>/dev/null \ | grep -oE 'R_WASM_TABLE_INDEX[A-Z0-9_]*[[:space:]]+[^[:space:]]+' \ | awk '{print $2}' | sed -E 's/\+[0-9]+$//' | sort -u)" _lso_atk_undef="$(comm -12 <(printf '%s\n' "$_lso_ataken") <(printf '%s\n' "$_lso_undef") 2>/dev/null)" # ── RT §11 GOT.func EXEMPTION (PLAN-DYNAMIC-LIBC; hwjs-rt-abi.rst §11 # "The runtime-op callable surface"). The guard above was written for # NATIVE-shim symbols (glibc's __execve etc.) that the -shared arm refuses # as inputs: those are expected-STATIC defs which, left undefined, place a # dead entry in the __indirect_function_table element segment and SIGSEGV # wasm-ld. But a §11 runtime-op name that a module ADDRESS-TAKES while # undefined is a legitimate GOT.func IMPORT the host loader binds from the # process's runtime provider (module #0 / the rt module; §10 partition # note) — it lowers to a GOT.func. import under the -shared PIC arm, # NOT an element-segment entry, so it never reaches the ElemSection crash. # musl's libc.so plain-imports the sentinel; glibc address-takes it # (llvm.used version-pairing per §8) — the same loader-provided symbol, # tripping this guard only because glibc takes its address. Exempt EXACTLY # the §11 RT-owned callable surface (the sentinel __hwjs_ds_region_abi_v2 + # the frame/parked/park/xfer/drive/sjlj/region/progress ops + the # compiler-glue thunks incl. the __hwjs_promise_* reader family) — NEVER a # blanket skip: any NON-§11 address-taken-undefined (a native __execve-class # shim) STILL fatals below. §10.2 kernel-registry ops (__hwjs_e8_refuse / # *_lookup) are NOT here — they are vmlinux-owned imports, not §11, and no # producer address-takes them (out of scope; add by name if one ever does). _lso_rt_surface="$(printf '%s\n' \ __hwjs_frame_alloc __hwjs_frame_free __hwjs_frame_publish \ __hwjs_set_parked __hwjs_clear_parked __hwjs_take_parked __hwjs_noreturn_completed \ __hwjs_user_park __hwjs_park_take_result __hwjs_slice_enter __hwjs_slice_exit __hwjs_fork_barrier_ok \ __hwjs_xfer_descend __hwjs_xfer_park __hwjs_xfer_return __hwjs_xfer_take \ __hwjs_drive __hwjs_drive_resume __hwjs_drive_done_status \ hwjs_sj_record hwjs_lj_arm_from hwjs_lj_match hwjs_lj_selector hwjs_lj_value hwjs_lj_clear hwjs_lj_unwound hwjs_trap \ __hwjs_ds_region_abi_v2 __hwjs_region_alloc __hwjs_region_save __hwjs_region_restore __hwjs_freea \ __hwjs_register_suspends \ __hwjs_progress_note __hwjs_drive_watch __hwjs_progress_violation __hwjs_set_stall_bound \ __hwjs_pt_count __hwjs_pt_op __hwjs_pt_h __hwjs_pt_owner __hwjs_sanitize_store \ hwjs_resume hwjs_done hwjs_destroy \ | LC_ALL=C sort -u)" # partition the address-taken-undefined set: §11 names (exact) + the # __hwjs_promise_* thunk family are EXEMPT (loader-provided GOT.func); the # residual is the genuine native-shim RED arm. _lso_atk_refuse="$(printf '%s\n' "$_lso_atk_undef" \ | { grep -vxF -f <(printf '%s\n' "$_lso_rt_surface") || true; } \ | { grep -vE '^__hwjs_promise_' || true; } \ | grep -E '.' || true)" _lso_atk_exempt="$(LC_ALL=C comm -23 <(printf '%s\n' "$_lso_atk_undef" | LC_ALL=C sort -u) \ <(printf '%s\n' "$_lso_atk_refuse" | LC_ALL=C sort -u) \ | grep -E '.' || true)" if [[ -n "$_lso_atk_exempt" ]]; then printf 'wasmld: NOTE (WASMLD_LIBC_SO): address-taken RT §11 GOT.func import(s) resolved by the loader from the runtime provider (hwjs-rt-abi.rst §11) — EXEMPT from the address-taken-undefined refusal: %s\n' $_lso_atk_exempt >&2 fi if [[ -n "$_lso_atk_refuse" ]]; then printf 'wasmld: address-taken + UNDEFINED: %s\n' $_lso_atk_refuse >&2 fatal "WASMLD_LIBC_SO: the merged libc.so closure ADDRESS-TAKES function symbol(s) that are UNDEFINED and are NOT part of the RT §11 loader-provided callable surface (named above) — wasm-ld would SIGSEGV building the __indirect_function_table element segment (ElemSection::writeBody -> getFunctionIndex; spike G1 risk 1), not print a clean error. These are native-object symbols the -shared arm refuses as inputs and that were not rebuilt as PIC bitcode: on the glibc arm the known case is __execve (address-taken by __spawni / posix_spawn), which must ride the glibc native-as-bitcode shim ('make -C sysroot glibc-native-bc-pic' -> build/glibc-native-shims.pic.bc, joined via WASMLD_HWJS_TLS_ACCESS_BC). Rebuild the missing symbol(s) as PIC bitcode and re-link. Refusing rather than segfaulting." fi fi # BRINTOS-WORKAROUND: brintos/brintium#3 revisit — pin-gated: this patch IS # the fix, git am'ed onto the submodule at the base gitlink, so it goes when # BRINTOS_BRINTIUM_SRCREV advances past it, not when the issue closes. # BRINTOS-2670 (marker: EXPORT_PASS_OWNED_SYMBOLS) — a package's version # script must not hide the PASS's own symbols. # # BRINTOS-2364 forwards --version-script to wasm-ld, which is right: wasm # collapses the versioning half of a GNU version script but honours the # global:/local: half as export visibility, and consuming it had been # discarding each module's declared export surface. What it also does is # apply `local: *` to symbols the package has never heard of. The # coroutinize pass emits its runtime surface as WEAK definitions — # @__hwjs_budget, @__hwjs_module_id, the F117 registrars # (__hwjs_register_suspends / _capsummary / _fpcast_adapter), the promise # readers, the loud handlers — and no package's script names any of them, # so applyVersionScript() (lld/wasm/Driver.cpp) calls setHidden(true) on # every one. # # `hidden` is the shared term in all three of the linker's decisions about # a weak definition in a -shared link, so one setHidden removes three # things at once: the symbol is not exported (Symbols.cpp isExported), not # imported (Writer.cpp shouldImport — the clause whose own comment is "we # need to both import and export the symbol"), and its address-takes # become internal GOT entries rather than GOT imports (Relocations.cpp # requiresGOTAccess). The import is the one that matters: the loader binds # the per-instance budget and the main-/kernel-owned registrar ops through # env, so a version-scripted side module gets a budget that stays at the # weak-0 default (every consumer dies in __wasm_call_ctors -> # __hwjs_budget_sanity before its own code runs) and registers its # suspends and capability summaries into its OWN weak no-op stubs instead # of the process and kernel registries — a silent misdispatch, not a load # refusal. Measured on core-image-brintos-emcraft: 88 of 220 side modules, # 25 recipes, every one of them a package that passes a version script. # # applyVersionScript() skips isExportedExplicit() symbols — "command-line # exports are authoritative and must never be dropped" — and --export/ # --export-if-defined set forceExport before it runs. So naming them is the # sanctioned way out, and it is what __hwjs_budget_set/_get and # __hwjs_drive_abi already get for free from their wasm-export-name attr. # # DERIVED FROM THE ARTIFACT, never a list: the pass grows ops, and a list # written today is wrong the first time it does — silently, in the # misdispatch direction. llvm-nm separates the classes exactly: W is a # pass-emitted weak definition (at risk), t/d is already-local (must NOT be # exported), U is already an import (unaffected). -if-defined because a # module with no safepoint placed carries no budget global at all. # # No-op without a version script: the symbols are already exported there, # and the re-linked module is identical section for section but for the # debug name section. if [[ $IS_SHARED == 1 ]]; then _pass_owned=() # `if`, not `&&`: under `set -e` a loop whose LAST iteration ends on a # false test exits non-zero and takes the whole link with it. while read -r _kind _name; do if [[ "$_kind" == "W" ]]; then _pass_owned+=( "--export-if-defined=$_name" ) fi done < <("$HWJS_NM" "$WORK/merged.o" 2>/dev/null \ | awk 'NF >= 2 && $NF ~ /^__hwjs_/ { print $(NF - 1), $NF }') if [[ ${#_pass_owned[@]} -gt 0 ]]; then LINK_FLAGS+=( "${_pass_owned[@]}" ) printf 'wasmld: NOTE (EXPORT_PASS_OWNED_SYMBOLS): pinned %d pass-emitted weak symbol(s) against a version script (BRINTOS-2670): %s\n' \ "${#_pass_owned[@]}" "${_pass_owned[*]#--export-if-defined=}" >&2 fi fi set +e link_shared "${LINK_FLAGS[@]}" "$WORK/merged.o" -o "$OUT" 2>"$finallink_err" link_rc=$? set -e cat "$finallink_err" >&2 if [[ $link_rc -ne 0 ]]; then # Attributed wrap of the cryptic dso_local/TABLE_INDEX_REL_SLEB refusal # (issue #52 secondary finding 1's backstop — the path-keyed pool-member # pre-refuse above catches the configured pool; THIS catches a # libc-internal-assuming TU arriving from anywhere else). if grep -q 'R_WASM_TABLE_INDEX_REL_SLEB' "$finallink_err" \ && grep -qi 'undefined' "$finallink_err"; then fatal "-shared final link refused with R_WASM_TABLE_INDEX_REL_SLEB relocations against undefined symbol(s) (wasm-ld error above). This is the LIBC-POOL-MEMBER-IN-A-.so shape: the input carries dso_local-keyed address-takes of symbols it expects the surrounding libc to define (glibc's pthread-cleanup handlers are the known case), which a PIC side module cannot express. Pool members / libc-internal TUs cannot ride a -shared link — bind the libc API from the MAIN instead (WASMLD_DYN_EXPORT_LIBC on the main's link) or use a self-contained PIC TU (issue linux-in-the-browser#52)." fi # Attributed wrap of the cryptic dso_local/MEMORY_ADDR_REL_SLEB refusal # (PLAN-DYNAMIC-LIBC "third blocker" backstop). The crt/dynamic # boundary-definition seed above defines the CLOSED allowlist of hidden # DATA symbols glibc references dso_local; THIS catches a hidden-data # REL_SLEB reference to a symbol NOT on that allowlist (a genuinely-missing # data TU), naming it instead of leaving the cryptic wasm-ld error. Keeps # the RED arm: only the enumerated crt/dynamic bounds are stubbed; a novel # undefined data symbol still refuses. if [[ "$LIBC_SO" == "1" ]] \ && grep -q 'R_WASM_MEMORY_ADDR_REL_SLEB' "$finallink_err" \ && grep -qi 'undefined' "$finallink_err"; then _bnd_resid="$(grep -oE 'R_WASM_MEMORY_ADDR_REL_SLEB is not supported against an undefined symbol `[^`]+`' "$finallink_err" \ | sed -E 's/.*`([^`]+)`.*/\1/' | LC_ALL=C sort -u | tr '\n' ' ')" fatal "WASMLD_LIBC_SO: -shared final link refused with R_WASM_MEMORY_ADDR_REL_SLEB relocation(s) against undefined DATA symbol(s) [ ${_bnd_resid}] NOT covered by the crt/dynamic boundary-definition seed (wasm-ld error above). A -shared PIC producer lowers a dso_local (hidden) data reference to a fixed-layout REL_SLEB reloc that cannot be left undefined and cannot ride as a GOT import; the non-PIC main link resolves these to 0. Either the symbol is a genuinely-missing pool data TU (add it via build-glibc-libc.sh) or a new inert crt/dynamic boundary that belongs on the seed's allowlist (libexec/brintld, the '_lso_bnd_allow' set). Refusing rather than shipping an unlinkable closure." fi exit $link_rc fi # ── SINGLE-RECORD MANIFEST invariant, enforced on the FINAL BYTES: lld # concatenates same-named custom sections, so any second hwjs.abi record # (a smuggled pre-sectioned object, a future regression) must refuse # here, at the artifact, not at the host loader. Exactly ONE hwjs.abi # custom section, carrying exactly ONE v1 record (8 one-byte LEB fields). command -v node >/dev/null 2>&1 \ || fatal "-shared: node unavailable — cannot verify the single-record hwjs.abi manifest on the final .so. Refusing rather than silently skipping." manifest_shape="$(hwjs_abi_manifest_shape "$OUT")" || fatal "-shared: could not parse the final .so's custom sections for the hwjs.abi manifest check. Refusing." # ── SIDE-MODULE TLS AUDIT on the FINAL BYTES. A thread_local's export value # is its offset into the module's TLS block, and the loader resolves an # exported data symbol as __memory_base + value — a consumer would bind a # wrong address with nothing to say so. The drivers compile with # -fvisibility=default, so a non-static thread_local IS exported unless # this refuses. The four binding exports must all be present. if [[ "$SIDE_TLS" == "1" ]]; then _side_tls_names="$(sed -n 's/.*TLS-CENSUS tls_globals=[0-9]* names=\[\(.*\)\].*/\1/p' "$hwjs_err" | tr ' ' '\n' | { grep -E '^[A-Za-z_]' || true; } | LC_ALL=C sort -u)" _side_tls_exports="$(node -e ' const fs = require("fs"); const m = new WebAssembly.Module(fs.readFileSync(process.argv[1])); for (const e of WebAssembly.Module.exports(m)) console.log(e.name); ' "$OUT" 2>&1 | LC_ALL=C sort -u)" || { echo "$_side_tls_exports" >&2; rm -f "$OUT"; fatal "-shared: cannot read the final .so's export surface for the side-module TLS audit. Refusing and removing the artifact."; } _side_tls_leak="$(LC_ALL=C comm -12 <(printf '%s\n' "$_side_tls_exports") <(printf '%s\n' "$_side_tls_names") 2>/dev/null)" if [[ -n "$_side_tls_leak" ]]; then printf 'wasmld: EXPORTED thread_local: %s\n' $_side_tls_leak >&2 rm -f "$OUT" fatal "-shared: the side module EXPORTS thread_local name(s) named above — an exported thread_local's value is an offset into its TLS block, which a consumer would bind as an address. Give them hidden visibility (or static linkage); a thread_local cannot be shared across modules on this target. Refusing and removing the artifact." fi for _side_tls_sym in __tls_base __tls_size __tls_align __wasm_init_tls; do grep -qx "$_side_tls_sym" <<<"$_side_tls_exports" || { rm -f "$OUT" fatal "-shared: the side module owns TLS but its final bytes do not export $_side_tls_sym — the loader cannot bind its per-thread block without it. Refusing and removing the artifact." } done echo "wasmld: NOTE (side-module TLS audit): TLS binding exports present; 0 thread_local exported (of $(printf '%s\n' "$_side_tls_names" | grep -c .) census names)." >&2 fi if [[ "$LIBC_SO" == "1" || "$RT_SO" == "1" || -n "${WASMLD_HWJS_XMOD_PARTITION_LIBC:-}" ]]; then # Dynamic-libc arms + the runtime producer (both PUBLISH a partition, so # both stamp v2): ONE v2 record of exactly 10 LEB fields (8 v1 fields + # suspender-partition digest + refused-exports count — hwjs-rt-abi §9.4 # bump ceremony; wasmld's byte-shape check updated in LOCKSTEP here; the # loader-side pin is loader scope). case "$manifest_shape" in "1 2 10 "*) : ;; *) rm -f "$OUT" fatal "-shared (dynamic-libc arm): final .so's hwjs.abi manifest is not the ONE v2 10-field record (got 'sections version nfields payload-bytes' = '$manifest_shape', require '1 2 10 *') — the dynamic-libc arms stamp suspender-partition digest + refused-exports as fields 9/10 (PLAN-DYNAMIC-LIBC §3.2). A stale plugin or a second smuggled record; refusing and removing the artifact." ;; esac elif [[ "$manifest_shape" != "1 1 8 8 "* ]]; then rm -f "$OUT" fatal "-shared: final .so's hwjs.abi manifest is malformed: got 'sections version nfields payload-bytes drive_abi' = '$manifest_shape', require '1 1 8 8 *' (exactly ONE section, ONE v1 record of 8 one-byte LEB fields; only the dynamic-libc partition arms are v2). A second record means a pre-sectioned object reached wasm-ld (lld concatenates same-named custom sections) — the single-record invariant is broken. Refusing and removing the artifact." fi # drive_abi FIELD value (manifest field 2) must match this link's drive # expectation — the byte-shape pin's version-keyed acceptance learning 4 # (STATUS-CHANNEL-CONTRACT §10). The DRIVE-CONTRACT log check above pins # the pass's claim; this pins the FINAL BYTES the loader will audit, so a # manifest/log skew (second smuggled record, stale pre-sectioned object) # cannot ship an .so whose stamped abi lies to the loader's want-table pin. _mf_drive_abi="$(awk '{print $5}' <<<"$manifest_shape")" if [[ "${HWJS_STATUS_CHANNEL:-0}" == "1" ]]; then _mf_expect_abi=4 elif [[ "$HWJS_DRIVE_FLAT" == "0" ]]; then _mf_expect_abi=2 else _mf_expect_abi=3; fi if [[ "$_mf_drive_abi" != "$_mf_expect_abi" ]]; then rm -f "$OUT" fatal "-shared: final .so's hwjs.abi manifest stamps drive_abi=$_mf_drive_abi but this link's drive expectation is drive_abi=$_mf_expect_abi (HWJS_STATUS_CHANNEL=${HWJS_STATUS_CHANNEL:-unset}, HWJS_DRIVE_FLAT=$HWJS_DRIVE_FLAT) — manifest/expectation skew: the loader's version-keyed want-table pin would refuse (or worse, accept a mis-stamped image). Refusing and removing the artifact." fi # lanes bit (flags bit5, lane_addressed — PLAN-THREADS-SINGLE-MEMORY §4 # step 2): the FINAL BYTES must agree with this driver's HWJS_RT_LANES # knob, exactly like drive_abi above — a mis-stamped bit would either # defeat the loader's lane-mixing refusal (bit absent on a lane-addressed # image: a lane-unaware peer mixes in silently and reads lane 0 from any # lane, I3-2) or refuse a correct image (bit present on an absolute one). _mf_flags="$(awk '{print $6}' <<<"$manifest_shape")" _mf_lane_bit=$(( (_mf_flags & 32) != 0 ? 1 : 0 )) if [[ "$_mf_lane_bit" != "${HWJS_LANE_ADDR:-0}" ]]; then rm -f "$OUT" fatal "-shared: final .so's hwjs.abi manifest stamps flags bit5 lane_addressed=$_mf_lane_bit but this link's HWJS_RT_LANES knob says $( [[ "${HWJS_LANE_ADDR:-0}" == "1" ]] && echo "lane-aware (HWJS_RT_LANES=${HWJS_RT_LANES:-})" || echo "single-lane (unset/1)" ) — manifest/knob skew: the loader's lane-mixing refusal (I3-2) would judge this image wrongly. Stale plugin or a smuggled record; refusing and removing the artifact." fi # ── libc.so PRODUCER post-link (WASMLD_LIBC_SO=1): cross-verify the PUBLISHED verdicts against the # FINAL BYTES' name section (the gen-so-import-surface --verify discipline: a fn export is a # suspender iff its name-section-resolved function has a ".resume" or " (.resume)" clone), then install the # partition at WASMLD_LIBC_SO_PARTITION_OUT. A publish/artifact divergence is the link-time # derivation-loss class the digest lockstep exists to make detectable — refuse and remove, never install. if [[ "$LIBC_SO" == "1" ]]; then _pverify="$(node -e ' const fs = require("fs"); const file = process.argv[1], partFile = process.argv[2]; const b = fs.readFileSync(file); let p = 8; const exports = [], fnNames = new Map(); const leb = () => { let r = 0, s = 0; for (;;) { const x = b[p++]; r |= (x & 127) << s; if (!(x & 128)) return r >>> 0; s += 7; } }; const name = () => { const n = leb(); const s = b.slice(p, p + n).toString(); p += n; return s; }; while (p < b.length) { const id = b[p++]; const sz = leb(); const end = p + sz; if (id === 7) { const n = leb(); for (let i = 0; i < n; i++) { const nm = name(); const kind = b[p++]; const idx = leb(); if (kind === 0) exports.push({ name: nm, idx }); } } else if (id === 0) { const nm = name(); if (nm === "name") { while (p < end) { const sub = b[p++]; const ssz = leb(); const send = p + ssz; if (sub === 1) { const n = leb(); for (let i = 0; i < n; i++) { const idx = leb(); fnNames.set(idx, name()); } } p = send; } } } p = end; } if (fnNames.size === 0) { console.error("no name section — the partition cannot be cross-verified on the final bytes (stripped .so?)"); process.exit(1); } const have = new Set(fnNames.values()); const verdicts = new Map(); for (const line of fs.readFileSync(partFile, "utf8").split("\n")) { const t = line.trim(); if (!t) continue; /* Track M (PLAN-CXX-STATUS-CHANNEL §7): a status-channel producer suffixes every * verdict token with the +nothrow/+throws lane — strip it for the suspend/native * cross-verify (the lane is a THROW fact; the .resume-clone check judges the PARK * fact). The pass itself enforces the lane grammar; this verify only needs * the base verdict. */ const i = t.indexOf(" "); verdicts.set(t.slice(i + 1), t.slice(0, i).split("+")[0]); } /* lld-synthesized function exports have no IR-level verdict by construction; reserved- * namespace runtime/registrar ops are never partition-carried (both parsers refuse them). */ const SYNTH = new Set(["__wasm_call_ctors", "__wasm_apply_data_relocs", "__wasm_init_tls", "_initialize"]); const problems = []; for (const e of exports) { if (SYNTH.has(e.name)) continue; if (e.name.startsWith("__hwjs_") || e.name.startsWith("hwjs_") || e.name.startsWith("llvm.")) continue; const fn = fnNames.get(e.idx) ?? e.name; const isCoro = have.has(fn + ".resume") || have.has(fn + " (.resume)"); const v = verdicts.get(e.name); if (v === undefined) { problems.push(`${e.name}: fn export ABSENT from the published partition`); continue; } if (v === "suspend" && !isCoro) problems.push(`${e.name}: published suspend but the final artifact binding is NATIVE (no ${fn}.resume clone)`); if ((v === "native" || v === "refused") && isCoro) problems.push(`${e.name}: published ${v} but the final artifact binding IS a coroutine (${fn}.resume present)`); } if (problems.length) { console.error(problems.join("\n")); process.exit(1); } console.log(`ok: ${exports.length} fn exports cross-verified against ${partFile}`); ' "$OUT" "$WORK/partition.pub" 2>&1)" || { echo "$_pverify" | sed 's/^/wasmld: partition-verify: /' >&2 rm -f "$OUT" fatal "WASMLD_LIBC_SO: the PUBLISHED partition disagrees with the FINAL .so's name-section verdicts (lines above) — the link-time derivation-loss class the digest lockstep exists to catch. Refusing and removing the artifact (PLAN-DYNAMIC-LIBC §3.2)." } echo "wasmld: NOTE (WASMLD_LIBC_SO): $_pverify" >&2 # ── R6 EXPORT AUDIT on the FINAL BYTES (never-silent teeth; spike G2 # §4.1 R6 invariant + G1 R6). The 29 thread_local names (the pass's own # TLS-CENSUS set) and every __GI_ internal alias MUST stay UNexported: a # thread_local is TP-relative (its "global" export is a meaningless # offset a consumer would bind as a real address), and __GI_ is a # PLT-avoidance internal. They are hidden in the pool (so -shared never # auto-exports them) and excluded from the R6 promotion superset — but # assert it on the ARTIFACT, so a future visibility/promotion regression # refuses HERE rather than shipping a broken export to a live loader. _dyn_exp_names="$(node -e ' const fs = require("fs"); const m = new WebAssembly.Module(fs.readFileSync(process.argv[1])); for (const e of WebAssembly.Module.exports(m)) console.log(e.name); ' "$OUT" 2>&1)" || { echo "$_dyn_exp_names" >&2; rm -f "$OUT"; fatal "WASMLD_LIBC_SO: cannot read the final .so's export surface for the R6 thread_local/__GI_ absence audit. Refusing and removing the artifact."; } _dyn_tls_names_pl="$(sed -n 's/.*TLS-CENSUS tls_globals=[0-9]* names=\[\(.*\)\].*/\1/p' "$hwjs_err" | tr ' ' '\n' | { grep -E '^[A-Za-z_]' || true; } | LC_ALL=C sort -u)" _dyn_exp_sorted="$(printf '%s\n' "$_dyn_exp_names" | LC_ALL=C sort -u)" _dyn_exp_tls="$(LC_ALL=C comm -12 <(printf '%s\n' "$_dyn_exp_sorted") <(printf '%s\n' "$_dyn_tls_names_pl") 2>/dev/null)" _dyn_exp_gi="$(printf '%s\n' "$_dyn_exp_sorted" | grep -E '^__GI_' || true)" if [[ -n "$_dyn_exp_tls" || -n "$_dyn_exp_gi" ]]; then [[ -n "$_dyn_exp_tls" ]] && printf 'wasmld: EXPORTED thread_local: %s\n' $_dyn_exp_tls >&2 [[ -n "$_dyn_exp_gi" ]] && printf 'wasmld: EXPORTED __GI_ alias: %s\n' $_dyn_exp_gi >&2 rm -f "$OUT" fatal "WASMLD_LIBC_SO: the FINAL libc.so EXPORTS thread_local and/or __GI_ name(s) named above — neither is public ABI (a thread_local export is a meaningless TP-relative offset; __GI_ is an internal PLT-avoidance alias). This is the R6 never-silent gate on the artifact (spike G1/G2 R6). Refusing and removing." fi echo "wasmld: NOTE (WASMLD_LIBC_SO R6 audit): FINAL .so exports $(printf '%s\n' "$_dyn_exp_sorted" | grep -c .) name(s); 0 thread_local (of $(printf '%s\n' "$_dyn_tls_names_pl" | grep -c .) census names) and 0 __GI_ exported." >&2 # ── libc.so PRODUCER IMPORT-SURFACE AUDIT (S1 disposition closure; PLAN-DYNAMIC-LIBC §3.1). # Every import of the final .so must be EXPLAINED: (a) self-bound — the module also EXPORTS # the name (the F87 suspender-alias late-bound self-import + GOT self-resolution classes); # (b) hwjs runtime/registrar/sentinel ops (__hwjs_*/hwjs_* — module #0/main surface, R4/#45 # loader machinery); (c) the S2-designed late-bound main-side set (tls size/align/init + # canonical setter); (d) the raw `linux` syscall module; (e) the dylib spine (env memory/ # table/__stack_pointer/__memory_base/__table_base). ANY residue is a LINK FATAL — the known # class is the compiler-rt builtins (__addtf3 family) when WASMLD_HWJS_LIBC_BUILTINS_BC is # missing/incomplete; without this gate the residue surfaces as a module-#1 instantiate # LinkError on a LIVE machine (the never-silent doctrine's exec-time class). # WASMLD_LIBC_SO_IMPORT_ALLOW: space-separated names the closure's OWNER acknowledges as # deliberate late-bound imports (the toy fixtures' opaque-escape sinks). Same by-name # ceremony class as WASMLD_LIBC_SO_UNWIND_WAIVE — each use is a loud NOTE, never a default. if [[ -n "${WASMLD_LIBC_SO_IMPORT_ALLOW:-}" ]]; then echo "wasmld: NOTE (WASMLD_LIBC_SO_IMPORT_ALLOW): acknowledging deliberate late-bound import(s) [${WASMLD_LIBC_SO_IMPORT_ALLOW}] in the import-surface audit — the loader must bind each at module-#1 instantiate or refuse loudly." >&2 fi _iaudit="$(node -e ' const fs = require("fs"); const m = new WebAssembly.Module(fs.readFileSync(process.argv[1])); const ex = new Set(WebAssembly.Module.exports(m).map((e) => e.name)); /* __wasm32_set_stack_pointer joined the designed late-bound set with the * wasm32 __unmapself port (musl src/thread/wasm32/__unmapself.c, threads * step 6): the detached-thread self-exit switches to its static pad via * the crt/wasm_thread.S canonical sp setter — main-exported (a dce root * + thread-entry surface), same main-side late-bind class as the S2 TLS * setters; the loader defers its GOT.func slot and patches post-main. */ const s2 = new Set(["__wasm32_set_tls_base", "__wasm32_get_tls_size", "__wasm32_get_tls_align", "__wasm_init_tls", "__wasm32_set_stack_pointer"]); const allow = new Set((process.argv[2] || "").split(/\s+/).filter(Boolean)); const spine = new Set(["__stack_pointer", "__memory_base", "__table_base"]); const residue = []; let n = 0; for (const i of WebAssembly.Module.imports(m)) { n++; if (i.module === "linux") continue; if (i.module === "env" && (i.kind === "memory" || i.kind === "table")) continue; if (i.module === "env" && i.kind === "global" && spine.has(i.name)) continue; if (i.module !== "env" && i.module !== "GOT.func" && i.module !== "GOT.mem") { residue.push(`${i.module}.${i.name} (unknown import module)`); continue; } if (ex.has(i.name)) continue; if (/^(__hwjs_|hwjs_)/.test(i.name)) continue; /* GOT.func of a designed late-bound name is the same class as its * env twin: __unmapself ADDRESS-CHECKS the weak sp setter, so the * reference lowers to a GOT.func slot the loader defers and * patches post-main (worker.mjs libc deferGotFunc). */ if ((i.module === "env" || i.module === "GOT.func") && s2.has(i.name)) continue; if (allow.has(i.name)) continue; residue.push(`${i.module}.${i.name}`); } if (residue.length) { console.error(residue.join("\n")); process.exit(1); } console.log(`ok: ${n} imports audited (every one self-bound, hwjs-runtime, S2 late-bound, linux.syscall, dylib spine, or owner-acknowledged)`); ' "$OUT" "${WASMLD_LIBC_SO_IMPORT_ALLOW:-}" 2>&1)" || { echo "$_iaudit" | sed 's/^/wasmld: import-audit: /' >&2 rm -f "$OUT" fatal "WASMLD_LIBC_SO: the final .so carries UNEXPLAINED imports (lines above) — each would be an unresolvable binding at module-#1 instantiate on a live machine. Soft-float/wide-int names (__addtf3 class) mean the compiler-rt builtins TU is missing/incomplete: build it ('make -C sysroot builtins-pic') or point WASMLD_HWJS_LIBC_BUILTINS_BC at it. Refusing and removing the artifact (PLAN-DYNAMIC-LIBC §3.1/S1)." } echo "wasmld: NOTE (WASMLD_LIBC_SO): $_iaudit" >&2 cp -f "$WORK/partition.pub" "$WASMLD_LIBC_SO_PARTITION_OUT.tmp.$$" \ && mv -f "$WASMLD_LIBC_SO_PARTITION_OUT.tmp.$$" "$WASMLD_LIBC_SO_PARTITION_OUT" \ || fatal "WASMLD_LIBC_SO: failed to install the published partition at $WASMLD_LIBC_SO_PARTITION_OUT." echo "wasmld: NOTE (WASMLD_LIBC_SO): published suspender partition -> $WASMLD_LIBC_SO_PARTITION_OUT (sha256 $(sha256sum "$WASMLD_LIBC_SO_PARTITION_OUT" | cut -c1-16)…; digest lo64 stamped in the hwjs.abi v2 manifest)." >&2 fi # runtime-module producer: install the published rt partition (its Track # M record; nothing consumes it yet — the rt ops' suspend surface is # source-annotated at every consumer compile — but the record must # exist and travel with the artifact, never-silent). if [[ "$RT_SO" == "1" ]]; then # The runtime's partition is legitimately EMPTY (its ops publish no # partitioned exports — the manifest's digest_lo64 e3b0c44… empty-input # attestation): the pass then emits no file. Install the honest empty # record rather than refusing — the manifest still stamps the digest. [[ -f "$WORK/partition.pub" ]] || : > "$WORK/partition.pub" cp -f "$WORK/partition.pub" "$WASMLD_RT_SO_PARTITION_OUT.tmp.$$" \ && mv -f "$WASMLD_RT_SO_PARTITION_OUT.tmp.$$" "$WASMLD_RT_SO_PARTITION_OUT" \ || fatal "WASMLD_RT_SO: failed to install the published partition at $WASMLD_RT_SO_PARTITION_OUT." echo "wasmld: NOTE (WASMLD_RT_SO): published runtime suspender partition -> $WASMLD_RT_SO_PARTITION_OUT ($(wc -c < "$WASMLD_RT_SO_PARTITION_OUT") bytes; sha256 $(sha256sum "$WASMLD_RT_SO_PARTITION_OUT" | cut -c1-16)…)." >&2 fi else set +e link_coro "${LINK_FLAGS[@]}" "$WORK/merged.o" "${NATIVE_INPUTS[@]}" -o "$OUT" 2>"$finallink_err" link_rc=$? set -e if [[ $link_rc -ne 0 ]]; then needed_bytes="$(grep -oE 'initial memory too small, [0-9]+ bytes needed' "$finallink_err" | grep -oE '[0-9]+' | head -1 || true)" if [[ -n "$needed_bytes" && -z "${WASMLD_INITIAL_PAGES:-}" ]]; then auto_pages=$(( (needed_bytes + 65535) / 65536 + 16 )) if (( auto_pages > MAX_PAGES )); then cat "$finallink_err" >&2 fatal "static layout needs $needed_bytes bytes ($auto_pages pages incl. headroom), which exceeds --max-memory=$MAX_PAGES pages. Raise WASMLD_MAX_PAGES deliberately." fi echo "wasmld: NOTE (initial-memory auto-size): the default $INIT_PAGES pages is too small for this image's static layout ($needed_bytes bytes needed) — re-linking at $auto_pages pages. Pin WASMLD_INITIAL_PAGES to make the floor an explicit contract." >&2 INIT_PAGES=$auto_pages link_coro "${LINK_FLAGS[@]}" "$WORK/merged.o" "${NATIVE_INPUTS[@]}" -o "$OUT" \ || { link_rc=$?; fatal "final link failed again after initial-memory auto-size to $auto_pages pages (rc=$link_rc; error above)."; } else cat "$finallink_err" >&2 exit $link_rc fi else cat "$finallink_err" >&2 fi fi # IS_SHARED final-link split # ── (5) RESIDUAL-INTRINSIC witness (FINDING 15's hole, closed at the # link, 2026-06-11): --allow-undefined lets an UN-LOWERED llvm.* # intrinsic survive as a synthesized wasm import — the image links # silently and refuses to INSTANTIATE at run ("missing import # env.llvm.coro.suspend"). First hit: the F89 sigsetjmp lowering # leaking llvm.coro.suspend as an address-taken elem entry. Any llvm.* # import in the FINAL image is an escaped intrinsic = refuse loudly. # # Binaryen (wasm-dis/wasm-opt) is RETIRED with emsdk, so this is now an # emsdk-free, LLVM-only check that ACTUALLY RUNS against the final wasm: # llvm-nm reads the linked image and an un-lowered intrinsic surfaces as # an undefined `llvm.*` symbol (the synthesized import). A raw scan of # the binary for `llvm.`-prefixed import-name strings is the backstop # when llvm-nm is unavailable. The check runs UNCONDITIONALLY and is # LOUD — if no tool can perform it, that itself is a build refusal, # never a silent skip. residual="" if [[ -x "$HWJS_NM" ]]; then residual="$("$HWJS_NM" --undefined-only "$OUT" 2>/dev/null \ | awk '{print $NF}' | grep '^llvm\.' | sort -u | head -5 || true)" elif command -v grep >/dev/null 2>&1; then residual="$(grep -a -o 'llvm\.[A-Za-z0-9._]\+' "$OUT" 2>/dev/null \ | sort -u | head -5 || true)" else fatal "RESIDUAL-INTRINSIC witness cannot run: neither llvm-nm ('$HWJS_NM') nor grep is available to scan the final image for un-lowered llvm.* intrinsic imports. Refusing rather than silently skipping the check." fi [[ -z "$residual" ]] \ || fatal "final image carries residual UN-LOWERED intrinsic import(s): ${residual} — an escaped llvm.* intrinsic survived to the binary (it would fail at instantiate, far from its cause). Refusing." # ── (5b) PREEMPTIBILITY — the image property, checked against the FINAL BYTES ──────────────── # # WHAT THIS REPLACES. Preemptibility used to be a convention: a comment in the loader, a sentence # in six link recipes, and six function names typed into three distro Makefiles. Nothing compared # any of it to the image that came out. A recipe that forgot the knob produced an image that could # not be preempted and nothing said so; the loader's one check read a single bit that a # three-checkpoint image and a whole-program image set identically. # # THE PROPERTY, stated over the artifact rather than over the build: every function DEFINED in # this image either has no loop, or has only loops that provably terminate, provably yield, or # carry a budget checkpoint in a frame that can park — or is in the exempt surface (the manual # drive loop, and anything explicitly annotated native). The pass computes that over the merged # module and stamps the verdict into hwjs.abi flags (bit6 with bit2: none / scoped / # whole-program). This block reads the verdict back OFF THE FINAL BYTES — not off the log, not off # an env var — and decides whether it is the verdict this link was supposed to produce. # # BOTH SIDES ARE DERIVED, NEITHER IS DECLARED. The expectation comes from whether checkpoints were # armed for this link; the attestation comes from the artifact. There is no list of images, no list # of functions, and no per-recipe opt-in to keep in sync with anything. # # THE DEFAULT IS THE STRONG ONE. Checkpoints are on unless someone turned them off, so an ordinary # link is required to attest whole-program. HWJS_PREEMPTIBLE=none is the way to say otherwise, and # it is a declaration in the link that produces the image rather than a name in a table somewhere # else. It does not work on a lane-addressed link — see the second refusal. { _pm_want="${HWJS_PREEMPTIBLE:-}" if [[ -z "$_pm_want" ]]; then # No declaration: the demand follows the pass's own default. HWJS_CORO_SAFEPOINTS is # value-keyed in the pass (unset/1 = armed, 0 = disarmed); mirror that reading exactly, so the # driver and the pass can never disagree about what was asked for. case "${HWJS_CORO_SAFEPOINTS:-}" in 0) _pm_want=none ;; ""|1) _pm_want=whole-program ;; *) fatal "HWJS_CORO_SAFEPOINTS='${HWJS_CORO_SAFEPOINTS}' — value-keyed knob: 1 (armed), 0 (disarmed), or unset (armed, the default). Refusing to guess." ;; esac fi case "$_pm_want" in none|whole-program) ;; scoped) fatal "HWJS_PREEMPTIBLE=scoped is not a thing a link may ask for. 'scoped' is a MEASUREMENT — it is what an image attests when checkpoints are armed and some loop in it still has neither a checkpoint nor a proof. It promises a consumer nothing, so demanding it would be demanding nothing while looking like a requirement. Ask for whole-program (and fix whatever the PREEMPT-OPEN census names), or declare none." ;; *) fatal "HWJS_PREEMPTIBLE='$_pm_want' — accepted values are 'whole-program' (this image must be preemptible everywhere) and 'none' (this image is deliberately not preemptible; the reason belongs in the recipe next to the knob). Refusing to guess." ;; esac # Read the verdict off the artifact. The manifest is emitted by the ONE merged-module pass run # on every coro-arm link now, static mains included, so its ABSENCE here is itself a finding: it # means the plugin that compiled this module predates the attestation and cannot make the claim. if [[ "$BRINT_TOOL_INSTALLED" == 1 ]]; then _pm_flags="$("$BRINT_TOOL" abi "$OUT" flags 2>&1)" \ || { echo "$_pm_flags" >&2; rm -f "$OUT"; fatal "PREEMPTIBILITY: could not parse the final image's custom sections for the hwjs.abi manifest. Refusing and removing the artifact."; } else command -v node >/dev/null 2>&1 \ || fatal "PREEMPTIBILITY: node unavailable — cannot read the hwjs.abi manifest off the final image to check whether it is preemptible. Refusing rather than silently skipping the check." _pm_flags="$(node -e ' const fs = require("fs"); const b = fs.readFileSync(process.argv[1]); let p = 8, payload = null; const leb = () => { let r = 0, s = 0; for (;;) { const x = b[p++]; r |= (x & 127) << s; if (!(x & 128)) return r >>> 0; s += 7; } }; while (p < b.length) { const id = b[p++]; const sz = leb(); const end = p + sz; if (id === 0) { const nl = leb(); const nm = b.slice(p, p + nl).toString(); p += nl; if (nm === "hwjs.abi" && !payload) payload = b.slice(p, end); } p = end; } if (!payload) { console.log("ABSENT"); process.exit(0); } let q = 0; const f = []; const rd = () => { let r = 0, s = 0; for (;;) { const x = payload[q++]; r |= (x & 127) << s; if (!(x & 128)) return r >>> 0; s += 7; } }; while (q < payload.length) f.push(rd()); console.log(f.length >= 8 ? String(f[7]) : "MALFORMED"); ' "$OUT" 2>&1)" || { echo "$_pm_flags" >&2; rm -f "$OUT"; fatal "PREEMPTIBILITY: could not parse the final image's custom sections for the hwjs.abi manifest. Refusing and removing the artifact."; } fi case "$_pm_flags" in ABSENT) rm -f "$OUT" fatal "PREEMPTIBILITY: the final image carries NO hwjs.abi manifest, so it makes no claim about whether it can be preempted. Every coro-arm wasmld link runs the merged-module pass exactly once and that run emits the manifest — its absence means the loaded plugin predates the attestation. Rebuild it (toolchain/build.sh). Refusing and removing the artifact rather than shipping an image whose preemptibility nothing can state." ;; MALFORMED) rm -f "$OUT" fatal "PREEMPTIBILITY: the final image's hwjs.abi manifest has fewer than 8 fields — flags is field 8 and is not there. A truncated or foreign record reached the artifact. Refusing and removing it." ;; ''|*[!0-9]*) rm -f "$OUT" fatal "PREEMPTIBILITY: unexpected hwjs.abi flags read '$_pm_flags' off the final image. Refusing and removing the artifact." ;; esac _pm_got=$(( (_pm_flags & 64) != 0 ? 2 : ((_pm_flags & 4) != 0 ? 1 : 0) )) _pm_got_name=none; [[ $_pm_got == 1 ]] && _pm_got_name=scoped; [[ $_pm_got == 2 ]] && _pm_got_name=whole-program _pm_open="$(grep -m20 '\[hwjs-coro\] PREEMPT-OPEN ' "$hwjs_err" 2>/dev/null || true)" _pm_census="$(grep -m1 '\[hwjs-coro\] PREEMPT-CENSUS ' "$hwjs_err" 2>/dev/null || true)" # REFUSAL 1 — this link was supposed to produce a preemptible image and did not. if [[ "$_pm_want" == "whole-program" && $_pm_got != 2 ]]; then [[ -n "$_pm_census" ]] && echo "$_pm_census" >&2 [[ -n "$_pm_open" ]] && echo "$_pm_open" >&2 rm -f "$OUT" fatal "PREEMPTIBILITY: this link asked for a preemptible image and produced one that attests preemptibility=${_pm_got_name}. The functions listed above (PREEMPT-OPEN) each carry at least one loop with neither a budget checkpoint nor a proof that it yields; a task inside one of them reaches no slice boundary, which means (1) a fork quiesce over its lanes cannot complete and fork() returns EAGAIN on a deadline — a failure no Linux produces — and (2) a pending signal is never dispatched, because delivery happens at a checkpoint breath, so ^C on a spinning guest does nothing. Fix the named functions, or state the exemption where the image is built with HWJS_PREEMPTIBLE=none and say why next to it. Refusing and removing the artifact." fi # REFUSAL 2 — a lane-addressed image is held to whole-program whatever the link declares. # A declaration is a statement about ONE image; lane addressing is a statement about how it # shares a machine with its own siblings. A lane executing a syscall-free loop holds the whole # process at the quiesce gate, so "I meant to do that" is not an available answer here. if [[ "${HWJS_LANE_ADDR:-0}" == "1" && $_pm_got != 2 ]]; then [[ -n "$_pm_census" ]] && echo "$_pm_census" >&2 [[ -n "$_pm_open" ]] && echo "$_pm_open" >&2 rm -f "$OUT" fatal "PREEMPTIBILITY: this link is LANE-ADDRESSED (HWJS_RT_LANES=${HWJS_RT_LANES:-}) but the image attests preemptibility=${_pm_got_name}. Sibling lanes run concurrently in one process: a lane inside a syscall-free unbounded loop never reaches a slice boundary, so an all-lanes fork quiesce cannot complete and every other lane waits out the deadline, and no lane's pending signals are dispatched. HWJS_PREEMPTIBLE=none does NOT waive this — it is a statement about one image, and this is a property of how that image shares a machine. Arm checkpoints and clear the PREEMPT-OPEN census above. Refusing and removing the artifact." fi # REFUSAL 3 — the one class the pass cannot see: functions that arrive at the final link ALREADY # NATIVE. They never enter the merged module, so no analysis in the pass has an opinion about # them, and the verdict above quantifies over a set that does not contain them. wasm has # structured control flow, so this is decidable and cheap rather than an analysis: the only # backward branch in wasm is a branch to a `loop` label, so "this native input can hold the CPU" # is a scan of its code section for the opcode. # # Intersected with the final image's own defined symbols, because an archive member that lld # never pulled is not in the artifact and the property quantifies over the artifact. That is the # same shape as the native-stage §8b intersection above — both sides computed from artifacts. # # Not a list and not a waiver: the day someone adds a loop to the hand-written wasm thread # trampoline, this link fails. Today it is green because the only native-stage function with a # loop anywhere in the tree is compiler-rt's __udivmodti4 (bit-width bounded, and not pulled into # any shipping image); if it ever is pulled, the answer is to build compiler-rt as bitcode for # main links, which already exists (`make -C sysroot builtins-pic`) and is already used on # the libc.so arm. if [[ "$_pm_want" == "whole-program" && ${#NATIVE_INPUTS[@]} -gt 0 ]]; then [[ -x "$HWJS_OBJDUMP" ]] \ || fatal "PREEMPTIBILITY: llvm-objdump ('$HWJS_OBJDUMP') is not executable, so the native-stage inputs [${NATIVE_INPUTS[*]}] cannot be scanned for unpreemptible loops. A native-stage input never reaches the coroutinize pass, so nothing else in this build has an opinion about it. Refusing rather than silently skipping the check (point WASMLD_HWJS_OBJDUMP at the pinned llvm-objdump)." _pm_defined="$("$HWJS_NM" --defined-only "$OUT" 2>/dev/null | awk '{print $NF}' | LC_ALL=C sort -u)" [[ -n "$_pm_defined" ]] \ || fatal "PREEMPTIBILITY: llvm-nm read no defined symbols from the final image, so the native-stage loop scan cannot be intersected with what actually landed in it. Refusing rather than scanning nothing and passing." _pm_loopy="" for _nat in "${NATIVE_INPUTS[@]}"; do _nat_loops="$("$HWJS_OBJDUMP" -d "$_nat" 2>/dev/null \ | awk '/^[0-9a-f]+ <.*>:/ { fn = $2; gsub(/[<>:]/, "", fn) } /[[:space:]]loop[[:space:]]/ { if (fn != "") print fn }' \ | LC_ALL=C sort -u || true)" [[ -z "$_nat_loops" ]] && continue while IFS= read -r _lf; do [[ -z "$_lf" ]] && continue grep -qxF "$_lf" <<<"$_pm_defined" && _pm_loopy+=" $_nat -> $_lf"$'\n' done <<<"$_nat_loops" done echo "wasmld: NOTE (PREEMPTIBILITY): scanned ${#NATIVE_INPUTS[@]} native-stage input(s) for wasm \`loop\` opcodes reaching the final image — $( [[ -z "$_pm_loopy" ]] && echo "none" || echo "$(grep -c . <<<"$_pm_loopy") found" ). The pass never sees these, so nothing else in this build has an opinion about them." >&2 if [[ -n "$_pm_loopy" ]]; then printf '%s' "$_pm_loopy" >&2 rm -f "$OUT" fatal "PREEMPTIBILITY: this image attests preemptibility=whole-program, but the function(s) listed above arrived as NATIVE-STAGE link inputs and contain a wasm \`loop\`. A native-stage input never reaches the HwjsCoroutinize pass: its back-edges carry no budget checkpoint and its frames have nowhere to park, so the whole-program verdict — which quantifies over the merged module — does not cover them, and the image's claim is wider than its evidence. Rebuild the input as BITCODE so the single merged-module pass run covers it (compiler-rt already has this arm: 'make -C sysroot builtins-pic' -> builtins-wasm32-pic.bc, consumed at the libc.so link), or take it out of the link. Refusing and removing the artifact." fi fi echo "wasmld: NOTE (PREEMPTIBILITY): image attests preemptibility=${_pm_got_name}; this link required ${_pm_want}. ${_pm_census:-}" >&2 } # ── (6) PHANTOM-IMPORT witness (ticket #4). --allow-undefined (which the # coro link NEEDS: linux.syscall / hwjs_suspend.park are genuine imports) # also lets ANY undefined symbol become a synthesized env.* function # import that traps at first call — so every autoconf AC_CHECK_FUNCS # link-probe answered "yes" and bash shipped importing env.tputs/tgetent # because configure's termcap search "passed" against no library. A # STATIC whole-program image has a closed sanctioned surface — linux.*, # hwjs_suspend.*, env.user_memory — and anything else is a phantom: # refuse it AT THE LINK, naming every symbol, not at the first call in a # booted machine. A dlopen-capable DYNAMIC MAIN (WASMLD_DYNAMIC_MAIN/ # _MODULE) additionally imports ONLY env.__indirect_function_table — its # env FUNCTION surface is exactly as closed as a static image's, because # the host instantiates a MAIN against the bare kernel ABI (worker.mjs # filterImports binds env fns only for dlopen'd .so loads). The retired # blanket "dynamic" mode here sanctioned every env fn import for the # DYNAMIC_MAIN glibc arm, so the 2026-07-05 pool restructure's dropped # nscd client stubs shipped as env.__nscd_* imports that failed bash//init # instantiate on the live glibc machine (superproject issue #27) — the # exact trap this witness exists to refuse. Only an actual -shared side # module (open-world by design: the host binds its env fn imports from # the main image / kernel registry; unresolved ones hit the E8 registry # refuse at load — that world's loud path) gets the open env fn surface. # WASMLD_ALLOW_ENV_IMPORTS # (comma-separated names) is the explicit, loudly-NOTEd escape hatch for # a deliberate extra host binding. The witness needs node (already a # toolchain dependency: libexec/brintrun, the pass harness), or brint-tool on # an installed toolchain; absence is a refusal, never a silent skip. if [[ "$BRINT_TOOL_INSTALLED" == 1 ]]; then IMPORT_WITNESS=( "$BRINT_TOOL" import-witness ) else IMPORT_WITNESS=( "$HWJS_HOME/libexec/wasm-import-witness.mjs" ) [[ -f "${IMPORT_WITNESS[0]}" ]] \ || fatal "PHANTOM-IMPORT witness missing at ${IMPORT_WITNESS[0]} — cannot attest the final image's import surface. Refusing rather than silently skipping." command -v node >/dev/null 2>&1 \ || fatal "PHANTOM-IMPORT witness needs node (a toolchain dependency; libexec/brintrun and the pass harness already require it) — cannot attest the final image's import surface. Refusing rather than silently skipping." IMPORT_WITNESS=( node "${IMPORT_WITNESS[@]}" ) fi witness_mode=static [[ "$DYNAMIC_MAIN" != "0" || "$DYNAMIC_MODULE" != "0" ]] && witness_mode=dynamic-main for _wa in "${args[@]}"; do [[ "$_wa" == "-shared" || "$_wa" == "--shared" ]] && witness_mode=dynamic done witness_args=( "$OUT" "--mode=$witness_mode" ) # PLAN-HWJS-RT-PSABI R4: a runtime-import dynamic main (WASMLD_RT_IMPORT=1, # the wasmcc arm that excludes hwjs_user_rt from the merge) may import # exactly the kernel-provided runtime's callable surface — the witness's # grown-by-name allowlist, never a blanket allow. if [[ "${WASMLD_RT_IMPORT:-0}" == "1" && "$witness_mode" == "dynamic-main" ]]; then echo "wasmld: NOTE (WASMLD_RT_IMPORT): sanctioning the kernel-provided runtime's callable surface as main imports (PLAN-HWJS-RT-PSABI R4; loader binds module #0)." >&2 witness_args+=( "--rt-import" ) fi # Dynamic-libc consumer (PLAN-DYNAMIC-LIBC): sanction env. BY NAME from libc.so's fn-export # list and GOT.mem./GOT.func. BY NAME from its data/fn-export lists — all DERIVED by the # witness from the built artifact. This is a SOUNDNESS gate, not hygiene: under import-dynamic a # misspelled extern datum links rc=0 as a plausible GOT.mem import (RED-1's silent NULL bind class # made a BUILD failure here, never an exec-time LinkError). The partition file additionally gates # every libc-bound env fn import: absent-from-partition = link fatal, never an orphan. if [[ "$LIBC_IMPORT" == "1" ]]; then echo "wasmld: NOTE (WASMLD_LIBC_IMPORT): sanctioning libc.so's derived export surface (env fns + GOT.mem/GOT.func by name) + requiring every libc-bound import in the published partition (PLAN-DYNAMIC-LIBC)." >&2 # WORK snapshots, not the staged paths (TOCTOU close): the witness judges the same # immutable pair the pass consumed and the post-link checks hash. witness_args+=( "--libc-import" "--libc-so=$LIBC_SO_SNAPSHOT" "--suspenders-partition=$LIBC_PARTITION_CONSUMED" ) # ...and the needed libraries' surfaces. Without these the by-name soundness gate # (S5 change (i)) refuses every import a package library answers, so the gate would # have to be dropped wholesale to link a needed-main — turning a soundness check into # an off switch. Extending it per artifact keeps the check, and keeps it derived. for _nsnap in ${NEEDED_SO_SNAPSHOTS[@]+"${NEEDED_SO_SNAPSHOTS[@]}"}; do witness_args+=( "--needed-so=$_nsnap" ) done fi if [[ -n "${WASMLD_ALLOW_ENV_IMPORTS:-}" ]]; then echo "wasmld: NOTE (WASMLD_ALLOW_ENV_IMPORTS): sanctioning extra env function imports [${WASMLD_ALLOW_ENV_IMPORTS}] — a deliberate host binding, acknowledged on the link line." >&2 witness_args+=( "--allow-env=$WASMLD_ALLOW_ENV_IMPORTS" ) fi set +e phantom="$("${IMPORT_WITNESS[@]}" "${witness_args[@]}" 2>&1)" witness_rc=$? set -e if [[ $witness_rc -eq 1 ]]; then echo "$phantom" | sed 's/^/wasmld: phantom import: /' >&2 rm -f "$OUT" # never leave a trapping-at-first-call artifact for a later stale use fatal "final image imports symbol(s) outside the sanctioned surface (lines above) — undefined symbols were synthesized into env.* imports that TRAP at first call (--allow-undefined's dark side; the AC_CHECK_FUNCS false-yes). The symbol is misspelled, the providing library is missing from the link line, or a genuinely-host-bound import needs WASMLD_ALLOW_ENV_IMPORTS= acknowledged explicitly. Refusing at the link, not at the first call." elif [[ $witness_rc -ne 0 ]]; then echo "$phantom" >&2 rm -f "$OUT" fatal "PHANTOM-IMPORT witness failed to attest the image (rc=$witness_rc, output above) — an unverifiable import surface is a refusal, never a silent pass." fi # ── Dynamic-libc consumer post-link (WASMLD_LIBC_IMPORT=1; PLAN-DYNAMIC-LIBC §3.2/§3.4). if [[ "$LIBC_IMPORT" == "1" ]]; then # (a) D2 DIGEST LOCKSTEP: the partition file this main CONSUMED must be the one the staged # libc.so PUBLISHED — libc.so's hwjs.abi v2 field 9 records sha256-lo64 of its published # partition bytes; a mismatch is the partition-N-vs-libc.so-M skew class, refused at LINK here # (the loader repeats the refusal at LOAD via the digests both artifacts carry — L2). # TOCTOU-CLOSED (2026-07-16): every hash below is taken over the WORK SNAPSHOTS captured # before the pass ran (the same bytes the pass consumed), and the CONSUMED digest is # additionally READ BACK off $OUT's own hwjs.abi v2 field 9 (stamped by the pass at # merged-codegen time) and required to equal sha256-lo64(snapshot partition) — so a # producer rebuild swapping the staged pair mid-link can neither pass the lockstep nor # skew what the needed-list records. _part_sha="$(sha256sum "$LIBC_PARTITION_CONSUMED" | awk '{print $1}')" _libc_digest="$(node -e ' const fs = require("fs"); const b = fs.readFileSync(process.argv[1]); let p = 8, payload = null; const leb = () => { let r = 0, s = 0; for (;;) { const x = b[p++]; r |= (x & 127) << s; if (!(x & 128)) return r >>> 0; s += 7; } }; while (p < b.length) { const id = b[p++]; const sz = leb(); const end = p + sz; if (id === 0) { const nl = leb(); const name = b.slice(p, p + nl).toString(); p += nl; if (name === "hwjs.abi" && !payload) payload = b.slice(p, end); } p = end; } if (!payload) { console.error("no hwjs.abi manifest in libc.so"); process.exit(1); } let q = 0; const fields = []; while (q < payload.length) { let r = 0n, s = 0n; for (;;) { if (q >= payload.length) { console.error("truncated LEB in hwjs.abi"); process.exit(1); } const x = BigInt(payload[q++]); r |= (x & 127n) << s; if (!(x & 128n)) break; s += 7n; } fields.push(r); } if (fields.length !== 10 || fields[0] !== 2n) { console.error(`libc.so manifest is not v2/10-field (version=${fields[0] ?? "?"} nfields=${fields.length}) — not an libc.so producer artifact`); process.exit(1); } console.log(fields[8].toString(16).padStart(16, "0")); ' "$LIBC_SO_SNAPSHOT" 2>&1)" || { echo "$_libc_digest" >&2 rm -f "$OUT" fatal "WASMLD_LIBC_IMPORT: cannot read the suspender-partition digest off '$WASMLD_LIBC_SO_PATH' (WORK snapshot; output above) — an artifact without the D2 stamp is not a consumable libc.so (built through WASMLD_LIBC_SO=1?)." } if [[ "${_part_sha:0:16}" != "$_libc_digest" ]]; then rm -f "$OUT" fatal "WASMLD_LIBC_IMPORT: DIGEST SKEW — the consumed partition file '$WASMLD_HWJS_XMOD_SUSPENDERS' (sha256 ${_part_sha:0:16}…) is NOT the partition libc.so '$WASMLD_LIBC_SO_PATH' published (manifest records ${_libc_digest}…). A main linked against partition N running against libc.so M is exactly the skew class the never-silent doctrine exists for (PLAN-DYNAMIC-LIBC §3.2). Regenerate/relink against the matching pair." fi # (a2) CONSUMED-DIGEST READBACK: $OUT's OWN hwjs.abi v2 field 9 was stamped BY THE PASS with # sha256-lo64 of the partition bytes it ACTUALLY CONSUMED at merged-codegen time # (CoroutinizePass PartitionConsume → xmodPartition().VerdictDigestLo64). Requiring it to equal the # snapshot's hash proves end-to-end that the pass-consumed bytes ARE the bytes the lockstep # above judged and the needed-list below stamps — the authoritative consumed value, immune to # any staged-path swap (and a tripwire on snapshot-plumbing bugs). A mismatch is a stale-file/ # plumbing refusal, never installable. _consumed_digest="$(node -e ' const fs = require("fs"); const b = fs.readFileSync(process.argv[1]); let p = 8, payload = null; const leb = () => { let r = 0, s = 0; for (;;) { const x = b[p++]; r |= (x & 127) << s; if (!(x & 128)) return r >>> 0; s += 7; } }; while (p < b.length) { const id = b[p++]; const sz = leb(); const end = p + sz; if (id === 0) { const nl = leb(); const name = b.slice(p, p + nl).toString(); p += nl; if (name === "hwjs.abi" && !payload) payload = b.slice(p, end); } p = end; } if (!payload) { console.error("no hwjs.abi manifest on the dynamic-libc main"); process.exit(1); } let q = 0; const fields = []; while (q < payload.length) { let r = 0n, s = 0n; for (;;) { if (q >= payload.length) { console.error("truncated LEB in hwjs.abi"); process.exit(1); } const x = BigInt(payload[q++]); r |= (x & 127n) << s; if (!(x & 128n)) break; s += 7n; } fields.push(r); } if (fields.length !== 10 || fields[0] !== 2n) { console.error(`main manifest is not v2/10-field (version=${fields[0] ?? "?"} nfields=${fields.length}) — the dynamic-libc consumer pass run did not stamp the consumed digest`); process.exit(1); } console.log(fields[8].toString(16).padStart(16, "0")); ' "$OUT" 2>&1)" || { echo "$_consumed_digest" >&2 rm -f "$OUT" fatal "WASMLD_LIBC_IMPORT: cannot read the CONSUMED partition digest off the main's own hwjs.abi manifest (output above) — without the readback the digest lockstep cannot attest what the pass actually consumed (PLAN-DYNAMIC-LIBC §3.2)." } if [[ "${_part_sha:0:16}" != "$_consumed_digest" ]]; then rm -f "$OUT" fatal "WASMLD_LIBC_IMPORT: CONSUMED-DIGEST MISMATCH — the main's own hwjs.abi records the pass consumed partition bytes with sha256-lo64 ${_consumed_digest}…, but the snapshot this link hashes/stamps is ${_part_sha:0:16}… (a mid-link partition swap or snapshot-plumbing bug). The digest lockstep would otherwise certify verdicts the pass never saw. Refusing." fi # (a3) TRACK M CHECK-EMITTING LOCKSTEP (PLAN-CXX-STATUS-CHANNEL §7; I3's link-time half — # the loader repeats it at exec/dlopen). Both artifacts' hwjs.abi flags bit4 (check_emitting) # must AGREE: a check-emitting main over a non-check-emitting libc.so (or the converse) lets # a status-channel raise transit uninstrumented frames SILENTLY — the §9 R1 miscompile class. # Absent bit (legacy v1/v2 artifact) reads 0, which mixes identically to a deliberate # non-check-emitting build — refuse both, naming both sides and both bit values. _ce_pair="$(node -e ' const fs = require("fs"); const flagsBit4 = (file) => { const b = fs.readFileSync(file); let p = 8, payload = null; const leb = () => { let r = 0, s = 0; for (;;) { const x = b[p++]; r |= (x & 127) << s; if (!(x & 128)) return r >>> 0; s += 7; } }; while (p < b.length) { const id = b[p++]; const sz = leb(); const end = p + sz; if (id === 0) { const nl = leb(); const name = b.slice(p, p + nl).toString(); p += nl; if (name === "hwjs.abi" && !payload) payload = b.slice(p, end); } p = end; } if (!payload) { console.error(`no hwjs.abi manifest in ${file}`); process.exit(1); } let q = 0; const fields = []; while (q < payload.length) { let r = 0n, s = 0n; for (;;) { if (q >= payload.length) { console.error(`truncated LEB in ${file} hwjs.abi`); process.exit(1); } const x = BigInt(payload[q++]); r |= (x & 127n) << s; if (!(x & 128n)) break; s += 7n; } fields.push(r); } if (fields.length < 8) { console.error(`hwjs.abi in ${file} has ${fields.length} fields — no flags field to judge`); process.exit(1); } return `${Number((fields[7] >> 4n) & 1n)},${Number((fields[7] >> 5n) & 1n)}`; }; console.log(`${flagsBit4(process.argv[1])} ${flagsBit4(process.argv[2])}`); ' "$OUT" "$LIBC_SO_SNAPSHOT" 2>&1)" || { echo "$_ce_pair" >&2 rm -f "$OUT" fatal "WASMLD_LIBC_IMPORT: cannot read the check_emitting/lane_addressed flags (hwjs.abi flags bits 4/5) off the main and/or libc.so (output above) — without them the Track M / lane mixing refusals cannot be decided (PLAN-CXX-STATUS-CHANNEL §7; PLAN-THREADS-SINGLE-MEMORY §4 step 2). Refusing." } read -r _main_bits _libc_bits <<<"$_ce_pair" _main_ce="${_main_bits%,*}"; _main_la="${_main_bits#*,}" _libc_ce="${_libc_bits%,*}"; _libc_la="${_libc_bits#*,}" if [[ "$_main_ce" != "$_libc_ce" ]]; then rm -f "$OUT" fatal "WASMLD_LIBC_IMPORT: CHECK-EMITTING MISMATCH — the main '$OUT' attests check_emitting=$_main_ce but libc.so '$WASMLD_LIBC_SO_PATH' attests check_emitting=$_libc_ce (hwjs.abi flags bit4). Mixing check-emitting and non-check-emitting modules lets a status-channel throw/longjmp transit an UNINSTRUMENTED frame silently — the I3 silent-transit miscompile (PLAN-CXX-STATUS-CHANNEL §7/§9 R1). Rebuild the pair in lockstep: both with HWJS_STATUS_CHANNEL=1, or neither. Refusing and removing the artifact." fi # (a3′) LANE-ADDRESSED LOCKSTEP (PLAN-THREADS-SINGLE-MEMORY §4 step 2; # I3-2's link-time half — the loader repeats it at exec/dlopen). Both # artifacts' hwjs.abi flags bit5 (lane_addressed) must AGREE: a # lane-unaware module reading absolute __hwjs_status_channel inside a # laned process reads LANE 0 from ANY lane — silent transit of every # non-NORMAL outcome raised on lanes >= 1. Absent bit (legacy artifact) # reads 0, which mixes identically to a deliberate single-lane build — # refuse both directions, naming both sides and both bit values. if [[ "$_main_la" != "$_libc_la" ]]; then rm -f "$OUT" fatal "WASMLD_LIBC_IMPORT: LANE-ADDRESSED MISMATCH — the main '$OUT' attests lane_addressed=$_main_la but libc.so '$WASMLD_LIBC_SO_PATH' attests lane_addressed=$_libc_la (hwjs.abi flags bit5). Mixing a lane-addressed module with a lane-unaware one lets the lane-unaware side read LANE 0's status word from ANY lane — silent transit under threads (I3-2; PLAN-THREADS-SINGLE-MEMORY §4 step 2). Rebuild the pair in lockstep: both with HWJS_RT_LANES>1, or neither. Refusing and removing the artifact." fi # (b) NEEDED-LIST carrier (D4): ONE carrier — a post-link custom section on the main (the # wasm.linux.exec appending precedent, binfmt.rst:190-195), naming the digest-qualified libc.so # plus the consumed-partition sha256, both hashed over the WORK SNAPSHOTS (the pair this main # was actually linked against — content-addressed staging then binds the loader to the same # generation). The lld-emitted dylink.0 (MEM_INFO, an import-dynamic side effect) is TOLERATED # but its NEEDED subsection is deliberately unused (two carriers invite skew — the §3.4 # single-carrier rule). Kernel binfmt/exec-header tolerance of dylink.0-on-mains is an L1 gate # (PLAN-DYNAMIC-LIBC §3.4), pinned by test/m5-libc-so-roundtrip.sh GREEN-5: the REAL kernel parser # (arch/wasm32/kernel/wasm_module.c, the wasm32_user_exec_load/-exec_image validation path) # compiled host-side accepts a dynamic-libc main carrying dylink.0 + THIS appended section; the # loader-side (module-#1 graph walk) half stays L2. _libc_sha="$(sha256sum "$LIBC_SO_SNAPSHOT" | awk '{print $1}')" # libc.so FIRST — the loader instantiates in list order and module #1 is libc by # construction; the rest follow in link order. The suspenders digest stays a # top-level scalar because it is libc.so's alone: no other library publishes one # (a per-library partition is its own milestone; see the design record). _needed_json="\"libc.so.$_libc_sha\"" for _nn in ${NEEDED_SO_NAMES[@]+"${NEEDED_SO_NAMES[@]}"}; do _needed_json="$_needed_json,\"$_nn\"" done node -e ' const fs = require("fs"); const [file, name, payloadStr] = process.argv.slice(1); const nameB = Buffer.from(name), payloadB = Buffer.from(payloadStr); const leb = (n) => { const out = []; do { let b = n & 127; n >>>= 7; if (n) b |= 128; out.push(b); } while (n); return Buffer.from(out); }; const body = Buffer.concat([leb(nameB.length), nameB, payloadB]); fs.appendFileSync(file, Buffer.concat([Buffer.from([0]), leb(body.length), body])); ' "$OUT" "wasm.linux.needed" "{\"needed\":[$_needed_json],\"suspenders_sha256\":\"$_part_sha\"}" \ || { rm -f "$OUT"; fatal "WASMLD_LIBC_IMPORT: failed to append the wasm.linux.needed custom section — a needed-list-less dynamic main cannot be loaded (D4)."; } echo "wasmld: NOTE (WASMLD_LIBC_IMPORT): needed-list appended (wasm.linux.needed: libc.so.${_libc_sha:0:16}…${NEEDED_SO_NAMES[0]+ + ${#NEEDED_SO_NAMES[@]} package library/libraries: ${NEEDED_SO_NAMES[*]}}; suspenders sha256 ${_part_sha:0:16}…) — the ONE carrier (dylink.0 NEEDED deliberately unused, §3.4)." >&2 # (c) install the §3.5 owner-audit sidecar next to the artifact (the loader's lookup input). cp -f "$WORK/export-owners" "$OUT.export-owners" 2>/dev/null \ || fatal "WASMLD_LIBC_IMPORT: owner-audit sidecar missing — the audit did not run?" # (d) WEAK-UNDEFINED FUNCTION gate (2026-07-16; the S5 (vi) weak-disposition rule made # complete). The pinned lld's handleWeakUndefines (lld/wasm/SymbolTable.cpp:1163, called at # Driver.cpp:1700-1704) is gated ONLY on !ctx.isPic — which the dynamic-libc fixed-layout non-PIE main # IS — and is NOT disabled by --unresolved-symbols=import-dynamic: every weak-undefined # FUNCTION reference is silently bound to a hidden null-address `unreachable` stub, NO import # materializes, and the witness/partition/owner audits are structurally blind to it. ELF # semantics would bind the ref from the needed libc.so at load; here `if (weak_fn)` silently # skips and a direct call traps — the RED-1 silent-wrong-binding class on ordinary inputs # (libstdc++/libgcc __pthread_key_create-style probes; the rt's own weak `freea` delegation # is the S5 freea-phantom's provenance, same lld gating). Weak DATA is fine: its GOT.mem # import DOES materialize and the by-name witness judges it. Gate: any weak-undefined fn in # the merged closure that libc.so EXPORTS (a loader-bindable ref lld just nulled) is FATAL # unless acknowledged by name in WASMLD_ALLOW_NULL_WEAK; a name libc.so does NOT export # passes with a loud NOTE (genuinely-null weak probe — the ELF-equivalent outcome). _weak_fn_undefs="$(run_llvm_bulk "$HWJS_NM" "$WORK/merged.o" ${NATIVE_INPUTS[@]+"${NATIVE_INPUTS[@]}"} 2>/dev/null \ | awk '$1 == "w" { print $NF }' | sort -u)" if [[ -n "$_weak_fn_undefs" ]]; then _out_bound="$(node -e ' const fs = require("fs"); const m = new WebAssembly.Module(fs.readFileSync(process.argv[1])); for (const i of WebAssembly.Module.imports(m)) if (i.module === "env" || i.module === "GOT.func" || i.module === "GOT.mem") console.log(i.name); ' "$OUT" 2>&1)" || { echo "$_out_bound" >&2; rm -f "$OUT"; fatal "WASMLD_LIBC_IMPORT: cannot enumerate the final image's imports for the weak-undefined gate. Refusing rather than silently skipping."; } _null_weak_allow=" $(tr ',' ' ' <<<"${WASMLD_ALLOW_NULL_WEAK:-}") " while IFS= read -r _wsym; do [[ -z "$_wsym" ]] && continue # materialized as a real import (env fn / GOT.func / GOT.mem) — the loader binds it; judged by the witness above. grep -qx "$_wsym" <<<"$_out_bound" && continue if grep -qE "^function ${_wsym}$" <<<"$_libc_exports"; then if [[ "$_null_weak_allow" == *" $_wsym "* ]]; then echo "wasmld: NOTE (WASMLD_ALLOW_NULL_WEAK): weak-undefined fn '$_wsym' IS exported by libc.so but its null-stub binding is explicitly acknowledged — 'if (${_wsym})' stays false in this main by deliberate choice." >&2 else rm -f "$OUT" fatal "WEAK-UNDEFINED fn '$_wsym' was silently bound to a NULL-address unreachable stub by lld (handleWeakUndefines runs on every non-PIE link, import-dynamic notwithstanding) — but libc.so EXPORTS '$_wsym', so the loader COULD bind it and ELF semantics WOULD: 'if ($_wsym)' silently skips and a direct call traps unreachable at run time (the RED-1 silent-wrong-binding class). Acknowledge the null binding explicitly with WASMLD_ALLOW_NULL_WEAK=$_wsym, or make the reference strong so it imports (PLAN-DYNAMIC-LIBC §3.4 (vi))." fi else echo "wasmld: NOTE (weak-undefined): fn '$_wsym' null-stubbed by lld; libc.so does not export it — the genuinely-null weak-probe outcome (ELF-equivalent). On the record, never silent." >&2 fi done <<<"$_weak_fn_undefs" fi fi # The §3.5 owner-audit sidecar for the RT-import-only arm (the libc.so-consumer arm installs its # own copy above, inside the needed-list/digest block it belongs to). The table is the loader's # lookup input: it says which of the process ABI's force-exports this image owns and which the # loader must find on another module. An image whose surface was audited must ship it, or the # loader is back to guessing which module answers a lookup. if [[ "$EXPORT_AUDIT_RAN" == "1" && "$LIBC_IMPORT" != "1" && -f "$OUT" ]]; then cp -f "$WORK/export-owners" "$OUT.export-owners" \ || fatal "owner-audit sidecar: cannot install '$OUT.export-owners' — the audit ran but its table cannot travel with the artifact, so a loader lookup would have to guess the owning module." echo "wasmld: NOTE (owner audit): sidecar installed -> $OUT.export-owners" >&2 fi # ── (8) XMOD CONSULT EXPORT ────────────────────────────────────────────── # # BRINTOS-WORKAROUND: linux-in-the-browser#199 remove — the released frame binds the kernel-embedded runtime's env.__hwjs_xmod_dispatch_check from the main's exports, and a main only imports it. # # The pass emits the consult as an env function import and nothing defines # it, so the §3.5 audit cannot put it on the surface: there is no definition # to export. The helper appends a one-call thunk that forwards to the import # and exports that, so the runtime reaches the host function the main itself # was bound to. The thunk rather than an export of the import's own funcidx: # llvm-nm crashes on a function export with no body, and recipes read mains # with it. Without the export the runtime's first call, in libc's # constructors, aborts the process before main. # # Mains only. The frame reads a side module's exports for none of this, and # a -shared output keeps its bytes: libc.so and the package libraries are # cited by the digest of exactly those bytes. A main that does not import the # consult is left byte-identical; the (6) witness already refuses the import # on a static main. if [[ $IS_SHARED != 1 ]]; then set +e if [[ "$BRINT_TOOL_INSTALLED" == 1 ]]; then _consult="$("$BRINT_TOOL" export-consult "$OUT" 2>&1)" else _consult="$(node "$HWJS_HOME/libexec/wasm-export-consult.mjs" "$OUT" 2>&1)" fi _consult_rc=$? set -e if [[ $_consult_rc -ne 0 ]]; then echo "$_consult" >&2 rm -f "$OUT" fatal "xmod consult re-export failed (rc=$_consult_rc, output above) — a main that imports env.__hwjs_xmod_dispatch_check without exporting it aborts in libc's constructors under a released HardwareJS. Refusing and removing the artifact." fi case "$_consult" in "exported "*) echo "wasmld: NOTE (xmod consult): exported __hwjs_xmod_dispatch_check as a thunk over its import (funcidx ${_consult#exported }) for the kernel-embedded runtime's late bind." >&2 ;; already-exported|no-import) ;; *) rm -f "$OUT" fatal "xmod consult re-export: unexpected helper output '$_consult'. Refusing and removing the artifact." ;; esac fi # ── (9) DYNAMIC-MAIN SUSPENDER PUBLICATION (BRINTOS-2323) ───────────────── # # WASMLD_DYNMAIN_SUSPENDERS_OUT= — the producer path a dlopen-capable # dynamic main did not have. libc.so publishes a partition # (WASMLD_LIBC_SO_PARTITION_OUT) and so does the runtime module # (WASMLD_RT_SO_PARTITION_OUT); a MAIN published nothing, so a side module # linked against it learned nothing about which of the main's exports park. # The pass then falls back to attribute keying, which for an ordinary C # declaration in an ordinary header means NATIVE for every one of them — the # blanket, not a narrowing, exactly as the -shared partition-drop was # (SHARED_PARTITION_CONSUME) before it was closed. # # What that costs, measured on this image rather than argued: python3 exports # 1504 functions of which 1275 are coroutines; the crc32c extension imports 33 # names from env and 8 of them are in that set, PyModule_Create2 and # PyArg_ParseTuple among them. Its own PyInit_crc32c is coroutinized and calls # PyModule_Create2 BARE — no clear_parked before, no take_parked after — # because the seed said native. The callee parks anyway, and the next park # trips "set-over-set: parked slot not empty at a new park". That is the # first-import trap, and it is at first import because PyModule_Create2 is # what a module init calls. # # DERIVED FROM THE ARTIFACT, not from the pass. A ramp `f` is a coroutine iff # the module carries `f.resume` (`f (.resume)` for a demangled C++ name, see # _hwjs_coro_export_names), so the verdicts can be read off the bytes # this link just produced — the same derivation, character for character, that # the needed-library seed uses on a needed .so (BRINTOS-1634). No new build # input, no new pass knob, and nothing to keep in sync with the pass's # internals: the file states what the artifact IS. # # NAMES-ONLY BY CONSTRUCTION, and that is the form the consumer wants. The # -shared arm forwards WASMLD_HWJS_XMOD_SUSPENDERS to HWJS_CORO_XMOD_SUSPENDERS, # the names-only knob, which the pass UNIONS with any verdict partition # (libc.so's) and cross-checks. Publishing a " " file here would # be handed to the names-only parser and seed every native verdict line # may-suspend — the trap the SHARED_PARTITION_CONSUME carry names. # # NEVER-SILENT: a main with no name section cannot be classified, and guessing # either way re-creates one of the two mis-partition traps. Refuse. if [[ -n "${WASMLD_DYNMAIN_SUSPENDERS_OUT:-}" ]]; then [[ $IS_SHARED != 1 ]] \ || fatal "WASMLD_DYNMAIN_SUSPENDERS_OUT on a -shared link — a side module is not a main and publishes no main partition; a package library publishes the same names list through WASMLD_SO_SUSPENDERS_OUT, and the libc.so and runtime producers through WASMLD_LIBC_SO_PARTITION_OUT and WASMLD_RT_SO_PARTITION_OUT (BRINTOS-2323)." [[ "$DYNAMIC_MAIN" != "0" ]] \ || fatal "WASMLD_DYNMAIN_SUSPENDERS_OUT requires WASMLD_DYNAMIC_MAIN=1 — a static main has no dlopen path, so nothing can ever consume the partition and publishing one would assert a binding surface this image does not have (BRINTOS-2323)." [[ -f "$OUT" ]] \ || fatal "WASMLD_DYNMAIN_SUSPENDERS_OUT: the final image '$OUT' is not there to derive the partition from (BRINTOS-2323)." command -v node >/dev/null 2>&1 \ || fatal "WASMLD_DYNMAIN_SUSPENDERS_OUT: node unavailable — cannot read the final image's export and name sections. Refusing rather than publishing a partition derived from nothing (BRINTOS-2323)." _dmraw="$WORK/dynmain-suspenders.raw" _dmerr="$WORK/dynmain-suspenders.err" if ! node -e ' const fs = require("fs"); const b = fs.readFileSync(process.argv[1]); let p = 8, expfns = [], names = null; function u32() { let r = 0, s = 0, x; do { x = b[p++]; r |= (x & 0x7f) << s; s += 7; } while (x & 0x80); return r >>> 0; } function str() { const n = u32(), s = b.toString("utf8", p, p + n); p += n; return s; } while (p < b.length) { const id = b[p++], len = u32(), end = p + len; if (id === 7) { const n = u32(); for (let i = 0; i < n; i++) { const nm = str(); const k = b[p++]; const idx = u32(); if (k === 0) expfns.push([nm, idx]); } } else if (id === 0) { const save = p, sec = str(); if (sec === "name") { while (p < end) { const sub = b[p++], sl = u32(), se = p + sl; if (sub === 1) { names = new Map(); const n = u32(); for (let i = 0; i < n; i++) { const idx = u32(); names.set(idx, str()); } p = se; break; } p = se; } } p = save; } p = end; } if (!names) { console.error("no name section"); process.exit(2); } const have = new Set(names.values()); const coro = (f) => have.has(f + ".resume") || have.has(f + " (.resume)"); const out = []; for (const [nm, idx] of expfns) { const inner = names.get(idx); if ((inner && coro(inner)) || coro(nm)) out.push(nm); } console.error(`exports=${expfns.length} coroutine=${out.length}`); console.log(out.join("\n")); ' "$OUT" > "$_dmraw" 2>"$_dmerr"; then [[ -s "$_dmerr" ]] && sed 's/^/wasmld: dynmain-partition: /' "$_dmerr" >&2 fatal "WASMLD_DYNMAIN_SUSPENDERS_OUT: cannot derive the suspend partition from '$OUT' (node's reason above) — a main whose coroutine exports cannot be classified would leave every consumer .so seeding them NATIVE wholesale, which is the set-over-set orphan this partition exists to prevent. Link the main with its name section intact (BRINTOS-2323)." fi # stderr is NOT merged into the derivation, for the same reason and by the # same rule as the needed seed above: node warning text (NODE_OPTIONS, an # ExperimentalWarning) would otherwise be published verbatim as a NAME. _dmcensus="$(grep -m1 '^exports=' "$_dmerr" 2>/dev/null || true)" if grep -qv '^exports=' "$_dmerr" 2>/dev/null; then echo "wasmld: NOTE (WASMLD_DYNMAIN_SUSPENDERS_OUT): node wrote to stderr while classifying '$OUT' (kept OUT of the partition):" >&2 grep -v '^exports=' "$_dmerr" >&2 || true fi # The same three-in-one filter the needed seed runs, and it must be the same # one: drop the reserved namespaces the pass hard-refuses (a runtime op must # never be seeded through a partition), and dedup. A main's export list is # already unique, so the dedup is belt-and-braces rather than load-bearing — # kept so the two derivations cannot drift apart. awk 'NF && $0 !~ /^(__hwjs_|hwjs_|llvm\.|env\.|GOT\.)/ && !seen[$0]++' \ "$_dmraw" > "$WORK/dynmain-suspenders.txt" _dmkept=$(wc -l < "$WORK/dynmain-suspenders.txt") _dmall=$(awk 'NF' "$_dmraw" | wc -l) # Installed even when EMPTY, and deliberately. A recipe that publishes this # file makes its existence a build contract its consumers depend on, so a # main that happens to coroutinize nothing must leave an honest empty record # rather than no record — the same call the rt producer makes above. The # CONSUMER skips an empty file, because the pass refuses an empty list. cp -f "$WORK/dynmain-suspenders.txt" "$WASMLD_DYNMAIN_SUSPENDERS_OUT.tmp.$$" \ && mv -f "$WASMLD_DYNMAIN_SUSPENDERS_OUT.tmp.$$" "$WASMLD_DYNMAIN_SUSPENDERS_OUT" \ || fatal "WASMLD_DYNMAIN_SUSPENDERS_OUT: failed to install the published partition at $WASMLD_DYNMAIN_SUSPENDERS_OUT (BRINTOS-2323)." echo "wasmld: NOTE (WASMLD_DYNMAIN_SUSPENDERS_OUT): published dynamic-main suspender partition -> $WASMLD_DYNMAIN_SUSPENDERS_OUT ($_dmkept coroutine export(s) of $_dmall derived${_dmcensus:+, $_dmcensus}; $(( _dmall - _dmkept )) dropped as reserved-namespace or duplicate; sha256 $(sha256sum "$WASMLD_DYNMAIN_SUSPENDERS_OUT" | cut -c1-16)…)." >&2 if [[ "$_dmkept" == "0" ]]; then echo "wasmld: NOTE (WASMLD_DYNMAIN_SUSPENDERS_OUT): the partition is EMPTY — this main exports no coroutine, so every consumer .so keeps its attribute-keyed seed. Honest empty record installed; a consumer must skip an empty partition rather than hand the pass an empty list." >&2 fi fi # ── (10) SIDE-MODULE SUSPENDER PUBLICATION ──────────────────────────────── # # WASMLD_SO_SUSPENDERS_OUT= — (9)'s names list, published by a package # LIBRARY rather than a main. It is for a library whose ABI is bound by # modules that do not link it: CPython's extensions call the C API without # naming libpython (upstream links them against it only on Android and # Cygwin), so neither the needed-library seed nor the sibling seed reaches # them, and once libpython is shared the main no longer defines that API and # (9) cannot describe it. The consumer is unchanged: a module linked with # WASMLD_DYNMAIN_SUSPENDERS= seeds from it, names only. # # The derivation is _hwjs_coro_export_names, the one the needed and sibling # seeds run on a .so; (9) still carries its own copy. The filter is (9)'s. # The knob's misplacements are refused at the top, before the link. if [[ -n "${WASMLD_SO_SUSPENDERS_OUT:-}" ]]; then [[ -f "$OUT" ]] \ || fatal "WASMLD_SO_SUSPENDERS_OUT: the final module '$OUT' is not there to derive the partition from." command -v node >/dev/null 2>&1 \ || fatal "WASMLD_SO_SUSPENDERS_OUT: node unavailable — cannot read the module's export and name sections. Refusing rather than publishing a partition derived from nothing." _soraw="$WORK/so-suspenders.raw" _soerr="$WORK/so-suspenders.err" : > "$_soraw" if ! _hwjs_coro_export_names "$OUT" "$_soraw" "$_soerr"; then [[ -s "$_soerr" ]] && sed 's/^/wasmld: so-partition: /' "$_soerr" >&2 fatal "WASMLD_SO_SUSPENDERS_OUT: cannot derive the suspend partition from '$OUT' (node's reason above) — a library whose coroutine exports cannot be classified would leave every consumer seeding them NATIVE, which is the set-over-set trap." fi if [[ -s "$_soerr" ]]; then echo "wasmld: NOTE (WASMLD_SO_SUSPENDERS_OUT): node wrote to stderr while classifying '$OUT' (kept OUT of the partition):" >&2 cat "$_soerr" >&2 fi awk 'NF && $0 !~ /^(__hwjs_|hwjs_|llvm\.|env\.|GOT\.)/ && !seen[$0]++' \ "$_soraw" > "$WORK/so-suspenders.txt" _sokept=$(wc -l < "$WORK/so-suspenders.txt") # Installed even when EMPTY, for (9)'s reason: the file is a build contract. cp -f "$WORK/so-suspenders.txt" "$WASMLD_SO_SUSPENDERS_OUT.tmp.$$" \ && mv -f "$WASMLD_SO_SUSPENDERS_OUT.tmp.$$" "$WASMLD_SO_SUSPENDERS_OUT" \ || fatal "WASMLD_SO_SUSPENDERS_OUT: failed to install the published partition at $WASMLD_SO_SUSPENDERS_OUT." echo "wasmld: NOTE (WASMLD_SO_SUSPENDERS_OUT): published side-module suspender partition -> $WASMLD_SO_SUSPENDERS_OUT ($_sokept coroutine export(s))." >&2 fi exit 0 fi # ============================================================ # LEGACY ARM (Asyncify substrate) — RETIRED # ============================================================ # # The Asyncify arm's whole reason to exist was Binaryen's # `wasm-opt -O2 --asyncify` post-link pass (+ optional --fpcast-emu). emsdk # is gone, Binaryen is not part of the toolchains LLVM build, and the # substrate is being retired in favor of the coro arm (WASMLD_HWJS=1). So a # real legacy link can no longer be performed — and rather than re-add a # dead $EMSDK path that fails obscurely (or, worse, silently produce a # NON-asyncified binary that looks like a fork regression), we refuse it # LOUDLY and by name. # # An OUT-less invocation (e.g. `--version` / a configure -shared feature # probe) needs no Binaryen — it is a plain wasm-ld pass-through, which is # now fully emsdk-free (WASM_LD resolves from the toolchains build). Keep # that alive on both arms so probes still work; the coro arm only reaches # here for such an OUT-less probe. if [[ -z "$OUT" ]]; then link "$@" exit $? fi # A real legacy link (OUT set, WASMLD_HWJS!=1): never-silent refusal. echo "wasmld: FATAL: the Asyncify/Binaryen legacy arm is RETIRED and unavailable." >&2 echo "wasmld: It depended on 'wasm-opt --asyncify', which is gone with emsdk (Binaryen is" >&2 echo "wasmld: not part of the toolchains LLVM build). Re-link on the coro arm: WASMLD_HWJS=1." >&2 echo "wasmld: (got WASMLD_HWJS='$HWJS' for output '$OUT')" >&2 exit 1