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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/porting-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,19 @@ hardcoding it.
in `depends_on`. The resolver builds deps before you and exports
their paths via `WASM_POSIX_DEP_<NAME>_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

Expand Down
15 changes: 15 additions & 0 deletions packages/registry/erlang/build-erlang.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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\
Expand Down
50 changes: 49 additions & 1 deletion packages/registry/erlang/test/erlang.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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})`, () => {});
}
});
158 changes: 158 additions & 0 deletions packages/registry/erlang/test/run-wasm32-miscompilation-smoke.ts
Original file line number Diff line number Diff line change
@@ -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=<dir> Write passed/failed/skipped outcome lists
* (durable artifacts per the validation std).
* MISCOMP_TIMEOUT_MS=<n> 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();
Loading
Loading