Address the WordPress.org review: leave no output buffer open - #12
Merged
Conversation
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>
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.
The review finding
The cited early return is one of several paths, not the whole problem. The capture opened on
template_redirectand closed onwp_head, so the buffer was also left open by a template that never callswp_head(), a plugin thatexits inside it, or a fatal. And a capture that has to see the theme'sheader.phpcannot close in the function that opened it — so no arrangement of guards makes anob_start()/ob_get_clean()pair across two hooks safe.What changed
There is no pair any more.
wp_template_enhancement_output_bufferfilter 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.ob_start()with a callback and withoutPHP_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 zeroob_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:
</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.template_redirectPHP_INT_MAXrather than1. 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
test:core,test:woocommerce,test:multisite), and again withWP_DEBUGon. Assertion counts differ by version — that is how we know both mechanisms are actually exercised, andtest_the_capture_uses_core_s_buffer_where_there_is_oneasserts which one ran.php -lclean.bin/build-plugin-zip.shbuilds 1.1.2.WP_DEBUGtrue, a theme that prints its own canonical inheader.php): block lands in the head, CiteCue's canonical is dropped as already taken, no duplicate title, no notices, and the buffer level atshutdownmatches the level at the end ofwp_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:ob_get_clean()form🤖 Generated with Claude Code