A source path with no directory component (`sx build main.sx` from the project dir — what the chess app does) made `diFileFor` emit a `DIFile` with an empty `directory:`, so the compile unit's `DW_AT_comp_dir` was "". Apple's ld then silently drops the *entire* object's debug map (0 N_OSO) and the binary is undebuggable — lldb resolves no sx source. Builds whose path had any directory (`.sx-tmp/x.sx`, `examples/x.sx`) were unaffected, which is why small repros + the stepping smoke passed and only the bundled chess app hit it. Fix: diFileFor falls back to "." (and "/" for a root-level file) when the path has no directory component, so comp_dir is never empty. Verified: chess (`sx build --target macos --emit-obj main.sx`) now links with OSO=1 and lldb resolves `frame at main.sx:82:8`. Regression guard added to the DWARF unit test (asserts `DIFile(... directory: ".")` for a bare filename). Gates: zig build, zig build test, run_examples.sh -> 291 passed, debug-stepping smoke ok.
108 lines
5.6 KiB
Markdown
108 lines
5.6 KiB
Markdown
# 0058 — large/bundled macOS build links with an empty DWARF debug map
|
||
|
||
## ✅ RESOLVED (2026-06-01)
|
||
|
||
Root cause: **an empty `DW_AT_comp_dir`**. A source path with no directory
|
||
component (`sx build main.sx` from the project dir) made `emit_llvm`'s
|
||
`diFileFor` emit a `DIFile` with an empty `directory:`, so the compile unit's
|
||
`comp_dir` was `""`. Apple's `ld` then silently drops the *entire* object's
|
||
debug map (no `N_OSO`) — the binary becomes undebuggable. Builds whose path had
|
||
any directory component (`.sx-tmp/x.sx`, `examples/x.sx`) were unaffected, which
|
||
is why small repros + the smoke passed and only the chess app (`sx build
|
||
main.sx`) hit it.
|
||
|
||
Fix: `diFileFor` falls back to `"."` (and `/` for a root-level file) when the
|
||
path has no directory component, so `comp_dir` is never empty. One-line change
|
||
in `src/ir/emit_llvm.zig`. Regression guard added to the DWARF unit test in
|
||
`src/ir/emit_llvm.test.zig` (asserts `DIFile(... directory: ".")` for a bare
|
||
filename). Verified: chess (`sx build --target macos --emit-obj main.sx`) now
|
||
links with `OSO: 1` and lldb resolves `frame at main.sx:82:8`.
|
||
|
||
---
|
||
|
||
## Symptom
|
||
|
||
One-line: `sx build --emit-obj` produces a **debuggable** binary for small sx
|
||
programs (lldb resolves `.sx:line`), but for the chess app (a large,
|
||
multi-module bundled macOS build) the linked binary has an **empty debug map**
|
||
(0 `N_OSO` entries) even though `main.o` carries valid DWARF — so lldb / a
|
||
VSCode debug session cannot resolve any sx source for the real app.
|
||
|
||
- **Observed:** `dsymutil -dump-debug-map sx-out/macos/SxChess` → `---` (no
|
||
objects). `nm -ap … | grep -c ' OSO '` → 0. lldb shows
|
||
`where = SxChess\`frame, … unresolved` (symbol present, no `at main.sx:line`).
|
||
A `dsymutil` `.dSYM` is therefore empty (UUID matches, but no line info).
|
||
- **Expected:** an `N_OSO` debug-map entry for `main.o` (like every small
|
||
build gets), so lldb resolves `func at main.sx:line` and steps sx source.
|
||
|
||
## What's confirmed / ruled out
|
||
|
||
- `main.o` **has** valid DWARF: `llvm-dwarfdump --debug-line .sx-tmp/main.o`
|
||
shows `main.sx`; `--debug-info` shows `DW_TAG_compile_unit DW_AT_name
|
||
"main.sx"`, DWARF32 v4. So DWARF emission (ERR E3.0 slices 1–2) is fine and
|
||
`--emit-obj` correctly forces `-O0`.
|
||
- The link command is clean — `cc <objs> -o out -lc -lobjc -framework
|
||
Foundation -framework Metal -L/opt/homebrew/lib -lSDL3`. **No** `-S` /
|
||
`-dead_strip` / `strip` / `-g` / explicit linker override.
|
||
- NOT caused by: the framework/lib set (linking a small DWARF object with the
|
||
exact same frameworks keeps `OSO=1`); ad-hoc **codesign** (signing a small
|
||
binary keeps `OSO=1`; the binary is `linker-signed` by ld anyway); the
|
||
**bundler** (the *pre-bundle* `sx-out/macos/SxChess` already has 0 OSO, and
|
||
`bundle.sx` runs no `strip`); **opt level** (DWARF is present in the .o);
|
||
`#import "modules/std.sx"` (a tiny std-importing program keeps `OSO=1`);
|
||
missing **global `_main`** (chess *does* have `T _main`).
|
||
- The remaining correlate: chess's `main.o` is **large** (~1.1 MB, many
|
||
monomorphized functions merged from many imported modules). Every small
|
||
repro tried keeps `OSO=1`; only the large multi-module build drops it. A
|
||
minimal repro has NOT been isolated yet.
|
||
|
||
## Reproduction
|
||
|
||
Not yet minimal (this is part of the fix). Reproduces reliably on the chess
|
||
app at `~/projects/game`:
|
||
|
||
```sh
|
||
cd ~/projects/game
|
||
/path/to/sx build --target macos --emit-obj main.sx
|
||
dsymutil -dump-debug-map sx-out/macos/SxChess # → `---` (empty); BUG
|
||
llvm-dwarfdump --debug-line .sx-tmp/main.o | grep main.sx # DWARF IS present
|
||
```
|
||
|
||
Small programs (single file, with or without `#import "modules/std.sx"`, with
|
||
or without the chess framework set) all produce `OSO: 1` and step fine in lldb.
|
||
|
||
## Investigation prompt
|
||
|
||
The Mach-O **debug map** (`N_OSO`/`N_FUN` stabs) is synthesized by Apple's
|
||
`ld` at link time from each object's DWARF + symbol table; sx does not emit it
|
||
directly. ld is silently emitting **none** for the large chess `main.o`.
|
||
|
||
Suspected areas:
|
||
1. **Object emission** — `src/ir/emit_llvm.zig` `emitObject` /
|
||
`LLVMTargetMachineEmitToFile`. Diff the working small `main.o` vs the chess
|
||
`main.o`: do both have the same Mach-O symbol-table shape (N_FUN/global
|
||
anchors), `__DWARF` section set, and `__debug_aranges`/`__debug_line`
|
||
layout? A large single compile unit, a DWARF feature ld dislikes
|
||
(e.g. `.debug_names`, `DW_FORM` ld can't follow), or a section-size/symbol
|
||
threshold could make ld skip the debug map for that object.
|
||
2. **Minimize**: grow a single-file program (more functions / import more
|
||
modules / pull in `platform/bundle.sx`) until `dsymutil -dump-debug-map`
|
||
flips from 1 → 0. That pins the trigger (size? a specific module/construct?).
|
||
3. Check whether ld prints a (suppressed) warning — run the link with
|
||
`SX_DEBUG_LINK=1` and `-Wl,-debug_variant` / ld verbosity, or link the
|
||
chess objects by hand with `ld -v`.
|
||
|
||
Likely fix is in how we emit the object's DWARF/symbol table (so ld accepts
|
||
it), or emitting our own `.dSYM`-compatible companion. The cheap workaround for
|
||
users until fixed: debug a small sx repro of the failing logic, or emit per-
|
||
module objects. Verification: `dsymutil -dump-debug-map <chess-binary>` lists
|
||
`main.o`, and `lldb` resolves `frame` → `main.sx:line`.
|
||
|
||
## Impact
|
||
|
||
Blocks source-level **debugging of real (large/bundled) sx apps** in lldb /
|
||
VSCode. The trace-formatting feature (ERR E3) is unaffected — runtime + comptime
|
||
return traces resolve in-process via the embedded `Frame` table (no debug map
|
||
needed). ERR E3 stepping rungs 1–2 (small macOS + iOS-sim binaries) are still
|
||
verified; this gap is specific to large builds' debug map.
|