From f88ce560733e688b5d4127fdab5019b11e2c84ac Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 11:38:24 +0700 Subject: [PATCH] slurp reads a whole file, barf writes one, and failure is a condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- test/programs/slurp-unhandled.flan | 10 +++ test/programs/slurp.flan | 103 +++++++++++++++++++++++++++++ test/slurp-made.txt | 1 + test/slurp-out.txt | 1 + test/test_acceptance.ml | 53 +++++++++++++++ 5 files changed, 168 insertions(+) create mode 100644 test/programs/slurp-unhandled.flan create mode 100644 test/programs/slurp.flan create mode 100644 test/slurp-made.txt create mode 100644 test/slurp-out.txt diff --git a/test/programs/slurp-unhandled.flan b/test/programs/slurp-unhandled.flan new file mode 100644 index 0000000..25c4b51 --- /dev/null +++ b/test/programs/slurp-unhandled.flan @@ -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) diff --git a/test/programs/slurp.flan b/test/programs/slurp.flan new file mode 100644 index 0000000..bb609d0 --- /dev/null +++ b/test/programs/slurp.flan @@ -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) diff --git a/test/slurp-made.txt b/test/slurp-made.txt new file mode 100644 index 0000000..e1de303 --- /dev/null +++ b/test/slurp-made.txt @@ -0,0 +1 @@ +made by the handler diff --git a/test/slurp-out.txt b/test/slurp-out.txt new file mode 100644 index 0000000..e019be0 --- /dev/null +++ b/test/slurp-out.txt @@ -0,0 +1 @@ +second diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index e71ec30..4a748c9 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -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