flan/test/test_dyn.ml
Joseph Ferano 27b672a3d2 Five back from review, and the first one was the mangle eating a type
A descriptor's symbol was the type's printed form with every character an
assembler would refuse replaced by a dot, and the table was keyed by that.
The mangle is many-to-one — a Flan name may hold -, +, *, ? and / — so row-a
and row+a were one entry, the second of them was pushed with the first's
descriptor, and the collector read at another type's offsets: past the end of
the object when the first was the larger, and never where the second's dyn
actually sat.  ASan named it, a stack-buffer-overflow inside gc_mark_all.  It
is the same corruption root_plan pools its temporaries to avoid, arriving
through the name rather than through the supply, which is a lesson about where
identity lives: the table is keyed by Types.to_string now, which is an
identity, and the symbol carries a counter so two types cannot collide however
they mangle.  dyn-struct.flan grows the pair, held live across the churn, and
an acceptance assertion asks the emitter directly how many descriptors it
wrote under that label — two, or the two are sharing one.  That assertion is
the half with teeth: whether an overread off the end of a frame slot lands on
anything is luck, and the run's own output was not red under the defect.

The cap on a flattened array's offsets was bypassable by the thing it was
meant to stop.  [4611686018427387904 S] wrapped the multiplication negative,
so the test read as under the cap, the declaration was accepted, and the
emitter then sat building the offset list until something killed it.  A
refusal that overflows into an acceptance is worse than no refusal.  The count
saturates at one past the cap now and the message says more-than rather than a
figure that came out of a wrap.

dyn_ops.c's second assertion had no teeth: a marker never writes through a
root, so "the word at a non-dyn offset is untouched" passed under any marker
at all.  What discriminates offset-driven from word-driven is a dyn word the
descriptor leaves out, holding five hundred objects, that must NOT survive —
and it is checked by adding its offset to the table and watching the line go
red.

dyn_anywhere descended through Ptr and Slice, so (Vec (Ptr Cond)) was refused
with a sentence about a dyn inside a type whose storage contains none.  A
vector of pointers to condition structs is an ordinary thing to write.  It
stops at a pointer now, which is the line hidden_dyn already took for a bare
(Ptr S) and the line the whole argument rests on: a pointer is a view of
storage something else roots.

Which leaves the one honest hole, and it is named at the boundary where it
opens rather than left in a comment.  Storage C hands back was never rooted
and never will be, so a (Ptr S) crossing a declare with a dyn anywhere under S
is refused by name — the same sentence a bare dyn already gets there, one
level down.

dune test --force: green, 0 failures.  dyn-struct.flan clean under ASan and
UBSan and identical at -O2, -O0 and --x86.
2026-09-19 22:53:16 +07:00

187 lines
8.1 KiB
OCaml

