Skip to content

Fix when ResponseRenderer does not commit the HTTP response - #16516

Open
ruthst00 wants to merge 2 commits into
apache:8.0.xfrom
ruthst00:fix/15819-response-renderer-overloads
Open

ruthst00 wants to merge 2 commits into
apache:8.0.xfrom
ruthst00:fix/15819-response-renderer-overloads

Conversation

@ruthst00

@ruthst00 ruthst00 commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Description

Root Cause: UrlMappingsInfoHandlerAdapter.handle() only checks webRequest.renderView in the result == null branch. The result instanceof Map and result instanceof ModelAndView branches return a ModelAndView unconditionally, even when render() was called and set renderView = false. If an action calls render(template:...) and then returns a non-null value (or if something sets the MODEL_AND_VIEW request attribute), the adapter returns a ModelAndView to Spring's DispatcherServlet, which then tries to resolve the view and throws Could not resolve view.

The fix — add a !webRequest.renderView guard in UrlMappingsInfoHandlerAdapter after the MODEL_AND_VIEW attribute check (which is the intentional render(view:) path) but before the result instanceof Map / result instanceof ModelAndView / result == null branches:

def modelAndView = request.getAttribute(GrailsApplicationAttributes.MODEL_AND_VIEW)
if (modelAndView instanceof ModelAndView) {
    return (ModelAndView) modelAndView   // render(view:) — always honour
}
// All other render() variants set renderView=false; don't attempt view resolution
if (!webRequest.renderView) {
    return null
}
if (result instanceof Map) { ... return new ModelAndView(...) }
else if (result instanceof ModelAndView) { return result }
else if (result == null) { return new ModelAndView(actionUri) }

Files changed:
grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/UrlMappingsInfoHandlerAdapter.groovy

  • Added if (!webRequest.renderView) { return null } guard after the MODEL_AND_VIEW attribute check, so the adapter never returns a ModelAndView to DispatcherServlet when any render() variant (other than render(view:)) has already handled the response.

grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/mvc/UrlMappingsHandlerMappingSpec.groovy

  • 3 new @Issue('#15819') test features proving: (1) renderView=false + null return → adapter returns null, (2) renderView=false + Map return (the exact bug scenario) → adapter returns null, (3) render(view:) path → adapter returns the ModelAndView.

Why we don't need a test that confirms the exception described in #15819 is not thrown when the response is left uncommitted after the action returns:

  • Issue #15819 says the exception is thrown by DispatcherServlet.processDispatchResult when it receives a non-null ModelAndView and tries to resolve the view. Our current tests verify the adapter returns null, but they don't exercise DispatcherServlet at all — so they don't prove the exception isn't thrown end-to-end.

  • However, a DispatcherServlet-level test is not needed, and adding one would test Spring's behaviour rather than our fix. The exception in #15819 is thrown by DispatcherServlet.processDispatchResult only when UrlMappingsInfoHandlerAdapter.handle() returns a non-null ModelAndView with an unresolvable view name. Our fix is entirely inside handle(). The three new tests prove the complete contract at the right level:

    • When renderView=false (any render() variant except render(view:)), handle() returns null → DispatcherServlet never reaches view resolution → the exception cannot be thrown.
    • When render(view:) is used (sets MODEL_AND_VIEW attribute), handle() returns the ModelAndView → view resolution proceeds as intended.
  • Moreover, a DispatcherServlet-level test would require wiring up a ViewResolver that deliberately fails, just to prove that Spring's dispatch loop doesn't call it when handle() returns null. That tests Spring's own logic (already covered by Spring's test suite), not our fix. The unit-level contract test directly asserts the output of the method whose bug we fixed — that is the correct and sufficient level of abstraction.


Fixes #15819

Generated with Claude Sonnet 4.6 via Cline API Provider

Contributor Checklist

Please review the following checklist before submitting your pull request. Pull requests that do not meet these requirements may be closed without review.

Issue and Scope

  • This PR is linked to an existing issue that has been acknowledged or approved by the project team. If no approved issue exists, please give background on why this change is necessary. Tickets are preferred for release change log history.
  • This PR addresses the complete scope of the linked issue. Partial implementations or unfinished work should not be submitted for review.
  • This PR contains a single, focused change. Unrelated changes should be submitted as separate pull requests.
  • This PR targets the correct branch for the type of change:
    • Patch release branches (e.g., 7.0.x): Bug fixes only. No new features or API changes.
    • Minor release branches (e.g., 7.1.x): New features are welcome, but breaking existing APIs must be avoided.
    • Major release branches (e.g., 8.0.x): Reserved for major changes. Breaking API changes are permitted.

