flan/test/watchdog.ml
Joseph Ferano c3e0703642 Five more, and the pattern held: the messages were right, the prose wasn't
check_struct's comment claimed a nonempty map is never mistaken for a
struct literal; is_struct_map (parse.ml) says otherwise for any size of
.field-first map, not just {} -- (g {.x 1}) on a function g still reported
bare "unknown struct g" with none of the help {} gets. Extended the
helpful message to that shape too rather than only fixing the comment,
since it is the same trap one shape over. The two shapes need different
advice, not the same one reworded: an empty map can be bound to a variable
and passed as an ordinary argument, (let [m {}] (g m)); a .field-keyed one
cannot, because expr itself refuses a bare {.field v} outside a
struct-literal position, so there is no let-binding that rescues it. The
message for that shape says so instead of repeating advice that would not
work.

Watchdog.is_dying is gone. The reviewer's own probe settled it: forcing
watchdog.ml's dying to flush_all and Unix._exit 2 directly, bypassing
at_exit entirely, gives the same exit 2 with no shared flag, no LIFO
assumption, and no window for a raise between setting a flag and exiting
to leave failures recorded and the process at exit 0. test_acceptance.exe
has no other at_exit registration (grep confirms), so nothing depends on
the watchdog path running through that chain.

test_acceptance.ml's own comment claimed calling exit inside an at_exit
handler recurses through do_at_exit. Checked directly against this
compiler (OCaml 5.2.0): it does not -- each handler gets a run-once guard
since 4.14, two stacked handlers with the inner one calling exit 7 both
ran exactly once and exited 7. Unix._exit is kept, but for the true
reason: it is not about correctness, it is about being the last word --
_exit terminates immediately and skips whatever the rest of the at_exit
chain would otherwise still do, so a handler this file grows later cannot
change the outcome underneath this one.

test_flan.ml's loose needle "keyword/value pairs" matched both the
odd-number-of-forms message and the wrong-key message; tightened to
"keyword/value pairs — found", which only the row's actual message
contains.

And {:where 1} as a defn's whole single-form dyn-map body now refuses,
where {:a 1} and {} in the same position do not -- :where is peeled
unconditionally, with no single-form exception, because that is what lets
it catch a moved closing paren leaving a stray predicate as ordinary body
code. Deliberate, and now pinned, so the next edit to this arm has to
notice it is choosing to narrow the language again rather than finding out
from a bug report.

Verified every case by compiling, including the two-handler exit(7) probe
run directly against this OCaml. dune test --force: exit 0, clean grep for
FAIL and Fatal error.
2026-09-19 21:51:31 +07:00

74 lines
3.3 KiB
OCaml

(* A clock on every test binary, because a green run is not the only outcome
to plan for.
The mutation pass that produced NEXT.md's blind-spot list turned up one
defect that did not make the suite fail — it made it *hang*. A reader loop
that forgets to advance reads the same character for ever, and every binary
that reads a .flan file stops there. Nothing prints, nothing exits, and
[dune test] waits as long as it is left to. In CI that is a job killed by
the runner's own timeout, with no failing case named and no output to read.
So: an alarm, at two scales.
[arm] is the per-binary backstop. It is deliberately generous — test_dev
launches a daemon and test_acceptance builds for wasm32 — because an alarm
that fires on a slow machine is a flake, and a flake is how a watchdog gets
deleted. It is here to turn "for ever" into "fails in ten minutes", not to
measure anything.
[within] is the tight one, for a call whose budget really is small: reading
a few characters of source. It raises [Timeout] rather than exiting, so the
caller can report one failing row and carry on through the rest of its
table — a binary that dies on the first hang tells you much less than one
that finishes and names every case that hung.
SIGALRM is delivered at OCaml's safepoints, which are inserted at loop
back-edges and function entries, so a tight loop that allocates nothing is
still interruptible. *)
exception Timeout
let label = ref "test"
let budget = ref 0
let deadline = ref 0.0
let dying _ =
Printf.eprintf
"\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\
\ turned into a failing run.\n"
!label !budget;
(* [flush_all] and [Unix._exit], not [exit]: the rows that did pass are in
stdout's buffer and a watchdog that threw them away would be worse than
the hang, so both channels are flushed explicitly here rather than left
to [exit]'s own at_exit-registered flush. [_exit] then skips the rest of
that at_exit chain entirely, which matters now that test_acceptance.ml
registers a handler of its own: that handler forces exit 1 whenever
[failures > 0], and calling plain [exit] here would hand it a chance to
relabel this exit — after the alarm already fired — as an ordinary
failing run instead of a hang. Going around the chain is simpler than
coordinating with what is in it, and does not depend on how many
handlers get added there later. *)
flush_all ();
Unix._exit 2
(* Re-arm the backstop for whatever is left of its budget. One second is the
floor, because [alarm 0] cancels rather than fires. *)
let backstop () =
Sys.set_signal Sys.sigalrm (Sys.Signal_handle dying);
let left = !deadline -. Unix.gettimeofday () in
ignore (Unix.alarm (max 1 (int_of_float left)))
let arm ?(seconds = 600) name =
label := name;
budget := seconds;
deadline := Unix.gettimeofday () +. float_of_int seconds;
backstop ()
(* [f] under a tighter alarm, with the backstop restored afterwards however
[f] left. *)
let within seconds f =
Sys.set_signal Sys.sigalrm (Sys.Signal_handle (fun _ -> raise Timeout));
ignore (Unix.alarm seconds);
Fun.protect ~finally:backstop f