diff --git a/gradle/publish-root-config.gradle b/gradle/publish-root-config.gradle
index 30aa6c4aa2d..2d49bca0ff5 100644
--- a/gradle/publish-root-config.gradle
+++ b/gradle/publish-root-config.gradle
@@ -98,6 +98,7 @@ def publishedProjects = [
'grails-testing-support-dbcleanup-h2',
'grails-testing-support-dbcleanup-postgresql',
'grails-testing-support-http-client',
+ 'grails-testing-support-latency',
'grails-testing-support-mongodb',
'grails-testing-support-views-gson',
'grails-testing-support-web',
diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc b/grails-doc/src/en/guide/introduction/whatsNew.adoc
index 482b962915d..d397f64817e 100644
--- a/grails-doc/src/en/guide/introduction/whatsNew.adoc
+++ b/grails-doc/src/en/guide/introduction/whatsNew.adoc
@@ -105,6 +105,15 @@ Integration tests that implement `HttpClientSupport` can now use `httpPostForm(.
request bodies. The helper URL-encodes form fields using UTF-8, and repeated values in a `Collection` or array are encoded as
repeated keys in insertion order.
+==== Latency Testing Support
+
+The new `grails-testing-support-latency` module injects artificial latency into the application under test, so that
+requests randomly take longer. Slower responses widen the window in which timing bugs occur, turning intermittently
+flaky functional tests — assertions that race a navigation, missing waits, ordering assumptions — into deterministic
+failures. The module is inert until `grails.testing.latency.enabled` is set to `true`, and the delay range, the
+fraction of requests affected, and the url patterns are all configurable. See the
+xref:testing/functionalTesting[Functional Testing] section for details.
+
==== GORM for MongoDB: TTL, Text, and Reconciled Indexes
GORM for MongoDB can now declare a https://www.mongodb.com/docs/manual/core/index-ttl/[TTL index] directly in the mapping DSL.
diff --git a/grails-doc/src/en/guide/testing/functionalTesting.adoc b/grails-doc/src/en/guide/testing/functionalTesting.adoc
index e4bd122a9e1..e33611915ac 100644
--- a/grails-doc/src/en/guide/testing/functionalTesting.adoc
+++ b/grails-doc/src/en/guide/testing/functionalTesting.adoc
@@ -88,3 +88,69 @@ class MySpec extends Specification {
}
----
+
+
+==== Simulating Latency
+
+Functional tests are prone to timing bugs: an assertion that races a page navigation, a missing
+wait, or an assumption about response ordering may pass reliably on a fast local machine and fail
+intermittently on slower CI infrastructure. The `grails-testing-support-latency` module makes such
+bugs reproducible by injecting artificial latency into the application under test, so that requests
+randomly take longer.
+
+Add the dependency to your integration test configuration:
+
+[source,groovy]
+----
+dependencies {
+ integrationTestRuntimeOnly 'org.apache.grails:grails-testing-support-latency'
+}
+----
+
+The module is inert until enabled. To activate it, set `grails.testing.latency.enabled` to `true`,
+for example in `application.yml` under a test environment, or as a system property on the test JVM:
+
+[source,groovy]
+----
+tasks.named('integrationTest', Test) {
+ systemProperty('grails.testing.latency.enabled', 'true')
+}
+----
+
+When enabled, a servlet filter is registered with the highest precedence that delays each matched
+request by a random amount of time before it is processed, mirroring what a slow network or an
+overloaded server looks like to the browser or HTTP client driving the test.
+
+The following configuration properties are supported under the `grails.testing.latency` prefix:
+
+[format="csv", options="header"]
+|===
+Property,Default Value,Description
+enabled,`false`,Whether to inject artificial latency
+min-delay,`0`,Lower bound (inclusive) of the random delay per request
+max-delay,`2s`,Upper bound (inclusive) of the random delay per request
+probability,`1.0`,Fraction of requests to delay between `0.0` (none) and `1.0` (all)
+url-patterns,`/*`,Servlet url patterns the filter applies to
+seed,none,Optional fixed random seed for reproducible delay sequences
+|===
+
+Durations without a unit suffix are interpreted as milliseconds; suffixes such as `500ms`, `2s` are
+also supported. For example, to delay half of all requests by between a quarter and two seconds:
+
+[source,yaml]
+----
+grails:
+ testing:
+ latency:
+ enabled: true
+ min-delay: 250ms
+ max-delay: 2s
+ probability: 0.5
+----
+
+A useful pattern is a dedicated CI job (or an occasional local run) that executes the functional
+test suite with latency enabled and a generous `max-delay`. Tests that only pass on a fast server —
+for example a Geb assertion that reads the page before a click-triggered navigation completes —
+will fail deterministically under latency instead of flaking rarely in CI.
+
+WARNING: This module is a testing aid. Never enable it in production configuration.
diff --git a/grails-test-examples/latency/build.gradle b/grails-test-examples/latency/build.gradle
new file mode 100644
index 00000000000..f6531a05763
--- /dev/null
+++ b/grails-test-examples/latency/build.gradle
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+plugins {
+ id 'org.apache.grails.buildsrc.properties'
+ id 'org.apache.grails.buildsrc.dependency-validator'
+ id 'org.apache.grails.buildsrc.compile'
+ id 'org.apache.grails.buildsrc.vulnerability-scan'
+}
+
+version = '0.1'
+group = 'latencyapp'
+
+apply plugin: 'org.apache.grails.gradle.grails-web'
+
+dependencies {
+ implementation platform(project(':grails-bom'))
+
+ implementation 'org.apache.grails:grails-dependencies-starter-web'
+ implementation 'com.h2database:h2'
+ implementation 'org.apache.grails:grails-data-hibernate5'
+ runtimeOnly 'com.zaxxer:HikariCP'
+
+ integrationTestImplementation 'org.apache.grails.testing:grails-testing-support-core'
+ integrationTestImplementation project(':grails-testing-support-http-client')
+ integrationTestRuntimeOnly project(':grails-testing-support-latency')
+}
+
+apply {
+ from rootProject.layout.projectDirectory.file('gradle/functional-test-config.gradle')
+ from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle')
+}
diff --git a/grails-test-examples/latency/grails-app/conf/application.yml b/grails-test-examples/latency/grails-app/conf/application.yml
new file mode 100644
index 00000000000..30b2dfed4a6
--- /dev/null
+++ b/grails-test-examples/latency/grails-app/conf/application.yml
@@ -0,0 +1,47 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+---
+grails:
+ profile: web
+ codegen:
+ defaultPackage: latencyapp
+ mime:
+ types:
+ json:
+ - application/json
+ html:
+ - text/html
+ xml:
+ - text/xml
+ - application/xml
+ all: '*/*'
+
+environments:
+ test:
+ dataSource:
+ dbCreate: create-drop
+ url: jdbc:h2:mem:testDb;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000
+ driverClassName: org.h2.Driver
+ username: sa
+ password: ''
+ grails:
+ testing:
+ latency:
+ enabled: true
+ min-delay: 1500ms
+ max-delay: 3s
+ url-patterns:
+ - '/slow/*'
diff --git a/grails-test-examples/latency/grails-app/conf/logback.xml b/grails-test-examples/latency/grails-app/conf/logback.xml
new file mode 100644
index 00000000000..d03662bf7de
--- /dev/null
+++ b/grails-test-examples/latency/grails-app/conf/logback.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
+
+
+
+
+
+
diff --git a/grails-test-examples/latency/grails-app/controllers/latencyapp/FastController.groovy b/grails-test-examples/latency/grails-app/controllers/latencyapp/FastController.groovy
new file mode 100644
index 00000000000..173db5c1dd8
--- /dev/null
+++ b/grails-test-examples/latency/grails-app/controllers/latencyapp/FastController.groovy
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package latencyapp
+
+import grails.compiler.GrailsCompileStatic
+
+/**
+ * Outside the configured {@code grails.testing.latency.url-patterns}, so responses
+ * from this controller are served without artificial delay.
+ */
+@GrailsCompileStatic
+class FastController {
+
+ def ping() {
+ render('pong')
+ }
+
+}
diff --git a/grails-test-examples/latency/grails-app/controllers/latencyapp/SlowController.groovy b/grails-test-examples/latency/grails-app/controllers/latencyapp/SlowController.groovy
new file mode 100644
index 00000000000..5781be26741
--- /dev/null
+++ b/grails-test-examples/latency/grails-app/controllers/latencyapp/SlowController.groovy
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package latencyapp
+
+import grails.compiler.GrailsCompileStatic
+
+/**
+ * Matched by the configured {@code grails.testing.latency.url-patterns}, so responses
+ * from this controller are artificially delayed.
+ */
+@GrailsCompileStatic
+class SlowController {
+
+ def ping() {
+ render('pong')
+ }
+
+}
diff --git a/grails-test-examples/latency/grails-app/controllers/latencyapp/UrlMappings.groovy b/grails-test-examples/latency/grails-app/controllers/latencyapp/UrlMappings.groovy
new file mode 100644
index 00000000000..fe7179e7d77
--- /dev/null
+++ b/grails-test-examples/latency/grails-app/controllers/latencyapp/UrlMappings.groovy
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package latencyapp
+
+class UrlMappings {
+
+ static mappings = {
+ "/$controller/$action?/$id?(.$format)?" {
+ constraints {
+ }
+ }
+ }
+
+}
diff --git a/grails-test-examples/latency/grails-app/init/latencyapp/Application.groovy b/grails-test-examples/latency/grails-app/init/latencyapp/Application.groovy
new file mode 100644
index 00000000000..0b553547a77
--- /dev/null
+++ b/grails-test-examples/latency/grails-app/init/latencyapp/Application.groovy
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package latencyapp
+
+import grails.boot.GrailsApp
+import grails.boot.config.GrailsAutoConfiguration
+
+class Application extends GrailsAutoConfiguration {
+
+ static void main(String[] args) {
+ GrailsApp.run(Application, args)
+ }
+
+}
diff --git a/grails-test-examples/latency/src/integration-test/groovy/latencyapp/LatencyFunctionalSpec.groovy b/grails-test-examples/latency/src/integration-test/groovy/latencyapp/LatencyFunctionalSpec.groovy
new file mode 100644
index 00000000000..57e57aee26f
--- /dev/null
+++ b/grails-test-examples/latency/src/integration-test/groovy/latencyapp/LatencyFunctionalSpec.groovy
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package latencyapp
+
+import java.time.Duration
+
+import spock.lang.Specification
+
+import grails.testing.mixin.integration.Integration
+import org.apache.grails.testing.http.client.HttpClientSupport
+
+/**
+ * Confirms the behavior of the {@code grails-testing-support-latency} module against a running
+ * application. The test environment enables latency for {@code /slow/*} with a minimum delay of
+ * 1500ms (see {@code application.yml}), so matched requests must take at least that long while
+ * unmatched requests stay well under it.
+ */
+@Integration
+class LatencyFunctionalSpec extends Specification implements HttpClientSupport {
+
+ private static final long MIN_DELAY_MILLIS = 1500
+
+ void 'requests matching the latency url patterns are delayed by at least the configured minimum'() {
+ when:
+ long start = System.nanoTime()
+ def response = http('/slow/ping')
+ long elapsedMillis = Duration.ofNanos(System.nanoTime() - start).toMillis()
+
+ then:
+ response.assertStatus(200)
+ response.assertContains('pong')
+ elapsedMillis >= MIN_DELAY_MILLIS
+ }
+
+ void 'requests outside the latency url patterns are not delayed'() {
+ when:
+ long start = System.nanoTime()
+ def response = http('/fast/ping')
+ long elapsedMillis = Duration.ofNanos(System.nanoTime() - start).toMillis()
+
+ then:
+ response.assertStatus(200)
+ response.assertContains('pong')
+ elapsedMillis < MIN_DELAY_MILLIS
+ }
+}
diff --git a/grails-testing-support-latency/build.gradle b/grails-testing-support-latency/build.gradle
new file mode 100644
index 00000000000..b76c4feaed0
--- /dev/null
+++ b/grails-testing-support-latency/build.gradle
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+plugins {
+ id 'groovy'
+ id 'java-library'
+ id 'org.apache.grails.buildsrc.properties'
+ id 'org.apache.grails.buildsrc.dependency-validator'
+ id 'org.apache.grails.buildsrc.compile'
+ id 'org.apache.grails.buildsrc.publish'
+ id 'org.apache.grails.buildsrc.sbom'
+ id 'org.apache.grails.buildsrc.vulnerability-scan'
+ id 'org.apache.grails.gradle.grails-code-style'
+ id 'org.apache.grails.gradle.grails-jacoco'
+}
+
+version = projectVersion
+group = 'org.apache.grails'
+
+ext {
+ pomTitle = 'Grails Latency Testing Support'
+ pomDescription =
+ 'Testing support that injects artificial latency into a running Grails application, ' +
+ 'so functional tests can be exercised against randomly slowed responses to surface timing bugs'
+}
+
+dependencies {
+
+ implementation platform(project(':grails-bom'))
+
+ api 'org.springframework.boot:spring-boot-autoconfigure'
+ api 'org.springframework.boot:spring-boot-servlet' // FilterRegistrationBean
+
+ implementation 'org.apache.groovy:groovy'
+ implementation 'org.springframework:spring-core' // Ordered
+ implementation 'org.springframework:spring-web' // OncePerRequestFilter
+ implementation 'org.slf4j:slf4j-api'
+
+ compileOnly 'jakarta.servlet:jakarta.servlet-api'
+
+ testImplementation 'jakarta.servlet:jakarta.servlet-api'
+ testImplementation 'org.assertj:assertj-core' // required by the spring-boot-test context runners
+ testImplementation 'org.spockframework:spock-core'
+ testImplementation 'org.springframework:spring-test'
+ testImplementation 'org.springframework.boot:spring-boot-test'
+
+ testRuntimeOnly 'org.slf4j:slf4j-nop' // Remove annoying warning
+}
+
+apply {
+ from rootProject.layout.projectDirectory.file('gradle/test-config.gradle')
+ from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle')
+}
diff --git a/grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyAutoConfiguration.groovy b/grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyAutoConfiguration.groovy
new file mode 100644
index 00000000000..3ea739200d1
--- /dev/null
+++ b/grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyAutoConfiguration.groovy
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.grails.testing.latency
+
+import groovy.transform.CompileStatic
+
+import org.springframework.boot.autoconfigure.AutoConfiguration
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication
+import org.springframework.boot.context.properties.EnableConfigurationProperties
+import org.springframework.boot.web.servlet.FilterRegistrationBean
+import org.springframework.context.annotation.Bean
+import org.springframework.core.Ordered
+
+/**
+ * Auto-configuration that registers the {@link LatencyFilter} when
+ * {@code grails.testing.latency.enabled} is {@code true}.
+ *
+ * The filter is registered with the highest precedence so that the configured delay applies
+ * to the entire request processing pipeline, mirroring what a slow network or server would
+ * look like to an HTTP client or browser driving the application under test.
+ *
+ * @see LatencyProperties
+ */
+@CompileStatic
+@AutoConfiguration
+@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
+@ConditionalOnProperty(prefix = 'grails.testing.latency', name = 'enabled', havingValue = 'true')
+@EnableConfigurationProperties(LatencyProperties)
+class LatencyAutoConfiguration {
+
+ @Bean
+ FilterRegistrationBean latencyFilter(LatencyProperties latencyProperties) {
+ FilterRegistrationBean registration = new FilterRegistrationBean<>()
+ registration.filter = new LatencyFilter(latencyProperties)
+ registration.urlPatterns = latencyProperties.urlPatterns
+ registration.order = Ordered.HIGHEST_PRECEDENCE
+ registration
+ }
+}
diff --git a/grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyFilter.groovy b/grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyFilter.groovy
new file mode 100644
index 00000000000..25a062cd6e3
--- /dev/null
+++ b/grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyFilter.groovy
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.grails.testing.latency
+
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
+import jakarta.servlet.FilterChain
+import jakarta.servlet.ServletException
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.servlet.http.HttpServletResponse
+
+import org.springframework.web.filter.OncePerRequestFilter
+
+/**
+ * Servlet filter that delays each matched request by a random amount of time before
+ * passing it down the filter chain.
+ *
+ * Slowing responses down widens the window in which timing bugs occur, turning
+ * intermittent functional test flakiness (assertions that race a navigation, missing
+ * waits, ordering assumptions) into deterministic failures. It is intended purely as a
+ * testing aid and should never be enabled in production.
+ *
+ * @see LatencyAutoConfiguration
+ * @see LatencyProperties
+ */
+@Slf4j
+@CompileStatic
+class LatencyFilter extends OncePerRequestFilter {
+
+ private final long minDelayMillis
+ private final long maxDelayMillis
+ private final double probability
+ private final Random random
+
+ LatencyFilter(LatencyProperties properties) {
+ minDelayMillis = properties.minDelay.toMillis()
+ maxDelayMillis = properties.maxDelay.toMillis()
+ probability = properties.probability
+ if (minDelayMillis < 0) {
+ throw new IllegalArgumentException("grails.testing.latency.min-delay must not be negative, was ${properties.minDelay}")
+ }
+ if (maxDelayMillis < minDelayMillis) {
+ throw new IllegalArgumentException("grails.testing.latency.max-delay (${properties.maxDelay}) " +
+ "must not be less than min-delay (${properties.minDelay})")
+ }
+ if (probability < 0.0d || probability > 1.0d) {
+ throw new IllegalArgumentException("grails.testing.latency.probability must be between 0.0 and 1.0, was $probability")
+ }
+ random = properties.seed == null ? new Random() : new Random(properties.seed)
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+ long delayMillis = nextDelayMillis()
+ if (delayMillis > 0) {
+ log.debug('Delaying {} {} by {}ms', request.method, request.requestURI, delayMillis)
+ try {
+ Thread.sleep(delayMillis)
+ }
+ catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt()
+ }
+ }
+ filterChain.doFilter(request, response)
+ }
+
+ private long nextDelayMillis() {
+ if (probability < 1.0d && random.nextDouble() >= probability) {
+ return 0
+ }
+ if (minDelayMillis == maxDelayMillis) {
+ return minDelayMillis
+ }
+ minDelayMillis + (long) (random.nextDouble() * (maxDelayMillis - minDelayMillis + 1))
+ }
+}
diff --git a/grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyProperties.groovy b/grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyProperties.groovy
new file mode 100644
index 00000000000..89fea292fd4
--- /dev/null
+++ b/grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyProperties.groovy
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.grails.testing.latency
+
+import java.time.Duration
+
+import groovy.transform.CompileStatic
+
+import org.springframework.boot.context.properties.ConfigurationProperties
+
+/**
+ * Configuration for artificial request latency, bound from the {@code grails.testing.latency} prefix.
+ *
+ * Example configuration ({@code application.yml} of the application under test, or system properties
+ * passed to the test JVM):
+ *
+ * grails:
+ * testing:
+ * latency:
+ * enabled: true
+ * min-delay: 250ms
+ * max-delay: 2s
+ * probability: 0.5
+ *
+ * Durations without a unit suffix are interpreted as milliseconds.
+ *
+ * @see LatencyAutoConfiguration
+ */
+@CompileStatic
+@ConfigurationProperties(prefix = 'grails.testing.latency')
+class LatencyProperties {
+
+ /**
+ * Whether to inject artificial latency. Disabled by default so the library is inert
+ * unless explicitly switched on for a test run.
+ */
+ boolean enabled = false
+
+ /**
+ * Lower bound (inclusive) of the random delay applied to a request.
+ */
+ Duration minDelay = Duration.ZERO
+
+ /**
+ * Upper bound (inclusive) of the random delay applied to a request.
+ */
+ Duration maxDelay = Duration.ofSeconds(2)
+
+ /**
+ * Fraction of requests to delay, between {@code 0.0} (none) and {@code 1.0} (all, the default).
+ */
+ double probability = 1.0d
+
+ /**
+ * Servlet url patterns the latency filter is mapped to. Defaults to every request.
+ */
+ List urlPatterns = ['/*']
+
+ /**
+ * Optional fixed seed for the random number generator, for reproducible delay sequences.
+ * When unset, delays vary from run to run.
+ */
+ Long seed
+}
diff --git a/grails-testing-support-latency/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/grails-testing-support-latency/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
new file mode 100644
index 00000000000..a8ebd5a9b9b
--- /dev/null
+++ b/grails-testing-support-latency/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -0,0 +1 @@
+org.apache.grails.testing.latency.LatencyAutoConfiguration
diff --git a/grails-testing-support-latency/src/test/groovy/org/apache/grails/testing/latency/LatencyAutoConfigurationSpec.groovy b/grails-testing-support-latency/src/test/groovy/org/apache/grails/testing/latency/LatencyAutoConfigurationSpec.groovy
new file mode 100644
index 00000000000..8b0efa942f8
--- /dev/null
+++ b/grails-testing-support-latency/src/test/groovy/org/apache/grails/testing/latency/LatencyAutoConfigurationSpec.groovy
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.grails.testing.latency
+
+import spock.lang.Specification
+
+import org.springframework.boot.autoconfigure.AutoConfigurations
+import org.springframework.boot.test.context.runner.WebApplicationContextRunner
+import org.springframework.boot.web.servlet.FilterRegistrationBean
+
+class LatencyAutoConfigurationSpec extends Specification {
+
+ private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner()
+ .withConfiguration(AutoConfigurations.of(LatencyAutoConfiguration))
+
+ void 'registers the latency filter when enabled'() {
+ expect:
+ contextRunner
+ .withPropertyValues(
+ 'grails.testing.latency.enabled=true',
+ 'grails.testing.latency.min-delay=50',
+ 'grails.testing.latency.max-delay=250',
+ 'grails.testing.latency.probability=0.75',
+ 'grails.testing.latency.url-patterns=/report/*'
+ )
+ .run { context ->
+ assert context.getBean('latencyFilter', FilterRegistrationBean).filter instanceof LatencyFilter
+ def properties = context.getBean(LatencyProperties)
+ assert properties.minDelay.toMillis() == 50
+ assert properties.maxDelay.toMillis() == 250
+ assert properties.probability == 0.75d
+ assert properties.urlPatterns == ['/report/*']
+ }
+ }
+
+ void 'does nothing when disabled or not configured'() {
+ expect:
+ contextRunner
+ .withPropertyValues(properties as String[])
+ .run { context ->
+ assert !context.containsBean('latencyFilter')
+ }
+
+ where:
+ properties << [[], ['grails.testing.latency.enabled=false']]
+ }
+}
diff --git a/grails-testing-support-latency/src/test/groovy/org/apache/grails/testing/latency/LatencyFilterSpec.groovy b/grails-testing-support-latency/src/test/groovy/org/apache/grails/testing/latency/LatencyFilterSpec.groovy
new file mode 100644
index 00000000000..a6660780f23
--- /dev/null
+++ b/grails-testing-support-latency/src/test/groovy/org/apache/grails/testing/latency/LatencyFilterSpec.groovy
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.grails.testing.latency
+
+import java.time.Duration
+
+import spock.lang.Specification
+
+import org.springframework.mock.web.MockFilterChain
+import org.springframework.mock.web.MockHttpServletRequest
+import org.springframework.mock.web.MockHttpServletResponse
+
+class LatencyFilterSpec extends Specification {
+
+ void 'delays the request by at least the configured minimum before continuing the chain'() {
+ given:
+ def filter = new LatencyFilter(new LatencyProperties(
+ minDelay: Duration.ofMillis(100),
+ maxDelay: Duration.ofMillis(100)
+ ))
+ def chain = new MockFilterChain()
+
+ when:
+ long start = System.nanoTime()
+ filter.doFilter(new MockHttpServletRequest('GET', '/report/show'), new MockHttpServletResponse(), chain)
+ long elapsedMillis = Duration.ofNanos(System.nanoTime() - start).toMillis()
+
+ then:
+ elapsedMillis >= 100
+ chain.request != null
+ }
+
+ void 'does not delay when the probability is zero'() {
+ given:
+ def filter = new LatencyFilter(new LatencyProperties(
+ minDelay: Duration.ofSeconds(30),
+ maxDelay: Duration.ofSeconds(30),
+ probability: 0.0d
+ ))
+ def chain = new MockFilterChain()
+
+ when:
+ long start = System.nanoTime()
+ filter.doFilter(new MockHttpServletRequest('GET', '/'), new MockHttpServletResponse(), chain)
+ long elapsedMillis = Duration.ofNanos(System.nanoTime() - start).toMillis()
+
+ then: 'the request completes long before the configured delay'
+ elapsedMillis < 30_000
+ chain.request != null
+ }
+
+ void 'random delays never undercut the configured minimum'() {
+ given:
+ def filter = new LatencyFilter(new LatencyProperties(
+ minDelay: Duration.ofMillis(20),
+ maxDelay: Duration.ofMillis(60),
+ seed: 42L
+ ))
+
+ expect:
+ timings(filter, 10).every { it >= Duration.ofMillis(20).toNanos() }
+ }
+
+ void 'rejects invalid configuration: #description'() {
+ when:
+ new LatencyFilter(properties)
+
+ then:
+ thrown(IllegalArgumentException)
+
+ where:
+ description | properties
+ 'max-delay below min-delay' | new LatencyProperties(minDelay: Duration.ofSeconds(2), maxDelay: Duration.ofSeconds(1))
+ 'negative min-delay' | new LatencyProperties(minDelay: Duration.ofMillis(-1))
+ 'probability above 1.0' | new LatencyProperties(probability: 1.5d)
+ 'negative probability' | new LatencyProperties(probability: -0.5d)
+ }
+
+ private static List timings(LatencyFilter filter, int requests) {
+ (1..requests).collect {
+ long start = System.nanoTime()
+ filter.doFilter(new MockHttpServletRequest('GET', '/'), new MockHttpServletResponse(), new MockFilterChain())
+ System.nanoTime() - start
+ }
+ }
+}
diff --git a/settings.gradle b/settings.gradle
index da33232c4d7..36495d66153 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -237,6 +237,7 @@ include(
'grails-testing-support-dbcleanup-h2',
'grails-testing-support-dbcleanup-postgresql',
'grails-testing-support-http-client',
+ 'grails-testing-support-latency',
'grails-testing-support-mongodb',
'grails-testing-support-redis',
'grails-testing-support-views-gson',
@@ -541,6 +542,7 @@ include(
'grails-test-examples-issue-698-domain-save-npe',
'grails-test-examples-issue-views-182',
'grails-test-examples-jetty',
+ 'grails-test-examples-latency',
'grails-test-examples-namespaces',
'grails-test-examples-plugins-exploded',
'grails-test-examples-plugins-issue11005',
@@ -594,6 +596,7 @@ project(':grails-test-examples-views-functional-tests-plugin').projectDir = file
project(':grails-test-examples-test-phases').projectDir = file('grails-test-examples/test-phases')
project(':grails-test-examples-spring-dependency-management').projectDir = file('grails-test-examples/spring-dependency-management')
project(':grails-test-examples-jetty').projectDir = file('grails-test-examples/jetty')
+project(':grails-test-examples-latency').projectDir = file('grails-test-examples/latency')
includeBuild('./grails-gradle') {
name = 'grails-gradle'