The runtime's twin emitters share their bodies, and six dead symbols go

This commit is contained in:
Joseph Ferano 2026-09-20 12:06:48 +07:00
commit 26439dc22e
9 changed files with 173 additions and 195 deletions

View File

@ -653,8 +653,6 @@ let stamp_of prog =
| st -> Printf.sprintf "%s:%d:%f" path st.Unix.st_size st.Unix.st_mtime
| exception Unix.Unix_error _ -> path
let clang_stamp = lazy (stamp_of clang)
(* Compile one C translation unit to an object file, reusing a cached one when
the source text, the compiler and the flags are all unchanged. The key has
to carry [opt] and [target]: the acceptance table builds the same programs

View File

@ -3395,17 +3395,17 @@ let ignore_sigpipe () =
and this is a *daemon* whose client died.
What makes this answerable without a new protocol is that the connection is
per session and not per request. [emacs/flan-dev.el] opens one
[make-network-process] in [flan-dev--open], keeps it in
[flan-dev--connection], and reopens only when the process is dead; every
site that tears it down deliberately sends [close] first. [serve] mirrors
per session and not per request. [emacs/flan.el] opens one
[make-network-process] in [flan--open], keeps it in [flan--connection], and
reopens only when the process is dead; every site that tears it down
deliberately sends [close] first. [serve] mirrors
that shape it loops on the one fd until EOF so while an editor is
attached this loop is not even cycling. An editor left open and idle
overnight is therefore *attached* overnight, and the grace below can never
accumulate under it. That is structural, not a number chosen to outlast
human patience, and it is why no PID handshake and no heartbeat op is
needed: holding the socket open is the heartbeat, and a client that only
ever polls or one that has [flan-dev-poll-interval] set to nil and polls
ever polls or one that has [flan-poll-interval] set to nil and polls
never is indistinguishable from any other attached client.
So the signal is client *absence*, and the only absence this is allowed to
@ -3456,7 +3456,7 @@ let client_grace () =
window would kill the session, which is the bug this whole change exists to
remove, reintroduced one line further out. The grace does not reopen that
hole: it reads the client, not the program, and all of an Emacs's windows
share the one [flan-dev--connection], so closing one of them changes
share the one [flan--connection], so closing one of them changes
nothing this loop can see. *)
let accept_loop ?grace t ls =
let grace = match grace with Some g -> g | None -> client_grace () in

View File

@ -57,15 +57,11 @@ let tag_of_int = function
dynload_stubs.c, one field at a time. Everything allocated here is owned by
[Dynload] and released together after the call. *)
let rec marshal (f : Form.t) : Dynload.addr =
let p = Dynload.take form_size in
write p f;
p
(* Into an existing 24 bytes, which is what an argument array needs: the macro
takes a [Form] slice, and a slice is contiguous elements and not an array of
pointers. *)
and write p (f : Form.t) =
pointers so this writes *into* memory the caller took, and every caller
here takes it as part of an array. *)
let rec write p (f : Form.t) =
let tag t = Dynload.poke_i32 p 0 (tag_int t) in
let str t s =
tag t;

View File

@ -656,13 +656,6 @@ let elem_ty loc (t : Types.t) =
| Types.String -> Types.Int Types.U8
| t -> at loc "indexing %s is not in the JS dialect" (Types.to_string t)
let is_bytes (t : Types.t) =
match t with
| Types.String -> true
| Types.Slice (Types.Int Types.U8) | Types.Array (_, Types.Int Types.U8) ->
true
| _ -> false
let rec value f (e : Tast.expr) : string =
refuse_ty e.Tast.loc e.Tast.ty;
match e.Tast.e with

View File

