A CFn may sit in a struct field, a fixed array or a global, and a call through a null one signals NullCall on both backends

This commit is contained in:
Joseph Ferano 2026-09-25 15:26:02 +07:00
parent 8cc63ed9f6
commit f6ba1e6c4d
11 changed files with 225 additions and 15 deletions

View File

@ -1006,13 +1006,10 @@ at all, which is what the diagnosis predicted. A =map= that *changes* the elemen
type is the one shape that did not come with them: one copy per ordered pair of type is the one shape that did not come with them: one copy per ordered pair of
types rather than per type. types rather than per type.
** NEXT CFn in a struct or a fixed array ** DONE CFn in a struct or a fixed array
Decided 2026-09-25: allowed. A call through a null =CFn= is a named runtime condition on both backends, and parks in a dev build. CLOSED: [2026-09-25]
A zeroed function value is a null pointer, so a function value is refused in any A zeroed =CFn= is admitted everywhere and a call through a null one signals
position zero-initialisation would conjure one — =CFn= included. An =(Option =NullCall= before its arguments run. =(Fn ...)= stays refused in those positions.
(CFn ...))= field is already legal. A table of function pointers is exactly what
=CFn= is for, and the objection is about zero-initialisation rather than about
capture.
** DONE Structural compatibility is identical layout ** DONE Structural compatibility is identical layout
Same fields, same types, same order, so structural compatibility is "the same Same fields, same types, same order, so structural compatibility is "the same

View File

@ -4336,8 +4336,9 @@ implemented.
refusal's witness now runs. The escape refusal that replaced it is gone too; see "Escape: only an escaping refusal's witness now runs. The escape refusal that replaced it is gone too; see "Escape: only an escaping
closure's environment is the collector's".* closure's environment is the collector's".*
- **An `fn` with nothing to say what it takes** (`fn-no-type.flan`), above. - **An `fn` with nothing to say what it takes** (`fn-no-type.flan`), above.
- **A position that would zero one** (`fn-in-struct.flan`): a struct field, a global, a fixed array's element, - **A position that would zero an `(Fn ...)`** (`fn-in-struct.flan`): a struct field, a global, a fixed array's element,
`(zeroed)`. ZII fills an omitted field with all-bytes-zero, and **a zeroed function value is a null pointer, which `(zeroed)`. A `(CFn ...)` is admitted in all four: every call through one tests for null and signals `NullCall`
(`fn-cfn-table.flan`). ZII fills an omitted field with all-bytes-zero, and **a zeroed function value is a null pointer, which
is the one kind of zero that is not a value the type can have** — every other type's zero is one: `0`, `false`, an is the one kind of zero that is not a value the type can have** — every other type's zero is one: `0`, `false`, an
empty slice, `None`, a union's first case. A parameter, a return type and a `let` binding are not on the list empty slice, `None`, a union's first case. A parameter, a return type and a `let` binding are not on the list
because none of them is ever conjured, and an `(Option (Fn ...))` is not either, because a `None`'s tag is what because none of them is ever conjured, and an `(Option (Fn ...))` is not either, because a `None`'s tag is what

View File