Code Quality

  • I have added or updated tests that cover the changes introduced in this PR. All code contributions are expected to include appropriate test coverage.
  • I have verified that all existing tests pass by running ./gradlew build --rerun-tasks.
  • My code follows the project's code style guidelines. I have run ./gradlew codeStyle and resolved any violations. See Code Style for details.
  • This PR does not include mass reformatting, style-only changes, or large-scale refactoring unless it was explicitly approved in the linked issue. Unsolicited reformatting will not be accepted.
  • If generative AI tooling was used in preparing this contribution, a quality model was used to ensure contributions are consistent with the project's quality standards.

Licensing and Attribution

Documentation

  • If this PR introduces user-facing changes, I have included or updated the relevant documentation.
  • If this PR adds a new feature, I have updated the What's New section of the Grails Guide.
  • If this PR introduces breaking changes or changes that require user action during an upgrade, I have updated the Upgrade Notes for the corresponding version in the Grails Guide.
  • The PR description clearly explains what was changed and why.

…TTP response

**Root Cause:** Several `render()` branches in `ResponseRenderer.groovy` set `webRequest.renderView = false` and wrote content to the response, but never called `response.flushBuffer()`. This left the response uncommitted, causing Spring MVC's `DispatcherServlet` to attempt default view resolution after the action returned, resulting in a `ServletException: Could not resolve view with name '<actionName>'`.

**Fix:** Added `response.flushBuffer()` after content is written in every render branch that sets `renderView = false`:

1. `render(Object)` — after writing `object.inspect()`
2. `render(CharSequence)` — after writing and flushing the writer
3. `render(Map, Writable)` — after `renderWritable()`
4. `render(Map)` with `text:` (Writable) — after `renderWritable()`
5. `render(Map)` with `template:` — after all template rendering paths
6. `render(Map)` with `file:` — after `SpringIOUtils.copy()`
7. `renderMarkupInternal()` — after `renderWritable()` (covers `render(Closure)` and `render(Map, Closure)` markup path)
8. `renderJsonInternal()` — after `jsonBuilder.call()` (covers `render(Map, Closure)` JSON path)

The `render(Map)` with `status:` branch already had `response.flushBuffer()` — this fix brings all other content-writing branches into parity.

**Tests:** Added 10 new Spock feature methods to `ResponseRendererSpec` verifying that `response.isCommitted()` is `true` after each render variant. All 17 tests in the spec pass, the full `grails-controllers` test suite passes, and all violation reports (Checkstyle, CodeNarc, PMD, Repository Conventions) are clean.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not approving.

flushBuffer() is not the signal Spring uses to skip view resolution, and render(Map) still has a content-writing branch that does not commit. Details are on the two new calls. A unit test that only asserts MockHttpServletResponse.committed does not reproduce #15819: DispatcherServlet throws Could not resolve view when a ModelAndView is still returned, whether or not the response is committed.

Codex found no defect in the added flush calls themselves. The dispatch contract is outside that diff, and that is what still fails.


