flan/test/reload_host.c
Joseph Ferano 22cc0bc1c2 Names that did not exist when the process started
Editing a defvar or a defn is a symbol the host exports. Adding one is not:
there is nothing to bind to and ELF cannot grow a symbol. runtime/flan_dev.c is
the two lookups that cover it - flan_dev_cell for a new function's cell,
flan_dev_global for a new global's storage - both idempotent, so the second
module to mention a name gets what the first one got. That is the whole point:
two modules with their own copy of a new function would each call their own,
and redefining it would update one of them.

The compiler picks per name. A name the host has is a symbol and costs one load
at a call site; a name it lacks is a registry lookup cached at install time in
a module-local slot, and costs two. The common case pays nothing for the
general one.

The redefinition unit is now a list of top-level forms rather than one
function. It has to be: v3 of the fixture adds a var and uses it from a
redefined bump, and splitting that into two loads leaves a module referring to
storage that does not exist yet. C-c C-c passes one name, C-c C-k passes a
file's worth, one path either way.

Four rules, each silent if broken. Every lookup resolves before any body is
published, or a caller reaches a function whose slots are still null - asserted
on the emitted flan_reload_install, since it cannot be race-tested.
flan_dev_global refuses a size change, which is the layout-drift rule's first
enforcement point rather than another exception to it. Nothing is ever
dlclosed, because a cell holds an address inside a module's text. And the table
is fixed capacity, because a module holds a cell's address for as long as it is
loaded and a realloc would strand it.

The test that separates this from a plausible wrong version is v4, which
redefines a name v3 introduced at run time. v3's bump is already installed and
is not rebuilt, so it picks v4 up only if its call goes through a cell both
modules found by the same name. Had v3 cached the function's address instead,
every other assertion would still pass and the transcript would read 246
instead of 432.

Sizes are spelled LLVM's way, ptrtoint getelementptr null 1, rather than by a
layout calculator in OCaml that would have to agree with LLVM's on every
target.
2026-09-10 21:34:31 +07:00

94 lines
3.7 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. */
extern int64_t flan_outer(void) __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;
}
printf("host %lld\n", (long long)flan_outer());
for (int i = 1; i < argc; i++) {
if (!install(argv[i])) return 1;
printf("after%d %lld\n", i, (long long)flan_outer());
}
printf("counter %lld\n", (long long)flan_counter);
return 0;
}