Skip to content

feat(engine): capture the page in the outermost output buffer - #182

Merged
ouun merged 2 commits into
nextfrom
feat/early-output-buffer
Aug 7, 2026
Merged

feat(engine): capture the page in the outermost output buffer#182
ouun merged 2 commits into
nextfrom
feat/early-output-buffer

Conversation

@ouun

@ouun ouun commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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_redirect at PHP_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 Processor flush-time approach carried directly into this implementation.

Design

  • Single code path, no escape hatch. The buffer opens unconditionally in Engine::run() right after the cache lookup (cache hits still serve-and-exit before any buffer exists). The whole capture lifecycle lives in Response\Processor::start_output_buffer().
  • Capture position moves, eligibility does not. A sentinel remains at the exact template_redirect PHP_INT_MAX - 10 slot 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 manual do_action replays. 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-DB wp_die() page reaches the client intact (proven by a subprocess probe that runs without WordPress loaded).
  • Rule decisions apply at flush. TTL/grace/bypass set by rules (or the options API) at any point up to the final flush are honored; a late bypass passes the output through unstored. A late do_cache(true) after a sentinel negative is still refused (sentinel AND flush decision).
  • Abort guards. The handler receives the output-handler phase flags: a buffer cleaned or partially flushed by another plugin, a chunk overflow past the 5MB cap (the response streams instead of buffering), or a 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 a Content-Encoding (an in-PHP compressor nesting inside our buffer) are never stored, on both the buffer and the middleware store() paths.
  • Separate commit: 3xx responses are never stored (pre-existing gap — a redirect issued during rendering could be cached and replayed).

API

New public accessor for extensions: millicache()->response()->is_storable() — true only when the sentinel fired positive and nothing has aborted the buffer. (No new Engine facade method; refusing storage stays with the rules/options cache decision.)

Verification

  • PHP output-handler semantics verified on PHP 7.4.33 (the final 7.4 release, via Docker) and 8.4.16: handlers are invoked on clean with the CLEAN bit, chunk overflows arrive without FINAL, mid-request flushes lack FINAL, shutdown functions run before the FINAL flush, null on 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.
  • Unit + integration: 1016 tests / 2049 assertions passing (new: phase guards, sticky sentinel, in-shutdown gate, flush-time decisions, Content-Encoding bypass, SWR regen header reuse, lock TTL, boot-fatal subprocess probes). PHPStan level 9 and PHPCS clean.
  • E2E: full Playwright suite, 88 passed — including a new step16 with a mu-plugin that opens an ob_start post-processor on init (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)

  • Early boot output (display_errors notices, drop-in warnings) is captured into stored entries — keep display_errors off in production (noted in the troubleshooting docs).
  • Sites running an init-phase ob_gzhandler become 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 need MC_CACHE_NOCACHE_PATHS.

Follow-up

MilliCache Pro's Edge Cache tagger currently uses headers_sent() as a cacheability heuristic; it will switch to millicache()->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

ouun and others added 2 commits August 4, 2026 22:02
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
ouun force-pushed the feat/early-output-buffer branch 2 times, most recently from 34c1714 to 7606f04 Compare August 4, 2026 20:07
@ouun
ouun merged commit 6c4d393 into next Aug 7, 2026
10 checks passed
@ouun
ouun deleted the feat/early-output-buffer branch August 7, 2026 14:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant