Two lanes were sent after a contention race on a socket path. There is none. The sentence came from three sites and not the two HANDOFF-f1.md named — test_dev.ml:77 checks the *dev* socket with exactly that wording, in the same block whose *agent* socket check was fixed — and the cause is a bound too short for a build: flan dev links the whole program before it binds, ~600ms warm, measured at 6.5s here and 6.6-6.8s by the lane that fixed it, against a 5000ms await in test_dev.ml and 8000ms in the other two. Build.cachedir sits under dune's per-run TMPDIR, so that build is cold on every invocation and the failure is not occasional at all. The fix landed on another branch; nothing in this commit touches a test. The agent.sock ordering fix it does confirm: 30 sequential full dune test runs, 0 failures, no "never bound" in any log — so NEXT.md's "One flaky test, measured rather than suspected" section, left standing for whoever confirmed it, goes. HANDOFF-f1.md's "lengthening any timeout" bullet is qualified rather than deleted. It was true of the agent.sock check and read as a rule, and reading it as a rule is what kept this open for a second lane.
6.6 KiB
Handoff — the test_dev.ml "never bound agent.sock" flake
Root cause
test_dev.ml's first block checked Sys.file_exists agent_sock without having completed a single
round-trip on the daemon socket, so nothing had ordered the check after the bind.
The evidence is in lib/dev.ml:
merged_setup(lib/dev.ml:2264) binds and listens on the dev socket (Unix.bind lsat lib/dev.ml:2300) and returns. The program's main thread — the thread that callsagent/start, which is what bindsagent.sock(vendor/agent/flan_agent.c:1097flan_agent_start) — only runs aftermerged_setupreturns.- So the test's
await (fun () -> Sys.file_exists sock)(test/test_dev.ml:76) says nothing aboutagent.sock, andconnect sock(test/test_dev.ml:82) succeeds off the listen backlog without anyone having accepted:merged_serve(lib/dev.ml:2311) does not reachaccept_loopuntil after its ownawait ~ms:10000 (fun () -> Sys.file_exists t.agent)(lib/dev.ml:2322). - The comment at lib/dev.ml:2314-2320 states the ordering outright.
There is a second race on the same path, which the timings confirmed. The test unlinks
agent.sock immediately after the check (test/test_dev.ml, Unix.unlink agent_sock). Both the test
and merged_serve poll that path at 5ms. When the test won and unlinked first, merged_serve burned
its full 10s timeout and printed the "does it call (agent/start ...)?" warning before accepting — the
test still passed, ~10s slower. Measured: failing runs clustered at ~24s, passing runs at ~30-36s.
What changed
One file: test/test_dev.ml. The existing describe request and its status check were moved up
to sit immediately before the agent_sock existence check (and removed from their old position a few
lines below). No new sleep, no lengthened timeout, no change to lib/dev.ml.
A completed reply proves merged_serve got past its await — i.e. it has already observed
agent.sock — which makes the existence check deterministic and puts the unlink safely after that
observation, killing both races at once.
The genuine-failure property is preserved: if a program really never binds, merged_serve warns
after 10s and accepts anyway, the reply arrives, and the existence check fails as it should.
Verified
Run as ./test_dev.exe from _build/default/test, serially, whole binary, detecting the flake by
grepping stdout for never bound:
- Before: 2/8 runs failed (25%) — matches the rate NEXT.md records. Failing runs 24.7s and 24.0s; passing runs 30.0-36.0s (the 10s stall described above).
- After: 0/10 runs failed (0%), and every run landed in the fast band, 17.0-26.7s — the 10s stall band is gone as well, which is the independent confirmation that the second race is also closed.
Run counts are small (8 and 10) because the session was cut short for budget; each run is ~25s. A longer sweep was not measured.
dune test --root . was run twice after the change. The second run was green. The first run reported
1 failure(s) with FAIL the daemon never listened — see item 1 below.
What remains
- Decide whether
merged_serve's 10s warning path deserves a test. Nothing currently exercises lib/dev.ml:2322-2326 (a program that never callsagent/start). It was reachable only by accident here, via the unlink race that is now closed.
Closed since: the other "the daemon never listened", which was not a race at all
The previous item 1 sent two lanes after a contention race. There is none, and the reading it was built on was wrong twice over:
- The wording is not unique to
test_emacs.ml:50andtest_repl.ml:71.test/test_dev.ml:77— the dev socket check in the same first block this handoff fixed the agent socket check in — emitted exactlythe daemon never listenedas well. Three sites, not two, which is why a sighting in a full-run log could not be attributed. - The cause is a bound that is too short for a build, not a race.
flan devdoes an llc-and-link of the whole program before it binds its socket, so the await covers a build. Warm and idle that is ~600ms; under dune's own parallelism it was measured at 6.5s here and at 6.6s and 6.8s by the lane that fixed it — against a 5000ms await intest_dev.mland 8000ms in the other two. Nothing about it is occasional; it reproduces whenever the build is cold, andBuild.cachedirsits under dune's per-runTMPDIR, so the object cache starts empty on everydune testinvocation.
Fixed on another branch: all three awaits now wait a minute, bounded by the existing watchdog rather
than by a guess, and test_dev.ml carries a named listening helper so the reason is written once
for its thirteen daemons.
Two measurements from this lane that stand whatever else changes: 30 sequential full dune test
runs, 0 failures and no never bound in any of them — the agent.sock ordering fix above is
confirmed at a sweep wide enough to close item 3 of the old list, so NEXT.md's "One flaky test,
measured rather than suspected" section is deleted. The reproduction that came after it, on the 31st
run, was the dev socket at test_dev.ml:77, with built dev-loop.flan in 6525ms sitting four lines
below the FAIL in the same log — which is what turned the hypothesis into the finding above.
Tried and rejected
- Wrapping the check in the file's
awaithelper (await (fun () -> Sys.file_exists agent_sock)). It looks like the obvious fix and it is wrong: it makes the test observe the file at the earliest possible instant, which makes it more likely to win the unlink race againstmerged_serveand more likely to trigger the silent 10s stall. Do not reintroduce it. - Lengthening any timeout — for the
agent.sockcheck. Not needed there; the ordering was missing, not the time. This does not generalise, and reading it as a rule cost two lanes: the socket-appears checks above are a wait for a build, and there lengthening the bound was exactly the fix. See the section before this one.
Environment note for whoever picks this up
Running _build/default/test/test_dev.exe directly requires the dune-copied deps
(test/programs/*.flan) to be present in _build. A bare dune build --root . does not copy
them — every daemon then dies with Sys_error("programs/dev-loop.flan: No such file or directory")
and all eleven blocks report "never listened". Run dune build --root . @test/runtest once first to
populate _build/default/test/programs/. Also run the loop strictly serially: the socket names
under $TMPDIR are fixed (flan-devtest-*.sock), so parallel runs collide and manufacture failures.