@ -1134,12 +1134,19 @@ let fn_sig (t : Types.t) =
let callable_ty t = fn_sig t <> None let callable_ty t = fn_sig t <> None
(* A (CFn ...) is not on the list: it is one code address, and every call
through one tests for null and signals NullCall (see [Emit.null_check]), so
a zeroed one is an empty slot rather than a crash. That is what lets a table
of function pointers be a struct or a fixed array. An (Fn ...) stays
refused: a call through one is not tested, and (Option (Fn ...)) is the
field that holds one. *)
let rec no_zeroed_fn loc what (t : Types.t) = let rec no_zeroed_fn loc what (t : Types.t) =
match t with match t with
| Types.Fn _ | Types.CFn _ -> | Types.Fn _ ->
fail loc fail loc
"%s cannot be %s — it would be zeroed, and a zeroed function value is a \ "%s cannot be %s — it would be zeroed, and a zeroed function value is a \
null pointer. Pass it as a parameter, or hold it in a let" null pointer. Pass it as a parameter, hold it in a let, or store a \
(CFn ...) if it captures nothing"
what (Types.to_string t) what (Types.to_string t)
| Types.Array (_, e) -> no_zeroed_fn loc what e | Types.Array (_, e) -> no_zeroed_fn loc what e
| _ -> () | _ -> ()
@ -10685,6 +10692,14 @@ and ordinary_call ctx ~want loc name args =
match capture ctx loc name with match capture ctx loc name with
| Some b -> call_value ctx ~want loc (mk loc b.bty (Tast.Local b.slot)) args | Some b -> call_value ctx ~want loc (mk loc b.bty (Tast.Local b.slot)) args
| None -> assert false) | None -> assert false)
(* A global holding a function value — a (CFn ...) table entry's cousin,
since a global is one of the zeroed positions a CFn may sit in. Called
by its name the way a local one is. *)
| _ when (match Hashtbl.find_opt ctx.env.globals name with
| Some (ty, _) -> callable_ty ty
| None -> false) ->
let ty, _ = Hashtbl.find ctx.env.globals name in
call_value ctx ~want loc (mk loc ty (Tast.Global name)) args
| _ when Hashtbl.mem ctx.env.gsigs name -> | _ when Hashtbl.mem ctx.env.gsigs name ->
private_ref ctx loc name; private_ref ctx loc name;
let vars, params, ret = Hashtbl.find ctx.env.gsigs name in let vars, params, ret = Hashtbl.find ctx.env.gsigs name in

View File

