spec-memory.md's case 2, capture by value into a stack environment, and
the calling convention the author's rulings asked for.
(Fn [i32] i32) captures; {code, env}; the common case
(CFn [i32] i32) the bare address; one word; cannot capture
A local of the enclosing function that an fn names is copied into a
struct the checker synthesises, held in a slot of that function's frame,
and the value carries its address; the lifted body reads the copies back
into named slots of its own, once, at entry. So the name in the body
means what the local held at the instant the value was made --
fn-capture.flan changes the local through a pointer after the value
exists and the fn still answers with the old one.
Two types rather than a uniform environment parameter: "while it's dyn
first, static side should never have to pay the price for the existence
of the dyn side... if you fully opt out, for instance, using --no-gc
flag, then we should be operating under Odin/C semantics and never paying
any runtime costs." The environment is declared by exactly the bodies an
(Fn ...) value can reach -- a lifted literal in an Fn position, every
handler clause, and the widening thunks -- and by nothing else. An
ordinary defn emits the signature it always did; calc-me and fourteen
corpus programs were diffed to say so.
CFn, because the C carries information: a value with no environment is
the only kind that could ever cross to C, and under the --no-conditions
direction FIX.org records it becomes literally a C function pointer. It
is not that today -- a declare cannot take a function type at all -- and
crossable's refusal says so where a reader would otherwise be misled.
Nobody needs CFn: Fn accepts everything, and the commonest reason to
reach for the narrow one is that a *named* function handed to an Fn pays
a hop through the widening thunk where a CFn is a direct call.
That thunk is one small function per distinct signature widened, which
reads the bare address back out of the environment and calls it. The
cheaper trick -- the environment last, ignored by a body that never
declared it -- is legal under SysV and is a trap under wasm32's
call_indirect, which compares the signature at the call. Every indirect
call is exactly typed now.
A handler clause captures the same way and is sound with nothing left
over: its frame is popped by the body that pushed it. What is refused
there is a *store* into a captured name -- it is a copy, and writing to
it would leave the local as it was.
And the other half, which is what "non-escaping" means: a value carrying
an environment may be called, passed down and let-bound, and may not be
returned, stored, pointed at or pushed into a container. A parameter of
type Fn is treated as one, which answers "passed to something that stores
it" with no interprocedural analysis -- the store is refused inside the
callee. Everything of type CFn is clean for free, which is the second
thing having two types buys. Every refusal names case 3, the environment
the collector owns.
Two pre-existing bugs fell out on the way. A lifted fn asked for Fnval,
so `flan reload' on any function containing an fn literal died at llc
with an undefined cell; it takes Flanfn now, which is the choice a
handler clause always made. And a redefinition module now carries its
own hidden copy of every thunk it names, which is the same bug shape
caught before it shipped.
135 lines
6.1 KiB
C
135 lines
6.1 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 <string.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.
|
|
*
|
|
* No environment: [outer] is called by name and not through a function value,
|
|
* so it declares none. That is the point of there being two function types. */
|
|
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;
|
|
}
|
|
|
|
/* The same sentence [vendor/agent/flan_agent.c] says, for the same failure.
|
|
* Duplicated rather than shared: the agent is vendored to be dropped into a
|
|
* user's game and carries no header of its own, and this host is a test
|
|
* fixture that links against neither it nor the runtime's dev half.
|
|
*
|
|
* A dev build defines a marker naming the backend that built it, and a
|
|
* redefinition module holds a pointer to the marker it was itself built for.
|
|
* The two backends agree on scalars and disagree on every aggregate, so a
|
|
* crossed pair would run until the first call into a redefined function that
|
|
* takes or returns a struct and then die with SIGSEGV. The marker turns that
|
|
* into a relocation the loader cannot resolve. What it says then is
|
|
* "undefined symbol: flan.abi.x86", so the marker's name is matched — not the
|
|
* loader's phrasing, which is libc's to change — and the reason is stated. */
|
|
static const char *abi_mismatch(const char *err) {
|
|
if (err == NULL) return NULL;
|
|
if (strstr(err, "flan.abi.x86") != NULL)
|
|
return "the module and this host were built by different backends: the "
|
|
"module came from the x86 dev backend and needs flan.abi.x86, "
|
|
"which this host does not define. The two backends pass every "
|
|
"struct differently. Rebuild the host with --x86.";
|
|
if (strstr(err, "flan.abi.llvm") != NULL)
|
|
return "the module and this host were built by different backends: the "
|
|
"module came from LLVM and needs flan.abi.llvm, which an --x86 "
|
|
"host does not define. The two backends pass every struct "
|
|
"differently. Rebuild the host without --x86.";
|
|
return NULL;
|
|
}
|
|
|
|
static int install(const char *path) {
|
|
double t0 = now_ms();
|
|
void *h = dlopen(path, RTLD_NOW | RTLD_LOCAL);
|
|
if (h == NULL) {
|
|
/* [dlerror] is one-shot, so the pointer is taken once and used twice. */
|
|
const char *err = dlerror();
|
|
const char *why = abi_mismatch(err);
|
|
if (why != NULL) fprintf(stderr, "flan: %s\n", why);
|
|
else fprintf(stderr, "dlopen %s: %s\n", path, err);
|
|
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;
|
|
}
|