flan/HANDOFF-x86-debug.md
Joseph Ferano 853bc35be7 A release build stops calling into a registry that is switched off
emit.ml:1918 drops the allocation registry's notes in a release build --
the checker builds a Tast.Rt it cannot know is unwanted, because it does
not know whether this is a dev build. The x86 backend had no counterpart
and emitted the calls for real: correct, since the registry answers
nothing when it is disabled, but one call per container operation into a
function that returns immediately.

The guard is emit.ml's byte for byte, strict > 17 included: bare
flan_dev_reg_note is the runtime's own C entry point and is never a
Tast.Rt, and what check.ml builds is the _vec, _map and _pool wrappers.
emit.ml drops the note before the arguments are walked so that taking the
address of the container does not leave an escaped alloca behind; here the
arguments are not touched until call_rt, so answering () is already early
enough.

Measured, call sites of flan_dev_reg_note in the disassembly:

  vec.flan       46 -> 1     (--dev: 46)
  maps.flan      55 -> 1     (--dev: 55)
  registry.flan  36 -> 1     (--dev: 36)

The one left in each is not emitted code -- it is inside the runtime's own
flan_dev_reg_note_vec. An LLVM release build of vec.flan has the same one,
so the two backends now agree.

HANDOFF-x86-debug.md is the stub for item 6, which is next, and it leads
with the finding that changes that item's plan: .loc does not work against
a backend that emits .byte blobs, so the line table has to be written out
by hand.
2026-09-13 23:32:35 +07:00

4.0 KiB

Handoff — dropping the registry notes, and debug information for --x86

Branch dev-loop, worktree agent-a123a91f32f4c9779, from 957ba07. Items 2 and 6 of HANDOFF-x86-rt.md §6.

The finding that changes the brief, read this first

.loc does not work in this backend's output, and no amount of care makes it work. The brief called line tables "by far the least work, since as will build .debug_line for you from .loc directives". That is true of a backend that emits mnemonics. This one emits .byte blobs — the header says so and says why — and GAS builds its line table from dwarf2_emit_insn, which only runs out of md_assemble. No instruction is ever assembled, so nothing flushes the pending .loc.

What GAS does instead is flush the pending .loc when it sees the next .loc, at whatever the location counter has reached by then. Measured on as 2.44:

	.file	1 "foo.flan"
main:	.loc 1 10 1
	.byte	0x55
	.byte	0x48,0x89,0xe5
	.loc	1 11 1
	.byte	0xb8,0x07,0x00,0x00,0x00
	.loc	1 12 1
	.byte	0xc9
	.byte	0xc3
foo.flan     10      0x4     x      <- should be 0x0
foo.flan     11      0x9     x      <- should be 0x4
foo.flan      -      0xb            <- line 12 has no row at all

Every row is one statement late and the last statement in the function gets none. Interposing labels between the .loc and the .byte does not help; it was tested. Do not try to correct this by shifting the .loc directives back by one statement — the last-statement hole is a correctness gap, not just fragility, and the behaviour being exploited is undocumented.

So the line table is written out by hand here, as raw .byte / .uleb128 / .quad in .debug_line, which is the same thing this backend already does for instructions. Scope grew accordingly; see the plan below.

Plan, in the order it is being done

  1. Item 2 — flan_dev_reg_note dropped in a release build. Done; see below.
  2. A hand-written .debug_line, plus the minimal .debug_info / .debug_abbrev CU that makes a debugger find it. These are one step and not two: readelf will read a bare .debug_line, but gdb reaches a line table only by walking .debug_info and following DW_AT_stmt_list.
  3. DW_TAG_subprogram DIEs, so break and list scope by function. Note that gdb already names frames from the ELF symbol table, which this backend emits via .globl, so this buys less than it looks like.
  4. .cfi — only if it can be placed correctly against .byte output. If it cannot, that is said plainly and gdb's prologue analyser is left to recognise push rbp; mov rbp,rsp; sub rsp,N, which is canonical.
  5. Locals and types, only if everything above is solid and committed.

Item 2 — done

lib/x86.ml, prim's Tast.Rt dispatch, one arm above the general one, mirroring lib/emit.ml:1918 test for test including the strict > 17: bare flan_dev_reg_note is the runtime's own C entry point and is never a Tast.Rt; what check.ml builds is the _vec, _map and _pool wrappers. The node's type is Unit, so the body is () and dst is untouched.

emit.ml is careful to drop the note before the arguments are walked, because emitting the address of the container only to discard the call would leave an escaped alloca that mem2reg refuses. Here the arguments are not touched until call_rt, so the guard is already early enough.

Measured by disassembly, objdump -d | grep -c 'call.*flan_dev_reg_note':

program --x86 release --x86 --dev LLVM release
vec.flan 46 → 1 46 1
maps.flan 55 → 1 55
registry.flan 36 → 1 36

The one that remains in every release build is not emitted code: it is inside the runtime's own flan_dev_reg_note_vec in flan_rt.c, which calls flan_dev_reg_note. LLVM's release build has exactly the same one, so the two backends now agree.

Baseline

Measured on this tree, not recalled. (Filled in below as it is measured.)

Open questions

(none yet)