kit

kit
git clone https://git.ryansepassi.com/git/kit.git
Log | Files | Refs | README

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:
Msrc/link/link_reloc_layout.c | 12+++++++++++-
Msrc/link/link_resolve.c | 13+++++++++++++
Mtest/driver/run.sh | 39+++++++++++++++++++++++++++++++++++++++
Atest/ecosystem/expected/yyjson.txt | 3+++
Mtest/ecosystem/known_bugs/README.md | 16+++++++++++-----
Mtest/ecosystem/known_bugs/sqlite-o1-lkit-ro.md | 21+++++++++++++++++++--
Atest/ecosystem/known_bugs/sqlite-o1-runtime-segfault.md | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/ecosystem/known_bugs/yyjson-write-label.md | 18++++++++++++++++--
Mtest/ecosystem/recipes/sqlite.recipe | 12+++++++-----
Mtest/ecosystem/recipes/yyjson.recipe | 11++++-------
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"; }