A bad index stops the program where it stands instead of taking the session with it

This commit is contained in:
Joseph Ferano 2026-09-13 08:02:22 +07:00
parent a5fa83fe87
commit 542bc6a65c
8 changed files with 551 additions and 37 deletions

View File

@ -18,7 +18,9 @@ assignable, which makes the generated step its only writer.
level of a function body. Each one is checked in place, then registered on the context; it emits nothing where it level of a function body. Each one is checked in place, then registered on the context; it emits nothing where it
stands. Function exit runs them innermost-first, and an explicit `return` runs the ones registered *above* it — a defer stands. Function exit runs them innermost-first, and an explicit `return` runs the ones registered *above* it — a defer
written below a return has not executed yet and must not fire. A trap runs none of them, which follows from the written below a return has not executed yet and must not fire. A trap runs none of them, which follows from the
bounds-check shape (`noreturn` then `unreachable`) rather than being a separate decision. bounds-check shape (`noreturn` then `unreachable`) rather than being a separate decision. **Amended** once a bounds
failure became a signal: an *answered* one leaves through the unwind path and runs them like any other transfer, an
unanswered one still runs none. See "An index out of range is a condition" at the foot of this file.
`defer` inside a `let`, a loop or a branch is **rejected**, not accepted with function scope. It would run once at `defer` inside a `let`, a loop or a branch is **rejected**, not accepted with function scope. It would run once at
function exit rather than once per iteration, and that is the silent-wrongness class the rule below is about. function exit rather than once per iteration, and that is the silent-wrongness class the rule below is about.
@ -3933,3 +3935,82 @@ assertable. `raylib-image.flan` carves one 6×3 sheet twice at two different `y`
`y`, `width` and `height` are each pinned by an answer that moves if they do, and the sheet surviving both carves is `y`, `width` and `height` are each pinned by an answer that moves if they do, and the sheet surviving both carves is
what distinguishes this from `image-crop`. Bind it to `ImageCrop` by mistake and the second carve reads out of a 2×1 what distinguishes this from `image-crop`. Bind it to `ImageCrop` by mistake and the second carve reads out of a 2×1
image and the case goes red. image and the case goes red.
## An index out of range is a condition
`flan_bounds_fail` printed the source location, the index and the length, and called `exit(134)`. That was defensible
while `flan dev` was two processes. It is not now: the compiler runs **inside the program**, so the trap took the
whole session with it — and the session not having to restart is the project's thesis. `PORTING.md` found the customer
and found it on the most ordinary path there is: a grid indexed straight from a mouse position is out of bounds the
first time the pointer leaves the window, and the author's Common Lisp port had to add an `in-bounds-p` to survive it.
A failed bounds check now signals **`BoundsError`** with `error`, the same way a failed allocation signals
`StorageExhausted`. `runtime/flan_rt.c` has `flan_bounds_error` and `flan_slice_error`; each walks the handlers, then
offers the break loop, and only if neither transferred does it tail into the `flan_bounds_fail`/`flan_slice_fail` that
were there before — same message, same status 134. Nothing was removed; a die was demoted to a last resort.
`(defstruct BoundsError [low i64 high i64 length i64])` is in the prelude. Fixed numeric fields and no rendered
message, for `StorageExhausted`'s reason: formatting allocates, and a condition raised on a path that may be out of
storage must not. `low` and `high` are the same index for an `(at xs i)` and the two ends of the range for a `(slice
xs lo hi)`, so **one** condition type covers both and a handler that wants to survive a bad index writes one clause
rather than two. The three `int64_t`s in `flan_rt.c` have to agree with that `defstruct` field for field — the same
hand-kept agreement `flan_name_id` already keeps with `Check.type_id`, and for the same reason: a struct is a layout,
a type is a number, and neither side can see the other.
### Why no restart is established at the failing index
This is the decision, and it is a decision rather than an omission.
`alloc_guard` offers `retry` and `file_guard` offers `retry`/`use-value` **because their attempt is repeatable**. A
handler frees something and the allocation succeeds; a handler supplies another path and the open succeeds. That is
what makes them `plan.org`'s named exceptions to "restarts go at the resync point, once" — a restart at an outer loop
cannot re-attempt an allocation, and only the allocation site can.
Nothing a handler can do makes index 51 valid for a length-50 array. There is no attempt to re-run, so there is
nothing for a site restart to resume into, and bounds falls on the **default** side of that rule.
- **`continue` is wrong.** `(at xs i)` has to produce a value of the element type and there is none to produce. It
would mean something in the `(set (at xs i) v)` position and nothing in the other, and `at` is one form.
- **`use-value` for the index is the near miss and is still wrong.** It costs the hot path, not just the cold block:
`idx` is an SSA value feeding the gep, and retrying needs it in an alloca reloaded per attempt, plus a restart frame
pushed and popped on **every** indexing operation. What it buys is a *different element*, silently — the class of
answer this codebase refuses everywhere else.
- **What actually answers a bad index is already on the stack.** A frame loop's `continue``sand.flan`'s shape — is
an ordinary `restart-case`, `flan_find_restart` walks to it, and the break loop lists it. Signalling is the whole
fix. A site restart would add nothing the frame loop does not already offer, at a cost on every index in the
program.
### Release, and what a build without the agent does
`flan_break_hook` is NULL unless the agent package was imported, so a release build — or any program that did not
import it — signals, finds no handler, finds no hook, and dies with the message and the status it always had. That is
still right: there is nowhere to stand. The change is not "bounds failures stopped being fatal"; it is "a bounds
failure is now answerable, and is fatal when unanswered".
### Defer, which had to be answered rather than inherited
The note at the top of this file said *a trap runs no defers, which follows from the bounds-check shape (`noreturn`
then `unreachable`) rather than being a separate decision*. The shape changed, so the consequence could not be
inherited. It now splits:
- **An answered bounds failure runs the defers.** `guard` routes through `current_pad`, which with no enclosing
`restart-case` sets `f.unwound` and branches to the function's unwind block — the same path `return` uses, which is
where §5's defers already live. So a transfer out of a bad index is an ordinary transfer and runs cleanup
innermost-first, like every other one.
- **An unanswered one still runs none**, because it is still a `rt_die()` inside C with no Flan frame involved.
`bounds-condition.flan` asserts the first of those directly: five `defer`s across five abandoned and finished frames,
counted.
### Vec and Map
`(at v i)` and `(at arr i)` are the same form in the source, so shipping one signalling and the other exiting would
have read as a bug. A `Vec`'s bounds check lives *inside* `flan_vec_at` and `flan_vec_as_slice` rather than in emitted
IR (which is also why `--no-bounds-checks` never reached it), so both grew a trailing transfer-channel parameter and
`Emit`'s `Rt` arm guards those two symbols and no others — they are the only ones in that family that can transfer;
everything else there is arithmetic over a container header.
**A `Map`'s bounds and a `Vec`'s stale-allocator check still die.** `flan_vec_stale_fail` is a different kind of
failure — the region a container lived in was released, and there is no frame to go back to that would not read freed
memory — and the map path was left alone rather than converted half-way. Written down here so it is a known edge
rather than a discovery.

