Skip to content

ci: make the three untraceable CI flakes say what is happening when they next occur - #16220

Open
codeconsole wants to merge 4 commits into
apache:8.0.xfrom
codeconsole:ci/flake-diagnostics
Open

ci: make the three untraceable CI flakes say what is happening when they next occur#16220
codeconsole wants to merge 4 commits into
apache:8.0.xfrom
codeconsole:ci/flake-diagnostics

Conversation

@codeconsole

Copy link
Copy Markdown
Contributor

Instrumentation for the three CI flakes that are currently untraceable: #16217, #16218 and #16219. No production code, no behaviour change, and each piece removable when its issue closes.

They are together rather than in three pull requests because each run of this branch exercises all three, and these failures are rare enough that coverage per run is what decides whether the next one is diagnosable.

Why instrumentation rather than a fix

Each of the three has occurrences but no evidence beyond a client-side stack trace, and each has already had a fix proposed on a mechanism that turned out to be wrong. What follows is designed to make the next occurrence say which layer is responsible - not to guess at it now.

#16219 - a request that never answers

AsyncPromiseSpec fails with HttpTimeoutException after 120 seconds against /asyncTest/useAsyncService, an action that waits on a future for five seconds and answers. Four occurrences, three different features, two applications, one of them on 8.0.x; all say only that the client gave up.

An integration test runs the application in the test's own JVM, so the threads that would serve the request are available to be asked. A request that passes 90 seconds now prints every thread in the JVM while it is still outstanding; after the client times out, those threads have moved on. Every request also carries X-Grails-Test-Correlation-Id.

That distinguishes: never served, queued and starved, blocked in the controller, or answered but not read.

Checked: with the threshold at zero, 17 reports, 36 threads each including Tomcat's acceptor and every http-nio-auto-1-exec-*. With it at one second, none, because no request was that slow. 800 functional tests pass unchanged either way.

#16217 - a login form submitted without a session

The page captured at that failure is a fresh login form: empty fields, no error. Three explanations fit it and the page cannot tell them apart - no cookie was issued, one was issued for another host, or one was issued and then lost.

The browser is now reported at three moments - login page loaded, about to submit, and whatever follows - with cookie names, hashes, domain and path, plus the url. Hashed rather than printed: enough to see a session replaced or lost, without putting a session id in a public log. Spring Security logs at debug in this application for the server half.

Checked, on a passing run:

