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:
107
issues/0058-empty-debug-map-large-build.md
Normal file
107
issues/0058-empty-debug-map-large-build.md
Normal 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 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.
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
Reference in New Issue
Block a user