32
NEXT.md
View File

@ -11,11 +11,33 @@ surface and must not be load-bearing, because the default build has no `FLAN_RAY
**2. `Key` has no `left-shift` — DONE.** `left-shift 340`, and nothing else: `PORTING.md` §5 checked every other enum **2. `Key` has no `left-shift` — DONE.** `left-shift 340`, and nothing else: `PORTING.md` §5 checked every other enum
value the game touches and they were all already right. value the game touches and they were all already right.
**3. An out-of-bounds index should signal a condition, not `exit(134)`.** `PORTING.md`'s own first recommendation **3. An out-of-bounds index should signal a condition, not `exit(134)` — DONE.** A failed bounds check signals
after the binding lines. The game indexes grids straight from mouse coordinates — `game.lisp` had to add an `BoundsError` with `error`; `runtime/flan_rt.c`'s `flan_bounds_error`/`flan_slice_error` walk the handlers, then offer
`in-bounds-p` to survive it — and today a trap ends the process *and* the `flan dev` session with it. `flan_rt.c`'s the break loop, and tail into the old `flan_bounds_fail` message and status only if nothing answered. `Vec`'s two
`flan_bounds_fail` should go through the condition machinery, which would make it a break loop with a restart instead checks are plumbed the same way, since `(at v i)` and `(at arr i)` are one form in the source.
of a dead session. Note this interacts with the merged one-process build: killing the program now kills the compiler.
**No restart is established at the failing index**, and `BUILT.md` has the argument: `retry` exists for allocation and
for files because those attempts are *repeatable*, and nothing a handler can do makes index 51 valid for a length-50
array. `use-value` for the index would cost every indexing operation a restart frame and buy a silently different
element. What answers a bad index is the restart the program already had — a frame loop's `continue`, `sand.flan`'s
shape — which is on the restart stack and on the break loop's list without anything being pushed at the site.
**Defer** had to be answered rather than inherited, since the `noreturn`-then-`unreachable` shape is what the old note
followed from: an answered bounds failure leaves through the function's unwind block, which is `return`'s path, so it
runs the defers; an unanswered one still runs none. `test/programs/bounds-condition.flan` counts them.
**Still dying, deliberately:** a `Map`'s bounds check and `flan_vec_stale_fail`. The stale-allocator case is a
different kind of failure — the region the container lived in was released — and there is no frame to go back to that
would not read freed memory. The map path was left alone rather than converted half-way.
### What is left on `PORTING.md`'s list
Tier 0 is finished. Of Tier 1, items 5 and 6 are the next two and they are both dev-loop work rather than language
work: **a watch for a *running* program** (the stopped-stack inspector is a different tool for a different moment —
`watch.clj` + `spy-num` is the shape, and the numeric accumulator for hot loops is the part that is least obvious),
and **frame rollback as a worked example**`snapshot`/`restore` callbacks beside the `continue` restart, which is
now genuinely reachable from a bad index and so is worth more than it was yesterday. `bounds-condition.flan` shows an
abandoned frame leaving half-written state behind; rollback is what finishes that thought.
**What `PORTING.md` says NOT to build, with evidence:** escaping closures (one capture site, fixed by one parameter), **What `PORTING.md` says NOT to build, with evidence:** escaping closures (one capture site, fixed by one parameter),
`Handle`/pools, `Result`/`try`, `handler-case`, `loop`/`recur` and tail calls, user allocators, structural typing — `Handle`/pools, `Result`/`try`, `handler-case`, `loop`/`recur` and tail calls, user allocators, structural typing —

View File

@ -312,6 +312,14 @@ another name. Two things are missing behind it:
`lib/dev.ml` answers every subsequent request with "the program exited; restart flan `lib/dev.ml` answers every subsequent request with "the program exited; restart flan
dev". dev".
> **Fixed, 2026-09-13.** It signals `BoundsError` with `error` now, and dies with that
> same message only if nothing answered. The site establishes **no restart** — nothing
> a handler can do makes a bad index good, so there is no attempt to re-run — and what
> answers it is the restart the program already had, which is exactly the frame loop's
> `continue` this section is about. `BUILT.md` has the reasoning and
> `test/programs/bounds-condition.flan` has the worked case. Item 1 below, the
> rollback, is now the whole of what is left here.
That is not theoretical for this game. `game.clj`'s `update-game` computes `row` and That is not theoretical for this game. `game.clj`'s `update-game` computes `row` and
`col` straight from the mouse position and indexes the grid with them, with no bounds `col` straight from the mouse position and indexes the grid with them, with no bounds
check anywhere. **`game.lisp` added `in-bounds-p` and calls it in `update-drag`** — the check anywhere. **`game.lisp` added `in-bounds-p` and calls it in `update-drag`** — the
@ -487,13 +495,20 @@ not compete for the same slot.
### Tier 1 — language and tooling, in the order that unblocks the most of this game ### Tier 1 — language and tooling, in the order that unblocks the most of this game
4. **A bounds failure should stop the program, not end it.** Route `flan_bounds_fail` 4. ~~**A bounds failure should stop the program, not end it.**~~ **Done, 2026-09-13.** It
through the condition machinery so it reaches the break loop with the restarts that are signals `BoundsError`, reaches handlers and the break loop, and is fatal only when
on the stack, instead of `exit(134)`. This is the single largest gap between what Flan nothing answers. `Vec`'s checks went with it, since `(at v i)` and `(at arr i)` are one
promises and what this game would experience, and the game reaches it through the most form in the source. No restart is established at the site: `retry` exists for
ordinary path in it — a mouse coordinate outside the window, which `game.lisp` had to allocation and for files because those attempts are repeatable, and this one is not —
add `in-bounds-p` to survive. Everything else here is a workaround with a known cost; so what answers a bad index is the `continue` the frame loop already offered, which is
this one ends the session. the restart this document was pointing at all along.
Two consequences worth carrying forward. **Defer** had to be decided rather than
inherited — an answered bounds failure runs the function's defers, because it leaves
through the same unwind path a `return` does; an unanswered one still runs none.
And **item 6 below got more valuable**, not less: now that a bad index lands in
`continue` instead of ending the process, the missing half is the rollback, because an
abandoned frame leaves the grid half-written. That is the next thing on this list.
5. **A watch for a running program.** `watch.clj` + `watch.el` + `spy` + `spy-num` is a 5. **A watch for a running program.** `watch.clj` + `watch.el` + `spy` + `spy-num` is a
tool the author built deliberately and uses constantly, and the stopped-stack inspector tool the author built deliberately and uses constantly, and the stopped-stack inspector

View File

@ -707,7 +707,12 @@ let fninfo m (fn : Tast.fn) ~nslots =
Indices are i32 in Flan and sign-extended to i64 for the gep, so a negative Indices are i32 in Flan and sign-extended to i64 for the gep, so a negative
one arrives here as a huge unsigned value: an unsigned comparison catches one arrives here as a huge unsigned value: an unsigned comparison catches
the negative and the too-large case in a single test. *) the negative and the too-large case in a single test.
This is the *trapping* shape and it still has four users: the restart
lookups and the unarmed-clause check, none of which is recoverable there
is nowhere to resume a transfer whose target does not exist. The two bounds
checks moved off it; see [signal_block]. *)
let fail_block f (loc : Loc.t) ok emit_call = let fail_block f (loc : Loc.t) ok emit_call =
let good = fresh_label f "inb" and bad = fresh_label f "oob" in let good = fresh_label f "inb" and bad = fresh_label f "oob" in
term f "br i1 %s, label %%%s, label %%%s" ok good bad; term f "br i1 %s, label %%%s, label %%%s" ok good bad;
@ -717,21 +722,44 @@ let fail_block f (loc : Loc.t) ok emit_call =
term f "unreachable"; term f "unreachable";
label f good label f good
(* The same branch, for a failure that *signals* rather than dying. The call is
an ordinary one it returns when a handler or the break loop transferred
so it is followed by a guard, and the fall-through past the guard is what is
unreachable: nothing answered, so the runtime already died inside the call.
[guard] is passed in rather than called directly because [guard] is part of
the expression emitter's recursive group and this is defined above it. It is
the same [guard f] every call site emits, so a bounds failure that is
answered leaves the function through the innermost pad a restart-case's,
or the function's own unwind block, which runs its defers and returns.
**That is the answer to "does a trap run defers": an answered one does, an
unanswered one still does not, because the unanswered one is still a die
inside C.** *)
let signal_block f (loc : Loc.t) ~guard ok emit_call =
let good = fresh_label f "inb" and bad = fresh_label f "oob" in
term f "br i1 %s, label %%%s, label %%%s" ok good bad;
label f bad;
let id, n = string_bytes f.md (Loc.to_string loc) in
emit_call id n;
guard ();
term f "unreachable";
label f good
(* [at] is strict: the last valid index is len - 1. *) (* [at] is strict: the last valid index is len - 1. *)
let check_at f loc idx len = let check_at f ~guard loc idx len =
if f.md.checks then begin if f.md.checks then begin
let ok = fresh f in let ok = fresh f in
ins f "%s = icmp ult i64 %s, %s" ok idx len; ins f "%s = icmp ult i64 %s, %s" ok idx len;
fail_block f loc ok (fun id n -> signal_block f loc ~guard ok (fun id n ->
ins f "call void @flan_bounds_fail(ptr %s, i64 %d, i64 %s, i64 %s)" ins f "call void @flan_bounds_error(ptr %s, i64 %d, i64 %s, i64 %s, ptr %s)"
id n idx len) id n idx len xfer_param)
end end
(* [slice] is not: a slice ending at len — or an empty one at lo = len — is (* [slice] is not: a slice ending at len — or an empty one at lo = len — is
legal, and its one-past-the-end gep is defined. [lo <= hi] is not redundant legal, and its one-past-the-end gep is defined. [lo <= hi] is not redundant
with it, because a reversed range would otherwise yield hi - lo as a huge with it, because a reversed range would otherwise yield hi - lo as a huge
unsigned length, which is a worse hole than the missing check. *) unsigned length, which is a worse hole than the missing check. *)
let check_slice f loc lo hi len = let check_slice f ~guard loc lo hi len =
if f.md.checks then begin if f.md.checks then begin
let a = fresh f in let a = fresh f in
ins f "%s = icmp ule i64 %s, %s" a lo hi; ins f "%s = icmp ule i64 %s, %s" a lo hi;
@ -739,9 +767,11 @@ let check_slice f loc lo hi len =
ins f "%s = icmp ule i64 %s, %s" b hi len; ins f "%s = icmp ule i64 %s, %s" b hi len;
let ok = fresh f in let ok = fresh f in
ins f "%s = and i1 %s, %s" ok a b; ins f "%s = and i1 %s, %s" ok a b;
fail_block f loc ok (fun id n -> signal_block f loc ~guard ok (fun id n ->
ins f "call void @flan_slice_fail(ptr %s, i64 %d, i64 %s, i64 %s, i64 %s)" ins f
id n lo hi len) "call void @flan_slice_error(ptr %s, i64 %d, i64 %s, i64 %s, i64 %s, \
ptr %s)"
id n lo hi len xfer_param)
end end
(* ── Expressions ───────────────────────────────────────────────────── *) (* ── Expressions ───────────────────────────────────────────────────── *)
@ -1026,7 +1056,7 @@ and element_addr f (target : Tast.expr) idx =
(match ty with (match ty with
| Types.Array (n, elem) -> | Types.Array (n, elem) ->
(* The bound is static; LLVM folds the check away for a literal index. *) (* The bound is static; LLVM folds the check away for a literal index. *)
check_at f i.Tast.loc i64 (Int64.to_string n); check_at f ~guard:(fun () -> guard f) i.Tast.loc i64 (Int64.to_string n);
let p = fresh f in let p = fresh f in
ins f "%s = getelementptr inbounds %s, ptr %s, i64 0, i64 %s" ins f "%s = getelementptr inbounds %s, ptr %s, i64 0, i64 %s"
p (ll ty) ptr i64; p (ll ty) ptr i64;
@ -1038,7 +1068,7 @@ and element_addr f (target : Tast.expr) idx =
ins f "%s = extractvalue %%slice %s, 0" base s; ins f "%s = extractvalue %%slice %s, 0" base s;
let len = fresh f in let len = fresh f in
ins f "%s = extractvalue %%slice %s, 1" len s; ins f "%s = extractvalue %%slice %s, 1" len s;
check_at f i.Tast.loc i64 len; check_at f ~guard:(fun () -> guard f) i.Tast.loc i64 len;
let p = fresh f in let p = fresh f in
ins f "%s = getelementptr inbounds %s, ptr %s, i64 %s" p (ll elem) base i64; ins f "%s = getelementptr inbounds %s, ptr %s, i64 %s" p (ll elem) base i64;
go p elem rest go p elem rest
@ -1745,7 +1775,7 @@ and prim f (e : Tast.expr) (p : Tast.prim) (args : Tast.expr list) =
match target.Tast.ty with match target.Tast.ty with
| Types.Array (n, _) -> | Types.Array (n, _) ->
let a = addr f target in let a = addr f target in
check_slice f e.Tast.loc lo64 hi64 (Int64.to_string n); check_slice f ~guard:(fun () -> guard f) e.Tast.loc lo64 hi64 (Int64.to_string n);
let p = fresh f in let p = fresh f in
ins f "%s = getelementptr inbounds %s, ptr %s, i64 0, i64 %s" ins f "%s = getelementptr inbounds %s, ptr %s, i64 0, i64 %s"
p (ll target.Tast.ty) a lo64; p (ll target.Tast.ty) a lo64;
@ -1756,7 +1786,7 @@ and prim f (e : Tast.expr) (p : Tast.prim) (args : Tast.expr list) =
ins f "%s = extractvalue %%slice %s, 0" q v; ins f "%s = extractvalue %%slice %s, 0" q v;
let n = fresh f in let n = fresh f in
ins f "%s = extractvalue %%slice %s, 1" n v; ins f "%s = extractvalue %%slice %s, 1" n v;
check_slice f e.Tast.loc lo64 hi64 n; check_slice f ~guard:(fun () -> guard f) e.Tast.loc lo64 hi64 n;
let p = fresh f in let p = fresh f in
ins f "%s = getelementptr inbounds %s, ptr %s, i64 %s" p (ll elem) q lo64; ins f "%s = getelementptr inbounds %s, ptr %s, i64 %s" p (ll elem) q lo64;
p p
@ -1766,7 +1796,7 @@ and prim f (e : Tast.expr) (p : Tast.prim) (args : Tast.expr list) =
ins f "%s = extractvalue %%slice %s, 0" q v; ins f "%s = extractvalue %%slice %s, 0" q v;
let n = fresh f in let n = fresh f in
ins f "%s = extractvalue %%slice %s, 1" n v; ins f "%s = extractvalue %%slice %s, 1" n v;
check_slice f e.Tast.loc lo64 hi64 n; check_slice f ~guard:(fun () -> guard f) e.Tast.loc lo64 hi64 n;
let p = fresh f in let p = fresh f in
ins f "%s = getelementptr inbounds i8, ptr %s, i64 %s" p q lo64; ins f "%s = getelementptr inbounds i8, ptr %s, i64 %s" p q lo64;
p p
@ -1826,13 +1856,25 @@ and prim f (e : Tast.expr) (p : Tast.prim) (args : Tast.expr list) =
| t -> [ ll t ^ " " ^ value f a ]) | t -> [ ll t ^ " " ^ value f a ])
args) args)
in in
(* The two runtime entry points whose bounds check signals. They are the
only [Rt] symbols that can transfer, so they are the only ones that take
the channel and the only ones guarded everything else in this family
is arithmetic over a container header and cannot reach a handler. A Vec
is checked inside the runtime rather than in emitted IR (BUILT.md), so
this is where (at v i) gets what (at arr i) gets from [check_at]. *)
let signals =
String.equal sym "flan_vec_at" || String.equal sym "flan_vec_as_slice"
in
let vs = if signals then vs @ [ "ptr " ^ xfer_param ] else vs in
let args' = String.concat ", " vs in let args' = String.concat ", " vs in
if is_void e.Tast.ty then begin if is_void e.Tast.ty then begin
ins f "call void @%s(%s)" sym args'; ins f "call void @%s(%s)" sym args';
if signals then guard f;
"zeroinitializer" "zeroinitializer"
end else begin end else begin
let t = fresh f in let t = fresh f in
ins f "%s = call %s @%s(%s)" t (ll e.Tast.ty) sym args'; ins f "%s = call %s @%s(%s)" t (ll e.Tast.ty) sym args';
if signals then guard f;
t t
end end
| Tast.SizeOf t, [] -> Printf.sprintf "%d" (fst (lay f.md t)) | Tast.SizeOf t, [] -> Printf.sprintf "%d" (fst (lay f.md t))
@ -2286,8 +2328,10 @@ declare void @flan_restart_fail(ptr, i64, ptr, i64) noreturn cold
declare void @flan_restart_args_fail(ptr, i64, ptr, i64, ptr, i64, ptr, i64) noreturn cold declare void @flan_restart_args_fail(ptr, i64, ptr, i64, ptr, i64, ptr, i64) noreturn cold
declare void @flan_restart_unarmed(ptr, i64, ptr, i64, ptr, i64) noreturn cold declare void @flan_restart_unarmed(ptr, i64, ptr, i64, ptr, i64) noreturn cold
declare void @flan_transfer_fail(ptr, i64) noreturn cold declare void @flan_transfer_fail(ptr, i64) noreturn cold
declare void @flan_bounds_fail(ptr, i64, i64, i64) noreturn cold ; Not noreturn: each signals BoundsError and returns when something answered
declare void @flan_slice_fail(ptr, i64, i64, i64, i64) noreturn cold ; it, which is the one path out. The trailing ptr is the transfer channel.
declare void @flan_bounds_error(ptr, i64, i64, i64, ptr) cold
declare void @flan_slice_error(ptr, i64, i64, i64, i64, ptr) cold
declare ptr @flan_context_allocator() declare ptr @flan_context_allocator()
declare ptr @flan_context_temp() declare ptr @flan_context_temp()
declare ptr @flan_heap_allocator() declare ptr @flan_heap_allocator()
@ -2311,8 +2355,11 @@ declare i8 @flan_vec_reserve(ptr, i64, i64, i64, ptr, i64)
declare i8 @flan_vec_push(ptr, ptr, i64, i64, ptr, i64) declare i8 @flan_vec_push(ptr, ptr, i64, i64, ptr, i64)
declare i8 @flan_vec_clone(ptr, ptr, ptr, i64, i64, ptr, i64) declare i8 @flan_vec_clone(ptr, ptr, ptr, i64, i64, ptr, i64)
declare i64 @flan_vec_len(ptr, ptr, i64) declare i64 @flan_vec_len(ptr, ptr, i64)
declare ptr @flan_vec_at(ptr, i32, i64, ptr, i64) ; These two take the transfer channel as well, because a Vec's bounds check is
declare void @flan_vec_as_slice(ptr, ptr, i32, i32, i64, ptr, i64) ; inside the runtime rather than emitted here and (at v i) has to signal the
; same condition (at arr i) does.
declare ptr @flan_vec_at(ptr, i32, i64, ptr, i64, ptr)
declare void @flan_vec_as_slice(ptr, ptr, i32, i32, i64, ptr, i64, ptr)
declare void @flan_vec_free(ptr, i64, i64, ptr, i64) declare void @flan_vec_free(ptr, i64, i64, ptr, i64)
; (Map K V). The two ptr arguments before the location on put/get/clone are the ; (Map K V). The two ptr arguments before the location on put/get/clone are the
; hash and equality pair, which the checker emits per key type and passes here ; hash and equality pair, which the checker emits per key type and passes here

View File

@ -44,6 +44,40 @@ let source = {flan|
;; handler or the break loop, where a working allocator is known. ;; handler or the break loop, where a working allocator is known.
(defstruct StorageExhausted [bytes i64 align i64 allocator i64]) (defstruct StorageExhausted [bytes i64 align i64 allocator i64])
;; What an out-of-range index signals. Same shape as StorageExhausted and for
;; the same reasons: fixed numeric fields, no rendered message, nothing that
;; allocates the condition is built on the failing frame's stack and the
;; formatting is the handler's or the break loop's job, where a working
;; allocator is known.
;;
;; It is signalled with `error`, from the runtime rather than from Flan:
;; flan_bounds_error and flan_slice_error in runtime/flan_rt.c, which every
;; bounds check now branches to. **The three fields there are a C struct that
;; has to agree with this one field for field**, the same hand-kept agreement
;; flan_name_id keeps with Check.type_id.
;;
;; `low` and `high` are the same index for an (at xs i), and the two ends of
;; the range for a (slice xs lo hi). One condition type rather than two,
;; because a handler that wants to survive a bad index should not have to
;; write two clauses to cover the two ways of writing one.
;;
;; **Nothing establishes a restart at the failing site**, which is the
;; difference from StorageExhausted and from FileError. Those offer `retry`
;; because their attempt is repeatable: free something, or supply another path,
;; and the same operation succeeds the second time. Nothing a handler can do
;; makes index 51 valid for a length-50 array, so there is no attempt to
;; re-run. `use-value` for the index is the near miss and is not built: it
;; would put an alloca and a restart frame on every indexing operation, and
;; what it buys is a *different element*, silently.
;;
;; The restarts that matter are the ones the program already established a
;; frame loop's `continue`, sand.flan's shape and they are on the restart
;; stack and reachable from a handler or from the break loop without anything
;; being pushed here. That is plan.org's "restarts go at the resync point,
;; once", with allocation and file failure as the named exceptions and this on
;; the default side of the rule.
(defstruct BoundsError [low i64 high i64 length i64])
;; A breakpoint. (pause) stops the program where it stands and hands it to the ;; A breakpoint. (pause) stops the program where it stands and hands it to the
;; break loop, with the whole stack under it readable C-c C-b lists the ;; break loop, with the whole stack under it readable C-c C-b lists the
;; frames, TAB opens one, and taking `continue` resumes at the call. ;; frames, TAB opens one, and taking `continue` resumes at the call.

View File

@ -466,6 +466,80 @@ _Noreturn void flan_slice_fail(const uint8_t *loc, int64_t loclen,
rt_die(); rt_die();
} }
/* ── An index out of range is a condition ──────────────────────────────
*
* The two functions above are still here and still die; what changed is that
* they are no longer the *first* thing a bad index reaches. A bounds failure
* now signals BoundsError with `error`, exactly as a failed allocation signals
* StorageExhausted, and only reaches the message above if nothing answered.
*
* Why it had to change. `flan dev` runs the compiler inside the program, in
* one process. exit(134) therefore took the session with it, and the session
* is the thing the project is built around never having to restart. The
* ordinary route into it is not exotic: a grid indexed from a mouse position
* is out of bounds the first time the pointer leaves the window.
*
* **No restart is established here**, and that is a decision rather than an
* omission. flan_alloc_* and the file guards offer `retry` because their
* attempt is repeatable a handler frees something, or supplies another
* path, and the same operation then succeeds. Nothing a handler can do makes
* index 51 valid for a length-50 array, so there is no attempt to re-run and
* nothing for a site restart to resume into. `use-value` for the index is the
* near miss: it would cost every indexing operation an alloca and a restart
* frame, and what it buys is a *different element*, silently, which is the
* class of answer this codebase refuses everywhere else. The restarts that
* matter are the ones the program already established a frame loop's
* `continue` and those are on the restart stack and reachable from the break
* loop without anything being pushed here.
*
* With no break hook a release build, or any program that did not import the
* agent flan_break_hook is NULL, nothing transfers, and this falls through
* to the same message and the same status it always had. That is still the
* right answer: there is nowhere to stand.
*
* The condition is three int64s on this frame and it must agree field for
* field with the prelude's (defstruct BoundsError [low i64 high i64 length
* i64]) the same hand-kept agreement flan_name_id has with Check.type_id,
* and for the same reason: a struct is a layout and a type is a number, and
* neither side can see the other. `low` and `high` are the same index for an
* `at`, and the two ends of the range for a `slice`, so one condition type
* covers both and a handler writes one clause rather than two. */
typedef struct { int64_t low, high, length; } flan_bounds_cond;
static const uint8_t flan_bounds_name[] = "BoundsError";
#define FLAN_BOUNDS_NAMELEN 11
/* Returns nonzero if something transferred, in which case the caller returns
* and its caller's guard carries the transfer out. */
static int flan_bounds_signal(void *xfer, int64_t low, int64_t high,
int64_t len) {
flan_bounds_cond c;
uint32_t id = flan_name_id(flan_bounds_name, FLAN_BOUNDS_NAMELEN);
c.low = low;
c.high = high;
c.length = len;
flan_signal(id, &c, xfer);
if (*(void **)xfer != NULL) return 1;
if (flan_break_hook != NULL) {
flan_break_hook(flan_bounds_name, FLAN_BOUNDS_NAMELEN, &c, xfer);
if (*(void **)xfer != NULL) return 1;
}
return 0;
}
void flan_bounds_error(const uint8_t *loc, int64_t loclen, int64_t idx,
int64_t len, void *xfer) {
if (flan_bounds_signal(xfer, idx, idx, len)) return;
flan_bounds_fail(loc, loclen, idx, len);
}
void flan_slice_error(const uint8_t *loc, int64_t loclen, int64_t lo,
int64_t hi, int64_t len, void *xfer) {
if (flan_bounds_signal(xfer, lo, hi, len)) return;
flan_slice_fail(loc, loclen, lo, hi, len);
}
/* ── Allocators, spec-memory.md ──────────────────────────────────────── /* ── Allocators, spec-memory.md ────────────────────────────────────────
* *
* One type-erased procedure plus an opaque data pointer, which is Odin's * One type-erased procedure plus an opaque data pointer, which is Odin's
@ -955,23 +1029,40 @@ int64_t flan_vec_len(flan_vec *v, const uint8_t *loc, int64_t loclen) {
return v->len; return v->len;
} }
/* [xfer] is this operation's end of the transfer channel, so that an index out
* of range signals BoundsError instead of ending the process see the note
* above flan_bounds_error. (at v i) and (at arr i) are the same form in the
* source and shipping one of them signalling and the other exiting would read
* as a bug, so the two are plumbed together. The channel is the last
* parameter, as it is on every Flan signature; Emit appends it and guards the
* call, and a transfer therefore leaves through the caller's pad with NULL
* here never dereferenced. */
void *flan_vec_at(flan_vec *v, int32_t i, int64_t size, const uint8_t *loc, void *flan_vec_at(flan_vec *v, int32_t i, int64_t size, const uint8_t *loc,
int64_t loclen) { int64_t loclen, void *xfer) {
flan_vec_check(v, loc, loclen); flan_vec_check(v, loc, loclen);
/* The same unsigned comparison the fixed-array bounds check uses: a negative /* The same unsigned comparison the fixed-array bounds check uses: a negative
* index sign-extends to a huge unsigned and is caught by the one test. */ * index sign-extends to a huge unsigned and is caught by the one test. */
if ((uint64_t)(int64_t)i >= (uint64_t)v->len) if ((uint64_t)(int64_t)i >= (uint64_t)v->len) {
if (flan_bounds_signal(xfer, (int64_t)i, (int64_t)i, v->len)) return NULL;
flan_vec_bounds_fail(loc, loclen, (int64_t)i, v->len); flan_vec_bounds_fail(loc, loclen, (int64_t)i, v->len);
}
return (uint8_t *)v->ptr + (int64_t)i * size; return (uint8_t *)v->ptr + (int64_t)i * size;
} }
/* [hi] of -1 means "to the end": (as-slice v) has no static length to write. */ /* [hi] of -1 means "to the end": (as-slice v) has no static length to write. */
void flan_vec_as_slice(flan_vec *v, void *out, int32_t lo, int32_t hi, void flan_vec_as_slice(flan_vec *v, void *out, int32_t lo, int32_t hi,
int64_t size, const uint8_t *loc, int64_t loclen) { int64_t size, const uint8_t *loc, int64_t loclen,
void *xfer) {
struct { void *p; int64_t n; } s; struct { void *p; int64_t n; } s;
int64_t l = lo, h = (hi < 0) ? v->len : hi; int64_t l = lo, h = (hi < 0) ? v->len : hi;
flan_vec_check(v, loc, loclen); flan_vec_check(v, loc, loclen);
if (l < 0 || h > v->len || l > h) flan_vec_bounds_fail(loc, loclen, l, v->len); if (l < 0 || h > v->len || l > h) {
/* Both ends, because both are what went wrong — the fixed-array slice
* check reports the same pair. [out] is left untouched on the transfer
* path; the caller's guard branches before it reads the slice. */
if (flan_bounds_signal(xfer, l, h, v->len)) return;
flan_vec_bounds_fail(loc, loclen, l, v->len);
}
s.p = (uint8_t *)v->ptr + l * size; s.p = (uint8_t *)v->ptr + l * size;
s.n = h - l; s.n = h - l;
memcpy(out, &s, sizeof s); memcpy(out, &s, sizeof s);

View File

@ -0,0 +1,190 @@
;;;; An index out of range is a condition, not the end of the process.
;;;;
;;;; Until now a bad index printed its source location and called exit(134).
;;;; That was defensible when `flan dev` was two processes; it is not now that
;;;; the compiler runs inside the program, because the trap takes the session
;;;; with it and the session is the thing the project is built around never
;;;; having to restart. And the route in is the most ordinary one there is: a
;;;; grid indexed from a mouse position is out of bounds the first time the
;;;; pointer leaves the window (PORTING.md, §3).
;;;;
;;;; So a failed bounds check signals BoundsError with `error`, the same way a
;;;; failed allocation signals StorageExhausted, and dies with the old message
;;;; only if nothing answered. This program is the "something answered" half —
;;;; the unhandled half is bounds.flan, which still exits 134 with the same
;;;; text it always did.
;;;;
;;;; **No restart is established at the failing index**, and that is the
;;;; decision worth reading this file for. StorageExhausted offers `retry`
;;;; because its attempt is repeatable: free something and the allocation
;;;; succeeds. Nothing a handler can do makes index 7 valid for a length-4
;;;; array. `use-value` for the index would cost every indexing operation a
;;;; restart frame and buy a *different element*, silently. What answers a bad
;;;; index is the restart the program already had — the frame loop's
;;;; `continue`, which is sand.flan's shape and is what a game wants: abandon
;;;; this frame, keep the window open.
;;;;
;;;; Four things are asserted, and the first two are the ones that matter:
;;;;
;;;; 1. The frame is abandoned and the program carries on. `frames` counts
;;;; the ones that finished and `skipped` the ones that did not.
;;;; 2. **Defers run.** A trap ran none, which BUILT.md recorded as following
;;;; from the noreturn-then-unreachable shape rather than as a decision.
;;;; The shape changed, so the question had to be answered rather than
;;;; inherited: an *answered* bounds failure leaves through the same
;;;; unwind path a `return` uses, and therefore runs the function's
;;;; defers, innermost first. An unanswered one still runs none, because
;;;; it is still a die inside C.
;;;; 3. The condition carries the numbers. `low` and `high` are the same
;;;; index for an `at` and the two ends of the range for a `slice`, which
;;;; is why there is one condition type and not two.
;;;; 4. Every route to a bad index signals: reading a fixed array, writing
;;;; one (a different lowering — place/Pindex, not At), a slice, a Vec
;;;; element, and a Vec's as-slice. A Vec's check lives inside the
;;;; runtime rather than in emitted IR, so those two are plumbed
;;;; separately and are the ones most likely to be left behind.
(defvar grid [4 i32])
;;; Handlers cannot see the locals of the function that established them —
;;; check.ml refuses a capture by name and says to use a global — so
;;; everything this program counts lives up here.
(defvar frames i64)
(defvar skipped i64)
(defvar cleaned i64)
(defvar low i64)
(defvar high i64)
(defvar length i64)
;;; Two frames deep, with a defer on the way, so the transfer has something to
;;; cross and something to run on its way out.
(defn show [name string n i64] ()
(print name) (print " ") (print n) (println ""))
(defn read-cell [i i32] i32
(defer (set cleaned (+ cleaned 1)))
(at grid i))
(defn write-cell [i i32] ()
(defer (set cleaned (+ cleaned 1)))
(set (at grid i) 99))
;;; The frame loop's shape: one restart-case around the work, offering
;;; `continue`, which abandons this frame and nothing else. sand.flan's main
;;; loop is this.
(defn read-frame [i i32] ()
(restart-case
(do (show "read" (i64 (read-cell i)))
(set frames (+ frames 1)))
(continue [] (set skipped (+ skipped 1)))))
(defn write-frame [i i32] ()
(restart-case
(do (write-cell i)
(show "wrote" (i64 (at grid i)))
(set frames (+ frames 1)))
(continue [] (set skipped (+ skipped 1)))))
(defn slice-frame [s [u8] lo i32 hi i32] ()
(restart-case
(do (show "slice" (i64 (len (slice s lo hi))))
(set frames (+ frames 1)))
(continue [] (set skipped (+ skipped 1)))))
;;; The two Vec frames are written inline in main rather than as functions,
;;; because a (Vec T) is move-only: passing one to a helper would hand
;;; ownership over and the caller's binding would be dead afterwards. A
;;; restart-case does not have to be in a different function from the
;;; handler-bind that answers into it — the transfer is by frame address and
;;; the frames here are simply both this one.
(defn main [] i32
(set (at grid 0) 10)
(set (at grid 1) 11)
(set (at grid 2) 12)
(set (at grid 3) 13)
(let [s (bytes "hello") ; len 5
v (vec-new i32)]
(push v 100)
(push v 200)
(handler-bind
[(BoundsError [c]
;; The numbers are here rather than in a message, for the same reason
;; StorageExhausted has none: formatting allocates, and this is a path
;; that must be able to run when allocation is what failed.
(set low (.low c))
(set high (.high c))
(set length (.length c))
;; Abandon the frame. The transfer crosses read-cell (running its
;; defer) and lands in the clause of the restart-case two frames out.
(invoke-restart 'continue))]
;; In bounds: the frame finishes, the handler never runs, and the defer
;; runs on the ordinary return path.
(read-frame 2)
;; Past the end, then negative. A negative index sign-extends to a huge
;; unsigned and is caught by the same single comparison, but the number
;; the condition carries is the signed one.
(read-frame 7)
(show "low" low)
(show "length" length)
(read-frame -1)
(show "low" low)
;; The write path lowers through place/Pindex rather than through At, so
;; it would be perfectly possible to convert one and not the other.
(write-frame 1)
(write-frame 4)
(show "low" low)
;; A slice reports both ends, which is the whole reason low and high are
;; two fields: [2 9) against a length of 5.
(slice-frame s 1 4)
(slice-frame s 2 9)
(show "low" low)
(show "high" high)
(show "length" length)
;; A reversed range, which the lo <= hi test is what catches: without it
;; this builds a slice of length hi - lo as a huge unsigned.
(slice-frame s 3 1)
(show "low" low)
(show "high" high)
;; And the Vec pair, whose checks are inside the runtime rather than in
;; emitted IR — a different code path entirely, and the one most likely
;; to be left behind by a change made in emit.ml.
(restart-case
(do (show "vec" (i64 (at v 1)))
(set frames (+ frames 1)))
(continue [] (set skipped (+ skipped 1))))
(restart-case
(do (show "vec" (i64 (at v 5)))
(set frames (+ frames 1)))
(continue [] (set skipped (+ skipped 1))))
(show "low" low)
(show "length" length)
(restart-case
(do (show "vec-slice" (i64 (len (as-slice v 0 2))))
(set frames (+ frames 1)))
(continue [] (set skipped (+ skipped 1))))
(restart-case
(do (show "vec-slice" (i64 (len (as-slice v 0 9))))
(set frames (+ frames 1)))
(continue [] (set skipped (+ skipped 1))))
(show "high" high))
(free v))
;; Six frames finished, six were abandoned, and every one of the twelve ran
;; its defer — which is the claim about defer that the old shape could not
;; make.
(show "frames" frames)
(show "skipped" skipped)
(show "cleaned" cleaned)
;; The write that did land, and the one that did not: grid[1] is 99 and
;; nothing else moved.
(print (at grid 0)) (print " ") (print (at grid 1)) (print " ")
(print (at grid 2)) (print " ") (print (at grid 3)) (println "")
0)

View File

@ -1124,16 +1124,50 @@ let () =
let p = let p =
Reader.read_file "programs/bounds.flan" |> Parse.program |> Check.program Reader.read_file "programs/bounds.flan" |> Parse.program |> Check.program
in in
if not (contains (Emit.program p) "call void @flan_bounds_fail(") then begin if not (contains (Emit.program p) "call void @flan_bounds_error(") then begin
incr failures; incr failures;
print_endline "FAIL checks on: no bounds call emitted" print_endline "FAIL checks on: no bounds call emitted"
end; end;
let off = Emit.program ~checks:false p in let off = Emit.program ~checks:false p in
if contains off "call void @flan_bounds_fail(" || contains off "call void @flan_slice_fail(" then begin if contains off "call void @flan_bounds_error("
|| contains off "call void @flan_slice_error(" then begin
incr failures; incr failures;
print_endline "FAIL --no-bounds-checks: a check survived" print_endline "FAIL --no-bounds-checks: a check survived"
end; end;
(* The other half of the same change: a bad index that something *answers*.
bounds.flan above is still the unhandled case and still exits 134 with
the same text; this one establishes a frame loop's `continue` and a
handler that takes it, and the program runs to the end.
Three claims, and the second is the one that had to be decided rather
than inherited. (1) Five frames finish and seven are abandoned, out of
five routes to a bad index fixed-array read, fixed-array write (a
different lowering), slice, Vec element and Vec as-slice, the last two
checked inside the runtime rather than in emitted IR. (2) `cleaned` is
5, which is every defer on every one of those paths: an answered bounds
failure leaves through the same unwind path a `return` uses and runs
them, where a trap ran none. (3) The condition's numbers are the real
ones 7 and -1 for the two bad `at`s, [2 9) and [3 1) for the two bad
slices, with `low` and `high` equal for an index and the two ends for a
range, which is why there is one condition type and not two.
Also at -O0 and as a dev build: the dev build is the one with the break
loop hook installed, and a handler that transfers must reach the
transfer before the hook rather than after it. *)
let bounds_cond_out =
"read 12\nlow 7\nlength 4\nlow -1\nwrote 99\nlow 4\n\
slice 3\nlow 2\nhigh 9\nlength 5\nlow 3\nhigh 1\n\
vec 200\nlow 5\nlength 2\nvec-slice 2\nhigh 9\n\
frames 5\nskipped 7\ncleaned 5\n10 99 12 13\n"
in
outputs "a bad index is a condition" "programs/bounds-condition.flan"
bounds_cond_out;
outputs ~opt:"-O0" "a bad index is a condition, -O0"
"programs/bounds-condition.flan" bounds_cond_out;
outputs ~dev:true "a bad index is a condition, dev"
"programs/bounds-condition.flan" bounds_cond_out;
(* ── Packages: the link follows the program ──────────────────────── (* ── Packages: the link follows the program ────────────────────────
A package's C and linker arguments used to come with the import, A package's C and linker arguments used to come with the import,
whatever [main] did which is what made sand's two halves two files whatever [main] did which is what made sand's two halves two files