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.
141 lines
6.4 KiB
Plaintext
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"))))
|