From 468dab6e4cafbdc5bb89c043c89beca1b0114304 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 10:46:24 +0700 Subject: [PATCH 1/3] Restarts take parameters, and the check for them is where it has to be MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec-conditions.md §3's remaining half: a clause binds parameters, an invoke-restart supplies them, and what a restart takes is compared at run time because a restart is found by name on a dynamic stack — neither end of the transfer can see the other. The parameters live in a buffer the restart-case owns, not the invoker's frame. A clause runs after every frame between the two has returned (§5), so anything on the invoking side is gone by then; the invoker stores into the target frame while both are still alive, which is the one moment they are. The frame carries the parameter count and a hash of how the types are spelled, and every frame carries them whether it takes parameters or not: a clause taking none has to refuse arguments as loudly as one taking two of the wrong type. The count is not redundant with the hash — it is what makes a 32-bit collision between two different signatures harmless — and the spelling itself rides along so that a mismatch can say what was wanted and what was given, which neither end alone knows. The arguments are evaluated into slots before the invoke node rather than hanging off it. An argument that transfers on its own is then guarded before anything aims the channel, and a call written in an argument is on the ordinary walk Reach and Load already do — a node they treat as a leaf would have dropped the function and failed to link. The other way a transfer starts is the break loop, which chooses by position and has nothing to fill parameters in with. It reaches a clause through the same channel, so nothing downstream could tell the two apart: the frame is pushed with the buffer marked unfilled and a clause with parameters checks that mark before reading it. Refused with the reason rather than run on values no one supplied. runtime/flan_rt.c gains two message functions and nothing else; the restart frame's first four fields, which are the ones C declares, do not move. --- lib/ast.ml | 20 +++-- lib/check.ml | 84 +++++++++++++++++-- lib/emit.ml | 159 ++++++++++++++++++++++++++++++++---- lib/load.ml | 35 ++++++-- lib/parse.ml | 42 ++++------ lib/tast.ml | 26 +++++- runtime/flan_rt.c | 34 ++++++++ test/programs/restarts.flan | 78 +++++++++++++++++- test/test_acceptance.ml | 96 +++++++++++++++++++++- test/test_flan.ml | 23 ++++-- 10 files changed, 517 insertions(+), 80 deletions(-) diff --git a/lib/ast.ml b/lib/ast.ml index 3edf084..c37147e 100644 --- a/lib/ast.ml +++ b/lib/ast.ml @@ -57,10 +57,11 @@ and expr_kind = A clause binds a name for the condition, so this cannot be a call. *) | HandlerBind of hclause list * expr list | Signal of sigkind * expr (* (signal c) / (error c) *) - (* (restart-case body (name [] body ...) ...) and (invoke-restart 'name). - Both alter control flow, so neither can be a call. *) + (* (restart-case body (name [p T] body ...) ...) and + (invoke-restart 'name arg ...). Both alter control flow, so neither can be + a call, and a clause binds its parameters — §3. *) | RestartCase of expr * rclause list - | InvokeRestart of string + | InvokeRestart of string * expr list (* Two ways to signal, because they are two different things — §1 and §2. [signal] returns Unit whatever it finds; [error] has type Never and, with @@ -68,7 +69,16 @@ and expr_kind = and sigkind = Ssignal | Serror and hclause = { hty : texpr; hname : string; hbody : expr list; hloc : Loc.t } -and rclause = { rname : string; rbody : expr list; rloc : Loc.t } +(* [rparams] are §3's inline annotations, the same name/type pairs a [defn] + takes. They are bound in the clause body and filled in by whatever invoked + the restart, which is why their count and types are checked at run time + (§3): a restart is found by name on a dynamic stack. *) +and rclause = + { rname : string; rparams : field list; rbody : expr list; rloc : Loc.t } + +(* Inline name/type pairs, as in [defn], [let] and [defstruct]. Here because a + restart clause's parameters are one, and a clause is part of an expression. *) +and field = { fname : string; fty : texpr; floc : Loc.t } (* Two unwrap operators, because they are two different things — plan.org. *) and unwrap = Usome | Utry @@ -90,8 +100,6 @@ and pattern = (* ── Declarations ──────────────────────────────────────────────────── *) -type field = { fname : string; fty : texpr; floc : Loc.t } - type fn = { name : string; params : field list; diff --git a/lib/check.ml b/lib/check.ml index e85f043..931e09c 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -338,6 +338,21 @@ let type_id name = name; !h +(* How a restart's parameter list is spelled, and with it what the two ends of + an [invoke-restart] compare — spec-conditions.md §3's run-time check. A + restart is found by name on a dynamic stack, so neither end can see the + other and nothing static can be checked: what is compared at run time is + this string's hash, alongside the count, and the string itself is carried so + that a mismatch can say what was wanted and what was given. + + Comparing a 32-bit hash means two different parameter lists could in + principle collide. The count is checked separately, which rules out every + practical case (a collision would have to be between two lists of the same + length), and the types are parenthesised so that [(Option i32)] cannot read + as two parameters. *) +let restart_sig tys = + "(" ^ String.concat " " (List.map Types.to_string tys) ^ ")" + let expect loc ~want (got : Tast.expr) = match want with | None -> got @@ -489,18 +504,48 @@ let rec check ctx ?want (e : Ast.expr) : Tast.expr = establishes frames around a body, and the other leaves the function it is written in — so both are their own nodes all the way down. *) | Ast.RestartCase (body, clauses) -> check_restart_case ctx ?want loc body clauses - | Ast.InvokeRestart name -> + | Ast.InvokeRestart (name, args) -> (* Never: control resumes at the restart-case, which yields the clause's value to *its* continuation, so nothing here has a value and nothing after it runs. The lookup is at run time because restarts are - dynamically scoped and named — §4. *) + dynamically scoped and named — §4 — and so, for the same reason, is the + check that these arguments are the ones the clause takes (§3). *) if ctx.in_defer then fail loc "invoke-restart is not allowed inside a defer — a defer is the cleanup \ a transfer runs on its way out, so starting one there would leave \ this function's defers half run with two targets and no way to \ choose"; - expect loc ~want (mk loc Types.Never (Tast.InvokeRestart (type_id name, name, loc))) + let args = map_lr (fun a -> check ctx a) args in + List.iter + (fun (a : Tast.expr) -> + match a.Tast.ty with + | Types.Unit | Types.Never -> + fail a.Tast.loc + "a restart argument must be a value, and this one is %s" + (Types.to_string a.Tast.ty) + | _ -> ()) + args; + let sg = restart_sig (List.map (fun (a : Tast.expr) -> a.Tast.ty) args) in + (* Evaluated into slots first, so that an argument which transfers on its + own is guarded before this form aims the channel, and so that a call + written in an argument is on the ordinary walk rather than hidden + inside a node that [Reach] and [Load] treat as a leaf. *) + let binds = + List.map (fun (a : Tast.expr) -> (fresh_slot ctx a.Tast.ty, a)) args + in + let locals = + List.map + (fun (s, (a : Tast.expr)) -> mk a.Tast.loc a.Tast.ty (Tast.Local s)) + binds + in + let invoke = + mk loc Types.Never + (Tast.InvokeRestart (type_id name, name, locals, sg, type_id sg, loc)) + in + expect loc ~want + (if binds = [] then invoke + else mk loc Types.Never (Tast.Let (binds, [ invoke ]))) | Ast.Defer _ -> (* Registered by [check_fn], which is the only place that sees a form's @@ -703,13 +748,36 @@ and check_restart_case ctx ?want loc body clauses = if List.mem c.Ast.rname !seen then fail c.Ast.rloc "this restart-case offers %s twice" c.Ast.rname; seen := c.Ast.rname :: !seen; - (* Each is checked against what the form has settled on so far, so a - clause that disagrees fails where it is written. The first one to - produce a value is what settles it when nothing outside did. *) - let b = block ctx ?want:!ty c.Ast.rloc c.Ast.rbody in + (* §3's parameters. They are slots in *this* function — a clause runs + here, not where the invoke was — and the invoker stores into a + buffer this frame owns, because its own frame is gone by the time + the clause body starts (§5). Bound like a function's parameters: + visible only in the clause, and not assignable. *) + let params, b = + scoped ctx (fun () -> + let params = + List.map + (fun (p : Ast.field) -> + let ty = resolve ctx.env p.Ast.fty in + (match ty with + | Types.Unit | Types.Never -> + fail p.Ast.floc + "%s would be a restart parameter of type %s, which is \ + not a value" p.Ast.fname (Types.to_string ty) + | _ -> ()); + (bind ctx p.Ast.fname ty ~assignable:false, ty)) + c.Ast.rparams + in + (* Each is checked against what the form has settled on so far, so + a clause that disagrees fails where it is written. The first one + to produce a value is what settles it when nothing outside + did. *) + (params, block ctx ?want:!ty c.Ast.rloc c.Ast.rbody)) + in if !ty = None && b.Tast.ty <> Types.Never then ty := Some b.Tast.ty; + let sg = restart_sig (List.map snd params) in { Tast.rname_id = type_id c.Ast.rname; rname = c.Ast.rname; - rbody = [ b ] }) + rparams = params; rsig = sg; rsig_id = type_id sg; rbody = [ b ] }) clauses in let ty = match !ty with Some t -> t | None -> Types.Never in diff --git a/lib/emit.ml b/lib/emit.ml index 44f2dd7..d13093a 100644 --- a/lib/emit.ml +++ b/lib/emit.ml @@ -638,7 +638,11 @@ and value_at f (e : Tast.expr) : string = (* §4's lookup, then the transfer itself: the frame that was found goes into the channel and this function leaves through its landing block. Type Never, so nothing follows. *) - | Tast.InvokeRestart (id, name, rloc) -> + | Tast.InvokeRestart (id, name, args, sg, sg_id, rloc) -> + (* The arguments are already in slots — the checker put them there, so an + argument that transferred on its own has been guarded before anything + here runs. *) + let vals = List.map (fun a -> (value f a, a.Tast.ty)) args in let t = fresh f in ins f "%s = call ptr @flan_find_restart(i32 %d)" t id; let ok = fresh f in @@ -649,6 +653,52 @@ and value_at f (e : Tast.expr) : string = let nid, nn = string_bytes f.md name in ins f "call void @flan_restart_fail(ptr %s, i64 %d, ptr %s, i64 %d)" id n nid nn); + (* §3's run-time check. A restart is found by name on a dynamic stack, so + what it takes is not knowable here: the frame carries its parameter + count and the hash of how they are spelled, and both are compared. + The count is not redundant with the hash — it is what makes a 32-bit + collision between two different signatures harmless in practice — and + it is also the cheaper half. *) + let arity = fresh f in + ins f "%s = load i32, ptr %s" arity (restart_field f t 5); + let a_ok = fresh f in + ins f "%s = icmp eq i32 %s, %d" a_ok arity (List.length args); + let want = fresh f in + ins f "%s = load i32, ptr %s" want (restart_field f t 6); + let s_ok = fresh f in + ins f "%s = icmp eq i32 %s, %d" s_ok want sg_id; + let both = fresh f in + ins f "%s = and i1 %s, %s" both a_ok s_ok; + fail_block f rloc both (fun id n -> + let nid, nn = string_bytes f.md name in + (* What the frame says it takes is read off the frame, because only the + frame knows; what was given is this call site's own spelling. *) + let wp = fresh f in + ins f "%s = load ptr, ptr %s" wp (restart_field f t 8); + let wl = fresh f in + ins f "%s = load i64, ptr %s" wl (restart_field f t 9); + let gid, gn = string_bytes f.md sg in + ins f + "call void @flan_restart_args_fail(ptr %s, i64 %d, ptr %s, i64 %d, \ + ptr %s, i64 %s, ptr %s, i64 %d)" id n nid nn wp wl gid gn); + (* Into the buffer the target frame owns, field by field: this frame is + about to go, and the clause runs after it has. The layout is the one the + signature just agreed on. *) + if vals <> [] then begin + let buf = fresh f in + ins f "%s = load ptr, ptr %s" buf (restart_field f t 4); + let sty = + "{ " ^ String.concat ", " (List.map (fun (_, ty) -> ll ty) vals) ^ " }" + in + List.iteri + (fun i (v, ty) -> + let p = fresh f in + ins f "%s = getelementptr inbounds %s, ptr %s, i32 0, i32 %d" + p sty buf i; + ins f "store %s %s, ptr %s" (ll ty) v p) + vals; + ins f "store i32 1, ptr %s" (restart_field f t 7) + end; ins f "store ptr %s, ptr %s" t xfer_param; term f "br label %%%s" (current_pad f); "zeroinitializer" @@ -898,7 +948,19 @@ and emit_handled f frames body = if not reached then begin f.live <- false; "zeroinitializer" end else begin label f ld; "zeroinitializer" end -(* (restart-case BODY (name [] BODY-1) ...) — §3, §4 and §6 together. +(* A clause's parameters, as one LLVM struct: what the invoker stores into and + what the clause loads out of. The two ends never see each other, so the + layout is agreed by the signature hash they compare first — same types in + the same order is the same struct. *) +and args_type (c : Tast.rclause) = + "{ " ^ String.concat ", " (List.map (fun (_, t) -> ll t) c.Tast.rparams) ^ " }" + +and restart_field f slot i = + let p = fresh f in + ins f "%s = getelementptr inbounds %%restart, ptr %s, i32 0, i32 %d" p slot i; + p + +(* (restart-case BODY (name [p T] BODY-1) ...) — §3, §4 and §6 together. One frame per clause, so that the frame a transfer names says which clause to run: the address is the identity, which is exact where a number would @@ -909,33 +971,55 @@ and emit_handled f frames body = §5's defers between here and the invoke have already run — each function on the way out ran its own before returning. What is left here is to take these - frames off and start the clause. *) + frames off, copy §3's parameters out of the buffer the invoker filled, and + start the clause. + + The parameters live in a buffer this frame owns, not the invoker's: by the + time a clause runs, every frame between the two has returned, so anything on + the invoking side is gone. The invoker stores into it while both are alive, + which is the one moment they are. *) and emit_restart_case f ty clauses body = let result = if is_void ty then None else Some (alloca f ty) in let frames = map_lr (fun (c : Tast.rclause) -> let slot = alloca_raw f "%restart" in - let nid = fresh f in - ins f "%s = getelementptr inbounds %%restart, ptr %s, i32 0, i32 1" - nid slot; - ins f "store i32 %d, ptr %s" c.Tast.rname_id nid; + ins f "store i32 %d, ptr %s" c.Tast.rname_id (restart_field f slot 1); (* The name itself, beside the hash. A hash is all that matching needs, but a break loop has to *show* someone their choices, and nothing at run time can turn a hash back into a name. *) let sid, slen = string_bytes f.md c.Tast.rname in - let np = fresh f in - ins f "%s = getelementptr inbounds %%restart, ptr %s, i32 0, i32 2" - np slot; - ins f "store ptr %s, ptr %s" sid np; - let nl = fresh f in - ins f "%s = getelementptr inbounds %%restart, ptr %s, i32 0, i32 3" - nl slot; - ins f "store i64 %d, ptr %s" slen nl; + ins f "store ptr %s, ptr %s" sid (restart_field f slot 2); + ins f "store i64 %d, ptr %s" slen (restart_field f slot 3); + (* §3's signature, which every frame carries whether it takes + parameters or not: an [invoke-restart] compares against whatever + frame the name found, and a clause taking none has to be able to + refuse arguments as loudly as one taking two of the wrong type. *) + ins f "store i32 %d, ptr %s" + (List.length c.Tast.rparams) (restart_field f slot 5); + ins f "store i32 %d, ptr %s" c.Tast.rsig_id (restart_field f slot 6); + let gid, glen = string_bytes f.md c.Tast.rsig in + ins f "store ptr %s, ptr %s" gid (restart_field f slot 8); + ins f "store i64 %d, ptr %s" glen (restart_field f slot 9); + let args = + if c.Tast.rparams = [] then None + else begin + let buf = alloca_raw f (args_type c) in + ins f "store ptr %s, ptr %s" buf (restart_field f slot 4); + (* Nothing has filled it in yet. Whoever aims a transfer at this + frame without going through an [invoke-restart] — the break + loop, today — leaves this zero, and the clause traps rather + than running on values no one supplied. *) + ins f "store i32 0, ptr %s" (restart_field f slot 7); + Some buf + end + in ins f "call void @flan_restart_push(ptr %s)" slot; - slot) + (slot, args)) clauses in + let args_of slot = List.assoc slot frames in + let frames = List.map fst frames in let pop () = List.iter (fun slot -> ins f "call void @flan_restart_pop(ptr %s)" slot) @@ -966,6 +1050,37 @@ and emit_restart_case f ty clauses body = guarded like any other; it must not start with the channel still set. *) ins f "store ptr null, ptr %s" xfer_param; pop (); + (* §3's parameters, copied out of the frame's buffer into the clause's own + slots before its body starts. The frame is still addressable — it is an + alloca of *this* function — and the buffer is whatever the invoker left + there. *) + let bind_params slot (c : Tast.rclause) = + match args_of slot with + | None -> () + | Some buf -> + let armed = fresh f in + ins f "%s = load i32, ptr %s" armed (restart_field f slot 7); + let ok = fresh f in + ins f "%s = icmp ne i32 %s, 0" ok armed; + (* Aimed here by something that supplied no arguments — there is no such + path from an [invoke-restart], so this is the break loop taking a + restart it cannot yet fill in. Refused with the reason, rather than + running the clause on a buffer nobody wrote. *) + fail_block f (List.hd c.Tast.rbody).Tast.loc ok (fun id n -> + let nid, nn = string_bytes f.md c.Tast.rname in + let gid, gn = string_bytes f.md c.Tast.rsig in + ins f + "call void @flan_restart_unarmed(ptr %s, i64 %d, ptr %s, i64 %d, \ + ptr %s, i64 %d)" id n nid nn gid gn); + List.iteri + (fun i (slot_i, ty) -> + let p = fresh f in + ins f "%s = getelementptr inbounds %s, ptr %s, i32 0, i32 %d" + p (args_type c) buf i; + let v = load f p ty in + ins f "store %s %s, ptr %s" (ll ty) v f.slots.(slot_i)) + c.Tast.rparams + in let rec dispatch = function | [] -> ins f "store ptr %s, ptr %s" tgt xfer_param; @@ -976,6 +1091,7 @@ and emit_restart_case f ty clauses body = ins f "%s = icmp eq ptr %s, %s" t tgt slot; term f "br i1 %s, label %%%s, label %%%s" t hit next; label f hit; + bind_params slot c; yield (block f c.Tast.rbody); label f next; dispatch rest @@ -1545,7 +1661,14 @@ let header = {|; Generated by flan. The layout is C's: no object headers anywher ; target field, because the frame's own address *is* the target — which makes ; a transfer's aim exact, and makes re-entering a restart-case work with ; nothing extra, since each activation allocates its own. -%restart = type { ptr, i32, ptr, i64 } +; +; Then §3's parameters: the buffer the clause reads them out of — owned by the +; restart-case, because the invoker's frame is gone by the time a clause runs — +; how many there are, the hash of how they are spelled, whether anything has +; filled the buffer in, and that spelling itself for the message when the two +; ends disagree. The first four fields are what the runtime's own +; [flan_restart] declares and their offsets do not move. +%restart = type { ptr, i32, ptr, i64, ptr, i32, i32, i32, ptr, i64 } declare void @flan_rt_init(i32, ptr) declare void @flan_argv(ptr) @@ -1565,6 +1688,8 @@ declare void @flan_restart_push(ptr) declare void @flan_restart_pop(ptr) declare ptr @flan_find_restart(i32) 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_unarmed(ptr, i64, ptr, i64, ptr, i64) noreturn cold declare void @flan_transfer_fail(ptr, i64) noreturn cold declare void @flan_bounds_fail(ptr, i64, i64, i64) noreturn cold declare void @flan_slice_fail(ptr, i64, i64, i64, i64) noreturn cold diff --git a/lib/load.ml b/lib/load.ml index a1e1a3d..5514be6 100644 --- a/lib/load.ml +++ b/lib/load.ml @@ -202,15 +202,29 @@ let rec rename_expr owned alias bound (e : Ast.expr) : Ast.expr = | Ast.Unwrap (u, v) -> Ast.Unwrap (u, go v) | Ast.Signal (k, c) -> Ast.Signal (k, go c) (* A restart name is not a top-level name — it is looked up on the restart - stack, not in the environment — so an import does not qualify it. Only - the bodies are rewritten. *) + stack, not in the environment — so an import does not qualify it. The + bodies are rewritten, and so are a clause's parameter types, which name + types like any other annotation; the parameters themselves bind inside + the clause and shadow a package name there. *) | Ast.RestartCase (body, clauses) -> Ast.RestartCase (go body, List.map - (fun (c : Ast.rclause) -> { c with Ast.rbody = gos c.Ast.rbody }) + (fun (c : Ast.rclause) -> + let ps = + List.map + (fun (p : Ast.field) -> + { p with Ast.fty = rename_texpr owned alias p.Ast.fty }) + c.Ast.rparams + in + let bound = + List.map (fun (p : Ast.field) -> p.Ast.fname) ps @ bound + in + { c with + Ast.rparams = ps; + rbody = List.map (rename_expr owned alias bound) c.Ast.rbody }) clauses) - | Ast.InvokeRestart _ -> e.Ast.e + | Ast.InvokeRestart (n, args) -> Ast.InvokeRestart (n, gos args) (* A clause names a condition *type*, which an import renames like any other, and binds a name for the condition inside its own body. *) | Ast.HandlerBind (clauses, body) -> @@ -336,8 +350,11 @@ let rec expr_uses acc (e : Ast.expr) = let go = expr_uses acc in let gos = List.iter go in match e.Ast.e with - | Ast.Int _ | Ast.Float _ | Ast.Byte _ | Ast.Str _ | Ast.Kw _ | Ast.Quote _ - | Ast.InvokeRestart _ -> () + | Ast.Int _ | Ast.Float _ | Ast.Byte _ | Ast.Str _ | Ast.Kw _ | Ast.Quote _ -> + () + (* The name is not one an import can supply, but the arguments are ordinary + expressions and may well use one. *) + | Ast.InvokeRestart (_, args) -> gos args | Ast.Var n -> acc := (n, e.Ast.loc) :: !acc | Ast.Do body -> gos body | Ast.Let (bs, body) -> @@ -365,7 +382,11 @@ let rec expr_uses acc (e : Ast.expr) = | Ast.Signal (_, c) -> go c | Ast.RestartCase (body, clauses) -> go body; - List.iter (fun (c : Ast.rclause) -> gos c.Ast.rbody) clauses + List.iter + (fun (c : Ast.rclause) -> + List.iter (fun (p : Ast.field) -> texpr_uses acc p.Ast.fty) c.Ast.rparams; + gos c.Ast.rbody) + clauses | Ast.HandlerBind (clauses, body) -> List.iter (fun (c : Ast.hclause) -> texpr_uses acc c.Ast.hty; gos c.Ast.hbody) diff --git a/lib/parse.ml b/lib/parse.ml index ef25878..8b708e3 100644 --- a/lib/parse.ml +++ b/lib/parse.ml @@ -256,47 +256,41 @@ and form f mk (head : Form.t) (args : Form.t list) : Ast.expr = in mk (Ast.HandlerBind (List.map clause clauses, body_of body)) - (* (restart-case BODY (name [] BODY-1) ...) — spec-conditions.md §3. - The body and every clause have the same type, which is the form's. - Restarts take no parameters in this version; a clause that declares one is - rejected below rather than ignored. *) + (* (restart-case BODY (name [p T ...] BODY-1) ...) — spec-conditions.md §3. + The body and every clause have the same type, which is the form's. A + clause's parameters are inline name/type pairs, like any other binding + form; what fills them in is the [invoke-restart] that chose the clause. *) | Sym "restart-case" -> let body, clauses = match args with | body :: clauses when clauses <> [] -> (body, clauses) | _ -> - fail f "restart-case is (restart-case body (name [] body ...) ...)" + fail f "restart-case is (restart-case body (name [p T] body ...) ...)" in let clause (c : Form.t) = match c.Form.v with | Form.List ({ v = Form.Sym n; _ } :: { v = Form.Vec ps; _ } :: cbody) when cbody <> [] -> - if ps <> [] then - fail c - "a restart takes no parameters yet — spec-conditions.md §3 has \ - them, and they need argument marshalling and a runtime arity \ - check that this version does not do"; - { Ast.rname = n; rbody = List.map expr cbody; rloc = c.Form.loc } - | _ -> fail c "a restart-case clause is (name [] body ...)" + { Ast.rname = n; rparams = fields c ps; + rbody = List.map expr cbody; rloc = c.Form.loc } + | _ -> fail c "a restart-case clause is (name [p T] body ...)" in mk (Ast.RestartCase (expr body, List.map clause clauses)) - (* (invoke-restart 'name) : Never. The name is a quoted symbol — that is what - the reader's quote is for — and it is resolved on the restart stack at run - time, since restarts are dynamically scoped. *) + (* (invoke-restart 'name arg ...) : Never. The name is a quoted symbol — that + is what the reader's quote is for — and it is resolved on the restart + stack at run time, since restarts are dynamically scoped. The arguments + fill in the clause's parameters, and how many there are and what they are + is settled at run time too, against the frame the name found (§3). *) | Sym "invoke-restart" -> (match args with - | [ { v = Form.List [ { v = Form.Sym "quote"; _ }; { v = Form.Sym n; _ } ]; _ } ] -> - mk (Ast.InvokeRestart n) - | [ _ ] -> - fail f - "invoke-restart takes a quoted restart name, as in \ - (invoke-restart 'use-placeholder)" + | { v = Form.List [ { v = Form.Sym "quote"; _ }; { v = Form.Sym n; _ } ]; _ } + :: rest -> + mk (Ast.InvokeRestart (n, List.map expr rest)) | _ -> fail f - "a restart takes no arguments yet — spec-conditions.md §3 has them, \ - and they need argument marshalling and a runtime arity check that \ - this version does not do") + "invoke-restart takes a quoted restart name and then its arguments, \ + as in (invoke-restart 'use-value 42)") (* ── macros ────────────────────────────────────────────────────── *) (* The reader now produces these three, so they arrive here as ordinary heads diff --git a/lib/tast.ml b/lib/tast.ml index 0e17f01..5b99033 100644 --- a/lib/tast.ml +++ b/lib/tast.ml @@ -81,9 +81,20 @@ and expr_kind = *its* frames it runs that clause instead, and the whole form yields either way. [InvokeRestart] looks the name up on the restart stack, writes the frame it found into the transfer channel and leaves — it has type Never, - so nothing follows it. *) + so nothing follows it. + + [InvokeRestart]'s arguments are already evaluated: the checker binds each + to a slot and wraps the node in a [Let], so what is left here is a list of + locals to copy into the frame. Two reasons, and both matter. An argument + that transfers on its own must be guarded before this one aims the + channel; and a call written in an argument has to be on the walk [Reach] + and [Load] already do, which a list hanging off a node they treat as a + leaf would not be. [rsig] is the argument types as written, and [rsig_id] + their hash — §3's run-time check, since the name is resolved on a stack + nothing static can see. *) | RestartCase of rclause list * expr - | InvokeRestart of int * string * Loc.t (* name id, name, where *) + (* name id, name, arguments, their spelling, its hash, where *) + | InvokeRestart of int * string * expr list * string * int * Loc.t (* [Serror] is §2's diverging variant: the same lookup, type Never, and with nothing transferring the program stops rather than carrying on. *) @@ -102,8 +113,15 @@ and hframe = { htype : int; hfn : string } (* A restart clause. [rname_id] is what [invoke-restart] matches by name; the body is a branch in the function that wrote it, because unlike a handler a - clause runs at the restart-case, which is where it was written. *) -and rclause = { rname_id : int; rname : string; rbody : expr list } + clause runs at the restart-case, which is where it was written. + + [rparams] are the slots §3's parameters are bound to, in order, with their + types; the invoker stores into a buffer this frame owns and the clause loads + them from it. [rsig] is how those types are spelled and [rsig_id] its hash: + what the two ends compare, since neither can see the other. *) +and rclause = + { rname_id : int; rname : string; rparams : (int * Types.t) list; + rsig : string; rsig_id : int; rbody : expr list } (* [binds] are the slots the pattern's fields are bound to, in field order. *) and arm = { acase : string option; binds : int list; abody : expr list } diff --git a/runtime/flan_rt.c b/runtime/flan_rt.c index c649104..3b8fb33 100644 --- a/runtime/flan_rt.c +++ b/runtime/flan_rt.c @@ -398,6 +398,40 @@ _Noreturn void flan_restart_fail(const uint8_t *loc, int64_t loclen, rt_die(); } +/* The frame the name found does not take these arguments — spec-conditions.md + * §3's run-time check. It has to be at run time: a restart is resolved on a + * dynamic stack, so the invoke site cannot see what it will find, and the + * frame cannot see who will find it. What each end knows is its own parameter + * list, so the message is both of them side by side. */ +_Noreturn void flan_restart_args_fail(const uint8_t *loc, int64_t loclen, + const uint8_t *name, int64_t namelen, + const uint8_t *want, int64_t wantlen, + const uint8_t *got, int64_t gotlen) { + fflush(stdout); + fprintf(stderr, "%.*s: restart %.*s takes %.*s, given %.*s\n", + (int)loclen, (const char *)loc, (int)namelen, (const char *)name, + (int)wantlen, (const char *)want, (int)gotlen, (const char *)got); + rt_die(); +} + +/* A clause with parameters was reached by a transfer that filled none of them + * in. No [invoke-restart] can do that — it writes the arguments before it aims + * the channel — so this is the other way a transfer starts: the break loop, + * which today takes a restart by position and has no way to supply a value. + * Refused at the clause rather than run on a buffer nobody wrote. */ +_Noreturn void flan_restart_unarmed(const uint8_t *loc, int64_t loclen, + const uint8_t *name, int64_t namelen, + const uint8_t *want, int64_t wantlen) { + fflush(stdout); + fprintf(stderr, + "%.*s: restart %.*s takes %.*s, and whatever took it supplied no " + "arguments — a restart with parameters cannot be taken from the " + "break loop yet\n", + (int)loclen, (const char *)loc, (int)namelen, (const char *)name, + (int)wantlen, (const char *)want); + rt_die(); +} + /* Something a defer called invoked a restart. A defer is the cleanup a * transfer runs on its way out (§5), so a transfer starting there would leave * this frame's defers half run with two targets and no way to choose. The diff --git a/test/programs/restarts.flan b/test/programs/restarts.flan index 1515e5b..d38baf2 100644 --- a/test/programs/restarts.flan +++ b/test/programs/restarts.flan @@ -3,7 +3,12 @@ ;;;; The transfer. A handler runs where the signal was, decides, and control ;;;; resumes at a restart-case further out: every function in between returns ;;;; early with the target in the channel, running its defers on the way (§5). -;;;; Restarts take no parameters in this version. +;;;; +;;;; §3's parameters are here too, along with the run-time check they need: the +;;;; supply-a-value half of the vocabulary, which is the half whose answer comes +;;;; from outside the program. With no argument this program is the exit-0 case +;;;; the table pins; with one it selects a mismatch, which traps and is asserted +;;;; on its reason. (defstruct AssetMissing [id i32]) (defvar log i64) @@ -46,7 +51,53 @@ 0) (use-placeholder [] -2))) -(defn main [] i32 +;;; Called from nowhere but inside an [invoke-restart]'s argument list. +(defn half [x i32] i32 (/ x 2)) + +;;; §3: a clause with parameters. The value comes from the handler, which is +;;; the whole point — [use-value] and [store-value] are the two restarts whose +;;; answer is not in the program. The parameters are slots of *this* function +;;; and the invoker fills a buffer this frame owns, because by the time the +;;; clause runs the invoking frame has gone (§5). +(defn supplied [n i32] i32 + (restart-case (middle n) + (use-value [v i32] (* v 2)) + (use-pair [a i32 b i32] (+ a b)) + (retry [] 7))) + +;;; Parameters of more than one type, and one that is not a machine word: a +;;; string is ptr+len and crosses the transfer as the two of them. +(defn labelled [n i32] i32 + (restart-case (middle n) + (use-labelled [label string v i32] + (do (print label) (println "") v)))) + +;;; The mismatch cases. Each is selected by the argument, because each stops +;;; the program: what a restart takes is not knowable where it is invoked, so +;;; §3 checks it at run time and this is what that check refuses. +(defn mismatched [n i32] i32 + (handler-bind [(AssetMissing [c] (invoke-restart 'use-value))] + (supplied n))) + +(defn mistyped [n i32] i32 + (handler-bind [(AssetMissing [c] (invoke-restart 'use-value "forty-one"))] + (supplied n))) + +(defn overfull [n i32] i32 + (handler-bind [(AssetMissing [c] (invoke-restart 'retry 1))] + (supplied n))) + +(defn main [args [string]] i32 + ;; One argument selects a trap; none runs the table's case. + (if (> (len args) 1) + (let [k (i32 (bytes->i64 (bytes (at args 1))))] + (cond + (= k 1) (print (mismatched 90)) + (= k 2) (print (mistyped 91)) + (= k 3) (print (overfull 92)) + :else (println "?")) + (return 0))) + ;; Nothing handles it, so signal is a no-op and the body's own value stands. (print (fetch 1)) (println "") ; 101 (print log) (println "") ; 1 @@ -77,4 +128,27 @@ ;; the trap case in the acceptance table rather than a line here. (handler-bind [(AssetMissing [c] (invoke-restart 'use-placeholder))] (print (strict 6)) (println "")) ; -2 + + ;; §3: the handler supplies the value, and the clause computes with it — + ;; the arithmetic is in the clause so that a transfer which forgot to copy + ;; the argument could not pass by returning what it was given. + (handler-bind [(AssetMissing [c] (invoke-restart 'use-value 21))] + (print (supplied 7)) (println "")) ; 42 + ;; Two parameters, so their order is pinned: 1 and 2 would sum the same + ;; whichever way round they landed. + (handler-bind [(AssetMissing [c] (invoke-restart 'use-pair 30 4))] + (print (supplied 8)) (println "")) ; 34 + ;; A clause with no parameters is still reachable from a restart-case that + ;; has some, and an invoke with no arguments still matches it. + (handler-bind [(AssetMissing [c] (invoke-restart 'retry))] + (print (supplied 9)) (println "")) ; 7 + ;; A string and an integer together: two different widths, and the string is + ;; ptr+len rather than a machine word. + (handler-bind [(AssetMissing [c] (invoke-restart 'use-labelled "supplied" 5))] + (print (labelled 10)) (println "")) ; supplied / 5 + ;; The argument is an ordinary expression, evaluated where the invoke is — + ;; here a call, and [half] is reached from nowhere else, so a walk that did + ;; not look inside an invoke-restart would drop it and fail to link. + (handler-bind [(AssetMissing [c] (invoke-restart 'use-value (half 42)))] + (print (supplied 12)) (println "")) ; 21 * 2 0) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 05b46a2..eac002a 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -256,11 +256,100 @@ let () = shadowing an outer one of the same name, and a handler that returns normally still transferring nothing. At -O0 as well, because the guard after every call is control flow the optimiser would otherwise launder; - and as a dev build, where every one of those calls goes through a cell. *) - let restarts_out = "101\n1\n-1\n2\n7\n1010\n101\n105\n-2\n" in + and as a dev build, where every one of those calls goes through a cell. + + Then §3's parameters: one, two of them in an order a sum would not pin, + a string beside an integer, a clause taking none in the same form as + clauses taking some, and an argument that is a call to a function + reached from nowhere else. The last one is the reachability claim — an + invoke-restart whose arguments were not walked would drop [half] and + fail to link, which is why the arguments are evaluated into slots before + the node rather than hanging off it. *) + let restarts_out = + "101\n1\n-1\n2\n7\n1010\n101\n105\n-2\n42\n34\n7\nsupplied\n5\n42\n" + in outputs "restarts" "programs/restarts.flan" restarts_out; outputs ~opt:"-O0" "restarts, -O0" "programs/restarts.flan" restarts_out; outputs ~dev:true "restarts, dev" "programs/restarts.flan" restarts_out; + (* §3's run-time check, which is the price of a restart being found by name + on a dynamic stack: neither end of an invoke can see the other, so what + a clause takes against what was given is settled where the transfer + starts. Each of these stops the program, so each is asserted on its + reason rather than on the exit status alone — too few arguments, the + right count of the wrong type, and arguments handed to a clause that + takes none. *) + let restart_mismatch ?opt () = + let exe = compile ?opt "programs/restarts.flan" in + let refuses name arg reason = + let code, text = run exe (Some arg) in + if code <> 134 + || not (contains text "programs/restarts.flan:") + || not (contains text reason) + then begin + incr failures; + Printf.printf + "FAIL %s\n got: %S (exit %d)\n wanted: %S (exit 134)\n" + name text code reason + end + in + refuses "a restart invoked with too few arguments" "1" + "restart use-value takes (i32), given ()"; + refuses "a restart invoked with the wrong type" "2" + "restart use-value takes (i32), given (string)"; + refuses "arguments given to a restart that takes none" "3" + "restart retry takes (), given (i32)"; + (try Sys.remove exe with Sys_error _ -> ()) + in + restart_mismatch (); + restart_mismatch ~opt:"-O0" (); + (* The other way a transfer starts is the break loop, which chooses a + restart by position and has nothing to fill parameters in with. It + reaches the clause through the same channel an invoke-restart writes, so + nothing downstream could tell the two apart — except that the clause's + buffer is still the zero the frame was pushed with. Asserted on the IR, + because driving it needs a stopped program and a socket, and what is + being claimed is that the guard exists at all. *) + let p = + Reader.read_file "programs/restarts.flan" |> Parse.program |> Check.program + in + if not (contains (Emit.program p) "call void @flan_restart_unarmed(") then begin + incr failures; + print_endline + "FAIL a clause with parameters has no guard against being taken \ + without any" + end; + (* What is refused before anything runs, and why. Not everything about a + restart's arguments waits for run time: the shape of the form and + whether an argument is a value at all are here, and each is asserted on + its reason. *) + let refuses_src name src needle = + match Check.program (Parse.program (Reader.read_all ~file:"" src)) with + | _ -> + incr failures; + Printf.printf "FAIL %s\n it was accepted\n" name + | exception Loc.Error (_, m) -> + if not (contains m needle) then begin + incr failures; + Printf.printf "FAIL %s\n said: %S\n wanted: %S in it\n" + name m needle + end + in + (* The name is still a quoted symbol, and now it is the *first* of several + things, so an unquoted one has to say what the form is rather than read + as a call with a spare argument. *) + refuses_src "invoke-restart without a quoted name" + "(defn main [] i32 (invoke-restart use-value 1) 0)" + "a quoted restart name and then its arguments"; + (* A clause parameter is a binding, so it needs something to hold. *) + refuses_src "a restart parameter that is not a value" + "(defn main [] i32 (restart-case 0 (use-value [v Unit] 1)))" + "which is not a value"; + (* And so does an argument: a [println] is Unit, and there would be nothing + to store into the clause's buffer. *) + refuses_src "a restart argument that is not a value" + "(defn main [] i32 (restart-case 0 (use-value [v i32] v))\n\ + \ (invoke-restart 'use-value (println \"\")) 0)" + "a restart argument must be a value"; (* §2's other half, which cannot be an [outputs] case because it does not exit 0: a handler runs, returns normally, and has still not answered the error, so the program stops and names the condition. *) @@ -1512,10 +1601,11 @@ ERR@7 unexpected token: not the kind the caller was reading (defn fetch [n i32] i32\n\ \ (restart-case\n\ \ (do (error (Missing {:id n})) 0)\n\ + \ (use-value [v i32 s string] (do (print s) v))\n\ \ (use-placeholder [] -1)))\n\ (defn run [] i32\n\ \ (defer (set seen (+ seen 1)))\n\ - \ (handler-bind [(Missing [m] (invoke-restart 'use-placeholder))]\n\ + \ (handler-bind [(Missing [m] (invoke-restart 'use-value 4 \"\"))]\n\ \ (fetch 3)))\n\ (defn main [] i32\n\ \ (set (at arr 2) 9)\n\ diff --git a/test/test_flan.ml b/test/test_flan.ml index ff84835..be11e1e 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -811,17 +811,22 @@ let () = rejects_check "return inside restart-case" "(defn f [] i32 (restart-case (return 1) (skip [] 2)))" ~needle:"return is not allowed inside restart-case"; - (* The two halves of §3 this version does not do, each refused by name with - the reason rather than parsed into something that means less. *) - rejects_check "a restart with parameters" - "(defn f [] i32 (restart-case 1 (skip [n i32] n)))" - ~needle:"a restart takes no parameters yet"; - rejects_check "invoke-restart with arguments" - "(defn f [] (invoke-restart 'skip 1))" - ~needle:"a restart takes no arguments yet"; + (* §3's parameters. A clause binds them like a function's, so the body sees + them and nothing outside does; what they are is checked against the + invoke at run time, because the two ends meet on a dynamic stack. *) + accepts "a restart with parameters" + "(defn f [] i32 (restart-case 1 (skip [n i32] n)))"; + accepts "invoke-restart with arguments" + "(defn f [] (invoke-restart 'skip 1))"; + rejects_check "a restart parameter outside its clause" + "(defn f [] i32 (+ (restart-case 1 (skip [n i32] n)) n))" + ~needle:"unknown name n"; + rejects_check "a restart argument that is not a value" + "(defn f [] (invoke-restart 'skip (println \"\")))" + ~needle:"a restart argument must be a value"; rejects_check "invoke-restart on an unquoted name" "(defn f [] (invoke-restart skip))" - ~needle:"quoted restart name"; + ~needle:"a quoted restart name and then its arguments"; (* §5 runs the defers on the way out, so a defer is already the cleanup path a transfer uses. One that starts its own transfer has no answer. *) rejects_check "invoke-restart inside a defer" From 5184d732c94b467e5a070b2636863d160991234a Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 10:48:21 +0700 Subject: [PATCH 2/3] Say what a typed restart still cannot do, and who has to do it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The language half of §3's parameters is in; the half that makes it worth having is not. A break loop chooses by position and has nothing to fill a clause's parameters in with, and that is now the top item in NEXT.md, spelled out end to end — the accessors the frame can already answer, the signature on the wire, and the one store that has to happen before the channel is aimed. §3 asks for a clause's report string to be settled before parameters and it was not. The field is cheap and so is the accessor; the only thing that would read either is the break loop's listing, which lives in the agent and the daemon, so it would have shipped as a field nothing read. It belongs with the editor half, which is changing that listing anyway. --- BUILT.md | 67 ++++++++++++++++++++++++++++++++++++++++++++------ NEXT.md | 37 ++++++++++++++++++++-------- conditions.org | 34 ++++++++++++++++++++++--- 3 files changed, 116 insertions(+), 22 deletions(-) diff --git a/BUILT.md b/BUILT.md index 1fe5847..9a565ef 100644 --- a/BUILT.md +++ b/BUILT.md @@ -1069,14 +1069,8 @@ there are no function values yet, so nothing can call *back* into Flan across on Scope, each piece refused by name with its reason and a test on the reason: -- **restarts take no parameters.** That covers §1's own `load-texture` example and skips argument marshalling and §3's -runtime arity check. -- **TODO — CL-style interactive recovery.** A stopped program should be able to offer a typed restart such as -`(use-value [value T] value)` or `(use-function [replacement (Fn ...)] ...)`, and the editor should show its signature -and ask for the replacement before invoking it. That is the missing "this variable is None; what should I use instead?" -path: named, hard-coded branches are useful for `retry` and `skip`, but not a substitute for an interactive value or -alternate implementation. It needs restart argument marshalling and validation in the runtime, plus an editor protocol -for entering and checking the supplied value/expression. +- **restarts took no parameters.** That covered §1's own `load-texture` example and skipped argument marshalling and +§3's runtime check. Both are in now; see "Conditions — step 3" below. - `return` inside a `restart-case` body, exactly as inside `handler-bind`: a bare `ret` skips the pops. - one `restart-case` offering a name twice — §4 finds the first frame offering it, and two in one frame makes that a choice nothing in the source shows. @@ -1120,6 +1114,63 @@ else entirely. here: the guard after every call is control flow the optimiser would otherwise launder, and the dev build is where each of those calls goes through a cell. +### Conditions — step 3: restarts take parameters + +§3's other half, and the half the comparative studies all ask for: `use-value` and `store-value` are the two restarts +whose answer is not in the program. + +``` +(restart-case (middle n) + (use-value [v i32] (* v 2)) + (retry [] 7)) + +(handler-bind [(AssetMissing [c] (invoke-restart 'use-value 21))] (supplied 7)) ; 42 +``` + +**The parameters live in a buffer the restart-case owns.** The obvious place is the invoker's frame — it is where the +values are — and it is wrong: a clause runs after every frame between the invoke and the target has returned (§5), so +the invoking side is gone by then. The invoker stores into the *target's* buffer while both are still alive, which is +the one moment they are. A clause's parameters are then ordinary slots of the establishing function, loaded out of that +buffer in the clause's landing block, and the clause body is in-frame code that sees this function's scope like any +other. + +**What a restart takes is checked at run time, and it has to be.** §4 finds a restart by name on a dynamic stack: the +invoke site cannot see what it will find and the frame cannot see who will find it, so there is nothing for the checker +to compare. The frame therefore carries its parameter count and a 32-bit hash of how the types are spelled, and +`invoke-restart` compares both against its own before it stores anything. Every frame carries them, parameterless ones +included — a clause taking none has to refuse arguments as loudly as one taking two of the wrong type. The count is not +redundant with the hash: it is what keeps a hash collision between two *different* signatures harmless, since a +collision would then have to be between two lists of the same length. The spelling itself rides in the frame as well, +because the message has to say what was wanted and what was given and neither end knows both: + +``` +restarts.flan:79:36: restart use-value takes (i32), given () +restarts.flan:87:36: restart retry takes (), given (i32) +``` + +**The arguments are evaluated into slots before the invoke node, not hung off it.** Two reasons and both are real. An +argument can transfer on its own, and that guard must fire before anything aims the channel. And a call written inside +an argument has to be on the walk `Reach` and `Load` already do — `InvokeRestart` was a leaf to both, and a leaf that +grew a subexpression would have dropped a function that is called from nowhere else and failed in the linker. +`restarts.flan` has exactly that function, `half`, to keep the claim tested. + +**The break loop can take a restart it cannot fill in, so it is refused.** A transfer has two sources: an +`invoke-restart`, which writes the arguments first, and the break loop, which aims the channel at a frame by position +and has no value to supply. They reach a clause through the same channel by design, so nothing downstream can tell them +apart — which is what makes this the kind of hole that ships silently. The frame is pushed with its buffer marked +unfilled, `invoke-restart` marks it filled, and a clause with parameters checks the mark before reading. Choosing +`use-value` from a break loop today stops the program and says why. Filling it in is the editor half, and it is now the +top of `NEXT.md`: the answer is a Flan expression, and there is already something that compiles one against the live +program. + +`runtime/flan_rt.c` gained two message functions and nothing else. The restart frame's first four fields are the ones C +declares and their offsets do not move; everything §3 needed is appended after them, and C never allocates one. + +§3's other open point, a **report string per clause**, is still open and was not settled first as §3 asks. The field +and the accessor are both cheap; the only thing that would read them is the break loop's listing, which lives in the +agent and the daemon, so it would have landed as a field nothing read. It goes with the editor half, which is changing +that listing anyway. + ### What is left - **Editor comforts**: completion, eldoc, jump-to-definition, error overlays. diff --git a/NEXT.md b/NEXT.md index 4f2b851..8cc64e4 100644 --- a/NEXT.md +++ b/NEXT.md @@ -19,12 +19,19 @@ restarts and resumes into the choice. A restart is chosen **by position**, off a entered, because a name resolves to the innermost frame offering it and the stopped thread's stack does not hold still. Restarts below the evaluation a break is inside are listed, marked, and refused with the reason. -Still open from §3, each refused by name today: **restarts with parameters** (argument marshalling plus a runtime arity -check), and **`handler-case`**, which §"What this does not settle" leaves open as possibly a macro over `handler-bind` +**Restarts take parameters now** — §3's other half. `(use-value [v i32] ...)` binds them, `(invoke-restart 'use-value 21)` +supplies them, and what a clause takes against what was given is checked at run time and refused with both spellings, +because a restart is found by name on a dynamic stack and neither end of a transfer can see the other. The one path +that cannot yet supply a value is the break loop, which is item 2 below and is where the interesting half is. + +Still open: **`handler-case`**, which §"What this does not settle" leaves open as possibly a macro over `handler-bind` plus a transfer. **`find-restart` and `compute-restarts` are blocked on a type, not on effort** — §4 gives them `(Option Restart)` and a list, and there is no `Restart` type and no list to return one in. The minibuffer prompt never -needed them; it reads the snapshot over the agent's socket. And a `restart-case` clause should carry a **report -string** before any of this: `use-placeholder` is what `invoke-restart` needs, not what a person reading a list needs. +needed them; it reads the snapshot over the agent's socket. And a `restart-case` clause should still carry a **report +string**: `use-placeholder` is what `invoke-restart` needs, not what a person reading a list needs. §3 says to settle +that *before* parameters and it was not settled — the field is cheap and the accessor is cheap, but the only consumer +is the break loop's listing, which lives in the agent and the daemon, so it would have shipped as a field nothing +read. It belongs with item 2, where the listing is being changed anyway. Read SBCL for what restarts should *mean* and ignore how it moves control: it transfers with `block`/`return-from`, which §6 rules out. @@ -340,12 +347,22 @@ plan.org's single line on it (831) names a `for` the language does not have and nothing. Flan has a better answer available for free: a `StorageExhausted` condition with a `retry` restart. Decide which, because `push`/`put`/`clone`'s signatures depend on it. -2. **Typed restarts — `(use-value [v T] v)`.** The author's third TODO, and the most-wanted thing across every - comparative study. SBCL's report: restarts without parameters lose "the entire supply-a-value half of the standard - vocabulary", because `use-value` and `store-value` are the only two whose answer comes from outside the program. - Needs argument marshalling in `emit.ml` and §3's arity check in `check.ml`; both files are free now. The leverage - SBCL lacks: `eval` already compiles and runs an expression inside the live program, and the daemon already holds - the struct layouts, so "ask the human, type-check the answer, hand it over" is a short hop. +2. **The editor half of a typed restart.** The language half is in (see "Landed"): `(use-value [v i32] ...)` and + `(invoke-restart 'use-value 21)` work, and a mismatch is refused at run time with both signatures in the message. + What is missing is the half only an editor can do — the leverage SBCL lacks. `eval` already compiles and runs an + expression inside the live program and the daemon already holds the struct layouts, so "ask the human, type-check + the answer, hand it over" is a short hop, and it is the one path the runtime today *refuses*: a restart with + parameters taken from the break loop traps, because `flan_break_resume` and `flan_restart_take` aim the channel at + a frame and have nothing to fill its buffer with. What it needs, end to end: + - the frame already carries the arity and the signature as a string — `flan_restart_arity` and `flan_restart_sig` + beside `flan_restart_name`, the same walk, so `restarts` can say what each one takes; + - `:restarts` on the wire carries the signature per entry, so the minibuffer can show `use-value (i32)` rather + than a bare name, and `restart-at` grows an `:args` form — a list of *expressions*, since the answer is a Flan + expression and there is already something that compiles one; + - the daemon compiles each argument against the declared type with the session's layouts (the same path `C-x C-e` + takes), refuses it there if it does not fit, and otherwise writes the values into the frame's buffer and marks it + filled before aiming the channel. That last store is what `flan_restart_take` cannot do today and is the whole + of the remaining work; the marking exists so this cannot be forgotten silently. 3. **`handler-case`.** Not a convenience — it is the fix for the loudest gotcha in `conditions.org`. A handler closes over nothing *only because* a `handler-bind` clause runs at the signal point; a `handler-case` clause runs in the diff --git a/conditions.org b/conditions.org index 7d81d3e..823b38f 100644 --- a/conditions.org +++ b/conditions.org @@ -13,11 +13,27 @@ Why it is shaped this way: [[file:spec-conditions.md][spec-conditions.md]]. Some (handler-bind [(Type [c] body ...) ...] body ...) ; match by type, no hierarchy (restart-case BODY ; BODY and every clause have the same type = the form's - (name [] CLAUSE) ...) + (name [p T ...] CLAUSE) ...) -(invoke-restart 'name) ; Never. Innermost frame offering the name wins. +(invoke-restart 'name arg ...) ; Never. Innermost frame offering the name wins. #+end_src +#+begin_src lisp +(defn supplied [n i32] i32 + (restart-case (middle n) + (use-value [v i32] (* v 2)) ; the answer comes from outside + (retry [] 7))) + +(handler-bind [(AssetMissing [c] (invoke-restart 'use-value 21))] + (supplied 7)) ; 42 +#+end_src + +A clause's parameters are slots of the function that wrote it, and the invoker +fills a buffer that function owns — by the time a clause runs, the invoking +frame has gone. What a clause takes is compared with what was given at *run +time*, count then spelling, because a restart is found by name on a dynamic +stack and neither end can see the other. + #+begin_src lisp (defn fetch [n i32] i32 (restart-case (middle n) ; its value if nothing transfers @@ -48,8 +64,13 @@ above it, or ~abort~. * Not yet -~handler-case~ · ~find-restart~ · ~compute-restarts~ · restarts with -parameters. Each refused by name with its reason. +~handler-case~ · ~find-restart~ · ~compute-restarts~ · a clause's report +string. Each refused by name with its reason. + +A restart *with parameters* cannot be taken from the break loop: it aims at a +frame by position and has nothing to fill the parameters in with, so the clause +stops the program rather than running on values no one supplied. Choose one +that takes none, or ~abort~. ~find-restart~ and ~compute-restarts~ are blocked on a type rather than on effort: §4 gives them ~(Option Restart)~ and a list, and there is no ~Restart~ @@ -64,6 +85,8 @@ the agent's socket instead. at the ~restart-case~, so a ~retry~ repeats side effects after it. Put the ~restart-case~ where re-entry is safe. - *An unknown restart name is a hard stop.* No ~find-restart~ to test with. +- *So are the wrong arguments*, and for the same reason: nothing static can + know what a name will find. The message names both signatures. - *No supertype*, so nothing can say "any condition". - *~signal~ cannot hand a value back.* Deliberate (§1). - A condition must be a *struct*. ~return~ is refused inside either form. @@ -74,4 +97,7 @@ the agent's socket instead. unhandled AssetMissing file.flan:3:25: no restart named nope is active file.flan:4:7: a defer invoked a restart, which a defer may not do — ... +file.flan:9:12: restart use-value takes (i32), given (string) +file.flan:6:5: restart use-value takes (i32), and whatever took it supplied + no arguments — ... #+end_example From 7c1fcbff1927c31c8c528a4e1da11abf8cc2e4c6 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 10:51:00 +0700 Subject: [PATCH 3/3] A name finds one frame; it does not search for one that fits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §4 meets §3, and the answer a reader will assume is the other one. An inner (use-value [s string] ...) shadows an outer (use-value [v i32] ...), so an i32 is refused there and the outer clause that would have taken it is never consulted. Searching outward for a frame whose signature fits would make which restart runs depend on the arguments, which is overload resolution on a dynamic stack. Also: neither of the new guards is a bounds check, so --no-bounds-checks does not remove them. A wrong index is a wrong answer; a transfer into a clause whose parameters were written to a different layout is not. --- BUILT.md | 11 +++++++++++ conditions.org | 4 ++++ test/programs/restarts.flan | 15 +++++++++++++++ test/test_acceptance.ml | 6 ++++++ 4 files changed, 36 insertions(+) diff --git a/BUILT.md b/BUILT.md index 9a565ef..04f0e4e 100644 --- a/BUILT.md +++ b/BUILT.md @@ -1163,6 +1163,17 @@ unfilled, `invoke-restart` marks it filled, and a clause with parameters checks top of `NEXT.md`: the answer is a Flan expression, and there is already something that compiles one against the live program. +**Lookup stayed by name, and the signature is checked against what it found.** `flan_find_restart` matches the name +hash and nothing else, so an inner `(use-value [s string] ...)` shadows an outer `(use-value [v i32] ...)` and an i32 +is refused there — the outer frame that would have taken it is never consulted. That is §4 read straight ("the first +frame offering the name") and it is the thing a reader will assume works the other way, so `restarts.flan` has a case +for it. Searching outward for a frame whose signature fits would make which restart runs depend on the arguments, +which is overload resolution on a dynamic stack. + +Both of these guards go through `fail_block` unconditionally, unlike the bounds checks: `--no-bounds-checks` does not +remove them. A wrong index is a wrong answer, and a transfer into a clause whose parameters were never written, or +written to a different layout, is memory corruption. + `runtime/flan_rt.c` gained two message functions and nothing else. The restart frame's first four fields are the ones C declares and their offsets do not move; everything §3 needed is appended after them, and C never allocates one. diff --git a/conditions.org b/conditions.org index 823b38f..99d7b6f 100644 --- a/conditions.org +++ b/conditions.org @@ -87,6 +87,10 @@ the agent's socket instead. - *An unknown restart name is a hard stop.* No ~find-restart~ to test with. - *So are the wrong arguments*, and for the same reason: nothing static can know what a name will find. The message names both signatures. +- *Lookup is by name; the signature is checked after it.* Nothing searches for + a frame the arguments would fit. An inner ~(use-value [s string] ...)~ + shadows an outer ~(use-value [v i32] ...)~, so ~(invoke-restart 'use-value + 21)~ stops the program even though the outer clause would have taken it. - *No supertype*, so nothing can say "any condition". - *~signal~ cannot hand a value back.* Deliberate (§1). - A condition must be a *struct*. ~return~ is refused inside either form. diff --git a/test/programs/restarts.flan b/test/programs/restarts.flan index d38baf2..0b6b6ba 100644 --- a/test/programs/restarts.flan +++ b/test/programs/restarts.flan @@ -87,6 +87,20 @@ (handler-bind [(AssetMissing [c] (invoke-restart 'retry 1))] (supplied n))) +;;; §4 meets §3: lookup is by *name*, and the signature is checked against +;;; whatever that finds. An inner frame offering use-value shadows the outer +;;; one, so an i32 is refused here even though the outer clause would have +;;; taken it — a name finds one frame, it does not search for a fitting one. +(defn shadowed [n i32] i32 + (restart-case + (restart-case (middle n) + (use-value [s string] (do (print s) 0))) + (use-value [v i32] v))) + +(defn mislaid [n i32] i32 + (handler-bind [(AssetMissing [c] (invoke-restart 'use-value 21))] + (shadowed n))) + (defn main [args [string]] i32 ;; One argument selects a trap; none runs the table's case. (if (> (len args) 1) @@ -95,6 +109,7 @@ (= k 1) (print (mismatched 90)) (= k 2) (print (mistyped 91)) (= k 3) (print (overfull 92)) + (= k 4) (print (mislaid 93)) :else (println "?")) (return 0))) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index eac002a..92e8a0c 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -298,6 +298,12 @@ let () = "restart use-value takes (i32), given (string)"; refuses "arguments given to a restart that takes none" "3" "restart retry takes (), given (i32)"; + (* §4 meets §3. The name finds the innermost frame offering it and the + signature is checked against *that*; nothing searches outward for a + frame the arguments would have fitted, and an outer clause that would + have taken them is not consulted. *) + refuses "a shadowing clause of the same name and a different signature" "4" + "restart use-value takes (string), given (i32)"; (try Sys.remove exe with Sys_error _ -> ()) in restart_mismatch ();