slurp reads a whole file, barf writes one, and failure is a condition

Decisions 2 and 5. slurp allocates, which is why it waited for Vec, and
it follows spec-memory.md's rule exactly: no allocating operation
returns an error, so there is no Result here and no out-parameter. A
failure to allocate is StorageExhausted under retry; a failure to read
is FileError under retry and use-value. The two guards nest rather than
merge, because they are two different failures with two different
answerable questions — the handler that grows an arena is not the
handler that supplies another path.

The restarts are the pair Common Lisp establishes for a file-error.
use-value is a typed restart, the other thing that landed this session,
and this is the first one the compiler itself emits with a parameter.
Its parameter *is* the path slot the attempt reads, so the clause body
is empty: emit.ml's bind_params stores the invoker's argument into the
slot, the clause falls through, and the loop re-attempts against the new
path. Everything is inside that loop, so a use-value naming a different
file re-measures it and re-allocates for its size; the Vec is freed at
the top of each turn, which is why a retry does not leak.

The host ABI grows by three calls and one reason reader: flan_file_size,
flan_file_read, flan_file_write, flan_file_fail_reason. They are
POSIX-shaped and Vec-ignorant — no handle crosses the boundary and
nothing is held between calls — so a second target implements three
functions. flan_slurp_into is runtime glue on this side of the ABI
rather than a fourth call. These do touch paths, which is the widening
plan.org names as the #1 portability risk and which decision 2 took
knowingly; embed is the answer that does not touch them at all.
This commit is contained in:
Joseph Ferano 2026-09-12 11:38:24 +07:00
parent 1d7f5e1c85
commit f88ce56073
5 changed files with 168 additions and 0 deletions

View File

@ -0,0 +1,10 @@
;;;; A missing file with nothing handling it. spec-conditions.md §2: `error` is
;;;; the diverging variant, so the program stops on the frame that erred rather
;;;; than carrying on with a Vec that was never filled. The restarts are
;;;; offered whether or not anyone takes them — a break loop lists both.
(defn main [] i32
(println "before")
(let [v (slurp "programs/assets/does-not-exist")]
(println "unreachable")
(free v))
0)

103
test/programs/slurp.flan Normal file
View File

