commit e71fc1bd3e36d4e648e2f4355ccdf04a00a0fe8d
parent 08c1a50a4f0995d70a5b05429fe4e3405982d9ae
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Fri, 12 Jun 2026 11:42:40 -0700
fix(link): honor removed-symbol tombstones when linking an in-memory object
`kit cc src.c archive.a -o app` at -O1 aborted with
"undefined reference to '.Lkit_ro.N'" (and, on a different shape,
"reloc references unmapped symbol") — the defect that kept sqlite and
yyjson red at -O1 in the ecosystem gate (Bug B).
Root cause: the one-shot compile+link path links the just-compiled object
*in memory*. A file-format emitter runs obj_sweep_dead and then skips
`removed` (tombstoned) entries as it writes, so a serialized-then-reread
object never carries them; the in-memory builder still does, and the
linker walked symbols/relocs by raw id without honoring the `removed`
bit (the documented contract for post-sweep consumers). A deferred local
rodata constant that is never materialized — obj_symbol_defer leaves
`.Lkit_ro.N` a removed local SK_OBJ once its function is optimized away at
-O1 — therefore survived as an undefined local, and a dead reloc against
one tripped the unmapped-symbol fault.
link_resolve_symbols now skips removed symbols (leaving their slot
LINK_SYM_NONE), and link_emit_relocations drops a reloc whose target is a
removed symbol instead of faulting — exactly what obj_sweep_dead pass 3
does before a write, and what the stub/GOT passes already did. The result
matches what a serialized object presents to the linker.
yyjson is now green at -O0 and -O1 (clang-identical; golden added). sqlite
links at -O1 and then trips a separate, previously masked -O1 runtime
miscompile (a null-pointer deref) now tracked as Bug C
(known_bugs/sqlite-o1-runtime-segfault.md).
Regression: test/driver/run.sh -> cc-o1-oneshot-deferred-rodata
(red-green verified).
Diffstat:
10 files changed, 189 insertions(+), 22 deletions(-)
diff --git a/src/link/link_reloc_layout.c b/src/link/link_reloc_layout.c
@@ -938,9 +938,19 @@ void link_emit_relocations(Linker* l, LinkImage* img, const LinkSymId* got_map,
compiler_panic(l->c, SRCLOC_NONE,
"link: reloc references unknown symbol");
target = m->sym[r->sym];
- if (target == LINK_SYM_NONE)
+ if (target == LINK_SYM_NONE) {
+ /* link_resolve_symbols leaves a tombstoned (removed) symbol
+ * unregistered, so its slot stays LINK_SYM_NONE. A reloc still pointing
+ * at one is dead — obj_sweep_dead pass 3 retires exactly these relocs
+ * before a file-format emitter writes the object, and the stub/GOT
+ * passes above already skip them — so drop it here as well rather than
+ * faulting. Any other unmapped target is a genuine linker bug; keep the
+ * hard error for it. */
+ const ObjSym* ts = obj_symbol_get(ob, r->sym);
+ if (ts && ts->removed) continue;
compiler_panic(l->c, SRCLOC_NONE,
"link: reloc references unmapped symbol");
+ }
if (got_map && reloc_kind_uses_got(l->c, r->kind)) {
LinkSymId slot = got_map[target];
if (slot == LINK_SYM_NONE)
diff --git a/src/link/link_resolve.c b/src/link/link_resolve.c
@@ -247,6 +247,19 @@ void link_resolve_symbols(Linker* l, LinkImage* img) {
const ObjSym* s = e.sym;
LinkSymbol rec;
LinkSymId existing;
+ /* Tombstoned symbols are deleted, not just undefined: a file-format
+ * emitter (which runs obj_sweep_dead, then `if (s->removed) continue`)
+ * never writes them, so a serialized-then-re-read input carries none.
+ * An in-memory ObjBuilder linked directly (the one-shot compile+link
+ * path) still holds them, so honor the same `removed` contract here.
+ * Without this, a deferred-but-never-materialized local rodata constant
+ * (`obj_symbol_defer` leaves `.Lkit_ro.N` as a removed local SK_OBJ when
+ * its function is optimized away at -O1) survives as an undefined local
+ * and trips the "undefined reference to '.Lkit_ro.N'" panic below.
+ * Leaving such a symbol unregistered (m->sym stays LINK_SYM_NONE); a
+ * reloc that still targets it is dead and is dropped in the reloc passes
+ * (matching obj_sweep_dead pass 3, which retires those relocs on write). */
+ if (s->removed) continue;
if (link_sym_is_spurious_undef(s)) continue;
int is_def = link_sym_is_def(s);
diff --git a/test/driver/run.sh b/test/driver/run.sh
@@ -770,6 +770,45 @@ else
not_ok "cc-link-archive-order" "$work/order-setup.diag"
fi
+# ---- one-shot -O1 compile+link drops a deferred local .Lkit_ro (Bug B) ----
+# At -O1, a function-local const is recorded as a DEFERRED local .Lkit_ro
+# symbol (obj_symbol_defer leaves it a `removed` tombstone) and only
+# materialized when its function is emitted. An unused static helper is never
+# emitted, so its const stays a tombstone, and a discarded reference to the
+# helper can leave a dead reloc against it. A file-format emitter sweeps these
+# away before writing, so the separate compile-then-link path is clean; the
+# one-shot path links the just-compiled object IN MEMORY (un-swept), so the
+# linker must honor the same tombstones. Before the fix this aborted with
+# "undefined reference to '.Lkit_ro.N'" or "reloc references unmapped symbol".
+# Freestanding + _start so the link pulls no compiler runtime (host-portable,
+# rt-independent); link success + an emitted executable is the assertion.
+cat > "$work/lkitro-lib.c" <<'SRC'
+int lkitro_lib(void) { return 0; }
+SRC
+cat > "$work/lkitro-drv.c" <<'SRC'
+static const char* lkitro_helper(int i) {
+ static const char* const names[] = {"alpha", "beta", "gamma", "delta"};
+ return names[i & 3];
+}
+int lkitro_lib(void);
+int _start(void) { (void)lkitro_helper; return lkitro_lib(); }
+SRC
+if "$KIT" cc -O1 -ffreestanding -nostdlib -c "$work/lkitro-lib.c" \
+ -o "$work/lkitro-lib.o" \
+ > "$work/lkitro-lib.out" 2> "$work/lkitro-lib.err" &&
+ "$KIT" ar rcs "$work/liblkitro.a" "$work/lkitro-lib.o" \
+ > "$work/lkitro-ar.out" 2> "$work/lkitro-ar.err" &&
+ "$KIT" cc -O1 -ffreestanding -nostdlib -e _start \
+ "$work/lkitro-drv.c" "$work/liblkitro.a" -o "$work/lkitro-app" \
+ > "$work/lkitro-link.out" 2> "$work/lkitro-link.err"; then
+ is_executable "cc-o1-oneshot-deferred-rodata" "$work/lkitro-app"
+else
+ { sed 's/^/lib: /' "$work/lkitro-lib.err"
+ sed 's/^/ar: /' "$work/lkitro-ar.err"
+ sed 's/^/link| /' "$work/lkitro-link.err"; } > "$work/lkitro.diag"
+ not_ok "cc-o1-oneshot-deferred-rodata" "$work/lkitro.diag"
+fi
+
# ---- rv64 cross-target end-to-end (as, cc, ld, objdump) ----
# Exercises the rv64 lane of each tool the toolchain claims to support.
# Cross-compile-only; no qemu/native exec required.
diff --git a/test/ecosystem/expected/yyjson.txt b/test/ecosystem/expected/yyjson.txt
@@ -0,0 +1,3 @@
+name=kit n=42 arrlen=3
+arr-sum=60 nested-ok=1
+built={"lib":"yyjson","answer":42}
diff --git a/test/ecosystem/known_bugs/README.md b/test/ecosystem/known_bugs/README.md
@@ -17,12 +17,18 @@ When the gate fails, the failing build's artifacts are preserved under
| id | file | trigger | symptom |
|----|------|---------|---------|
-| A | [yyjson-write-label.md](yyjson-write-label.md) | yyjson.c at **-O0** | `MCEmitter: label NNNN placed twice` (codegen abort) |
-| B | [sqlite-o1-lkit-ro.md](sqlite-o1-lkit-ro.md) | `kit cc src.c archive.a` at **-O1** | `undefined reference to '.Lkit_ro.N'` (one-shot compile+link drops a local rodata symbol) |
+| C | [sqlite-o1-runtime-segfault.md](sqlite-o1-runtime-segfault.md) | SQLite shell at **-O1** | runs then SIGSEGVs (null-pointer deref); -O1 optimizer miscompile, surfaced once Bug B unblocked the -O1 link |
-Bug B hits both SQLite and yyjson at -O1; Bug A hits yyjson at -O0. The other
-five projects (cJSON, LZ4, miniz, tinyexpr, Lua) are green at both -O0 and -O1
-and match clang byte-for-byte.
+SQLite is green at -O0 (matches clang byte-for-byte) and links at -O1 but
+crashes at runtime (Bug C). The other six projects — cJSON, LZ4, miniz,
+tinyexpr, Lua, **and yyjson** — are green at both -O0 and -O1 and match clang.
Each bug doc has a self-contained reproduction against the provisioned cache
(`make provision-ecosystem` first).
+
+## Fixed bugs
+
+| id | file | was | fixed by |
+|----|------|-----|----------|
+| A | [yyjson-write-label.md](yyjson-write-label.md) | yyjson.c at -O0 → `MCEmitter: label NNNN placed twice` | front end: a goto label first seen inside a constant-false (codegen-suppressed) region now gets a real CG-label id |
+| B | [sqlite-o1-lkit-ro.md](sqlite-o1-lkit-ro.md) | `kit cc src.c archive.a` at -O1 → `undefined reference to '.Lkit_ro.N'` | linker: one-shot in-memory link now honors `removed` (tombstoned) symbols/relocs, matching a serialized object |
diff --git a/test/ecosystem/known_bugs/sqlite-o1-lkit-ro.md b/test/ecosystem/known_bugs/sqlite-o1-lkit-ro.md
@@ -1,7 +1,24 @@
# Bug B — `-O1` one-shot compile+link against an archive drops a local `.Lkit_ro.N`
-**Status:** open. Kept red by the ecosystem gate (`sqlite:O1:build`,
-`yyjson:O1:build`).
+**Status:** FIXED. The `sqlite:O1` and `yyjson:O1` builds now succeed; yyjson is
+green end-to-end at both opt levels. (sqlite then trips a *separate*, previously
+masked -O1 runtime fault — see [sqlite-o1-runtime-segfault.md](sqlite-o1-runtime-segfault.md).)
+**Fix:** `src/link/link_resolve.c` + `src/link/link_reloc_layout.c`. The
+one-shot path links the just-compiled object *in memory*; a file-format emitter
+runs `obj_sweep_dead` and omits `removed` (tombstoned) entries when it writes,
+but the in-memory builder still holds them, and the linker walked them by raw
+id without honoring the `removed` bit. A deferred-but-never-materialized local
+rodata constant (`obj_symbol_defer` leaves `.Lkit_ro.N` a removed local SK_OBJ
+when its function is optimized away at -O1) therefore survived as an undefined
+local, and a dead reloc against one tripped "reloc references unmapped symbol".
+The resolver now skips `removed` symbols and the reloc passes skip dead relocs
+against them — matching exactly what a serialized-then-reread object presents.
+Regression: `test/driver/run.sh` → `cc-o1-oneshot-deferred-rodata`.
+
+---
+
+_Original report (kept for the root-cause record):_
+
**Component:** linker / one-shot compile+link object handling at -O1
(`src/link/`, interaction with the in-invocation compiled object's local
`.Lkit_ro` rodata symbols).
diff --git a/test/ecosystem/known_bugs/sqlite-o1-runtime-segfault.md b/test/ecosystem/known_bugs/sqlite-o1-runtime-segfault.md
@@ -0,0 +1,66 @@
+# Bug C — SQLite segfaults at runtime when built at `-O1`
+
+**Status:** open. Kept red by the ecosystem gate (`sqlite:O1:run`).
+**Component:** -O1 optimizer/codegen (a null-pointer dereference in optimized
+SQLite code; not the linker, not the front end).
+**Severity:** the sqlite shell built at -O1 crashes before producing output.
+
+## How this surfaced
+
+This fault was **masked by Bug B**
+([sqlite-o1-lkit-ro.md](sqlite-o1-lkit-ro.md)): until Bug B was fixed, the
+`sqlite:O1` *link* failed, so the -O1 binary never existed and never ran. With
+Bug B fixed the link succeeds, and the binary now crashes at runtime — so this
+is a distinct, pre-existing -O1 miscompile that Bug B was hiding, not a
+regression from the Bug B fix.
+
+What is and isn't affected:
+
+- **sqlite at -O0** builds, runs, and matches clang byte-for-byte (green).
+- **yyjson at -O0 and -O1** both build, run, and are correct (green) — so the
+ -O1 pipeline is not broadly broken; this is specific to SQLite's -O1 code.
+- The failure reproduces on **both** the one-shot and the separate
+ compile-then-link paths at -O1, so it is independent of the link path.
+
+## Symptom
+
+```
+$ ./sqlite3 :memory: < test/ecosystem/scripts/sqlite.sql
+Segmentation fault: 11 # exit 139
+```
+
+Under lldb (arm64-macOS):
+
+```
+stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
+frame #0: sqlite_o1`... + N # ldr w16, [x9] with x9 == 0
+```
+
+A null-pointer load (`x9 == 0`). The enclosing symbol lldb prints is the nearest
+preceding exported symbol with a very large `+offset`, i.e. the real (local)
+function is mis-symbolicated — reduce against `kit nm`/DWARF to pin the function.
+
+## Reproduce
+
+```sh
+make provision-ecosystem # once (network)
+KIT=build/kit
+SDK="$(xcrun --sdk macosx --show-sdk-path)"
+SQ="$(sh scripts/ecosystem.sh srcdir sqlite)"
+DEFS="-DSQLITE_THREADSAFE=0 -DSQLITE_OMIT_LOAD_EXTENSION"
+
+$KIT cc -O1 --sysroot "$SDK" $DEFS -I "$SQ" -c "$SQ/sqlite3.c" -o /tmp/sqlite3.o
+$KIT ar rcs /tmp/libsqlite3.a /tmp/sqlite3.o
+$KIT cc -O1 --sysroot "$SDK" $DEFS -I "$SQ" "$SQ/shell.c" /tmp/libsqlite3.a -lc -o /tmp/sqlite3
+/tmp/sqlite3 :memory: < test/ecosystem/scripts/sqlite.sql # SIGSEGV
+```
+
+## Notes for digging
+
+- Bisect the SQLite TU: compile `sqlite3.c` at -O1 but `shell.c` at -O0 (and
+ vice versa) to localize which TU's -O1 code faults.
+- This tree is under active -O1 optimizer/type-cache work; confirm whether the
+ fault predates those changes (build an -O1 sqlite from an earlier revision)
+ before assuming it is purely a long-standing optimizer bug.
+- A standalone reducer (smallest SQL + smallest pragma path that crashes) next
+ to this doc would make the null-deref tractable; still TODO.
diff --git a/test/ecosystem/known_bugs/yyjson-write-label.md b/test/ecosystem/known_bugs/yyjson-write-label.md
@@ -1,7 +1,21 @@
# Bug A — `MCEmitter: label NNNN placed twice` compiling yyjson at -O0
-**Status:** open. Kept red by the ecosystem gate (`yyjson:O0:build`).
-**Component:** codegen / machine-code emitter (`src/arch/*/` MCEmitter label pass).
+**Status:** FIXED. yyjson compiles, links, and runs at both -O0 and -O1 and
+matches clang. The defect was in the **front end**, not the emitter: a goto
+label whose first mention is inside a constant-false (codegen-suppressed)
+region was assigned `pcg_label_new`'s suppression sentinel instead of a real
+CG-label id, so it later aliased the function's first real label and the -O0
+native emitter aborted placing that id twice. `parse_function_body` now keys
+goto-label allocation off whether the function emits at all
+(`Parser.cur_func_emits`), not the momentary suppress depth. Regression:
+`test/parse/cases/6_8_06_01_goto_label_in_dead_branch.c`.
+
+---
+
+_Original report (kept for the root-cause record):_
+
+**Component:** the symptom prints from the machine-code emitter (`src/arch/*/`
+MCEmitter label pass), but the root cause is front-end label allocation.
**Severity:** kit cannot compile yyjson at -O0 at all.
## Symptom
diff --git a/test/ecosystem/recipes/sqlite.recipe b/test/ecosystem/recipes/sqlite.recipe
@@ -33,11 +33,13 @@ ECO_INCLUDES="."
ECO_DEFINES="-DSQLITE_THREADSAFE=0 -DSQLITE_OMIT_LOAD_EXTENSION"
ECO_LIBS=""
ECO_DRIVER=shell.c
-# KNOWN-RED at -O1 only (O0 builds + matches clang): the final link fails with
-# "undefined reference to '.Lkit_ro.119'"
-# — a kit -O1 local rodata-label (.Lkit_ro) resolution bug. Kept red on purpose.
-# Details + repro: test/ecosystem/known_bugs/sqlite-o1-lkit-ro.md.
-ECO_BUG="known_bugs/sqlite-o1-lkit-ro.md"
+# KNOWN-RED at -O1 only (O0 builds, runs, and matches clang). The -O1 link now
+# succeeds (the old `.Lkit_ro` one-shot-link bug is fixed —
+# known_bugs/sqlite-o1-lkit-ro.md), but the -O1 binary then SIGSEGVs at runtime
+# on a null-pointer deref: a separate -O1 optimizer miscompile that the link
+# failure used to mask. Kept red on purpose. Details + repro:
+# test/ecosystem/known_bugs/sqlite-o1-runtime-segfault.md.
+ECO_BUG="known_bugs/sqlite-o1-runtime-segfault.md"
eco_lib_srcs() { echo "$ECO_SRC/sqlite3.c"; }
diff --git a/test/ecosystem/recipes/yyjson.recipe b/test/ecosystem/recipes/yyjson.recipe
@@ -1,12 +1,10 @@
# test/ecosystem/recipes/yyjson.recipe — real-world C build gate for yyjson.
# Contract documented in sqlite.recipe.
#
-# KNOWN-RED (kept red on purpose — do not xfail): kit aborts compiling
-# yyjson.c at the write path with "MCEmitter: label NNNN placed twice"
-# (reproduces at -O0 AND -O1 with `-c` on yyjson_write_minify, the function near
-# yyjson.c:9216 with its many `goto fail_*` epilogues). ECO_BUG only annotates
-# the failure; the gate stays red until the codegen bug is fixed. Details +
-# repro: test/ecosystem/known_bugs/yyjson-write-label.md.
+# Green at both -O0 and -O1 (build, run, and clang-identical output). It was
+# previously kept red by two now-fixed defects — the -O0 "MCEmitter: label
+# placed twice" codegen abort (known_bugs/yyjson-write-label.md, Bug A) and the
+# -O1 one-shot `.Lkit_ro` link failure (known_bugs/sqlite-o1-lkit-ro.md, Bug B).
ECO_VERSION=0.12.0
ECO_URL="https://github.com/ibireme/yyjson/archive/refs/tags/0.12.0.tar.gz"
@@ -16,7 +14,6 @@ ECO_INCLUDES="."
ECO_DEFINES=""
ECO_LIBS=""
ECO_DRIVER=use/use_yyjson.c
-ECO_BUG="known_bugs/yyjson-write-label.md"
eco_lib_srcs() { echo "$ECO_SRC/yyjson.c"; }