i64->bytes and f64->bytes allocate from a growing temp allocator that (free-temp) releases once a frame and a dev build's agent poll wipes, so a number drawn every frame no longer leaks

This commit is contained in:
Joseph Ferano 2026-09-25 13:07:21 +07:00
parent 042c969e30
commit 02e4fef830
16 changed files with 253 additions and 34 deletions

View File

@ -1271,12 +1271,10 @@ lowering buffer annotates all four sections, the two =llc= ones from a =--debug=
copy of the IR. Rules out writing a disassembler, and reading the source off disk copy of the IR. Rules out writing a disassembler, and reading the source off disk
at disassembly time. at disassembly time.
** NEXT A temporary allocator, wiped each frame ** DONE A temporary allocator, wiped each frame
Decided 2026-09-25: Odin's context.temp_allocator. i64->bytes, f64->bytes and CLOSED: [2026-09-25]
other quick formatting allocate from it, so a number drawn every frame no longer =i64->bytes= and =f64->bytes= allocate from context/temp, which grows rather than failing. A
leaks from the default allocator. A dev build wipes it at each frame boundary; dev build wipes it at a top-level agent poll, never while stopped or inside a thunk.
otherwise the program calls (free-temp) once per frame. Text kept past the frame
is cloned.
* Runtime * Runtime
@ -1534,7 +1532,7 @@ cosmetic.
** DONE A formatted number outlives its frame ** DONE A formatted number outlives its frame
CLOSED: [2026-09-25] CLOSED: [2026-09-25]
=i64->bytes= and =f64->bytes= copy their text into the context allocator; the prelude and =i64->bytes= and =f64->bytes= copy their text into the temp allocator; the prelude and
the printer keep the frame slot. Rules out refusing the escape, which needs flow tracking. the printer keep the frame slot. Rules out refusing the escape, which needs flow tracking.
** DONE A shift count is bounded two different ways ** DONE A shift count is bounded two different ways

View File

@ -4178,7 +4178,7 @@ It takes a `(Ptr (Vec u8))` and not a `(Vec u8)`, and that is not style: a `Vec`
builder would be consumed by its first append and refused on the second. builder would be consumed by its first append and refused on the second.
`append-i64` and `append-f64` append a number's text to a builder. `i64->bytes` and `f64->bytes` render into a frame `append-i64` and `append-f64` append a number's text to a builder. `i64->bytes` and `f64->bytes` render into a frame
slot and, outside the prelude, copy the text into the context allocator so it outlives the frame; inside the prelude slot and, outside the prelude, copy the text into the temp allocator so it outlives the frame; inside the prelude
they answer the slot, and these two copy it into the builder, so appending a number allocates nothing beyond the they answer the slot, and these two copy it into the builder, so appending a number allocates nothing beyond the
builder's own growth. `strings.flan` puts two integers and a float on one line. builder's own growth. `strings.flan` puts two integers and a float on one line.

View File

@ -158,7 +158,7 @@ face says.")
"zeroed" "filled" "dead-beef" "zeroed" "filled" "dead-beef"
;; allocators ;; allocators
"make-allocator" "allocator-from" "allocator" "heap-allocator" "make-allocator" "allocator-from" "allocator" "heap-allocator"
"arena-new" "arena-destroy" "free-all" "can-free?" "can-free-all?" "arena-new" "arena-destroy" "free-all" "free-temp" "can-free?" "can-free-all?"
"alloc-epoch" "alloc-id" "alloc-budget" "set-alloc-budget" "alloc-epoch" "alloc-id" "alloc-budget" "set-alloc-budget"
"alloc-live-blocks" "with-allocator" "alloc-live-blocks" "with-allocator"
;; Vec ;; Vec

View File

@ -65,4 +65,8 @@
rl/black)))) rl/black))))
(rl/draw-text "touch the screen at multiple locations to get multiple balls" (rl/draw-text "touch the screen at multiple locations to get multiple balls"
10 10 20 rl/darkgray))))) 10 10 20 rl/darkgray))
;; The numbers drawn this frame were formatted into the temp allocator;
;; this hands that memory back once the frame is drawn.
(free-temp))))

View File