@ -0,0 +1,103 @@
;;;; slurp and barf — NEXT.md decisions 2 and 5.
;;;;
;;;; slurp reads a whole file and answers a (Vec u8). It allocates, which is
;;;; why it waited for Vec, and it follows spec-memory.md's rule to the letter:
;;;; no allocating operation returns an error, so there is no Result here and
;;;; no out-parameter anywhere.
;;;;
;;;; Failure signals a condition under a restart, which is the pattern
;;;; StorageExhausted set this session. Two restarts, and they are the pair
;;;; Common Lisp establishes for a file-error:
;;;;
;;;; retry the file may be there now
;;;; use-value [p string] try this other path instead
;;;;
;;;; use-value is a *typed* restart — the second thing this session bought —
;;;; and it is the first one the compiler itself emits. Its parameter is the
;;;; path slot the attempt reads, so the clause body is empty: the invoker's
;;;; argument lands in the slot, the clause falls through, and the loop
;;;; re-attempts against the new path.
;; Handlers cannot see the locals of the function that established them, so the
;; observations are globals — the same shape exhausted.flan uses.
(defvar seen i64)
(defvar last-reason i32)
(defvar last-op i32)
(defvar last-path string)
(defn main [] i32
;; ── The happy path ────────────────────────────────────────────────
(let [v (slurp "programs/assets/a.txt")]
(println (len v)) ; 13
(print (string (as-slice v))) ; hello from a
(free v))
;; Byte-exact, the same as an embed: nothing here decodes anything.
(let [v (slurp "programs/assets/raw.bin")]
(println (len v)) ; 4
(println (at v 0)) ; 0
(println (at v 2)) ; 255
(free v))
;; ── use-value: a missing file, answered with another path ─────────
;; The textbook case. The handler does not know what slurp was going to do
;; with the bytes and does not have to: it names a file that is there and
;; the read resumes as if that had been asked for all along.
(handler-bind
[(FileError [c]
(set seen (+ seen 1))
(set last-reason (.reason c))
(set last-op (.op c))
(set last-path (.path c))
(invoke-restart 'use-value "programs/assets/b.bin"))]
(let [v (slurp "programs/assets/does-not-exist")]
(println (len v)) ; 3
(println (string (as-slice v))) ; BBB
(free v)))
(println seen) ; 1
(println (= last-reason file-missing)) ; true
(println (= last-op file-op-read)) ; true
;; The condition carries the path that actually failed, not the one that
;; eventually worked — the handler is told what it is answering about.
(println last-path) ; programs/assets/does-not-exist
;; ── barf, and reading back what it wrote ──────────────────────────
(barf "slurp-out.txt" (bytes "round trip\n"))
(let [v (slurp "slurp-out.txt")]
(println (len v)) ; 11
(print (string (as-slice v))) ; round trip
(free v))
;; barf's own failure signals the same condition with op = write. A directory
;; that does not exist is the reachable case on every platform.
(set seen 0)
(handler-bind
[(FileError [c]
(set seen (+ seen 1))
(set last-op (.op c))
(invoke-restart 'use-value "slurp-out.txt"))]
(barf "no-such-dir/x.txt" (bytes "second\n")))
(println seen) ; 1
(println (= last-op file-op-write)) ; true
(let [v (slurp "slurp-out.txt")]
(print (string (as-slice v))) ; second
(free v))
;; ── retry: the file was not there, so the handler makes it ────────
;; The other restart, and the one use-value cannot stand in for: here the
;; path is right and the world is wrong. The handler fixes the world and
;; re-attempts the *same* request, which is exactly what retry means for a
;; failed allocation too.
(set seen 0)
(handler-bind
[(FileError [c]
(set seen (+ seen 1))
(set last-reason (.reason c))
(barf "slurp-made.txt" (bytes "made by the handler\n"))
(invoke-restart 'retry))]
(let [v (slurp "slurp-made.txt")]
(print (string (as-slice v))) ; made by the handler
(free v)))
(println seen) ; 1
(println (= last-reason file-missing)) ; true
0)

1
test/slurp-made.txt Normal file
View File

@ -0,0 +1 @@
made by the handler

1
test/slurp-out.txt Normal file
View File

@ -0,0 +1 @@
second

View File

@ -453,6 +453,59 @@ let () =
end;
(try Sys.remove exe with Sys_error _ -> ());
(* -- Files, NEXT.md decisions 1, 2 and 5 --------------------------
Embedding first, because it is the one that costs nothing at run time
and needs no host ABI at all: a compiler feature, so no linker
arguments, no per-target packaging, and identical on desktop and web.
At -O0 and as a dev build too - a dev build emits a defconst as a
mutable global, so the (embed-dir) constant travels a different path
there and is worth seeing twice. *)
let embed_out =
"13\nhello from a\nBBB\n4\n0\n255\n254\n3\na.txt\nb.bin\nraw.bin\nBBB\n\
no nope.txt\n"
in
outputs "embed, a file and a directory" "programs/embed.flan" embed_out;
outputs ~opt:"-O0" "embed, -O0" "programs/embed.flan" embed_out;
outputs ~dev:true "embed, dev" "programs/embed.flan" embed_out;
(* slurp and barf, with all three restart paths taken: use-value on a read,
use-value on a write, and retry after the handler made the file. The
typed restart is the thing being exercised as much as the file I/O -
this is the first restart clause the *compiler* emits with a parameter,
and its parameter is the path slot the attempt reads. *)
let slurp_out =
"13\nhello from a\n4\n0\n255\n3\nBBB\n1\ntrue\ntrue\n\
programs/assets/does-not-exist\n11\nround trip\n1\ntrue\nsecond\n\
made by the handler\n1\ntrue\n"
in
let clean () =
List.iter (fun f -> try Sys.remove f with Sys_error _ -> ())
[ "slurp-out.txt"; "slurp-made.txt" ]
in
clean ();
outputs "slurp and barf, with restarts" "programs/slurp.flan" slurp_out;
clean ();
outputs ~opt:"-O0" "slurp and barf, -O0" "programs/slurp.flan" slurp_out;
clean ();
(* A missing file with nothing handling it. The same rule StorageExhausted
follows: [error] is the diverging variant, so the program stops on the
frame that erred rather than carrying on with a Vec that was never
filled. Neither restart is taken and both were still offered. *)
let exe = compile "programs/slurp-unhandled.flan" in
let code, text = run exe None in
if code <> 134 || not (contains text "before")
|| not (contains text "unhandled FileError")
|| contains text "unreachable"
then begin
incr failures;
Printf.printf
"FAIL an unhandled FileError stops the program\n\
\ got: %S (exit %d)\n wanted: exit 134, naming the condition\n"
text code
end;
(try Sys.remove exe with Sys_error _ -> ());
(* The epoch trap: a container whose allocator has been released. This is
spec-memory.md's shipping answer to "Open: catching a use-after-release
statically" — detection, loud and immediate, rather than a static rule