Skip to content

Address the WordPress.org review: leave no output buffer open - #12

Merged
henry-mosh merged 1 commit into
mainfrom
feat/citecue-plugin-review-8f1f1f
Aug 13, 2026
Merged

Address the WordPress.org review: leave no output buffer open#12
henry-mosh merged 1 commit into
mainfrom
feat/citecue-plugin-review-8f1f1f

Conversation

@henry-mosh

Copy link
Copy Markdown
Collaborator

The review finding

Unclosed ob_start()includes/class-citecue-seo-head.php:158
finish_capture() normally calls ob_get_clean(), but if ob_get_level() no longer matches it returns early and leaves the buffer opened by ob_start() unclosed for the rest of the request.

The cited early return is one of several paths, not the whole problem. The capture opened on template_redirect and closed on wp_head, so the buffer was also left open by a template that never calls wp_head(), a plugin that exits inside it, or a fatal. And a capture that has to see the theme's header.php cannot close in the function that opened it — so no arrangement of guards makes an ob_start()/ob_get_clean() pair across two hooks safe.

What changed

There is no pair any more.

  • WordPress 6.9+ — registers a wp_template_enhancement_output_buffer filter and opens nothing. Core owns the buffer and hands over the finished document. This is the API the review pointed at. The filter is registered only when there is a cached block to inject, so CiteCue never makes core buffer a response it would have streamed.
  • WordPress 5.8–6.8 — opens the buffer in the form core itself uses: ob_start() with a callback and without PHP_OUTPUT_HANDLER_FLUSHABLE. PHP finalizes it, at the end of the request if nothing did it sooner.

Shipped code now holds one ob_start() and zero ob_get_clean() / ob_end_flush() / ob_end_clean() calls, so the plugin can neither take a buffer it did not open nor forget one it did.

Two things follow from injecting into a document rather than at the end of an action:

  • The tags go immediately before </head>, and the occupied-slot check reads only what precedes it. A <title> inside a body SVG, or a <meta> quoted in page content, used to read as a slot another plugin had filled — those pages lost their metadata for it.
  • The capture is arranged at template_redirect PHP_INT_MAX rather than 1. A whole-response buffer should not be opened on a request another plugin is about to redirect or answer itself.

Version bumped to 1.1.2, with changelog and upgrade notice.

Testing

  • 351 tests green on WordPress 7.0, 6.8, 6.5 and 5.9, in all three modes (test:core, test:woocommerce, test:multisite), and again with WP_DEBUG on. Assertion counts differ by version — that is how we know both mechanisms are actually exercised, and test_the_capture_uses_core_s_buffer_where_there_is_one asserts which one ran.
  • PHPCS (WordPress-Core, WordPress-Docs, PHPCompatibilityWP) and php -l clean. bin/build-plugin-zip.sh builds 1.1.2.
  • Two real installs (WP 7.0 and 6.8, WP_DEBUG true, a theme that prints its own canonical in header.php): block lands in the head, CiteCue's canonical is dropped as already taken, no duplicate title, no notices, and the buffer level at shutdown matches the level at the end of wp_head.

One interaction worth knowing about

A third-party plugin that buffers the whole page and retrieves it with ob_get_clean() suppresses the injection — PHP discards whatever a handler returns in the clean phase. Measured side by side, that happens identically on WP 7.0 under core's own buffer, which skips its own enhancement filter on those same requests for the same reason. A plugin using the callback form — what page caches and minifiers actually do — composes fine on both:

no third-party buffer callback form ob_get_clean() form
WP 7.0 (core's buffer) injected injected skipped
WP 6.8 (plugin's buffer) injected injected skipped

🤖 Generated with Claude Code

The WordPress.org review found the SEO head capture leaving a buffer
open: finish_capture() returns early when ob_get_level() no longer
matches, and the ob_start() it was paired with stays open for the rest of
the request. The cited path is one of several — a template that never
calls wp_head(), a plugin that exits inside it, or a fatal all end the
request with the buffer still on the stack, and a buffer left open is one
the next component's ob_get_clean() takes by mistake.

The pairing itself was the problem. A capture that has to see the theme's
header.php cannot close in the function that opened it, so no arrangement
of guards makes an ob_start()/ob_get_clean() pair across two hooks safe.

So there is no pair any more. From WordPress 6.9 the plugin registers a
wp_template_enhancement_output_buffer filter and opens nothing at all:
core owns the buffer and hands over the finished document. The filter is
registered only when there is a cached block, so CiteCue never makes core
buffer a response it would have streamed. Below 6.9 the plugin opens the
buffer in the form core itself uses — ob_start() with a callback and
without PHP_OUTPUT_HANDLER_FLUSHABLE — which PHP finalizes on its own,
at the end of the request if nothing did it sooner. Shipped code now
holds one ob_start() and no ob_get_clean(), ob_end_flush() or
ob_end_clean() at all, so it can neither take a buffer it did not open
nor forget one it did.

Two things follow from injecting into a document rather than at the end
of an action. The tags go immediately before </head>, and the check for
what is already there reads only what precedes it: a <title> inside a
body SVG or a <meta> quoted in page content used to read as a slot
somebody else had filled, and those pages lost their metadata for it. And
the capture is arranged at template_redirect PHP_INT_MAX rather than 1,
since a whole-response buffer should not be opened on a request another
plugin is about to redirect or answer itself.

Verified on real installs of WordPress 7.0 and 6.8 with WP_DEBUG on,
against a theme that prints its own canonical in header.php: the block
lands in the head, CiteCue's canonical is dropped as already taken, and
the buffer level at shutdown matches the level at the end of wp_head. The
suite runs green on 7.0, 6.8, 6.5 and 5.9 — the assertion counts differ
by version, which is how we know both mechanisms are exercised.

One interaction is worth knowing about: a third-party plugin that
buffers the whole page and retrieves it with ob_get_clean() suppresses
the injection, because PHP discards what a handler returns in the clean
phase. That happens identically on 6.9+ under core's own buffer, which
skips its own enhancement filter on those requests for the same reason.
A plugin using the callback form — which is what page caches and
minifiers do — composes fine on both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@henry-mosh
henry-mosh merged commit 641d2e5 into main Aug 13, 2026
8 checks passed
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