The refusal for a frame whose body has been redefined underneath it did not fire because four of its five hand-offs were never written. `Emit.fninfo` has been storing `slot_fingerprint` in the last `i32` of every `%fninfo` all along; `flan_dev.c` called that field `spare`, there was no accessor for it, the agent never snapshotted it, the backtrace line never carried it, and `Dev.locals` compared slot counts and nothing else. The handoff note's "every piece is written and the refusal does not happen" was a guess, and the first step it suggested — printing both sides of the comparison — could not have found it, because there was no comparison. So: `spare` becomes `slotsig` and gets `flan_dev_frame_slotsig`; the agent snapshots it beside the slot count and puts it on the backtrace line *before* the location, since the name is the one field that can contain a space and has to stay last; `Dev.backtrace` parses it; `Dev.locals` compares it against `Emit.slot_fingerprint` of the body this session holds and refuses by name when they differ. No change to `emit.ml` — the value was already there. The mechanism itself is right and stays. `slot_fingerprint` hashes every slot's name together with the spelling of its type, so a rename that keeps the count and the types — exactly the case this exists for — changes it. The count check stays in front of it because its message is the more specific one. The fingerprint stays off the wire. A hash is not something an editor can act on, and the refusal says the fact in words: this frame's body was redefined since it was entered, so its names no longer describe its values. `test_dev.ml` gains the inverse and the control. A body that drops a `let` is refused on the count, and `main` — untouched by the redefinition of `look` — must still answer, which is the assertion that would catch a fingerprint that never matched anything and made the verb useless while turning the suite green.
419 lines
18 KiB
C
419 lines
18 KiB
C
/* flan_dev — the part of the host ABI that only a dev build has.
|
|
*
|
|
* A redefinition module reaches the host's functions and globals through
|
|
* symbols the host already exports: a cell for each function, the storage for
|
|
* each global. That covers everything the program was *built* with. It does
|
|
* not cover a name the module introduces — a defn or a defvar typed into the
|
|
* REPL after the process started — because there is no symbol in the host to
|
|
* bind to and ELF cannot grow one.
|
|
*
|
|
* So a name that is new at run time is keyed by string instead. This file is
|
|
* the two lookups that make that work, and deliberately nothing else:
|
|
*
|
|
* flan_dev_cell(name) the cell a new function lives in
|
|
* flan_dev_global(name, size, init) the storage a new global lives in
|
|
* flan_dev_emit(...) where an evaluated expression's rendering
|
|
* goes, piece by piece, to be read back
|
|
*
|
|
* Both are idempotent: the second module to mention a name gets what the first
|
|
* one got. That is the whole point. Two modules that each define their own
|
|
* copy of a new function would each call their own, and redefining it would
|
|
* update one of them.
|
|
*
|
|
* The table never moves. A module holds the address of a cell for as long as
|
|
* it is loaded, so a growable table would leave those addresses pointing into
|
|
* a freed allocation. Fixed capacity and a loud failure instead.
|
|
*
|
|
* Never dlclose a module. A cell holds an address inside that module's text,
|
|
* and unloading it leaves every call site pointing at unmapped memory. There
|
|
* is no unload path here on purpose.
|
|
*/
|
|
|
|
#include <stdint.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
#define FLAN_DEV_MAX 4096
|
|
|
|
typedef struct {
|
|
const char *name; /* strdup'd: the module that passed it may go away */
|
|
void *cell; /* a function's cell, or a global's storage */
|
|
size_t size; /* a global's size; 0 for a function */
|
|
} entry;
|
|
|
|
static entry table[FLAN_DEV_MAX];
|
|
static size_t used;
|
|
|
|
static void die(const char *what, const char *name) {
|
|
fprintf(stderr, "flan_dev: %s: %s\n", what, name);
|
|
fflush(stderr);
|
|
abort();
|
|
}
|
|
|
|
static entry *find(const char *name) {
|
|
for (size_t i = 0; i < used; i++)
|
|
if (strcmp(table[i].name, name) == 0) return &table[i];
|
|
return NULL;
|
|
}
|
|
|
|
static entry *intern(const char *name) {
|
|
if (used == FLAN_DEV_MAX) die("out of dev name slots", name);
|
|
entry *e = &table[used++];
|
|
e->name = strdup(name);
|
|
if (e->name == NULL) die("out of memory", name);
|
|
e->cell = NULL;
|
|
e->size = 0;
|
|
return e;
|
|
}
|
|
|
|
/* The cell a run-time-introduced function is called through. One indirection
|
|
* more than a function the host was built with, whose cell is a symbol the
|
|
* module can name directly — the compiler picks per name, so the common case
|
|
* stays a single load. */
|
|
void **flan_dev_cell(const char *name) {
|
|
entry *e = find(name);
|
|
if (e == NULL) e = intern(name);
|
|
return &e->cell;
|
|
}
|
|
|
|
/* Storage for a run-time-introduced global, allocated once.
|
|
*
|
|
* [init] is its declared initial value, or NULL for all-zero. It is copied on
|
|
* the allocation and ignored on every call after it, which is where "a reload
|
|
* must not reset the program's state" lives: the second module to mention this
|
|
* name is a redefinition, and re-running an initialiser would throw away
|
|
* exactly what the reload exists to preserve. Doing it here rather than by a
|
|
* branch in the caller means the rule cannot be got wrong at one call site.
|
|
*
|
|
* A size mismatch is the layout-drift failure, caught at its first chance: the
|
|
* running process has already laid this memory out, and handing back the old
|
|
* allocation for a differently shaped type means the new body reads fields at
|
|
* the wrong offsets and nothing ever says so. Retyping a var needs a restart. */
|
|
void *flan_dev_global(const char *name, uint64_t size, const void *init) {
|
|
entry *e = find(name);
|
|
if (e == NULL) {
|
|
e = intern(name);
|
|
e->cell = calloc(1, size ? (size_t)size : 1);
|
|
if (e->cell == NULL) die("out of memory", name);
|
|
e->size = (size_t)size;
|
|
if (init != NULL && size > 0) memcpy(e->cell, init, (size_t)size);
|
|
return e->cell;
|
|
}
|
|
if (e->size != (size_t)size) die("size changed; restart to retype", name);
|
|
return e->cell;
|
|
}
|
|
|
|
/* ── The value of an evaluated expression ──────────────────────────── */
|
|
|
|
/* C-x C-e compiles a thunk that renders one expression and emits it here, a
|
|
* piece at a time. It is not written to stdout: stdout belongs to the program,
|
|
* it is in the hot path for anything that prints, and a dev-only feature must
|
|
* not put a branch in it. The daemon reads this back over the agent's socket.
|
|
*
|
|
* Emitting piece by piece rather than returning one string is what makes a
|
|
* composite renderer possible at all — a struct is its fields with punctuation
|
|
* between them, and concatenating that in the generated IR would mean an
|
|
* allocator the language does not have.
|
|
*
|
|
* The output bound lives here and nowhere else. A slice of a million elements
|
|
* renders with a loop the compiler cannot bound, so [emit] truncates and
|
|
* [end] says so with an ellipsis. One place enforcing it means no renderer has
|
|
* to carry a budget.
|
|
*
|
|
* [generation] is what makes the read safe without a handshake. The thunk runs
|
|
* on the game thread at a frame boundary, whenever that happens to be; the
|
|
* daemon waits for the counter to move rather than guessing it has.
|
|
*
|
|
* It is a *seqlock*, and it has to be a real one, because the reader is the
|
|
* agent's listener thread and the writer is the game thread and neither waits
|
|
* for the other. The counter is odd for exactly as long as a value is being
|
|
* written, so a reader that sees an odd count, or a different count either
|
|
* side of its copy, has read a value that was being overwritten underneath it
|
|
* and reads again. A count of 2k means k complete values; the count the
|
|
* outside world is given is that k, so that the daemon's "has it moved" keeps
|
|
* meaning "is there a new value".
|
|
*
|
|
* The copy is what makes it safe, and the API is shaped around that: a reader
|
|
* gets *bytes of its own*, not a pointer into [result]. The pointer version of
|
|
* this was the bug — it read the generation, then a length, then handed back
|
|
* the buffer itself, and the caller sent it down a socket some time later
|
|
* while the game thread was free to be a hundred bytes into the next value.
|
|
* A seqlock cannot validate a read that happens after it returns. */
|
|
|
|
#define RESULT_MAX 4096
|
|
static char result[RESULT_MAX];
|
|
static size_t result_len;
|
|
static int result_full;
|
|
static uint64_t generation;
|
|
|
|
void flan_dev_result_begin(void) {
|
|
/* Odd first, and only then the reset: the counter has to say "in progress"
|
|
* before the buffer stops being the value it used to be.
|
|
*
|
|
* The fence is the half of that a release *store* cannot do. A release store
|
|
* orders what comes before it, not what comes after, so the writes below —
|
|
* and every memcpy in [emit] — would be free to become visible ahead of the
|
|
* odd count, and a reader could see an even count either side of a copy it
|
|
* made while the buffer was being overwritten. Which is the bug this
|
|
* replaced, with more ceremony. So: mark it relaxed, fence, then write.
|
|
*
|
|
* Setting the low bit rather than incrementing, because a [begin] with no
|
|
* [end] is reachable and must not poison the counter for the life of the
|
|
* process. A render thunk that signals is stopped inside this window, and a
|
|
* restart taken from that break transfers past the thunk — [end] never runs.
|
|
* Repairing it here costs nothing in the ordinary case (2k, 2k+1, 2k+2) and
|
|
* means an abandoned write is over as soon as the next evaluation starts,
|
|
* rather than leaving every later read reporting "in progress" forever.
|
|
*
|
|
* What it does not fix, because the buffer cannot: an evaluation that runs
|
|
* while another is stopped mid-render shares this one buffer, so the inner
|
|
* value is the one that survives and the outer thunk, if it is ever resumed,
|
|
* appends to it. That was true before the counter was a seqlock. */
|
|
__atomic_store_n(&generation, generation | 1, __ATOMIC_RELAXED);
|
|
__atomic_thread_fence(__ATOMIC_RELEASE);
|
|
result_len = 0;
|
|
result_full = 0;
|
|
}
|
|
|
|
void flan_dev_emit(const uint8_t *bytes, int64_t len) {
|
|
size_t n = len < 0 ? 0 : (size_t)len;
|
|
if (result_len + n > RESULT_MAX) {
|
|
n = RESULT_MAX - result_len;
|
|
result_full = 1;
|
|
}
|
|
memcpy(result + result_len, bytes, n);
|
|
result_len += n;
|
|
}
|
|
|
|
static void emit_cstr(const char *s) {
|
|
flan_dev_emit((const uint8_t *)s, (int64_t)strlen(s));
|
|
}
|
|
|
|
/* Rendered in C so that u64 is not a lie: the language's own i64->bytes is
|
|
* signed, and anything past 2^63 would come back negative. */
|
|
void flan_dev_emit_u64(uint64_t x) {
|
|
char buf[32];
|
|
snprintf(buf, sizeof buf, "%llu", (unsigned long long)x);
|
|
emit_cstr(buf);
|
|
}
|
|
|
|
void flan_dev_emit_i64(int64_t x) {
|
|
char buf[32];
|
|
snprintf(buf, sizeof buf, "%lld", (long long)x);
|
|
emit_cstr(buf);
|
|
}
|
|
|
|
void flan_dev_emit_f64(double x) {
|
|
char buf[64];
|
|
snprintf(buf, sizeof buf, "%g", x);
|
|
emit_cstr(buf);
|
|
}
|
|
|
|
/* Quoted and escaped, in C, because doing it in the generated IR would be a
|
|
* loop per string and the language has no allocator to build the result in.
|
|
* A string whose content is not escaped does not round-trip and reads as a
|
|
* framing bug rather than as the value it is. */
|
|
void flan_dev_emit_str(const uint8_t *bytes, int64_t len) {
|
|
size_t n = len < 0 ? 0 : (size_t)len;
|
|
emit_cstr("\"");
|
|
for (size_t i = 0; i < n; i++) {
|
|
unsigned char c = bytes[i];
|
|
switch (c) {
|
|
case '"': emit_cstr("\\\""); break;
|
|
case '\\': emit_cstr("\\\\"); break;
|
|
case '\n': emit_cstr("\\n"); break;
|
|
case '\t': emit_cstr("\\t"); break;
|
|
case '\r': emit_cstr("\\r"); break;
|
|
default:
|
|
if (c < 0x20) {
|
|
char buf[8];
|
|
snprintf(buf, sizeof buf, "\\x%02x", c);
|
|
emit_cstr(buf);
|
|
} else {
|
|
flan_dev_emit(&c, 1);
|
|
}
|
|
}
|
|
}
|
|
emit_cstr("\"");
|
|
}
|
|
|
|
void flan_dev_result_end(void) {
|
|
if (result_full) {
|
|
/* Room is made for it rather than assumed: the buffer is full by
|
|
* definition when this fires. */
|
|
const char *ell = "...";
|
|
size_t k = strlen(ell);
|
|
if (result_len > RESULT_MAX - k) result_len = RESULT_MAX - k;
|
|
memcpy(result + result_len, ell, k);
|
|
result_len += k;
|
|
}
|
|
/* Last, and back to even, so a reader that sees the new generation sees the
|
|
* whole value. [| 1] first for the same reason [begin] sets rather than
|
|
* increments: this must land on an even count whatever state an abandoned
|
|
* write left behind. */
|
|
__atomic_store_n(&generation, (generation | 1) + 1, __ATOMIC_RELEASE);
|
|
}
|
|
|
|
/* Copy the current value out, with the counter that says which one it is.
|
|
*
|
|
* Returns 1 having copied a value that was complete for the whole of the copy,
|
|
* 0 if the game thread was in the middle of writing one — in which case [gen]
|
|
* is the last *complete* value's number and [len] is 0, so a caller polling
|
|
* for a new one keeps polling instead of being handed half of it. Spinning
|
|
* here is bounded: the writer is a render thunk between frames, not a loop,
|
|
* and the reader is the listener thread, which has nothing better to do.
|
|
*
|
|
* [cap] is the caller's buffer. A value longer than it is truncated, which is
|
|
* the only failure this can have and is a clamp rather than an overrun; the
|
|
* agent sizes its buffer at RESULT_MAX so it does not arise. */
|
|
/* What a caller's buffer has to be for the copy never to be truncated. The
|
|
* bound is declared in one place and asked for rather than written down twice:
|
|
* the agent's buffer and this one agreeing is the whole of "never truncated",
|
|
* and two literals in two files is how that stops being true. */
|
|
uint64_t flan_dev_result_cap(void) { return RESULT_MAX; }
|
|
|
|
int flan_dev_result_read(char *dst, uint64_t cap, uint64_t *gen,
|
|
uint64_t *len) {
|
|
for (int attempt = 0; attempt < 64; attempt++) {
|
|
uint64_t g1 = __atomic_load_n(&generation, __ATOMIC_ACQUIRE);
|
|
if (g1 & 1) continue; /* a write is in progress */
|
|
size_t n = __atomic_load_n(&result_len, __ATOMIC_RELAXED);
|
|
if (n > RESULT_MAX) n = RESULT_MAX; /* a torn read cannot overrun */
|
|
if ((uint64_t)n > cap) n = (size_t)cap;
|
|
memcpy(dst, result, n);
|
|
/* The copy must be ordered before the second read of the counter, or the
|
|
* check is of a copy the compiler was free to make afterwards. */
|
|
__atomic_thread_fence(__ATOMIC_ACQUIRE);
|
|
if (__atomic_load_n(&generation, __ATOMIC_ACQUIRE) == g1) {
|
|
*gen = g1 / 2;
|
|
*len = (uint64_t)n;
|
|
return 1;
|
|
}
|
|
}
|
|
/* Integer division is the same answer either side of a write in progress:
|
|
* during value k the counter is 2k-1 and k-1 are complete. */
|
|
*gen = __atomic_load_n(&generation, __ATOMIC_ACQUIRE) / 2;
|
|
*len = 0;
|
|
return 0;
|
|
}
|
|
|
|
/* ── The shadow stack ───────────────────────────────────────────────── */
|
|
|
|
/* plan.org's "Dev vs release builds" has had *Frames: shadow stack* in the dev
|
|
* column since the beginning. This is it, and it exists for one reason: the
|
|
* more a break loop can show, the less often a real debugger is needed. A
|
|
* stopped program that cannot say where it is sends someone to lldb.
|
|
*
|
|
* Native unwinding would be the other route and is deliberately not taken:
|
|
* plan.org lowers every non-local exit explicitly rather than through platform
|
|
* unwinding, so there is no .eh_frame walk to borrow, and a frame pointer walk
|
|
* gives addresses that only DWARF can turn back into names. A pushed record
|
|
* carries the name itself, is the same on wasm32 as it is here, and needs no
|
|
* agreement with the optimiser about what a frame looks like.
|
|
*
|
|
* The compiler emits the push and the pop inline (emit.ml, [emit_fn]) rather
|
|
* than calling in here: this is on every call in a dev build, and a call to
|
|
* record a call would double the thing being measured.
|
|
*
|
|
* A plain global, not a _Thread_local. It matches what the handler stack and
|
|
* the restart stack in flan_rt.c already assume — one thread runs Flan; the
|
|
* listener thread runs C and the loader and never enters a Flan body. If the
|
|
* language ever grows threads this becomes thread-local and the compiler's
|
|
* two stores become TLS-relative, which is the only change.
|
|
*
|
|
* Nothing in here walks the chain while the game thread is running. The break
|
|
* loop snapshots it on the stopped thread, exactly as it snapshots the restart
|
|
* list and for exactly the same reason: a chain read by another thread is a
|
|
* chain that can be popped underneath the reader. The accessors below are the
|
|
* snapshot's, and take the frame they were given rather than re-reading the
|
|
* head. */
|
|
|
|
typedef struct {
|
|
const char *name; /* the Flan name, qualified; not NUL-terminated */
|
|
int64_t namelen;
|
|
const char *loc; /* file:line:col, as Loc spells it */
|
|
int64_t loclen;
|
|
int32_t nslots;
|
|
/* What the two ends compare about a frame's slots, from
|
|
[Emit.slot_fingerprint]: a hash over every slot's name and the spelling of
|
|
its type. The frame carries the one belonging to the body it was compiled
|
|
from; the daemon recomputes it from the body it now holds, and a
|
|
difference means the frame is running a superseded body. A count alone
|
|
cannot see a rename, which is the case this exists for. */
|
|
int32_t slotsig;
|
|
} flan_fninfo;
|
|
|
|
typedef struct flan_frame {
|
|
struct flan_frame *prev;
|
|
const flan_fninfo *info;
|
|
/* One entry per slot, each null until the binding that fills that slot has
|
|
* run — so "not bound yet at the point this frame stopped" is a null and
|
|
* needs no liveness analysis to work out. Null altogether for a function
|
|
* with no named slot, and in a release build there is no frame at all.
|
|
* Read through [flan_dev_frame_slot], which is where the bound is checked. */
|
|
void **slots;
|
|
} flan_frame;
|
|
|
|
/* The compiler names this symbol directly. A redefinition module reaches it
|
|
* the same way it reaches any other host global — through the dynamic symbol
|
|
* table, which [--dev] links with -rdynamic. */
|
|
flan_frame *flan_frame_head;
|
|
|
|
/* [i] counts from the innermost. NULL past the end, which is how a caller
|
|
* learns the depth without a second walk. */
|
|
void *flan_dev_frame_at(int32_t i) {
|
|
flan_frame *f = flan_frame_head;
|
|
while (f != NULL && i > 0) { f = f->prev; i--; }
|
|
return f;
|
|
}
|
|
|
|
int32_t flan_dev_frame_count(void) {
|
|
int32_t n = 0;
|
|
for (flan_frame *f = flan_frame_head; f != NULL; f = f->prev) {
|
|
n++;
|
|
if (n > 100000) break; /* a corrupt chain says so rather than hanging */
|
|
}
|
|
return n;
|
|
}
|
|
|
|
const char *flan_dev_frame_name(const void *frame, int64_t *len) {
|
|
const flan_frame *f = frame;
|
|
if (f == NULL || f->info == NULL) { *len = 0; return NULL; }
|
|
*len = f->info->namelen;
|
|
return f->info->name;
|
|
}
|
|
|
|
const char *flan_dev_frame_loc(const void *frame, int64_t *len) {
|
|
const flan_frame *f = frame;
|
|
if (f == NULL || f->info == NULL) { *len = 0; return NULL; }
|
|
*len = f->info->loclen;
|
|
return f->info->loc;
|
|
}
|
|
|
|
int32_t flan_dev_frame_nslots(const void *frame) {
|
|
const flan_frame *f = frame;
|
|
return (f == NULL || f->info == NULL) ? 0 : f->info->nslots;
|
|
}
|
|
|
|
/* The fingerprint of the body this frame was compiled from. Zero for a frame
|
|
* with no description, which is the same "nothing to compare" a zero slot
|
|
* count already means. */
|
|
int32_t flan_dev_frame_slotsig(const void *frame) {
|
|
const flan_frame *f = frame;
|
|
return (f == NULL || f->info == NULL) ? 0 : f->info->slotsig;
|
|
}
|
|
|
|
/* Where slot [i] of this frame lives, or NULL — which means one of three
|
|
* things, all of which are "there is nothing to read here": this build records
|
|
* no slots, the index is not one of them, or the binding that fills it had not
|
|
* run when the frame stopped. A caller renders what it is given and refuses
|
|
* what it is not; nothing here guesses. */
|
|
void *flan_dev_frame_slot(const void *frame, int32_t i) {
|
|
const flan_frame *f = frame;
|
|
if (f == NULL || f->info == NULL || f->slots == NULL) return NULL;
|
|
if (i < 0 || i >= f->info->nslots) return NULL;
|
|
return f->slots[i];
|
|
}
|
|
|