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.
This commit is contained in:
Joseph Ferano 2026-09-20 13:00:55 +07:00
parent 2daf7e9164
commit 6461322aa9

View File

@ -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 ───────────────────── */