From 6461322aa96e1a760f4e50c7d0f52b95d94ab760 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 13:00:55 +0700 Subject: [PATCH] The length write-back moves back inside the seqlock odd window end_value did the release store and left the caller to narrow its length afterwards, which is a length a reader is entitled to have missed. Split into truncate_value and close_value so flan_dev_watch_end can store its 32-bit length between them. Benign today -- only a full slot is truncated and a full slot length is already WATCH_VAL -- and not a rule anyone would keep. The two externs flan_dev.c borrows from flan_rt.c now get a value probe, once per process on the first render, the way flan_vec_layout ties the three statements of the vec header together: nothing else compares those prototypes and the link matches names, not types. --- runtime/flan_dev.c | 101 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 79 insertions(+), 22 deletions(-) diff --git a/runtime/flan_dev.c b/runtime/flan_dev.c index 00b2cb8..4d844ec 100644 --- a/runtime/flan_dev.c +++ b/runtime/flan_dev.c @@ -246,12 +246,6 @@ static void put_i64(sink out, int64_t x) { * 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]; - 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 @@ -259,11 +253,55 @@ static void put_f64(sink out, double x) { * 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. */ + * [flan_escape_bytes] escapes through: one table, and the two callers differ + * only in how they frame what comes out of it. flan_dyn.c has a copy of the + * table rather than a call to it, deliberately and for a reason argued there + * and in docs/SPIKE-DUPLICITY.md §9. */ extern int flan_escape_char(unsigned char c, char *out); +/* The two [extern]s above are this file's hand copies of prototypes flan_rt.c + * owns, and nothing in the build compares the two: each translation unit is + * compiled on its own with no include path (see [Build.compile_c]), and the + * link that joins them matches names and not types. A parameter added on one + * side, a [size_t] cap that becomes an [int], a return that stops being a + * length — all of those build clean and then go wrong here, inside a render, + * with nothing pointing at the cause. + * + * So they are checked the way the tree checks its other cross-file agreement + * it cannot #include its way out of — by value, at run time, loudly. That is + * [flan_vec_layout] and test/dyn_ops.c's "layout" mode for the vec header; + * this is the same idea one function wide. Two calls with known answers, once + * per process, on the first value this file renders: if the callee is not the + * function these declarations describe, the answers do not come back right + * and the process stops here rather than emitting a wrong wire format. + * + * Not a constructor, because flan_dev.c is linked into every build and not + * only a dev one, and nothing should run in a release image that its program + * did not ask for. On the first render instead: the branch is one predictable + * test per value, and it runs in the dev paths only, which are the only paths + * that reach these two functions from here. */ +static void check_shared(void) { + static int checked; + char buf[64]; + char e[4]; + if (checked) return; + checked = 1; + if (flan_f64_format(1.5, buf, sizeof buf) != 3 || strcmp(buf, "1.5") != 0) + die("flan_f64_format is not the function this file declares", "1.5"); + if (flan_escape_char('\n', e) != 2 || e[0] != '\\' || e[1] != 'n') + die("flan_escape_char is not the function this file declares", "newline"); +} + +static void put_f64(sink out, double x) { + char buf[64]; + check_shared(); + flan_f64_format(x, buf, sizeof buf); + put(out, buf); +} + static void put_str(sink out, const uint8_t *bytes, int64_t len) { size_t n = len < 0 ? 0 : (size_t)len; + check_shared(); put(out, "\""); for (size_t i = 0; i < n; i++) { char e[4]; @@ -273,19 +311,20 @@ static void put_str(sink out, const uint8_t *bytes, int64_t len) { 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. +/* Closing a value, in the two halves the seqlock needs it in. 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 first half is the ellipsis a full buffer earns. Room for it 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) { + * They are two functions and not one because everything a reader will look at + * has to be stored before the generation is, and a caller may have a length + * of its own to write back — the watch slot keeps its length in 32 bits and + * so cannot pass its own field here. Splitting lets that write-back land + * inside the odd window where it belongs, rather than after the release + * store, where it would be a tear the day a length actually changed. */ +static void truncate_value(char *buf, size_t *len, size_t cap, int full) { if (full) { const char *ell = "..."; size_t k = strlen(ell); @@ -293,6 +332,14 @@ static void end_value(char *buf, size_t *len, size_t cap, int full, memcpy(buf + *len, ell, k); *len += k; } +} + +/* The second half: the counter 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. Nothing a reader reads may + * be written after this returns. */ +static void close_value(uint64_t *gen) { __atomic_store_n(gen, (*gen | 1) + 1, __ATOMIC_RELEASE); } @@ -305,7 +352,8 @@ void flan_dev_emit_str(const uint8_t *bytes, int64_t len) { } void flan_dev_result_end(void) { - end_value(result, &result_len, RESULT_MAX, result_full, &generation); + truncate_value(result, &result_len, RESULT_MAX, result_full); + close_value(&generation); } /* Copy the current value out, with the counter that says which one it is. @@ -563,12 +611,21 @@ void flan_dev_watch_end(void) { watch_slot *s = watch_cur; watch_cur = NULL; if (s == NULL) return; - /* [len] widened and narrowed around the shared close, which counts in + /* [len] widened and narrowed around the shared truncate, 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. */ + * and [WATCH_VAL] is 192, so neither conversion can lose anything. + * + * The narrowing write-back goes before [close_value] and not after it: a + * reader takes [len] and [val] together under the generation, so a length + * stored after the release store is a length the reader is entitled to + * have missed. It happens to write back the same bit pattern today — only + * a [full] slot is truncated, and a full slot's length is already + * [WATCH_VAL] — but that is a fact about the current cap arithmetic and + * not a rule anyone reading this would keep. */ size_t len = s->len; - end_value(s->val, &len, WATCH_VAL, s->full, &s->gen); + truncate_value(s->val, &len, WATCH_VAL, s->full); s->len = (uint32_t)len; + close_value(&s->gen); } /* ── Watching one scalar, with no compiler change ───────────────────── */