flan/test/test_support.ml
Joseph Ferano 76270eac0d The test directory had been copying its own plumbing, file by file
Ten test binaries share a directory and had shared nothing in it but
watchdog.ml. Everything else each one needed it wrote out again: the
failure counter and its FAIL line, the three-line report tail, a poll,
a socket connect, the wait for a [flan dev] daemon to bind, a substring
search, and the Load -> Check -> Reach.link front half of a compile.

[listening] was the clearest case. Three copies, byte for byte apart
from one comment, and two of them said in that comment that they were
kept separate because "these three files have no module between them".
That was not true when it was written: watchdog.ml was already named in
the same (modules ...) stanzas. test_support.ml is the second such
module, wired the same way, and those two sentences go with the copies
they were explaining.

test_repl.ml's [quote] was Wire.quote character for character, in a file
that already links Wire and already names Wire.quote in a comment about
what the case below it is checking. It is Wire.quote now.

One real behaviour change, and it is a fix. [connect] existed twice over
with different retries: the agent's narrowed to ECONNREFUSED with a
comment saying why -- the socket file appears at bind, a moment before
listen -- while dev's and repl's retried any Unix_error, which meant an
ENOENT or an EACCES was retried to the full timeout before raising
something the reader still had to interpret. The shared one takes the
narrow version. Every caller connects to a socket [listening] has
already seen on disk, so the race it does catch is the only one left.

The rest is left where it is, on purpose. The three output-capturing
[run]s differ in what they wrap -- a pid suffix, a sanitizer environment,
a valgrind invocation -- and are not the same function. The report tails
in test_repl, test_web and the two sweep binaries print different things
for different reasons. The per-file scratch prefixes are the feature that
keeps two suites running at once from unlinking each other's sockets, so
the shared helper takes the prefix rather than choosing one. And the
[match Sys.command "command -v clang ..."] probes stay as they are:
their skip lines are output this suite pins.

bin/main.ml has the compile pipeline written out twice more. Left alone
-- this was a test/-scoped change and bin/ should not be reaching into a
test module -- and noted in FIX.org as what it actually needs, which is
the pipeline moving into lib/.

dune test: exit 0, and its output is the same line for line once the
temp-directory hash and the millisecond counts are normalised.
2026-09-20 11:56:30 +07:00

208 lines
10 KiB
OCaml

