Fix flaky PerTestRecordingSpec by waiting for recording files to stabilize - #16031
Fix flaky PerTestRecordingSpec by waiting for recording files to stabilize#16031borinquenkid wants to merge 2 commits into
Conversation
waitForRecordingFiles() polled the recordings directory for matching .mp4/.flv files but only checked that the expected file count existed by name, never that each file's content was fully written. Testcontainers' VncRecordingContainer.saveRecordingToFile() copies the recording with a plain, non-atomic Files.copy(..., REPLACE_EXISTING), so a destination file becomes visible to the directory scan the moment the copy starts - potentially at 0 bytes or mid-write. Two still-partial recordings (e.g. both 0 bytes) can register as byte-identical, intermittently failing the "recordings of the previous two tests are different" assertion (#16030). Track each candidate file's size across polls and only treat it as ready once its size is non-zero and unchanged from the previous poll, which means the copy has finished. Keeps the existing 10s timeout / 500ms poll interval. Verified with :grails-test-examples-geb:integrationTest (PerTestRecordingSpec) - all three iterations pass, including the comparison assertion.
There was a problem hiding this comment.
Pull request overview
This PR addresses a flaky Geb/Testcontainers integration spec (PerTestRecordingSpec) by tightening the readiness criteria for VNC recording files so the test doesn’t compare partially-copied (e.g., 0-byte) videos.
Changes:
- Update
waitForRecordingFilesto only treat recording files as “ready” once their size is> 0and unchanged across two consecutive polling scans. - Keep the existing polling cadence (10s timeout / 500ms interval) while improving correctness of the “recordings are available” detection.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #16031 +/- ##
================================================
+ Coverage 0 51.4701% +51.4701%
- Complexity 0 17754 +17754
================================================
Files 0 2039 +2039
Lines 0 95537 +95537
Branches 0 16571 +16571
================================================
+ Hits 0 49173 +49173
- Misses 0 39062 +39062
- Partials 0 7302 +7302 🚀 New features to boost your workflow:
|
jdaugherty
left a comment
There was a problem hiding this comment.
Thanks for digging into this one — the polling helper is definitely the right place to look, and the stability-tracking code itself is clean and readable.
My concern is with the diagnosis. I walked the recording lifecycle and I don't think the partial-write race described in the PR body can actually occur:
GebRecordingTestListener.afterIterationcallscontainerHolder.container.afterTest(...)synchronously, on the test thread, in Spock's per-iteration callback.BrowserWebDriverContainer.afterTest→retainRecordingIfNeeded→vncRecordingContainer.saveRecordingToFile(recordingFile).saveRecordingToFileistry (InputStream in = streamRecording()) { Files.copy(in, file.toPath(), REPLACE_EXISTING); }, andstreamRecording()itself blocks onexecInContainer("ffmpeg", ...)before the copy even begins.
So both Files.copy calls have fully returned before the third feature method starts — nothing is still writing to those files while waitForRecordingFiles scans. Files.copy not being atomic only matters if a reader can observe the file concurrently with the writer, and here the spec is @Stepwise, so there is no concurrent writer to observe. That makes me think the change doesn't remove the flake so much as change which assertion reports it.
Worth noting too that the linked flaky report shows 0/1419 hard failures and 1/1419 flaky, so there's no captured failure output in the issue — which means the root cause is still a hypothesis. Before landing a fix I'd want to know which assertion actually failed, because the two candidates want very different fixes:
recordingFiles.size() >= minFileCountfailed — only one recording landed.GebRecordingTestListenerswallowsNotFoundExceptionfor/newScreen.mp4with a debug log, which is exactly what happens when the freshly restarted VNC container hadn't captured any frames yet forffmpegto re-encode. If that's the cause, the fix belongs inrestartVncRecordingContainer(wait until the new container is actually capturing), and this PR makes the symptom worse: the wait now burns the full 10s before failing.Files.mismatch(...) != -1failed — the two recordings really were byte-identical. Requiringsize > 0rules out the both-files-empty case, but not the case that seems likelier to me: two degenerate near-blank captures thatffmpeg -vcodec libx264 -movflags faststartencodes to the same bytes. Those are non-zero and size-stable, so they sail through the new check. If this is the cause, the byte-comparison assertion is itself the flake and should be replaced with something that asserts the actual framework contract (a distinct, non-trivial recording per feature method) rather than raw byte inequality of ffmpeg output.
Suggestion: rather than land this, could you pull the failure output for the flaky run from the CI/Develocity build scan (or reproduce with the recording container restart forced to fail) so we can confirm which assertion broke? Happy to help chase it. I'd rather not add a readiness check for a race that the synchronous call chain rules out — it costs latency on every run and leaves the real cause in place.
One process note: PerTestRecordingSpec exists on 7.0.x too, with an older waitForRecordingFiles that doesn't even have the per-run directory scoping from c179aac. Whatever we land here, it'd be good to decide whether it goes to 7.0.x first and merges forward, so the two copies of this helper don't keep diverging.
| // Testcontainers copies each recording with a plain, non-atomic | ||
| // stream copy, so a file can appear in the directory scan above | ||
| // while still 0 bytes or only partially written. Only treat a | ||
| // recording as ready once its size is non-zero and unchanged | ||
| // since the previous poll, which means the copy has finished. | ||
| readyFiles = candidateFiles.findAll { File file -> | ||
| long size = currentSizes[file.absolutePath] | ||
| size > 0 && previousSizes[file.absolutePath] == size | ||
| } |
There was a problem hiding this comment.
The premise in this comment doesn't hold, so this check is guarding against something that can't happen.
GebRecordingTestListener.afterIteration calls container.afterTest(...) synchronously on the test thread. That reaches BrowserWebDriverContainer.retainRecordingIfNeeded, which calls vncRecordingContainer.saveRecordingToFile(recordingFile):
try (InputStream inputStream = streamRecording()) {
Files.copy(inputStream, file.toPath(), StandardCopyOption.REPLACE_EXISTING);
}and streamRecording() blocks on execInContainer("ffmpeg", ...) before the copy starts. Because the spec is @Stepwise, features 1 and 2 have both returned from afterIteration — and therefore from Files.copy — before this feature method runs. Files.copy lacking a temp-file+rename only matters when a reader can race a live writer; there is no live writer here.
Separately, even on its own terms "size unchanged since the previous poll" isn't proof a copy finished — a docker tar stream that stalls for more than the 500ms poll interval under CI I/O contention gives two equal readings mid-copy. If a readiness check is genuinely needed, it would want N consecutive stable polls rather than one.
| ) { | ||
| long deadline = System.currentTimeMillis() + timeoutMillis | ||
| List<File> recordingFiles = [] | ||
| Map<String, Long> previousSizes = [:] |
There was a problem hiding this comment.
previousSizes starts empty, so on the first poll previousSizes[path] == size is null == size for every candidate and readyFiles is always empty — no matter how long the recordings have been sitting complete on disk.
That means every run of this spec now sleeps at least one full pollIntervalMillis (500ms) before it can succeed, where previously the first poll could break immediately. Seeding previousSizes from an initial scan before entering the loop would avoid the unconditional penalty if this check is kept.
| sleep(pollIntervalMillis) | ||
| } | ||
| return recordingFiles | ||
| return readyFiles |
There was a problem hiding this comment.
On timeout this returns readyFiles, and candidateFiles is discarded. That makes a real failure harder to diagnose than before: previously the returned list held whatever was on disk, so the recordingFiles.size() >= minFileCount failure message showed the files that were found. Now a timeout typically reports an empty list, with no signal distinguishing "no recording was ever written" from "two recordings existed but never satisfied the stability check" — and those point at different bugs.
If the check stays, please surface the candidates on timeout (return them, or at minimum log their names and sizes) so the next flaky run leaves behind something actionable.
Review on this PR (#16031) showed the original fix's diagnosis doesn't hold: GebRecordingTestListener.afterIteration calls BrowserWebDriverContainer.afterTest -> saveRecordingToFile synchronously on the test thread, and saveRecordingToFile's own Files.copy blocks until the copy is done - verified directly against the Testcontainers 2.0.5 source. There is no concurrent writer for a directory scan to race against, so the size-stability polling added here didn't remove a race; it just added latency. The more likely real cause: a VNC recording container that was just restarted (WebDriverContainerHolder#restartVncRecordingContainer) is only guaranteed to have connected, not to have captured meaningful frames, by the time a fast test iteration finishes. Two such near-blank captures can encode to identical, non-zero, size-stable bytes via ffmpeg, passing both the old and the new check without being distinct, meaningful recordings. - PerTestRecordingSpec: revert the stability-polling change, and instead assert each recording independently exceeds a sensible minimum size before asserting the two differ - this is the actual framework contract, not raw byte inequality of ffmpeg output. - WebDriverContainerHolder#restartVncRecordingContainer: fix a real bug found while investigating - the vncRecordingContainer field was set to the new container BEFORE start() was called, so a thrown (and swallowed) start() failure left the field pointing at a container that never actually started. - WebDriverContainerHolder#stop: wrap container?.stop() in try/finally so a thrown stop() also can't skip the state reset, leaving isInitialized() reporting true for a broken container. Verified against the real Testcontainers-backed integration test (PerTestRecordingSpec, 3/3 passing) and repo-wide codeStyle. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
✅ All tests passed ✅🏷️ Commit: 24a0b59 Learn more about TestLens at testlens.app. |
Summary
PerTestRecordingSpec > the recordings of the previous two tests are differentisflaky (~1% of runs per #16030), with 0 hard failures — same commit,
different outcome on rerun.
Root cause
waitForRecordingFilespolled for candidate recording files by existence + name-matchVncRecordingContainer.saveRecordingToFile()copies thevideo with a plain
Files.copy(...)and no atomic temp-file+rename, so the destinationfile becomes visible to a directory scan the instant the copy starts — a
still-writing (possibly 0-byte) file passes the existence check. Two still-partial
files can register as byte-identical via
Files.mismatch, intermittently failing the"recordings are different" assertion.
This builds on James's prior fix in c179aac ("Address flaky test"), which fixed an
earlier race in which files get matched but didn't check that a matched file had
finished being written.
Fix
waitForRecordingFilesnow tracks each candidate file's size across polls and onlyaccepts a file once its size is
> 0and unchanged from the prior poll — proof thecopy has actually finished, not just that a file handle exists. Kept the existing
10s timeout / 500ms poll interval. This is a targeted readiness check, not a generic
retry — no
@Retry, no blanket rerun.Testing
./gradlew :grails-test-examples-geb:integrationTest --tests "org.demo.spock.PerTestRecordingSpec"— BUILD SUCCESSFUL, all 3 iterations passed including the previously-flaky assertion.
waitForRecordingFilesis a private helper embedded in theintegration spec with no public-API surface to test independently; the existing
integration spec is the only legitimate exercise path per the repo's public-API
testing convention.
aggregateStyleViolations, no violations).Related: #16030