Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure the entire template is passed to the output buffer callback for Optimization Detective to process #1317

Merged
merged 16 commits into from
Aug 8, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions plugins/optimization-detective/optimization.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,35 @@
* @return string Unmodified value of $passthrough.
*/
function od_buffer_output( string $passthrough ): string {
/*
* Instead of the default PHP_OUTPUT_HANDLER_STDFLAGS (cleanable, flushable, and removable) being used for flags,
* we need to omit PHP_OUTPUT_HANDLER_FLUSHABLE. If the buffer were flushable, then each time that ob_flush() is
* called, it would send a fragment of the output into the output buffer callback. When buffering the entire
* response as an HTML document, this would result in broken HTML processing.
*
* If this ends up being problematic, then PHP_OUTPUT_HANDLER_FLUSHABLE could be added to the $flags and the
* output buffer callback could check if the phase is PHP_OUTPUT_HANDLER_FLUSH and abort any subsequent
* processing while also emitting a _doing_it_wrong().
*/
$flags = PHP_OUTPUT_HANDLER_CLEANABLE;

// When running unit tests the output buffer must also be removable in order to obtain the buffered output.
if ( php_sapi_name() === 'cli' ) {
// TODO: Do any caching plugins need the output buffer to be removable? This is unlikely, as they would pass an output buffer callback to ob_start() instead of calling ob_get_clean() at shutdown.
$flags |= PHP_OUTPUT_HANDLER_REMOVABLE;
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This actually does need to be removable because WordPress core calls wp_ob_end_flush_all() at shutdown, and the lack of PHP_OUTPUT_HANDLER_REMOVABLE results in:

Notice: ob_end_flush(): Failed to send buffer of Closure::__invoke (1) in /var/www/html/wp-includes/functions.php on line 5427

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 0b4f2cd


ob_start(
static function ( string $output ): string {
static function ( string $output, ?int $phase ): string {
// When the output is being cleaned (e.g. pending template is replaced with error page), do not send it through the filter.
if ( ( $phase & PHP_OUTPUT_HANDLER_CLEAN ) !== 0 ) {
return $output;
}

// Since ob_start() was called without PHP_OUTPUT_HANDLER_FLUSHABLE, at this point the phase should never be flush, and it should always be final.
assert( ( $phase & ( PHP_OUTPUT_HANDLER_FLUSH ) ) === 0 );
assert( ( $phase & ( PHP_OUTPUT_HANDLER_FINAL ) ) !== 0 );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about using assert() as I don't think there's a precedent in WP or this plugin for that. Can we use one of the more common ways to aid debugging in WordPress?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are sanity checks that should not ever return false given the flags.

There are instances of assert() being used in core, although in bundled libraries: https://github.com/search?q=repo%3AWordPress%2Fwordpress-develop+%2Fassert%5C%28%2F+path%3A%2Fsrc%2F&type=code

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, but not in WordPress itself, and I don't know how relevant the presence of these flags is in said code, e.g. whether the code paths are even reached.

If they should never return false, then I'd say we should either go with what's more established for debugging in WordPress, or simply omit them.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK. Removed in 364fa43.


/**
* Filters the template output buffer prior to sending to the client.
*
Expand All @@ -42,7 +69,9 @@ static function ( string $output ): string {
* @return string Filtered output buffer.
*/
return (string) apply_filters( 'od_template_output_buffer', $output );
}
},
0, // Unlimited buffer size.
$flags
);
return $passthrough;
}
Expand Down Expand Up @@ -139,7 +168,7 @@ function od_is_response_html_content_type(): bool {
* @return string Filtered template output buffer.
*/
function od_optimize_template_output_buffer( string $buffer ): string {
if ( ! od_is_response_html_content_type() ) {
if ( ! od_is_response_html_content_type() ) { // TODO: This should check to see if there is an HTML tag.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this TODO intended for this PR or another issue?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I'm opening a sub-PR for this. It's unrelated to output buffering.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #1442

return $buffer;
}

Expand Down
75 changes: 73 additions & 2 deletions plugins/optimization-detective/tests/test-optimization.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,23 +54,94 @@ public function test_od_buffer_output(): void {
// buffer callback. See <https://stackoverflow.com/a/61439514/93579>.
ob_start();

$filter_invoked = false;
add_filter(
'od_template_output_buffer',
function ( $buffer ) use ( $original, $expected ) {
function ( $buffer ) use ( $original, $expected, &$filter_invoked ) {
$this->assertSame( $original, $buffer );
$filter_invoked = true;
return $expected;
}
);

$original_ob_level = ob_get_level();
od_buffer_output( '' );
$template = sprintf( 'page-%s.php', wp_generate_uuid4() );
$this->assertSame( $template, od_buffer_output( $template ), 'Expected value to be passed through.' );
$this->assertSame( $original_ob_level + 1, ob_get_level(), 'Expected call to ob_start().' );
echo $original;

ob_end_flush(); // Flushing invokes the output buffer callback.

$buffer = ob_get_clean(); // Get the buffer from our wrapper output buffer.
$this->assertSame( $expected, $buffer );
$this->assertTrue( $filter_invoked );
}

/**
* Test that calling ob_flush() will not result in the buffer being processed and that ob_clean() will successfully prevent content from being processed.
*
* @covers ::od_buffer_output
*/
public function test_od_buffer_with_cleaning_and_attempted_flushing(): void {
$template_aborted = 'Before time began!';
$template_start = 'The beginning';
$template_middle = ', the middle';
$template_end = ', and the end!';

// In order to test, a wrapping output buffer is required because ob_get_clean() does not invoke the output
// buffer callback. See <https://stackoverflow.com/a/61439514/93579>.
$initial_level = ob_get_level();
$this->assertTrue( ob_start() );
$this->assertSame( $initial_level + 1, ob_get_level() );

$filter_count = 0;
add_filter(
'od_template_output_buffer',
function ( $buffer ) use ( $template_start, $template_middle, $template_end, &$filter_count ) {
$filter_count++;
$this->assertSame( $template_start . $template_middle . $template_end, $buffer );
return '<filtered>' . $buffer . '</filtered>';
}
);

od_buffer_output( '' );
$this->assertSame( $initial_level + 2, ob_get_level() );

echo $template_aborted;
$this->assertTrue( ob_clean() ); // By cleaning, the above should never be seen by the filter.

// This is the start of what will end up getting filtered.
echo $template_start;

// Attempt to flush the output, which will fail because the output buffer was opened without the flushable flag.
$this->assertFalse( ob_flush() );

// This will also be sent into the filter.
echo $template_middle;
$this->assertFalse( ob_flush() );
$this->assertSame( $initial_level + 2, ob_get_level() );

// Start a nested output buffer which will also end up getting sent into the filter.
$this->assertTrue( ob_start() );
echo $template_end;
$this->assertSame( $initial_level + 3, ob_get_level() );
$this->assertTrue( ob_flush() );
$this->assertTrue( ob_end_flush() );
$this->assertSame( $initial_level + 2, ob_get_level() );

// Close the output buffer opened by od_buffer_output(). This only works in the unit test because the removable flag was passed.
$this->assertTrue( ob_end_flush() );
$this->assertSame( $initial_level + 1, ob_get_level() );

$buffer = ob_get_clean(); // Get the buffer from our wrapper output buffer and close it.
$this->assertSame( $initial_level, ob_get_level() );

$this->assertSame( 1, $filter_count, 'Expected filter to be called once.' );
$this->assertSame(
'<filtered>' . $template_start . $template_middle . $template_end . '</filtered>',
$buffer,
'Excepted return value of filter to be the resulting value for the buffer.'
);
}

/**
Expand Down