@ -3132,6 +3132,9 @@ and call_ptr ?at f ret callee args =
code, Some ("ptr " ^ env) code, Some ("ptr " ^ env)
| _ -> c, None | _ -> c, None
in in
(match callee.Tast.ty, at with
| Types.CFn _, Some loc -> null_check f loc callee.Tast.ty code
| _ -> ());
let vs = map_lr (fun (a : Tast.expr) -> let vs = map_lr (fun (a : Tast.expr) ->
let v = value f a in Printf.sprintf "%s %s" (ll a.Tast.ty) v) args in let v = value f a in Printf.sprintf "%s %s" (ll a.Tast.ty) v) args in
Option.iter (mark_call f) at; Option.iter (mark_call f) at;
@ -3139,6 +3142,19 @@ and call_ptr ?at f ret callee args =
if at <> None then clear_call f; if at <> None then clear_call f;
r r
(* A (CFn ...) may be a zeroed field, array element or global, and a zeroed
one is a null address. Tested before the arguments are evaluated, so a
call that is not going to be made runs none of them — the x86 backend
tests at the same point. [flan_null_call] signals [NullCall] and returns
only when something transferred, the shape of a bounds failure. *)
and null_check f loc ty code =
let ok = fresh f in
ins f "%s = icmp ne ptr %s, null" ok code;
signal_block f loc ~guard:(fun () -> guard f) ok (fun id n ->
let tys = fst (fi_bytes f.md (Types.to_string ty ^ "\000")) in
ins f "call void @flan_null_call(ptr %s, i64 %d, ptr %s, ptr %s)"
id n tys xfer_param)
(* The code address behind one of the three [fnref]s, which is the same string (* The code address behind one of the three [fnref]s, which is the same string
whether it is wanted as a bare [(Ptr ())] or as the first word of a whether it is wanted as a bare [(Ptr ())] or as the first word of a
function value. function value.
@ -4875,6 +4891,9 @@ declare void @flan_arith_error(ptr, i64, i32, i64, i64, ptr) cold
; as C strings, then the cell and the channel. Signals StaleCall; returns when ; as C strings, then the cell and the channel. Signals StaleCall; returns when
; something answered. ; something answered.
declare void @flan_stale_call(ptr, ptr, ptr, ptr, ptr) cold declare void @flan_stale_call(ptr, ptr, ptr, ptr, ptr) cold
; A call through a (CFn ...) holding null: the site, the value's type as a C
; string and the channel. Signals NullCall; returns when something answered.
declare void @flan_null_call(ptr, i64, ptr, ptr) cold
declare ptr @flan_context_allocator() declare ptr @flan_context_allocator()
declare ptr @flan_context_use(ptr, i64) declare ptr @flan_context_use(ptr, i64)
declare void @flan_context_value(ptr) declare void @flan_context_value(ptr)

View File

@ -197,6 +197,17 @@ let source = {flan|
;; nothing a handler supplies makes the old arguments fit the new body. ;; nothing a handler supplies makes the old arguments fit the new body.
(defstruct StaleCall :parent Error [callee string compiled string current string]) (defstruct StaleCall :parent Error [callee string compiled string current string])
;; A call through a (CFn ...) that holds no function. A CFn may be a struct
;; field, a fixed array's element or a global, and each of those starts out
;; zeroed, which for a function value is no address at all. Every call
;; through one tests first and signals this instead of jumping to nothing.
;; `type` is the value's type as written, "(CFn [i32] i32)".
;;
;; Signalled from the runtime — flan_null_call in runtime/flan_rt.c — so
;; **this field is a C struct that has to agree with this one**. No restart is
;; established at the call, BoundsError's decision for BoundsError's reason.
(defstruct NullCall :parent Error [type string])
;; What a generic function signals when no method answers. `generic` is the ;; What a generic function signals when no method answers. `generic` is the
;; name written at the defgeneric or defmulti, and `value` is what the ;; name written at the defgeneric or defmulti, and `value` is what the
;; dispatch actually produced -- the class of the first argument for a ;; dispatch actually produced -- the class of the first argument for a

View File

@ -1916,6 +1916,9 @@ and lower_at f (e : Tast.expr) (dst : loc) : unit =
| Types.Fn _ -> Some (Aint (shift c 8, Types.Ptr (Types.Mut, Types.Unit))) | Types.Fn _ -> Some (Aint (shift c 8, Types.Ptr (Types.Mut, Types.Unit)))
| _ -> None | _ -> None
in in
(match callee.Tast.ty with
| Types.CFn _ -> null_check f e.Tast.loc callee.Tast.ty c
| _ -> ());
call_flan f ?env ~at:e.Tast.loc ~target:(`Loc c) ~args ~rty:t dst call_flan f ?env ~at:e.Tast.loc ~target:(`Loc c) ~args ~rty:t dst
| Tast.Do body -> block f body dst t | Tast.Do body -> block f body dst t
| Tast.Let (bs, body) -> | Tast.Let (bs, body) ->
@ -2702,6 +2705,26 @@ and elements f (base : loc) (ty : Types.t) (is : Tast.expr list) : loc =
That is also the answer to "does a bounds trap run defers": an answered one That is also the answer to "does a bounds trap run defers": an answered one
does, because it leaves through the innermost pad; an unanswered one still does, because it leaves through the innermost pad; an unanswered one still
does not, because it is a die inside C. Identical on both backends. *) does not, because it is a die inside C. Identical on both backends. *)
(* [Emit.null_check]: a (CFn ...) holding null is not called. Tested before
the arguments, as the LLVM backend does, and [flan_null_call] returns only
when something transferred. *)
and null_check f (loc : Loc.t) ty (c : loc) =
load_loc f ~reg:rax c ty;
test_rr f.b ~a:rax ~c:rax;
let ok = new_label f "fnok" in
jcc_lbl f.b ~cc:cc_ne ok;
note f "A null (CFn ...): the site, the type as a C string and the channel.";
str_args f ~preg:rdi ~nreg:rsi (Loc.to_string loc);
let tys, _ = fi_bytes f (Types.to_string ty ^ "\000") in
lea f.b ~dst:rdx ~mm:(Sym (tys, 0));
chan_into f ~reg:rcx;
mark_at f loc;
xor_rr f.b ~dst:rax ~src:rax;
call_sym f.b "flan_null_call";
guard f;
ud2 f.b;
lbl f.b ok
and bounds_call f sym (loc : Loc.t) (extra : int list) = and bounds_call f sym (loc : Loc.t) (extra : int list) =
note f (Printf.sprintf note f (Printf.sprintf
"Out of bounds: the location string, the operands, and this frame's channel, \ "Out of bounds: the location string, the operands, and this frame's channel, \

View File

@ -1538,6 +1538,51 @@ void flan_stale_call(const char *site, const char *callee, const char *want,
rt_die(); rt_die();
} }
/* ── A call through a null (CFn ...) ───────────────────────────────────
*
* A (CFn ...) may sit in a struct field, a fixed array or a global, all of
* which zero-initialise, and a zeroed one is a null address. Every call
* through a CFn value tests it first and lands here on null, so the call is
* not made. It signals NullCall with `error` — BoundsError's shape and its
* decision about restarts: no value a handler supplies turns into a function
* to call, so what answers it is a restart the program already has, or the
* break loop in a dev build.
*
* `type` is copied and never freed, for flan_stale_call's reason: the text
* lives in the image of the module that compiled the call, which may be a
* thunk that is unloaded once it returns. Must agree with the prelude's
* (defstruct NullCall :parent Error [type string]). */
typedef struct { flan_slice type; } flan_nullcall_cond;
static const uint8_t flan_nullcall_name[] = "NullCall";
#define FLAN_NULLCALL_NAMELEN 8
static void nullcall_sentence(const char *ty) {
rt_sentence("this call is through a %s that holds no function — a field, "
"an array element or a global of that type starts out empty. "
"Store a function in it before calling it, or hold it as an "
"(Option %s) and match on it",
ty, ty);
}
void flan_null_call(const uint8_t *loc, int64_t loclen, const char *ty,
void *xfer) {
flan_nullcall_cond c;
flan_condesc d;
uint32_t chain[2];
c.type = flan_stale_copy(ty);
nullcall_sentence(ty);
rt_condesc(&d, chain, flan_nullcall_name, FLAN_NULLCALL_NAMELEN, loc,
loclen);
flan_signal(&d, &c, xfer);
if (*(void **)xfer != NULL) return;
nullcall_sentence(ty); /* in full; see flan_bounds_signal */
if (rt_error_break(&d, &c, xfer)) return;
rt_print_sentence(loc, loclen);
rt_die();
}
/* ── 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

View File

@ -0,0 +1,55 @@
;; A (CFn ...) in the places that zero-initialise: a struct field, a fixed
;; array's element and a global. A table of bare code addresses is what the
;; narrow function type is for, and the zero is the only objection there ever
;; was — a zeroed CFn is a null address. So a call through one tests it first
;; and signals NullCall rather than jumping to nothing.
;;
;; With no argument the empty calls are answered and the program carries on;
;; with "1" nothing answers and it dies, with the site and the type.
(defn double [x i32] i32 (* x 2))
(defn negate [x i32] i32 (- 0 x))
(defstruct Ops [name string run (CFn [i32] i32)])
(defonce table [3 (CFn [i32] i32)])
(defonce hook (CFn [i32] i32))
(defonce evaluated i32 0)
(defonce caught i32 0)
(defonce seen string "")
(defn arg [x i32] i32
(set evaluated (+ evaluated 1))
x)
(defn try-call [f (CFn [i32] i32) x i32] ()
(restart-case
(do (print (f (arg x))) (println ""))
(continue [] (println "empty"))))
(defn main [args [string]] i32
(set (at table 0) double)
(set (at table 2) negate)
(let [ops (Ops {.name "half-built"})]
(if (> (length args) 1)
;; Unanswered: the process dies at the call.
(do (print ((at table 1) 5)) (println "") 0)
(do
(handler-bind
[(NullCall [c]
(set caught (+ caught 1))
(set seen (.type c))
(invoke-restart 'continue))]
(dotimes [i 3] (try-call (at table i) 7)) ; 14, empty, -7
(try-call (.run ops) 1) ; empty
(try-call hook 2) ; empty
(set hook double)
(try-call hook 2)) ; 4
;; The argument of a call that is not made is never evaluated.
(print evaluated) (println "") ; 3
(print caught) (println "") ; 3
(println seen) ; (CFn [i32] i32)
;; And a filled field calls as any CFn does.
(let [full (Ops {.name "full" .run negate})]
(print ((.run full) 9)) (println "")) ; -9
0))))

View File

@ -9,10 +9,8 @@
;; the collector and may be kept anywhere, and (Option (Fn ...)) is the field ;; the collector and may be kept anywhere, and (Option (Fn ...)) is the field
;; that holds one — see fn-escape.flan. ;; that holds one — see fn-escape.flan.
;; ;;
;; Which means a (CFn ...) field is refused too, and for the zero alone — ;; A (CFn ...) field is not refused: every call through one tests for null and
;; a table of function pointers is exactly what that type is for, and nothing ;; signals NullCall, so its zero is an empty slot — fn-cfn-table.flan.
;; about capture stands in its way. An (Option (CFn ...)) field is already
;; legal and is the shape that works; TODO.org, "CFn in a struct or a fixed array", carries the rest as its own item.
(defstruct Ops [run (Fn [i32] i32)]) (defstruct Ops [run (Fn [i32] i32)])
(defn main [] i32 0) (defn main [] i32 0)

View File

@ -4663,6 +4663,35 @@ level "1"
outputs ~dev:true "the two function types, dev" "programs/fn-cfn.flan" outputs ~dev:true "the two function types, dev" "programs/fn-cfn.flan"
fn_ptr_out; fn_ptr_out;
(* A CFn in a struct field, a fixed array and a global, each zeroed until
stored into. A call through an empty one signals NullCall, before its
arguments run; answered, the program carries on, and unanswered it dies
naming the site and the type — on both backends. *)
let cfn_table_out =
"14\nempty\n-7\nempty\nempty\n4\n3\n3\n(CFn [i32] i32)\n-9\n"
in
outputs "a CFn table" "programs/fn-cfn-table.flan" cfn_table_out;
outputs ~x86:true "a CFn table, --x86" "programs/fn-cfn-table.flan"
cfn_table_out;
outputs ~dev:true "a CFn table, dev" "programs/fn-cfn-table.flan"
cfn_table_out;
List.iter
(fun x86 ->
let exe = compile ~x86 "programs/fn-cfn-table.flan" in
let code, text = run exe (Some "1") in
if code <> 134
|| not (contains text "programs/fn-cfn-table.flan:")
|| not (contains text "this call is through a (CFn [i32] i32) \
that holds no function")
then begin
incr failures;
Printf.printf "FAIL an unanswered empty CFn call dies%s\n \
got: %S (exit %d)\n"
(if x86 then ", --x86" else "") text code
end;
(try Sys.remove exe with Sys_error _ -> ()))
[ false; true ];
(* Two signatures that flatten to one string under [mangle_ty], which is (* Two signatures that flatten to one string under [mangle_ty], which is
how the thunk memo used to be keyed. Keyed on the name, the second how the thunk memo used to be keyed. Keyed on the name, the second
widening reuses the first's thunk at the wrong arity — a miscompile widening reuses the first's thunk at the wrong arity — a miscompile

View File

@ -3595,6 +3595,23 @@ let () =
"(defn h [x i32] i32 x) (defn g [i i32] (Fn [i32] i32) h)\n\ "(defn h [x i32] i32 x) (defn g [i i32] (Fn [i32] i32) h)\n\
(defn f [] i32 (let [a (array-gen [2] g)] 0))" (defn f [] i32 (let [a (array-gen [2] g)] 0))"
~needle:"a fixed array's element cannot be (Fn [i32] i32)"; ~needle:"a fixed array's element cannot be (Fn [i32] i32)";
(* A (CFn ...) is not refused in any of them: a call through one tests for
null and signals NullCall, so its zero is an empty slot. *)
accepts "a CFn struct field"
"(defstruct Ops [run (CFn [i32] i32)])\n\
(defn f [o Ops] i32 ((.run o) 1))";
accepts "a fixed array of CFn"
"(defonce tbl [4 (CFn [i32] i32)])\n(defn f [] i32 ((at tbl 0) 1))";
accepts "a CFn global with no initialiser"
"(defonce hook (CFn [] ()))\n(defn f [] () (hook))";
accepts "(zeroed) at a CFn"
"(defn f [] i32 (let [g (the (CFn [i32] i32) (zeroed))] (g 1)))";
accepts "an array-gen of CFn"
"(defn h [x i32] i32 x) (defn g [i i32] (CFn [i32] i32) h)\n\
(defn f [] i32 (let [a (array-gen [2] g)] ((at a 1) 3)))";
rejects_check "an Fn struct field is still refused"
"(defstruct Ops [run (Fn [i32] i32)])"
~needle:"the field run cannot be (Fn [i32] i32)";
(* The inline form, the design's canonical one. An fn normally takes its (* The inline form, the design's canonical one. An fn normally takes its
types from a (Fn ...) want, and this position has none — the *form* types from a (Fn ...) want, and this position has none — the *form*