(* The dynamic-value runtime, driven from C.
runtime/flan_dyn.c is a C ABI with no Flan spelling yet — the compiler lane
is what gives it one — so the only way to reach every operation, and every
way each one refuses, is a C main. test/dyn_ops.c is that main and
programs/dyn-host.flan is the program it is linked against, which has no
[main] of its own for the same reason programs/reload.flan does not.
What is asserted here, and why each is its own thing:
ops every operation's happy path, the tags, the printed form of
each, text identity against text equality, and a vec that
contains itself
gc a million allocations against a hundred live, and the heap's
high-water mark bounded
unrooted the positive control: an object nothing points at is reclaimed.
desc an aggregate root: a struct whose dyn fields are named by a
descriptor rather than pushed one at a time.
Without it a collector that never freed would pass everything
nested a chain of vecs sixty-four deep, traced through one root
sharing one object held three times — written through one path and read
through another, and swept once when the last goes
refuse:* twenty-four refusals, one process each, asserted on the sentence
as well as on the status: a process that died some other way is
not the guard firing, and the exit code cannot tell them apart
One binary, built once, run twenty-nine times. The build is the expensive
part and the runs are milliseconds, which is what keeps this inside
`dune test` rather than behind an alias. *)
open Flan
(* The watchdog first: a hang is the one failure mode that reports nothing at
all. See watchdog.ml. *)
let () = Watchdog.arm ~seconds:600 "test_dyn"
let failures = ref 0
let fail fmt =
Printf.ksprintf (fun s -> incr failures; print_endline ("FAIL " ^ s)) fmt
let scratch = Filename.get_temp_dir_name ()
let tmp name = Filename.concat scratch ("flan-dyn-" ^ name)
let has hay needle =
let n = String.length needle and h = String.length hay in
let rec go i = i + n <= h && (String.sub hay i n = needle || go (i + 1)) in
go 0
let () =
match Sys.command "command -v clang > /dev/null 2>&1" with
| 0 ->
let p =
Check.program
(Load.program ~file:"programs/dyn-host.flan"
(Reader.read_file "programs/dyn-host.flan")).Load.decls
in
let exe = tmp "ops" in
ignore (Build.executable ~csrcs:[ "dyn_ops.c" ] p ~out:exe);
let run mode =
let o = tmp (mode ^ ".out") and e = tmp (mode ^ ".err") in
let code =
Sys.command
(Printf.sprintf "%s %s > %s 2> %s" (Filename.quote exe)
(Filename.quote mode) (Filename.quote o) (Filename.quote e))
in
let out = In_channel.with_open_bin o In_channel.input_all in
let err = In_channel.with_open_bin e In_channel.input_all in
List.iter (fun x -> try Sys.remove x with Sys_error _ -> ()) [ o; e ];
(code, out, err)
in
(* The happy paths. Each assertion inside prints its own FAIL line, so the
output is the report and this only has to notice that there was one. *)
let code, out, err = run "ops" in
if code <> 0 || out <> "ops ok\n" then
fail "the operations\n got: %S (exit %d, err %S)" out code err;
(* The collector. Four sentences, each a yes: the high-water mark stayed
under half a megabyte across forty megabytes of allocation, the heap
settled small, the object count stayed bounded, and the hundred rooted
values were all still what they were set to. *)
let code, out, err = run "gc" in
let want_gc =
"peak under 512K: yes\nsettled under 32K: yes\n\
live objects bounded: yes\nlive set intact: yes\n"
in
if code <> 0 || out <> want_gc then
fail "a million allocations against a hundred live\n\
\ got: %S (exit %d, err %S)\n wanted: %S"
out code err want_gc;
(* And the control. A collector that never freed anything would pass every
other case in this file; this is the one it cannot. *)
let code, out, _ = run "unrooted" in
let want_un = "allocated: yes\nreclaimed all but the ring: yes\n" in
if code <> 0 || out <> want_un then
fail "an unrooted object\n got: %S (exit %d)\n wanted: %S"
out code want_un;
(* The aggregate roots the per-type descriptors added: a struct with dyn
fields at three offsets, one of them inside a nested struct, rooted by
address and descriptor rather than word by word.
The second line is the one that discriminates, and the first on its own
does not: a marker that walked every word of the struct would keep the
named fields too. So the struct also holds five hundred objects behind a
dyn word at an offset the descriptor leaves out, and the claim is that
they are *not* kept. *)
let code, out, _ = run "desc" in
let want_desc =
"aggregate root survives collection: yes\n\
a dyn at an offset the descriptor omits is not marked: yes\n\
and is reclaimed once dropped: yes\n"
in
if code <> 0 || out <> want_desc then
fail "an aggregate root\n got: %S (exit %d)\n wanted: %S"
out code want_desc;
let code, out, _ = run "nested" in
if code <> 0 || out <> "chain of 64 intact: yes\n" then
fail "a chain of nested vecs\n got: %S (exit %d)" out code;
let code, out, _ = run "sharing" in
let want_sh =
"three slots hold one object: yes\n\
write through one path is seen through another: yes\n\
shared object survives on the holder alone: yes\n\
still reachable: yes\n\
live bytes after dropping everything: ok\n"
in
if code <> 0 || out <> want_sh then
fail "interior sharing\n got: %S (exit %d)\n wanted: %S"
out code want_sh;
(* Every refusal. The pair is (mode, a phrase the sentence must contain);
the phrase is chosen to be the part that says *which* mistake it was,
so a message that named the wrong operation or the wrong tag would not
pass by accident.
The tag names are asserted here too, as words: "int" and "text" and not
2 and 4. A message with a number in it is a puzzle, and the rule is
written down in flan_dyn.c beside the table the words come from. *)
let refusals =
[ ("add", "dyn +: int and text");
("sub", "dyn -: nil and int");
("mul", "dyn *: bool and int");
("div", "dyn /: vec and int");
("rem", "dyn %: int and nil");
("divzero", "does not divide by zero");
("remzero", "does not divide by zero");
("divover", "one past the largest i64");
("lt", "dyn <: int and text");
("le", "dyn <=: text and nil");
("gt", "dyn >: vec and vec");
("ge", "dyn >=: bool and bool");
("len", "dyn len: int");
("at", "dyn at: int and int");
("atindex", "an index must be an int");
("atrange", "index 9 is out of bounds for text of length 2");
("atnegative", "index -1 is out of bounds");
("setattext", "a text is immutable");
("setatnotvec", "only a vec is assigned into");
("setatrange", "index 0 is out of bounds for vec of length 0");
("push", "only a vec is pushed to");
("needi64", "dyn i64: text");
("needf64", "dyn f64: int");
("needbool", "dyn bool: nil") ]
in
List.iter
(fun (mode, phrase) ->
let code, out, err = run ("refuse:" ^ mode) in
if code = 0 then
fail "%s returned rather than trapping: %S" mode out
else if not (has err phrase) then
fail "%s did not say %S; it said %S" mode phrase err)
refusals;
(try Sys.remove exe with Sys_error _ -> ());
(* A line on the way out, because a test that says nothing when it passes
is a test nobody can tell from a test that did not run. *)
if !failures = 0 then
Printf.printf " ok the dyn runtime: %d refusals and six runs\n"
(List.length refusals)
else exit 1
| _ -> print_endline "SKIP test_dyn: no clang"