@ -190,4 +190,8 @@
(rl/draw-text "button: " 10 34 20 rl/lightgray) (rl/draw-text "button: " 10 34 20 rl/lightgray)
(rl/draw-text (string (i64->bytes (i64 pressed))) (rl/draw-text (string (i64->bytes (i64 pressed)))
(+ 10 (rl/measure-text "button: " 20)) 34 20 (+ 10 (rl/measure-text "button: " 20)) 34 20
rl/lightgray))))) rl/lightgray))
;; The number drawn this frame was formatted into the temp allocator;
;; this hands that memory back once the frame is drawn.
(free-temp))))

View File

@ -24,13 +24,10 @@
;;;; a `char *` into a rotating static buffer, which `declare-c` refuses by ;;;; a `char *` into a rotating static buffer, which `declare-c` refuses by
;;;; name anyway. ;;;; name anyway.
;;;; ;;;;
;;;; ONE TRAP, and it is the reason every function below is written as a strict ;;;; `i64->bytes` and `f64->bytes` put their text in the temp allocator, where
;;;; sequence of format-draw-measure rather than as a let of several pieces: ;;;; it lasts until the next `(free-temp)`. A program drawing these every frame
;;;; `i64->bytes` and `f64->bytes` both write into a single shared static ;;;; calls `(free-temp)` once per frame, after drawing, and clones any text it
;;;; buffer in the runtime, overwritten by the next such call. `(string ...)` ;;;; keeps longer.
;;;; does not copy it. So a number must be *drawn before the next one is
;;;; formatted* — holding two at once is wrong pixels with no crash and no
;;;; diagnostic.
;;;; ;;;;
;;;; Everything here needs a window: `measure-text` answers 0 for every string ;;;; Everything here needs a window: `measure-text` answers 0 for every string
;;;; until init-window has loaded the default font, and a zero advance would ;;;; until init-window has loaded the default font, and a zero advance would

View File