[16217] login page loaded: url=.../login cookies=[JSESSIONID#522aef1b@host.testcontainers.internal/]
[16217] about to submit:   url=.../login cookies=[JSESSIONID#522aef1b@host.testcontainers.internal/]
[16217] signed in:         url=.../book/index?continue cookies=[JSESSIONID#5289051f@...]

The same session through the submit, a new one after it - session fixation protection working. A failure will differ in exactly one of those places.

#16218 - a build that stops after its last task

Twice this job has stopped producing output after :grails-test-examples-aot:check and held a runner to the six hour ceiling, leaving only the process list the runner prints while cleaning up.

A step placed after the build cannot help: while the build is hung, a later step never arrives. The watchdog is therefore armed before it, sleeps past the point where the job should have finished, and reports every JVM twice a minute apart - command line, heap, threads with locks - plus the process tree and the tail of the Gradle daemon logs. One dump says what a thread is doing; two say whether it is doing anything at all.

The job is also capped at timeout-minutes: 120. It normally finishes in 40 to 65 minutes, so a hang costs a fifth of what it did, and the watchdog has reported long before. Its output is printed by a step marked always(), which is reached when the cap cancels the job.

Checked: the script runs locally, finds the JVMs through jps and dumps them; ./gradlew validateActions passes and the workflow parses with the steps in the intended order.

Removing it

Each commit is self-contained and revertible on its own. When an issue closes, its commit goes.

GRAILS-16219: AsyncPromiseSpec fails now and then with the client giving up after 120 seconds,
against an endpoint that cannot take that long - it waits on a future for five seconds and
answers. All four occurrences say only that the client gave up, which does not distinguish a
request that was never served from one that was served too slowly.

An integration test runs the application in the test's own JVM, so the threads that would answer
are here to be asked. A request that passes 90 seconds now prints every thread in the JVM,
Tomcat's acceptor and workers among them, while the request is still outstanding - after the
client times out they have moved on. Each request also carries a correlation id, so a line from
the client can be paired with one from the server.

Off with -Dgrails.http.client.diagnostics=false, and the threshold moves with
-Dgrails.http.client.diagnostics.seconds. The timer thread is a daemon, deliberately: a build has
already hung on a test JVM that could not exit.

Checked by lowering the threshold to zero: 17 reports, 36 threads each, Tomcat's included; and at
one second, none, because nothing was that slow. 800 functional tests unchanged either way.
GRAILS-16217: UserControllerSpec waits thirty seconds for a login page that never goes away, and
the page captured at the failure is a fresh login form - empty fields, no error. Three
explanations fit that page, and the captured page cannot tell them apart: no session cookie was
ever issued, one was issued for another host, or one was issued and then lost.

What tells them apart is which cookie the browser held, and when. It is now reported when the
login page loads, immediately before the form is submitted, and at whatever follows - hashed
rather than printed, which is enough to see a session replaced or lost without putting a session
id in a public log. Spring Security logs at debug in this application for the other half of the
question: whether the request arrived, under which session, and what was made of it.

On a passing run the three lines read as they should: the same session through the submit, and a
new one after it, which is session fixation protection doing its job.
GRAILS-16218: this job has twice stopped producing output after its last task and held a runner
until GitHub's six hour ceiling. There is no evidence about it beyond the process list the runner
prints as it cleans up, and a step placed after the build cannot help - while the build is hung,
a later step never arrives.

So the watchdog is armed before the build instead, and sleeps past the point where this job should
have finished. It then reports every JVM twice, a minute apart: command line, heap, and threads
with locks, plus the process tree and the tail of the Gradle daemon logs. One dump says what a
thread is doing; two say whether it is doing anything at all.

The job is also capped at two hours. It normally finishes in 40 to 65 minutes, so a hang now costs
a fifth of what it did, and the watchdog has long since reported by then. Its output is printed by
a step marked always(), which is reached when the cap cancels the job.
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 44.00000% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 54.1114%. Comparing base (27b6097) to head (c41e9bf).

Files with missing lines Patch % Lines
...rails/testing/http/client/SlowRequestReport.groovy 41.6667% 12 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16220        +/-   ##
==================================================
- Coverage     54.1129%   54.1114%   -0.0015%     
- Complexity      20301      20304         +3     
==================================================
  Files            2107       2108         +1     
  Lines          101146     101171        +25     
  Branches        17922      17923         +1     
==================================================
+ Hits            54733      54745        +12     
- Misses          38605      38616        +11     
- Partials         7808       7810         +2     
Files with missing lines Coverage Δ
...rails/testing/http/client/HttpClientSupport.groovy 83.3333% <100.0000%> (+3.3333%) ⬆️
...rails/testing/http/client/SlowRequestReport.groovy 41.6667% <41.6667%> (ø)

... and 1 file 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.

@testlens-app

testlens-app Bot commented Aug 25, 2026

Copy link
Copy Markdown

🚨 TestLens detected 1 failed test 🚨

Here is what you can do:

  1. Inspect the test failures carefully.
  2. If you are convinced that some of the tests are flaky, you can mute them below.
  3. Finally, trigger a rerun by checking the rerun checkbox.

Test Summary

SiteMesh 2 Compatibility / SiteMesh 2 Functional Tests (Java 21, indy=false) > :grails-test-examples-scaffolding:integrationTest

Test Runs Flakiness
UserControllerSpec > User list 1% 🟡

🏷️ Commit: c41e9bf
▶️ Tests: 28460 executed
⚪️ Checks: 27/27 completed

Test Failures

UserControllerSpec > User list (:grails-test-examples-scaffolding:integrationTest in SiteMesh 2 Compatibility / SiteMesh 2 Functional Tests (Java 21, indy=false))
geb.waiting.WaitTimeoutException: condition did not pass in 30 seconds (failed with exception)
	at geb.waiting.Wait.waitFor(Wait.groovy:128)
	at geb.waiting.DefaultWaitingSupport.doWaitFor(DefaultWaitingSupport.groovy:55)
	at geb.waiting.DefaultWaitingSupport.waitFor(DefaultWaitingSupport.groovy:41)
	at geb.Page.waitFor(Page.groovy:120)
	at com.example.pages.LoginPage.login(LoginPage.groovy:48)
	at com.example.UserControllerSpec.User list(UserControllerSpec.groovy:48)
Caused by: Assertion failed: 

title != pageTitle && $('input', name: 'username').empty
|     |  |         |
|     |  |         false
|     |  'Please sign in'
|     false
'Please sign in'

	at com.example.pages.LoginPage.login_closure1(LoginPage.groovy:48)
	at com.example.pages.LoginPage.login_closure1(LoginPage.groovy)
	at geb.waiting.Wait.waitFor(Wait.groovy:117)
	... 5 more

Rerun Controls

Select tests to mute in this pull request:

  • UserControllerSpec > User list

Reuse successful test results:

  • ♻️ Only rerun the tests that failed or were muted before

Click the checkbox to trigger a rerun:

  • Rerun jobs

Learn more about TestLens at testlens.app/docs.

@codeconsole

Copy link
Copy Markdown
Contributor Author

The failing run on this branch is #16217

SiteMesh 2 Functional Tests (Java 21, indy=false) failed on :grails-test-examples-scaffolding:integrationTest - UserControllerSpec > User list, WaitTimeoutException after 30 seconds at LoginPage.groovy:48. Three tests in that task, one failed; the build otherwise ran to completion under --continue.

That is not a regression from this branch. It is #16217 occurring on the branch that instruments it, and the instrumentation caught it. What follows is the first evidence on this issue beyond a client-side stack trace.

What the browser held

Three logins ran back to back in the same test JVM against the same application. The two that passed:

[16217] login page loaded: JSESSIONID#24f8c0a2@host.testcontainers.internal/
[16217] about to submit:   JSESSIONID#24f8c0a2@host.testcontainers.internal/
[16217] signed in:         JSESSIONID#a5eefb0a@host.testcontainers.internal/   url=.../book/index?continue

[16217] login page loaded: JSESSIONID#3802ec7f@host.testcontainers.internal/
[16217] about to submit:   JSESSIONID#3802ec7f@host.testcontainers.internal/
[16217] signed in:         JSESSIONID#60614cd@host.testcontainers.internal/    url=.../community/user/index?continue

The one that failed:

[16217] login page loaded:     JSESSIONID#94d58a76@host.testcontainers.internal/
[16217] about to submit:       JSESSIONID#94d58a76@host.testcontainers.internal/
[16217] login page never left: JSESSIONID#94d58a76@host.testcontainers.internal/  url=.../login

What that settles

The description offered three explanations: no cookie was issued, one was issued for another host, or one was issued and then lost. None of them holds. A cookie was issued, it carried the right domain and path, and it was still held - unchanged, same hash - for the full thirty seconds after the submit.

What is absent is the change both successful logins show. #24f8c0a2 -> #a5eefb0a and #3802ec7f -> #60614cd are session fixation protection rotating the session on successful authentication. The failing login's session never rotated, so Spring Security never authenticated anyone.

The url is consistent with that. This application has no SecurityFilterChain of its own, so Spring Boot's default form login applies and a rejected credential redirects to /login?error. The browser sat on /login with no query string at all.

So the fault is upstream of the cookie, and two possibilities remain:

  1. the POST never left the browser - loginButton.click() returned without navigating;
  2. the POST arrived and something answered with the login page again.

The report as it stood cannot separate those two.

Why it could not go further

Two pieces of evidence existed on the runner and neither reached us.

The server half never ran. The org.springframework.security DEBUG logger this branch added sits inside <springProfile name="development">. Integration tests run under the test profile - GrailsApp does environment.addActiveProfile(env.name), and the banner in this same log reads in environment: test - so the logger was never applied and <root level="ERROR"> silenced it. Across 49,134 lines of job log there is not one org.springframework.security line, against 4,284 DEBUG lines from other applications. The question that would have settled it, did the POST reach the filter chain, went unasked.

The page was captured and then discarded. UserControllerSpec is annotated @ContainerGebConfiguration(reporting = true), and GebOnFailureReporter calls testManager.reportFailure(), so Geb wrote the page source and a screenshot of the failing browser into build/geb-reports. gradle.yml uploads that directory on failure; sitemesh2.yml had no upload step at all, so on this workflow it dies with the runner.

Changes

logback-spring.xml - the security logger moves out of the development profile block, so it applies under test. UserControllerSpec run locally now produces the server half:

DEBUG o.s.security.web.FilterChainProxy         : Securing POST /login
DEBUG o.s.s.a.dao.DaoAuthenticationProvider     : Authenticated user
DEBUG .s.ChangeSessionIdAuthenticationStrategy  : Changed session id from 7E63940B...
DEBUG o.s.s.web.DefaultRedirectStrategy         : Redirecting to .../user/index?continue

Securing POST /login is the line that settles it. Present at the next occurrence, the request reached the filter chain and the answer is on the server; absent, the submit never left the browser.

sitemesh2.yml - uploads **/build/geb-reports/ on failure, matching what gradle.yml already does for the same suites. The page and screenshot from a failure on this workflow now survive the job.

LoginPage - each report carries form=: retained when the username field still holds what was typed, empty when the field is there but blank, gone when it is absent. On a passing login that reads empty -> retained -> gone. A retained at the failure means the document was never replaced and the POST did not leave the browser; an empty means a new login page was served, so it did. That separates the two remaining possibilities without waiting on the server logs.

One asymmetry worth naming: the client-side report hashes session ids deliberately, and Spring Security at DEBUG prints them in full. These are sessions of a throwaway test application with hardcoded credentials, running in a container for the length of one test, so there is nothing there to protect - but it is not the same standard on both sides, and it is another reason this logger comes out when #16217 closes.

Each change remains revertible on its own.

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

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant