Skip to content

Commit ed3e48c

Browse files
avrabeclaude
andauthored
fix(arm): optimized path declines br_table to the direct selector (#507, #242) (#508)
* fix(arm): optimized path declines br_table to the direct selector (#507, #242) On the optimized (non-`--relocatable`) path, `synth compile` SILENTLY miscompiled `br_table`: the dispatch is dropped during the wasm→IR lowering (`optimize_full`), so `ir_to_arm` emitted the arm bodies in fall-through sequence with no `cmp`/branch on the selector — the selector value isn't even loaded, so every input hits the last arm (exit 0, no warning, violating the #180/#185 Ok-or-Err contract). The `--relocatable` direct path lowered the identical module correctly, so gust and the shipped falcon firmware (both `--relocatable`) were unaffected — this was an optimized-path-only hazard. Because the dispatch is dropped BEFORE `ir_to_arm`, there's no `Err` to fall back on. The fix detects `br_table` on the raw wasm op stream in `arm_backend` and forces the direct selector (`select_with_stack`), which lowers it as a real cmp-chain. Same decline-to-direct shape as #496 / issue-#120: honest degradation, the function compiles correctly without IR-level optimization. The declined output is byte-identical to `--no-optimize`. Frozen-safe by construction: the frozen byte gate compiles `--relocatable` (already direct), and no optimized-path fixture (control_step, flight_algo) contains `br_table`, so they stay on the optimized path bit-identically. Gated by scripts/repro/br_table_507_differential.py (CI: br-table-507-oracle): two table shapes (sequential, shared-tail) × every selector incl. out-of-range, compiled via the DEFAULT optimized path, execute bit-identically to wasmtime; pre-fix every non-last selector mismatched. Unit test test_507_br_table_declines_to_direct asserts the optimized-default bytes equal the direct selector's. Frozen bit-identical; control_step (13/13), r12-spill-496, stack-args-503, block-brif-483 oracles all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style: rustfmt the #507 test's I32Store literals Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4591ba4 commit ed3e48c

3 files changed

Lines changed: 271 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,3 +497,42 @@ jobs:
497497
env:
498498
SYNTH: ./target/debug/synth
499499
run: python scripts/repro/stack_args_503_differential.py
500+
501+
br-table-507-oracle:
502+
name: optimized-path br_table oracle
503+
# VCR-ORACLE-001 (#242, #507): EXECUTE br_table dispatch compiled via the
504+
# DEFAULT optimized path (no --relocatable) under unicorn (UC_ARCH_ARM /
505+
# Thumb) and diff the resulting linear memory vs wasmtime across every
506+
# selector (incl. out-of-range → default arm) and two table shapes. Guards
507+
# the #507 fix: the optimized path DROPPED the br_table dispatch during
508+
# wasm→IR, emitting the arm bodies in fall-through with no selector compare —
509+
# a silent miscompile (every input hit the last arm). The fix detects
510+
# br_table on the raw wasm op stream and forces the direct selector (which
511+
# lowers it as a cmp-chain). The frozen byte gate only covers the
512+
# --relocatable direct path, so nothing else exercises the default path's
513+
# br_table lowering. Isolated job: emulation deps pip-installed here ONLY.
514+
runs-on: ubuntu-latest
515+
steps:
516+
- uses: actions/checkout@v7
517+
- uses: dtolnay/rust-toolchain@stable
518+
- name: Cache Cargo dependencies
519+
uses: actions/cache@v5
520+
with:
521+
path: |
522+
~/.cargo/registry
523+
~/.cargo/git
524+
target/
525+
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
526+
restore-keys: |
527+
${{ runner.os }}-cargo-
528+
- name: Build synth
529+
run: cargo build -p synth-cli
530+
- uses: actions/setup-python@v5
531+
with:
532+
python-version: "3.x"
533+
- name: Install emulation deps
534+
run: pip install wasmtime unicorn pyelftools
535+
- name: Run br_table oracle
536+
env:
537+
SYNTH: ./target/debug/synth
538+
run: python scripts/repro/br_table_507_differential.py

crates/synth-backend/src/arm_backend.rs

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,23 @@ fn compile_wasm_to_arm(
314314
// host-linked object, where the linmem base arrives via `fp` at runtime and
315315
// callees follow AAPCS. `select_with_stack` (now i64-spill capable after
316316
// #171) handles fp-relative memory + caller-saved preservation correctly.
317-
let arm_instrs = if config.no_optimize || config.relocatable {
317+
//
318+
// #507: `br_table` is DROPPED during the optimized path's wasm→IR lowering
319+
// (`optimize_full`), so `ir_to_arm` never sees the dispatch — it emits the
320+
// arm bodies in fall-through sequence with no `cmp`/branch on the selector, a
321+
// SILENT miscompile (every input hits the last arm). The selector value isn't
322+
// even loaded. Because the drop happens before `ir_to_arm`, there's no `Err`
323+
// to fall back on; detect it on the raw wasm op stream here and force the
324+
// direct selector (`select_with_stack` lowers `br_table` correctly as a
325+
// cmp-chain — confirmed on the `--relocatable` path). Same honest-degradation
326+
// contract as the issue-#120 f32 decline: the function still compiles
327+
// correctly, just without IR-level optimization. Frozen-safe: the frozen
328+
// fixtures compile `--relocatable` (already direct), and no optimized-path
329+
// fixture (control_step, flight_algo) contains `br_table`.
330+
let has_br_table = wasm_ops
331+
.iter()
332+
.any(|op| matches!(op, WasmOp::BrTable { .. }));
333+
let arm_instrs = if config.no_optimize || config.relocatable || has_br_table {
318334
select_direct()?
319335
} else {
320336
let opt_config = if config.loom_compat {
@@ -1306,6 +1322,72 @@ mod tests {
13061322
);
13071323
}
13081324

1325+
/// #507: a `br_table` function compiled via the DEFAULT (optimized) config
1326+
/// must produce the SAME bytes as the direct (`no_optimize`) selector —
1327+
/// i.e. the optimized path declined it to direct, lowering the dispatch as a
1328+
/// real cmp-chain instead of silently dropping it (which left all arms in
1329+
/// fall-through). Pre-fix the two outputs differed (the optimized one had no
1330+
/// selector compare). Execution correctness is gated by
1331+
/// `scripts/repro/br_table_507_differential.py`.
1332+
#[test]
1333+
fn test_507_br_table_declines_to_direct() {
1334+
let backend = ArmBackend::new();
1335+
// dispatch(sel): br_table over 3 blocks, each storing a marker to mem[0].
1336+
let ops = vec![
1337+
WasmOp::Block,
1338+
WasmOp::Block,
1339+
WasmOp::Block,
1340+
WasmOp::LocalGet(0),
1341+
WasmOp::BrTable {
1342+
targets: vec![0, 1, 2],
1343+
default: 2,
1344+
},
1345+
WasmOp::End,
1346+
WasmOp::I32Const(0),
1347+
WasmOp::I32Const(10),
1348+
WasmOp::I32Store {
1349+
offset: 0,
1350+
align: 2,
1351+
},
1352+
WasmOp::Return,
1353+
WasmOp::End,
1354+
WasmOp::I32Const(0),
1355+
WasmOp::I32Const(20),
1356+
WasmOp::I32Store {
1357+
offset: 0,
1358+
align: 2,
1359+
},
1360+
WasmOp::Return,
1361+
WasmOp::End,
1362+
WasmOp::I32Const(0),
1363+
WasmOp::I32Const(30),
1364+
WasmOp::I32Store {
1365+
offset: 0,
1366+
align: 2,
1367+
},
1368+
];
1369+
let opt = CompileConfig {
1370+
target: TargetSpec::cortex_m4(),
1371+
..CompileConfig::default()
1372+
};
1373+
let direct = CompileConfig {
1374+
target: TargetSpec::cortex_m4(),
1375+
no_optimize: true,
1376+
..CompileConfig::default()
1377+
};
1378+
let a = backend
1379+
.compile_function("dispatch", &ops, &opt)
1380+
.expect("optimized-default must compile br_table (via decline)");
1381+
let b = backend
1382+
.compile_function("dispatch", &ops, &direct)
1383+
.expect("direct must compile br_table");
1384+
assert_eq!(
1385+
a.code, b.code,
1386+
"#507: optimized-default br_table output must be byte-identical to the \
1387+
direct selector (i.e. declined to direct), not a dropped dispatch"
1388+
);
1389+
}
1390+
13091391
/// Issue #94: end-to-end byte-size check for the canonical u64-packed
13101392
/// FFI-return hi32 extract pattern. Compiles two near-identical
13111393
/// functions — one with the optimized shift-by-32, one with a generic
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#!/usr/bin/env python3
2+
"""#507 (epic #242) — EXECUTION-validate the optimized-path br_table fix.
3+
4+
On the optimized (non-`--relocatable`) path, `synth compile` silently
5+
miscompiled `br_table`: the dispatch is DROPPED during the wasm→IR lowering, so
6+
the arm bodies were emitted in fall-through sequence with no `cmp`/branch on the
7+
selector — every input hit the last arm (a silent wrong-code, exit 0). The
8+
`--relocatable` direct path lowered it correctly. The fix detects `br_table` on
9+
the raw wasm op stream and forces the direct selector (which lowers it as a
10+
cmp-chain), so the DEFAULT `synth compile` is correct too.
11+
12+
This harness compiles the fixture via the DEFAULT optimized path (no
13+
`--relocatable`), runs `dispatch(sel)` under unicorn (UC_ARCH_ARM / Thumb) for
14+
each selector, and asserts the resulting linear memory matches wasmtime. Symbols
15+
come from the ELF symtab (SHT_SYMTAB); the linmem base is read from
16+
`__linear_memory_base` (mask the Thumb bit on the function symbol).
17+
18+
NON-VACUITY: pre-fix every selector yielded mem[0]=40 (the last arm); the fix
19+
changed the function's bytes (the optimized fall-through became a cmp-chain,
20+
byte-identical to `--no-optimize`). A regression that re-drops the dispatch
21+
re-breaks every non-last selector here.
22+
23+
Run (needs wasmtime + unicorn + pyelftools):
24+
SYNTH=./target/debug/synth python scripts/repro/br_table_507_differential.py
25+
Exits nonzero on any mismatch or fault.
26+
"""
27+
import os
28+
import struct
29+
import subprocess
30+
import sys
31+
from pathlib import Path
32+
33+
import wasmtime
34+
from elftools.elf.elffile import ELFFile
35+
from unicorn import UC_ARCH_ARM, UC_MODE_THUMB, Uc, UcError
36+
from unicorn.arm_const import (
37+
UC_ARM_REG_LR,
38+
UC_ARM_REG_R0,
39+
UC_ARM_REG_R11,
40+
UC_ARM_REG_SP,
41+
)
42+
43+
SYNTH = os.environ.get("SYNTH", "./target/release/synth")
44+
CODE, STK, RET = 0x100000, 0x900000, 0x300000
45+
LIN_SIZE = 0x10000 # 1 page
46+
47+
# Three br_table shapes (sequential targets, shared-tail, value-returning) — the
48+
# three gale reproduced. Each stores a selector-dependent marker to mem[0].
49+
FIXTURES = {
50+
# sequential distinct targets
51+
"seq": """(module (memory (export "memory") 1)
52+
(func (export "dispatch") (param i32)
53+
(block (block (block (block
54+
(br_table 0 1 2 3 (local.get 0)))
55+
(i32.store (i32.const 0)(i32.const 10))(return))
56+
(i32.store (i32.const 0)(i32.const 20))(return))
57+
(i32.store (i32.const 0)(i32.const 30))(return))
58+
(i32.store (i32.const 0)(i32.const 40))))""",
59+
# shared-tail: targets 0 and 2 jump to the same arm
60+
"shared": """(module (memory (export "memory") 1)
61+
(func (export "dispatch") (param i32)
62+
(block (block (block
63+
(br_table 0 1 0 2 (local.get 0)))
64+
(i32.store (i32.const 0)(i32.const 11))(return))
65+
(i32.store (i32.const 0)(i32.const 22))(return))
66+
(i32.store (i32.const 0)(i32.const 33))))""",
67+
}
68+
# selectors to test per fixture (incl. an out-of-range → default arm)
69+
SELECTORS = [0, 1, 2, 3, 4, 7]
70+
71+
72+
def compile_default(wat_text, elf):
73+
wat = elf + ".wat"
74+
Path(wat).write_text(wat_text)
75+
r = subprocess.run(
76+
[SYNTH, "compile", wat, "-o", elf, "--target", "cortex-m4", "--all-exports"],
77+
capture_output=True, text=True, env={"PATH": "/usr/bin:/bin"},
78+
)
79+
if r.returncode != 0 or "skipping" in r.stderr:
80+
sys.exit(f"compile failed/skipped for {elf}:\n{r.stderr}")
81+
return wat
82+
83+
84+
def load(elf):
85+
f = ELFFile(open(elf, "rb"))
86+
text = f.get_section_by_name(".text")
87+
code, base = text.data(), text["sh_addr"]
88+
syms = {}
89+
for s in f.iter_sections():
90+
if s.header.sh_type == "SHT_SYMTAB":
91+
for sym in s.iter_symbols():
92+
if sym.name:
93+
syms[sym.name] = sym["st_value"]
94+
return code, base, syms
95+
96+
97+
def wasmtime_mem0(wat_text, sel):
98+
eng = wasmtime.Engine()
99+
mod = wasmtime.Module(eng, wat_text)
100+
st = wasmtime.Store(eng)
101+
inst = wasmtime.Instance(st, mod, [])
102+
inst.exports(st)["dispatch"](st, sel)
103+
return struct.unpack("<i", bytes(inst.exports(st)["memory"].read(st, 0, 4)))[0]
104+
105+
106+
def unicorn_mem0(code, base, faddr, lin_base, sel):
107+
foff = (faddr & ~1) - base
108+
mu = Uc(UC_ARCH_ARM, UC_MODE_THUMB)
109+
mu.mem_map(CODE, 0x20000)
110+
mu.mem_map(lin_base, LIN_SIZE)
111+
mu.mem_map(STK - 0x10000, 0x20000)
112+
mu.mem_map(RET & ~0xFFF, 0x1000)
113+
mu.mem_write(CODE, code)
114+
mu.reg_write(UC_ARM_REG_SP, STK)
115+
mu.reg_write(UC_ARM_REG_R11, lin_base)
116+
mu.reg_write(UC_ARM_REG_R0, sel & 0xFFFFFFFF)
117+
mu.reg_write(UC_ARM_REG_LR, RET | 1)
118+
try:
119+
mu.emu_start((CODE + foff) | 1, RET, count=200000)
120+
except UcError as e:
121+
return f"ERR:{e}"
122+
return struct.unpack("<i", bytes(mu.mem_read(lin_base, 4)))[0]
123+
124+
125+
def main():
126+
if not os.path.exists(SYNTH):
127+
sys.exit(f"{SYNTH} not found — build synth first")
128+
fails = 0
129+
for name, wat_text in FIXTURES.items():
130+
elf = f"/tmp/bt507_{name}.elf"
131+
wat = compile_default(wat_text, elf)
132+
code, base, syms = load(elf)
133+
lin_base = syms["__linear_memory_base"]
134+
fa = syms.get("dispatch") or syms["func_0"]
135+
wt_text = Path(wat).read_text()
136+
for sel in SELECTORS:
137+
exp = wasmtime_mem0(wt_text, sel)
138+
got = unicorn_mem0(code, base, fa, lin_base, sel)
139+
ok = isinstance(got, int) and got == exp
140+
fails += 0 if ok else 1
141+
g = str(got) if isinstance(got, int) else got
142+
print(f"{'OK ' if ok else 'FAIL'} {name} dispatch({sel}) -> mem[0]={g} "
143+
f"(wasmtime {exp})")
144+
print("\nORACLE:", "PASS" if fails == 0 else f"FAIL ({fails})")
145+
sys.exit(1 if fails else 0)
146+
147+
148+
if __name__ == "__main__":
149+
main()

0 commit comments

Comments
 (0)