@ -203,80 +203,109 @@ void flan_dev_emit(const uint8_t *bytes, int64_t len) {
result_len += n;
}
static void emit_cstr(const char *s) {
flan_dev_emit((const uint8_t *)s, (int64_t)strlen(s));
/* ── Rendering a scalar, into whichever buffer is being written ──────── */
/* This file has two buffers a rendered value can go into: the result buffer
* above, which an evaluated expression's rendering streams into for the REPL
* to read back, and the current watch slot further down. What they hold is
* the same four renderings an i64, a u64, an f64, a quoted string and the
* only thing that differed was which of [flan_dev_emit] and
* [flan_dev_watch_emit] the bytes went to. So that is the parameter, and the
* eight exported entry points are eight one-line calls into these four.
*
* The exports stay eight. The compiler emits the result four by name see
* [Session.externs] and the watch four are exported C, which a program can
* reach through declare-c and which the [(watch ...)] arm described further
* down would be pointed at. They are ABI, and ABI does not collapse just
* because the bodies did. */
typedef void (*sink)(const uint8_t *, int64_t);
static void put(sink out, const char *s) {
out((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) {
static void put_u64(sink out, uint64_t x) {
char buf[32];
snprintf(buf, sizeof buf, "%llu", (unsigned long long)x);
emit_cstr(buf);
put(out, buf);
}
void flan_dev_emit_i64(int64_t x) {
static void put_i64(sink out, int64_t x) {
char buf[32];
snprintf(buf, sizeof buf, "%lld", (long long)x);
emit_cstr(buf);
put(out, buf);
}
/* Unsigned NaN, for flan_f64_to_bytes's reason and one of its own: the REPL
* and println must not disagree about what a value looks like, which is the
* rule the escape table below is already held to. A NaN's sign bit is decided
* by whether the value was folded or computed, so showing it makes the printed
* form depend on the backend and the optimisation level rather than on the
* number. */
void flan_dev_emit_f64(double x) {
/* Unsigned NaN, and the rule is flan_rt.c's rather than a second statement of
* it: [flan_f64_format] is what println renders through, and the REPL, a watch
* row and println must not disagree about what one value looks like. A NaN's
* sign bit is decided by whether the value was folded or computed, so showing
* it would make the printed form depend on the backend and the optimisation
* level rather than on the number. */
extern int flan_f64_format(double x, char *buf, size_t cap);
static void put_f64(sink out, double x) {
char buf[64];
if (x != x) snprintf(buf, sizeof buf, "nan");
else snprintf(buf, sizeof buf, "%g", x);
emit_cstr(buf);
flan_f64_format(x, buf, sizeof buf);
put(out, 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) {
* framing bug rather than as the value it is and a newline in a watched
* string would become a second row of the table on the wire.
*
* The table is flan_rt.c's [flan_escape_char], which is also what println's
* [flan_escape_bytes] escapes through. One table, three framings of it. */
extern int flan_escape_char(unsigned char c, char *out);
static void put_str(sink out, const uint8_t *bytes, int64_t len) {
size_t n = len < 0 ? 0 : (size_t)len;
emit_cstr("\"");
put(out, "\"");
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);
}
}
char e[4];
int k = flan_escape_char(bytes[i], e);
out((const uint8_t *)e, (int64_t)k);
}
emit_cstr("\"");
put(out, "\"");
}
/* Closing a value: the ellipsis a full buffer earns, and the counter back to
* even. Shared by [flan_dev_result_end] and [flan_dev_watch_end], which is
* the same seqlock twice over two different buffers.
*
* Room for the ellipsis is made rather than assumed: the buffer is full by
* definition when [full] is set.
*
* The counter goes 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. */
static void end_value(char *buf, size_t *len, size_t cap, int full,
uint64_t *gen) {
if (full) {
const char *ell = "...";
size_t k = strlen(ell);
if (*len > cap - k) *len = cap - k;
memcpy(buf + *len, ell, k);
*len += k;
}
__atomic_store_n(gen, (*gen | 1) + 1, __ATOMIC_RELEASE);
}
void flan_dev_emit_u64(uint64_t x) { put_u64(flan_dev_emit, x); }
void flan_dev_emit_i64(int64_t x) { put_i64(flan_dev_emit, x); }
void flan_dev_emit_f64(double x) { put_f64(flan_dev_emit, x); }
void flan_dev_emit_str(const uint8_t *bytes, int64_t len) {
put_str(flan_dev_emit, bytes, len);
}
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);
end_value(result, &result_len, RESULT_MAX, result_full, &generation);
}
/* Copy the current value out, with the counter that says which one it is.
@ -444,10 +473,6 @@ void flan_dev_watch_enable(int on) {
__atomic_store_n(&watch_on, on ? 1 : 0, __ATOMIC_RELAXED);
}
int flan_dev_watch_enabled(void) {
return __atomic_load_n(&watch_on, __ATOMIC_RELAXED);
}
/* The slot a name owns, or NULL if the table is full.
*
* Linear, because the table is 64 long and a hash would need a policy for
@ -523,76 +548,27 @@ void flan_dev_watch_emit(const uint8_t *bytes, int64_t len) {
s->len += (uint32_t)n;
}
static void watch_cstr(const char *str) {
flan_dev_watch_emit((const uint8_t *)str, (int64_t)strlen(str));
}
/* The four renderings, aimed at the watch slot instead of the result buffer.
* The bodies are [put_i64] and friends above see the note there for why the
* entry points stay four while the rendering is one. */
void flan_dev_watch_emit_i64(int64_t x) { put_i64(flan_dev_watch_emit, x); }
void flan_dev_watch_emit_u64(uint64_t x) { put_u64(flan_dev_watch_emit, x); }
void flan_dev_watch_emit_f64(double x) { put_f64(flan_dev_watch_emit, x); }
void flan_dev_watch_emit_i64(int64_t x) {
char buf[32];
snprintf(buf, sizeof buf, "%lld", (long long)x);
watch_cstr(buf);
}
void flan_dev_watch_emit_u64(uint64_t x) {
char buf[32];
snprintf(buf, sizeof buf, "%llu", (unsigned long long)x);
watch_cstr(buf);
}
/* Unsigned NaN, the same rule [flan_dev_emit_f64] states: a watch row and a
* REPL answer for one value must read the same. */
void flan_dev_watch_emit_f64(double x) {
char buf[64];
if (x != x) snprintf(buf, sizeof buf, "nan");
else snprintf(buf, sizeof buf, "%g", x);
watch_cstr(buf);
}
/* Quoted and escaped, for [flan_dev_emit_str]'s reason and one more: a string
* whose content is not escaped does not round-trip, and a newline in one would
* become a second row of the table on the wire rather than part of a value. */
void flan_dev_watch_emit_str(const uint8_t *bytes, int64_t len) {
size_t n = len < 0 ? 0 : (size_t)len;
watch_cstr("\"");
for (size_t i = 0; i < n; i++) {
unsigned char c = bytes[i];
switch (c) {
case '"': watch_cstr("\\\""); break;
case '\\': watch_cstr("\\\\"); break;
case '\n': watch_cstr("\\n"); break;
case '\t': watch_cstr("\\t"); break;
case '\r': watch_cstr("\\r"); break;
default:
if (c < 0x20) {
char buf[8];
snprintf(buf, sizeof buf, "\\x%02x", c);
watch_cstr(buf);
} else {
flan_dev_watch_emit(&c, 1);
}
}
}
watch_cstr("\"");
put_str(flan_dev_watch_emit, bytes, len);
}
void flan_dev_watch_end(void) {
watch_slot *s = watch_cur;
watch_cur = NULL;
if (s == NULL) return;
if (s->full) {
/* Room is made for it rather than assumed: the value is full by
* definition when this fires. */
const char *ell = "...";
size_t k = strlen(ell);
if (s->len > WATCH_VAL - k) s->len = (uint32_t)(WATCH_VAL - k);
memcpy(s->val + s->len, ell, k);
s->len += (uint32_t)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 an abandoned write
* left behind. */
__atomic_store_n(&s->gen, (s->gen | 1) + 1, __ATOMIC_RELEASE);
/* [len] widened and narrowed around the shared close, which counts in
* size_t because the result buffer does; a slot's own length is 32 bits
* and [WATCH_VAL] is 192, so neither conversion can lose anything. */
size_t len = s->len;
end_value(s->val, &len, WATCH_VAL, s->full, &s->gen);
s->len = (uint32_t)len;
}
/* ── Watching one scalar, with no compiler change ───────────────────── */
@ -630,13 +606,6 @@ int32_t flan_dev_watch_i64(const char *name, int64_t x) {
return 1;
}
int32_t flan_dev_watch_u64(const char *name, uint64_t x) {
if (!flan_dev_watch_begin(name)) return 0;
flan_dev_watch_emit_u64(x);
flan_dev_watch_end();
return 1;
}
int32_t flan_dev_watch_f64(const char *name, double x) {
if (!flan_dev_watch_begin(name)) return 0;
flan_dev_watch_emit_f64(x);

View File

@ -457,11 +457,18 @@ static void emit(const char *s) {
static void emit_n(const uint8_t *p, int64_t n) { flan_write_stdout(p, n); }
/* A text inside a structure, quoted and escaped. The same table as
* flan_rt.c's [flan_escape_bytes] and flan_dev.c's [flan_dev_emit_str], and
* for the same reason those two are the same as each other: three printers
* that disagree about what a string looks like is three wire formats. If that
* table changes, change this one. Streamed rather than built, so there is no
* buffer to overrun and no length to cap. */
* flan_rt.c's [flan_escape_char], which is where the typed side's printers
* [flan_escape_bytes] and flan_dev.c's emitters now share their one copy of
* it: printers that disagree about what a string looks like are that many
* wire formats.
*
* This copy is deliberate, and the argument for it is docs/SPIKE-DUPLICITY.md
* §9's: the dyn printer lives inside the runtime that owns the storage it
* walks, which is why it prints a dyn vec structurally where the typed
* printer answers <vec>. The whole printer is this side's; the table it
* shares with the other side is the part that must not drift. If that table
* changes, change this one. Streamed rather than built, so there is no buffer
* to overrun and no length to cap. */
static void emit_escaped(const uint8_t *p, int64_t n) {
int64_t i;
emit("\"");

View File

@ -3,12 +3,12 @@
* This header is not compiled into a program. The build embeds the runtime's
* .c files as strings and hands each one to clang on its own, with no include
* path (see [Build.compile_c]), so runtime/flan_dyn.c declares everything it
* defines and this file declares it a second time. That is the same standing
* arrangement flan_escape_bytes and flan_dev_emit_str already live under
* "if either table changes, change both" and it is made mechanical rather
* than hopeful: test/dyn_ops.c includes this header and names every function
* below, so a signature that drifts from the implementation is a link error in
* `dune test` rather than a surprise at someone else's call site.
* defines and this file declares it a second time. That second copy is not
* held honest by hand the way a repeated escape table would be: it is made
* mechanical, because test/dyn_ops.c includes this header and names every
* function below, so a signature that drifts from the implementation is a
* link error in `dune test` rather than a surprise at someone else's call
* site.
*
* Who reads it: the compiler lane, which emits calls to these names, and the
* C tests. The whole of the boundary is here. What is behind it the value

View File

@ -134,10 +134,9 @@ void *flan_restart_frame(int32_t i) {
return NULL;
}
/* Aim the transfer channel at a frame obtained earlier. The same store
* [flan_break_resume] makes and the same one an invoke-restart makes this
* only spells it without a lookup, for a caller that did its looking up when
* the stack was worth reading. */
/* Aim the transfer channel at a frame obtained earlier. The same store an
* invoke-restart makes this only spells it without a lookup, for a caller
* that did its looking up when the stack was worth reading. */
void flan_restart_take(void *frame, void *xfer) { *(void **)xfer = frame; }
/* [T] and string are both ptr+len — see Emit.ll. */
@ -340,10 +339,19 @@ int64_t flan_bytes_to_i64(const uint8_t *p, int64_t n) {
*
* The test is x != x rather than isnan, which keeps math.h out of this file
* and is the same comparison the prelude uses. An infinity still prints signed:
* there the sign is the value. */
* there the sign is the value.
*
* Spelled as a format into a caller's buffer rather than inline here, because
* the rule has a second reader: flan_dev.c's REPL emitter and its watch table
* render a f64 for an editor to show, and a build where the inspector said
* "-nan" and println said "nan" would be the same disagreement one layer out.
* That file calls this; there is one copy of the rule. */
int flan_f64_format(double x, char *buf, size_t cap) {
return (x != x) ? snprintf(buf, cap, "nan") : snprintf(buf, cap, "%g", x);
}
void flan_f64_to_bytes(double x, uint8_t *buf, flan_slice *out) {
int n = (x != x) ? snprintf((char *)buf, FLAN_NUM_BYTES, "nan")
: snprintf((char *)buf, FLAN_NUM_BYTES, "%g", x);
int n = flan_f64_format(x, (char *)buf, FLAN_NUM_BYTES);
out->ptr = buf;
out->len = fit(n);
}
@ -364,15 +372,45 @@ void flan_u64_to_bytes(uint64_t x, uint8_t *buf, flan_slice *out) {
out->len = fit(n);
}
/* The escape table itself: what one byte reads as inside a quoted string,
* written into [out] and returning how many bytes that took. Never more than
* four, which is what every caller's headroom is sized from.
*
* A table rather than a printer, because the callers frame the same mapping
* differently and that framing is the part that is genuinely theirs: the one
* below builds a capped slice to hand back, and flan_dev.c's streams into a
* fixed buffer it does not own the end of. What they must not differ about is
* this switch the REPL and println disagreeing about what a struct looks
* like is two wire formats so this switch exists once and they share it.
*
* flan_dyn.c keeps its own copy of the table on purpose; see the comment
* there and docs/SPIKE-DUPLICITY.md §9. */
int flan_escape_char(unsigned char c, char *out) {
switch (c) {
case '"': out[0] = '\\'; out[1] = '"'; return 2;
case '\\': out[0] = '\\'; out[1] = '\\'; return 2;
case '\n': out[0] = '\\'; out[1] = 'n'; return 2;
case '\t': out[0] = '\\'; out[1] = 't'; return 2;
case '\r': out[0] = '\\'; out[1] = 'r'; return 2;
default:
if (c < 0x20) {
out[0] = '\\';
out[1] = 'x';
out[2] = "0123456789abcdef"[c >> 4];
out[3] = "0123456789abcdef"[c & 0xf];
return 4;
}
out[0] = (char)c;
return 1;
}
}
/* A string *inside* a printed structure, quoted and escaped, so that the run
* of bytes can be told from the punctuation around it (S {:name "a b"}) has
* two fields if the quotes are missing and one if they are there.
*
* This is the same escape table as flan_dev_emit_str in flan_dev.c, and
* deliberately so: the REPL and println must not disagree about what a struct
* looks like. It cannot be the *same function* because the dev one streams
* into the result buffer and this one has to hand back a slice; if either
* table changes, change both.
* The table is [flan_escape_char] above, which is also what flan_dev.c's
* emitters use; this function is the framing and nothing else.
*
* Its own buffer, not `scratch`: escaping is the one conversion whose output
* is not a bounded handful of characters. Over-long input is truncated with an
@ -394,20 +432,10 @@ void flan_escape_bytes(const uint8_t *p, int64_t n, flan_slice *out) {
escaped[w++] = '"';
for (size_t i = 0; i < len; i++) {
if (w + 5 + 4 >= ESCAPE_MAX) { cut = 1; break; }
unsigned char c = p[i];
switch (c) {
case '"': escaped[w++] = '\\'; escaped[w++] = '"'; break;
case '\\': escaped[w++] = '\\'; escaped[w++] = '\\'; break;
case '\n': escaped[w++] = '\\'; escaped[w++] = 'n'; break;
case '\t': escaped[w++] = '\\'; escaped[w++] = 't'; break;
case '\r': escaped[w++] = '\\'; escaped[w++] = 'r'; break;
default:
if (c < 0x20) {
w += (size_t)snprintf(escaped + w, 5, "\\x%02x", c);
} else {
escaped[w++] = (char)c;
}
}
char e[4];
int k = flan_escape_char(p[i], e);
memcpy(escaped + w, e, (size_t)k);
w += (size_t)k;
}
if (cut) { escaped[w++] = '.'; escaped[w++] = '.'; escaped[w++] = '.'; }
escaped[w++] = '"';
@ -603,16 +631,6 @@ static uint32_t flan_name_id(const uint8_t *s, int64_t n) {
return h;
}
/* What the break loop calls to resume: look a restart up by the name someone
* typed and aim the channel at it. 0 if no frame offers it, and then the loop
* says so rather than resuming into nothing. */
int32_t flan_break_resume(const uint8_t *name, int64_t namelen, void *xfer) {
void *r = flan_find_restart(flan_name_id(name, namelen));
if (r == NULL) return 0;
*(void **)xfer = r;
return 1;
}
void flan_error(uint32_t type_id, void *condition, void *xfer,
const uint8_t *name, int64_t namelen) {
flan_signal(type_id, condition, xfer);

View File

@ -85,7 +85,6 @@ uint64_t flan_dev_result_cap(void);
* [enable] the table is written only while somebody is reading it, so
* opening and closing a watch buffer is a message that arrives here. */
void flan_dev_watch_enable(int on);
int flan_dev_watch_enabled(void);
/* And [reset], which opens a new accumulation window for the numeric slots.
* It moves one counter and touches no slot, so the game thread stays the only
* writer of the table. */
@ -310,8 +309,6 @@ extern void (*flan_break_hook)(const uint8_t *name, int64_t namelen,
* flan_rt.c, which carries the argument for why they are two hooks and not
* one. This end of it is [trap_stop] below. */
extern void (*flan_trap_hook)(const uint8_t *name, int64_t namelen);
extern int32_t flan_break_resume(const uint8_t *name, int64_t namelen,
void *xfer);
extern int32_t flan_restart_count(void);
/* The shadow stack (runtime/flan_dev.c). The compiler pushes a frame per Flan
* call in a dev build; these read one, and only ever on the thread that owns