flan/test/programs/dyn-struct.flan
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

141 lines
6.4 KiB
Plaintext

;;;; A struct with a dyn field, under an actual collection.
;;;;
;;;; Until the per-type descriptors this program did not compile: the checker
;;;; refused a dyn field outright, because the collector's roots were frames
;;;; and a struct outlives the frame that built it, so the field's vector was
;;;; reachable only through memory the marker never walked.
;;;;
;;;; What lifted it: every type that holds dyn words at static offsets gets a
;;;; descriptor — a table of byte offsets, emitted once as static data — and
;;;; every place a value of that type can live goes on the collector's root
;;;; stack with the descriptor beside it. The instance never points at its
;;;; descriptor and the collector never derives one from the bytes; the pairing
;;;; is made at the push, by the code that knows the static type of what it put
;;;; there. runtime/flan_dyn.h's flan_dyn_root_push_desc argues that at length.
;;;;
;;;; So this file exercises the four places such a value lives, and does it
;;;; past flan_dyn.c's one-megabyte floor, which is the only way a mark and a
;;;; sweep actually run:
;;;;
;;;; - a frame slot, which is [keep] in [churn];
;;;; - a global, which is [registry], rooted before the startup function;
;;;; - the temporary a call's by-value return lands in, which is what
;;;; [make-row] hands back — the callee rooted that vector and popped it in
;;;; its epilogue, so between the return and the caller's store the only
;;;; copy is a register the collector cannot see;
;;;; - a condition's payload, which crosses a handler boundary as a pointer
;;;; into a live frame while the handler allocates.
;;;;
;;;; Nesting is in here twice over: Row holds a Tag by value, and Tag holds the
;;;; dyn. A descriptor is flattened, so Row's table names Tag's dyn word at
;;;; Row's offset plus Tag's, and there is no second descriptor to follow.
;;;;
;;;; What a lost root looks like here is not a wrong number. It is a use of
;;;; freed memory — a crash, or a word that decodes as another tag and traps
;;;; with a sentence about the wrong type.
(defstruct Tag [name dyn])
(defstruct Row [id i32 tag Tag rows dyn])
(defstruct Stalled [why dyn id i32])
;;; Two types whose names differ only in a character a descriptor symbol's
;;; mangle flattens: - and + both come out as a dot, so these two once shared
;;; one entry in the emitter's table and the second of them was pushed with the
;;; first's descriptor. Three words apart in size and one dyn each at opposite
;;; ends, so the mistake is not a subtle one — the collector read twenty-four
;;; bytes past an eight-byte stack object and never marked the word that was
;;; actually there. ASan called it what it was, a stack-buffer-overflow inside
;;; gc_mark_all. They are held live across the churn below, which is where a
;;; descriptor pointing at the wrong offsets shows.
(defstruct dyn-row [a i64 b i64 c i64 d dyn])
(defstruct dyn+row [e dyn])
;;; A global holding dyn words, which main roots before a line of the program
;;; runs and never pops. Zero until its field is set, and a zero word is not a
;;; value the collector follows.
(defvar registry Row)
;;; What the handler saw, read back after the handler had allocated.
(defvar echoed dyn)
(defvar stalls i64)
;;; The collector's own counters, so that "nothing leaks" is a fact this
;;; program states rather than one the absence of a crash implies.
(declare gc-collect [] () "flan_gc_collect")
(declare gc-count [] i64 "flan_gc_count")
;;; Returned by value. The vector is rooted in this frame and unrooted the
;;; instant the epilogue pops, so the caller's own root is the only thing
;;; between it and the next allocation.
(defn make-row [i i32] Row
(let [rows (vec-new dyn)]
(push rows i)
(push rows "row")
(push rows 2.5)
(Row {.id i .tag (Tag {.name "tag"}) .rows rows})))
;;; An aggregate parameter, which arrives in its slot before the roots are
;;; pushed. Zeroing its dyn words over the top of the argument would be a
;;; silent miscompile, so the count this returns is the check for it.
(defn row-len [r Row] i64 (i64 (len (.rows r))))
;;; The payload crosses as a pointer to a value in this frame, and the handler
;;; below allocates before it reads it.
(defn stall [i i32] ()
(let [why (vec-new dyn)]
(push why "stalled")
(push why i)
(signal (Stalled {.why why .id i}))))
;;; The garbage is a whole Row per iteration, kept by nothing. The live one
;;; grows *through* the collections rather than only between them.
(defn churn [n i32] i64
(let [keep (make-row 0)
total (i64 0)
i 0]
(while (< i n)
(let [junk (make-row i)]
(set total (+ total (row-len junk))))
(if (= 0 (% i 64)) (push (.rows keep) i))
(if (= 0 (% i 4096)) (stall i))
(set i (+ i 1)))
(set (.rows registry) (.rows keep))
total))
(defn boxed [n i64] dyn
(let [v (vec-new dyn)]
(push v n)
v))
(defn main [] ()
(set (.name (.tag registry)) "registry")
(set (.rows registry) (vec-new dyn))
;; Bound before the churn and read after it, wide first so that its
;; descriptor is the one registered first and the narrow one is what a
;; shared entry would corrupt.
(let [wide (dyn-row {.a 1 .b 2 .c 3 .d (boxed 7)})
narrow (dyn+row {.e (boxed 11)})]
(handler-bind [(Stalled [c]
;; Allocate first, then read the payload: if the payload's
;; vector were unrooted across the transfer, this is the
;; allocation that would free it.
(let [noise (vec-new dyn)]
(push noise "noise"))
(set stalls (+ stalls 1))
(set echoed (at (.why c) 0)))]
(print (churn 40000)) (print "\n"))
(print stalls) (print "\n")
(print echoed) (print "\n")
(print (.name (.tag registry))) (print "\n")
(print (len (.rows registry))) (print "\n")
(print (at (.rows registry) 0)) (print "\n")
;; The two that mangle alike, read after forty thousand rows of churn
;; collected over them many times.
(print (at (.d wide) 0)) (print "\n")
(print (at (.e narrow) 0)) (print "\n")
;; And the heap after a final collection, which is the leak question asked
;; rather than assumed. Everything the run built is unreachable by now
;; except the registry's vector and the handful of words it holds.
(gc-collect)
(if (< (gc-count) 2000) (print "bounded\n") (print "LEAKED\n"))))