Six defects back from review, fixed rather than reworded around

F3: the unknown-struct-vs-function message overgeneralized a one-case
parser quirk into a language rule that does not exist. (g {:a 1}) compiles
fine -- only the empty map is stolen by is_struct_map's Map [] -> true.
The message now names {} specifically and says why: it is read as the
zero-field struct literal, not "a map literal cannot be passed as an
argument".

F2: the ordering refusal named machine numbers only, when Types.is_comparable
also admits enums and enum-compare.flan orders one with (< k :mid). Both
messages -- equality and ordering -- now name every type each actually
covers.

F1 and F5, together, since both live in the same parse.ml arm: the
rest = [] carve-out that let a single-form dyn map body through
reintroduced the exact bug the earlier fix was for. (defn mx [a $t b $t]
$t {:where (ordered? $t)}) -- a :where clause with nothing after it, what
a moved closing paren produces -- no longer matched, fell through to expr,
and printed "unknown function ordered?" from inside what was meant as a
predicate. A :where map is peeled unconditionally again, whether or not
anything follows it; every other keyword, plus the empty map and any
non-keyword key, is still peeled only when the body has more after it, so
a real single-form dyn map body is still left alone. The comment
justifying the discarded-map refusal claimed a map literal has no side
effects; it does -- {:a (println "hi")} prints, confirmed by running it --
so the reasoning now names the actual problem, a value going unused, not
a false claim about purity.

Closing that F1 hole this way opened two more, both traced by rebuilding
the pre-fix parse.ml and diffing real compiler output rather than
reasoning about it: {.x 1} at a defn body's head used to get its own
message, "a bare map is not an expression", and briefly started getting
"a constraint map is keyword/value pairs" instead, because the broadened
guard no longer required a keyword and started catching the struct-field
shape too. Excluded explicitly, with a comment saying why, so expr's
dedicated diagnostic fires again. And (defn main [] i32 {} 0) and
(defn main [] i32 {"a" 1} 0) -- the empty map and the non-keyword-keyed
map, the two siblings a keyword-only guard could never have caught --
now get their own refusals alongside the keyword case, each pinned with
a rejects_check row, along with the where-with-no-body case and the two
legitimate single-form bodies that must keep compiling.

F6: the acceptance runner's tail already caught every failure on every
path through the binary -- there is no skip branch that bypasses it, and
the no-clang branch runs zero rows -- so last round's at_exit guard and
the FIX.org note both overstated what was broken. Both now say what was
actually true: the exit status was already trustworthy, the guard is
insurance against a future case leaving past the tail instead of through
it, and the other nine test binaries were already sound the same way.
The guard also had a real bug of its own: forcing exit 1 whenever
failures was nonzero would stomp the watchdog's own exit 2 if a hang
followed a few already-failed rows, since Stdlib.exit runs at_exit
handlers LIFO. watchdog.ml now flags when it is the one unwinding, and
test_acceptance.ml's guard defers to it -- shared state for a single
caller, justified by there being no other way for one at_exit handler to
know a sibling handler is already mid-exit with a code of its own to
protect.

Verified every case in this commit by compiling and, where it mattered,
running the actual program -- not by inspecting the arm and assuming.
dune test --force: exit 0, clean grep for FAIL and Fatal error.
This commit is contained in:
Joseph Ferano 2026-09-19 21:35:08 +07:00
parent c76a507d3b
commit 87aeb7b0da
6 changed files with 150 additions and 55 deletions

18
FIX.org
View File