@ -7868,6 +7868,9 @@ and named_call ?(qualified = false) ctx ~want loc name args =
(* One of spec-memory.md's two release points. It takes the source location (* One of spec-memory.md's two release points. It takes the source location
as a string so that an allocator with no region to release names the site as a string so that an allocator with no region to release names the site
rather than the runtime. *) rather than the runtime. *)
| "free-temp" ->
arity ctx loc name 0 args;
expect ctx loc ~want (rt loc Types.Unit "flan_free_temp" [])
| "free-all" -> | "free-all" ->
arity ctx loc name 1 args; arity ctx loc name 1 args;
let a = alloc_value ctx loc (List.hd args) in let a = alloc_value ctx loc (List.hd args) in
@ -9165,9 +9168,10 @@ and named_call ?(qualified = false) ctx ~want loc name args =
arity ctx loc name 1 args; arity ctx loc name 1 args;
prim Tast.BytesToI64 (Types.Int Types.I64) [ byte_slice ctx (List.hd args) ] prim Tast.BytesToI64 (Types.Int Types.I64) [ byte_slice ctx (List.hd args) ]
(* The rendered text is copied out of the frame slot [to_bytes] renders into (* The rendered text is copied out of the frame slot [to_bytes] renders into
and into a block from the context allocator, so the slice outlives the and into the temp allocator, so the slice outlives the frame: a function
frame: a function may return one and a Vec may hold one. The block lives may return one and a Vec may hold one, until the next (free-temp). A
until that allocator's free-all or destroy, the same as (bytes s)'s. number drawn every frame is then reclaimed every frame rather than leaked
from the context allocator; text kept longer is cloned.
The prelude is answered with the slot itself. Its callers — append-i64, The prelude is answered with the slot itself. Its callers — append-i64,
append-f64, format-f64, gensym — each copy the bytes into a Vec before append-f64, format-f64, gensym — each copy the bytes into a Vec before
@ -9186,7 +9190,7 @@ and named_call ?(qualified = false) ctx ~want loc name args =
expect ctx loc ~want expect ctx loc ~want
(if String.equal loc.Loc.file Prelude.file then slot (if String.equal loc.Loc.file Prelude.file then slot
else dup_elems ctx loc (Types.Int Types.U8) slot else dup_elems ctx loc (Types.Int Types.U8) slot
(allocator_arg ctx loc [])) (rt loc raw_alloc "flan_context_temp" []))
| "write-stdout" -> | "write-stdout" ->
arity ctx loc name 1 args; arity ctx loc name 1 args;
prim Tast.WriteStdout Types.Unit [ byte_slice ctx (List.hd args) ] prim Tast.WriteStdout Types.Unit [ byte_slice ctx (List.hd args) ]
@ -10599,6 +10603,11 @@ let builtins : (string * string * string) list =
("arena-destroy", "arena-destroy [Allocator] ()", ("arena-destroy", "arena-destroy [Allocator] ()",
"Hands the arena's pages back to the system, which free-all \ "Hands the arena's pages back to the system, which free-all \
deliberately does not."); deliberately does not.");
("free-temp", "free-temp [] ()",
"Releases everything in the temp allocator, context/temp — where \
i64->bytes and f64->bytes put their text. Called once a frame; a dev \
build also does it at every frame boundary the agent polls at. Text \
kept past the frame is cloned out first.");
("free-all", "free-all [Allocator] ()", ("free-all", "free-all [Allocator] ()",
"Releases everything the allocator holds and bumps its epoch, keeping \ "Releases everything the allocator holds and bumps its epoch, keeping \
the capacity. It traps rather than quietly doing nothing when there is \ the capacity. It traps rather than quietly doing nothing when there is \
@ -10773,12 +10782,11 @@ let builtins : (string * string * string) list =
("bytes->i64", "bytes->i64 [[u8]] i64", ("bytes->i64", "bytes->i64 [[u8]] i64",
"Parses an integer out of the bytes."); "Parses an integer out of the bytes.");
("f64->bytes", "f64->bytes [f64] [u8]", ("f64->bytes", "f64->bytes [f64] [u8]",
"The number's text, in a frame slot belonging to this call site — so \ "The number's text, %g, in the temp allocator: it lasts until the next \
two of them can be held at once, and neither survives its frame. Copy \ (free-temp). Clone it to keep it longer.");
the bytes to keep one.");
("i64->bytes", "i64->bytes [i64] [u8]", ("i64->bytes", "i64->bytes [i64] [u8]",
"The number's text, in a frame slot belonging to this call site; it \ "The number's text, in the temp allocator: it lasts until the next \
does not survive the frame."); (free-temp). Clone it to keep it longer.");
("write-stdout", "write-stdout [[u8]] ()", ("write-stdout", "write-stdout [[u8]] ()",
"Writes the bytes to standard output exactly as given: no newline and \ "Writes the bytes to standard output exactly as given: no newline and \
no formatting."); no formatting.");

View File

@ -4458,6 +4458,7 @@ declare void @flan_stale_call(ptr, ptr, ptr, ptr, ptr) cold
declare ptr @flan_context_allocator() declare ptr @flan_context_allocator()
declare ptr @flan_context_use(ptr, i64) declare ptr @flan_context_use(ptr, i64)
declare void @flan_context_value(ptr) declare void @flan_context_value(ptr)
declare void @flan_free_temp()
declare void @flan_alloc_seal(ptr, ptr) declare void @flan_alloc_seal(ptr, ptr)
declare ptr @flan_alloc_use(ptr, ptr, i64) declare ptr @flan_alloc_use(ptr, ptr, i64)
declare ptr @flan_context_temp() declare ptr @flan_context_temp()

View File

@ -1680,7 +1680,7 @@ let source = {flan|
(push (deref b) (at s i)))) (push (deref b) (at s i))))
;; The two number appends. Outside the prelude i64->bytes and f64->bytes copy ;; The two number appends. Outside the prelude i64->bytes and f64->bytes copy
;; their text into the context allocator; inside it they answer a view of the ;; their text into the temp allocator; inside it they answer a view of the
;; frame slot they render into (check.ml, the "i64->bytes" arm), so these ;; frame slot they render into (check.ml, the "i64->bytes" arm), so these
;; append the text without an allocation per number. ;; append the text without an allocation per number.
(defn append-i64 [b (Ptr (Vec u8)) n i64] () (defn append-i64 [b (Ptr (Vec u8)) n i64] ()

View File

@ -292,7 +292,7 @@ void flan_condition_stacks_reset(void) {
* to see, because the read was inside a buffer that was perfectly alive. * to see, because the read was inside a buffer that was perfectly alive.
* *
* The slice points into the caller's frame, so the checker copies it into the * The slice points into the caller's frame, so the checker copies it into the
* context allocator for i64->bytes and f64->bytes, whose results may outlive * temp allocator for i64->bytes and f64->bytes, whose results may outlive
* the frame. The printer writes each one out at once and takes no copy. The * the frame. The printer writes each one out at once and takes no copy. The
* size is agreed with check.ml, which allocates the slot — grep * size is agreed with check.ml, which allocates the slot — grep
* FLAN_NUM_BYTES there before changing it here. */ * FLAN_NUM_BYTES there before changing it here. */
@ -1433,13 +1433,65 @@ static flan_allocator flan_heap = {
* The epoch is bumped either way: the pages are the same but every container * The epoch is bumped either way: the pages are the same but every container
* made before the reset is invalid, which is the whole point of the trap. */ * made before the reset is invalid, which is the whole point of the trap. */
/* A block the temp arena grew out of. It still holds what was allocated from
* it before the growth, so it is kept until the next free-all. */
typedef struct flan_chunk {
struct flan_chunk *next;
uint8_t *base;
int64_t cap;
} flan_chunk;
typedef struct flan_arena { typedef struct flan_arena {
uint8_t *base; uint8_t *base;
int64_t cap; int64_t cap;
int64_t offset; int64_t offset;
int64_t peak; int64_t peak;
/* The temp arena only: a request that does not fit starts a bigger block
* instead of failing, and [old] keeps the ones it outgrew until free-all,
* which keeps the biggest. A program that formats more text between two
* (free-temp)s than the first block holds gets more room rather than
* StorageExhausted, and one that never calls it grows the way the heap
* would. A program's own arena-new keeps its fixed capacity. */
int grow;
flan_chunk *old;
} flan_arena; } flan_arena;
/* Retire the current block and start one that fits [need]. */
static int flan_arena_grow(flan_arena *ar, int64_t need) {
flan_chunk *c;
uint8_t *b;
int64_t cap = ar->cap;
while (cap < need) {
if (cap > ((int64_t)1 << 40)) { cap = need; break; }
cap *= 2;
}
if (cap == ar->cap) cap *= 2;
c = (flan_chunk *)malloc(sizeof *c);
if (!c) return 0;
b = (uint8_t *)malloc((size_t)cap);
if (!b) { free(c); return 0; }
c->base = ar->base;
c->cap = ar->cap;
c->next = ar->old;
ar->old = c;
ar->base = b;
ar->cap = cap;
ar->offset = 0;
return 1;
}
/* The blocks the arena outgrew, handed back; the dev registry and memcheck
* are told each one died, the same as the live block. */
static void flan_arena_drop_old(flan_arena *ar) {
while (ar->old) {
flan_chunk *c = ar->old;
ar->old = c->next;
flan_dev_reg_dead_range(c->base, c->cap);
free(c->base);
free(c);
}
}
static int64_t flan_align_up(int64_t x, int64_t a) { static int64_t flan_align_up(int64_t x, int64_t a) {
if (a <= 1) return x; if (a <= 1) return x;
return (x + a - 1) / a * a; return (x + a - 1) / a * a;
@ -1456,6 +1508,11 @@ static void *flan_arena_proc(flan_allocator *a, int32_t mode, void *p,
if (align < 1) align = 1; if (align < 1) align = 1;
start = flan_align_up(ar->offset, align); start = flan_align_up(ar->offset, align);
end = start + size; end = start + size;
if ((end > ar->cap || end < start) && ar->grow && size < ((int64_t)1 << 40)
&& flan_arena_grow(ar, size + align)) {
start = flan_align_up(ar->offset, align);
end = start + size;
}
if (end > ar->cap || end < start) return NULL; /* exhausted, or overflow */ if (end > ar->cap || end < start) return NULL; /* exhausted, or overflow */
ar->offset = end; ar->offset = end;
if (end > ar->peak) ar->peak = end; if (end > ar->peak) ar->peak = end;
@ -1506,6 +1563,7 @@ static void *flan_arena_proc(flan_allocator *a, int32_t mode, void *p,
The whole capacity rather than [0, offset): everything past the offset The whole capacity rather than [0, offset): everything past the offset
is equally reusable and equally stale, and two calls would only be is equally reusable and equally stale, and two calls would only be
cheaper if the second could be skipped. */ cheaper if the second could be skipped. */
flan_arena_drop_old(ar);
flan_dev_reg_dead_range(ar->base, ar->cap); flan_dev_reg_dead_range(ar->base, ar->cap);
FLAN_VG_MAKE_MEM_UNDEFINED(ar->base, ar->cap); FLAN_VG_MAKE_MEM_UNDEFINED(ar->base, ar->cap);
ar->offset = 0; ar->offset = 0;
@ -1563,11 +1621,29 @@ void flan_context_value(flan_alloc_value *out) { *out = flan_ctx; }
* which never touches it has not paid for a heap. */ * which never touches it has not paid for a heap. */
#define FLAN_TEMP_DEFAULT (1 << 20) #define FLAN_TEMP_DEFAULT (1 << 20)
/* Odin's context.temp_allocator: where i64->bytes and f64->bytes put their
* text, and what context/temp names. It grows rather than failing (see
* flan_arena's [grow]), and it is wiped by (free-temp), which a program calls
* once a frame — and, in a dev build, by the agent at every frame boundary it
* polls at. Text kept past the frame is cloned out of it first. */
flan_allocator *flan_context_temp(void) { flan_allocator *flan_context_temp(void) {
if (!flan_ctx_tmp) flan_ctx_tmp = flan_arena_new(FLAN_TEMP_DEFAULT); if (!flan_ctx_tmp) {
flan_ctx_tmp = flan_arena_new(FLAN_TEMP_DEFAULT);
if (flan_ctx_tmp) ((flan_arena *)flan_ctx_tmp->data)->grow = 1;
}
return flan_ctx_tmp; return flan_ctx_tmp;
} }
/* (free-temp): everything in the temp arena dies, the same release free-all
* is, and a container made from it traps on its next use. Nothing to do when
* nothing has made it yet. */
void flan_free_temp(void) {
flan_allocator *a = flan_ctx_tmp;
if (!a) return;
a->proc(a, FLAN_ALLOC_FREE_ALL, NULL, 0, 0, 0);
a->epoch++;
}
/* with-allocator hands in two values in its own frame: [0] the one to install /* with-allocator hands in two values in its own frame: [0] the one to install
* and [1] where the displaced one is kept. It gets the same pointer back and * and [1] where the displaced one is kept. It gets the same pointer back and
* passes it to restore — on the normal path and on the transfer path both — * passes it to restore — on the normal path and on the transfer path both —
@ -1660,6 +1736,7 @@ void flan_arena_destroy(flan_allocator *a) {
if (a == flan_ctx.rec) { flan_ctx.rec = &flan_heap; flan_ctx.inc = 0; } if (a == flan_ctx.rec) { flan_ctx.rec = &flan_heap; flan_ctx.inc = 0; }
if (a == flan_ctx_tmp) flan_ctx_tmp = NULL; if (a == flan_ctx_tmp) flan_ctx_tmp = NULL;
a->epoch++; a->epoch++;
flan_arena_drop_old(ar);
flan_dev_reg_dead_range(ar->base, ar->cap); flan_dev_reg_dead_range(ar->base, ar->cap);
free(ar->base); free(ar->base);
free(ar); free(ar);

View File

@ -489,9 +489,11 @@ There are exactly two release points, and neither of them is a scope.
error. error.
2. **Region release** — `(free-all a)` on an allocator, which releases 2. **Region release** — `(free-all a)` on an allocator, which releases
everything made from it at once, including storage reachable from bindings everything made from it at once, including storage reachable from bindings
that are still in scope. The per-frame `(free-all context/temp)` at the top that are still in scope. The per-frame `(free-temp)`, which is
of a game loop *is* the frame arena, and it is the normal way arena-tier `(free-all context/temp)`, *is* the frame arena, and it is the normal way
storage dies. arena-tier storage dies. `i64->bytes` and `f64->bytes` put their text there,
and a dev build also wipes it at every frame boundary the agent polls at.
The temp allocator grows rather than running out.
**Nothing is released at scope exit.** Not at the end of a `let`, not at the end **Nothing is released at scope exit.** Not at the end of a `let`, not at the end
of a function, not at the end of a `with-allocator` body. `with-allocator` of a function, not at the end of a `with-allocator` body. `with-allocator`

View File

@ -0,0 +1,18 @@
;;;; The dev half of temp-frame.flan: a frame loop that polls the agent at the
;;;; top of every frame and never calls (free-temp). In a dev build the poll is
;;;; a frame boundary and wipes the temp allocator, so the registry does not
;;;; fill; the last line is whether it overflowed.
(import agent "vendor:agent")
(declare-c reg-overflowed [] i32 "flan_dev_reg_overflowed")
(defn main [args [string]] i32
(let [n (bytes->i64 (bytes-view (at args 1)))
total (i64 0)]
(dotimes [i (i32 n)]
(agent/poll)
(let [s (string (i64->bytes (i64 i)))]
(set total (+ total (i64 (length (bytes-view s)))))))
(println total)
(println (reg-overflowed)))
0)

View File

@ -0,0 +1,30 @@
;;;; A frame loop that formats numbers every frame and calls (free-temp) at the
;;;; end of each. i64->bytes and f64->bytes put their text in the temp
;;;; allocator, so the loop's memory stays flat however many frames it runs,
;;;; and in a dev build the allocation registry does not fill: each frame's
;;;; blocks die at the free-temp and their addresses are handed out again.
;;;; Frame 7's text is kept past its free-temp by cloning it into the heap,
;;;; which is how text outlives its frame.
;;;;
;;;; The argument is the frame count. A negative one runs that many frames and
;;;; never calls free-temp, which is the control: memory grows and a dev
;;;; build's registry fills. The last line is whether the registry overflowed.
(declare-c reg-overflowed [] i32 "flan_dev_reg_overflowed")
(defn main [args [string]] i32
(let [arg (bytes->i64 (bytes-view (at args 1)))
n (if (< arg 0) (- 0 arg) arg)
total (i64 0)
kept (bytes-view "")]
(dotimes [i (i32 n)]
(let [s (string (i64->bytes (i64 i)))
f (f64->bytes (+ (f64 i) 0.5))]
(set total (+ total (i64 (+ (length (bytes-view s)) (length f)))))
(when (= i 7)
(set kept (clone f (heap-allocator)))))
(when (> arg 0)
(free-temp)))
(println total)
(println (string kept))
(println (reg-overflowed)))
0)

View File

@ -8,7 +8,7 @@
;;;; No crash, no diagnostic, and nothing for a sanitizer to catch, because ;;;; No crash, no diagnostic, and nothing for a sanitizer to catch, because
;;;; every byte read was inside an object that was alive. The wrong bytes. ;;;; every byte read was inside an object that was alive. The wrong bytes.
;;;; ;;;;
;;;; The conversion's bytes are now copied into the context allocator, so a ;;;; The conversion's bytes are now copied into the temp allocator, so a
;;;; result outlives the frame that made it: the last two cases return one from ;;;; result outlives the frame that made it: the last two cases return one from
;;;; a function and push them into a Vec that outlives the loop that made them. ;;;; a function and push them into a Vec that outlives the loop that made them.
(defn numstr [n i64] string (defn numstr [n i64] string

View File

@ -908,6 +908,79 @@ let () =
clone_slice_out; clone_slice_out;
outputs ~x86:true "clone of a slice, --x86" "programs/clone-slice.flan" outputs ~x86:true "clone of a slice, --x86" "programs/clone-slice.flan"
clone_slice_out; clone_slice_out;
(* The temp allocator. A frame loop that formats numbers and calls
(free-temp) each frame stays flat from a thousand frames to a million,
and in a dev build the registry does not fill; frame 7's text survives
because it was cloned. The negative count, which never frees, is the
control that shows the check can fail. Every backend and a dev build. *)
let peak exe arg =
let o = Filename.concat scratch
(Printf.sprintf "flan-temp-%d.rss" (Unix.getpid ())) in
let code =
Sys.command
(Printf.sprintf "/usr/bin/time -f %%M -o %s %s %s > /dev/null 2>&1"
(Filename.quote o) (Filename.quote exe) arg)
in
let kb =
try int_of_string (String.trim (In_channel.with_open_bin o
In_channel.input_all))
with _ -> -1
in
(try Sys.remove o with Sys_error _ -> ());
(code, kb)
in
List.iter
(fun (x86, opt, dev, tag) ->
let exe = compile ~x86 ~opt ~dev "programs/temp-frame.flan" in
let code, text = run exe (Some "1000") in
if code <> 0 || text <> "7780\n7.5\n0\n" then begin
incr failures;
Printf.printf "FAIL a frame loop over the temp allocator%s\n\
\ got: %S (exit %d)\n" tag text code
end;
if Sys.file_exists "/usr/bin/time" then begin
let c1, small = peak exe "1000" and c2, large = peak exe "1000000" in
if c1 <> 0 || c2 <> 0 || small < 0 || large < 0
|| large - small > 4_000 then begin
incr failures;
Printf.printf
"FAIL free-temp keeps a frame loop flat%s\n\
\ got: %d KB after 1000 frames, %d KB after 1000000\n"
tag small large
end;
if dev then begin
let _, text = run exe (Some "1000000") in
if not (contains text "\n0\n") then begin
incr failures;
Printf.printf "FAIL the dev registry fills under free-temp%s\n\
\ got: %S\n" tag text
end;
let _, text = run exe (Some "-10000") in
if not (contains text "\n1\n") then begin
incr failures;
Printf.printf "FAIL the control: with no free-temp the dev \
registry never filled%s\n got: %S\n" tag text
end
end
end;
(try Sys.remove exe with Sys_error _ -> ()))
[ (false, "-O2", false, ""); (false, "-O0", false, ", -O0");
(true, "-O2", false, ", --x86"); (false, "-O2", true, ", dev");
(true, "-O2", true, ", dev --x86") ];
(* A dev build's agent poll is a frame boundary and wipes the temp
allocator itself: a loop that polls and never calls free-temp does not
fill the registry. *)
List.iter
(fun (x86, tag) ->
let exe = compile ~x86 ~dev:true "programs/temp-agent.flan" in
let code, text = run exe (Some "20000") in
if code <> 0 || text <> "88890\n0\n" then begin
incr failures;
Printf.printf "FAIL a dev agent poll wipes the temp allocator%s\n\
\ got: %S (exit %d)\n" tag text code
end;
(try Sys.remove exe with Sys_error _ -> ()))
[ (false, ""); (true, ", --x86") ];
(* The context restored by with-allocator after its arena was destroyed and (* The context restored by with-allocator after its arena was destroyed and
its record reused: the next use of the context, or of a value read its record reused: the next use of the context, or of a value read
from it, traps at its site. *) from it, traps at its site. *)

View File

@ -114,6 +114,7 @@ int64_t flan_dev_reg_by_type(int32_t live_only, int64_t *counts,
int64_t *typelens, int64_t cap, int64_t *unread); int64_t *typelens, int64_t cap, int64_t *unread);
int flan_dev_reg_enabled(void); int flan_dev_reg_enabled(void);
int flan_dev_reg_overflowed(void); int flan_dev_reg_overflowed(void);
void flan_free_temp(void);
/* A ring the listener writes and the game thread reads. One producer, one /* A ring the listener writes and the game thread reads. One producer, one
* consumer, so two atomics and no lock — the game thread must never block on * consumer, so two atomics and no lock — the game thread must never block on
@ -985,6 +986,12 @@ static void trap_stop(const uint8_t *name, int64_t namelen) {
* thread writes tail, nesting included. */ * thread writes tail, nesting included. */
int32_t flan_agent_poll(void) { int32_t flan_agent_poll(void) {
int32_t n = 0; int32_t n = 0;
/* A poll the game loop makes is a frame boundary, and a dev build wipes the
* temp allocator there, as the program's own (free-temp) would. Not a poll
* from a break loop or from inside a thunk: the stopped frames below it,
* or the thunk's own caller, may still be holding text from it. */
if (atomic_load(&depth) <= 0 && eval_boundary == NULL && flan_dev_reg_enabled())
flan_free_temp();
for (;;) { for (;;) {
unsigned t = atomic_load_explicit(&tail, memory_order_relaxed); unsigned t = atomic_load_explicit(&tail, memory_order_relaxed);
unsigned h = atomic_load_explicit(&head, memory_order_acquire); unsigned h = atomic_load_explicit(&head, memory_order_acquire);