fix(dwarf): non-empty comp_dir so ld keeps the debug map (issue 0058)

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.
This commit is contained in:
agra
2026-06-01 16:47:51 +03:00
parent b2ebf774bc
commit 0d7f786db2
3 changed files with 117 additions and 3 deletions

View File

@@ -0,0 +1,107 @@
# 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 12) 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 12 (small macOS + iOS-sim binaries) are still
verified; this gap is specific to large builds' debug map.

View File

@@ -989,7 +989,10 @@ test "emit: ERR E3.0 — DWARF debug info (compile unit + subprogram + per-inst
try std.testing.expect(std.mem.indexOf(u8, ir_str, "\"Debug Info Version\"") != null);
try std.testing.expect(std.mem.indexOf(u8, ir_str, "\"Dwarf Version\"") != null);
try std.testing.expect(std.mem.indexOf(u8, ir_str, "DICompileUnit") != null);
try std.testing.expect(std.mem.indexOf(u8, ir_str, "DIFile(filename: \"probe.sx\"") != null);
// Regression (issue 0058): a bare filename (no directory component) must
// still get a NON-EMPTY `directory:` — an empty `DW_AT_comp_dir` makes ld
// silently drop the whole debug map, so the binary becomes undebuggable.
try std.testing.expect(std.mem.indexOf(u8, ir_str, "DIFile(filename: \"probe.sx\", directory: \".\")") != null);
try std.testing.expect(std.mem.indexOf(u8, ir_str, "DISubprogram(name: \"main\"") != null);
try std.testing.expect(std.mem.indexOf(u8, ir_str, "DILocation(line: 3") != null);
}

View File

@@ -394,11 +394,15 @@ pub const LLVMEmitter = struct {
}
/// The `DIFile` for `path`, created once and cached. Splits the path
/// into basename + directory as DWARF expects.
/// into basename + directory as DWARF expects. The directory MUST be
/// non-empty: an empty `DW_AT_comp_dir` makes Apple's `ld` silently drop
/// the whole object's debug map (no `N_OSO`), so a binary built from a
/// bare filename (e.g. `sx build main.sx`) becomes undebuggable. Fall back
/// to "." when the path has no directory component.
fn diFileFor(self: *LLVMEmitter, path: []const u8) c.LLVMMetadataRef {
if (self.di_files.get(path)) |f| return f;
const slash = std.mem.lastIndexOfScalar(u8, path, '/');
const dir = if (slash) |s| path[0..s] else "";
const dir = if (slash) |s| (if (s == 0) "/" else path[0..s]) else ".";
const base = if (slash) |s| path[s + 1 ..] else path;
const f = c.LLVMDIBuilderCreateFile(self.di_builder, base.ptr, base.len, dir.ptr, dir.len);
self.di_files.put(path, f) catch {};