Skip to content

Fix flaky PerTestRecordingSpec by waiting for recording files to stabilize - #16031

Open
borinquenkid wants to merge 3 commits into
8.0.xfrom
fix/flaky-per-test-recording-spec
Open

Fix flaky PerTestRecordingSpec by waiting for recording files to stabilize#16031
borinquenkid wants to merge 3 commits into
8.0.xfrom
fix/flaky-per-test-recording-spec

Conversation

@borinquenkid

Copy link
Copy Markdown
Member

Summary

PerTestRecordingSpec > the recordings of the previous two tests are different is
flaky (~1% of runs per #16030), with 0 hard failures — same commit,
different outcome on rerun.

Root cause

waitForRecordingFiles polled for candidate recording files by existence + name-match

  • count only. Testcontainers' VncRecordingContainer.saveRecordingToFile() copies the
    video with a plain Files.copy(...) and no atomic temp-file+rename, so the destination
    file 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

waitForRecordingFiles now tracks each candidate file's size across polls and only
accepts a file once its size is > 0 and unchanged from the prior poll — proof the
copy 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

  • Real Testcontainers run (Chrome + vnc-recorder, full recording lifecycle):
    ./gradlew :grails-test-examples-geb:integrationTest --tests "org.demo.spock.PerTestRecordingSpec"
    — BUILD SUCCESSFUL, all 3 iterations passed including the previously-flaky assertion.
  • No new unit test added: waitForRecordingFiles is a private helper embedded in the
    integration 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.
  • CodeNarc/Checkstyle: clean (aggregateStyleViolations, no violations).

Related: #16030

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.
Copilot AI review requested due to automatic review settings July 21, 2026 21:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 waitForRecordingFiles to only treat recording files as “ready” once their size is > 0 and 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.

@borinquenkid
borinquenkid requested a review from jdaugherty July 21, 2026 22:05
@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 51.8821%. Comparing base (6d1acad) to head (08800e4).
⚠️ Report is 139 commits behind head on 8.0.x.

Additional details and impacted files

Impacted file tree graph

@@               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     

see 2046 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jamesfredley jamesfredley moved this to Todo in Apache Grails Jul 24, 2026
@borinquenkid borinquenkid added this to the grails:8.0.0-RC1 milestone Jul 25, 2026

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.afterIteration calls containerHolder.container.afterTest(...) synchronously, on the test thread, in Spock's per-iteration callback.
  • BrowserWebDriverContainer.afterTestretainRecordingIfNeededvncRecordingContainer.saveRecordingToFile(recordingFile).
  • saveRecordingToFile is try (InputStream in = streamRecording()) { Files.copy(in, file.toPath(), REPLACE_EXISTING); }, and streamRecording() itself blocks on execInContainer("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:

  1. recordingFiles.size() >= minFileCount failed — only one recording landed. GebRecordingTestListener swallows NotFoundException for /newScreen.mp4 with a debug log, which is exactly what happens when the freshly restarted VNC container hadn't captured any frames yet for ffmpeg to re-encode. If that's the cause, the fix belongs in restartVncRecordingContainer (wait until the new container is actually capturing), and this PR makes the symptom worse: the wait now burns the full 10s before failing.
  2. Files.mismatch(...) != -1 failed — the two recordings really were byte-identical. Requiring size > 0 rules out the both-files-empty case, but not the case that seems likelier to me: two degenerate near-blank captures that ffmpeg -vcodec libx264 -movflags faststart encodes 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.

borinquenkid and others added 2 commits July 26, 2026 22:24
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>
@borinquenkid

Copy link
Copy Markdown
Member Author

@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 (saveRecordingToFile's Files.copy has returned before this feature method ever runs), and reverted that change entirely in 24a0b59.

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:

  1. Fixed a real bug in WebDriverContainerHolder#restartVncRecordingContainer — the vncRecordingContainer field was updated to the new container before start() succeeded, so a swallowed start() failure left it silently pointing at a container that never started. This doesn't cover the "connected but not yet capturing frames" timing gap you described — I don't have a way to force that deterministically without a real repro.
  2. Replaced the byte-difference-only check with a minimum-size floor per recording. Kept the byte-mismatch check on top rather than dropping it — it also catches two recordings accidentally resolving to the same file, which the size floor alone wouldn't. The floor isn't a guess anymore either: 08800e4 grounds MIN_MEANINGFUL_RECORDING_BYTES in two real local runs against the container, where every genuine recording measured 75KB-855KB (5,000 bytes stays more than an order of magnitude below the smallest of those).
  3. Added WebDriverContainerHolderSpec (08800e4) covering stop()'s try/finally reset and restartVncRecordingContainer()'s guard clauses / exception-swallowing — no Docker required, since neither had unit coverage before.

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 waitForRecordingFiles shouldn't keep diverging. Proposing we land this on 8.0.x first, and I'll follow up with a backport PR to 7.0.x that also picks up the per-run directory scoping from c179aac, since 7.0.x doesn't have that either. Let me know if you'd rather sequence it the other way.

@testlens-app

testlens-app Bot commented Aug 1, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

⚠️ TestLens detected flakiness ⚠️

Test Summary

CI / Functional Tests (Java 25, indy=false) > :grails-test-examples-gsp-sitemesh3:integrationTest

Test Runs Flakiness
EndToEndSpec > async multiple levels of layouts ❌ ✅ 1% 🟡

🏷️ Commit: 08800e4
▶️ Tests: 19306 executed
⚪️ Checks: 62/62 completed


Learn more about TestLens at testlens.app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

4 participants