flan/test/dyn_ops.c
Joseph Ferano 3f7c42257f Review found the hazard relocation-safety missed: a view can outlive its frame
Relocation was proved sound and stayed sound — a Vec view holding the
header's own address survives a push that grows and moves it, because
there is no snapshot to invalidate. That was never the whole of the hazard.
Refusing every container into dyn outright, before this lane, meant a
dangling view was unreachable; the moment box stopped refusing, three
routes opened at once — a view returned from the function whose frame the
Vec lived in, one stashed in a dyn global and read after that frame is
gone, and one left behind when a condition transfer unwinds it. All three
are stack-use-after-return, reachable for the first time.

The rule: a typed container crosses into dyn as a view only when its own
storage is permanent — a global's. On the dynamic side Flan follows Clojure
and Common Lisp, where holding a value can never hand you garbage; treating
a view as a bare pointer and calling the lifetime the programmer's problem
is the Odin answer, and it is the wrong trade on this side of the language.
check.ml's permanent_root walks the checked expression back to its root: a
global is permanent, a field or an array element of one is permanent at the
same fixed offset, and a slice cut directly from one at the call site
inherits it — the trace is what a slice carries, and it is lost the moment
the slice is bound to a name first, so that case is refused too rather than
guessed at. Everything else answers false: a local, a parameter, a
temporary, and anything reached through a (Ptr T), because a heap-durable
pointer and a frame's own are the same type and the checker cannot tell
them apart — admitting one admits the other, which is the whole hazard this
closes. An arena-held header turns out not to be a separate case at all: an
arena changes where a Vec's elements live, never where its own header — the
binding — lives, so it is already covered by the storage-class check above.
Both directions of the F1 escape were reproduced before the fix (a genuine
ASan stack-use-after-return, reproduced by building the pre-fix tree) and
confirmed refused at check time after it, for all three routes.

Three more findings, all in the runtime rather than the boundary:

view_vec_check, on finding a stale container, rendered the very view it had
just declared unsafe to read — which called back into the same check,
unconditionally, an infinite recursion rather than the intended trap. Fixed
by never rendering the container in the stale message at all; the sentence
names the two epochs and nothing else, which is everything a reader needs
and the one thing that was safe to read.

dyn_equal's VEC arm read x->len and x->u.v.items regardless of kind, which
for a view answers 0 and the union's other member reinterpreted as dyn
words: two views with different contents compared equal, a view and an
equal heap vec compared unequal, and a map keyed by any view collided with
every other view, silently. vecish_len and vecish_at read either shape
correctly and the arm now goes through them. obj_words gets the same
explicit OBJ_VIEW case on the same reasoning, unreachable today only
because mark_push's own gate already excludes the kind — this is the belt
next to that brace.

The three restatements of flan_vec's layout — flan_rt.c's real struct,
flan_dyn.c's mirror, and dyn_ops.c's hand-built one — had a comment
claiming a reorder would not compile or link, which was never true of a
void*-typed forward declaration. flan_vec_layout and
flan_dyn_vec_hdr_layout each report their struct's size and field offsets;
dyn_ops.c's new "layout" mode compares both against offsetof on its own
hand_vec, so a disagreement is a FAIL line in dune test instead of a
silent corruption at whichever view reads through the wrong offset next.

Also: the survey program's comment excusing a by-value parameter's view as
"value semantics, not a hole" was wrong on its own terms — a write through
such a view does reach the caller's storage, only growth diverges — but the
question is moot now: every container the program views is a global, and
the file was rewritten around that rather than patched. And an i32 element
does not cross into a view either, but the refusal used to say why in words
that were true only of a string element; it now says what i32 actually is
and what the restriction is actually for.

Rebased onto dev-loop's item-4 landing (221df5a).
2026-09-20 10:10:52 +07:00

950 lines
40 KiB
C

