E2E and dashboard suites flake when another worktree builds concurrently #489
Labels
No labels
agent-ready
bug
contract
documentation
duplicate
enhancement
good first issue
help wanted
invalid
needs-human
needs-input
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
iterative-works/iw-cli#489
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The E2E and dashboard suites contend on real resources and go red without any defect in the tree when another worktree of this repo builds at the same time.
Evidence
Seen while verifying the IW-476 phase-01 review fixes, with another iw-cli worktree running
batch-implement.dashboard.test->CaskServerTest."GET /api/status shows correct worktree count"failed atdashboard/jvm/test/src/CaskServerTest.scala:439. Green on the next run with no code change. The test binds a real port and writes a state file../iw ./test e2e-> exit 1 with 7not oklines across theissue create,openandrmsuites.bats test/rm.batsalone passed both its tests, and the next full run returned exit 0 with 190 ok and no failures.Neither touches the code changed in that phase.
A third sighting, 2026-09-01
Pushing the IW-476 feature branch, the
pre-pushhook printed:The hook's step 3 is
if ./iw ./test unit && ./iw ./test compile. Run standalone on the same tree, both exit 0, and the hook script run directly (bash .git-hooks/pre-push) passes. The identical push retried immediately and succeeded with no change. So./iw ./test unitreturned non-zero inside the push while printing a passing suite. That half is not explained by suite flakiness and may be a separate fault.Why it matters
A red that does not reproduce costs an investigation into code that is fine, and it trains the reader to retry rather than to read. The
pre-pushcase is worse: the banner asserts "Tests failed" over output that says the tests passed.Wanted
Find the resource these suites contend on and make the suites independent of it, or serialise them. Separately, find why
./iw ./test unitcan return non-zero to the hook while printing success.Related
Duplicate of #468, which reports the same non-determinism with a fuller investigation (temp-directory race and socket collision already ruled out). Everything this issue added — the dashboard.test flake, the concurrent-worktree correlation, and the pre-push 'PUSH BLOCKED over SUCCESS' anomaly — is copied onto #468. Closing here; track it there.
Diagnosis
Investigated on
batch-implement-blockers. This is not one bug. It is three, and only two of them are about concurrency. Fix was deliberately kept out of scope for this session; this comment records the causes so the fix can be scoped properly.(c) The gate that says "Tests failed" over passing output — highest value, and the only one that is a lie
Two things compose.
First, an ordering artefact.
.iw/commands/test.scala:74-84runsmill core.testfirst, thenmill dashboard.test, and ANDs the two exit codes. It prints a section header before each and nothing after either. So the last thing on screen is always the second command's tail. Acore.testthat failed five minutes earlier leavesdashboard.test SUCCESSas the final visible line, then the hook's red banner. That alone explains the reported ordering, with no wrong exit code anywhere.Second, and this is the real defect:
runStreamingcannot tell a failing command from one we killed, and says nothing either way.core/adapters/Process.scala:99-121passestimeout = timeoutMs(default 5 minutes, line 24) to os-lib'scall(check = false, ...). In os-lib 0.11.6 that reachessub.join(timeout, shutdownGracePeriod), which sends SIGTERM, waits 100 ms, then SIGKILL. No exception is thrown —check = falseshort-circuits the throw.callreturns aCommandResultwhoseexitCodeis 143 or 137, andProcess.scala:120returns that Int verbatim. The caller sees a non-zero number with no way to know we killed it.The repo already models the correct behaviour one function up:
ProcessAdapter.runcomputesval timedOut = result.exitCode == SigtermExitCode(Process.scala:78, constant at line 41) and surfaces it onProcessResult.core/test/ProcessTest.scalapins it.runStreamingdrops the whole channel.So:
mill core.testcrossing 300 s — which needs no bug at all, only a cold Mill daemon plus a scoverage-instrumented compile (build.mill:36) plus a second worktree saturating the machine — is SIGTERMed by our own harness.dashboard.testthen runs, passes in 10 s, prints SUCCESS, andrunUnitTestsreturns false. Exactly the reported shape.I cannot prove from code alone which of the two produced the specific run: a silent timeout kill, or a genuine failure whose output scrolled away. That is the point — today the two are indistinguishable, at the exit code and on screen.
Hypothesis, unconfirmed: Mill 1.1.5's launcher reads the daemon's exit code out of
out/mill-daemon/, so a daemon dying after finishing the graph could also produce SUCCESS-then-nonzero. The check is./mill dashboard.test; echo $?under load. The timeout mechanism above needs no such assumption.(a)
CaskServerTest."GET /api/status shows correct worktree count"The shared resource is the TCP port, not the state file. The state file is
${java.io.tmpdir}/iw-server-test-${Random.nextLong().abs}/state.json(CaskServerTest.scala:29-32) — never the shared~/.local/share/iw/server/state.json. Collision there is not credible.Three facts compose into a silent cross-JVM hijack:
findAvailablePort()(CaskServerTest.scala:74-78) closes the socket before returning the number. Two JVMs can be handed the same freed ephemeral port.startTestServer(:43-47) runsCaskServer.starton a barenew Thread(...)with no try/catch. Undertow throws a RuntimeException wrappingBindException; on that thread it reaches only the default uncaught handler — a stack trace in the log, and the test carries on.:50-58polls/healthand accepts any 200./healthisujson.Obj("status" -> "ok")with no identity (CaskServer.scala:769-771). The winner answers 200, so the loser declares itself ready.The loser then PUTs
IWLE-111andIWLE-222into the winner's server and reads/api/statusback from it.status()(CaskServer.scala:815-825) returnsstate.worktrees.sizewith no pruning, so the count is the other JVM's.worktreeCountfails — andporton the next line still passes, because both servers were told the same number. That is why the failure looks inexplicable.On the reported line 439: at both
7a4fa367and HEAD that line is blank; theworktreeCountassertion is at 457. The report either points at an exception out of the first PUT, or the line is slightly off. Either reading lands inside the same test and both are explained by "this test's server never owned its port."The itest suite retries (
CaskServerItest.scala:80-137) and is mostly safe — but it swallowscase _: java.net.BindException => ()(line 104) and also accepts any 200, so a foreign holder still reads as ready there too. The retry only rescues the case where nothing answers.(b) The seven scattered
not oklines inissue create/open/rmThese suites isolate cwd, the tmux socket, the dashboard and plugins. They do not isolate
HOMEorXDG_DATA_HOME, and four resources leak:/tmp/testproject-*is a cross-run destructor. Six teardowns glob it (rm.bats:41,open.bats:41,start.bats:41,analyze.bats:42,open-prompt.bats:54,start-prompt.bats:54). Two concurrent E2E runs delete each other's worktrees mid-test.open.batsandrm.batsare two of the suites named in the sighting.iw <cmd>in every BATS test isexec scala-cli run -q .... Confirmed on this machine: exactly one daemon,-Xmx8g, shared by all three checked-out worktrees. Per-worktree.scala-build/keeps the projects distinct; the daemon, its heap and its compile queue are not.~/.cache/coursierand its lock files, shared by every mill and scala-cli invocation.runE2ETestssetsIW_CORE_JARtoinstallDir / "build" / "iw-core.jar"under a comment saying it avoids per-test jar rebuilds. That file is written only byscripts/package-release.sh:79into the release staging dir — it never exists in a dev checkout. Worse, the override destroys a good value:iw-runhad already exported a validIW_CORE_JARbefore exec'ing the test command. Net effect: every one of ~190 BATS tests spawns a Mill launcher againstout/. Any single Mill hiccup under load becomesError: Mill failed to resolve core.jar→iwexit 1 → a red line in whichever suite was unlucky. That is precisely "7 not ok scattered across three unrelated suites, each green when run alone."What a fix would have to do, in order
(c) first — stop the tooling lying.
runStreamingmust detect the kill and return the existing-1sentinel rather than the signal code, and print one line naming what was killed.SigtermExitCodealready exists atProcess.scala:41.runUnitTestsmust give the Mill invocations a build-appropriate timeout —runE2ETestsandrunContractTestsalready pass 10 minutes, so 5 minutes for a scoverage-instrumentedcore.testis an oversight, not a policy — and must name the culprit before returning..git-hooks/pre-push:56-68runs./iw ./test unit && ./iw ./test compile, so a unit failure never reaches compile and the banner cannot say which step failed. Run them separately and report the failing step. Do not weaken the gate.(a) — make a test server prove it owns its port.
4. Give
/healthan instance identity. Without it no readiness probe can tell "my server" from "someone else's server on the same number".5.
startTestServermust capture the startup throwable instead of dropping it, accept readiness only on its own instance id, retry with a fresh port on mismatch, andfail(...)with the capturedBindExceptionwhen attempts run out.CaskServerItest.scala:80-137is the retry skeleton to copy — but fix its two holes in the copy. Note the suite isvar-free and the project bansvar: write it recursively, not as the itest'swhile/varloop.(b) — isolation.
6. Stop globbing
/tmp/testproject-*. Create sibling worktrees inside a per-test parent andrm -rfonly that parent. Touches the tests asserting on the sibling path (analyze.bats:49-50,start-prompt.bats:64-65).7. Stop overriding
IW_CORE_JARwith a path that does not exist. Either resolve the real jar or pass through whatiw-runalready exported. The comment above it then becomes true, and ~190 Mill spawns per E2E run disappear.8. The per-user bloop daemon and coursier cache stay shared by design. If cross-worktree E2E concurrency must be safe, that is a policy decision — the honest options are a machine-wide advisory lock on
iw ./test, or accepting that concurrent E2E is unsupported.Items 6 and 7 are bugs on their own merits and are worth fixing regardless of the concurrency policy.
Diagnosis only, per the scope of this session. Claims are cited to
file:line; the two places I could not prove are labelled as hypotheses.Follow-up from a real consumer hitting this, which sharpens fault (c) and adds a second defect beside it.
The kanon plugin added a
PhaseOutcome.AwaitCIhandler that shells out to./iw ci-gatethroughProcessAdapter.runStreaming(cmd, guardMs)and treated any exit that was not 0 or 3 as CI's verdict. WithrunStreamingreturning a killed child's signal code verbatim, a CI wait outliving that guard was reported to the user as "ci-gate exited with code 143; CI did not go green" — the batch dying and blaming a healthy build. They have fixed it on their side by treating 143 and 137 as "the guard fired, call the gate again".Two things that fix exposed:
1.
SigtermExitCodeis private, so a consumer cannot detect the case at all.core/adapters/Process.scala:42isprivate val SigtermExitCode = 143. A caller ofrunStreamingthat wants to distinguish "we killed it" from "it failed" has no constant to compare against and must hardcode the magic numbers, which is exactly what the plugin now does. Whatever shape the fix takes, the timeout signal has to be reachable by callers — either surfaced on the return value, or the constant made public.2.
run's owntimedOutmisses SIGKILL.Process.scala:76isval timedOut = result.exitCode == SigtermExitCode, andSigtermExitCodeis 143 only. os-lib sends SIGTERM, waits the grace period, then sends SIGKILL — a child that ignores SIGTERM, or is stopped, exits 137. SoProcessAdapter.run— the function that gets this right relative torunStreaming— still answerstimedOut = falsefor a process it killed the hard way. The correct check covers both 143 and 137.This is not a new fault so much as evidence that (c) is worse than "the harness is quiet about it": the missing channel propagates into consumers as confidently wrong error messages, and the two functions that should agree do not agree with each other either.