@ -507,13 +507,17 @@ instead of saying something false.
A lane runs the fast check and nothing more. `dune test` is the whole of a A lane runs the fast check and nothing more. `dune test` is the whole of a
lane's obligation. It used to be judged by reading the printed output rather lane's obligation. It used to be judged by reading the printed output rather
than by trusting the exit status, on the theory that some path through the than by trusting the exit status, on the theory that some path through the
acceptance runner could print a FAIL and still exit 0 — the tail check itself acceptance runner could print a FAIL and still exit 0. That theory did not
already exited 1, but nothing proved every skip path (no clang, no wasmtime, hold up: test_acceptance.ml is one match on whether clang is on PATH, the
no raylib, no lldb) still reached it. test_acceptance.ml now closes that class wasmtime/raylib/lldb probes inside it are ordinary `if`s that fall through to
with an `at_exit` handler that holds regardless of which path the process the same tail rather than branches that leave early, and the tail already
leaves by, so the exit status can be trusted again and either check does. turned a nonzero failure count into exit 1 — so did every other test binary's
Running one program directly to capture its real output for an acceptance row tail, checked the same way. test_acceptance.ml now also carries an `at_exit`
is still expected; that is cheap. What a lane may no longer do is sweep. guard, but it closes no open gap; it is insurance against a future case
leaving past the tail instead of through it. The exit status was already
trustworthy and stays that way, so either check does. Running one program
directly to capture its real output for an acceptance row is still expected;
that is cheap. What a lane may no longer do is sweep.
The x86 survey and the sanitizer sweep run once, after several lanes have The x86 survey and the sanitizer sweep run once, after several lanes have
landed, and whatever they turn up is dispatched as fixes in a single batch. landed, and whatever they turn up is dispatched as fixes in a single batch.

View File

@ -3056,18 +3056,22 @@ and check_struct ctx ~want loc name kvs =
"%s is a data type, and a data type value names the case as well as the \ "%s is a data type, and a data type value names the case as well as the \
type write (%s.%s {.field value ...}) for one of %s" type write (%s.%s {.field value ...}) for one of %s"
name name (first_case_name ctx.env name) (case_list ctx.env name) name name (first_case_name ctx.env name) (case_list ctx.env name)
(* [kvs = []] is the parser's one blind spot here: a symbol applied to (* [kvs = []] is the parser's one blind spot here, and only the empty
an empty map literal, [(name {})], parses as a struct literal with map: [is_struct_map] (parse.ml) reads [(name {})] as a struct
no fields regardless of what [name] turns out to be, because the literal with no fields regardless of what [name] turns out to be,
parser has no table to tell a struct name from a function name. If because the parser has no table to tell a struct name from a
[name] is a known function, the empty map was meant as an argument function name at that point -- but [(name {:a 1})] keeps [name] an
and there is no syntax to pass a map literal straight into a call ordinary call, since a nonempty map is never mistaken for a struct
that way, empty or not; every real function argument is a bound literal. So this is a narrow parser quirk about {} specifically,
name. Naming that is more useful than "unknown struct". *) not a rule that a map literal cannot be an argument -- a nonempty
one is. If [name] is a known function, the {} was meant as that
one argument, and naming the real trap is more useful than
"unknown struct". *)
else if kvs = [] && Hashtbl.mem ctx.env.fns name then else if kvs = [] && Hashtbl.mem ctx.env.fns name then
fail loc fail loc
"%s is a function, not a struct — a map literal cannot be passed \ "%s is a function, not a struct — {} on its own is read as the \
directly as an argument; bind it first, as (let [m {}] (%s m))" zero-field struct literal, so it cannot be passed here as an \
empty map; bind it first, as (let [m {}] (%s m))"
name name name name
else else
Loc.failk "check/unknown-struct" loc ~notes:(declared_note ctx.env name) Loc.failk "check/unknown-struct" loc ~notes:(declared_note ctx.env name)
@ -4009,7 +4013,7 @@ and named_call ctx ~want loc name args =
built-in equality (plan.org, Types)" name (Types.to_string a.Tast.ty) built-in equality (plan.org, Types)" name (Types.to_string a.Tast.ty)
| _ -> | _ ->
fail loc fail loc
"%s orders machine numbers; %s has no built-in ordering \ "%s orders machine numbers and enums; %s has no built-in ordering \
(plan.org, Types)" name (Types.to_string a.Tast.ty)); (plan.org, Types)" name (Types.to_string a.Tast.ty));
prim p Types.Bool [ a; b ] prim p Types.Bool [ a; b ]
end end

View File

