13 KiB
verify-baseline-static — triage guide
Static ISA scanner. Disassembles every instruction in .text of a baseline
Bun binary and flags anything the baseline CPU can't decode. Catches -march
leaks at compile time, before they SIGILL on a user's machine.
This file is for triaging CI failures. For architecture details see
README.md and inline comments in src/main.rs / src/aarch64.rs.
This is a best-effort check, not a proof
A PASS here does not guarantee the binary is baseline-safe, and a FAIL
does not guarantee a real bug. Treat it as a sensitive smoke detector, not an
oracle. The emulator phase (scripts/verify-baseline.ts) is the complementary
check — together they catch most things; neither alone is bulletproof.
Out of scope entirely (tool will never find these):
- JIT-emitted code. JSC compiles JS/WASM to machine code at runtime; none
of it exists in
.textat scan time. If the JIT backend emits post- baseline instructions on a baseline CPU, this tool is blind to it. The emulator's--jit-stresspath covers this. - Dynamically loaded code. N-API addons, FFI callees, dlopen'd shared
libs. Scanner only reads the
bun-profilebinary. - Gate correctness. The tool does not verify that a CPUID gate actually
checks the right bits. It trusts the allowlist. Feature ceilings catch the
"code grew new features, gate wasn't updated" case, but a gate that was
wrong from the start (checks AVX, uses AVX2) passes silently if the
ceiling says
[AVX, AVX2].
In scope but may miss:
- x64 linear-sweep may desync on data-in-
.textand skip real instructions that follow. Variable-length x86 encoding makes perfect code/data separation undecidable (README.md:53-59). aarch64 is more reliable (fixed-width words,$dmapping symbols mark data), but a missing mapping symbol can still hide a hit. - Instructions deliberately ignored (TZCNT/XGETBV on x64, hint-space PAC/BTI on aarch64) could theoretically be misused; we assume the compiler's idiom is the only one.
Can report false violations:
- Data bytes in
.textthat happen to form a valid post-baseline encoding. Rare on ELF (LLVM puts tables in.rodata), common on Windows PE (MSVC inlines jump tables). SeeREADME.md:61-74.
When in doubt, the emulator is ground truth: qemu -cpu Nehalem and hit the
code path. SIGILL = real bug. No SIGILL = either gated or a data-in-text
false positive.
Which builds run this
See needsBaselineVerification() in .buildkite/ci.mjs:
| Target | Allowlist file |
|---|---|
linux-x64, linux-x64-musl |
allowlist-x64.txt |
windows-x64 |
allowlist-x64-windows.txt |
linux-aarch64, linux-aarch64-musl, linux-aarch64-android |
allowlist-aarch64.txt |
x64 baseline = Nehalem (-march=nehalem). aarch64 baseline = armv8-a+crc.
Every x64 build is baseline (there is no separate -baseline variant).
Reproduce a CI failure locally
The scanner runs on the CI-built -profile artifact. You can't reproduce by
building locally unless you build with the exact baseline toolchain. Download
the artifact instead.
-
Get
<triplet>-profile.zipfrom the failing build'sbuild-bunstep (Artifacts tab in Buildkite). Triplets look likebun-linux-x64,bun-linux-aarch64-musl,bun-windows-x64. -
Build and run the scanner (host arch is irrelevant — the scanner reads the binary's headers, it doesn't execute it):
cargo build --release --manifest-path scripts/verify-baseline-static/Cargo.toml # Linux x64 baseline ./scripts/verify-baseline-static/target/release/verify-baseline-static \ --binary bun-linux-x64-profile/bun-profile \ --allowlist scripts/verify-baseline-static/allowlist-x64.txt # Linux aarch64 ./scripts/verify-baseline-static/target/release/verify-baseline-static \ --binary bun-linux-aarch64-profile/bun-profile \ --allowlist scripts/verify-baseline-static/allowlist-aarch64.txt # Windows x64 baseline (PDB auto-discovered at <binary>.pdb) ./scripts/verify-baseline-static/target/release/verify-baseline-static \ --binary bun-windows-x64-profile/bun-profile.exe \ --allowlist scripts/verify-baseline-static/allowlist-x64-windows.txt
Never scan the stripped release binary. It has no .symtab (ELF) / no
.pdb (PE), so every hit becomes <no-symbol@addr> and nothing matches the
allowlist.
Reading the output
VIOLATIONS (would SIGILL on Nehalem):
_ZN7simdutf7haswell14implementation17some_new_functionEPKcm [AVX, AVX2] (42 insns)
0x0000a1b2c3 Vpbroadcastb (AVX2)
0x0000a1b2d7 Vpshufb (AVX)
0x0000a1b2ee Vpcmpeqb (AVX)
... 39 more
ALLOWLISTED (suppressed, runtime-dispatched):
...
-- 550 symbols, 18234 instructions total
STALE ALLOWLIST ENTRIES (no matching symbol found — remove these?):
_ZN7simdutf7haswell14implementation13old_gone_funcEPKcm
SUMMARY:
violations: 1 symbols, 42 instructions
allowlisted: 550 symbols
stale allowlist entries: 1
FAIL
- Violation line format:
symbol [FEAT, ...] (N insns). Copy the symbol name exactly when allowlisting — it's compared post-canonicalization. - Feature names are iced-x86's
CpuidFeatureDebug names (x64) or the strings insrc/aarch64.rs:44-54(aarch64). They must match the allowlist brackets character-for-character. STALEentries are informational, not an error. One allowlist covers both glibc and musl; a symbol LTO'd away on one libc shows STALE on the other.
Triage: is this an allowlist entry or a real bug?
The tool found post-baseline instructions in some symbol. Two possibilities:
A. Runtime-dispatched. The symbol only runs after a CPUID/HWCAP gate decides the CPU supports it. This is fine — allowlist it.
B. Not gated. A -march flag leaked into a translation unit that's always
executed. Real bug, will SIGILL on baseline hardware. Fix the compile flags.
Deciding which
Identify the dependency. Demangle the symbol (c++filt, or recognize the
prefix: _ZN7simdutf = simdutf, _ZN3bun + N_AVX2/N_SVE = Bun's Highway
code, _RNv + memchr = Rust memchr, etc). Search the allowlist for that
dependency — if neighbors are there under an existing # Gate: ... header,
this is almost certainly (A).
Find the gate. Grep for the symbol name (unmangled) in the dependency's source. Trace up to the caller — there should be a CPUID check, a dispatcher table, an HWCAP test. Known patterns:
| Dependency | Gate | Where |
|---|---|---|
| simdutf | set_best() — CPUID first call, cached atomic ptr |
vendor/ or WebKit's bundled copy |
| Highway (Bun) | HWY_DYNAMIC_DISPATCH → hwy::SupportedTargets() |
src/jsc/bindings/highway_strings.cpp |
| BoringSSL | OPENSSL_ia32cap_P global, set at init |
vendor/boringssl/crypto/cpu_intel.c |
| zstd | ZSTD_cpuid() |
vendor/zstd/lib/common/cpu.h |
| libdeflate | libdeflate_init_x86_cpu_features() / HWCAP_ASIMDDP |
vendor/libdeflate/lib/x86/cpu_features.c |
Rust memchr |
is_x86_feature_detected!() |
(via lolhtml dep) |
| compiler-rt outline-atomics (aarch64) | __aarch64_have_lse_atomics (= AT_HWCAP & HWCAP_ATOMICS) |
compiler-rt builtin |
If no gate exists: (B). Usually a subbuild that picked up host
-march=native instead of the pinned -march=nehalem / -mcpu=cortex-a53.
Fix that dep's compile flags in scripts/build/deps/. Confirm with the
emulator (the ground-truth check):
qemu-x86_64 -cpu Nehalem ./bun-profile <code path that hits it> # x64 → SIGILL = bug
qemu-aarch64 -cpu cortex-a53 ./bun-profile <code path> # aarch64
Data-in-.text false positives (x64, mostly Windows)
Linear-sweep decode means data bytes in .text can happen to form a valid
instruction encoding. LLVM puts tables in .rodata so ELF builds are usually
clean; MSVC inlines jump tables and static const arrays into .text.
Signs of a false positive:
- Symbol is a lookup table or a function you know contains no SIMD.
- Reported instruction count is tiny (1–3) inside an otherwise-non-SIMD symbol.
objdump -daround the reported address showsretthen byte soup — no stack frame setup, no control flow leading to it.
If confirmed: allowlist the symbol as a blanket pass (bare name, no
[...] bracket). The reported features are misdecoded data bytes whose
values move with link layout, not gated code, so a ceiling has nothing to
bound and just re-flakes on the next layout that decodes differently. Note
the reason in the group comment.
Adding an allowlist entry
Append the symbol to the appropriate file. Group with its neighbors under the
existing # Gate: ... header; if no existing group matches, add one:
# ----------------------------------------------------------------------------
# <dependency> <variant>. Gate: <what checks CPUID/HWCAP>.
# (N symbols)
# ----------------------------------------------------------------------------
symbol_name_exactly_as_the_tool_printed_it [FEAT1, FEAT2]
Use a feature ceiling ([...]) for gated code. A blanket pass (no
brackets) defeats the "did the gate get updated when the dep grew AVX-512?"
check (src/main.rs:616-621). List exactly the features the tool reported;
that's what the gate currently checks. The exceptions are confirmed
data-in-.text misdecodes (previous section) and <no-symbol@...> padding
(below): there is no gate to drift past, so blanket-pass those.
x64 feature names (iced-x86 Debug strings — must match exactly):
AVX, AVX2, FMA, FMA4, BMI1, BMI2, MOVBE, ADX, RDRAND,
AES, PCLMULQDQ, VAES, VPCLMULQDQ, SHA, AVX512F, AVX512BW,
AVX512DQ, AVX512VL, AVX512_VBMI, AVX512_VBMI2, AVX512_VNNI,
AVX512_VPOPCNTDQ, AVX512_FP16, AVX_VNNI, …
aarch64 feature names: LSE, SVE, RCPC, DotProd, JSCVT, RDM,
PAC(non-hint).
Special symbol forms
Rust v0 mangling — <rust-hash>. Rust symbols contain a crate-hash
(Cs[base62]_) that changes across target triples and toolchains. The tool
canonicalizes both sides (src/main.rs:196-227), so allowlist entries should
use <rust-hash> in place of the hash:
# Tool reports:
_RNvMNtNtNtNtCs5QMN7YRSXc3_6memchr4arch6x86_644avx26memchrNtB2_3One13find_raw_avx2 [AVX, AVX2]
# Allowlist as:
_RNvMNtNtNtNt<rust-hash>6memchr4arch6x86_644avx26memchrNtB2_3One13find_raw_avx2 [AVX, AVX2]
Either form works (the tool canonicalizes both before comparing), but
<rust-hash> survives toolchain bumps.
Windows <lib:NAME.lib>. When PDB has no per-function record for a hit
(stripped CRT objects, anonymized staticlib helpers), the tool falls back to
section-contribution attribution: the linker-map "which .lib did this byte
come from" data. These attributions are stable across link layout changes.
Allowlist them literally:
<lib:lolhtml.lib> [AVX, AVX2]
<no-symbol@0x...> — the address fell in padding between functions or the
binary is stripped. If you see these for every violation, you're scanning the
wrong binary (use -profile). If it's just one or two, it's usually inter-
function padding that decoded as something; investigate with objdump -d
around that address and, if it's genuinely junk, add a brief # padding at <addr range> comment with a blanket-pass entry.
PDB coverage drift (Windows)
A function may get an S_LPROC32 record (real mangled name) on one toolchain
and fall through to <lib:...> on another. If the same code flips between
forms across CI runs, allowlist both.
Deliberately ignored (not reported even if found)
See src/main.rs:94-135 and src/aarch64.rs:
- TZCNT (x64) — decodes as REP BSF on pre-BMI1; LLVM preloads dest with
operand-width so the
src==0case matches. (LZCNT is NOT ignored —BSR≠LZCNTfor nonzero inputs and LLVM never emits it for Nehalem.) - XGETBV (x64) — needed by every AVX gate; a stray one SIGILLs at startup so the emulator catches it trivially.
- ENDBR64 (CET_IBT), RDSSP/INCSSP (CET_SS hint-space subset) (x64) — NOP-encoded on pre-CET by design. The rest of CET_SS (WRSSD/RSTORSSP/ SETSSBSY etc.) IS flagged — dedicated opcode slots that #UD on pre-CET.
- PACIASP/AUTIASP/BTI (aarch64) — HINT-space, architecturally NOP on
pre-PAC CPUs. (
LDRAA/LDRABare not HINT-space and are reported.) - 3DNow!, SMM, Cyrix, VIA, RTM/TSX (x64) — no toolchain targeting x86-64
emits these without explicit intrinsics. When their encodings show up
(
0f 0f3DNow!,C7/C6 F8XBEGIN/XABORT), it's data.