The handoff, with every number in it measured on this tree

The baseline rows, the twelve-program DWARF sweep re-run against the code
that was actually committed rather than a superseded version of it, the
lldb transcript, the unwind out of flan_bounds_error, and the compile
unit's range checked against nm rather than asserted.
This commit is contained in:
Joseph Ferano 2026-09-13 23:32:26 +07:00
parent 78368811c0
commit c8464abc18

View File

@ -43,7 +43,7 @@ which is the same thing this backend already does for instructions.
**`.cfi` is the opposite finding and it is good news.** GAS's CFI machinery computes its advances from frag
positions and not from `md_assemble`, so `.cfi_def_cfa_offset` and friends interleaved with `.byte` come out
*exactly* right — measured, `readelf --debug-dump=frames` on a `.byte`-only function gives the correct
advances. Nothing here emits them yet (see "What was not reached"), but the next lane should know it can.
advances. So this lane emits them, in a `--debug` build — see below.
## What landed
@ -86,6 +86,7 @@ In `lib/x86.ml`:
| `emit_fn ?dw` | opens the subprogram, seeds it with a row at the function symbol on the `defn`'s line, closes it with a label one past the last byte |
| `emit_dwarf` | `.debug_abbrev` (two abbreviations), `.debug_info` (one CU, one `DW_TAG_subprogram` per function), `.debug_line` (header, directory table, file table, program) |
| `program ~debug` | a numbered `.file` at the top, `.Ldwtext` / `.Ldwtext_end` around the text, and the sections at the end |
| `cfi_after_push` / `cfi_after_mov` / `cfi_after_leave`, above `emit_fn` | the whole frame model in five directives, at the three sites that share a prologue: `emit_fn`, `emit_main`, `emit_globals_init` |
Two decisions worth knowing about because they are not obvious:
@ -157,19 +158,64 @@ project's own source-level debugging case runs under **lldb**, which does not ha
debug.flan:19` and `break flan.tick` both work everywhere. Matching `emit.ml` was chosen over diverging from
it; if this is worth fixing it should be fixed in both places at once.
**lldb**, which is the debugger this project's own source-level case runs under, does better still — it
resolves `flan.tick` by name, shows the source inline and gives a column:
```
$ lldb -b -o "breakpoint set --name flan.tick" -o run -o bt ./dbg
* thread #1, stop reason = breakpoint 1.1
frame #0: 0x0000000000400647 dbg`flan.tick at debug.flan:18:3
17 (defn tick [c (Ptr Cell) n i32] i32
-> 18 (let [bump (+ n 1)]
^
19 (set (.heat c) (+ (.heat c) 1.5))
(lldb) bt
* frame #0: 0x0000000000400647 dbg`flan.tick at debug.flan:18:3
frame #1: 0x00000000004007b5 dbg`flan.main at debug.flan:25:28
frame #2: 0x0000000000400bea dbg`main + 41
frame #3: 0x00007ffff7cb9575 libc.so.6`__libc_start_call_main + 117
```
That transcript is now a test rather than a transcript: `test/test_acceptance.ml`'s lldb block has an `--x86`
arm beside its `--dev` one, asserting the breakpoint resolves and both Flan frames name `debug.flan`, and that
the program still prints what it printed. It claims no `frame variable`, which is the gap.
**Unwinding out of the C runtime works**, which is the case that matters for this project's error paths and
which the brief did not ask for but should have. `bounds.flan` built `--x86 --debug`, breaking on the C symbol:
```
Breakpoint 1, flan_bounds_error (loc=0x40a880 "test/programs/bounds.flan:11:44", ...) at flan_rt.c:534
#0 flan_bounds_error (...) at flan_rt.c:534
#1 0x00000000004006be in flan.main () at bounds.flan:11
#2 0x000000000040162e in main ()
```
**The compile unit's range is exact.** `DW_AT_low_pc` 0x400628 is `flan.tick`, the first function;
`DW_AT_high_pc` 0x5cf puts the end at 0x400bf7, and `nm -S` says `main` — the last thing this object puts in
`.text` — ends at 0x400bc1 + 0x36 = 0x400bf7. So `.Ldwtext` / `.Ldwtext_end` bracket the four functions this
unit emitted and nothing else.
`--x86 --dev --debug` together, which is a reachable combination and was never exercised by anything else,
builds, runs and gives a line table `readelf` reads with no warnings.
Cross-checked on a spread of the corpus — `debug`, `generics`, `vec`, `maps`, `strings`, `conditions`,
`bounds-condition`, `algorithms`, `handles`, `registry` — all build with `--x86 --debug`, run, and produce
`.debug_line` that `readelf --debug-dump=decodedline` reads with **zero warnings**. `generics` is the
`bounds-condition`, `algorithms`, `handles`, `registry`, `destructure`, `edn`, twelve in all — every one
builds with `--x86 --debug`, runs, and produces `.debug_line` that `readelf --debug-dump=decodedline` reads
with **zero warnings**. `edn` and `generics` are the large ones, at 40 and 36 subprograms. `generics` is the
multi-file case: its directory table has one entry and its file table two, `generics.flan` and `<prelude>`.
`<prelude>` has no path on disk and a debugger simply finds no source for it, which is the truth.
## Baseline, measured on this tree
| | before | after |
| | before (`957ba07`) | after |
|---|---|---|
| `spike/x86/survey.sh` | 99 MATCH / 0 DIFFER / 0 REFUSED / 0 NOX86, skips 28 + 6 + 2 | (see below) |
| `spike/x86/cells.sh` | 4/4 ok | (see below) |
| `dune test --root .` | exit 0 | (see below) |
| `spike/x86/survey.sh` | 99 MATCH / 0 DIFFER / 0 REFUSED / 0 NOX86, skips 28 + 6 + 2 | **99 / 0 / 0 / 0**, skips **28 + 6 + 2** — identical |
| `spike/x86/cells.sh` | 4/4 ok | **4/4 ok** |
| `dune test --root .` | exit 0 | **exit 0**, run without a pipe; acceptance reports 232 checks, 0 failures |
The survey is protected structurally as well as measured: every piece of this lane's work on the release path
is behind `debug`, so `--x86` without `--debug` emits the assembly it emitted before — `fnctx.dw` is `None`,
`dwline` does nothing, the `.Ldwtext` label is not written and neither are the `.cfi` directives.
Run `dune test --root .` **without a pipe**`HANDOFF-x86-redef.md` is emphatic and right: piping gives you
`tail`'s exit status, and the `dev-robust` fixture puts `ld` and `clang` failure text in the output either way
@ -187,14 +233,18 @@ while the run still exits 0.
reachable: `DW_TAG_formal_parameter` and `DW_TAG_variable` for `snames`-named slots only, with
`DW_AT_frame_base` as `DW_OP_call_frame_cfa` or `DW_OP_reg6`. That needs a type-DIE emitter, which is the
part `emit.ml` spends most of its debug lines on.
2. **`.cfi`, and it is now known to be cheap.** Nothing here emits it, so gdb unwinds by its prologue
analyser — which works, because `push rbp; mov rbp,rsp; sub rsp,N` is the canonical pattern and this
backend's header guarantees `rsp` is written exactly twice. The backtraces above are that analyser
working. But the experiment at the top of this file says `.cfi` against `.byte` output is exact, and the
frame model makes the content textbook: CFA is `rsp+16` after the `push`, `rbp`-based after the `mov`, and
unchanged for the whole body. It would want `.cfi_startproc` / `.cfi_endproc` in `emit_fn` and three
directives in the prologue. Worth doing; it is what makes a backtrace survive an unwind from inside the
runtime's C.
2. **`.cfi` is emitted, but only in a `--debug` build.** Five directives at three sites — `emit_fn`,
`emit_main`, `emit_globals_init`, which share a prologue. `readelf --debug-dump=frames` gives the exact
advances. `emit_main` gets no closing rule because it has no epilogue: it leaves through `flan_exit` and
the `ud2` after that is unreachable, so the `rbp` rule holds to the last byte.
gdb did not need it — its prologue analyser already unwound out of `flan_bounds_error`, because
`push rbp; mov rbp,rsp; sub rsp,N` is the pattern it recognises — so this is a description stated rather
than guessed, plus a correct unwind from the very first byte of a function, before the `push`.
**The open decision is whether to make it unconditional.** The description is correct in every build and a
release build is where a crash would most want it. What stopped it here is only that nothing measures the
`.eh_frame` it would add, and another lane is measuring backend cost right now. One `if` in three places.
3. **`X86.redefinition` emits no debug information.** It is passed no `dwarf`, so a redefinition module's
bodies have no lines. Nothing reaches that yet — `flan dev` does not build `--x86` at all
(`HANDOFF-x86-redef.md` §"What remains" item 3) — but when it does, a break loop stopping in a reloaded
@ -203,9 +253,10 @@ while the run still exits 0.
`high_pc` range, which is honest — a debugger finds no line for an address in them and says so — but a
backtrace through the globals' initialiser names nothing. `emit_globals_init` does lower expressions that
carry locations, so this is a small piece of work: give it a `dwsub` the way `emit_fn` has one.
5. **Nothing tests this in `dune test`.** The corpus's source-level debugging case (`test/programs/debug.flan`
and `debug-permuted.flan`) runs under lldb against the LLVM backend. An `--x86` arm of it would be the
right regression test and does not exist; the verification above is a transcript in a handoff, which rots.
5. **`debug-permuted.flan` has no `--x86` arm.** The new acceptance case covers `debug.flan` only. The
permuted fixture exists to catch a member offset that does not follow the declaration, and that is a
*types* claim — there are no type DIEs here to get wrong, so it would test nothing today. It becomes the
right test the moment item 1 above is attempted, and it is already written.
## Open questions for the author