Repository navigation
[bugfix] Replace CachingResponseWrapper's header-name allowlist with buffer+replay - #6692
Conversation
📊 XQTS result comparisonComparison of this run against
Relative to 🔴 Newly failing tests (1)
🟢 Newly passing tests (1)
Runtime: 529.1s (+231.23s vs |
|
[This response was prompted by Joe, drafted by Claude Code, and reviewed by Joe.] Approved. Replacing the allowlist with the config-versus-step-output distinction is the right call — an enumerated list of header names could only ever be closed one name at a time, and the next surface to set an unlisted header would have reopened it. Separating the two kinds of write structurally means the question stops being "which headers are special" and becomes "which step's output won", which is the question that actually determines whether a header should survive. Keeping The XQTS comparison above is a false alarm — please don't read the −3 as a regression.
A documentation-only PR cannot change XQuery conformance results. I'll open a separate issue for the flakiness with this evidence, since the real cost is the reverse case — a genuine conformance regression getting waved through as "probably the flaky ones again". |
f77ea91 to
c4d3d6a
Compare
…lus its counterpart Two tests scoping the CachingResponseWrapper header-buffering redesign (Option A) that follows in the next commit: - URLRewriteResponseSetHeaderViewPipelineTest is red against current code: a header an intermediate pipeline step sets programmatically via XQuery's response:set-header() -- not through controller.xql's static <exist:set-header> config -- currently leaks straight through to the client even though a view step discards that step's own output afterward. Same shape of bug as Content-Length (#6669), Content-Type, and Last-Modified/ETag/Accept-Ranges (already fixed on the branch this stacks on), just with no fixed header name an allowlist could ever enumerate in advance -- the actual reason to move past "suppress these specific known-bad names" to a structural distinction. - URLRewriteFinalStepResponseSetHeaderSurvivesTest is a characterization test, green both before and after: it pins the case that broke a first attempt at this redesign (an allowlist-based "suppress anything not in controller.xql's static config" approach) -- a header the *final*, actually-flushed step sets the same way, with no static config representation at all, must still reach the client. cache=true means "a view or error-handler exists somewhere in the pipeline," not "this step is provisional," so config-presence alone can't tell final from intermediate.
Supersedes the fixed allowlist (Content-Length, Content-Type, Last-Modified, ETag, Accept-Ranges) with a structural fix: split the two kinds of header write CachingResponseWrapper was conflating. - Pipeline configuration (controller.xql's <exist:set-header>, applied by URLRewrite#setHeaders before doRewrite()'s dispatcher.forward() even runs) must survive regardless of which step's output eventually wins -- applyViews() discards each step's wrapper for a fresh one on the next view step and only ever flushes the last one. This now has its own explicit bypass: HeaderPassthroughResponse#setPassthroughHeader, implemented by CachingResponseWrapper to tunnel through however many wrapper layers are stacked until it reaches a response that isn't one of these wrappers, applying the header there immediately. HttpResponseWrapper#setPassthroughHeader is new and deliberately distinct from #setHeader (which backs the XQuery response:set-header() function) so a step's own programmatic header call keeps going through the normal path below, not this one. - Everything else a dispatched step writes -- Jetty's own resource metadata, or the step's own response:set-header() calls -- is now buffered generically (setHeader/addHeader/setIntHeader/addIntHeader/ setDateHeader/addDateHeader all record a BufferedHeader entry) and replayed verbatim, in original order, from flush() -- but only when this wrapper is the one that actually gets flushed. An intermediate step's headers are correctly never replayed (its wrapper is discarded first); a final step's headers -- config-driven or programmatic, no longer distinguished by name -- correctly are. Content-Type and Content-Length keep their existing special-casing: Content-Type via the first-write-wins contentType field, Content-Length by being derived from the actual buffered byte count at flush time rather than trusted from any step's self-reported value (any buffered Content-Length entry is skipped during replay for exactly this reason). Status codes are unaffected -- setStatus()/sendError() already passed through immediately with a mirrored field, which is correct and load- bearing (service()/applyViews() read getStatus() right after doRewrite() returns), not part of this bug class. Turns URLRewriteResponseSetHeaderViewPipelineTest from the previous commit green, while URLRewriteFinalStepResponseSetHeaderSurvivesTest and URLRewriteSetHeaderSurvivesViewPipelineTest -- the safety nets for this exact redesign, added because a first attempt at it (an allowlist derived from controller.xql's static config) broke the former -- stay green throughout.
c4d3d6a to
4bfad7b
Compare
Summary
Stacked on #6691. Supersedes the fixed header-name allowlist that PR added (Content-Length, Content-Type, Last-Modified, ETag, Accept-Ranges) with a structural fix, per design discussion on #6691.
CachingResponseWrapperwas conflating two different kinds of header write:<exist:set-header>directive, applied byURLRewrite#setHeadersbeforedoRewrite()'sdispatcher.forward()even runs. Must survive regardless of which pipeline step's output eventually wins.response:set-header()calls). Provisional: if a later<exist:view>step replaces this step's output, its headers must not survive either.The allowlist from #6691 could only close this gap one enumerated header name at a time. This PR replaces it with a structural distinction instead:
HeaderPassthroughResponseinterface +HttpResponseWrapper#setPassthroughHeader, used only byURLRewrite#setHeaders, tunneling through however manyCachingResponseWrapperlayers are stacked to reach the real response immediately. Deliberately distinct fromHttpResponseWrapper#setHeader(which backsresponse:set-header()), so a step's own programmatic header call is not accidentally granted the same bypass.BufferedHeaderrecord hierarchy (setHeader/addHeader/setIntHeader/addIntHeader/setDateHeader/addDateHeader) and replayed verbatim, in original order, fromflush()— but only for whichever wrapper actually gets flushed.Content-Type and Content-Length keep their existing special-casing (untouched): Content-Type via the first-write-wins
contentTypefield, Content-Length derived from the actual buffered byte count at flush time rather than trusted from any step's self-reported value.close #3913
Test Plan
URLRewriteResponseSetHeaderViewPipelineTest— red against [bugfix] Fix CachingResponseWrapper gaps exposed by the Jetty 12 upgrade #6691's allowlist (an intermediate step's programmatic header leaked through), green after this change.URLRewriteFinalStepResponseSetHeaderSurvivesTest— characterization test pinning the case that broke a first attempt at this redesign (a config-only allowlist): a final step's ownresponse:set-header()call, with no static config representation, must still reach the client. Green throughout.URLRewriteSetHeaderSurvivesViewPipelineTest(from [bugfix] Fix CachingResponseWrapper gaps exposed by the Jetty 12 upgrade #6691) — config directive survival, green throughout.org.exist.http.urlrewritesuite (25 tests) green.org.exist.http.*suite (81 tests) andorg.exist.http.servlets.*suite green — no regressions outside the urlrewrite package.