flan/test/reload_host.c
Joseph Ferano 7faab27ea2 restart-case and invoke-restart, which are the transfer
spec-conditions.md §3 to §6. A handler runs where the signal was, decides, and
control resumes at a restart-case further out - so unlike step 1 this one does
alter control flow, and it is lowered explicitly rather than through platform
unwinding, because wasm32 cannot unwind and because a cmp/jne after a call
reads like ordinary code.

The channel is the out-parameter §6 settled on: one ptr appended to every Flan
signature, written by an invoke-restart and checked after every call. The
return type stays what the source says, one pointer threads down the whole
chain, and a frame that sees the channel set just returns early - which reuses
the existing return path and with it §5's defers for free. Emit.signature was
already the one place a signature is spelled, which is what made that part
small.

Every function is transfer-transparent, release included. §6's escape analysis
is an optimisation; in a dev build a cell can hold anything, so the honest
answer to what a call can reach is anything, and uniform means redefinition
acquires no new refusal class.

The transfer target is the restart frame's own address and not a static clause
id, which corrects what the handoff note had settled. An id has to be unique
against every module a running program may later load, and a hash is only
probably unique - two restart-cases colliding means the inner one silently
catches a transfer aimed at the outer. The frame is an alloca in the function
that offers it, so the address is exact and it also says which clause, which is
how clause ids disappeared. Re-entering a restart-case then needs nothing
extra, since each activation allocates its own frames.

Cleanup is landing blocks, one per region rather than one per function: a
restart-case's pops its frames and either dispatches or forwards, a
handler-bind's pops the handler frames on the way past, and the function's own
runs its defers and returns. One function-wide block would have jumped straight
past the very restart-case that was meant to catch the transfer. The channel is
cleared before any cleanup runs and put back after, or a defer's first call
would branch straight back into the block it came from.

flan_signal takes the channel and passes it to each handler, stopping once one
writes to it. That makes the one C frame every handler is reached through
transparent to a transfer, which it has to be; it is also the only one, since
extern is Flan-to-C only and there are no function values yet.

Refused by name with the reason, each with a test on the reason: restarts with
parameters, return inside a restart-case body, one restart-case offering a name
twice, and invoke-restart inside a defer - a defer is the cleanup a transfer
already runs, so starting one there leaves the defers half run with two targets
and no way to choose. The lexical case is the checker's and the one that
reaches a function through a call is trapped at run time. No restart of that
name is a located runtime error at the invoke site, because there is nowhere to
resume.

Two things found on the way. `{ ctx with in_handler = true }` was a latent bug:
ctx.slots is mutable, so a copy allocated the body's slots into a record the
function never saw again - harmless only because no handler-bind body in the
tests had a let in it. And test/reload_host.c calls flan.outer through an asm
label, which does not fail at link time when the prototype is a parameter
short; it reads garbage as the channel and dies somewhere else.

test/programs/restarts.flan runs at -O2, at -O0 and as a dev build. -O0 is not
redundant: the guard after every call is control flow the optimiser would
otherwise launder, and the dev build is where each of those calls goes through
a cell.
2026-09-11 08:14:03 +07:00

99 lines
4.0 KiB
C

/* reload_host.c — redefinition, exercised in one process.
*
* This is the smallest thing that can prove the dev loop's first two steps:
* a function recompiled into its own object, loaded into a program that is
* already running, *installed* there, and then reached by a call site that
* was compiled before it existed. No socket, no daemon, no frame boundary —
* those are step 3, and the agent that does them lives next to flan_rt.c for
* the same reason this host is C: there is no OCaml in a game process.
*
* It stands in for the entry point of a Flan program, so the .flan fixture it
* links against has no [main] of its own. Three things are being checked, and
* only a single process can check any of them:
*
* - installing a new body makes the host's own [outer] — linked once, never
* rebuilt — call it, which is the whole of C-c C-c;
* - the loaded copy writes the *host's* [counter] and calls the host's
* [helper], because a redefinition module declares both rather than
* defining them;
* - the state carries across every reload untouched;
* - a name the host was never built with — v3's [extra] and [added] — can be
* introduced, and then itself redefined by v4 while v3's already-installed
* [bump] keeps calling it. That last one is what separates a cell found by
* name from a function address cached by name; everything else passes
* either way.
*
* Each version is its own file rather than one path rewritten in place:
* dlopen keys its cache on the path, so re-opening the same name can hand back
* the handle it already has and the test would then "pass" on the code it
* loaded the first time. Nothing is ever dlclosed — a cell holds an address
* inside a module's text, and unloading it would leave call sites pointing at
* unmapped memory.
*/
#include <dlfcn.h>
#include <stdint.h>
#include <stdio.h>
#include <time.h>
/* The Flan symbols the executable itself defines. Flan names contain
* characters C identifiers cannot, so each one is reached through its asm
* label — the same name Emit spells. */
/* The trailing ptr is the transfer channel spec-conditions.md §6 puts in every
* Flan signature. This host never transfers, so it passes a slot of its own
* that stays null — but the parameter is not optional: getting it wrong reads
* garbage as the channel and fails nowhere near here. */
extern int64_t flan_outer(void *xfer) __asm__("flan.outer");
extern int64_t flan_counter __asm__("flan.counter");
void flan_rt_init(int32_t argc, char **argv);
/* What a redefinition module exposes. It is a named function and not an ELF
* constructor on purpose: the agent has to choose when the store happens —
* on the game thread, between frames — and a constructor would do it during
* dlopen, wherever that call happened to be. */
typedef void (*install_fn)(void);
/* The load is timed here rather than from the test process, because this is
* the part that has to fit inside a frame. */
static double now_ms(void) {
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return (double)t.tv_sec * 1e3 + (double)t.tv_nsec / 1e6;
}
static int install(const char *path) {
double t0 = now_ms();
void *h = dlopen(path, RTLD_NOW | RTLD_LOCAL);
if (h == NULL) {
fprintf(stderr, "dlopen %s: %s\n", path, dlerror());
return 0;
}
install_fn f = (install_fn)(uintptr_t)dlsym(h, "flan_reload_install");
if (f == NULL) {
fprintf(stderr, "dlsym flan_reload_install in %s: %s\n", path, dlerror());
return 0;
}
double t1 = now_ms();
f();
fprintf(stderr, "dlopen+dlsym %.2fms install %.4fms\n", t1 - t0,
now_ms() - t1);
return 1;
}
int main(int argc, char **argv) {
flan_rt_init(argc, argv);
if (argc < 2) {
fprintf(stderr, "usage: %s <module.so>...\n", argv[0]);
return 2;
}
void *xfer = NULL;
printf("host %lld\n", (long long)flan_outer(&xfer));
for (int i = 1; i < argc; i++) {
if (!install(argv[i])) return 1;
printf("after%d %lld\n", i, (long long)flan_outer(&xfer));
}
printf("counter %lld\n", (long long)flan_counter);
return 0;
}