A rendered number goes in the caller's frame, not in one buffer for the process

Every number-to-text conversion wrote into one file-static in the runtime and
answered a slice over it, and nothing copied. Two of them in one expression
printed the second number twice — no crash, no diagnostic, and nothing a
sanitizer could find, because every byte read was inside an object that was
alive. The wrong object.

The buffer is now the caller's, one frame slot per call site. The slot is
allocated in the checker rather than in either backend: a slot is a
function-lifetime location in both of them, where an x86 backend temporary is
bump-allocated and reclaimed at the end of the expression that made it — which
is the one lifetime a returned slice must outlive. Each backend gains one
pointer argument and no reasoning of its own, which is what keeps them
symmetric.

The static is gone rather than left unused, since a buffer with nothing but a
comment beside it is a loaded gun. What remains is the ordinary lifetime a
pointer into a frame has: storing one of these slices in a container that
outlives the frame, or returning it, is still a copy the caller has to make.
NEXT.md's sharp edge now says that instead of what it used to say.
This commit is contained in:
Joseph Ferano 2026-09-17 22:55:30 +07:00
parent 9d10e7edb0
commit 81b807f544
8 changed files with 192 additions and 60 deletions

32
NEXT.md
View File

@ -1321,7 +1321,7 @@ it *would* have written.
aborts. Only `SNAP_MAX`/`SNAP_NAMES` is still read rather than tested: sixty-five nested `restart-case`s are a lot aborts. Only `SNAP_MAX`/`SNAP_NAMES` is still read rather than tested: sixty-five nested `restart-case`s are a lot
of program for a clamp. `escaped[ESCAPE_MAX]` was already covered, because `println.flan` of program for a clamp. `escaped[ESCAPE_MAX]` was already covered, because `println.flan`
drives a 1100-character string through it on purpose — 1019 bytes out against a worst case of 1021 into 1024. drives a 1100-character string through it on purpose — 1019 bytes out against a worst case of 1021 into 1024.
`scratch[SCRATCH]` never sees more than 20 characters of 64. The number conversions' buffer never sees more than 20 characters of the 64 the caller supplies.
3. **Valgrind over the headless corpus, done.** `dune build --root . @valgrind` runs forty-nine programs under 3. **Valgrind over the headless corpus, done.** `dune build --root . @valgrind` runs forty-nine programs under
memcheck, twelve of them again with `--no-bounds-checks`, in 91 seconds including the compiles — on a warm object memcheck, twelve of them again with `--no-bounds-checks`, in 91 seconds including the compiles — on a warm object
cache; 162s was measured on a cold one, which is the compiles and not the sweep. Clean. It needs no cache; 162s was measured on a cold one, which is the compiles and not the sweep. Clean. It needs no
@ -2688,26 +2688,26 @@ memcheck sweep (`@valgrind`), whose alarm is looser at 5400s because memcheck is
## Sharp edges ## Sharp edges
- **Two formatted numbers cannot be held at once.** `flan_i64_to_bytes`, `flan_f64_to_bytes` and `flan_u64_to_bytes` - **A formatted number does not outlive its frame.** This entry used to say that two of them could not be held at
all write into one `static char scratch[64]` — "rendered text lives here until the next call", flan_rt.c:184 — and once, because `flan_i64_to_bytes`, `flan_f64_to_bytes` and `flan_u64_to_bytes` all wrote into one file-static buffer
`(string b)` does not copy. So and `(string b)` does not copy. That part is fixed: the buffer is the caller's now, one frame slot per call site,
allocated by the checker (`check.ml`, `to_bytes`) so that both backends get a function-lifetime location without
either of them reasoning about lifetimes. `test/programs/two-numbers.flan` is the case that used to print `22 22`.
What is left is the lifetime, and it is the ordinary one a pointer into a frame has:
``` ```
(let [a (string (i64->bytes 11)) (defn label [n i64] string (string (i64->bytes n))) ; a view of a frame that is gone
b (string (i64->bytes 22))] (push lines (string (i64->bytes n))) ; every element aliases one slot
(print a) (print " ") (println b)) ; => 22 22
``` ```
`a` is 11 and prints 22. No crash and no diagnostic. This is not new — the `[u8]` already aliased — but a `string` Neither is refused today. The first returns a view of storage the return has just released; the second pushes
reads as more value-like and invites exactly this. Format, draw, measure, then format the next one; `digits.flan` ptr+len, not the bytes, and a slot reused on the next turn of a loop leaves every element reading as the last
sequences itself strictly for this reason. `rl/draw-text` is safe because the shim's `flan_shim_cstr` copies out of number. Copy the bytes for anything that outlives the expression that made them — which is what the prelude's
ptr+len before the call. `append-i64!` and `append-f64!` do, and the reason that shape exists: they copy into a `(Vec u8)`, so a builder
holds as many rendered numbers as it likes, and `format-f64` answers a `Vec` rather than a view.
**The prelude now has the shape that does not have this problem**, and it is the reason that shape exists. `rl/draw-text` is safe for a third reason: the shim's `flan_shim_cstr` copies out of ptr+len before the call.
`append-i64!` and `append-f64!` copy out of the scratch buffer into a `(Vec u8)` before returning, so a builder
holds as many rendered numbers as it likes, and `format-f64` answers a `Vec` rather than a view. The hazard is
unchanged for anyone calling `i64->bytes` directly — nothing was taken away — but a caller assembling a line of
text has a way not to meet it.
- **Writing through a string literal is undefined, and the two build modes - **Writing through a string literal is undefined, and the two build modes
disagree about how.** `(let [s (bytes "Hi")] (set (at s 0) \h))` stores into disagree about how.** `(let [s (bytes "Hi")] (set (at s 0) \h))` stores into

