From bba61af0711bb1decfe09b49f6ae5520e4796dd5 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 2 Jul 2026 08:30:34 -0400 Subject: [PATCH] erlang: wasm32 -O2 miscompilation coverage (kd-r8h7 steps 1-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert the reactive per-file -O1 posture for LLVM wasm32 -O2 miscompilations into a systematic, bounded, detected one. Implements the low-risk core (steps 1-4) of the kd-r8h7 design. Step 1 (audit): ESTACK/WSTACK/EQUEUE/DMC risk audit of OTP 28.2 ERTS. Corrects the design's guessed list — refutes erl_bif_binary.c/erl_bif_re.c (no idiom), adds erl_term_hashing.c (phash), erl_iolist.c/erl_io_queue.c (EQUEUE iodata) — and surfaces that the facet-1 global.h init patch covers only ESTACK/WSTACK, not EQUEUE/DMC. Artifacts under test-runs/kd-jin7/. Step 2 (registry): packages/registry/erlang/wasm32-miscompilations.md — the greppable source of truth (triage runbook, applied-workaround table with chksum marked PR #824, detection-only table, CI wiring, removal checklist, OTP-bump re-audit trigger). Referenced from build-erlang.sh at both workaround sites. Doc/comment-only: no build output byte change, so build.toml revision is intentionally NOT bumped. Step 3 (detection matrix + local runner): test/wasm32-miscompilation-matrix.ts is the single {name, expr, expected} source with native-OTP-28 oracles and inputs sized past DEF_*_SIZE (16) to force the heap-stack path; test/erlang.test.ts runs the whole matrix in one BEAM boot under skipIf. Step 4 (CI gate runner): test/run-wasm32-miscompilation-smoke.ts — one boot, fails on mismatch, on incompletion, and on the false-coverage case (no active case ran = missing OTP tree). Emits passed/failed/skipped outcome lists. PR-gate promotion is documented in the registry; it is deferred because the bottle-build/smoke job + framebuffer gate are not yet on main and erlang is CI-disabled (see SUMMARY). Verified on native OTP 28 (the oracle source) via dev-shell: 8/8 active pass, 2 pending skip (chksum/compile behind PR #824); negative control fails on a corrupted oracle; false-coverage guard fails with no tree; md5/crc32/adler32 oracle cross-checked against Python hashlib/zlib. Also documents the miscompilation class as a cross-package pitfall in docs/porting-guide.md. Co-Authored-By: Claude Opus 4.8 --- docs/porting-guide.md | 13 + packages/registry/erlang/build-erlang.sh | 15 ++ packages/registry/erlang/test/erlang.test.ts | 50 +++- .../test/run-wasm32-miscompilation-smoke.ts | 158 ++++++++++++ .../test/wasm32-miscompilation-matrix.ts | 236 ++++++++++++++++++ .../registry/erlang/wasm32-miscompilations.md | 216 ++++++++++++++++ test-runs/kd-jin7/SUMMARY.md | 77 ++++++ .../kd-jin7/audit-estack-wstack-20260702.md | 147 +++++++++++ test-runs/kd-jin7/audit-grep-raw.txt | 50 ++++ .../gate-outcomes-native/failed-tests.txt | 0 .../gate-outcomes-native/passed-tests.txt | 8 + .../gate-outcomes-native/skipped-tests.txt | 2 + test-runs/kd-jin7/native-selfcheck.txt | 6 + test-runs/kd-jin7/oracle-native-otp28.txt | 12 + 14 files changed, 989 insertions(+), 1 deletion(-) create mode 100644 packages/registry/erlang/test/run-wasm32-miscompilation-smoke.ts create mode 100644 packages/registry/erlang/test/wasm32-miscompilation-matrix.ts create mode 100644 packages/registry/erlang/wasm32-miscompilations.md create mode 100644 test-runs/kd-jin7/SUMMARY.md create mode 100644 test-runs/kd-jin7/audit-estack-wstack-20260702.md create mode 100644 test-runs/kd-jin7/audit-grep-raw.txt create mode 100644 test-runs/kd-jin7/gate-outcomes-native/failed-tests.txt create mode 100644 test-runs/kd-jin7/gate-outcomes-native/passed-tests.txt create mode 100644 test-runs/kd-jin7/gate-outcomes-native/skipped-tests.txt create mode 100644 test-runs/kd-jin7/native-selfcheck.txt create mode 100644 test-runs/kd-jin7/oracle-native-otp28.txt diff --git a/docs/porting-guide.md b/docs/porting-guide.md index e54140cb9..0705e84ca 100644 --- a/docs/porting-guide.md +++ b/docs/porting-guide.md @@ -706,6 +706,19 @@ hardcoding it. in `depends_on`. The resolver builds deps before you and exports their paths via `WASM_POSIX_DEP__DIR` / `_SRC_DIR`. Hidden source-tree reads break on clean force-rebuild runs. +- **wasm32 `-O2` miscompilations returning plausible-but-wrong + results.** LLVM's wasm32 backend can miscompile C code that walks + deep/recursive structures via a stack whose pointers alias a local + array (returning garbage or reading out of bounds), only once inputs + are large enough to leave the inline fast path. Symptoms are silent + wrong output, not a crash. If a port fails a term/iodata/traversal + operation but is correct on the native build, suspect this class: + rebuild the suspect translation unit at `-O1` to confirm, then + **land the `-O1` with both a registry row and a detection smoke row + in the same change** so it can never silently regress. Erlang is the + worked example — see `packages/registry/erlang/wasm32-miscompilations.md` + (triage runbook, workaround table, removal checklist) and the smoke + matrix `packages/registry/erlang/test/wasm32-miscompilation-matrix.ts`. ## Existing Build Scripts diff --git a/packages/registry/erlang/build-erlang.sh b/packages/registry/erlang/build-erlang.sh index 19c92cbb0..320d5541a 100755 --- a/packages/registry/erlang/build-erlang.sh +++ b/packages/registry/erlang/build-erlang.sh @@ -456,6 +456,18 @@ if ! grep -q '__wasm32__' "$SYS_DRIVERS" 2>/dev/null; then echo "==> Patched sys_drivers.c to skip forker on wasm32" fi +# ===================================================================== +# wasm32 -O2 miscompilation workarounds (global.h init patch, per-file +# -O1 downgrades, and the erl_db_util bounds guard below). +# +# SOURCE OF TRUTH + triage runbook + removal checklist: +# packages/registry/erlang/wasm32-miscompilations.md +# Each workaround below has a row there and a guarding smoke case in +# test/erlang.test.ts. If you add/remove an -O1 file, update the +# registry and the smoke matrix in the SAME change (see the runbook). +# On an OTP_VERSION bump, re-run the audit in that registry first. +# ===================================================================== + # Patch global.h: ESTACK/WSTACK explicit field initialization on wasm32. # LLVM's wasm32 backend miscompiles aggregate initialization of structs # containing pointers to shadow-stack local arrays at -O2. @@ -527,6 +539,9 @@ fi # Patch Makefile: compile certain files at -O1. # LLVM's wasm32 backend miscompiles several BEAM files at -O2, causing # shadow-stack pointer corruption and incorrect aggregate initialization. +# Registry (facet, discovering bead, guarding smoke, removal criteria): +# packages/registry/erlang/wasm32-miscompilations.md +# erl_bif_chksum.c gets its own -O1 rule via PR #824 (kd-qe2c), not here. EMU_MAKEFILE="$SRC_DIR/erts/emulator/wasm32-unknown-wasi/Makefile" if [ -f "$EMU_MAKEFILE" ] && ! grep -q 'erl_unicode.o:' "$EMU_MAKEFILE"; then sed -i.bak '/\$(OBJDIR)\/beam_emu\.o: beam\/emu\/beam_emu\.c/i\ diff --git a/packages/registry/erlang/test/erlang.test.ts b/packages/registry/erlang/test/erlang.test.ts index 291e23791..a1ce8f350 100644 --- a/packages/registry/erlang/test/erlang.test.ts +++ b/packages/registry/erlang/test/erlang.test.ts @@ -5,12 +5,20 @@ * protection, Erlang-specific boot args) so tests use the serve.ts * launcher as a subprocess rather than the generic test helper. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeAll } from "vitest"; import { join, dirname } from "node:path"; import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { tryResolveBinary, findRepoRoot } from "../../../../host/src/binary-resolver"; +import { + cases as miscompCases, + activeCases, + pendingCases, + buildBatchProgram, + parseBatchOutput, + type BatchResult, +} from "./wasm32-miscompilation-matrix"; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = findRepoRoot(); @@ -70,3 +78,43 @@ describe.skipIf(!hasErlang)("Erlang BEAM", () => { expect(output).toContain("Completed in"); }); }); + +// wasm32 -O2 miscompilation detection matrix (kd-jin7 / kd-r8h7 Layer A). +// The matrix source (oracles + Erlang exprs) lives in +// ./wasm32-miscompilation-matrix.ts and is shared with the CI gate runner +// (test/run-wasm32-miscompilation-smoke.mjs) so coverage cannot drift. +// +// This local runner is a dev convenience: it `skipIf`s without a local erlang +// build, exactly like the suite above. The AUTHORITATIVE runner is the CI gate, +// which has the OTP runtime tree. To keep this cheap even locally, the whole +// matrix runs in ONE BEAM boot (startup dominates), and each case asserts its +// own line so a failure names the offending operation. Cases whose `-O1`/patch +// is not yet on this base (e.g. chksum/compile behind PR #824) are reported as +// pending skips rather than run. +describe.skipIf(!hasErlang)("wasm32 -O2 miscompilation smoke matrix", () => { + let batch: BatchResult; + + beforeAll(() => { + const output = runErlang(buildBatchProgram(), 120_000); + batch = parseBatchOutput(output, miscompCases.length); + }, 130_000); + + it("runs the whole matrix to completion (no silent truncation)", () => { + expect(batch.completed).toBe(true); + // No case may FAIL; the message surfaces the expected/got on regression. + expect([...batch.failures.values()]).toEqual([]); + }); + + for (const c of activeCases()) { + it(`${c.name} — ${c.exercises}`, () => { + const detail = + batch.failures.get(c.name) ?? `no 'ok ${c.name}' line in BEAM output`; + expect(batch.ok.has(c.name), detail).toBe(true); + }); + } + + for (const c of pendingCases()) { + // Not a bug: the guarding workaround lands with its PR; flip in that change. + it.skip(`${c.name} — pending PR #${c.pendingPr} (${c.exercises})`, () => {}); + } +}); diff --git a/packages/registry/erlang/test/run-wasm32-miscompilation-smoke.ts b/packages/registry/erlang/test/run-wasm32-miscompilation-smoke.ts new file mode 100644 index 000000000..05e37fcb5 --- /dev/null +++ b/packages/registry/erlang/test/run-wasm32-miscompilation-smoke.ts @@ -0,0 +1,158 @@ +/** + * CI gate runner for the wasm32 `-O2` miscompilation smoke matrix (Layer A, + * authoritative). Runs the shared matrix + * (./wasm32-miscompilation-matrix.ts) against a real BEAM in ONE boot, then + * exits non-zero on any mismatch, incompletion, or — critically — if NO active + * case executed (which would mean the OTP runtime tree is missing in the gate + * context; silently "passing" there is the design's highest-listed risk, a + * smoke that never runs). Pending cases (behind an unmerged PR) are reported as + * expected skips. + * + * Invocation (from the bottle-build / smoke job, OTP runtime tree present): + * npx tsx packages/registry/erlang/test/run-wasm32-miscompilation-smoke.ts + * + * The default BEAM path reuses demo/serve.ts, so it runs whatever + * `erlang.wasm` + `erlang-install/` the resolver finds (a from-source build or, + * once published, the fetched `erlang-otp.tar.zst` sidecar extracted into + * `erlang-install/`). + * + * Env: + * MISCOMP_BEAM_MODE=native-erl Run the batch under the host's native `erl` + * instead of erlang.wasm. For self-testing the + * RUNNER (parse + exit + outcome lists) without + * a wasm build; NOT a substitute for the gate. + * MISCOMP_OUTCOME_DIR= Write passed/failed/skipped outcome lists + * (durable artifacts per the validation std). + * MISCOMP_TIMEOUT_MS= BEAM run timeout (default 150000). + */ +import { execFileSync } from "node:child_process"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { mkdirSync, writeFileSync, existsSync } from "node:fs"; +import { + cases, + activeCases, + pendingCases, + buildBatchProgram, + parseBatchOutput, + OTP_ORACLE_VERSION, +} from "./wasm32-miscompilation-matrix"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Lightweight repo-root finder (workspace Cargo.toml + package.json), used only +// as the cwd for the serve.ts subprocess so `npx tsx` resolves. Kept inline to +// avoid pulling the binary-resolver's vfs/fzstd chain into this small gate +// script (serve.ts resolves its own paths from import.meta.url regardless). +function findRepoRootLite(from: string): string { + let dir = from; + for (let i = 0; i < 30; i++) { + if (existsSync(join(dir, "package.json")) && existsSync(join(dir, "Cargo.toml"))) return dir; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return from; +} +const repoRoot = findRepoRootLite(__dirname); +const timeoutMs = Number(process.env.MISCOMP_TIMEOUT_MS ?? 150_000); + +function runBeam(program: string): string { + if (process.env.MISCOMP_BEAM_MODE === "native-erl") { + return execFileSync("erl", ["-noshell", "-eval", program], { + encoding: "utf-8", + timeout: timeoutMs, + }); + } + // Authoritative path: run the actual erlang.wasm via the demo launcher. + const serve = join(__dirname, "..", "demo", "serve.ts"); + return execFileSync("npx", ["tsx", serve, "-eval", program], { + cwd: repoRoot, + encoding: "utf-8", + timeout: timeoutMs, + stdio: ["pipe", "pipe", "pipe"], + }); +} + +function main(): void { + const program = buildBatchProgram(); + const active = activeCases().map((c) => c.name); + const pending = pendingCases(); + + let stdout = ""; + let runError: unknown = null; + try { + stdout = runBeam(program); + } catch (e: any) { + // Capture partial output; a crash mid-matrix is itself a finding. + stdout = (e.stdout ?? "") + "\n" + (e.stderr ?? ""); + runError = e; + } + + const res = parseBatchOutput(stdout, cases.length); + const passed = [...res.ok].sort(); + const failed = [...res.failures.keys()].sort(); + const skipped = [...res.skipped.keys()].sort(); + + // Durable outcome lists (passed/failed/skipped) for the validation standard. + const outDir = process.env.MISCOMP_OUTCOME_DIR; + if (outDir) { + mkdirSync(outDir, { recursive: true }); + writeFileSync(join(outDir, "passed-tests.txt"), passed.join("\n") + "\n"); + writeFileSync( + join(outDir, "failed-tests.txt"), + failed.map((n) => res.failures.get(n)).join("\n") + (failed.length ? "\n" : ""), + ); + writeFileSync( + join(outDir, "skipped-tests.txt"), + skipped.map((n) => `${n}\tpending_pr_${res.skipped.get(n)}`).join("\n") + + (skipped.length ? "\n" : ""), + ); + } + + // Gate assertions. + const problems: string[] = []; + const ranAny = res.ok.size + res.failures.size > 0; + if (!ranAny) { + problems.push( + "NO active case executed — the OTP runtime tree is missing in this gate " + + "context (erlang.wasm + erlang-install/). This is a false-coverage guard, " + + "not a pass. Ensure the runtime tree is present before the gate runs.", + ); + } + if (!res.completed) { + problems.push( + "matrix did not run to completion (no matching 'matrix_done' sentinel) — " + + "BEAM likely crashed mid-run; output truncated.", + ); + } + for (const name of active) { + if (!res.ok.has(name)) { + problems.push(res.failures.get(name) ?? `missing 'ok ${name}' (no result line)`); + } + } + for (const c of pending) { + if (!res.skipped.has(c.name)) { + problems.push(`pending case '${c.name}' (PR #${c.pendingPr}) was not reported as a skip`); + } + } + if (runError && problems.length === 0) { + problems.push(`BEAM invocation errored: ${(runError as Error).message}`); + } + + // Report. + console.log(`wasm32 -O2 miscompilation smoke — oracle ${OTP_ORACLE_VERSION}`); + console.log(` passed: ${passed.length}/${active.length} active [${passed.join(", ")}]`); + console.log(` skipped: ${skipped.length} pending [${skipped.join(", ")}]`); + if (failed.length) console.log(` FAILED: ${failed.join(", ")}`); + for (const f of failed) console.log(` ${res.failures.get(f)}`); + + if (problems.length) { + console.error("\nGATE FAIL:"); + for (const p of problems) console.error(` - ${p}`); + process.exit(1); + } + console.log("\nGATE PASS: all active miscompilation guards green."); +} + +main(); diff --git a/packages/registry/erlang/test/wasm32-miscompilation-matrix.ts b/packages/registry/erlang/test/wasm32-miscompilation-matrix.ts new file mode 100644 index 000000000..afef458fa --- /dev/null +++ b/packages/registry/erlang/test/wasm32-miscompilation-matrix.ts @@ -0,0 +1,236 @@ +/** + * Single source of truth for the wasm32 `-O2` miscompilation smoke matrix. + * + * Background: LLVM's wasm32 backend miscompiles ERTS's shadow-stack work-stack + * idiom (ESTACK/WSTACK/EQUEUE/DMC_STACK) at `-O2`. `build-erlang.sh` works + * around known cases with per-file `-O1` + a `global.h` init patch; the full + * registry is `packages/registry/erlang/wasm32-miscompilations.md`. This matrix + * is the *detection* layer: it exercises each at-risk term/iodata operation + * with inputs large enough to force the heap-backed stack path and asserts the + * exact result a correct BEAM must produce. + * + * Consumed by TWO runners so coverage never drifts between them: + * - Local/dev: `erlang.test.ts` (vitest), `skipIf` no local build. + * - CI gate: `test/run-wasm32-miscompilation-smoke.mjs`, run in the + * bottle-build/smoke job where the OTP runtime tree is present. + * + * ORACLE PROVENANCE: every `expected` is the `~w` rendering of the result + * computed on native Erlang/OTP 28 (validated on ERTS 16.4; these operations + * are format-stable across OTP 28.x). The md5/crc32/adler32 oracle was + * additionally cross-checked byte-for-byte against Python hashlib/zlib. To + * re-derive on an OTP bump, re-run the generator described in the registry's + * "Re-audit trigger" section and update any value that legitimately changes. + * + * INPUT SIZING: `DEF_ESTACK_SIZE == DEF_WSTACK_SIZE == DEF_EQUEUE_SIZE == 16`, + * so every input nests/contains > 16 elements — otherwise the operation stays + * on the inline fast path and never reaches the miscompiled loop. Each case's + * `why` records the dimension that clears 16. + */ + +export interface SmokeCase { + /** Stable machine name; appears in `ok ` / `FAIL ` output. */ + name: string; + /** Which ERTS translation unit / operation this drives. */ + exercises: string; + /** + * An Erlang expression sequence (the body of a `fun() -> ... end`) that + * evaluates to a compact term. MUST stay byte-identical to the oracle + * generator so `expected` remains valid. + */ + expr: string; + /** `~w` string of the correct result, from native OTP 28. */ + expected: string; + /** Why the input exceeds the 16-slot inline threshold. */ + why: string; + /** + * If set, the guarding `-O1`/patch is not yet on `origin/main` (it arrives + * with this PR), so the case is *skipped* rather than run — running it on a + * base without the workaround would fail by design. Flip to run once the PR + * lands. See the registry's "PR #824 sequencing" note. + */ + pendingPr?: number; + /** Extra `-pa` code paths the case needs beyond kernel+stdlib (CI runner). */ + needsCodePath?: string[]; +} + +export const OTP_ORACLE_VERSION = + "OTP 28.2 (oracles validated on ERTS 16.4; results format-stable across OTP 28.x)"; + +export const cases: SmokeCase[] = [ + { + name: "term_to_binary_roundtrip", + exercises: "external.c (enc_term/dec_term, WSTACK)", + expr: `T = lists:foldl(fun(I,A) -> {I,A} end, nil, lists:seq(1,500)), +{binary_to_term(term_to_binary(T)) =:= T, byte_size(term_to_binary(T))}`, + expected: "{true,2741}", + why: "500-deep nested 2-tuple; encode/decode WSTACK depth >> 16", + }, + { + name: "unicode_deep", + exercises: "erl_unicode.c (iodata traversal, ESTACK) [known-hit -O1]", + expr: `L = lists:duplicate(50, [16#1F600, <<"héllo"/utf8>>, "abc"]), +B = unicode:characters_to_binary(L), +{byte_size(B), lists:sum(binary_to_list(B))}`, + expected: "{650,88400}", + why: "50-element list of mixed codepoint/binary/string fragments (> 16)", + }, + { + name: "chksum_iolist", + exercises: "erl_bif_chksum.c (do_chksum over iodata) [known-hit -O1 via PR #824]", + expr: `IO = lists:duplicate(100, <<"kandelo">>), +{erlang:md5(IO), erlang:crc32(IO), erlang:adler32(IO)}`, + expected: + "{<<140,120,254,181,217,34,17,213,230,74,169,247,123,44,26,193>>,2948946130,2665225928}", + why: "100-fragment iolist (> 16); md5 broke beam_asm in kd-qe2c", + pendingPr: 824, + }, + { + name: "ets_match", + exercises: "erl_db_util.c / erl_db_hash.c (DMC stack, match_traverse) [known-hit -O1]", + expr: `T = ets:new(jin7_ets, [bag]), +[ets:insert(T, {K, K*K, "v"}) || K <- lists:seq(1,100)], +R = ets:select(T, [{{'$1','$2','_'}, [{'>','$2',2500}], ['$1']}]), +ets:delete(T), +{length(R), lists:sum(R), lists:min(R), lists:max(R)}`, + expected: "{50,3775,51,100}", + why: "100-row table, match spec selects K where K*K>2500 (K in 51..100)", + }, + { + name: "term_compare_sort", + exercises: "utils.c (eq/cmp, ESTACK) + erl_map.c (WSTACK)", + expr: `L = [ {I rem 7, #{a => I, b => lists:seq(1, I rem 20)}, [I | lists:seq(1, I rem 25)]} + || I <- lists:seq(1,100) ], +S = lists:sort(L), +{S =:= lists:sort(lists:reverse(L)), erlang:phash2(S)}`, + expected: "{true,45350027}", + why: "100 heterogeneous deep terms (tuples/maps/lists) drive cmp beyond 16", + }, + { + name: "phash2_deep", + exercises: "erl_term_hashing.c (make_hash2, WSTACK)", + expr: `T = lists:foldl(fun(I,A) -> {I, A} end, done, lists:seq(1,300)), +erlang:phash2(T)`, + expected: "64685795", + why: "300-deep nested tuple; make_hash2 WSTACK depth >> 16", + }, + { + name: "copy_large_term", + exercises: "copy.c (copy_struct/size_object, ESTACK)", + expr: `T = lists:foldl(fun(I,A) -> {I, A} end, nil, lists:seq(1,400)), +S = self(), +P = spawn(fun() -> receive X -> S ! erlang:phash2(X) end end), +P ! T, +H = receive R -> R after 5000 -> timeout end, +{H =:= erlang:phash2(T), H}`, + expected: "{true,60113841}", + why: "400-deep term copied via closure capture + message send (copy_struct)", + }, + { + name: "format_p_deep", + exercises: "erl_printf_term.c (term printer stack)", + expr: `Str = lists:flatten(io_lib:format("~w", [lists:seq(1,100)])), +{length(Str), erlang:phash2(Str)}`, + expected: "{293,100105088}", + why: "printing a 100-element list walks the printer stack past 16", + }, + { + name: "iolist_to_binary_deep", + exercises: "erl_iolist.c (EQUEUE iodata traversal)", + expr: `L = lists:foldl(fun(I,A) -> [<>, A, "x"] end, [], lists:seq(1,100)), +B = iolist_to_binary(L), +{byte_size(B), binary:first(B), binary:last(B), erlang:iolist_size(L)}`, + expected: "{200,100,120,200}", + why: "100-deep nested iolist; EQUEUE traversal depth >> 16", + }, + { + name: "compile_module", + exercises: "beam_asm (MD5-over-iolist) + general term traversal [end-to-end]", + expr: `Scan = fun(Str) -> + {ok,Ts,_} = erl_scan:string(Str), + {ok,F} = erl_parse:parse_form(Ts), + F + end, +Forms = [ Scan("-module(jin7t)."), + Scan("-export([f/1])."), + Scan("f(N) -> lists:sum(lists:seq(1,N)).") ], +{ok, jin7t, Bin} = compile:forms(Forms, [binary]), +code:load_binary(jin7t, "jin7t.beam", Bin), +{jin7t:f(100), is_binary(Bin), byte_size(Bin) > 100}`, + expected: "{5050,true,true}", + why: "on-platform compile; beam_asm MD5s BEAM chunks as an iolist (kd-qe2c)", + pendingPr: 824, + needsCodePath: ["lib/compiler-9.0.3/ebin"], + }, +]; + +/** Cases whose workaround is on the current base and should run now. */ +export function activeCases(all: SmokeCase[] = cases): SmokeCase[] { + return all.filter((c) => !c.pendingPr); +} + +/** Cases deferred behind an unmerged PR (reported as expected skips). */ +export function pendingCases(all: SmokeCase[] = cases): SmokeCase[] { + return all.filter((c) => c.pendingPr); +} + +/** + * Build a single Erlang `-eval` program that runs every case in ONE BEAM boot + * (BEAM startup dominates cost, so batching keeps the CI gate cheap). Active + * cases print `ok ` or `FAIL expected=.. got=..`; pending cases + * print `skip pending_pr_`; a final `matrix_done ` sentinel proves + * the program ran to completion (so a truncated/among-missing run is detectable, + * satisfying "fail on unexpected skip"). + */ +export function buildBatchProgram(all: SmokeCase[] = cases): string { + const lines: string[] = []; + lines.push( + `Run = fun(Nm, Fun, Exp) ->` + + ` Got = try lists:flatten(io_lib:format("~w", [Fun()]))` + + ` catch Cls:Err -> lists:flatten(io_lib:format("caught_~w_~w", [Cls, Err])) end,` + + ` case Got =:= Exp of` + + ` true -> io:format("ok ~s~n", [Nm]);` + + ` false -> io:format("FAIL ~s expected=~s got=~s~n", [Nm, Exp, Got])` + + ` end end,`, + ); + for (const c of all) { + if (c.pendingPr) { + lines.push( + `io:format("skip ~s pending_pr_~w~n", ["${c.name}", ${c.pendingPr}]),`, + ); + } else { + lines.push(`Run("${c.name}", fun() ->\n${c.expr}\nend, ${JSON.stringify(c.expected)}),`); + } + } + lines.push(`io:format("matrix_done ~w~n", [${all.length}]),`); + lines.push(`halt().`); + return lines.join("\n"); +} + +/** Line-oriented result of one batch run. */ +export interface BatchResult { + completed: boolean; // saw `matrix_done ` with matching n + ok: Set; + skipped: Map; // name -> pendingPr + failures: Map; // name -> full FAIL line +} + +/** Parse a batch run's stdout (which also contains serve.ts boilerplate). */ +export function parseBatchOutput(stdout: string, expectedCount: number): BatchResult { + const res: BatchResult = { + completed: false, + ok: new Set(), + skipped: new Map(), + failures: new Map(), + }; + for (const raw of stdout.split(/\r?\n/)) { + const line = raw.trim(); + let m: RegExpMatchArray | null; + if ((m = line.match(/^ok (\S+)$/))) res.ok.add(m[1]); + else if ((m = line.match(/^skip (\S+) pending_pr_(\d+)$/))) + res.skipped.set(m[1], Number(m[2])); + else if ((m = line.match(/^FAIL (\S+) /))) res.failures.set(m[1], line); + else if ((m = line.match(/^matrix_done (\d+)$/))) + res.completed = Number(m[1]) === expectedCount; + } + return res; +} diff --git a/packages/registry/erlang/wasm32-miscompilations.md b/packages/registry/erlang/wasm32-miscompilations.md new file mode 100644 index 000000000..999002e90 --- /dev/null +++ b/packages/registry/erlang/wasm32-miscompilations.md @@ -0,0 +1,216 @@ +# Erlang/OTP wasm32 `-O2` miscompilation registry + +**Source of truth** for the wasm32 `-O2` codegen workarounds applied by +`build-erlang.sh`, the smoke case that guards each one, and the criteria for +ever removing them. `build-erlang.sh` references this file next to every +workaround it applies; the detection smoke +(`test/erlang.test.ts` + `test/wasm32-miscompilation-matrix.mjs`) references it +by smoke-case name. Adding or removing a workaround is **one reviewable edit +here plus a matching smoke row** — that coupling is deliberate, so the fix, the +guard, and the rationale never drift apart. + +Design: `docs/plans/2026-07-02-erlang-wasm32-o2-miscompilation-coverage-design.md` +(`kd-r8h7`). Point-in-time audit of the full at-risk surface: +`test-runs/kd-jin7/audit-estack-wstack-20260702.md`. + +> One-line summary of the bug class: LLVM's wasm32 backend at `-O2` +> miscompiles ERTS's "control struct whose pointer fields alias a **shadow-stack +> local array**, walked by a push/pop loop" idiom (`ESTACK`/`WSTACK`/`EQUEUE`/ +> `DMC_STACK`). It manifests as **wrong results** (worst case silent) or an +> out-of-bounds trap, only once the term is big enough (> `DEF_*_SIZE == 16`) to +> leave the inline fast path. + +--- + +## Triage runbook — "Erlang returns wrong output / crashes on Kandelo" + +Follow this fixed path before assuming a new bug. It is the reactive process +made systematic: + +1. **Does the operation walk terms or iodata?** term compare/sort, `phash2`, + `term_to_binary`/`binary_to_term`, unicode conversion, ETS match/select, + iolist/iodata (`iolist_to_binary`, checksums), or `~p`/`~w` printing. If yes, + suspect this class. (If it does none of these, it is probably *not* this + class — e.g. `erl_bif_binary.c`/`erl_bif_re.c` do not use the idiom.) +2. **Reproduce with a minimal `-eval`,** then confirm the *same input is correct + on native OTP 28*. Native OTP is the oracle. A result that differs between + native and Kandelo for a pure term operation is almost certainly this class. +3. **Rebuild just the suspect translation unit at `-O1`** (add a one-line + `$(OBJDIR)/.o:` rule to the Makefile patch in `build-erlang.sh`, mirroring + the existing ones). If the result becomes correct, it is facet 2 (traversal + codegen). If it was a *trap* rather than a wrong value, a bounds guard + (see `patches/patch-db-bounds-check.py`) may be the better containment. +4. **Land the fix and its guard together:** add a row to the table below **and** + a row to the smoke matrix (`wasm32-miscompilation-matrix.mjs`) with a + native-OTP oracle, in the same change. Link the discovering bead/PR. + +--- + +## The bug class in one paragraph (facets) + +The idiom declares a fixed 16-slot array on the C stack (which lives in the +wasm **shadow stack** / linear memory, because its address is taken) and a small +control struct (`start`/`sp`/`end`/`edefault`) pointing into it. There are +**two independent facets**: + +- **Facet 1 — aggregate init.** The stock struct-literal initialization is + miscompiled; fields can hold wrong values. Fixed **globally** by + `patches/patch-global-h.py` (`estack_make_default_`/`wstack_make_default_`, + explicit field assignment under `#ifdef __wasm32__`). Covers all ESTACK/WSTACK + users. **Does not** cover `EQUEUE`/`DMC_STACK` (latent — see audit). +- **Facet 2 — traversal codegen.** Even with correct init, the push/pop/pointer + loop is miscompiled in specific functions. **Not** fixed by init; this is what + per-file `-O1` addresses. + +`patches/patch-db-bounds-check.py` (`wasm_db_ptr_valid`) is a third, orthogonal +mitigation that converts a miscompiled OOB *trap* into a controlled failure. It +reduces blast radius; it does not make results correct, so it is not a +substitute for `-O1`. + +**Key rule:** using the idiom is a *risk marker, not proof of breakage*. A file +can be correctly initialized (facet 1) and still wrong in its loop (facet 2). So +the init patch does **not** retire the risk, and detection (the smoke) is what +actually protects a given operation. + +--- + +## Applied workarounds (the `-O1` / patch set) + +Each row is a translation unit `build-erlang.sh` actively works around. "Guard" +is the smoke-matrix case name that must stay green. + +| File | Symbol / function | Facet | Workaround | Discovered by | Guard (smoke case) | +| --- | --- | --- | --- | --- | --- | +| `erts/emulator/beam/global.h` | `ESTACK`/`WSTACK` `*_DEFAULT_VALUE`, `DECLARE_*` | 1 (init) | `patch-global-h.py` (explicit field init, `__wasm32__`) | initial wasm32 bring-up | *all* ESTACK/WSTACK cases (global) | +| `erts/emulator/beam/erl_unicode.c` | iodata → list traversal | 2 (traversal) | `-O1` (Makefile patch) | reactive: unicode conversion returned garbage | `unicode_deep` | +| `erts/emulator/beam/erl_db_util.c` | `db_is_fully_bound`, DMC match stack | 2 + trap | `-O1` **and** `patch-db-bounds-check.py` (`wasm_db_ptr_valid`) | reactive: ETS match OOB crash | `ets_match` | +| `erts/emulator/beam/erl_db_hash.c` | `match_traverse` | 2 (traversal)¹ | `-O1` (Makefile patch) | reactive: ETS traverse corruption | `ets_match` | +| `erts/emulator/beam/erl_db.c` | (whole TU) | — (consistency)² | `-O1` (Makefile patch) | kept at `-O1` for a uniform ETS opt level | `ets_match` | +| `erts/emulator/beam/erl_bif_chksum.c` | `do_chksum` (md5/crc32/adler32 over iodata) | 2 (traversal) | `-O1` **via PR #824 (`kd-qe2c`)** | reactive: `erlang:md5/1` on iolist → `badarg` → broke `beam_asm` → on-Kandelo `erlc` unusable | `chksum_iolist` | + +¹ `erl_db_hash.c` `match_traverse` is a **hand-rolled** recursive traversal, not +a declared `ESTACK`/`WSTACK` site; its `-O1` was found reactively. It is in this +table because it is an applied workaround, not because it matches the idiom +grep. + +² `erl_db.c` has **no confirmed miscompile of its own**; it is `-O1` only so the +ETS subsystem builds at one optimization level. If Layer A ever proves it safe, +it is the first candidate to return to `-O2`. + +> **PR #824 sequencing.** On `origin/main` the `erl_bif_chksum.c` `-O1` row and +> its `chksum_iolist` smoke are **not present yet** — they land with PR #824 +> (`kd-qe2c`). Anything that adds the `chksum_iolist` smoke must land with or +> after #824, or the guard fails against a still-`-O2` chksum. + +--- + +## Audited but detection-only (no workaround applied) + +The audit found high-risk idiom users with **no** confirmed miscompile. We do +**not** proactively `-O1` them (several are hot paths; unmeasured perf cost is a +design non-goal). They are covered by the smoke matrix so a *new* miscompile +fails CI instead of a user: + +| File | Risk | Why detection-only | Guard (smoke case) | +| --- | --- | --- | --- | +| `erl_term_hashing.c` | HIGH | `make_hash`/`make_hash2` = `phash2` + internal map/ETS hashing; very hot | `phash2_deep` | +| `utils.c` | HIGH | `eq`/`cmp` underlie `==`, `<`, `lists:sort`, map keys; hot | `term_compare_sort` | +| `external.c` | HIGH | `enc_term`/`dec_term`; distribution + persistence | `term_to_binary_roundtrip` | +| `erl_map.c` | HIGH | highest idiom density; maps are pervasive | `term_compare_sort` (deep map keys) | +| `copy.c` | MEDIUM | `copy_struct`/`size_object`; message copy | `copy_large_term` | +| `erl_printf_term.c` | MEDIUM | `~p`/`~w` term printer | `format_p_deep` | +| `erl_iolist.c` / `erl_io_queue.c` | MEDIUM | `EQUEUE` iodata class (same family as chksum) | `iolist_to_binary_deep` | + +Plus the end-to-end guard `compile_module` (`erlc`/`compile:file` of a +non-trivial module on-platform) — the original `kd-qe2c` failure mode, which +exercises `beam_asm`'s MD5-over-iolist plus general term traversal. + +--- + +## CI gate wiring (Layer A.2) — how to promote the smoke to a hard gate + +The detection matrix has two runners sharing one source +(`test/wasm32-miscompilation-matrix.ts`): + +- **Local/dev:** `test/erlang.test.ts` (vitest), `skipIf` no local build. Runs + the whole matrix in one BEAM boot; pending-PR cases are reported skips. +- **CI gate:** `test/run-wasm32-miscompilation-smoke.ts` — one boot, exits + non-zero on any mismatch, on incompletion, and (critically) **if no active + case ran** (a missing OTP runtime tree → false coverage, the design's + highest-listed risk). Emits passed/failed/skipped outcome lists to + `MISCOMP_OUTCOME_DIR`. + +Drop this step into the Homebrew **bottle-build / smoke job** (the recommended +gate home — the OTP runtime tree is already staged there, next to the `hello` +smoke and the framebuffer gate), after `erlang.wasm` + the `erlang-install/` +tree are in place: + +```yaml +- name: erlang wasm32 -O2 miscompilation smoke + run: > + bash scripts/dev-shell.sh npx tsx + packages/registry/erlang/test/run-wasm32-miscompilation-smoke.ts + env: + MISCOMP_OUTCOME_DIR: test-runs/erlang-wasm32-miscomp +# then upload test-runs/erlang-wasm32-miscomp/* as durable outcome lists +``` + +Alternative gate home (if the bottle job is not the chosen host): a host test +that fetches + extracts the published `erlang-otp.tar.zst` sidecar into +`erlang-install/` first, then runs the same command. Requires the sidecar to be +a published, stable artifact (tracked by `kd-yuef`). + +**Promotion status / blockers (as of 2026-07-02).** The runner is complete and +verified (native-OTP self-check: 8/8 active pass, 2 pending skip; negative +control fails; false-coverage guard fails). It is **not yet a hard PR gate** +because on `origin/main`: + +1. There is no bottle-build/smoke job — it lives on the unmerged `kd-8ho` + publishing branches; the framebuffer gate (`kd-ivdr` / spec `kd-jg94`) is + likewise not on main. Coordinate the insertion point with those. +2. `erlang` is in CI's `disabled_pkgs` ("too slow to rebuild in CI") and in + `WASM_POSIX_FETCH_SKIP_PKGS`, and the `erlang-otp` runtime tree is not in the + published `binaries-abi` release — so no current CI job has the tree. Wiring + it as a hard gate today would trip the false-coverage guard on every run. + +Do **not** wire this into the current PR CI as a hard gate until (1) a job with +the runtime tree exists and (2) `chksum_iolist` / `compile_module` are un-gated +by PR #824. Until then the runner is invoked manually under `dev-shell` against +a local build, and the local vitest runner provides dev coverage. + +## Removal checklist (Layer C exit ramp) + +The `-O1` set is a permanent perf tax until removed. Remove a downgrade only +when **all** hold, and do it one file at a time: + +- [ ] The toolchain pin (`flake.nix` LLVM) has advanced **past** a release with + the fixed wasm32 backend, and the fix is cited here. +- [ ] The file's smoke case(s) pass at `-O2` with the `-O1` rule removed, in the + **CI gate** runner (not just locally). +- [ ] For `erl_db.c` specifically: only remove after `erl_db_util.c` / + `erl_db_hash.c` are proven safe (it is `-O1` for consistency with them). +- [ ] Update this table and the smoke matrix in the same commit; keep the smoke + case (now a `-O2` regression guard). + +No `-O1` is removed as a side effect of an OTP bump or a "cleanup" — it is a +deliberate, smoke-verified act. + +--- + +## Re-audit trigger (OTP version bump) + +**On any `OTP_VERSION` bump in `build-erlang.sh`, re-run the audit** — a new OTP +release can move code between files, add ESTACK/WSTACK/EQUEUE users, or shift the +line/text anchors the Makefile and `global.h` patches key off: + +```sh +# from a fetched source tree: packages/registry/erlang/erlang-src +grep -rlE 'DECLARE_ESTACK|DECLARE_WSTACK|WSTACK_DECLARE|ESTACK_DECLARE|DECLARE_EQUEUE' \ + erts/emulator/beam +``` + +Then: (1) diff the file list against this registry; (2) confirm every applied +`-O1` file still exists and its patch anchor text is present (the patches +`exit`-fail loudly if an anchor is missing — do not "fix" that by skipping); +(3) re-validate every smoke oracle on the new native OTP and update any that +changed, noting the OTP version in the matrix. diff --git a/test-runs/kd-jin7/SUMMARY.md b/test-runs/kd-jin7/SUMMARY.md new file mode 100644 index 000000000..d740acdaa --- /dev/null +++ b/test-runs/kd-jin7/SUMMARY.md @@ -0,0 +1,77 @@ +# kd-jin7 — wasm32 -O2 miscompilation coverage (kd-r8h7 steps 1-4) + +Date: 2026-07-02. Branch base: `origin/main` via convoy base f4339836e. +Implements the low-risk core (steps 1-4) of the `kd-r8h7` design: +`docs/plans/2026-07-02-erlang-wasm32-o2-miscompilation-coverage-design.md`. + +## Delivered + +- **Step 1 — Audit (Layer B2).** ESTACK/WSTACK/EQUEUE/DMC risk audit of OTP 28.2 + `erts/emulator/beam`. Artifacts: `audit-estack-wstack-20260702.md`, + `audit-grep-raw.txt`. Corrected the design's guessed list (refuted + `erl_bif_binary.c`/`erl_bif_re.c`; added `erl_term_hashing.c`, `erl_iolist.c`, + `erl_io_queue.c`); surfaced a latent gap (facet-1 init patch covers only + ESTACK/WSTACK, not EQUEUE/DMC_STACK). +- **Step 2 — Registry (Layer B1).** `packages/registry/erlang/wasm32-miscompilations.md` + (triage runbook, applied-workaround table with chksum marked PR #824, + detection-only table, CI wiring, removal checklist, OTP-bump re-audit trigger), + referenced from `build-erlang.sh` at both workaround sites. Doc/comment-only: + **no build output byte change → `build.toml` revision NOT bumped.** +- **Step 3 — Smoke matrix + local runner (Layer A.1).** + `test/wasm32-miscompilation-matrix.ts` (single source of `{name, expr, + expected}` with native-OTP-28 oracles, inputs sized past + `DEF_*_SIZE == 16`); `test/erlang.test.ts` extended to run the whole matrix in + one BEAM boot under `skipIf`. +- **Step 4 — CI gate runner (Layer A.2).** + `test/run-wasm32-miscompilation-smoke.ts` — one boot, fails on mismatch, on + incompletion, and on the false-coverage case (no active case ran). Emits + passed/failed/skipped outcome lists. Wiring + promotion path documented in the + registry (PR-gate promotion is blocked on infra — see Limitations). + +## Verification (native OTP 28 — authoritative oracle source) + +A real from-source `erlang.wasm` build was not run (see Limitations); the matrix +was instead validated end-to-end on native OTP 28 via `scripts/dev-shell.sh`, +which is exactly where the design says oracles must come from. + +- **Positive self-check** (`native-selfcheck.txt`, `gate-outcomes-native/`): + `GATE PASS`, exit 0. **8/8 active pass, 2 pending skip, matrix_done=10.** +- **Negative control:** corrupting one oracle produces + `FAIL term_to_binary_roundtrip expected={deliberately,wrong} got={true,2741}` + and a non-zero gate — proves the gate catches a plausible-but-wrong regression. +- **False-coverage guard:** with the OTP tree absent (BEAM emits nothing) the + gate FAILs (`NO active case executed …`), exit 1 — the design's highest-listed + risk is actively guarded, not silently passed. +- **Oracle cross-check:** `md5`/`crc32`/`adler32` oracle verified byte-for-byte + against Python `hashlib`/`zlib`. Hand-verified oracles: `ets_match` + {50,3775,51,100}, `iolist_to_binary_deep` {200,100,120,200}, `format_p_deep` + length 293. +- **Syntax:** all three TS files pass esbuild transform. + +### Outcome counts + +| Category | Count | List | +| --- | --- | --- | +| passed (active) | 8 | `gate-outcomes-native/passed-tests.txt` | +| failed | 0 | `gate-outcomes-native/failed-tests.txt` | +| skipped (pending PR #824) | 2 | `gate-outcomes-native/skipped-tests.txt` | + +## Limitations / skipped-with-reason + +- **Full matrix on real `erlang.wasm`: not executed.** `erlang` is in CI's + `disabled_pkgs` ("too slow to rebuild") and a from-source wasm build was out + of proportion for a detection/registry change. Mitigation: validated on native + OTP 28 (the oracle source); the runner is verified and ready for the gate job. + A correctly-built Kandelo BEAM must reproduce the 8 oracles; a miscompiled one + diverges (negative control proves detection). +- **Local `vitest` run of `erlang.test.ts`: not executed here.** The worktree + has no `node_modules` and no local erlang build, so the suite `skipIf`s (by + design). Syntax validated via esbuild; logic validated by running the shared + matrix/runner directly on native OTP. +- **CI PR-gate wiring: deferred (documented, not landed).** The bottle-build/ + smoke job + framebuffer gate (`kd-ivdr`/`kd-jg94`) are not on `origin/main`, + and no current CI job has the OTP runtime tree, so a hard gate would trip the + false-coverage guard. Follow-up bead filed for promotion; coordinated by mail. +- **`chksum_iolist` + `compile_module`: pending PR #824 (`kd-qe2c`).** Their + `-O1`/fix is not on this base, so they are reported as expected skips until + #824 lands (flip `pendingPr` in the same change). diff --git a/test-runs/kd-jin7/audit-estack-wstack-20260702.md b/test-runs/kd-jin7/audit-estack-wstack-20260702.md new file mode 100644 index 000000000..e7b9171df --- /dev/null +++ b/test-runs/kd-jin7/audit-estack-wstack-20260702.md @@ -0,0 +1,147 @@ +# wasm32 -O2 ESTACK/WSTACK Risk Audit — Erlang/OTP 28.2 ERTS + +Bead: `kd-jin7` (impl of `kd-r8h7` design, step 1 — Layer B2 audit). +Date: 2026-07-02. No repo change; this is the enumeration that prioritizes the +Layer A smoke matrix and any future per-file `-O1` decision. + +## Method + +Source: `otp_src_28.2.tar.gz` (the exact tarball `build-erlang.sh` fetches for +`OTP_VERSION=28.2`; `package.toml [source]` pins `OTP-28.2`, sha256 +`b984f9e0…d141f`). Grepped the raw ERTS emulator source (no wasm build needed +— the audit only reads C source): + +```sh +# from erts/emulator/beam +grep -rlE 'DECLARE_ESTACK|DECLARE_WSTACK|WSTACK_DECLARE|ESTACK_DECLARE' . +``` + +Raw output committed alongside this file: `audit-grep-raw.txt`. + +## The idiom is broader than "ESTACK/WSTACK" + +The bug class is "a control struct whose pointer fields alias a **shadow-stack +local array**, initialized by aggregate/compound-literal assignment, then walked +by a push/pop loop." In ERTS that shape appears under **four** macro/coding +families, not just the two the design named: + +| Idiom family | Declared by | Example users | +| ------------------- | ------------------------------- | ------------- | +| `ESTACK` (Eterm) | `DECLARE_ESTACK` | utils.c, external.c, copy.c | +| `WSTACK` (UWord) | `WSTACK_DECLARE`/`DECLARE_WSTACK` | erl_map.c, erl_term_hashing.c | +| `EQUEUE` (Eterm) | `DECLARE_EQUEUE` | erl_iolist.c, erl_io_queue.c (3 users) | +| `DMC_STACK` | match-compiler local stack | erl_db_util.c (192 ops) | +| function-recursive traversal (not a declared stack) | hand-rolled | erl_db_hash.c `match_traverse` | + +`DEF_ESTACK_SIZE == DEF_WSTACK_SIZE == DEF_EQUEUE_SIZE == 16` (global.h). The +on-C-stack default array holds 16 slots; only when a term is deep/large enough +to overflow 16 does ERTS switch to the heap-backed stack whose miscompiled +push/pop loop is the failure. **Consequence for Layer A: every smoke input must +exceed 16 in the relevant dimension** (nesting depth, list length, map arity, +iolist fragment count) or it never enters the buggy path. + +## The two defect facets (why the global.h patch is necessary but not sufficient) + +1. **Aggregate init** (facet 1) — fixed globally by + `patches/patch-global-h.py`, which replaces the struct-literal init of + `ESTACK_DEFAULT_VALUE` / `DECLARE_ESTACK` / `WSTACK_DEFAULT_VALUE` / + `WSTACK_DECLARE` with explicit field-by-field assignment under + `#ifdef __wasm32__`. Covers every ESTACK/WSTACK user. Does **not** cover + EQUEUE or DMC_STACK (they are not wrapped) — a latent gap noted below. +2. **Traversal codegen** (facet 2) — NOT fixed by init. `-O2` still miscompiles + the push/pop/pointer-arithmetic loop in specific functions; this is what the + per-file `-O1` Makefile downgrades address. A file can be correctly + initialized (facet 1) and still wrong in its loop (facet 2), so idiom-use is + a **risk marker, not a proof of breakage**, and correct init does not retire + the risk. + +`patches/patch-db-bounds-check.py` (`wasm_db_ptr_valid` in erl_db_util.c) is an +orthogonal third mitigation: it turns a miscompiled OOB *trap* into a controlled +failure. It bounds blast radius; it does not make results correct. + +## At-risk surface (15 sites), ranked by user-facing risk + +Ops = count of declare+push macro invocations in the file (proxy for how +stack-heavy it is). Function attribution confirmed by grep. + +### Already handled on this convoy base (origin/main, f4339836e) + +| File | Ops | Workaround today | Facet | Notes | +| --- | --- | --- | --- | --- | +| erl_unicode.c | 12 | `-O1` | traversal | Known-hit: iodata→list returns garbage. Uses ESTACK. | +| erl_db_util.c | 17 (ESTACK) + DMC_STACK | `-O1` + bounds guard | traversal + trap | Known-hit: `db_is_fully_bound` OOB. Heavy DMC_STACK (192 ops). | +| erl_db_hash.c | — (no declared idiom) | `-O1` | traversal | Known-hit: `match_traverse` corruption. **Not an ESTACK decl site** — reactive `-O1` on a hand-rolled traversal. | +| erl_db.c | — (no declared idiom) | `-O1` | (consistency) | `-O1` only "for consistent ETS optimization level" per build comment; **no confirmed miscompile of its own**. | +| erl_bif_chksum.c | 3 | `-O1` **via PR #824 (kd-qe2c)** | traversal | Known-hit: `do_chksum` over iodata → md5/crc32/adler32 garbage → broke `beam_asm` MD5-of-iolist → on-Kandelo `erlc` unusable. **Not yet on origin/main**; arrives with PR #824. | +| global.h | 15 | init patch (facet 1) | init | The global aggregate-init fix; covers ESTACK/WSTACK for all users. | + +### HIGH — pervasive, hot, failure = silent wrong result; unaudited by smoke + +| File | Ops | Function(s) | Why high | +| --- | --- | --- | --- | +| erl_map.c | 36 | map build/compare/iter | Highest idiom density; maps back ETS keys, records, JSON-ish data. | +| external.c | 30 | `enc_term`/`dec_term`/`encode_size_struct` | `term_to_binary`/`binary_to_term`; distribution + persistence. Design-named gap. | +| erl_term_hashing.c | 30 | `make_hash`/`make_hash2` | **NEW — not in design's guess.** `erlang:phash/2`, `phash2/1`, and *internal* hashing for maps/ETS. Extremely hot and pervasive; a wrong hash silently corrupts map/ETS lookup. | +| utils.c | 23 | `eq`, `cmp`/`erts_cmp` | Term equality/ordering; underlies `==`, `<`, `lists:sort`, map key compare. Design-named. | + +### MEDIUM + +| File | Ops | Function(s) | Why medium | +| --- | --- | --- | --- | +| erl_printf_term.c | 16 | term printer | `io_lib:format("~p"/"~w")`; wrong output is visible but not silent corruption. | +| copy.c | 10 | `copy_struct`/`size_object` | Message send + `binary_to_term` sizing; hot but simpler traversal. Design-named. | +| erl_proc_sig_queue.c | 6 | signal-queue walk | Inter-process signals; deep queues rare. | +| erl_iolist.c | 5 | iolist size/collect (EQUEUE) | **Iodata class** — same family as the chksum/beam_asm hit; `iolist_to_binary`, `iolist_size`. | +| erl_io_queue.c | 5 | driver io-queue (EQUEUE) | Iodata via driver path. | + +### LOW / incidental + +| File | Ops | Note | +| --- | --- | --- | +| io.c | 2 | error/format helper use; not a term-walk hot path. | +| erl_process.c | 2 | scheduler bookkeeping; not term-data-dependent. | + +## Corrections to the design's *guessed* candidate list + +The design explicitly said "confirm with the grep above; do not treat this list +as authoritative." The audit confirms and corrects it: + +- **Confirmed HIGH:** utils.c, external.c, copy.c — all present and idiom-heavy. ✔ +- **Confirmed MEDIUM:** erl_map.c, erl_printf_term.c — present. ✔ +- **REFUTED:** `erl_bif_binary.c` and `erl_bif_re.c` — the design listed both as + medium, but **both have zero ESTACK/WSTACK/EQUEUE usage**. They traverse via + other mechanisms; they are not this idiom and should be dropped from the + idiom-coverage matrix (they may still merit separate scrutiny, but not here). +- **ADDED (missed by the design):** `erl_term_hashing.c` (HIGH — phash/phash2 + + internal map/ETS hashing), `erl_iolist.c` / `erl_io_queue.c` (EQUEUE, the + iodata class that actually produced the worst known bug), and + `erl_proc_sig_queue.c`. +- **Clarified the "handled" set:** only erl_unicode.c, erl_db_util.c, and + erl_bif_chksum.c(#824) are ESTACK/DMC idiom sites with a confirmed + miscompile. `erl_db_hash.c` is a hand-rolled `match_traverse` (reactive `-O1`, + no declared idiom) and `erl_db.c` is `-O1` purely for consistency with no + confirmed defect of its own. + +## Latent gaps this audit surfaces (for follow-up, not this bead) + +1. **EQUEUE and DMC_STACK are not covered by the facet-1 init patch.** The + global.h patch only wraps ESTACK/WSTACK. `DECLARE_EQUEUE` (erl_iolist.c, + erl_io_queue.c) and the DMC match stack (erl_db_util.c) use the same + shadow-stack-local-array shape but keep the stock aggregate init. If facet 1 + ever bites them, it is unpatched. Worth a targeted review. +2. **Proactive `-O1` is NOT recommended for the HIGH files** (utils.c, copy.c, + erl_term_hashing.c are hot paths; `erl_gc.c`-class reasoning applies). Prefer + detection (Layer A) + upstream (Layer C). No proactive downgrade without a + measured perf delta (design step 6). + +## Output → Layer A coverage requirement + +The smoke matrix (step 3) must hit every HIGH file at least once with inputs +> 16 in the relevant dimension: + +- utils.c → deep term `==` + `lists:sort` of heterogeneous deep terms. +- external.c → `term_to_binary`/`binary_to_term` round-trip on a deep nested term. +- erl_term_hashing.c → `phash2` of a deep nested term (oracle from native OTP). +- erl_map.c → large map (>16 keys) build/compare, exercised via sort/compare. +- Plus the known-hit regression guards: unicode, chksum (behind #824), ETS match. +- iodata (erl_iolist.c) → deep `iolist_to_binary` / `iolist_size`. diff --git a/test-runs/kd-jin7/audit-grep-raw.txt b/test-runs/kd-jin7/audit-grep-raw.txt new file mode 100644 index 000000000..daa104183 --- /dev/null +++ b/test-runs/kd-jin7/audit-grep-raw.txt @@ -0,0 +1,50 @@ +# Raw ESTACK/WSTACK audit grep output — OTP 28.2 erts/emulator/beam +# source: otp_src_28.2.tar.gz (package.toml [source] OTP-28.2), sha256 verified by download +# command: grep -rlE 'DECLARE_ESTACK|DECLARE_WSTACK|WSTACK_DECLARE|ESTACK_DECLARE' erts/emulator/beam + +## files (declaration idiom): +copy.c +erl_bif_chksum.c +erl_db_util.c +erl_io_queue.c +erl_iolist.c +erl_map.c +erl_printf_term.c +erl_proc_sig_queue.c +erl_process.c +erl_term_hashing.c +erl_unicode.c +external.c +global.h +io.c +utils.c + +## per-file op counts (declare+push): + 36 erl_map.c + 30 external.c + 30 erl_term_hashing.c + 23 utils.c + 17 erl_db_util.c + 16 erl_printf_term.c + 15 global.h + 12 erl_unicode.c + 10 copy.c + 6 erl_proc_sig_queue.c + 5 erl_iolist.c + 5 erl_io_queue.c + 3 erl_bif_chksum.c + 2 io.c + 2 erl_process.c + +## DEF sizes (heap-path thresholds): +322:#define DEF_ESTACK_SIZE (16) +493:#define DEF_WSTACK_SIZE (16) +837:#define DEF_EQUEUE_SIZE (16) + +## db_util idiom split: +ESTACK ops: 18 +DMC_STACK ops: 192 + +## design-guessed files that do NOT use the idiom (refuted): +erl_bif_binary.c: ESTACK|WSTACK count = 0 +erl_bif_re.c: ESTACK|WSTACK count = 0 diff --git a/test-runs/kd-jin7/gate-outcomes-native/failed-tests.txt b/test-runs/kd-jin7/gate-outcomes-native/failed-tests.txt new file mode 100644 index 000000000..e69de29bb diff --git a/test-runs/kd-jin7/gate-outcomes-native/passed-tests.txt b/test-runs/kd-jin7/gate-outcomes-native/passed-tests.txt new file mode 100644 index 000000000..8f5b6ed8e --- /dev/null +++ b/test-runs/kd-jin7/gate-outcomes-native/passed-tests.txt @@ -0,0 +1,8 @@ +copy_large_term +ets_match +format_p_deep +iolist_to_binary_deep +phash2_deep +term_compare_sort +term_to_binary_roundtrip +unicode_deep diff --git a/test-runs/kd-jin7/gate-outcomes-native/skipped-tests.txt b/test-runs/kd-jin7/gate-outcomes-native/skipped-tests.txt new file mode 100644 index 000000000..b65cb9461 --- /dev/null +++ b/test-runs/kd-jin7/gate-outcomes-native/skipped-tests.txt @@ -0,0 +1,2 @@ +chksum_iolist pending_pr_824 +compile_module pending_pr_824 diff --git a/test-runs/kd-jin7/native-selfcheck.txt b/test-runs/kd-jin7/native-selfcheck.txt new file mode 100644 index 000000000..5875cfd1e --- /dev/null +++ b/test-runs/kd-jin7/native-selfcheck.txt @@ -0,0 +1,6 @@ +wasm32 -O2 miscompilation smoke — oracle OTP 28.2 (oracles validated on ERTS 16.4; results format-stable across OTP 28.x) + passed: 8/8 active [copy_large_term, ets_match, format_p_deep, iolist_to_binary_deep, phash2_deep, term_compare_sort, term_to_binary_roundtrip, unicode_deep] + skipped: 2 pending [chksum_iolist, compile_module] + +GATE PASS: all active miscompilation guards green. +GATE_EXIT=0 diff --git a/test-runs/kd-jin7/oracle-native-otp28.txt b/test-runs/kd-jin7/oracle-native-otp28.txt new file mode 100644 index 000000000..ec9dfeb5e --- /dev/null +++ b/test-runs/kd-jin7/oracle-native-otp28.txt @@ -0,0 +1,12 @@ +warning: Git tree '/Users/brandon/src/kandelo' has uncommitted changes +kandelo dev shell — LLVM 21.1.7, Rust (pinned via rust-toolchain.toml), Node 24, Erlang 28 (minimal), SDK on PATH +term_to_binary_roundtrip {true,2741} +unicode_deep {650,88400} +chksum_iolist {<<140,120,254,181,217,34,17,213,230,74,169,247,123,44,26,193>>,2948946130,2665225928} +ets_match {50,3775,51,100} +term_compare_sort {true,45350027} +phash2_deep 64685795 +copy_large_term {true,60113841} +format_p_deep {293,100105088} +iolist_to_binary_deep {200,100,120,200} +compile_module {5050,true,true}