fleetd #399: fix TTL test race — wait on completion stamp, not Phase.DONE #405
Reference in New Issue
Block a user
Delete Branch "worker/ttl-stamp-race-399-f1122f-8"
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?
Fixes fleetd #399:
MessageServiceTest.aFinishedTicketIsStillPrunedOnceTheTtlPassesSinceItFinishedfailed every run on Linux and passed every run on macOS. Both readings were correct — the test had a race, not the product.Root cause
poll()reportsPhase.DONEfrom the future's value (f.getNow(null), aroundMessageService.java:1301).completedNanosis stamped by awhenCompletehook registered in theTaskconstructor (around line 260).CompletableFuture.complete()publishes its result and only afterwards runs dependents, so there is a real window wherepollanswersDONEwhilecompletedNanosis stillnull. The test'sawaitTicketPhaseOn(..., DONE)could return inside that window, then advance the injected clock past the TTL; if the hook then ran late, it stamped the advanced clock value,cutoff = advanced - TTLwas never reached, and the eviction the test exists to pin never happened.Fix — option 1 (new test seam)
I picked the test-seam option over "sweep with the clock unadvanced, then advance and sweep again" because I could not find a way to make the unadvanced sweep alone establish a real happens-before edge with the hook's write to
completedNanos: under an unadvanced clock,cutoff = now - TTLis always older than any valuecompletedNanoscould hold (whether stamped or stillnull,expiredisfalseeither way), so that sweep's outcome does not discriminate "stamped" from "not yet stamped" — it cannot be used as a barrier.Instead I added a package-private test seam:
Its javadoc says explicitly it carries no production behaviour and nothing in the class calls it —
pruneTerminalTicketsstill readsTask.completedNanosdirectly. Both TTL tests now loop on this (awaitCompletionStamped, a new private test helper) before advancing the clock, instead of looping onPhase.DONE.Happens-before argument (criterion 5):
Task.completedNanosisvolatile Long. The hook's assignmentcompletedNanos = nowNanos.getAsLong()(line 260) is a volatile write.isCompletionStampedForTest's readtask.completedNanos != nullis a volatile read of the same field. Per JLS 17.4.5, a write to a volatile field happens-before every subsequent read of that field that observes the written value. So the loop iteration in whichawaitCompletionStampedfirst observestrueis guaranteed to happen-after the hook's write — and, transitively, everything the test does afterward (includingclock.addAndGet(...)) is ordered after that write. This is a real synchronization edge, not a timing heuristic.Sibling test
aTaskRunningLongerThanTheTtlStillKeepsItsReportshares the sameawaitTicketPhaseOn(..., DONE)barrier before the sweep-triggeringsendAsynccall, but its own clock advance happens before completion, not afterDONE. So by the time the sweep runs, the clock has not moved further:cutoff = current_clock - TTLandcompletedNanos(whenever it lands) both track the same unadvancedcurrent_clock, andcompleted < cutoffis false either way — the assertion is safe today regardless of stamp timing. I still added the sameawaitCompletionStampedwait there, for consistency and to remove the fragile-looking pattern, even though it wasn't strictly required for correctness.Constraints honored
git diffonMessageService.javamakes no change to theexpiredexpression inpruneTerminalTickets(verified:git diff main -- fleetd/src/main/java/dev/ltms/fleet/msg/MessageService.java | grep 'expired ='returns nothing).git add -A.fleetd.yaml,.mcp.json,opencode.json,.autoenv, orwiki/.Build
(
dev.ltms.fleet.msg.MessageServiceTest:Tests run: 82, Failures: 0, Errors: 0, Skipped: 0)20 isolated runs of the changed test method
mvn -Dtest=MessageServiceTest#aFinishedTicketIsStillPrunedOnceTheTtlPassesSinceItFinished test, 20 times in a loop, output appended to a file and then read back:All 20 runs:
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0/BUILD SUCCESS/exit=0.Caveat, stated honestly: this is a necessary but not sufficient check. This race does not reproduce on macOS at all — that's the whole reason the bug is Linux-only in the ticket. 20 green runs here says the fix doesn't break anything and doesn't rule out a scheduling artifact, but it cannot demonstrate the race is closed, because the race was never observable on this host to begin with. The happens-before argument above, not the run count, is what should give confidence in the fix.
Same shape elsewhere (not fixed, per scope)
I did not search further than the two named tests — the ticket scoped this to
MessageServiceTest's two TTL tests. Not investigated further, per instructions to note out-of-scope observations rather than chase them: I did not grep the rest of the test suite for other tests that pollPhase.DONE(or an equivalent future-resolution signal) as a barrier before manipulating an injected clock or other completion-adjacent state. If similar patterns exist elsewhere in this file or others usingnowNanos-style injected clocks, they'd have the same latent race.Branch:
worker/ttl-stamp-race-399-f1122f-8