commit 6aa07f2e4635fb0b1af0c0afc2ab2360dbda43c5
parent f4828d2f04a2c393f74e136359bd15952323ac81
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Sat, 30 May 2026 06:46:18 -0700
test+asm: Toy-corpus L2 round-trip lane; fix .inst-dropped miscompile
Reuse the ~150-case Toy corpus (full CG op set + exit-code oracle) for free
round-trip coverage far beyond the hand-written set: per case, native
`cfree run case.toy` vs `cfree cc -S | cfree as | cfree run`, exit codes must
match (test/asm/roundtrip_toy.sh, test-asm-roundtrip-toy). 292/0/10.
It immediately found a silent miscompile the hand corpus never reached: a
multiply-high (smulh/umulh) the disassembler can't decode is emitted by cc -S
as `.inst 0x<word>`, and `as` *dropped* `.inst` (emitting no bytes) — deleting
the instruction and shifting every following branch. `.inst` now emits the
4-byte word(s) like GNU as / llvm-mc, so it round-trips correctly even before
the decode lands (132_intrinsic_bit_and_overflow: was exit 6, now 42).
jit-runner gains --entry <sym> (was hard-wired to test_main) so non-C
frontends (toy main) can be driven.
The 10 lane skips are documented backlog: cc -S symbolizer gaps (tentative/
common, merged strings, computed-goto label-address tables) and a Mach-O
object-file jump-table reloc crash (codegen's macho .o and the assemble-inline
path both work; an as-macho-obj axis, not a -S|as issue). Regressions green:
asm, symmetry (baseline unchanged), roundtrip 572/0, ISA unit.
Diffstat:
5 files changed, 153 insertions(+), 2 deletions(-)
diff --git a/doc/ASM_ROUNDTRIP_TESTING.md b/doc/ASM_ROUNDTRIP_TESTING.md
@@ -42,6 +42,13 @@ round-trip passes all three lanes at `-O0` and `-O1`: **858 lane-checks pass,
the `bss_size` cursor for a NOBITS section, so `.zero`/labels reserve space
rather than writing a byte buffer the emitters drop. (Codegen is unaffected —
it never `obj_write`s/`obj_pos`es a BSS section.) Closed the last corpus skip.
+- **`.inst` emits the word (DONE).** `as` silently dropped the `.inst` directive
+ (emitting no bytes), so an instruction the disassembler can't decode yet
+ (`.inst 0x<word>` in `cc -S`) was *deleted* on re-assembly — a silent
+ miscompile, and every following branch offset shifted. `.inst` now emits the
+ 4-byte word(s) like GNU as / llvm-mc. Found by the Toy round-trip lane below
+ (a multiply-high the disassembler doesn't decode); it now round-trips
+ correctly even before the decode lands.
- **Exclusive / acquire-release atomic decode (DONE).** The assembler already
encoded `ldxr`/`ldaxr`/`stxr`/`stlxr`/`ldar`/`stlr` (+ b/h), but the
disassembler rendered them `.inst`, so the atomic RMW sequence codegen emits
@@ -119,6 +126,30 @@ the baseline (`bash test/asm/symmetry.sh --update`).
RelocKind→syntax tables below. The self-symmetry sweep and llvm differential
are aa64-only too.
+### Toy-corpus L2 round-trip (`test-asm-roundtrip-toy`)
+
+Reuses the ~150-case Toy corpus (`test/toy/cases/`, which exercises the full CG
+op set and carries an exit-code oracle) for free L2 coverage far beyond the
+hand-written `roundtrip/` set (`test/asm/roundtrip_toy.sh`). For each case,
+native: `cfree run case.toy` (direct) vs `cfree cc -S | cfree as | cfree run`
+(round-trip); the exit codes must match. `cfree run` propagates `main()`'s
+return on the native arch (aarch64 macOS), so the oracle is the exit code.
+
+292 pass / 0 fail / 10 skip. It immediately found a real miscompile — see the
+`.inst` fix above. The 10 skips are two backlog groups:
+- **`cc -S` symbolizer gaps** — `as` hits an undefined reference / "symbol
+ required" because the disassembly omits a symbol kind: tentative/common
+ (`.comm`) definitions, merged-string symbols, and computed-`goto`
+ label-address tables (`62_decl_data_attrs`, `118_decl_extra_attrs`,
+ `51_labeladdr_goto`, `123_spec_demo`).
+- **Mach-O object-file jump table** — an `as`-produced macho `.o` with a switch
+ jump table crashes when run, while codegen's macho `.o` and the assemble-
+ inline path (`cfree run x.s`) both work — a macho obj write/read axis, not a
+ `-S`|`as` round-trip issue (the ELF round-trip of jump tables is green).
+- Also tracked: `smulh`/`umulh` (and `smaddl`/`umaddl`/`smsubl`/`umsubl`) DP3
+ long/high-multiply *decode* (the `.inst` the lane found; correctness is
+ already restored by the `.inst` fix, but `-S` still shows `.inst` for them).
+
### llvm differential (`test-diff-llvm`)
A second-oracle cross-check against llvm (`test/asm/diff_llvm.sh`), byte-level
diff --git a/src/asm/asm.c b/src/asm/asm.c
@@ -830,6 +830,21 @@ static void do_directive(AsmDriver* d, Sym name) {
d_skip_to_eol(d);
return;
}
+ /* .inst WORD[, WORD...] — emit raw 32-bit instruction word(s), little-endian
+ * (AArch64/RISC-V are fixed 4-byte). This is how `cc -S` round-trips an
+ * instruction the disassembler can't decode yet (`.inst 0x<word>`); silently
+ * dropping it — the old behavior — deletes the instruction and miscompiles.
+ * Matches GNU as / llvm-mc, which emit the word. */
+ if (sym_eq(d, name, "inst")) {
+ (void)asm_driver_cur_section(d);
+ for (;;) {
+ i64 v = asm_driver_parse_const(d);
+ emit_le(d, (u64)v, 4);
+ if (!asm_driver_eat_comma(d)) break;
+ }
+ d_skip_to_eol(d);
+ return;
+ }
if (sym_eq(d, name, "ascii") || sym_eq(d, name, "asciz") ||
sym_eq(d, name, "string")) {
int term = !sym_eq(d, name, "ascii");
diff --git a/test/asm/roundtrip_toy.sh b/test/asm/roundtrip_toy.sh
@@ -0,0 +1,93 @@
+#!/usr/bin/env bash
+# test/asm/roundtrip_toy.sh — L2 exec round-trip over the Toy corpus (native).
+#
+# The Toy frontend exercises the full CG op set, and each case carries an
+# exit-code oracle (test/toy/cases/<name>.expected). This lane reuses that
+# corpus for free round-trip coverage far beyond the hand-written
+# test/asm/roundtrip/ set: for every case, compare the DIRECT compile to the
+# round-tripped one and require the same exit code —
+#
+# direct: cfree run case.toy
+# round-trip: cfree cc -S case.toy | cfree as | cfree run <obj>
+#
+# Native target (aarch64 macOS here): cfree run propagates main()'s return as
+# the process exit, so the oracle is the exit code. This found a real miscompile
+# (a multiply-high the disassembler couldn't decode, dropped by `as` until the
+# `.inst` fix) that the hand corpus never reached.
+#
+# Cases that hit a known `cc -S` symbolizer gap (tentative/common defs, merged
+# strings, computed-goto label-address tables) are listed in SKIP below until
+# those land; the lane stays green and gates regressions. Opt-in.
+
+set -u
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+CFREE="$ROOT/build/cfree"
+CASES="$ROOT/test/toy/cases"
+WORK="$ROOT/build/test/asm/roundtrip_toy"
+OPTS="${CFREE_TEST_OPTS:-O0 O1}"
+FILTER="${1:-}"
+
+# Known gaps the toy corpus surfaced (see doc/ASM_ROUNDTRIP_TESTING.md):
+# (a) cc -S symbolizer gaps — `as` fails with an undefined reference /
+# "symbol required" because the disassembly omits a symbol kind it can't
+# resolve: tentative/common (.comm) defs, merged strings, and computed-goto
+# label-address tables.
+# (b) Mach-O object-file jump-table relocation — an `as`-produced .o with a
+# switch jump table crashes when run, while codegen's macho .o and the
+# assemble-inline path (`cfree run x.s`) both work. A macho obj write/read
+# axis, not a -S|as round-trip issue.
+SKIP="62_decl_data_attrs 118_decl_extra_attrs 51_labeladdr_goto 123_spec_demo \
+ 118_many_enum_switch_values 119_static_labeladdr_data 119_switch_strategy_hints \
+ 125_switch_dense_boundaries 127_switch_forced_jump_table 47_target_arch_switch"
+
+color_red() { printf '\033[31m%s\033[0m' "$1"; }
+color_grn() { printf '\033[32m%s\033[0m' "$1"; }
+color_yel() { printf '\033[33m%s\033[0m' "$1"; }
+
+if [ ! -x "$CFREE" ]; then
+ printf 'roundtrip-toy: %s cfree missing — run "make bin"\n' "$(color_red FATAL)" >&2
+ exit 1
+fi
+mkdir -p "$WORK"
+
+pass=0; fail=0; skip=0; failnames=()
+is_skip() { case " $SKIP " in *" $1 "*) return 0;; *) return 1;; esac; }
+
+shopt -s nullglob
+for src in "$CASES"/*.toy; do
+ name="$(basename "$src" .toy)"
+ [ -n "$FILTER" ] && [[ "$name" != *"$FILTER"* ]] && continue
+ if is_skip "$name"; then
+ skip=$((skip+1)); printf ' %s %s — known cc -S symbolizer gap\n' "$(color_yel SKIP)" "$name"; continue
+ fi
+ exp=0; [ -f "$CASES/$name.expected" ] && exp=$(head -n1 "$CASES/$name.expected")
+ exp=$((exp & 255))
+ for opt in $OPTS; do
+ w="$WORK/$name/$opt"; mkdir -p "$w"
+ if ! "$CFREE" cc -S "-$opt" "$src" -o "$w/s.s" 2>"$w/ccs.err"; then
+ fail=$((fail+1)); failnames+=("$name[-$opt] cc-S"); printf ' %s %s[-%s] cc -S failed\n' "$(color_red FAIL)" "$name" "$opt"; continue
+ fi
+ if ! "$CFREE" as "$w/s.s" -o "$w/rt.o" 2>"$w/as.err"; then
+ fail=$((fail+1)); failnames+=("$name[-$opt] as: $(head -1 "$w/as.err"|sed 's|.*: ||')")
+ printf ' %s %s[-%s] as failed: %s\n' "$(color_red FAIL)" "$name" "$opt" "$(head -1 "$w/as.err"|sed 's|.*: ||')"; continue
+ fi
+ "$CFREE" run "$w/rt.o" >"$w/out" 2>"$w/run.err"; rc=$?
+ if [ -s "$w/run.err" ]; then
+ fail=$((fail+1)); failnames+=("$name[-$opt] run: $(head -1 "$w/run.err"|sed 's|.*: ||')")
+ printf ' %s %s[-%s] run failed: %s\n' "$(color_red FAIL)" "$name" "$opt" "$(head -1 "$w/run.err"|sed 's|.*: ||')"; continue
+ fi
+ if [ "$rc" -eq "$exp" ]; then
+ pass=$((pass+1))
+ else
+ fail=$((fail+1)); failnames+=("$name[-$opt] exit $rc != $exp")
+ printf ' %s %s[-%s] exit %d != expected %d\n' "$(color_red FAIL)" "$name" "$opt" "$rc" "$exp"
+ fi
+ done
+done
+shopt -u nullglob
+
+printf '\n'
+[ "${#failnames[@]}" -gt 0 ] && { printf 'Failed:\n'; for f in "${failnames[@]}"; do printf ' %s\n' "$f"; done; }
+printf 'roundtrip-toy: %d pass, %d fail, %d skip\n' "$pass" "$fail" "$skip"
+[ "$fail" -eq 0 ]
diff --git a/test/link/harness/jit_runner.c b/test/link/harness/jit_runner.c
@@ -435,6 +435,7 @@ int main(int argc, char** argv) {
* --check-present SYM: after link, verify symbol IS in image. */
const char* check_absent = NULL;
const char* check_present = NULL;
+ const char* entry_sym = "test_main";
const char* script_path = NULL;
CfreeSlice objs[64];
@@ -460,6 +461,8 @@ int main(int argc, char** argv) {
check_present = argv[++i];
} else if (!strcmp(argv[i], "--linker-script") && i + 1 < argc) {
script_path = argv[++i];
+ } else if (!strcmp(argv[i], "--entry") && i + 1 < argc) {
+ entry_sym = argv[++i];
} else {
uint8_t* data;
size_t len;
@@ -515,7 +518,7 @@ int main(int argc, char** argv) {
CfreeLinkScript* script = NULL;
memset(&opts, 0, sizeof(opts));
opts.output_kind = CFREE_LINK_OUTPUT_JIT;
- opts.entry = CFREE_SLICE_LIT("test_main");
+ opts.entry = cfree_slice_cstr(entry_sym);
opts.gc_sections = gc_sections;
opts.jit_host = &jhost;
if (use_resolver) {
@@ -598,7 +601,7 @@ int main(int argc, char** argv) {
}
void* wasm_init = cfree_jit_lookup(jit, CFREE_SLICE_LIT("__cfree_wasm_init"));
- void* entry = cfree_jit_lookup(jit, CFREE_SLICE_LIT("test_main"));
+ void* entry = cfree_jit_lookup(jit, cfree_slice_cstr(entry_sym));
int (*fn)(void) = entry;
/* TLS local-exec setup. Build the static TLS block and install the
diff --git a/test/test.mk b/test/test.mk
@@ -41,6 +41,7 @@ TEST_TARGETS = \
test-asm-roundtrip \
test-asm-roundtrip-exec \
test-asm-symmetry \
+ test-asm-roundtrip-toy \
test-diff-llvm \
test-bounce \
test-cbackend \
@@ -672,6 +673,14 @@ test-asm-symmetry: $(ASM_RUNNER) $(AA64_SWEEP_GEN)
test-diff-llvm: bin
@CFREE_TEST_OPTS="O0 O1" bash test/asm/diff_llvm.sh
+# test-asm-roundtrip-toy: L2 exec round-trip over the Toy corpus (native arch).
+# Reuses the ~150 toy cases (full CG op set, exit-code oracle) for free
+# round-trip coverage: cfree cc -S | cfree as | cfree run, exit must match.
+# Opt-in; native target. Found a real miscompile (dropped .inst) the hand
+# corpus never reached.
+test-asm-roundtrip-toy: bin
+ @bash test/asm/roundtrip_toy.sh
+
test-wasm: test-wasm-front test-wasm-target test-wasm-toy
test-wasm-front: bin $(WASM_TOOL) $(LINK_EXE_RUNNER) $(JIT_RUNNER)