8.2 KiB
Handoff — a redefinition emitter in lib/x86.ml
Branch dev-loop, worktree agent-a884e1e2fcffe5052, from b1cc67b. Item 1 of HANDOFF-x86-rt.md §6.
It works and it is checked by running it. An --x86 host dlopens an --x86 redefinition module, the
module publishes a new body into the host's cell, and the host's un-rebuilt call site follows it.
test/test_reload.ml is green on both backends.
Baseline, measured before and after
before (b1cc67b) |
after | |
|---|---|---|
spike/x86/survey.sh |
97 MATCH / 0 DIFFER / 0 refused | (see §"After", below) |
spike/x86/cells.sh |
4/4 ok | 4/4 ok |
dune test --root . |
exit 0 | exit 0 |
dune test prints two ld / clang failures from inside the dev-robust fixture and still exits 0. Check the
exit code, not the output; grepping will mislead you.
Why this had to exist
x86.ml licenses its own calling convention on the grounds that a dev build is compiled entirely by it and a
release build entirely by LLVM, and the two never meet in one process. The conventions agree on every scalar
and disagree on every aggregate (here: every aggregate by pointer with a hidden sret in the first integer
register; LLVM classifies by eightbyte). An Emit.redefinition module dlopened into an --x86 host is correct
until the first redefined function takes or returns a struct. So an --x86 host gets --x86 modules.
There is still no aggregate classifier and there must not be one.
The crux, and the answer
program emits an executable; every symbol it names it defines, or the runtime does, and a pc-relative PC32
reference is right for all of them. A redefinition module is a .so and almost everything it names belongs to
the host executable — the cells flan.cell.<n>, the globals flan.<g>, the runtime's entry points. A
PC32 relocation against an undefined symbol cannot be used in a -shared link; ld refuses it.
So a reference to a symbol the object does not define goes through the GOT. Measured, not recalled:
- The spelling is
.long sym@GOTPCREL - 4. Quoted symbols ("flan.cell.x"@GOTPCREL) are accepted. - .is wrong.@GOTPCRELis already pc-relative; written- . - 4as the plain form needs,asproduces an addend of -8 and the load reads the wrong slot.readelf -ron the object is how you see it: wantflan.cell.x - 4.llc -relocation-model=picemitsmovq x@GOTPCREL(%rip), %rbxfor@x = external global i64, which is the same instruction bytes this backend already emits for aSymoperand. Only the relocation changes.
What was built
| file | what |
|---|---|
lib/x86.ml — modrm_got, mem's new Got case |
the @GOTPCREL field. mem_op and store_int's byte case were the only two matches on mem |
lib/x86.ml — loc's new Lgot case |
a symbol the object does not define. lmem loads the GOT slot into scratch and answers Reg (scratch, d) — the Lp shape, so a field offset is arithmetic on a register and never on the relocation |
lib/x86.ml — fnctx.ext |
string -> bool, true of a symbol this object does not define. Default false, so the whole-program path emits byte-identical output and the survey is protected structurally |
lib/x86.ml — sym_loc, addr_sym, load_sym |
the three spellings of naming a symbol; each picks pc-relative or GOT off ext |
lib/x86.ml — emit_fn ?ext ?hidden |
.hidden on a module's own bodies |
lib/x86.ml — program, the no-main case |
was unsupported "no main", now emits none, as emit.ml does. A program linked against a C host that brings its own entry point is legitimate — reload_host.c is exactly that, and the refusal made an --x86 host for the reload tests impossible to build |
lib/x86.ml — redefinition |
the emitter. No main, no .init_array, no flan..init-globals, no flan_dev_reg_enable ctor, no .bss for globals, no .data for cells. Bodies .hidden; one flan_reload_install that reads each cell's address out of the GOT and stores the new body's lea into it |
lib/build.ml — shared_x86 |
as + ld -shared, the same link shared does. No -relocation-model=pic to ask for: the emitter already writes every foreign reference through the GOT |
test/test_reload.ml |
the x86 section: an --x86 host, two --x86 modules, the same transcript, plus the refusal check |
The one real bug, because it is the shape of the next one
call_flan's `Cell target loaded one level: mov r11, cell@GOTPCREL(%rip) then call *%r11. That
reads as "call through the cell" in a disassembly and in fact calls the cell. The GOT slot holds the cell's
address; reading what the cell holds is two loads, not one. Found by the segfault, not by reading — which is
../DISCUSS.md item 15's claim, now with a sixth instance. load_sym is the fix and is the only helper that
double-loads; Lgot gets it right for free because lmem already had the shape.
Scope, deliberately narrower than Emit.redefinition
Only names the host already has. Refused by name, each with the reason in the message:
- a function new to the session — needs
flan_dev_cellandEmit.cellptr's second spelling (a module-local slot resolved by string at install time) - a global new to the session — needs
flan_dev_global, plus the init constant that travels with it consts— republishing adefconstcall— the transientflan_reload_callthunk and the@flan_reload_transientmarker the agent unloads on
That is C-c C-c on an existing defn, which is the demo, and it is what test/programs/reload.flan and
reload-v2.flan exercise (v3 and v4 are the new-name cases and are asserted to refuse).
What remains
- The new-name path.
flan_dev_cell/flan_dev_globalandEmit.cellptr's spelling, so that aC-c C-kintroducing a function or adefvarworks on--x86.test/test_reload.ml's v3/v4/v5 are the fixtures already written; the refusal check there is what to replace. - The transient thunk —
flan_reload_callplus@flan_reload_transient, which is what an expression evaluation (the break loop,C-x C-e) compiles to. Noteemit.ml's three conditions for emitting the marker, includingm.nstr = 0: a module holding a string literal can never say it is transient. - Wiring it to
flan dev/flan reload. Today nothing reaches this:--x86is read only byflan build's argument list inbin/main.ml, and the daemon builds host and module throughBuild.executable/Build.sharedwith nox86field set. When it is wired, the host's backend and the module's backend must be chosen together — that is the whole licence. - A redefined function that takes and returns a struct. This is the case that motivated the whole lane —
the two backends agree on every scalar and disagree on every aggregate — and it is exercised by nothing,
on either side of the reload boundary.
reload.flan'sbumpis-> i64andhelperisi64 -> i64. The argument that an--x86host plus--x86modules is same-convention-by-construction is sound, but it is an argument and not a measurement. Do not editreload.flanfor it — its transcript is shared with the LLVM path; add a fixture beside it. - Items 2–7 of
HANDOFF-x86-rt.md§6, unchanged.
Also still true and still worth doing: run spike/x86/survey.sh in CI.
After, measured
before (b1cc67b) |
after | |
|---|---|---|
spike/x86/survey.sh |
97 MATCH / 0 DIFFER / 0 refused | 97 / 0 / 0, skips 28 does-not-compile / 6 no-main / 2 runs-forever — identical |
spike/x86/cells.sh |
4/4 ok | 4/4 ok |
dune test --root . |
exit 0 | exit 0, run without a pipe |
The skip breakdown is the line that matters beyond the MATCH count, because the no-main relaxation is the one
change here not gated behind ext being false. It cannot in fact move it: survey.sh:70 classifies no-main
off the LLVM build failing at the link, before it ever runs the x86 one.
And run dune test --root . without a pipe: piping to tail gives you tail's exit status, which is the
trap this file warns about at the top, and the dev-robust fixture puts ld and clang failure text in the
output either way.