feat(engine): capture the page in the outermost output buffer - #182
Merged
Conversation
A redirect issued while the page was already rendering (e.g. by a plugin after the response-code rule had run) could be written to the cache and its Location target replayed to every visitor of that URL. Responses with a 3xx status are now always passed through unstored.
Open the capture buffer in the drop-in phase, before any plugin loads, so output-buffer post-processors (TranslatePress, HTML optimizers) nest inside it and their final HTML is what gets stored. A sentinel at the former buffer position (template_redirect, PHP_INT_MAX - 10) records the cache decision, sticky-negative across replays; the handler stores only on the end-of-request FINAL flush after a positive sentinel. Buffers cleaned or flushed mid-request by third parties, chunk overflows past the 5MB cap, mid-request fastcgi_finish_request() calls, and responses carrying Content-Encoding all pass through unstored. Rule-driven TTL, grace, and cache decisions are honored up to the final flush; a late bypass wins. Single code path, no escape hatch. New public API for extensions: millicache()->response()->is_storable(). Thanks to @LukePPx for the report, root-cause analysis, and initial patch (#180). Co-authored-by: Lukas Thoma <127091282+LukePPx@users.noreply.github.com>
ouun
force-pushed
the
feat/early-output-buffer
branch
2 times, most recently
from
August 4, 2026 20:07
34c1714 to
7606f04
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
MilliCache now opens its capture buffer in the drop-in phase (
advanced-cache.php), before any plugin or mu-plugin loads, making it the outermost output buffer. Plugins that post-process HTML in their own buffer (TranslatePress, Weglot, HTML optimizers) nest inside it and flush first, so the cache stores their final HTML.Previously the buffer opened last (
template_redirectatPHP_INT_MAX - 10) and captured the page before those plugins transformed it: the first visitor saw the correct page, but every cache hit served the untransformed version (e.g. the untranslated default language).Supersedes #180 — thanks @LukePPx for the report, root-cause analysis, and initial patch. Your
Processorflush-time approach carried directly into this implementation.Design
Engine::run()right after the cache lookup (cache hits still serve-and-exit before any buffer exists). The whole capture lifecycle lives inResponse\Processor::start_output_buffer().template_redirect PHP_INT_MAX - 10slot where the buffer used to open: only requests that reach it with a positive cache decision may be stored, and the sentinel is sticky-negative across manualdo_actionreplays. Admin screens, canonical redirects, early exits, and boot failures pass through unstored — identical storability to 1.7. The handler's passthrough paths are strictly WordPress-function-free, so even a dead-DBwp_die()page reaches the client intact (proven by a subprocess probe that runs without WordPress loaded).do_cache(true)after a sentinel negative is still refused (sentinel AND flush decision).fastcgi_finish_request()issued mid-request by a third party (detected via an in-shutdown marker) all abort storage instead of caching partial content. Responses carrying aContent-Encoding(an in-PHP compressor nesting inside our buffer) are never stored, on both the buffer and the middlewarestore()paths.API
New public accessor for extensions:
millicache()->response()->is_storable()— true only when the sentinel fired positive and nothing has aborted the buffer. (No newEnginefacade method; refusing storage stays with the rules/options cache decision.)Verification
nullon FINAL suppresses output. 7.4 and 8.4 behave identically. The probe (tests/probe/ob-phase-semantics.php) also runs inside the suite on every CI PHP version, so a future PHP deviation fails CI.step16with a mu-plugin that opens anob_startpost-processor oninit(the TranslatePress pattern): the marker appears in the MISS body and in the cached HIT body; canonical redirects never become hits. The whole existing suite (WooCommerce, rules, multisite, invalidation) ran with that post-processor active.Trade-offs (documented, intentional)
display_errorsnotices, drop-in warnings) is captured into stored entries — keepdisplay_errorsoff in production (noted in the troubleshooting docs).ob_gzhandlerbecome uncacheable via the Content-Encoding bypass (correct, but full coverage loss there; server-level compression is unaffected).flush()-only streaming endpoints (SSE) on extension-less URLs needMC_CACHE_NOCACHE_PATHS.Follow-up
MilliCache Pro's Edge Cache tagger currently uses
headers_sent()as a cacheability heuristic; it will switch tomillicache()->response()->is_storable()before 1.8.0 GA (tracked in the Pro repo).BEGIN_COMMIT_OVERRIDE
feat(engine): capture the page in the outermost output buffer
Pages transformed by plugins that post-process HTML in their own output buffer (translation plugins such as TranslatePress, HTML optimizers) are now cached correctly: the capture buffer opens before any plugin loads, so those plugins nest inside it and their final HTML is what gets stored. Rule-driven TTL, grace, and bypass decisions are honored up to the final flush; buffers cleaned or flushed mid-request by other plugins abort storage instead of caching partial content. Thanks to @LukePPx for the report, analysis, and initial patch (#180).
fix(engine): never store redirect responses
END_COMMIT_OVERRIDE