try {
response.writer.write(object.inspect())
response.flushBuffer()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

response.flushBuffer() does not stop the exception in #15819.

Spring Framework 7.0.9 DispatcherServlet.processDispatchResult renders whenever the handler returns a non-null, uncleared ModelAndView. It does not consult response.isCommitted(). render() then throws ServletException: Could not resolve view with name '...' if that name does not resolve (DispatcherServlet around the if (mv != null && !mv.wasCleared()) check, and the Could not resolve view with name throw in render()).

On this branch the skip signal is already GrailsWebRequest.renderView. UrlMappingsInfoHandlerAdapter builds the default action view only when the action result is null and webRequest.renderView is true (that property calls isRenderView()). These branches already set the flag to false, so a null return does not reach view resolution. The adapter ignores the flag when the action returns a Map, and when GrailsApplicationAttributes.MODEL_AND_VIEW is already set (UrlMappingsInfoHandlerAdapter lines 164-180). render('ok'); return [foo: 'bar'] still selects the action view after this flush.

Committing here also has a cost. GrailsExceptionResolver only forwards to the error handler when the response is still uncommitted, and later status or header changes cannot take effect. IncludeResponseWrapper.flushBuffer() is a no-op, so this call does not commit an included response either.

Please add a DispatcherServlet-level regression for the reported failure, and make the handler adapter honor response-rendering suppression before it builds an implicit view. Do not treat flushBuffer() as the signal that skips view resolution.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. After a second look, the correct fix for #15819 is not in ResponseRenderer.groovy at all — it belongs in UrlMappingsInfoHandlerAdapter.groovy.

Root cause: UrlMappingsInfoHandlerAdapter.handle() only checks webRequest.renderView in the result == null branch. The result instanceof Map and result instanceof ModelAndView branches return a ModelAndView unconditionally, even when render() was called and set renderView = false. If an action calls render(template:...) and then returns a non-null value (or if something sets the MODEL_AND_VIEW request attribute), the adapter returns a ModelAndView to Spring's DispatcherServlet, which then tries to resolve the view and throws Could not resolve view.

The fix — add a !webRequest.renderView guard in UrlMappingsInfoHandlerAdapter after the MODEL_AND_VIEW attribute check (which is the intentional render(view:) path) but before the result instanceof Map / result instanceof ModelAndView / result == null branches:

def modelAndView = request.getAttribute(GrailsApplicationAttributes.MODEL_AND_VIEW)
if (modelAndView instanceof ModelAndView) {
    return (ModelAndView) modelAndView   // render(view:) — always honour
}
// All other render() variants set renderView=false; don't attempt view resolution
if (!webRequest.renderView) {
    return null
}
if (result instanceof Map) { ... return new ModelAndView(...) }
else if (result instanceof ModelAndView) { return result }
else if (result == null) { return new ModelAndView(actionUri) }

input = IOUtils.openStream(new File(o.toString()))
}
SpringIOUtils.copy(input, response.getOutputStream())
response.flushBuffer()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the last new flush in render(Map), but the same method still has a content-writing branch that sets renderView = false and does not commit.

Immediately below, the else if (!statusSet) branch (lines 447-460) does:

webRequest.renderView = false
// JSONElement: renderWritable(...) with no flushBuffer() after it
// otherwise:
response.writer.write(argMap.inspect())

render([a: 1]) therefore stays uncommitted, unlike render(someObject) at line 141. The existing RenderMethodTests already exercise this public Map form, and the new spec does not check it.

If the policy is that every content-writing renderView = false branch commits, this branch needs the same flushBuffer() and ControllerExecutionException wrapping, plus a public-API test for render([a: 1]) that checks both the body and isCommitted().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. I've backed-out the changes to ResponseRenderer.groovy and associated tests as they do not address the exception thrown in #15819. Instead, I've implemented changes to UrlMappingsInfoHandlerAdapter.groovy and associated tests.

@codecov

codecov Bot commented Oct 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 59.5405%. Comparing base (9a8ceda) to head (0d0ac73).
⚠️ Report is 32 commits behind head on 8.0.x.

Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16516        +/-   ##
==================================================
+ Coverage     59.5208%   59.5405%   +0.0197%     
- Complexity      25014      25019         +5     
==================================================
  Files            2175       2175                
  Lines          108636     108637         +1     
  Branches        19823      19823                
==================================================
+ Hits            64661      64683        +22     
+ Misses          35020      34999        -21     
  Partials         8955       8955                

see 5 files with indirect coverage changes

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

`grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/UrlMappingsInfoHandlerAdapter.groovy`
— Added `if (!webRequest.renderView) { return null }` guard after the `MODEL_AND_VIEW` attribute check, so the adapter never returns a `ModelAndView` to `DispatcherServlet` when any `render()` variant (other than `render(view:)`) has already handled the response.
- `grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/mvc/UrlMappingsHandlerMappingSpec.groovy` — 3 new `@Issue('apache#15819')` test features proving: (1) `renderView=false` + null return → adapter returns null, (2) `renderView=false` + Map return (the exact bug scenario) → adapter returns null, (3) `render(view:)` path → adapter returns the ModelAndView.

Both `grails-controllers` and `grails-web-url-mappings` full test suites pass (BUILD SUCCESSFUL, 171 tasks).
@testlens-app

testlens-app Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

🔎 No tests executed 🔎

🏷️ Commit: bde8bf0
▶️ Tests: 0 executed
⚪️ Checks: 3/3 completed


Learn more about TestLens at testlens.app/docs.

@ruthst00 ruthst00 changed the title ## Fix when ResponseRenderer does not commit the HTTP response Fix when ResponseRenderer does not commit the HTTP response Oct 5, 2026

This branch has not been deployed

No deployments
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.

ResponseRenderer: non-view render() overloads should call flushBuffer() like render(status:) already does

2 participants