(* The plumbing every test binary in this directory needs, in one place.
Nothing here tests anything. It is the scaffolding the cases stand on: a
failure counter and the three lines that report it, a poll, a socket
connect that survives the bind/listen race, the wait for a [flan dev]
daemon to come up, a substring search, and the Load → Check → Reach.link
front half of a compile.
It exists because each of those had been written out again in every file
that wanted it — [listening] three times byte for byte apart from one
comment, the substring search ten times, a report tail in every suite — and
two of those copies carried a comment saying they were kept separate
because "these three files have no module between them". That was not true
when it was written: watchdog.ml was already named in the same
[(modules ...)] stanzas, which is exactly the module between them. This
file is the second such module, named in all four of test/dune's stanzas
the way watchdog is in three of them.
No [let () = ...] at the top level here on purpose. A module linked into
ten binaries must not do anything on the way in; the watchdog is armed by
each test's own first line, where the seconds are that test's decision. *)
open Flan
(* ── Failures, and the report of them ─────────────────────────────── *)
(* One counter per process, and every [fail] goes through it. Each binary
binds its own [failures] to this ref rather than making one of its own, so
[report] below sees what the file's own [incr failures] did. *)
let failures = ref 0
let fail fmt =
Printf.ksprintf (fun s -> incr failures; print_endline ("FAIL " ^ s)) fmt
(* The tail every suite ends on: a single line when nothing failed, and a
count plus a nonzero exit when something did. The label names the suite,
because these binaries run under dune's parallelism and their output is
interleaved — "all tests passed" on its own says nothing about which.
[?label] is optional for the one caller that has no label to give: the
last of test_flan.ml's three tails covers the whole file and has always
printed the bare sentence, and this keeps that byte for byte.
Not every tail in this directory is this one, and the others are left
where they are: test_repl.ml quotes the daemon's exit status underneath
the count, test_web.ml says "web: ok", and the two sweep binaries print a
count without exiting on the spot. Those differ because they report
different things, not because they drifted. *)
let report ?label () =
if !failures = 0 then
print_endline
(match label with
| Some l -> l ^ ": all tests passed"
| None -> "all tests passed")
else begin
Printf.printf "\n%d failure(s)\n" !failures;
exit 1
end
(* ── Strings ──────────────────────────────────────────────────────── *)
(* Substring search, hand-written because Str is a dependency the rest of
this directory does not take (test_valgrind.ml is the one exception, and
it takes it for a regexp over memcheck's summary line).
An empty needle is contained in anything, which is the answer the majority
of the copies this replaces gave and the one that agrees with every other
[contains] in the world. Three inline copies in test_dev.ml answered
[false] instead; every needle passed to any of them was a non-empty string
literal, so no call site could tell the difference. *)
let contains hay needle =
let n = String.length needle and h = String.length hay in
let rec go i = i + n <= h && (String.sub hay i n = needle || go (i + 1)) in
go 0
(* ── Scratch files ────────────────────────────────────────────────── *)
let scratch = Filename.get_temp_dir_name ()
(* [tmp prefix name] is a path in the scratch directory. The prefix is the
caller's, not a default, and that is the point: these binaries run at the
same time under dune, so "flan-agent-dev.sock" and "flan-repl-dev.sock"
being different files is what keeps two suites from unlinking each other's
sockets. *)
let tmp prefix name = Filename.concat scratch (prefix ^ name)
(* ── Toolchain probes ─────────────────────────────────────────────── *)
(* Whether a program is on PATH. Missing tools are a skip with the reason in
this directory, never a red test — the compiler does not depend on emacs,
emscripten or valgrind being installed. *)
let have prog =
Sys.command (Printf.sprintf "command -v %s > /dev/null 2>&1" prog) = 0
(* ── Waiting ──────────────────────────────────────────────────────── *)
(* Poll for a condition rather than sleeping a fixed time: a program has to
bind its socket before there is anything to connect to, and how long that
takes is not ours to predict. Five milliseconds a turn, [ms] milliseconds
in total, [false] if the budget ran out.
Five seconds by default, which is what test_dev.ml's copy had and what its
several dozen bare call sites were written against. The callers that want
another budget pass one. *)
let rec await ?(ms = 5000) f =
if f () then true
else if ms <= 0 then false
else begin ignore (Unix.select [] [] [] 0.005); await ~ms:(ms - 5) f end
(* The socket file appears at [bind], which is a moment before [listen], so a
connect can lose that race and get ECONNREFUSED. Retry rather than sleep.
ECONNREFUSED and not any [Unix_error], which is the narrower of the two
spellings this had grown. The wide one retried ENOENT and EACCES to the
full timeout as well, so a path that was never going to exist cost seconds
before raising something a reader still had to interpret; narrowed, those
two come straight back out of [Unix.connect] with their own name on them.
Every caller here connects to a socket [listening] has already seen on
disk, so the race this does catch is the only one left. *)
let rec connect ?(ms = 5000) path =
let s = Unix.socket Unix.PF_UNIX Unix.SOCK_STREAM 0 in
match Unix.connect s (Unix.ADDR_UNIX path) with
| () -> s
| exception Unix.Unix_error (Unix.ECONNREFUSED, _, _) when ms > 0 ->
Unix.close s;
ignore (Unix.select [] [] [] 0.005);
connect ~ms:(ms - 5) path
(* Waiting for a daemon to listen is waiting for two different things with one
timer: [flan dev] compiles the whole program first, and only then binds. The
old message, "the daemon never listened", named the second and was almost
always the first — which is a wrong diagnosis, and a wrong diagnosis costs
more than no message at all.
So this says which. It cannot separate the two waits without a signal from
[flan dev] that the build is done (see NEXT.md), but it can separate the two
*failures*, and that is what actually gets read: a daemon still running when
the timer expires was building, and a daemon that is gone bound nothing
because it died. The second no longer costs the whole timeout either —
the poll watches the process as well as the socket, so a crash fails in
milliseconds instead of in half a minute, which is the part that makes a
suite worth trusting.
Thirty seconds, down from a minute, because the object cache is durable now
(Build.cachedir) and the build this waits on is warm: 0.48s idle against
2.0s cold, and the worst ever measured under dune's own parallelism was
6.8s — cold. Each caller's watchdog is still what bounds its run.
[!listen_why] carries the reason to the caller so each site can keep its own
name for its daemon. One ref is enough even though three binaries now share
this code: they are three processes, each single-threaded, and the next
thing after a failed wait is always the report of it. *)
let listen_why = ref ""
let listening ?(ms = 30000) ~pid path =
let died = ref None in
ignore
(await ~ms (fun () ->
Sys.file_exists path
||
(* Reaped only once it is already gone, and only on the path that ends
in a failure, so a teardown's own [waitpid] is unaffected. *)
match Unix.waitpid [ Unix.WNOHANG ] pid with
| 0, _ -> false
| _, st -> died := Some st; true
| exception Unix.Unix_error _ -> false));
if Sys.file_exists path then true
else begin
listen_why :=
(match !died with
| Some (Unix.WEXITED n) ->
Printf.sprintf "exited with status %d before binding %s" n path
| Some (Unix.WSIGNALED n) ->
Printf.sprintf "was killed by signal %d before binding %s" n path
(* Unreachable without WUNTRACED, and here only for exhaustiveness. *)
| Some (Unix.WSTOPPED n) ->
Printf.sprintf "stopped on signal %d without binding %s" n path
| None ->
Printf.sprintf
"was still running after %ds without binding %s, so it was the \
build that did not finish, not the socket"
(ms / 1000) path);
false
end
(* ── The front half of a compile ──────────────────────────────────── *)
(* Read, load and check a program: the same two calls [flan build] makes
before it reaches the backend. Through [Load], so a program with an
(import ...) is buildable here — it brings back the package's declarations
as well as the file's own. *)
let checked path =
Check.program (Load.program ~file:path (Reader.read_file path)).Load.decls
(* And the third call, which is where the binaries below actually differ from
one another: [Reach.link] decides the link from the checked program — a
package nothing reachable calls into hands over no C and no linker
argument, and its functions are not emitted — and returns the program
together with the C sources and linker flags the build needs.
This stops at [Build.executable] deliberately. Every caller passes a
different [Build.opts] (a sanitized build, a -O0 one, an x86 one, a web
one) and several want the exception rather than the executable, so the
options record is the one part that is genuinely theirs. *)
let linked ?(dev = false) path =
let l = Load.program ~file:path (Reader.read_file path) in
let p = Check.program l.Load.decls in
Reach.link ~dev l p