commit 5cb0213fcda79c07c71fe59fa9aa10e792d32c67
parent 336cd946102c66acf0a2b6f75d6fd998af1ed51b
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Sat, 9 May 2026 12:09:04 -0700
test-link: move negative tests to bad/ dir; drop link_fail marker
link_fail was a per-case flag that converted a failing link into a PASS
when the failure was the contract being tested. That's a "fail → pass"
conversion, even if the intent is legitimate. Replace it with a
dedicated test/link/bad/ directory mirroring test/elf/bad/: the
location IS the marker, no special-cased flag in the harness.
Each bad/ case provides sources that compile cleanly plus an `expect`
substring; pass = runner exits non-zero with no signal AND stderr
contains expect. Both Path E (link-exe-runner) and Path J (jit-runner)
are exercised.
Moves cases/30_undef_strong → bad/30_undef_strong with an expect file
matching cfree's actual diagnostic ("undefined reference to
'missing_fn'").
Diffstat:
6 files changed, 130 insertions(+), 50 deletions(-)
diff --git a/doc/linker-status.md b/doc/linker-status.md
@@ -53,6 +53,13 @@ enforces is byte-equality of the *normalized* readelf dumps.
There is **no xfail mechanism** — failures fail. If a case is broken,
fix the bug or remove the case.
+The same rule applies to `test/link/`: there is no `link_fail` marker
+in `cases/`. Negative tests live in `test/link/bad/<name>/`, which
+mirrors `test/elf/bad/`. Each `bad/` case ships sources that compile
+cleanly plus an `expect` substring that must appear in the runner's
+stderr; pass = runner exits non-zero with no signal AND substring
+matches. The directory location *is* the marker.
+
---
## test-link / JIT — Apple Silicon J-path
diff --git a/test/link/CORPUS.md b/test/link/CORPUS.md
@@ -33,12 +33,17 @@ expects from the combined sequence.
|------|---------|
| `expected` | expected return value (default 0) |
| `jit_only` | skip R and E; run J only |
-| `link_fail` | E and J paths expect the linker to fail cleanly (non-zero, no signal) |
| `use_resolver` | pass `--use-resolver` to jit_runner |
| `linker_flags` | one flag per line passed to link-exe-runner and jit-runner |
| `gc_absent` | one symbol per line that must be absent post --gc-sections |
| `archive_b` | package b.o as b.a; content `demand` (normal) or `whole` (--whole-archive) |
+Negative tests live in `test/link/bad/<name>/` instead of `cases/`. Each
+bad-case directory contains source files (compile cleanly) plus an
+`expect` file: a substring that must appear in the runner's stderr.
+Pass = runner exits non-zero with no signal AND stderr contains
+`expect`. The `bad/` location *is* the marker — no `link_fail` flag.
+
## Case index
### Group A — single-TU, reloc coverage
@@ -97,7 +102,7 @@ Cases 02–09 all pair ADR_PREL_PG_HI21 with their primary LDST reloc.
| 28 | `extern_resolver` | `CfreeExternResolver` provides address for undefined symbol |
| 29 | `jit_lookup_miss` | `cfree_jit_lookup` returns NULL for unknown name |
-### Group F — error cases
+### bad/ — negative tests
| # | Name | Exercises |
|---|------|-----------|
diff --git a/test/link/cases/30_undef_strong/a.c b/test/link/bad/30_undef_strong/a.c
diff --git a/test/link/bad/30_undef_strong/expect b/test/link/bad/30_undef_strong/expect
@@ -0,0 +1 @@
+undefined reference to 'missing_fn'
diff --git a/test/link/cases/30_undef_strong/link_fail b/test/link/cases/30_undef_strong/link_fail
@@ -1 +0,0 @@
-1
diff --git a/test/link/run.sh b/test/link/run.sh
@@ -12,11 +12,15 @@
# → check return value.
# Validates JIT path (runs on aarch64 host only).
#
+# Negative tests live in test/link/bad/<name>/ — sources that compile
+# cleanly but should cause the linker (and JIT) to reject. Each bad/ case
+# requires an `expect` file containing a substring that must appear in
+# stderr. No special markers in test/link/cases/ trigger negative-test
+# behavior.
+#
# Case markers (files in the case directory):
# expected — expected exit/return value (default 0)
# jit_only — skip R and E paths; run J only
-# link_fail — E and J paths expect the link step to fail (non-zero,
-# signal-free exit from the runner)
# use_resolver — pass --use-resolver to jit-runner (case 28)
# linker_flags — one flag per line; passed to link-exe-runner and jit-runner
# gc_absent — one symbol per line; jit-runner verifies each is absent
@@ -147,7 +151,6 @@ for case_dir in "$TEST_DIR/cases"/*/; do
# Read markers
expected=0; [ -f "$case_dir/expected" ] && expected="$(cat "$case_dir/expected" | tr -d '[:space:]')"
jit_only=0; [ -f "$case_dir/jit_only" ] && jit_only=1
- link_fail=0; [ -f "$case_dir/link_fail" ] && link_fail=1
use_resolver=0; [ -f "$case_dir/use_resolver" ] && use_resolver=1
archive_mode="none"
if [ -f "$case_dir/archive_b" ]; then
@@ -266,25 +269,14 @@ for case_dir in "$TEST_DIR/cases"/*/; do
link_cmd=("$LINK_EXE_RUNNER" "${extra_flags[@]}" -o "$exe" \
"${link_obj_files[@]}" "$start_obj" "${link_arc_flags[@]}")
- if [ $link_fail -eq 1 ]; then
- if "${link_cmd[@]}" >"$work/exec_link.out" 2>"$work/exec_link.err"; then
- note_fail "$name/E (expected link failure but link succeeded)"
- else
- e_rc=$?
- # Clean fail: non-zero and no signal (exit < 128)
- if [ $e_rc -lt 128 ]; then note_pass "$name/E"
- else note_fail "$name/E (link failed via signal $e_rc)"; fi
- fi
+ if ! "${link_cmd[@]}" >"$work/exec_link.out" 2>"$work/exec_link.err"; then
+ note_fail "$name/E (link failed)"
+ elif [ $have_runner -eq 1 ]; then
+ run_aarch64 "$exe" "$work/exec.out" "$work/exec.err"
+ if [ "$RUN_RC" -eq "$expected" ]; then note_pass "$name/E"
+ else note_fail "$name/E (expected $expected, got $RUN_RC)"; fi
else
- if ! "${link_cmd[@]}" >"$work/exec_link.out" 2>"$work/exec_link.err"; then
- note_fail "$name/E (link failed)"
- elif [ $have_runner -eq 1 ]; then
- run_aarch64 "$exe" "$work/exec.out" "$work/exec.err"
- if [ "$RUN_RC" -eq "$expected" ]; then note_pass "$name/E"
- else note_fail "$name/E (expected $expected, got $RUN_RC)"; fi
- else
- note_skip "$name/E" "no runner (qemu/podman)"
- fi
+ note_skip "$name/E" "no runner (qemu/podman)"
fi
else
if [ $jit_only -eq 0 ]; then
@@ -298,41 +290,117 @@ for case_dir in "$TEST_DIR/cases"/*/; do
[ $use_resolver -eq 1 ] && jit_cmd+=(--use-resolver)
jit_cmd+=("${link_obj_files[@]}" "${link_arc_flags[@]}")
- if [ $link_fail -eq 1 ]; then
- if "${jit_cmd[@]}" >"$work/jit.out" 2>"$work/jit.err"; then
- note_fail "$name/J (expected link failure but link succeeded)"
+ "${jit_cmd[@]}" >"$work/jit.out" 2>"$work/jit.err"
+ j_rc=$?
+
+ # For gc_sections cases, additionally verify absent symbols.
+ # The jit_runner returns 0 on test_main pass; absent-symbol
+ # verification is handled by the jit_runner reading gc_absent
+ # via a side channel. Since the runner doesn't read case markers
+ # itself, we instead check the lookup from a thin wrapper here:
+ # re-run jit_runner with --check-absent SYM for each gc_absent sym.
+ for sym in "${gc_absent_syms[@]}"; do
+ if "${jit_cmd[@]}" --check-absent "$sym" \
+ >"$work/jit_gc.out" 2>"$work/jit_gc.err"; then
+ : # absent check passed
else
j_rc=$?
- if [ $j_rc -lt 128 ]; then note_pass "$name/J"
- else note_fail "$name/J (link failed via signal $j_rc)"; fi
+ break
fi
+ done
+
+ if [ "$j_rc" -eq "$expected" ]; then note_pass "$name/J"
+ else note_fail "$name/J (expected $expected, got $j_rc)"; fi
+ else
+ note_skip "$name/J" "no jit-runner (not aarch64 host or build failed)"
+ fi
+
+done
+
+# ---- bad/ — negative tests -------------------------------------------------
+# Sources that compile cleanly but should cause the linker (E) and JIT (J)
+# to reject. Pass = runner exits non-zero with no signal AND stderr
+# contains the substring from `expect`. No marker files; the bad/
+# directory itself is the marker.
+
+for case_dir in "$TEST_DIR/bad"/*/; do
+ [ -d "$case_dir" ] || continue
+ name="bad/$(basename "$case_dir")"
+ work="$BUILD_DIR/link/$name"
+ mkdir -p "$work"
+
+ expect_file="$case_dir/expect"
+ if [ ! -f "$expect_file" ]; then
+ note_fail "$name (missing $expect_file)"; continue
+ fi
+ expect="$(cat "$expect_file")"
+
+ if [ $have_clang_cross -eq 0 ]; then note_skip "$name" "no aarch64 clang"; continue; fi
+
+ tu_srcs=()
+ for f in "$case_dir/a.c" "$case_dir/b.c" "$case_dir/c.c"; do
+ [ -f "$f" ] && tu_srcs+=("$f")
+ done
+
+ obj_files=(); compile_ok=1
+ for src in "${tu_srcs[@]}"; do
+ base="$(basename "$src" .c)"
+ obj="$work/${base}.o"
+ if ! clang $CLANG_TARGET -O1 -fno-inline -ffreestanding -fno-stack-protector \
+ -fno-PIC -fno-pie -fcommon \
+ -c "$src" -o "$obj" 2>"$work/compile_${base}.err"; then
+ compile_ok=0; break
+ fi
+ obj_files+=("$obj")
+ done
+ if [ $compile_ok -eq 0 ]; then
+ note_fail "$name (compile failed; sources should compile, only link is expected to fail)"
+ continue
+ fi
+
+ # Path E
+ if [ $have_exe_runner -eq 1 ]; then
+ start_obj="$work/start.o"
+ clang $CLANG_TARGET -O1 -ffreestanding -fno-stack-protector \
+ -fno-PIC -fno-pie \
+ -c "$TEST_DIR/harness/start.c" -o "$start_obj" 2>/dev/null
+ exe="$work/linked.exe"
+ if "$LINK_EXE_RUNNER" -o "$exe" "${obj_files[@]}" "$start_obj" \
+ >"$work/link.out" 2>"$work/link.err"; then
+ note_fail "$name/E (linker succeeded; expected non-zero exit)"
else
- "${jit_cmd[@]}" >"$work/jit.out" 2>"$work/jit.err"
- j_rc=$?
-
- # For gc_sections cases, additionally verify absent symbols.
- # The jit_runner returns 0 on test_main pass; absent-symbol
- # verification is handled by the jit_runner reading gc_absent
- # via a side channel. Since the runner doesn't read case markers
- # itself, we instead check the lookup from a thin wrapper here:
- # re-run jit_runner with --check-absent SYM for each gc_absent sym.
- for sym in "${gc_absent_syms[@]}"; do
- if "${jit_cmd[@]}" --check-absent "$sym" \
- >"$work/jit_gc.out" 2>"$work/jit_gc.err"; then
- : # absent check passed
- else
- j_rc=$?
- break
- fi
- done
+ rc=$?
+ if [ $rc -ge 128 ]; then
+ note_fail "$name/E (linker died via signal $((rc-128)))"
+ elif ! grep -qF -- "$expect" "$work/link.err"; then
+ note_fail "$name/E (stderr did not contain: $expect)"
+ sed 's/^/ | /' "$work/link.err"
+ else
+ note_pass "$name/E"
+ fi
+ fi
+ else
+ note_skip "$name/E" "no link-exe-runner"
+ fi
- if [ "$j_rc" -eq "$expected" ]; then note_pass "$name/J"
- else note_fail "$name/J (expected $expected, got $j_rc)"; fi
+ # Path J
+ if [ $have_jit_runner -eq 1 ]; then
+ if "$JIT_RUNNER" "${obj_files[@]}" >"$work/jit.out" 2>"$work/jit.err"; then
+ note_fail "$name/J (jit-runner succeeded; expected non-zero exit)"
+ else
+ rc=$?
+ if [ $rc -ge 128 ]; then
+ note_fail "$name/J (jit-runner died via signal $((rc-128)))"
+ elif ! grep -qF -- "$expect" "$work/jit.err"; then
+ note_fail "$name/J (stderr did not contain: $expect)"
+ sed 's/^/ | /' "$work/jit.err"
+ else
+ note_pass "$name/J"
+ fi
fi
else
note_skip "$name/J" "no jit-runner (not aarch64 host or build failed)"
fi
-
done
# ---- summary ---------------------------------------------------------------