Repository navigation
Spring Boot 4.2.0-RC2 - #16871
Spring Boot 4.2.0-RC2#16871codeconsole wants to merge 49 commits into
Conversation
Spring Boot 4.2.0-RC2 manages Jackson 3.1.8 and 2.21.8. No advisory published since 3.2.3 and 2.22.3 affects either pin, so Jackson 2 stays at 2.22.3, which Groovy YAML 6.0.0 needs, and Jackson 3 stays at 3.2.3, so that moving to Spring Boot 4.2 does not take it from the 3.2 line back to 3.1.
Spring Boot 4.2.0-RC2 still manages Spring Security 7.2.0-RC1, which the advisory does not list and which carries the 7.1.1 fix.
…nages 2.6.1 Spring Boot 4.2.0-RC2 manages json-smart 2.6.1, which rejects a literal 0x1A after the value in strict mode (netplex/json-smart-v2#305). That fixes CVE-2026-113602, so the exclusion's removal condition, a patched release, is met.
Spring Boot 4.2.0-RC2 manages json-smart 2.6.1, whose accessors-smart 2.6.1 depends on ASM 9.11.1. That wins over the 9.10.1 the BOM managed, and validateDependencyVersions failed for every project with json-smart on its classpath, such as the gsp-spring-boot and scaffolding test examples.
Spring Boot 4.2.0-RC2 manages Hibernate ORM 7.4.14.Final. The Hibernate 7 BOM strictly re-declares Spring Boot's version so that enforcedPlatform consumers are forced to it, and was still at RC1's 7.4.13.Final. 7.4.14 depends on the same hibernate-models 1.1.2, so that pin does not move.
The Hibernate 7 BOM pinned Hibernate ORM in dependencies.gradle, so every Spring Boot upgrade that moved Hibernate needed a matching manual bump, and a forgotten one held the BOM back. grails-hibernate7-bom now reads the version spring-boot-dependencies manages for hibernate-core and strictly re-declares hibernate-core, hibernate-envers and hibernate-jcache at it, under Spring Boot's hibernate.version, so enforcedPlatform consumers are still forced to it. The published POM is unchanged apart from the order of those entries. When Spring Boot lags behind a Hibernate ORM 7 release Grails needs, hibernate7VersionOverride in dependencies.gradle manages that release instead. The build fails while the override is not newer than Spring Boot's version, so it cannot silently downgrade Hibernate once Spring Boot catches up. hibernate-models is strictly re-declared too, and now moves without anyone editing the Hibernate block, so the build also fails when hibernate-models.version differs from the version the managed Hibernate ORM release depends on.
Spring Security 7.2's configuration DSL no longer saves requests for /favicon.*, /apple-touch-icon*.png, /manifest.json, /manifest.webmanifest, /browserconfig.xml and Chrome DevTools' /.well-known/appspecific/com.chrome.devtools.json. The plugin builds its beans without the DSL and saved every request, so a denied request for one of these paths became the page a login redirected to. The default requestMatcher now leaves out the paths the new requestCache.ignoredPatterns setting names, by default the same list, which is private to the DSL. A class set with requestMatcherBeanClass is left as it was.
Spring Framework 7.1 adds HttpMethod.QUERY, for RFC 10008, and its FormContentFilter now parses form-encoded QUERY bodies. A mapping with method: 'QUERY' already routed, and a body bound as a POST's does, so URL mappings gain the query prefix they have for the other methods. GrailsMockHttpServletRequest stands in for FormContentFilter and did not parse a QUERY body, so a unit test saw different params from the running application. The app1, enable-mvc-check and Spring Security functional apps send QUERY requests with JSON and form-encoded bodies, and the guide says what QUERY does not do: an HTML form cannot send it, OpenAPI has no operation for it, and Spring Security's own DSL needs it allowed on its firewall.
Spring Framework 7.1 can render any unhandled exception as a problem with ProblemDetailsConfigurer.renderUnhandledExceptions, but in a Grails application that switch takes every exception of every action ahead of the Grails exception resolver, so a browser gets JSON and "500" URL mappings no longer apply. grails.exceptionresolver.problemDetails, off by default, uses the same translation for an exception an action throws and no @ExceptionHandler method handles, written by Spring MVC's handler adapter with its advice and interceptors. A client that prefers HTML, and an exception a URL mapping names, still go to the Grails exception resolver. As Spring MVC does, a server error is recorded on the HTTP server observation, and a failure to write the problem leaves the exception to the other resolvers. Spring's translation logs an unexpected exception without its request, so the resolver logs the method and URI too.
… 4.2 Spring Boot 4.2 builds its ForwardedHeaderFilter with the X-Forwarded-Prefix header off, where Spring Boot 4.1 used the constructor that turns it on. An application behind a proxy that strips a path prefix loses the prefix from its absolute links and redirects until it sets spring.mvc.forwarded-headers.use-forwarded-prefix. bootBuildImage now defaults to the ubuntu-resolute builder, on Ubuntu 26.04, whose run image the builder no longer pins, and can keep the build cache in an image in a registry.
With grails.exceptionresolver.problemDetails alone, an @ExceptionHandler method was never consulted, so an action's exception became a generic 500 where ProblemDetailsConfigurer.renderUnhandledExceptions, which the guide offers it in place of, would have run the method. A problem is the last resort after those methods, as it is in Spring MVC, so the setting now turns on grails.exceptionresolver.springHandlers unless the application sets that.
- A "503" mapping that names AsyncRequestTimeoutException, which the guide documents, was looked for under 500 and passed over. It is looked for under the status the Grails exception resolver uses. - An @ExceptionHandler method that declares what it produces and fails left that type on the request, so the problem could not be written. Spring MVC removes it for its own last resort, and this does too. - A client that went away is logged in a category of its own and no response is attempted, as in Spring MVC, where the write failure sent a WARN and an error page to a dead connection, and the exception was logged as a failed request. - renderView stayed false when the write failed, so an action answering the error page had what it returned dropped. It is set once the problem is written. - An action a page includes is left to the other resolvers, so that a problem is not written into the page. - The request of a forwarded action is logged by the URI the client asked for, as the Grails exception resolver logs it.
ActionReturnValues looked up the method in its static initializer, which every web application runs, whether it opts into problem details or not. Without a hint for it a native image would fail to initialize the class, and with it the handler adapter. It is now looked up where a problem is written, whose failure leaves the exception to the other resolvers, and UrlMappingRuntimeHints registers it.
… problems The two browser rows of the functional spec behaved as their neighbours, because grails.mime.disable.accept.header.userAgents, which makes Grails resolve any format for a browser, was not set in the test application, so the first concrete Accept type was never what decided. The application now sets it. The guide said an exception caused by an ErrorResponse keeps its status, which only holds for a ResponseStatusException or @ResponseStatus, and that any other exception is a 500 without a detail, where Spring's own exceptions keep theirs. It also did not say that the settings for what the Grails exception resolver logs do not apply, or that an action a page includes is left to that resolver.
The wiring in the plugin had no test beyond the default list: a list an application sets, an empty list, and a class set with requestMatcherBeanClass, which must not be given a property it may not have, all go through the real plugin now. The spec that only pinned how Spring's own matcher treats a parsed request path goes.
… guide says A QUERY reaches a secured action through the Spring Security plugin's authorization as a PUT and a PATCH do, and grails-openapi skips a QUERY mapping, which were claimed but not tested. The guide said a body binds as a POST's does, which does not hold for a domain class; that g:form sends a POST, which depends on how the hidden method is resolved; and that OpenAPI has no operation for QUERY, where this generator writes versions that have none. FormContentFilter's list of methods is brought up to date where it is repeated.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## 9.1.x #16871 +/- ##
==================================================
+ Coverage 65.2517% 65.3069% +0.0552%
- Complexity 32202 32339 +137
==================================================
Files 2430 2430
Lines 120285 120459 +174
Branches 22261 22300 +39
==================================================
+ Hits 78488 78668 +180
+ Misses 31801 31795 -6
Partials 9996 9996
🚀 New features to boost your workflow:
|
traceNativeMetadata merged what it recorded into src/native/resources/META-INF/native-image, as the guide says, but nothing put that directory on the classpath the image is built from, so the recording changed nothing. An image built after a trace started and failed on the first request that needed what was recorded, with a MissingReflectionRegistrationError for a call the file listed. The plugin now adds the classpath entry above META-INF, beside the metadata it generates, and fails the build with the reason if outputDirectory is somewhere an image cannot read. native-i18n calls a method only that directory registers, named by a property because GraalVM resolves a lookup of a constant name while it builds the image, so CI's image fails if the metadata stops reaching it: without the plugin change it fails with ClassNotFoundException.
DefaultMimeTypeResolver is statically compiled, but Groovy compiles a default argument dynamically even so: the methods without a request called GrailsWebRequest.lookup() through invokedynamic. Binding a command object and resolving a view both go through them, so in a native image they needed a reflection hint or a trace, and an error page rendered through a view can never be traced, since the trace fails on an error. Written out as overloads, they call it directly.
Binding a command object compares the request's HttpMethod with POST. Groovy's == calls compareTo on a Comparable unless the two are the same instance, so it reaches compareTo reflectively for every method but POST, and a trace that only submits forms never records it. A GET or QUERY then failed in a native image.
paths are asked for with GET and forms are posted as forms, so a trace could not reach
an action restricted to QUERY, or what binds a JSON body or writes a JSON response.
requests makes a request as it is declared, after the forms and in their session:
requests = [[method: 'QUERY', path: '/book/search', contentType: 'application/json',
body: '{"title": "Grails"}', accept: 'application/json']]
One that does not name its method and path, or that names anything else, fails the
trace rather than recording something other than what was meant.
A scaffolded create page renders its fields with value="", which the trace took for a value and sent. Blank fields failed validation, the page answered with the form again, and the trace reported success having recorded nothing of what a save does when it succeeds. In the image, the first real save then stored the row and answered with a 500. A field rendered empty is now filled in as one with no value is. A hidden field is the page's own and is still sent as it is.
BearerTokenReader parses the Content-Type of any request without a bearer header, and nothing caught the exception. Spring Framework 7.1 rejects a parameter without a value or a repeated one, so a request sending such a Content-Type, which used to be read, failed with a 500. Only the type matters here, so only the type is read, and a type that cannot be read is not a form.
…s RabbitMQ Spring Boot 4.2 makes it the starter for generic AMQP 1.0 and gives RabbitMQ starters of its own. Grails uses neither, but an application using RabbitMQ through it has to change them.
…r observation The resolver handled an exception out of sight of the observation of the request, so an action that threw answered with a 500 while http.server.requests tagged it with error=none and exception=none, and http.server.request.duration with error.type=none. Spring Framework 7.1 records the exceptions Spring MVC translates to a 5xx, but this one never reaches it. It now records them as Spring MVC does. The spec runs the resolver inside Spring's own ServerHttpObservationFilter, and fails without this. In a running application a 500 is tagged with the exception under both Micrometer's and OpenTelemetry's conventions, and a 404 still with none.
http.server.requests.active is no longer registered unless asked for, which a dashboard reading it needs to know, and metrics and spans can now use OpenTelemetry's semantic conventions. Under them http.route is /controller/action in a Grails application, which is worth saying since it is not the URL mapping's pattern.
…an error handler A "500" mapping to a controller forwards to it, which copies the resolver's model to the request and routes the request again. That left the GrailsWrappedRuntimeException in jakarta.servlet.error.exception, which ServerHttpObservationFilter reads last, so http.server.requests reported the wrapper over the exception the resolver had recorded, and the handler's route rather than the action's, so every failure a handler answered looked like the same route. The resolver now removes the attribute after the forward, as Spring MVC does once it has rendered an error view, and restores the route. Neither this nor the error=none the previous fix addressed comes from Spring Boot 4.2: 8.0.x on Boot 4.1.2 and 9.0.x report the same values. An application that has no error view to render, such as the cache example, hides the gap, because the failed render leaves the exception on the request for the filter. The new functional spec runs through a real dispatch for that reason: the unit spec cannot see what Spring MVC clears after rendering.
In development mode an action is invoked by reflection, so it failed with an InvocationTargetException, while the method handle used everywhere else throws the exception itself. The resolver does not unwrap that one, so what reached it, the action's span and the observation of the request depended on the environment.
The same fix goes to 8.0.x, on Spring Framework 7.0, so the comment and the spec no longer compare it with what Spring Framework 7.1 does. The upgrade guide still does. With the same text on both lines, the merge up does not conflict over it.
jamesfredley
left a comment
There was a problem hiding this comment.
The Spring Boot 4.2.0-RC2 bump keeps the exception handling, observation recording, and native metadata wiring on the same path. The new ProblemDetailExceptionHandler call matches the managed Spring Framework API.
The previous commit removed jakarta.servlet.error.exception once the forward had returned, so a filter that reads it after chain.doFilter got null, with or without an observation, where it used to get a GrailsWrappedRuntimeException. It now holds the exception the action threw. Holding the wrapper is not an option, because ServerHttpObservationFilter reads that attribute last and would report the wrapper again.
Setting the error on the context reaches the metrics, which read it when the observation stops, but only Observation.error calls the handlers' onError, and that is how the server tracing handler marks a span. With a real OpenTelemetry tracer the span of a failed action ended with status UNSET and no exception event. The resolver now finds the observation through the registry's scopes and records the exception on it, once the error page has been resolved. An exception a forward left on the request is not recorded there, because the filter records what it finds in that attribute when it stops the observation, and the handlers would hear of it twice.
ActionExceptionHandlerResolver set the error on the context, as the Grails exception resolver did, which reaches the metrics but not the handlers' onError, so a tracing handler did not mark the span of a server error written as a problem. It now records the exception on the observation in the same way.
|
@jamesfredley You approved this at 4c19027, and I've since pushed more commits, so please take another look at them. Your #16879 reviews apply here too, because the two PRs share that code, and
With a real OpenTelemetry tracer on RC2, a failed action's span now ends |
…first client Netty tracks about one buffer in 128 for leaks, picked at random, and a tracked buffer loads classes an untracked one does not. The one request the server answers as it starts rarely gets that far, so each buffer allocated while Spring Boot has the jars of an executable jar closed still had about one chance in 128 of failing. EmbeddedMongoLifecycleSpec failed that way in CI, in ResourceLeakDetector.track0. The server now allocates buffers as it starts until one has been tracked, whatever the configured level. The spec allocates as many while the jars are closed, so it fails on every run without this instead of about once in 128.
jamesfredley
left a comment
There was a problem hiding this comment.
Not approved. A handled failure is recorded on the observation context, but the observation handlers are not told. A tracing handler that listens for onError does not mark the span failed.
…uest The resolver looked for the observation through the application's registry, so a filter built with a registry of its own was missed, and the error only reached the context, which does not call the handlers' onError. A tracing handler then left the span unmarked. ServerHttpObservationFilter keeps its observation in a request attribute, whatever registry built it, so the resolver now uses that one. The attribute is private to the filter, which offers only the context. The specs run Spring's real filter and fail if the attribute is renamed.
ActionExceptionHandlerResolver had the same gap as the Grails exception resolver: a filter built with a registry the application does not know was missed, so the error only reached the context. It now uses the observation the filter keeps on the request.
The resolver left an exception a forward kept on the request to the filter, which records what it finds in that attribute when it stops the observation. On an asynchronous dispatch the filter leaves stopping it to a listener that does not look, so an asynchronous action answered by an error controller left the span unmarked. The resolver now leaves it to the filter only where the filter will read it. The functional spec fails actions through Spring's own async handling, for both an error view and an error controller.
The previous commit went by the type of the dispatch, which is FORWARD for an action forwarded to from an asynchronous dispatch, so its exception was still left to a filter that would not read it. The attribute the container sets on an asynchronous dispatch stays through the forward, and WebUtils.isAsync already reads it for the handler adapter.
jamesfredley
left a comment
There was a problem hiding this comment.
Not approved. Unwrapping a problem-details failure follows the cause chain and can turn a 410 into a 500. A handled failure now calls Observation.error, including on an asynchronous dispatch. Threads PRRT_kwDOAAfRJ860nXmn and PRRT_kwDOAAfRJ860nXmv resolved.
| /** As the Grails exception resolver finds the exception an action threw. */ | ||
| private static Exception unwrap(Exception ex) { | ||
| if (ex instanceof InvokerInvocationException || ex instanceof GrailsMVCException) { | ||
| Throwable rootCause = ExceptionUtils.getRootCause(ex) |
There was a problem hiding this comment.
An InvokerInvocationException wraps a ResponseStatusException of 410, and that 410 has an IllegalStateException cause. This walk reaches the cause and the response is 500. Stop at the application exception. Keep its status.
There was a problem hiding this comment.
Fixed in 24ecc02. unwrap now takes off only InvokerInvocationException and GrailsMVCException, and stops at the exception the action threw, so that 410 stays a 410 and is written with its own status. A new feature in ActionExceptionProblemDetailsSpec covers a ResponseStatusException with a cause of its own, wrapped by each of them and by both, and fails on the previous code.
Finding the exception an action threw followed the cause chain to its end, so a ResponseStatusException of 410 with a cause of its own, wrapped by a dynamic invocation, was written from that cause, as a 500. It now takes off only what Groovy and Grails wrap the exception in, and stops at the exception the action threw.
The resolver put the failed route back on the observation once its forward to an error handler returned. When the action had started async processing before it threw, the error handler was routed again after that, and its route replaced the one that failed. The handler that sets the route now leaves it alone for a request routed to an error handler, so the restore is gone and the route is never replaced, whenever the error handler is routed.
The handler that sets the route ran after every other interceptor of the chain, so a request that failed in one, such as in a Grails interceptor's before(), never got a route of its own, and its observation was reported as UNKNOWN. It now runs right after the WebRequestInterceptors, ahead of the interceptors that can fail.
✅ All tests passed ✅Test SummaryTip Inspect all details with the TestLens CLI: SiteMesh 2 Compatibility / SiteMesh 2 Functional Tests (Java 21, indy=false) > :grails-test-examples-geb:integrationTest
🏷️ Commit: 28dc2dd Test FailuresPerTestRecordingSpec > the recordings of the previous two tests are different (:grails-test-examples-geb:integrationTest in SiteMesh 2 Compatibility / SiteMesh 2 Functional Tests (Java 21, indy=false) | Attempt 1/2)Learn more about TestLens at testlens.app/docs. |
4.2.0 is next, due November 19, 2026
Moves 9.1.x from Spring Boot 4.2.0-RC1, which #16738 brought in, to Spring Boot 4.2.0-RC2.
Managed versions that change
Spring Security 7.2.0-RC1, Spring Data 2026.1.0-RC1, Reactor 2026.0.0-RC1, Tomcat 11.0.27, the MongoDB driver 5.13.0, graphql-java 26.1 and Jedis 8.0.2 stay where they are, so the graphql-java tripwire and the LEGAL-666 SBOM mapping do not move.
grails-bom
ASM goes from 9.10.1 to 9.11.1. json-smart 2.6.1 depends on ASM 9.11.1 through accessors-smart 2.6.1. That version won over the 9.10.1 the BOM managed, so
validateDependencyVersionsfailed in every project that has json-smart on its classpath, whichspring-boot-starter-testputs there through json-path.The Jackson pins stay at 2.22.3 and 3.2.3 in this PR, and 8.0 (fix): Jackson 2.22.4 and 3.2.4 for CVE-2026-110879 #16872 moves them to 2.22.4 and 3.2.4. Their comments now name the 2.21.8 and 3.1.8 that Spring Boot 4.2.0-RC2 manages. Both pinned versions are affected by CVE-2026-110879 in jackson-databind, and 3.2.3 also by CVE-2026-110729 in jackson-core. Spring Boot's 2.21.8 and 3.1.8 are not affected, but the pins override them. The 2.22.4 bump made on 7.x for that CVE was lost when 7.2.x was merged into 8.0.x, so 8.0 (fix): Jackson 2.22.4 and 3.2.4 for CVE-2026-110879 #16872 makes it on 8.0.x and it reaches 9.1.x with the merge-up. (The commit that updates these comments says no advisory affects the pins; that was wrong.) Jackson 2 stays on the 2.22 line because Groovy YAML 6.0.0 depends on 2.22.2, and Jackson 3 stays on the 3.2 line so that moving to Spring Boot 4.2 does not take it back to 3.1.
Hibernate ORM 7 is no longer pinned. It was pinned in
dependencies.gradle, so every Spring Boot upgrade that moved Hibernate needed a matching manual bump. Nowgrails-hibernate7-bomreads the versionspring-boot-dependenciesmanages forhibernate-core, 7.4.14.Final with RC2. It strictly re-declareshibernate-core,hibernate-enversandhibernate-jcacheat that version, under Spring Boot'shibernate.version, soenforcedPlatformconsumers are still forced to it. The published POM is unchanged apart from the order of those entries.When Spring Boot lags behind a Hibernate ORM 7 release Grails needs,
dependencies.gradlecan name that release instead:The build fails while the override is not newer than Spring Boot's version, so it cannot silently downgrade Hibernate once Spring Boot catches up:
hibernate-modelsis strictly re-declared too, and Spring Boot does not manage it. Because it now moves without anyone editing the Hibernate block, the build also fails whenhibernate-models.versiondiffers from the version the managed Hibernate ORM release depends on:Applications still override the version with
ext['hibernate.version'], as before.Embedded MongoDB from an executable jar
With Spring Boot 4.2.0-RC2, an application using the in-memory backend and run with
java -jarcould lose its database as it started and then hang when stopped. Stopping the application without this fix:ClearCachesApplicationListener, Netty 4.2.20Since spring-projects/spring-boot#52582, that listener finds
clearCache()on Spring Boot's class loader, so it closes the jars of an executable jar when the context has refreshed, and a class being loaded at that moment fails withIllegalStateException: Zip file closed. The failure also needs spring-projects/spring-boot#52246, which has the class loader look through those same jars directly, and which only 4.2 has: with the loader of 4.1.3-SNAPSHOT, the same application stopped cleanly 10 times out of 10. It is reported as spring-projects/spring-boot#52634, with a fix proposed in spring-projects/spring-boot#52635. Netty only changes the odds, so the message of 285fa77, which leads with Netty 4.2.20, overstates its part.The MongoDB driver connects just before that moment, and reading its first bytes allocates the event loop's first buffer. Netty logs the failure, Logback fails the same way to load a class to log it, and that ends the event loop's thread. The driver gets no answer, and stopping the server waits forever for its connection on that thread to close. It also stopped
traceNativeMetadataandtrainAotCachefrom finishing.The server now answers one
isMasteras it starts, and allocates buffers until Netty's leak detection has tracked one, which it does to about one buffer in 128 at random and which loads classes of its own. So the event loop has loaded what the first client needs well before that moment. The race itself remains for anything else loading a class right then: with HTTP requests hitting a plain application on Tomcat during startup, 18 of 30 starts logged it, against 0 of 30 on Spring Boot 4.1.2, and requests answered normally afterwards. If Spring Boot's fix lands in 4.2.0, this can be removed.Vulnerability scan
net.minidev:json-smartgoes. json-smart 2.6.1 rejects a literal 0x1A after the value in strict mode (netplex/json-smart-v2#305), which fixes CVE-2026-113602. The exclusion was to be removed once a patched release shipped, and 2.6.1 is that release.spring-security-cryptonow names Spring Boot 4.2.0-RC2, which still manages Spring Security 7.2.0-RC1.Features Spring Boot 4.2 makes possible
A login no longer redirects to a request the browser sent on its own
Spring Security 7.2's configuration DSL does not save requests for
/favicon.*,/apple-touch-icon*.png,/manifest.json,/manifest.webmanifest,/browserconfig.xmlor Chrome DevTools'/.well-known/appspecific/com.chrome.devtools.json. The Spring Security plugin builds its beans without the DSL and saved every denied request, so a login could land on one of these. Against the functional test app, a login after a denied request for/.well-known/appspecific/com.chrome.devtools.jsonredirected to/.well-known/appspecific/com.chrome.devtools.json?continue.The plugin now leaves the same paths out, with a setting of its own, since the DSL's list is private to it:
An empty list saves every request, as before. This is on by default, so the upgrade guide lists it. Unlike the DSL's matcher, it does not leave out XHR, JSON, multipart, server-sent event or websocket requests, because the plugin has its own Ajax handling.
The HTTP QUERY method
Spring Framework 7.1 adds
HttpMethod.QUERYfor RFC 10008, a safe, idempotent request with a body, andFormContentFilterparses its form-encoded bodies. A mapping withmethod: 'QUERY'already routed, so URL mappings gain the prefix the other methods have:A JSON or form-encoded body is bound to the command object.
GrailsMockHttpServletRequeststands in forFormContentFilterand now parses a form-encodedQUERYbody too, so a unit test sees what the running application sees.GET, a domain class the action takes is looked up by its identifier but not bound to the body.QUERYfrom an HTML form.traceNativeMetadatareaches it withrequests; see Native images below.grails-openapileaves these mappings out and logs each one, because swagger-models and the OpenAPI versions it writes have no operation for the method.QUERY, with itsDefaultHttpFirewall. An application using Spring Security's own DSL gets aStrictHttpFirewall, which rejects it until it is added withsetAllowedHttpMethods.RFC 9457 problems for exceptions nothing handles
Spring Framework 7.1 can render any unhandled exception as a problem with
ProblemDetailsConfigurer.renderUnhandledExceptions(true). In a Grails application that switch takes every exception of every action ahead of the Grails exception resolver, so a browser gets JSON and"500"URL mappings stop applying. Turned on throughExceptionHandlerExceptionResolverwith a Grails action as the handler and a browser'sAcceptheader, it writes{"status":500,"title":"Internal Server Error"}asapplication/problem+json.A new setting uses the same translation and keeps the error page for browsers:
ErrorResponse,ResponseStatusExceptionand@ResponseStatusexceptions keep their status and detail, Spring's own exceptions get the status and detailResponseEntityExceptionHandlergives them, and any other exception is a500without a detail, so its message is not exposed.@ExceptionHandlermethods, so it turns ongrails.exceptionresolver.springHandlersunless the application sets that.ResponseBodyAdviceandErrorResponse.Interceptorbeans apply, and a server error is recorded on the HTTP server observation."500"(controller: 'errors', action: 'denied', exception: AccessDeniedException)or the"503"mapping of anAsyncRequestTimeoutException, and an action a page includes.Which clients prefer HTML: a request whose format, from its URL or its
Acceptheader, ishtml; or, where Grails resolves any format, as for a browser undergrails.mime.disable.accept.header.userAgents, one whose first concreteAccepttype is HTML. A client that accepts any type, such ascurl, gets the problem.Native images
I built these into a GraalVM 25.2.4 image from
end-to-end/native-i18nand ran requests against it, with the happy path traced as the guide describes:respondwith aProblemDetail, an action returning one, unhandled exceptions written as problems, the HTML error page, andQUERYand other methods binding a command object from JSON and form bodies, including an action restricted toQUERY. All answer as on a JVM. That took three fixes:traceNativeMetadatahad no effect on the image. It writes tosrc/native/resources/META-INF/native-image, and nothing put that directory on the image's classpath, so an application that followed the guide still failed on the first request needing what it had recorded. The plugin now adds it.native-i18nchecks this in CI: it calls a method only that directory registers.DefaultMimeTypeResolvermade a dynamic call in a statically compiled class. Groovy compiles a default argument dynamically, soGrailsWebRequest.lookup()needed reflection on the way to binding a command object or resolving a view. It is now a direct call.HttpMethodis registered, because Groovy's==callscompareToon it for every method butPOST, which a trace that only submits forms never records.traceNativeMetadataused to send onlyGETand formPOST, so it could not reach an action restricted toQUERYor a JSON body.requestsnow makes a request as it is declared, after the forms and in their session, and a request with a key it does not know fails the trace:grails { nativeMetadata { requests = [ [method: 'QUERY', path: '/book/search', contentType: 'application/json', body: '{"title": "Grails"}', accept: 'application/json'], [method: 'DELETE', path: '/book/1'] ] } }A request path still has to be traced: registering what
validate()reaches, one hint at a time, only moved the failure to the next call.A Forge application generated with
--data=mongodb --features=spring-boot-starter-security, which secures itself with Spring Security's own DSL and no Grails plugin, builds and runs as an image with its login form, its scaffolded user admin andQUERYtraced. In the image, login and logout, a denied page being restored,/manifest.jsonnot being restored, role checks, CSRF, a scaffolded save and the login of the user it saved, andQUERYwith JSON and form bodies all answer as on a JVM. As the guide says,QUERYis answered with 400 untilStrictHttpFirewallallows it, and needs a token or a CSRF exception. That run found two more bugs:allowedMethodsanswered a refused method with a 500, not a 405. The check the compiler adds callsresponse.sendErrordynamically, and a trace cannot record it, since it fails on a request that answers with an error.HttpServletResponseis now registered.value="", which the trace took for a value and sent. Blank fields failed validation, the page answered with the form again, and the trace reported success. In the image, the first real save then stored the row and answered with a 500. A field rendered empty is now filled in. A hidden field is the page's own and is still sent as it is.Observability
An action that threw answered with a 500 while the observation of the request did not say what failed. With an error view, as in a generated application,
http.server.requeststagged it witherror=noneandexception=none, and the server span endedUNSET. With a"500"mapping to a controller, it tagged it withGrailsWrappedRuntimeExceptionand theuriof that controller, and the span carried the same wrapper and route. Spring Framework 7.1 records the exceptions Spring MVC translates to a 5xx, and these never reach it. So the resolver now records them on the observation, which tells its handlers, so that a tracing handler marks the span, and it keeps the action's route. With a real OpenTelemetry tracer:error=IllegalStateException, spanERRORerror.type=java.lang.IllegalStateException, spanERRORerror=noneerror.type=noneAn exception written as a problem is recorded in the same way. After a forward to an error controller,
jakarta.servlet.error.exceptionholds the exception the action threw rather than the wrapper, for a filter that reads it once the chain has returned. After an error view has rendered it is still cleared, by Spring MVC, because Tomcat'sErrorReportValveresets a response that isn't committed when it finds that attribute.This does not come from Spring Boot 4.2: 8.0.x and 9.0.x report the same values. #16879 fixes it on 8.0.x with the same code, which merges into this branch without conflicts, and it is here too so that 9.1.x does not depend on the order the two land in. In development mode an action also failed with an
InvocationTargetException, because it is invoked by reflection, and it now throws its own exception there as well.An alert that counts a non-
noneerrorvalue sees more server errors than before, and a dashboard that reads theuriof an error controller finds those failures under the routes of the actions, as the upgrade guide says. I measured spans with the OpenTelemetry bridge, not with Brave or a tracing backend.The guide also covers two changes from Spring Boot 4.2:
http.server.requests.activeis opt-in, throughmanagement.metrics.observations.include-active-long-task-timer, andmanagement.observations.conventions: open-telemetryswitches to OpenTelemetry's conventions. Under themhttp.routeis/controller/actionin a Grails application, not the URL mapping's pattern.Documentation of three Spring Boot 4.2 changes
X-Forwarded-Prefixis off by default. Spring Boot 4.1 builtForwardedHeaderFilterwith the constructor that turns it on. Withserver.forward-headers-strategy: framework, an application behind a proxy that strips a path prefix loses the prefix from absolute links and redirects until it setsspring.mvc.forwarded-headers.use-forwarded-prefix: true.bootBuildImagedefaults to theubuntu-resolutebuilder, on Ubuntu 26.04, whose run image the builder no longer pins, and can keep the build cache in an image in a registry.spring-boot-starter-amqpno longer means RabbitMQ. It is now the starter for generic AMQP 1.0, so an application using RabbitMQ through it changes tospring-boot-starter-rabbitmq.Spring Security plugin
Running the request cache change above in a native image and on every option of the plugin found these, neither of which comes from Spring Boot 4.2.
It builds a native image
processAotfailed on the plugin, and an image could not load its defaults, so an application using it had no image. It now traces its login as the guide describes for any login form:grails { nativeMetadata { forms = ['/login/auth?username=admin&password=secret'] paths = ['/', '/book'] } }passwordEncodersandnullAfterInvocationProviderto the context.passwordEncoder,authenticationManagerandspringSecurityFilterChainProxyhave overlapping constructors it cannot choose between, so the default classes are created by a static factory method. A class configured in their place is still created by its constructor, with what it was given before.DefaultSecurityConfigwas compiled from its script at every startup. It is now compiled with the plugin, and a runtime hint keeps the constructorConfigSlurpercalls, which a trace does not see.traceNativeMetadatafailed on the refusal andtrainAotCachetrained on it. Both now wait while/answers 503, for no longer thanstartTimeoutSeconds.In an image with a traced login, with both
InterceptUrlMapand the defaultAnnotationsecurity, the login page renders, a wrong password returns to it with its message, a login after a denied page returns to that page, and a login after/manifest.json,/apple-touch-icon.pngor the DevTools path goes to the default page instead.trainAotCachetrains against the plugin too.PluginBeansAotSpecruns Spring's ahead-of-time generator over what the plugin declares for each security config type, the optional filters, Digest authentication and configured classes, so a bean it cannot write out fails the module's tests.Digest authentication starts and signs users in
useDigestAuthwith its default of hashed passwords failed at startup, naming aDigestAuthPasswordEncoderthat no longer exists. It cannot come back, becausePasswordEncoderis no longer given the username the hash needs. Withdigest.useCleartextPasswordsthe application started but signed nobody in: the password encoder stored{noop}secret, and the filter hashes what is stored as a browser hashes the password.By default what a user has stored is now the hash a browser computes, which the application computes:
With
digest.useCleartextPasswords, what is stored is what was typed. In both casespasswordEncoderis aNoOpPasswordEncoder, unless a class is configured, because the filter hands the authentication manager what is stored as the credentials. So a form signs a user in with cleartext passwords stored, and cannot with the hash stored. An application that signed in through a form with stored values such as{noop}secretremoves the prefix, which the upgrade guide says.That filter leaves an unauthenticated token, so that the account checks of the authentication manager still run, and the compat
FilterSecurityInterceptorwent straight to the access decision, so a right password was answered with 403. It now authenticates such a token first, as Spring Security's own interceptor did before Spring Security 7 removed it, and honoursfii.alwaysReauthenticate, which did nothing. A disabled account is refused.DigestAuthenticationSpecsends a browser's handshake through the plugin's whole filter chain, andcurl --digestagainst a running application and against an image gets 200 and 401 for a right and a wrong password.A malformed Content-Type no longer fails a request with a token reader
BearerTokenReaderparses theContent-Typeof any request without a bearer header, and nothing caught the exception. Spring Framework 7.1 rejects a parameter without a value or a repeated one, soapplication/x-www-form-urlencoded;foo, which used to be read, failed the request with a 500. It now reads only the type, and a type it cannot read is not a form. Grails' own request handling does not have this problem: a running application answered malformedAcceptandContent-Typeheaders without an error, through bothrenderandrespond.Limitations
ERRORby Spring'sProblemDetailExceptionHandlerwith its stack trace, which Grails has no way to change, and by Grails with the method and URI, so it takes two log entries. Thegrails.exceptionresolversettings for what is logged, such aslogRequestParameters, apply to what the Grails exception resolver logs, and so not to these.UserDetailsService, because the fixture has no GORM. The defaultGormUserDetailsServicehas not been run in an image, and the image is not built in CI, where each one would add about three minutes.grails-data-hibernate5, the default of a generated application,compileAotJavafails on the code generated for itsdataSource(InstanceFactoryBean cannot be converted to DataSource), which stops AOT processing for it altogether. Withgrails-data-hibernate7an application is traced and compiled, and the image fails as it starts, because Hibernate's ByteBuddy bytecode provider cannot initialize there. MongoDB is the data layer that runs in an image.