/* dyn_ops.c — runtime/flan_dyn.c, driven directly.
*
* A C main, for the reason dev_limits.c and reload_host.c are C mains: the
* dynamic-value runtime's surface is a C ABI and has no Flan spelling yet, so
* there is no program that could reach it. The .flan this links against
* therefore has no [main] of its own; see programs/dyn-host.flan.
*
* This file includes runtime/flan_dyn.h and calls every function the header
* declares. That is not tidiness — the build compiles flan_dyn.c on its own
* with no include path, so the implementation declares its own prototypes and
* the header is a second copy of them. Including it *here* is what makes a
* divergence between the two a compile or link error in `dune test` rather
* than a surprise in the compiler lane's emitted code.
*
* One mode per run, chosen by argv, because most of the modes end in a trap
* and a trap ends the process. The happy paths share one run; each refusal
* gets its own.
*/
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
/* Resolved because [Build] drops runtime/flan_dyn.h into the directory it
* compiles each translation unit in, beside the .c it writes there. That is
* the only include path a package's C — or this — ever gets, and it is there
* so that C which computes with dyn values has one declaration to agree with
* rather than a hand-copied list. */
#include "flan_dyn.h"
void flan_rt_init(int32_t argc, char **argv);
void flan_vec_free(void *v, int64_t size, int64_t align, const uint8_t *loc,
int64_t loclen);
void flan_vec_layout(int64_t out[6]);
static int failures;
static void fail(const char *what) {
printf("FAIL %s\n", what);
failures++;
}
static void check(int ok, const char *what) {
if (!ok) fail(what);
}
/* A value's printed form, captured, so the print assertions can be exact
* strings rather than eyeballed. stdout is redirected into a pipe for the
* length of one call — cheaper and more honest than a second renderer that
* would have to be kept in step with the one under test. */
static char shown[4096];
static void show(flan_dyn v) {
int saved = dup(1);
int fds[2];
ssize_t n;
shown[0] = '\0';
if (pipe(fds) != 0) { fail("pipe"); return; }
fflush(stdout);
dup2(fds[1], 1);
close(fds[1]);
flan_dyn_print(v);
fflush(stdout);
dup2(saved, 1);
close(saved);
n = read(fds[0], shown, sizeof shown - 1);
close(fds[0]);
shown[n > 0 ? (size_t)n : 0] = '\0';
}
static void prints(flan_dyn v, const char *want) {
show(v);
if (strcmp(shown, want) != 0) {
printf("FAIL print: got %s, wanted %s\n", shown, want);
failures++;
}
}
static int truth(flan_dyn v) { return flan_dyn_need_bool(v) != 0; }
static int64_t num(flan_dyn v) { return flan_dyn_need_i64(v); }
static flan_dyn text(const char *s) {
return flan_dyn_from_bytes((const uint8_t *)s, (int64_t)strlen(s));
}
/* ── The happy paths ───────────────────────────────────────────────────*/
static void ops(void) {
/* Six slots and one push of six, which is the shape the compiler lane
emits: a frame's dyn locals are rooted as a block on entry and popped as a
block on the way out. Every one is nil before it is pushed, which is the
contract the header states — the collector reads these addresses on every
mark, and an unwritten slot is a word of stack garbage. */
flan_dyn a = flan_dyn_nil(), b = flan_dyn_nil(), c = flan_dyn_nil();
flan_dyn v = flan_dyn_nil(), w = flan_dyn_nil(), s = flan_dyn_nil();
flan_dyn_root_push(&a);
flan_dyn_root_push(&b);
flan_dyn_root_push(&c);
flan_dyn_root_push(&v);
flan_dyn_root_push(&w);
flan_dyn_root_push(&s);
/* Tags, and the words they are called by. The words are what a trap message
says, so they are asserted here rather than only read. */
check(flan_dyn_tag(flan_dyn_nil()) == FLAN_DYN_TAG_NIL, "tag nil");
check(flan_dyn_tag(flan_dyn_from_bool(1)) == FLAN_DYN_TAG_BOOL, "tag bool");
check(flan_dyn_tag(flan_dyn_from_i64(7)) == FLAN_DYN_TAG_INT, "tag int");
check(flan_dyn_tag(flan_dyn_from_f64(1.5)) == FLAN_DYN_TAG_FLOAT, "tag float");
check(flan_dyn_tag(text("x")) == FLAN_DYN_TAG_TEXT, "tag text");
check(flan_dyn_tag(flan_dyn_vec_new()) == FLAN_DYN_TAG_VEC, "tag vec");
check(strcmp(flan_dyn_tag_name(FLAN_DYN_TAG_NIL), "nil") == 0, "word nil");
check(strcmp(flan_dyn_tag_name(FLAN_DYN_TAG_BOOL), "bool") == 0, "word bool");
check(strcmp(flan_dyn_tag_name(FLAN_DYN_TAG_INT), "int") == 0, "word int");
check(strcmp(flan_dyn_tag_name(FLAN_DYN_TAG_FLOAT), "float") == 0, "word float");
check(strcmp(flan_dyn_tag_name(FLAN_DYN_TAG_TEXT), "text") == 0, "word text");
check(strcmp(flan_dyn_tag_name(FLAN_DYN_TAG_VEC), "vec") == 0, "word vec");
/* Every double is itself, at both ends of the range and at the values a
NaN-box could get wrong. -0.0 is the one that would be lost by a scheme
that normalised more than NaN. */
check(flan_dyn_need_f64(flan_dyn_from_f64(0.0)) == 0.0, "f64 zero");
check(flan_dyn_need_f64(flan_dyn_from_f64(-1.25)) == -1.25, "f64 neg");
check(flan_dyn_need_f64(flan_dyn_from_f64(1e308)) == 1e308, "f64 huge");
{
double z = flan_dyn_need_f64(flan_dyn_from_f64(-0.0));
check(z == 0.0 && 1.0 / z < 0, "f64 negative zero keeps its sign");
}
{
/* A NaN survives as a NaN, which is all a NaN promises. Its *sign* does
not, on purpose: flan_rt.c already refuses to print it and the box
needs the bit. */
double n = flan_dyn_need_f64(flan_dyn_from_f64(0.0 / 0.0));
check(n != n, "f64 nan is still nan");
}
/* Integers, including the two that do not fit the payload and go to the
heap. The round trip is what says a boxed int is the same int. */
check(num(flan_dyn_from_i64(0)) == 0, "i64 zero");
check(num(flan_dyn_from_i64(-1)) == -1, "i64 minus one");
check(num(flan_dyn_from_i64(140737488355327LL)) == 140737488355327LL,
"i64 largest inline");
check(num(flan_dyn_from_i64(-140737488355328LL)) == -140737488355328LL,
"i64 smallest inline");
check(num(flan_dyn_from_i64(140737488355328LL)) == 140737488355328LL,
"i64 first boxed");
check(num(flan_dyn_from_i64(INT64_MAX)) == INT64_MAX, "i64 max");
check(num(flan_dyn_from_i64(INT64_MIN)) == INT64_MIN, "i64 min");
check(flan_dyn_tag(flan_dyn_from_i64(INT64_MAX)) == FLAN_DYN_TAG_INT,
"a boxed int is still an int");
/* Arithmetic. Two ints answer an int; a float anywhere answers a float. */
check(num(flan_dyn_add(flan_dyn_from_i64(2), flan_dyn_from_i64(3))) == 5, "+");
check(num(flan_dyn_sub(flan_dyn_from_i64(2), flan_dyn_from_i64(3))) == -1, "-");
check(num(flan_dyn_mul(flan_dyn_from_i64(2), flan_dyn_from_i64(3))) == 6, "*");
check(num(flan_dyn_div(flan_dyn_from_i64(7), flan_dyn_from_i64(2))) == 3, "/");
check(num(flan_dyn_rem(flan_dyn_from_i64(7), flan_dyn_from_i64(2))) == 1, "%");
check(num(flan_dyn_rem(flan_dyn_from_i64(-7), flan_dyn_from_i64(2))) == -1,
"% keeps the sign of the dividend");
check(flan_dyn_need_f64(
flan_dyn_add(flan_dyn_from_i64(1), flan_dyn_from_f64(0.5))) == 1.5,
"int and float promote");
check(flan_dyn_need_f64(
flan_dyn_div(flan_dyn_from_f64(1.0), flan_dyn_from_f64(4.0))) == 0.25,
"float /");
check(flan_dyn_need_f64(
flan_dyn_rem(flan_dyn_from_f64(7.5), flan_dyn_from_f64(2.0))) == 1.5,
"float %");
/* The boxed end of the range arithmetically, not only as a round trip. */
check(num(flan_dyn_add(flan_dyn_from_i64(140737488355327LL),
flan_dyn_from_i64(1))) == 140737488355328LL,
"+ crosses into the box");
/* Ordering. Numbers against numbers across the two tags, text bytewise, and
a NaN that is none of less, equal or greater. */
check(truth(flan_dyn_lt(flan_dyn_from_i64(1), flan_dyn_from_i64(2))), "<");
check(!truth(flan_dyn_lt(flan_dyn_from_i64(2), flan_dyn_from_i64(2))), "< eq");
check(truth(flan_dyn_le(flan_dyn_from_i64(2), flan_dyn_from_i64(2))), "<=");
check(truth(flan_dyn_gt(flan_dyn_from_f64(2.5), flan_dyn_from_i64(2))), ">");
check(truth(flan_dyn_ge(flan_dyn_from_i64(2), flan_dyn_from_f64(2.0))), ">=");
check(truth(flan_dyn_lt(text("abc"), text("abd"))), "< text");
check(truth(flan_dyn_lt(text("ab"), text("abc"))), "< text prefix");
check(!truth(flan_dyn_lt(text("abc"), text("abc"))), "< text equal");
{
flan_dyn n = flan_dyn_from_f64(0.0 / 0.0), one = flan_dyn_from_i64(1);
check(!truth(flan_dyn_lt(n, one)) && !truth(flan_dyn_gt(n, one))
&& !truth(flan_dyn_le(n, one)) && !truth(flan_dyn_ge(n, one)),
"nan is unordered in all four directions");
}
/* Equality. Structural, never a trap, and numeric across the tags. */
check(truth(flan_dyn_eq(flan_dyn_nil(), flan_dyn_nil())), "= nil");
check(truth(flan_dyn_eq(flan_dyn_from_bool(1), flan_dyn_from_bool(1))), "= bool");
check(!truth(flan_dyn_eq(flan_dyn_from_bool(1), flan_dyn_from_bool(0))), "= bool no");
check(truth(flan_dyn_eq(flan_dyn_from_i64(1), flan_dyn_from_f64(1.0))),
"= across int and float");
check(truth(flan_dyn_eq(flan_dyn_from_i64(INT64_MAX),
flan_dyn_from_i64(INT64_MAX))),
"= two boxed ints");
check(!truth(flan_dyn_eq(flan_dyn_from_i64(1), text("1"))),
"= on unrelated tags answers false rather than trapping");
check(!truth(flan_dyn_eq(flan_dyn_nil(), flan_dyn_from_bool(0))),
"nil is not false");
{
flan_dyn n = flan_dyn_from_f64(0.0 / 0.0);
check(!truth(flan_dyn_eq(n, n)), "nan is not equal to itself");
}
/* Text: identity is not equality, and equality is the bytes.
[a] and [b] are separately built from separate storage and must be equal;
they must also be *different objects*, because from_bytes copies and does
not intern, and a test that did not say so would pass against an
implementation that silently shared. */
a = text("hello");
b = text("hello");
c = text("hellp");
check(a != b, "two texts with the same bytes are two objects");
check(truth(flan_dyn_eq(a, b)), "= text is bytewise");
check(!truth(flan_dyn_eq(a, c)), "= text sees the last byte");
check(truth(flan_dyn_eq(a, a)), "= text against itself");
check(num(flan_dyn_len(a)) == 5, "len text");
check(num(flan_dyn_at(a, flan_dyn_from_i64(0))) == 'h', "at text");
check(num(flan_dyn_at(a, flan_dyn_from_i64(4))) == 'o', "at text last");
{
/* Embedded NUL, because a length-prefixed text is the claim and strlen is
how that claim gets quietly broken. */
flan_dyn z = flan_dyn_from_bytes((const uint8_t *)"a\0b", 3);
check(num(flan_dyn_len(z)) == 3, "len counts past a NUL");
check(num(flan_dyn_at(z, flan_dyn_from_i64(2))) == 'b', "at past a NUL");
check(!truth(flan_dyn_eq(z, text("a"))), "= does not stop at a NUL");
}
{
flan_dyn e = flan_dyn_from_bytes((const uint8_t *)"", 0);
check(num(flan_dyn_len(e)) == 0, "len of the empty text");
check(truth(flan_dyn_eq(e, flan_dyn_from_bytes(NULL, 0))),
"= two empty texts");
}
/* Vecs. */
v = flan_dyn_vec_new();
check(num(flan_dyn_len(v)) == 0, "len of a new vec");
flan_dyn_push(v, flan_dyn_from_i64(1));
flan_dyn_push(v, flan_dyn_from_i64(2));
flan_dyn_push(v, flan_dyn_from_i64(3));
check(num(flan_dyn_len(v)) == 3, "len after three pushes");
check(num(flan_dyn_at(v, flan_dyn_from_i64(1))) == 2, "at vec");
flan_dyn_set_at(v, flan_dyn_from_i64(1), text("two"));
check(truth(flan_dyn_eq(flan_dyn_at(v, flan_dyn_from_i64(1)), text("two"))),
"set-at vec");
{
/* Past the initial capacity, so the growth path runs and the elements
survive the realloc. */
int i;
flan_dyn big = flan_dyn_vec_new();
flan_dyn_root_push(&big);
for (i = 0; i < 100; i++) flan_dyn_push(big, flan_dyn_from_i64(i));
check(num(flan_dyn_len(big)) == 100, "len after a hundred pushes");
check(num(flan_dyn_at(big, flan_dyn_from_i64(0))) == 0, "first survived");
check(num(flan_dyn_at(big, flan_dyn_from_i64(99))) == 99, "last survived");
flan_dyn_root_pop(1);
}
/* Vecs compare structurally, element by element and one level down. */
{
flan_dyn p = flan_dyn_vec_new(), q = flan_dyn_vec_new();
flan_dyn_root_push(&p);
flan_dyn_root_push(&q);
flan_dyn_push(p, flan_dyn_from_i64(1));
flan_dyn_push(p, text("x"));
flan_dyn_push(q, flan_dyn_from_i64(1));
flan_dyn_push(q, text("x"));
check(p != q, "two vecs are two objects");
check(truth(flan_dyn_eq(p, q)), "= vec is element by element");
flan_dyn_push(q, flan_dyn_nil());
check(!truth(flan_dyn_eq(p, q)), "= vec sees the length");
flan_dyn_root_pop(2);
}
/* A vec that contains itself. [=] must answer rather than recurse for
ever — the identity shortcut is what makes it answer — and [print] must
stop at its depth cap. */
{
flan_dyn cyc = flan_dyn_vec_new();
flan_dyn_root_push(&cyc);
flan_dyn_push(cyc, flan_dyn_from_i64(1));
flan_dyn_push(cyc, cyc);
check(truth(flan_dyn_eq(cyc, cyc)), "= on a cycle answers");
show(cyc);
check(strlen(shown) > 0 && strstr(shown, "...") != NULL,
"print stops at its depth cap on a cycle");
flan_dyn_root_pop(1);
}
/* Printing, per tag, against what a Flan program prints for the
corresponding type. Captured from a run of `flan run`, not read off
lib/render.ml — the leading space before each element is what the slice
printer emits and what an acceptance test would compare against. */
prints(flan_dyn_nil(), "nil");
prints(flan_dyn_from_bool(1), "true");
prints(flan_dyn_from_bool(0), "false");
prints(flan_dyn_from_i64(42), "42");
prints(flan_dyn_from_i64(INT64_MIN), "-9223372036854775808");
prints(flan_dyn_from_f64(3.5), "3.5");
prints(flan_dyn_from_f64(1.0), "1");
prints(flan_dyn_from_f64(0.0 / 0.0), "nan");
prints(flan_dyn_from_f64(-(0.0 / 0.0)), "nan");
prints(text("hi"), "hi");
{
flan_dyn nums = flan_dyn_vec_new();
flan_dyn strs = flan_dyn_vec_new();
flan_dyn_root_push(&nums);
flan_dyn_root_push(&strs);
flan_dyn_push(nums, flan_dyn_from_i64(1));
flan_dyn_push(nums, flan_dyn_from_i64(2));
flan_dyn_push(nums, flan_dyn_from_i64(3));
prints(nums, "[ 1 2 3]");
/* A text inside a structure is quoted and escaped, and bare at the top
level. That is flan_rt.c's rule and the two have to agree, because the
REPL parses the printed form back. */
flan_dyn_push(strs, text("x"));
flan_dyn_push(strs, text("a b"));
flan_dyn_push(strs, text("q\"\n"));
prints(strs, "[ \"x\" \"a b\" \"q\\\"\\n\"]");
/* And a vec of vecs, nested twice. */
{
flan_dyn outer = flan_dyn_vec_new();
flan_dyn_root_push(&outer);
flan_dyn_push(outer, nums);
flan_dyn_push(outer, strs);
prints(outer, "[ [ 1 2 3] [ \"x\" \"a b\" \"q\\\"\\n\"]]");
flan_dyn_root_pop(1);
}
prints(flan_dyn_vec_new(), "[]");
flan_dyn_root_pop(2);
}
/* The typed boundary, on the tags it accepts. What it refuses is three
separate modes below — each one ends the process. */
check(flan_dyn_need_i64(flan_dyn_from_i64(-5)) == -5, "need-i64");
check(flan_dyn_need_f64(flan_dyn_from_f64(2.5)) == 2.5, "need-f64");
check(flan_dyn_need_bool(flan_dyn_from_bool(1)) == 1, "need-bool true");
check(flan_dyn_need_bool(flan_dyn_from_bool(0)) == 0, "need-bool false");
s = text("kept");
w = v;
flan_dyn_root_pop(6);
(void)w;
(void)s;
}
/* A typed container's own header, restated a third time — flan_rt.c's
* [flan_vec], flan_dyn.c's [flan_dyn_vec_hdr], and this. The three must
* agree on layout, and none of them can [#include] another's to say so at
* compile time (see [Build.compile_c]): there is no [flan_vec_init] to call
* from here, so the header below is built by hand, the same five words
* [flan_vec_grow] would leave behind after a few pushes.
*
* What actually ties the three together is [layout], further down: it reads
* flan_rt.c's [flan_vec_layout] and flan_dyn.c's [flan_dyn_vec_hdr_layout]
* and compares both against [offsetof] on this very struct, so a field
* reordered in any one of the three is a FAIL line here rather than a
* silent corruption at whatever call site next dereferences the wrong
* offset. A declared-as-[void*] prototype on its own proves nothing about
* layout — it was named as if it did in an earlier version of this
* comment, which was wrong, and [layout] is what makes the claim true. */
typedef struct {
void *ptr;
int64_t len;
int64_t cap;
void *alloc;
int64_t epoch;
} hand_vec;
/* The runtime's half of M2 item 3: a typed container crossing into dyn as a
* view, driven directly with no compiler in the loop — [flan_dyn_view_vec]
* and [flan_dyn_view_flat] built by hand over a [hand_vec] and a plain
* array, exactly as the checker's [box] will build them over a real [(Vec
* i64)] and a real [[4]i64]. */
static void view(void) {
int64_t buf[4] = { 10, 20, 30, 40 };
flan_dyn flat = flan_dyn_nil(), vv = flan_dyn_nil();
flan_dyn_root_push(&flat);
flan_dyn_root_push(&vv);
/* A flat view over a fixed array: reads box, writes tag-check, and the
storage really is the array's own — a write through the view is read
back through the C array with no call into this file at all. */
flat = flan_dyn_view_flat(buf, 4, FLAN_VIEW_I64);
check(flan_dyn_tag(flat) == FLAN_DYN_TAG_VEC, "a view tags as a vec");
check(num(flan_dyn_len(flat)) == 4, "flat view len");
check(num(flan_dyn_at(flat, flan_dyn_from_i64(2))) == 30, "flat view at");
flan_dyn_set_at(flat, flan_dyn_from_i64(2), flan_dyn_from_i64(99));
check(buf[2] == 99, "flat view write reaches the array");
buf[3] = 7;
check(num(flan_dyn_at(flat, flan_dyn_from_i64(3))) == 7,
"the array's own write reaches the view — it is not a copy");
prints(flat, "[ 10 20 99 7]");
/* Structural equality, view-aware — review's third finding. [dyn_equal]'s
VEC arm used to read [x->len]/[x->u.v.items] regardless of kind, which
for a view answers 0 and garbage: two views with different contents
compared equal, a view and an equal heap vec compared unequal, and a
map keyed by any view collided with every other view. [buf] now reads
[ 10 20 99 7]; [same] is a second, independent view over the identical
bytes, and [other] a view over one differing element. */
{
int64_t same_buf[4] = { 10, 20, 99, 7 };
int64_t diff_buf[4] = { 10, 20, 99, 8 };
flan_dyn same = flan_dyn_view_flat(same_buf, 4, FLAN_VIEW_I64);
flan_dyn other = flan_dyn_view_flat(diff_buf, 4, FLAN_VIEW_I64);
flan_dyn heap = flan_dyn_vec_new();
flan_dyn_root_push(&same);
flan_dyn_root_push(&other);
flan_dyn_root_push(&heap);
check(truth(flan_dyn_eq(flat, same)),
"two views over equal bytes are equal");
check(!truth(flan_dyn_eq(flat, other)),
"two views over different bytes are not equal");
flan_dyn_push(heap, flan_dyn_from_i64(10));
flan_dyn_push(heap, flan_dyn_from_i64(20));
flan_dyn_push(heap, flan_dyn_from_i64(99));
flan_dyn_push(heap, flan_dyn_from_i64(7));
check(truth(flan_dyn_eq(flat, heap)),
"a view and an equal heap vec are equal");
flan_dyn_set_at(heap, flan_dyn_from_i64(3), flan_dyn_from_i64(0));
check(!truth(flan_dyn_eq(flat, heap)),
"a view and a differing heap vec are not equal");
flan_dyn_root_pop(3);
}
/* A vec view: points at the header's own address, so a push that grows
and moves it is seen on the very next read — there is no snapshot to
go stale. */
{
hand_vec hv;
hv.ptr = NULL; hv.len = 0; hv.cap = 0; hv.alloc = NULL; hv.epoch = 0;
vv = flan_dyn_view_vec(&hv, FLAN_VIEW_I64);
check(num(flan_dyn_len(vv)) == 0, "vec view starts empty");
{
int i;
for (i = 0; i < 20; i++) flan_dyn_push(vv, flan_dyn_from_i64(i));
}
check(num(flan_dyn_len(vv)) == 20, "vec view len after growth");
check(num(flan_dyn_at(vv, flan_dyn_from_i64(0))) == 0,
"first element survived the growth and the move");
check(num(flan_dyn_at(vv, flan_dyn_from_i64(19))) == 19,
"pushed element reachable after the header's ptr moved");
/* [hv]'s own fields moved under the view's feet, by construction — the
view never captured [hv.ptr]; it captured [&hv]. */
check(hv.len == 20 && hv.cap >= 20, "the hand-built header itself grew");
flan_dyn_set_at(vv, flan_dyn_from_i64(0), flan_dyn_from_i64(-1));
check(((int64_t *)hv.ptr)[0] == -1, "write through the view reaches hv");
flan_vec_free(&hv, 8, 8, (const uint8_t *)"view", 4);
}
/* A bool view and a float view, so the element-tag dispatch is exercised
on all three kinds and not only i64. */
{
uint8_t bools[2] = { 1, 0 };
double floats[2] = { 1.5, -2.0 };
flan_dyn bv = flan_dyn_view_flat(bools, 2, FLAN_VIEW_BOOL);
flan_dyn fv = flan_dyn_view_flat(floats, 2, FLAN_VIEW_F64);
check(truth(flan_dyn_at(bv, flan_dyn_from_i64(0))), "bool view at true");
check(!truth(flan_dyn_at(bv, flan_dyn_from_i64(1))), "bool view at false");
flan_dyn_set_at(bv, flan_dyn_from_i64(1), flan_dyn_from_bool(1));
check(bools[1] == 1, "bool view write");
check(flan_dyn_need_f64(flan_dyn_at(fv, flan_dyn_from_i64(0))) == 1.5,
"float view at");
flan_dyn_set_at(fv, flan_dyn_from_i64(0), flan_dyn_from_f64(3.25));
check(floats[0] == 3.25, "float view write");
}
flan_dyn_root_pop(2);
printf(failures == 0 ? "view ok\n" : "view failed\n");
}
/* Every wrong way to use a view: out of range, a mismatched write on each of
* the three element kinds, and a push against a fixed-size (flat) view. Each
* is its own mode because each ends the process. */
static void refuse_view(const char *what) {
static int64_t buf[2] = { 1, 2 };
flan_dyn v;
if (strcmp(what, "range") == 0) {
v = flan_dyn_view_flat(buf, 2, FLAN_VIEW_I64);
(void)flan_dyn_at(v, flan_dyn_from_i64(2));
} else if (strcmp(what, "wrongwrite") == 0) {
v = flan_dyn_view_flat(buf, 2, FLAN_VIEW_I64);
flan_dyn_set_at(v, flan_dyn_from_i64(0), text("nope"));
} else if (strcmp(what, "wrongbool") == 0) {
static uint8_t bb[1];
v = flan_dyn_view_flat(bb, 1, FLAN_VIEW_BOOL);
flan_dyn_set_at(v, flan_dyn_from_i64(0), flan_dyn_from_i64(1));
} else if (strcmp(what, "wrongfloat") == 0) {
static double ff[1];
v = flan_dyn_view_flat(ff, 1, FLAN_VIEW_F64);
flan_dyn_set_at(v, flan_dyn_from_i64(0), flan_dyn_from_i64(1));
} else if (strcmp(what, "flatpush") == 0) {
v = flan_dyn_view_flat(buf, 2, FLAN_VIEW_I64);
flan_dyn_push(v, flan_dyn_from_i64(9));
} else {
printf("no such refusal: %s\n", what);
exit(2);
}
printf("did not trap\n");
exit(3);
}
/* The three restatements of flan_vec's layout, compared — see [hand_vec]'s
* comment for why nothing at compile time otherwise ties them together.
* [offsetof] on [hand_vec] itself is this file's half; [flan_vec_layout]
* and [flan_dyn_vec_hdr_layout] are the other two's. */
static void layout(void) {
int64_t rt[6], dyn[6];
int64_t here[6] = {
(int64_t)sizeof(hand_vec),
(int64_t)offsetof(hand_vec, ptr),
(int64_t)offsetof(hand_vec, len),
(int64_t)offsetof(hand_vec, cap),
(int64_t)offsetof(hand_vec, alloc),
(int64_t)offsetof(hand_vec, epoch)
};
static const char *const names[6] =
{ "sizeof", "offset of ptr", "offset of len", "offset of cap",
"offset of alloc", "offset of epoch" };
int i;
char msg[128];
flan_vec_layout(rt);
flan_dyn_vec_hdr_layout(dyn);
for (i = 0; i < 6; i++) {
if (rt[i] != here[i]) {
snprintf(msg, sizeof msg, "flan_vec vs. hand_vec's %s: %lld vs. %lld",
names[i], (long long)rt[i], (long long)here[i]);
fail(msg);
}
if (dyn[i] != here[i]) {
snprintf(msg, sizeof msg,
"flan_dyn_vec_hdr vs. hand_vec's %s: %lld vs. %lld",
names[i], (long long)dyn[i], (long long)here[i]);
fail(msg);
}
}
printf(failures == 0 ? "layout ok\n" : "layout failed\n");
}
/* ── The collector ─────────────────────────────────────────────────────*/
/* Allocate a great many, hold a few, and assert the heap does not grow. The
* live set is a hundred texts in a rooted vec, rewritten round and round; the
* million texts that fall out of it have nothing pointing at them from the
* moment the next iteration overwrites their slot.
*
* The assertion is on the *bound* and not on any particular number: a
* collector's exact high-water mark is a fact about its trigger, and pinning
* it would make a tuning change a test failure. What must hold is that the
* figure stops climbing — that is the whole claim — so the test takes the
* heap's high-water mark over the run and requires it to be within a small
* multiple of what the live set actually needs.
*
* The floor is dropped to 64K first. A megabyte of floor would make this a
* megabyte of arithmetic before the first collection and prove nothing faster.
*/
static void gc(void) {
enum { LIVE = 100, ROUNDS = 1000000 };
flan_dyn keep = flan_dyn_nil();
int64_t peak = 0, settled, i;
int collections_happened;
flan_gc_init();
flan_gc_set_floor(64 * 1024);
flan_dyn_root_push(&keep);
keep = flan_dyn_vec_new();
for (i = 0; i < LIVE; i++) flan_dyn_push(keep, flan_dyn_nil());
for (i = 0; i < ROUNDS; i++) {
char buf[32];
int n = snprintf(buf, sizeof buf, "item-%lld", (long long)i);
flan_dyn_set_at(keep, flan_dyn_from_i64(i % LIVE),
flan_dyn_from_bytes((const uint8_t *)buf, n));
if (flan_gc_live_bytes() > peak) peak = flan_gc_live_bytes();
}
flan_gc_collect();
settled = flan_gc_live_bytes();
collections_happened = flan_gc_count() < LIVE + 64 + 8;
/* A million texts of forty-odd bytes is some forty megabytes allocated. A
heap that never collected would hold all of it, so any bound under a
megabyte is a bound only a working collector can meet, and 512K is
comfortably above twice the live set plus the floor plus the ring. */
printf("peak under 512K: %s\n", peak < 512 * 1024 ? "yes" : "no");
printf("settled under 32K: %s\n", settled < 32 * 1024 ? "yes" : "no");
printf("live objects bounded: %s\n", collections_happened ? "yes" : "no");
/* And the live set is intact: collecting a million times must not have lost
the hundred things that were rooted throughout. */
{
int intact = 1;
for (i = 0; i < LIVE; i++) {
char buf[32];
int64_t k = ROUNDS - LIVE + i;
int n = snprintf(buf, sizeof buf, "item-%lld", (long long)k);
flan_dyn got = flan_dyn_at(keep, flan_dyn_from_i64(k % LIVE));
if (!flan_dyn_need_bool(
flan_dyn_eq(got, flan_dyn_from_bytes((const uint8_t *)buf, n))))
intact = 0;
}
printf("live set intact: %s\n", intact ? "yes" : "no");
}
flan_dyn_root_pop(1);
}
/* Nested vecs, traced. A chain sixty-four deep reached through one root: every
* link has to survive a collection, which is what says the marker follows a
* vec's elements and not only its header. Sixty-four is also past the point
* where a recursive marker on a modest stack would be fine and a deeper one
* would not — the marker here is iterative, and this is the case that would
* notice if it stopped being. */
static void nested(void) {
enum { DEEP = 64 };
flan_dyn root = flan_dyn_nil(), cur;
int64_t i;
int ok = 1;
flan_gc_init();
flan_gc_set_floor(16 * 1024);
flan_dyn_root_push(&root);
root = flan_dyn_vec_new();
cur = root;
for (i = 0; i < DEEP; i++) {
flan_dyn inner = flan_dyn_vec_new();
flan_dyn_push(cur, flan_dyn_from_i64(i));
flan_dyn_push(cur, inner);
cur = inner;
}
flan_dyn_push(cur, text("bottom"));
/* Churn, so that collections certainly happen with the chain live, and then
one more by hand. */
for (i = 0; i < 20000; i++) (void)text("noise");
flan_gc_collect();
cur = root;
for (i = 0; i < DEEP; i++) {
if (flan_dyn_need_i64(flan_dyn_at(cur, flan_dyn_from_i64(0))) != i) ok = 0;
cur = flan_dyn_at(cur, flan_dyn_from_i64(1));
}
if (!flan_dyn_need_bool(
flan_dyn_eq(flan_dyn_at(cur, flan_dyn_from_i64(0)), text("bottom"))))
ok = 0;
printf("chain of %d intact: %s\n", DEEP, ok ? "yes" : "no");
flan_dyn_root_pop(1);
}
/* Interior sharing: one vec held twice, and a text held from two places.
* Three things have to be true and none of them follows from the others —
* the shared object is swept once and not twice (a double free would show as
* a crash or as a live-bytes figure that went negative), a write through one
* path is visible through the other (it is one object, not a copy), and
* dropping one of the two references does not collect it. */
static void sharing(void) {
flan_dyn holder = flan_dyn_nil(), shared = flan_dyn_nil();
flan_dyn was;
int i;
flan_gc_init();
flan_gc_set_floor(16 * 1024);
flan_dyn_root_push(&holder);
flan_dyn_root_push(&shared);
holder = flan_dyn_vec_new();
shared = flan_dyn_vec_new();
flan_dyn_push(shared, text("a"));
flan_dyn_push(holder, shared);
flan_dyn_push(holder, shared);
flan_dyn_push(holder, shared);
/* Three slots, one object. Identity and not equality: two vecs holding the
same text are equal and are still two vecs, so a structural test would
pass against an implementation that had quietly copied. The dyn word of a
vec *is* its address, so comparing the words is comparing the objects. */
printf("three slots hold one object: %s\n",
flan_dyn_at(holder, flan_dyn_from_i64(0))
== flan_dyn_at(holder, flan_dyn_from_i64(2))
? "yes" : "no");
/* And writing through one path is read through another. */
flan_dyn_set_at(flan_dyn_at(holder, flan_dyn_from_i64(0)),
flan_dyn_from_i64(0), text("b"));
printf("write through one path is seen through another: %s\n",
flan_dyn_need_bool(
flan_dyn_eq(flan_dyn_at(flan_dyn_at(holder, flan_dyn_from_i64(2)),
flan_dyn_from_i64(0)),
text("b")))
? "yes" : "no");
/* Dropping the direct root leaves it reachable through the holder, three
times over. It must still be there, at the same address — a collector
that swept it and handed the space to something else would answer this
with a different word, and one that swept it twice would be caught by the
sanitizer sweep rather than by an assertion. The churn in between is what
makes the collection real rather than a formality. */
was = shared;
shared = flan_dyn_nil();
for (i = 0; i < 5000; i++) (void)text("noise");
flan_gc_collect();
printf("shared object survives on the holder alone: %s\n",
flan_dyn_at(holder, flan_dyn_from_i64(1)) == was ? "yes" : "no");
printf("still reachable: %s\n",
flan_dyn_need_bool(
flan_dyn_eq(flan_dyn_at(flan_dyn_at(holder, flan_dyn_from_i64(1)),
flan_dyn_from_i64(0)),
text("b")))
? "yes" : "no");
/* And dropping the holder collects the lot, once. A double free of the
thrice-held vec is what this is really asking about: it would crash here,
or under @sanitize, or leave the byte count below zero. */
holder = flan_dyn_nil();
was = flan_dyn_nil();
for (i = 0; i < 5000; i++) (void)text("noise");
flan_gc_collect();
printf("live bytes after dropping everything: %s\n",
flan_gc_live_bytes() >= 0 && flan_gc_count() <= 64 ? "ok" : "wrong");
flan_dyn_root_pop(2);
}
/* An unrooted object is collected. The positive control for every assertion
* above: without this, a collector that never freed anything would pass the
* lot. The text is allocated, its object count noted, and then enough
* allocation happens to push it out of the temporaries ring — after which a
* collection must reclaim it. */
static void unrooted(void) {
int64_t before, after;
int i;
flan_gc_init();
flan_gc_set_floor(1 << 20); /* high, so only the explicit collect sweeps */
flan_gc_collect();
before = flan_gc_count();
for (i = 0; i < 500; i++) (void)text("garbage");
printf("allocated: %s\n", flan_gc_count() >= before + 500 ? "yes" : "no");
flan_gc_collect();
after = flan_gc_count();
/* The ring holds the last 64, by design, so the survivors are bounded by it
and not by zero. */
printf("reclaimed all but the ring: %s\n",
after <= before + 64 ? "yes" : "no");
}
/* An aggregate root: a struct with dyn fields somewhere inside it, rooted by
* its address and a descriptor rather than word by word. This is the runtime's
* half of the per-type descriptors, exercised with the descriptor written out
* here by hand — the compiler emits the same three words as static data, and a
* runtime that read them wrongly would be wrong in both lanes at once.
*
* The shape deliberately has a gap and a nesting in it: a header word that is
* not a dyn, an inner struct that carries one, and a trailing one.
*
* Two assertions, and the second is the one that tells an offset-driven marker
* from a word-driven one. The first — that the named fields survive — passes
* under both, because a marker that walked every word of the struct would keep
* them too. So [hidden] sits at an offset the descriptor does not name and
* holds five hundred objects: an offset-driven marker lets the lot go, and a
* word-driven one keeps them. The count is what says which happened. */
typedef struct {
int64_t n;
flan_dyn label;
struct { int32_t k; flan_dyn note; } inner;
flan_dyn tail;
flan_dyn hidden; /* deliberately absent from [offs] below */
} desc_row;
static void desc(void) {
static const int64_t offs[] = {
(int64_t)offsetof(desc_row, label),
(int64_t)offsetof(desc_row, inner.note),
(int64_t)offsetof(desc_row, tail)
};
static const flan_desc row_desc = {
(int64_t)sizeof(desc_row), 3, offs
};
desc_row row;
flan_dyn was_label, was_note;
int64_t before;
int i, ok = 1;
flan_gc_init();
flan_gc_set_floor(16 * 1024);
/* The contract the header states: the dyn words must hold valid values
before the push. Zero satisfies it; the header word need not. */
row.label = flan_dyn_nil();
row.inner.note = flan_dyn_nil();
row.tail = flan_dyn_nil();
row.hidden = flan_dyn_nil();
row.n = 0;
row.inner.k = 7;
flan_dyn_root_push_desc(&row, &row_desc);
row.label = flan_dyn_vec_new();
flan_dyn_push(row.label, text("held"));
row.inner.note = text("nested");
row.tail = flan_dyn_vec_new();
flan_dyn_push(row.tail, flan_dyn_from_i64(99));
was_label = row.label;
was_note = row.inner.note;
for (i = 0; i < 20000; i++) (void)text("noise");
flan_gc_collect();
if (row.label != was_label || row.inner.note != was_note) ok = 0;
if (!flan_dyn_need_bool(
flan_dyn_eq(flan_dyn_at(row.label, flan_dyn_from_i64(0)),
text("held")))) ok = 0;
if (!flan_dyn_need_bool(flan_dyn_eq(row.inner.note, text("nested")))) ok = 0;
if (flan_dyn_need_i64(flan_dyn_at(row.tail, flan_dyn_from_i64(0))) != 99)
ok = 0;
printf("aggregate root survives collection: %s\n", ok ? "yes" : "no");
/* And the half that discriminates. [hidden] is a dyn word inside the very
object the collector was handed, at an offset the descriptor leaves out,
and it holds five hundred and one objects. A marker that walked the struct
rather than the offsets would keep every one of them; one that reads the
offsets it was given lets them go. The ring holds the last sixty-four
allocations unconditionally, so the noise below is what puts them out of
its reach, and sixty-four is the slack the test allows. */
flan_gc_collect();
before = flan_gc_count();
/* Built under a dyn root of its own, because the descriptor does not name
this word and a collection in the middle of five hundred pushes would
free what the next push writes into. The pop is what leaves it reachable
from the unnamed offset and from nowhere else, which is the state the
question is about. */
flan_dyn_root_push(&row.hidden);
row.hidden = flan_dyn_vec_new();
for (i = 0; i < 500; i++) flan_dyn_push(row.hidden, text("hidden"));
flan_dyn_root_pop(1);
for (i = 0; i < 200; i++) (void)text("noise");
flan_gc_collect();
printf("a dyn at an offset the descriptor omits is not marked: %s\n",
flan_gc_count() <= before + 64 ? "yes" : "no");
/* And the positive control, which is the same one [unrooted] makes: drop the
fields and the objects go. One pop takes the aggregate entry off exactly
as it takes a dyn one off, which is what keeps a function's pop a count. */
row.label = flan_dyn_nil();
row.inner.note = flan_dyn_nil();
row.tail = flan_dyn_nil();
row.hidden = flan_dyn_nil();
flan_dyn_root_pop(1);
for (i = 0; i < 5000; i++) (void)text("noise");
flan_gc_collect();
printf("and is reclaimed once dropped: %s\n",
flan_gc_count() <= 128 ? "yes" : "no");
}
/* ── The refusals ──────────────────────────────────────────────────────
*
* One per mode, because each ends the process. The driver asserts on the
* sentence as well as on the exit status: a process that died some other way
* is not this guard firing, and the status alone cannot tell them apart. */
static void refuse(const char *what) {
flan_dyn v = flan_dyn_vec_new();
flan_dyn t = text("hi");
if (strcmp(what, "add") == 0) (void)flan_dyn_add(flan_dyn_from_i64(3), t);
else if (strcmp(what, "sub") == 0)
(void)flan_dyn_sub(flan_dyn_nil(), flan_dyn_from_i64(1));
else if (strcmp(what, "mul") == 0)
(void)flan_dyn_mul(flan_dyn_from_bool(1), flan_dyn_from_i64(2));
else if (strcmp(what, "div") == 0)
(void)flan_dyn_div(v, flan_dyn_from_i64(2));
else if (strcmp(what, "rem") == 0)
(void)flan_dyn_rem(flan_dyn_from_i64(2), flan_dyn_nil());
else if (strcmp(what, "divzero") == 0)
(void)flan_dyn_div(flan_dyn_from_i64(1), flan_dyn_from_i64(0));
else if (strcmp(what, "remzero") == 0)
(void)flan_dyn_rem(flan_dyn_from_i64(1), flan_dyn_from_i64(0));
else if (strcmp(what, "divover") == 0)
(void)flan_dyn_div(flan_dyn_from_i64(INT64_MIN), flan_dyn_from_i64(-1));
else if (strcmp(what, "lt") == 0)
(void)flan_dyn_lt(flan_dyn_from_i64(1), t);
else if (strcmp(what, "le") == 0) (void)flan_dyn_le(t, flan_dyn_nil());
else if (strcmp(what, "gt") == 0) (void)flan_dyn_gt(v, v);
else if (strcmp(what, "ge") == 0)
(void)flan_dyn_ge(flan_dyn_from_bool(0), flan_dyn_from_bool(1));
else if (strcmp(what, "len") == 0) (void)flan_dyn_len(flan_dyn_from_i64(1));
else if (strcmp(what, "at") == 0)
(void)flan_dyn_at(flan_dyn_from_i64(3), flan_dyn_from_i64(0));
else if (strcmp(what, "atindex") == 0) (void)flan_dyn_at(t, t);
else if (strcmp(what, "atrange") == 0)
(void)flan_dyn_at(t, flan_dyn_from_i64(9));
else if (strcmp(what, "atnegative") == 0)
(void)flan_dyn_at(t, flan_dyn_from_i64(-1));
else if (strcmp(what, "setattext") == 0)
flan_dyn_set_at(t, flan_dyn_from_i64(0), flan_dyn_from_i64(65));
else if (strcmp(what, "setatnotvec") == 0)
flan_dyn_set_at(flan_dyn_from_i64(1), flan_dyn_from_i64(0), t);
else if (strcmp(what, "setatrange") == 0)
flan_dyn_set_at(v, flan_dyn_from_i64(0), t);
else if (strcmp(what, "push") == 0) flan_dyn_push(t, flan_dyn_from_i64(1));
else if (strcmp(what, "needi64") == 0) (void)flan_dyn_need_i64(t);
else if (strcmp(what, "needf64") == 0)
(void)flan_dyn_need_f64(flan_dyn_from_i64(1));
else if (strcmp(what, "needbool") == 0)
(void)flan_dyn_need_bool(flan_dyn_nil());
else {
printf("no such refusal: %s\n", what);
exit(2);
}
/* Reached only if the operation returned, which is the failure this mode is
testing for. */
printf("did not trap\n");
exit(3);
}
int main(int argc, char **argv) {
flan_rt_init(argc, argv);
if (argc < 2) {
printf("usage: dyn_ops <mode>\n");
return 2;
}
if (strcmp(argv[1], "ops") == 0) {
ops();
printf(failures == 0 ? "ops ok\n" : "ops failed\n");
return failures == 0 ? 0 : 1;
}
if (strcmp(argv[1], "gc") == 0) { gc(); return 0; }
if (strcmp(argv[1], "nested") == 0) { nested(); return 0; }
if (strcmp(argv[1], "sharing") == 0) { sharing(); return 0; }
if (strcmp(argv[1], "unrooted") == 0) { unrooted(); return 0; }
if (strcmp(argv[1], "desc") == 0) { desc(); return 0; }
if (strcmp(argv[1], "view") == 0) {
view();
return failures == 0 ? 0 : 1;
}
if (strcmp(argv[1], "layout") == 0) {
layout();
return failures == 0 ? 0 : 1;
}
if (strncmp(argv[1], "refuse:", 7) == 0) { refuse(argv[1] + 7); return 0; }
if (strncmp(argv[1], "refuseview:", 11) == 0) {
refuse_view(argv[1] + 11);
return 0;
}
printf("no such mode: %s\n", argv[1]);
return 2;
}