View File

@ -1186,6 +1186,38 @@ let align_of loc t = mk loc (Types.Int Types.I64) (Tast.Prim (Tast.AlignOf t, []
let addr_of loc (e : Tast.expr) = let addr_of loc (e : Tast.expr) =
mk loc (Types.Ptr e.Tast.ty) (Tast.Prim (Tast.AddrOf, [ e ])) mk loc (Types.Ptr e.Tast.ty) (Tast.Prim (Tast.AddrOf, [ e ]))
(* ── Where a rendered number's bytes live ──────────────────────────────
The three number-to-text conversions used to answer a slice into one static
buffer in the runtime, shared by every call in the process, and nothing
copied it: (print a) (print b) over two of them printed the second number
twice. No crash and nothing for a sanitizer to find, because the read was
inside a buffer that was perfectly alive the wrong bytes, alive.
The buffer is now the caller's, one frame slot per call site, and it is
allocated here rather than in either backend on purpose: a slot is a
function-lifetime frame location in both of them an entry-block alloca in
[Emit], a prologue-allocated offset in [X86] where a backend temporary in
[X86] is bump-allocated and reclaimed at the end of the expression that made
it, which is exactly the lifetime a returned slice must outlive. Doing it
once here also keeps the two backends symmetric by construction: each gains
one pointer argument and no lifetime reasoning of its own.
64 bytes is agreed with flan_rt.c's FLAN_NUM_BYTES, which clamps the length
it publishes to it. The zeroing the [Let] does is one 64-byte clear beside
an snprintf. *)
let num_bytes = 64L
let to_bytes ctx loc pr (x : Tast.expr) =
let bty = Types.Array (num_bytes, Types.Int Types.U8) in
let bslice = Types.Slice (Types.Int Types.U8) in
let s = fresh_slot ctx bty in
mk loc bslice
(Tast.Let
([ (s, mk loc bty (Tast.Zero bty)) ],
[ mk loc bslice
(Tast.Prim (pr, [ x; addr_of loc (mk loc bty (Tast.Local s)) ])) ]))
(* ── The region requirement, emitted ─────────────────────────────────── (* ── The region requirement, emitted ───────────────────────────────────
spec-memory.md's arena rule, and the whole of what replaced the three spec-memory.md's arena rule, and the whole of what replaced the three
refusals a container of owning elements used to meet at its *type*. The refusals a container of owning elements used to meet at its *type*. The
@ -5031,11 +5063,13 @@ and named_call ctx ~want loc name args =
Provenance is still what the other direction needs; nothing here Provenance is still what the other direction needs; nothing here
depends on having it. depends on having it.
The one sharp edge is not new but is easier to trip over now: the slice The sharp edge left here is one of lifetime and no longer one of sharing:
that i64->bytes / f64->bytes / u64->bytes answer is a view into one shared the slice that i64->bytes / f64->bytes / u64->bytes answer is a view into
static buffer in the runtime, overwritten by the next such call. Calling a frame slot belonging to *that call site* (see [to_bytes]), so two of
it a string does not copy it. Use it before formatting the next number; them can be held at once and the text of one survives the making of the
you cannot hold two at once. *) next. What it does not survive is its frame calling it a string does not
copy it, so storing one in a container or returning it hands back a view
of storage that has been reused. Copy the bytes for that. *)
| "string" -> | "string" ->
arity loc name 1 args; arity loc name 1 args;
prim Tast.StrOfBytes Types.String [ byte_slice ctx (List.hd args) ] prim Tast.StrOfBytes Types.String [ byte_slice ctx (List.hd args) ]
@ -5047,12 +5081,14 @@ and named_call ctx ~want loc name 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) ]
| "f64->bytes" -> | "f64->bytes" ->
arity loc name 1 args; arity loc name 1 args;
prim Tast.F64ToBytes (Types.Slice (Types.Int Types.U8)) expect loc ~want
[ check ctx ~want:(Types.Float Types.F64) (List.hd args) ] (to_bytes ctx loc Tast.F64ToBytes
(check ctx ~want:(Types.Float Types.F64) (List.hd args)))
| "i64->bytes" -> | "i64->bytes" ->
arity loc name 1 args; arity loc name 1 args;
prim Tast.I64ToBytes (Types.Slice (Types.Int Types.U8)) expect loc ~want
[ check ctx ~want:(Types.Int Types.I64) (List.hd args) ] (to_bytes ctx loc Tast.I64ToBytes
(check ctx ~want:(Types.Int Types.I64) (List.hd args)))
| "write-stdout" -> | "write-stdout" ->
arity loc name 1 args; arity 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) ]
@ -5129,10 +5165,20 @@ and named_call ctx ~want loc name args =
else else
let bslice = Types.Slice (Types.Int Types.U8) in let bslice = Types.Slice (Types.Int Types.U8) in
let write x = mk loc Types.Unit (Tast.Prim (Tast.WriteStdout, [ x ])) in let write x = mk loc Types.Unit (Tast.Prim (Tast.WriteStdout, [ x ])) in
let conv pr x = mk loc bslice (Tast.Prim (pr, [ x ])) in (* One frame slot per conversion the printer emits, which is what
[to_bytes] is for. The printer writes each number out before making the
next, so a shared buffer would in fact have served it but the slot is
what the node now carries, and a printer that assembled its own buffer
would be a second answer to the same question. [escape] is the one that
still renders into a static: it is reachable from nowhere but here, and
its 1KB buffer per printed string field is a frame cost with no bug
behind it. Said here so the asymmetry is a decision and not an
oversight. *)
let conv pr x = to_bytes ctx loc pr x in
let emitter : Render.emitter = let emitter : Render.emitter =
{ Render.ebytes = write; { Render.ebytes = write;
estr = (fun x -> write (conv Tast.EscapeBytes x)); estr = (fun x -> write (mk loc bslice
(Tast.Prim (Tast.EscapeBytes, [ x ]))));
ei64 = (fun x -> write (conv Tast.I64ToBytes x)); ei64 = (fun x -> write (conv Tast.I64ToBytes x));
eu64 = (fun x -> write (conv Tast.U64ToBytes x)); eu64 = (fun x -> write (conv Tast.U64ToBytes x));
ef64 = (fun x -> write (conv Tast.F64ToBytes x)) } ef64 = (fun x -> write (conv Tast.F64ToBytes x)) }