@ -145,29 +145,62 @@ and pitems (items : Form.t list) : Ast.pitem list =
braces in a row meaning different things [(defn f [xs [$t]] {string i32} braces in a row meaning different things [(defn f [xs [$t]] {string i32}
{:where ...} body)]. The brace spelling has since been withdrawn from type {:where ...} body)]. The brace spelling has since been withdrawn from type
position entirely ([texpr] above), so the slot after the return type can be position entirely ([texpr] above), so the slot after the return type can be
nothing but this. A bare [{}] in *expression* position is already refused nothing but this.
([expr] below), so there is nothing for it to be confused with on the other
side either.
The leading keyword is still required and still checked, because it is what The leading keyword is checked, but is no longer what tells a constraint
tells a constraint map from a struct literal's field list, [{.x 1}], which map from anything else braces can mean in the position a body starts in
is what braces mean in the position a body starts in. *) dyn maps changed what else is possible there. [{.x 1}] is still read as a
struct literal's field list and never as a constraint map, but [expr]
below says so, with its own message; this function no longer sees that
shape at all. What DOES reach here besides [:where]: a keyword-keyed map
with a different key, an empty map, or a map keyed on something that is
neither a keyword nor a [.field] symbol, and only when the body has more
after it with nothing after, that map is the whole single-form body, a
real dyn value like [(defn f [] dyn {:a 1})] or [(defn f [] dyn {})], and
this function leaves it alone. *)
let constraints (body : Form.t list) : Ast.pred list * Form.t list = let constraints (body : Form.t list) : Ast.pred list * Form.t list =
match body with match body with
(* Any map literal whose first key is a keyword is read as a constraint (* A map literal opening on [:where] is always a constraint map, even with
map when something follows it, not just one that opens on [:where]: a nothing after it: a where-clause with no body past it is what a moved
keyword-first map is never a struct literal ([is_struct_map] wants a closing paren produces, and the whole point of naming the key here is
[.field] symbol or [Map []]), and one sitting where the body starts with to catch that as a constraint-map error ("a where predicate is ...", or
more body after it is discarded on evaluation a map literal has no whatever [keys] finds wrong with it) rather than let a stray [(ordered?
side effects, so that spot is always a mistake and not a case a real $t)] surface later as "unknown function ordered?" from inside what was
program uses. Reading a typo'd key, like [:wheer], as ordinary body code meant as a predicate. Any OTHER map literal keyed on a different
used to print a baffling "unknown function ordered?" from inside what keyword, keyed on something that is not a keyword at all, or holding no
the user meant as a predicate; reading it here instead lets [keys] below keys at all is read as a constraint map only when something follows
say which key it does not recognise. The single-form body, [rest = []], it in the body: unlike [:where], nothing about the map alone says it is
is left alone: there the map is not discarded, it IS the answer a a mistake and not an ordinary map literal until [rest] says whether it
[(defn f [] dyn {:a 1})] whose whole body is the dyn value to build. *) is one body form among several (discarded, so worth flagging as the
| ({ Form.v = Form.Map (({ Form.v = Form.Kw _; _ } :: _ as kvs)); _ } as m) typo it almost always is) or the function's entire single-form body
:: (_ :: _ as rest) -> ([(defn f [] dyn {:a 1})], where the map is not discarded, it IS the
answer, evaluated for both its side effects and its value like any
other body form). Note that "discarded" here is about the map's
*result*, not about whether evaluating it can do anything: {:a (println
"hi")} still prints, same as any expression statement whose value
nothing uses this arm's business is a value going unused, not
silence. An empty map, [{}], has no key to check and gets its own
message rather than running [keys] on nothing and saying nothing.
One shape is excluded on purpose: a map opening on a [.field] symbol,
[{.x 1}], is a struct field list with no struct name in front of it, and
[expr] below already gives that its own message, "a bare map is not an
expression; write (Type {.field v})" — the one this file had before any
of the above existed. Catching it here first would bury that dedicated
diagnostic under "a constraint map is keyword/value pairs", which is
true but not what is wrong with it. *)
| ({ Form.v = Form.Map kvs; loc } as m) :: rest
when (match kvs with
| { Form.v = Form.Kw "where"; _ } :: _ -> true
| { Form.v = Form.Sym s; _ } :: _
when String.length s > 1 && s.[0] = '.' -> false
| _ -> rest <> []) ->
if kvs = [] then
Loc.fail loc
"an empty map literal here is discarded — the body has more after \
it, and its value going unused is almost always a typo for \
{:where ...}; write (do {} ...) if the empty map is deliberate"
else
let pred (p : Form.t) = let pred (p : Form.t) =
match p.Form.v with match p.Form.v with
(* [$t] at a predicate, not bare [t]: the clause talks about the (* [$t] at a predicate, not bare [t]: the clause talks about the

View File

@ -12,24 +12,32 @@ let () = Watchdog.arm ~seconds:1200 "test_acceptance"
let failures = ref 0 let failures = ref 0
(* The tail of this file exits 1 when [failures] is nonzero, but that check (* The tail of this file already exits 1 when [failures] is nonzero, and
is one path among several a run can take: a probe for clang, wasmtime, there is no path through this binary that skips it: it is one match on
raylib or lldb can skip a whole section and jump past rows that already whether clang is on PATH, and the wasmtime/raylib/lldb probes further down
failed. Reading through the file to prove every path still reaches the are inner [if]s that fall back through to that same tail rather than
tail check is exactly the kind of proof that quietly stops being true the branches that leave early. The no-clang arm runs zero rows, so it cannot
next time a case is added. An [at_exit] guard makes the invariant hold no be carrying an unreported failure either. So this [at_exit] guard is not
matter which path the process leaves by: however this binary exits, if it closing an open gap in this file it is insurance against the next case
printed a FAIL it exits nonzero. [Unix._exit] and not [exit] calling this file grows doing what none of the current ones do: returning or
[exit] from inside an [at_exit] handler recurses through [do_at_exit] raising past the tail instead of falling through to it. However this
but [_exit] skips the channel flush stdlib's own [at_exit] handler does, binary exits from that point on, if it printed a FAIL it exits nonzero.
and handlers run LIFO, so this one would fire first and throw away every [Unix._exit] and not [exit] calling [exit] from inside an [at_exit]
row that already printed FAIL, buffered in stdout, before it ever reached handler recurses through [do_at_exit] but [_exit] skips the channel
the pipe. [flush_all] first is what [watchdog.ml]'s [dying] does for the flush stdlib's own [at_exit] handler does, and handlers run LIFO, so this
same reason: a report that eats the rows that failed is worse than the one would fire first and throw away every row that already printed FAIL,
hang it is there to catch. *) buffered in stdout, before it ever reached the pipe. [flush_all] first is
what [watchdog.ml]'s [dying] does for the same reason: a report that eats
the rows that failed is worse than the hang it is there to catch. And
[Watchdog.is_dying] is checked first for a reason specific to stacking two
[at_exit] handlers: a hang that follows a few already-failed rows should
still leave through the watchdog's own exit 2, not have this handler
relabel it as an ordinary exit 1 failing run. *)
let () = let () =
at_exit (fun () -> at_exit (fun () ->
if !failures > 0 then begin flush_all (); Unix._exit 1 end) if !failures > 0 && not (Watchdog.is_dying ()) then begin
flush_all (); Unix._exit 1
end)
let scratch = Filename.get_temp_dir_name () let scratch = Filename.get_temp_dir_name ()

View File

@ -930,10 +930,17 @@ let () =
~needle:"odd number of forms"; ~needle:"odd number of forms";
(* The struct spelling is untouched on both of its sides: bare braces (* The struct spelling is untouched on both of its sides: bare braces
opening on a .field are still a struct literal that wants its type opening on a .field are still a struct literal that wants its type
written, and (Type {.field v}) still builds one. *) written, and (Type {.field v}) still builds one. It is untouched at
the head of a defn body too, single-form or not [constraints]
(parse.ml) leaves a [.field]-first map alone precisely so this
dedicated message, not "a constraint map is keyword/value pairs",
is what a program gets there. *)
rejects_check "bare struct-shaped braces still refuse" rejects_check "bare struct-shaped braces still refuse"
"(defn main [] i32 (let [m {.x 1}] 0))" "(defn main [] i32 (let [m {.x 1}] 0))"
~needle:"write (Type {.field v})"; ~needle:"write (Type {.field v})";
rejects_check "bare struct-shaped braces at a defn body's head, too"
"(defn main [] i32 {.x 1} 0)"
~needle:"write (Type {.field v})";
accepts "a struct literal still builds" accepts "a struct literal still builds"
"(defstruct P [x i32])\n\ "(defstruct P [x i32])\n\
(defn main [] i32 (let [p (P {.x 1})] (.x p)))"; (defn main [] i32 (let [p (P {.x 1})] (.x p)))";
@ -943,6 +950,34 @@ let () =
accepts "a where clause is still a constraint map" accepts "a where clause is still a constraint map"
"(defn biggest [a $t b $t] $t {:where (ordered? $t)} (if (> a b) a b))\n\ "(defn biggest [a $t b $t] $t {:where (ordered? $t)} (if (> a b) a b))\n\
(defn main [] i32 (biggest 1 2))"; (defn main [] i32 (biggest 1 2))";
(* A where clause with nothing after it is what a moved closing paren
produces -- the body fell outside the defn. It stays a constraint map
even alone, so this is "no body", not the "unknown function ordered?"
it used to print from a stray predicate read as ordinary body code. *)
rejects_check "a where clause with no body after it"
"(defn ordered? [x] bool true)\n\
(defn mx [a $t b $t] $t {:where (ordered? $t)})"
~needle:"has no body";
(* A keyword-keyed map at the head of a multi-form body, other than
:where, is a typo almost every time -- its value is discarded, and a
map literal has no reason to sit somewhere its value goes unused. An
empty map there gets its own message, since it has no key for [keys]
to name. *)
rejects_check "a discarded keyword-keyed map at a defn body's head"
"(defn main [] i32 {:a 1} 0)"
~needle:"is not a key a defn's constraint map takes";
rejects_check "a discarded empty map at a defn body's head"
"(defn main [] i32 {} 0)"
~needle:"an empty map literal here is discarded";
rejects_check "a discarded string-keyed map at a defn body's head"
"(defn main [] i32 {\"a\" 1} 0)"
~needle:"keyword/value pairs";
(* Not discarded, because nothing follows it: the map IS the single-form
body, a real dyn value and not a mistake to flag. *)
accepts "a keyword-keyed map as a defn's whole body"
"(defn f [] dyn {:a 1})\n(defn main [] i32 0)";
accepts "an empty map as a defn's whole body"
"(defn f [] dyn {})\n(defn main [] i32 0)";
(* Keywords: dyn where nothing else is asked, still an enum member where an (* Keywords: dyn where nothing else is asked, still an enum member where an
enum is, and refused where a concrete non-dyn type is wanted. *) enum is, and refused where a concrete non-dyn type is wanted. *)

View File

@ -32,7 +32,18 @@ let label = ref "test"
let budget = ref 0 let budget = ref 0
let deadline = ref 0.0 let deadline = ref 0.0
(* Set the instant [dying] starts unwinding, and read by any [at_exit]
handler a binary registers of its own (test_acceptance.ml's failure-count
guard is the one that exists) so it knows not to relabel this exit as an
ordinary failing run: the watchdog's 2 is a distinct code from a FAIL's 1,
and an [at_exit] handler that forced 1 whenever [failures > 0] would
overwrite it out from under a hang that happened to come after a few rows
had already failed. *)
let dying_flag = ref false
let is_dying () = !dying_flag
let dying _ = let dying _ =
dying_flag := true;
Printf.eprintf Printf.eprintf
"\nFAIL %s: no result after %ds — stopped by the test watchdog.\n\ "\nFAIL %s: no result after %ds — stopped by the test watchdog.\n\
\ A test that hangs reports nothing at all; this is that outcome\n\ \ A test that hangs reports nothing at all; this is that outcome\n\