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 3 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.8821% +51.8821%
- Complexity 0 18118 +18118
================================================
Files 0 2046 +2046
Lines 0 96274 +96274
Branches 0 16727 +16727
================================================
+ Hits 0 49949 +49949
- Misses 0 38953 +38953
- Partials 0 7372 +7372 🚀 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.
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>
…ng size floor in real measurements jdaugherty's review on PR #16031 flagged two gaps in the previous commit: WebDriverContainerHolder's stop()/restartVncRecordingContainer() changes had no test coverage of their own, and MIN_MEANINGFUL_RECORDING_BYTES was justified only by an unquantified "tens of KB" claim. - Add WebDriverContainerHolderSpec: unit-tests stop()'s try/finally state reset (both the happy path and when container.stop() throws), and restartVncRecordingContainer()'s guard clauses plus its swallow-and-log behavior when the current recording container fails to stop. All exercised via Mocks - no Docker required. - PerTestRecordingSpec: replace the vague size justification with numbers from two real local runs against the VNC recording container (75KB-855KB per genuine recording), and document why the byte-mismatch check is kept alongside the size floor rather than replaced by it - the two assertions guard against different failure modes (near-blank captures vs. two recordings accidentally being the same file). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
@jdaugherty thanks for the deep read on this one — replied inline on each of the three specific comments, and wanted to close the loop on the top-level review too. On the diagnosis: you were right that the original stability-polling premise doesn't hold. Verified the same thing you did against the Testcontainers 2.0.5 source ( On confirming which assertion actually failed: I wasn't able to pull the original CI/Develocity output — it's a <1% flake and #16030 has no captured failure info attached, so I don't have a build scan to chase. Rather than sit on this without that signal, 24a0b59 hedges across both of the candidate diagnoses you laid out instead of landing a fix confirmed against the real failure:
I'd still take a real repro over this if one turns up — happy to hold or follow up further if you'd rather chase the actual signal first. On 7.0.x: agreed the two copies of |
✅ All tests passed ✅Test SummaryCI / Functional Tests (Java 25, indy=false) > :grails-test-examples-gsp-sitemesh3:integrationTest
🏷️ Commit: 08800e4 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