View File

@ -2169,9 +2169,12 @@ and prim f (e : Tast.expr) (p : Tast.prim) (args : Tast.expr list) =
| Tast.StrOfBytes, [ x ] -> value f x | Tast.StrOfBytes, [ x ] -> value f x
| Tast.BytesToF64, [ x ] -> shim_in f "@flan_bytes_to_f64" "double" x | Tast.BytesToF64, [ x ] -> shim_in f "@flan_bytes_to_f64" "double" x
| Tast.BytesToI64, [ x ] -> shim_in f "@flan_bytes_to_i64" "i64" x | Tast.BytesToI64, [ x ] -> shim_in f "@flan_bytes_to_i64" "i64" x
| Tast.F64ToBytes, [ x ] -> shim_out f "@flan_f64_to_bytes" x (* The second argument is the caller's buffer — a frame slot the checker gave
| Tast.I64ToBytes, [ x ] -> shim_out f "@flan_i64_to_bytes" x this call site, so that two conversions in one expression are two buffers.
| Tast.U64ToBytes, [ x ] -> shim_out f "@flan_u64_to_bytes" x See check.ml's [to_bytes]. *)
| Tast.F64ToBytes, [ x; b ] -> shim_out f "@flan_f64_to_bytes" x b
| Tast.I64ToBytes, [ x; b ] -> shim_out f "@flan_i64_to_bytes" x b
| Tast.U64ToBytes, [ x; b ] -> shim_out f "@flan_u64_to_bytes" x b
| Tast.EscapeBytes, [ x ] -> shim_in_out f "@flan_escape_bytes" x | Tast.EscapeBytes, [ x ] -> shim_in_out f "@flan_escape_bytes" x
| Tast.WriteStdout, [ x ] -> | Tast.WriteStdout, [ x ] ->
let p, n = explode f x in let p, n = explode f x in
@ -2263,10 +2266,11 @@ and shim_in f name ret x =
ins f "%s = call %s %s(ptr %s, i64 %s)" t ret name p n; ins f "%s = call %s %s(ptr %s, i64 %s)" t ret name p n;
t t
and shim_out f name (x : Tast.expr) = and shim_out f name (x : Tast.expr) (buf : Tast.expr) =
let v = value f x in let v = value f x in
let b = value f buf in
let tmp = alloca f (Types.Slice (Types.Int Types.U8)) in let tmp = alloca f (Types.Slice (Types.Int Types.U8)) in
ins f "call void %s(%s %s, ptr %s)" name (ll x.Tast.ty) v tmp; ins f "call void %s(%s %s, ptr %s, ptr %s)" name (ll x.Tast.ty) v b tmp;
load f tmp (Types.Slice (Types.Int Types.U8)) load f tmp (Types.Slice (Types.Int Types.U8))
(* Slice in, slice out: [shim_in] returns a scalar and [shim_out] takes one, so (* Slice in, slice out: [shim_in] returns a scalar and [shim_out] takes one, so
@ -2703,9 +2707,9 @@ declare void @flan_write_stdout(ptr, i64)
declare void @flan_exit(i32) declare void @flan_exit(i32)
declare double @flan_bytes_to_f64(ptr, i64) declare double @flan_bytes_to_f64(ptr, i64)
declare i64 @flan_bytes_to_i64(ptr, i64) declare i64 @flan_bytes_to_i64(ptr, i64)
declare void @flan_f64_to_bytes(double, ptr) declare void @flan_f64_to_bytes(double, ptr, ptr)
declare void @flan_i64_to_bytes(i64, ptr) declare void @flan_i64_to_bytes(i64, ptr, ptr)
declare void @flan_u64_to_bytes(i64, ptr) declare void @flan_u64_to_bytes(i64, ptr, ptr)
declare void @flan_escape_bytes(ptr, i64, ptr) declare void @flan_escape_bytes(ptr, i64, ptr)
declare void @flan_handler_push(ptr) declare void @flan_handler_push(ptr)
declare void @flan_handler_pop(ptr) declare void @flan_handler_pop(ptr)

View File

@ -2668,9 +2668,12 @@ and prim f (e : Tast.expr) (p : Tast.prim) (args : Tast.expr list) dst =
(* string and [u8] are the same two words, so both directions are views and (* string and [u8] are the same two words, so both directions are views and
not copies the same non-instruction [emit.ml] emits. *) not copies the same non-instruction [emit.ml] emits. *)
| (Tast.Bytes | Tast.StrOfBytes), [ a ] -> lower f a dst | (Tast.Bytes | Tast.StrOfBytes), [ a ] -> lower f a dst
| Tast.I64ToBytes, [ a ] -> shim_out f "flan_i64_to_bytes" a dst (* [b] is the caller's buffer, a frame slot the checker gave this call site.
| Tast.U64ToBytes, [ a ] -> shim_out f "flan_u64_to_bytes" a dst See check.ml's [to_bytes]: a backend temporary here would be reclaimed at
| Tast.F64ToBytes, [ a ] -> shim_out f "flan_f64_to_bytes" a dst the end of this expression and the slice outlives it. *)
| Tast.I64ToBytes, [ a; b ] -> shim_out f "flan_i64_to_bytes" a b dst
| Tast.U64ToBytes, [ a; b ] -> shim_out f "flan_u64_to_bytes" a b dst
| Tast.F64ToBytes, [ a; b ] -> shim_out f "flan_f64_to_bytes" a b dst
| Tast.EscapeBytes, [ a ] -> | Tast.EscapeBytes, [ a ] ->
let l = eval f a in let l = eval f a in
slice_in_out f "flan_escape_bytes" l dst slice_in_out f "flan_escape_bytes" l dst
@ -2718,17 +2721,23 @@ and prim f (e : Tast.expr) (p : Tast.prim) (args : Tast.expr list) dst =
| Tast.Cast target, [ a ] -> cast f a target dst | Tast.Cast target, [ a ] -> cast f a target dst
| _ -> unsupported "primitive with %d arguments" (List.length args) | _ -> unsupported "primitive with %d arguments" (List.length args)
(* [void shim(T, flan_slice *out)] — a scalar in, a slice written through a (* [void shim(T, uint8_t *buf, flan_slice *out)] — a scalar in, text rendered
hidden out pointer. The three number printers, and nothing else. *) into the caller's buffer, and a slice over it written through a hidden out
and shim_out f sym (a : Tast.expr) dst = pointer. The three number printers, and nothing else.
Both operands are evaluated before any argument register is loaded:
evaluating one is arbitrary code and would otherwise overwrite the other. *)
and shim_out f sym (a : Tast.expr) (buf : Tast.expr) dst =
let l = eval f a in let l = eval f a in
let lb = eval f buf in
if is_float a.Tast.ty then begin if is_float a.Tast.ty then begin
fload f.b ~dst:xmm0 ~mm:(lmem f l ~scratch:r11) ~f64:(f64_of a.Tast.ty); fload f.b ~dst:xmm0 ~mm:(lmem f l ~scratch:r11) ~f64:(f64_of a.Tast.ty);
addr_into f ~reg:rdi dst; load_loc f ~reg:rdi lb buf.Tast.ty;
addr_into f ~reg:rsi dst;
imm_into f ~reg:rax 1L imm_into f ~reg:rax 1L
end else begin end else begin
load_loc f ~reg:rdi l a.Tast.ty; load_loc f ~reg:rdi l a.Tast.ty;
addr_into f ~reg:rsi dst; load_loc f ~reg:rsi lb buf.Tast.ty;
addr_into f ~reg:rdx dst;
imm_into f ~reg:rax 0L imm_into f ~reg:rax 0L
end; end;
call_sym f.b sym call_sym f.b sym

View File

@ -233,21 +233,35 @@ void flan_condition_stacks_reset(void) {
/* The conversions are *text*: bytes->f64 parses "12.5", f64->bytes renders it. /* The conversions are *text*: bytes->f64 parses "12.5", f64->bytes renders it.
* calc-me's tokenizer needs the first, the prelude's printers the second. */ * calc-me's tokenizer needs the first, the prelude's printers the second. */
#define SCRATCH 64 /* Where the rendered text goes, and who owns it.
static char scratch[SCRATCH]; /* rendered text lives here until the next call */ *
* The buffer belongs to the *caller*: the compiler gives every one of these
* call sites a frame slot of its own and passes its address, so two
* conversions in one expression are two buffers and the text of the first is
* still there while the second is made. It used to be one file-static, shared
* by every call in the process (print a) (print b) over two conversions
* printed the second number twice, with no crash and nothing for a sanitizer
* to see, because the read was inside a buffer that was perfectly alive.
*
* What this does *not* buy is storage: the slice points into the caller's
* frame, so holding one past the function that made it, or pushing it into a
* container that outlives the frame, is still the caller's problem. Copy the
* bytes for that. The size is agreed with check.ml, which allocates the slot
* grep FLAN_NUM_BYTES there before changing it here. */
#define FLAN_NUM_BYTES 64
/* snprintf returns what it *would* have written, not what it did. The three /* snprintf returns what it *would* have written, not what it did. The three
* shims below hand the result back as a slice, so taking that number at face * shims below hand the result back as a slice, so taking that number at face
* value would publish a length past the end of the buffer and every reader of * value would publish a length past the end of the caller's buffer and every
* that slice would run off it. No format here can reach 64 %g is at most 13 * reader of that slice would run off it. No format here can reach 64 %g is
* characters and %lld at most 20 so this clamp cannot fire today; it is here * at most 13 characters and %lld at most 20 so this clamp cannot fire today;
* because the distance between "cannot fire" and "reads off the end of a * it is here because the distance between "cannot fire" and "reads off the end
* static buffer" is one format string, and nothing else in the file says so. * of the frame" is one format string, and nothing else in the file says so.
* Found by reading, under a sanitizer sweep that could not have found it: * Found by reading, under a sanitizer sweep that could not have found it:
* nothing in the corpus prints a number long enough. */ * nothing in the corpus prints a number long enough. */
static int64_t fit(int n) { static int64_t fit(int n) {
if (n < 0) return 0; if (n < 0) return 0;
return n < SCRATCH ? (int64_t)n : (int64_t)(SCRATCH - 1); return n < FLAN_NUM_BYTES ? (int64_t)n : (int64_t)(FLAN_NUM_BYTES - 1);
} }
/* The length is clamped below *and* above. Above is obvious and was always /* The length is clamped below *and* above. Above is obvious and was always
@ -282,15 +296,15 @@ int64_t flan_bytes_to_i64(const uint8_t *p, int64_t n) {
/* %g so that 3.5 prints as "3.5" and not "3.500000" — calc-me's expected /* %g so that 3.5 prints as "3.5" and not "3.500000" — calc-me's expected
* output is a table of exact strings. */ * output is a table of exact strings. */
void flan_f64_to_bytes(double x, flan_slice *out) { void flan_f64_to_bytes(double x, uint8_t *buf, flan_slice *out) {
int n = snprintf(scratch, SCRATCH, "%g", x); int n = snprintf((char *)buf, FLAN_NUM_BYTES, "%g", x);
out->ptr = (const uint8_t *)scratch; out->ptr = buf;
out->len = fit(n); out->len = fit(n);
} }
void flan_i64_to_bytes(int64_t x, flan_slice *out) { void flan_i64_to_bytes(int64_t x, uint8_t *buf, flan_slice *out) {
int n = snprintf(scratch, SCRATCH, "%lld", (long long)x); int n = snprintf((char *)buf, FLAN_NUM_BYTES, "%lld", (long long)x);
out->ptr = (const uint8_t *)scratch; out->ptr = buf;
out->len = fit(n); out->len = fit(n);
} }
@ -298,9 +312,9 @@ void flan_i64_to_bytes(int64_t x, flan_slice *out) {
* not -1, and routing it through the signed printer is the only way println * not -1, and routing it through the signed printer is the only way println
* could disagree with the REPL about a value both can hold. Hence a second * could disagree with the REPL about a value both can hold. Hence a second
* shim rather than a cast at the call site. */ * shim rather than a cast at the call site. */
void flan_u64_to_bytes(uint64_t x, flan_slice *out) { void flan_u64_to_bytes(uint64_t x, uint8_t *buf, flan_slice *out) {
int n = snprintf(scratch, SCRATCH, "%llu", (unsigned long long)x); int n = snprintf((char *)buf, FLAN_NUM_BYTES, "%llu", (unsigned long long)x);
out->ptr = (const uint8_t *)scratch; out->ptr = buf;
out->len = fit(n); out->len = fit(n);
} }

View File

@ -0,0 +1,42 @@
;;;; Two rendered numbers, held at once.
;;;;
;;;; i64->bytes and its two siblings render into a buffer and answer a slice
;;;; over it. That buffer used to be one file-static in the runtime, shared by
;;;; every call in the process, so the program below printed "22 22": the
;;;; second conversion overwrote the first, and the first slice — still a
;;;; perfectly valid pointer into a perfectly live buffer — was read after it.
;;;; 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.
;;;;
;;;; The buffer is the caller's now, one frame slot per call site, which is why
;;;; the two conversions below do not collide and why the f64 held across an
;;;; i64 conversion — a different shim, and the same buffer before — survives
;;;; it. What the slice still does not outlive is its frame: storing one in a
;;;; container that lives longer, or returning it, hands back a view of storage
;;;; that has been reused. That is copying's job and is said in check.ml.
(defn main [] i32
;; Two i64 conversions alive at the same time.
(let [a (string (i64->bytes 11))
b (string (i64->bytes 22))]
(print a) (print " ") (println b)) ; 11 22
;; Three, and read in the order they were made rather than in reverse, so a
;; version that rotated among two buffers would still be caught.
(let [a (string (i64->bytes 1))
b (string (i64->bytes 2))
c (string (i64->bytes 3))]
(print a) (print b) (println c)) ; 123
;; Across the two shims: the f64's text is made first and read last.
(let [x (string (f64->bytes 2.5))
n (string (i64->bytes 7))]
(print x) (print " ") (println n)) ; 2.5 7
;; Inside a loop, where the slot is reused per iteration: each turn's text is
;; read before the next turn writes it, which is the contract a frame slot
;; gives. Printed on one line so the loop's shape is visible in the output.
(dotimes [i 4]
(let [s (string (i64->bytes (i64 (* i 11))))]
(print s) (print " ")))
(println "") ; 0 11 22 33
0)

View File

@ -311,6 +311,17 @@ let () =
outputs ~opt:"-O0" "string of bytes, -O0" "programs/string-of-bytes.flan" outputs ~opt:"-O0" "string of bytes, -O0" "programs/string-of-bytes.flan"
string_of_bytes_out; string_of_bytes_out;
(* Two rendered numbers held at once, which is what one shared buffer in
the runtime made impossible: this printed "22 22" and could not have
been caught by a sanitizer, because every byte read was inside a live
object the wrong one. -O0 too, since the buffer is now a frame slot
and mem2reg is what decides whether the address escapes. *)
let two_numbers_out = "11 22\n123\n2.5 7\n0 11 22 33 \n" in
outputs "two rendered numbers at once" "programs/two-numbers.flan"
two_numbers_out;
outputs ~opt:"-O0" "two rendered numbers at once, -O0"
"programs/two-numbers.flan" two_numbers_out;
(* The other side of that boundary: bytes the copy cannot represent. A NUL (* The other side of that boundary: bytes the copy cannot represent. A NUL
inside the string is where ptr+len and C's "ends at the first NUL" stop inside the string is where ptr+len and C's "ends at the first NUL" stop
describing the same value, so the shim refuses instead of handing C a describing the same value, so the shim refuses instead of handing C a

View File

@ -118,6 +118,12 @@ let corpus =
the harness rather than in anything under test. *) the harness rather than in anything under test. *)
"programs/bounds.flan", [ "0" ]; "programs/bounds.flan", [ "0" ];
"programs/arena-value.flan", []; "programs/arena-value.flan", [];
(* Here for what it would catch rather than for what it prints: the three
number conversions render into a frame slot the checker allocates per
call site, and a slot that ended up as a reclaimed temporary instead
would be a stack-use-after-scope which is exactly what ASan sees and
an output comparison does not. *)
"programs/two-numbers.flan", [];
"programs/arena-edn.flan", []; "programs/arena-edn.flan", [];
"programs/bytes2.flan", []; "programs/bytes2.flan", [];
"programs/cleanup.flan", []; "programs/cleanup.flan", [];