| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Closes output buffers with a guaranteed exit, for every place this plugin |
| 9 |
* has to unwind buffers it opened (the AJAX and admin fatal-error responders, |
| 10 |
* and the admin endpoint's buffer cleanup). |
| 11 |
* |
| 12 |
* WHY THIS EXISTS -- the shape it replaces was: |
| 13 |
* |
| 14 |
* while (ob_get_level() > $minLevel) { ob_end_flush(); } |
| 15 |
* |
| 16 |
* which assumes every close either lowers the level or throws. Neither is |
| 17 |
* guaranteed. ob_end_flush()/ob_end_clean() return false and leave the level |
| 18 |
* untouched when a handler refuses to be deleted, and -- the case actually |
| 19 |
* observed in production -- flushing a buffer RUNS that buffer's callback, |
| 20 |
* which on a site with other output-buffering plugins can open a fresh buffer |
| 21 |
* while the drain is still walking the stack. Either way the condition never |
| 22 |
* goes false and the loop spins until the worker is killed. |
| 23 |
* |
| 24 |
* That is not hypothetical. Support report 193 (showmetech.com.br, LiteSpeed / |
| 25 |
* PHP 8.4, 4.3.3-beta.6) caught five workers in this loop for roughly three |
| 26 |
* minutes each: sustained ob_close_start/ob_close_end checkpoint pairs at ~39 |
| 27 |
* cycles/second, and NOT ONE finish_request or exit_sentinel record in a |
| 28 |
* 25,000-line journal -- positive proof the loop never reached the |
| 29 |
* litespeed_finish_request() call that sits immediately after it. The response |
| 30 |
* was therefore never detached and the admin page never loaded, which is the |
| 31 |
* bug the user reported. |
| 32 |
* |
| 33 |
* WordPress core hit the same hazard and bounds its own drain |
| 34 |
* (wp_ob_end_flush_all(), wp-includes/functions.php) by capturing the level |
| 35 |
* ONCE and running a fixed `for` over it. This class does that and adds a |
| 36 |
* stall check, so a non-decrementing close costs one wasted iteration instead |
| 37 |
* of the whole budget. The response tail no longer drains at all: native FPM |
| 38 |
* and LiteSpeed probes proved their finish-request boundaries own that flush. |
| 39 |
* |
| 40 |
* The return value is deliberately a telemetry array rather than void: the |
| 41 |
* caller and tests can distinguish a complete unwind from a deliberate stop. |
| 42 |
*/ |
| 43 |
final class ABJ_404_Solution_OutputBufferDrain { |
| 44 |
|
| 45 |
/** |
| 46 |
* Close output buffers until the level reaches $minLevel, the level stops |
| 47 |
* falling, or the one-time budget is spent -- whichever comes first. |
| 48 |
* |
| 49 |
* @param int $minLevel Stop once ob_get_level() is at or below this. Callers |
| 50 |
* must capture their inherited level before opening or taking ownership |
| 51 |
* of any buffer, then pass that level here. Global zero is not an owned |
| 52 |
* floor and is forbidden for production callers. |
| 53 |
* @param callable $closeOne Closes exactly one buffer. Receives no |
| 54 |
* arguments and its return value is ignored -- progress is measured from |
| 55 |
* the level reader, never from what the close call claims, because the |
| 56 |
* failing close returns false and the re-entrant one returns true. |
| 57 |
* @param callable|null $levelReader Returns the current buffer level. |
| 58 |
* Defaults to ob_get_level(). Injectable because the two failure modes |
| 59 |
* this class exists to survive (a level that never falls, and a level |
| 60 |
* that falls and immediately rises again) cannot be staged against the |
| 61 |
* real output stack from inside a test runner that is itself buffering. |
| 62 |
* @return array{iterations: int, level_before: int, level_after: int, |
| 63 |
* budget: int, stalled: bool, budget_exhausted: bool} Telemetry. `stalled` |
| 64 |
* means a close did not lower the level (non-removable handler, or a |
| 65 |
* callback re-opened one); `budget_exhausted` means the level kept |
| 66 |
* falling but more buffers appeared than existed when the drain started. |
| 67 |
* Either flag means buffers are still open on purpose, not by accident. |
| 68 |
*/ |
| 69 |
public static function drainTo(int $minLevel, callable $closeOne, ?callable $levelReader = null): array { |
| 70 |
$readLevel = $levelReader === null |
| 71 |
? static function () { return ABJ_404_Solution_OutputBufferDrain::currentLevel(); } |
| 72 |
: static function () use ($levelReader) { return (int)$levelReader(); }; |
| 73 |
|
| 74 |
$minLevel = max(0, $minLevel); |
| 75 |
$levelBefore = $readLevel(); |
| 76 |
$budget = max(0, $levelBefore - $minLevel); |
| 77 |
|
| 78 |
$iterations = 0; |
| 79 |
$stalled = false; |
| 80 |
while ($iterations < $budget) { |
| 81 |
$levelBeforeClose = $readLevel(); |
| 82 |
if ($levelBeforeClose <= $minLevel) { |
| 83 |
break; |
| 84 |
} |
| 85 |
$closeOne(); |
| 86 |
$iterations++; |
| 87 |
if ($readLevel() >= $levelBeforeClose) { |
| 88 |
// The close did not consume a buffer. Trying again cannot help: |
| 89 |
// nothing about the stack changed, so the next iteration would |
| 90 |
// take the identical branch. Stop and report it. |
| 91 |
$stalled = true; |
| 92 |
break; |
| 93 |
} |
| 94 |
} |
| 95 |
|
| 96 |
$levelAfter = $readLevel(); |
| 97 |
return array( |
| 98 |
'iterations' => $iterations, |
| 99 |
'level_before' => $levelBefore, |
| 100 |
'level_after' => $levelAfter, |
| 101 |
'budget' => $budget, |
| 102 |
'stalled' => $stalled, |
| 103 |
'budget_exhausted' => !$stalled && $iterations >= $budget && $levelAfter > $minLevel, |
| 104 |
); |
| 105 |
} |
| 106 |
|
| 107 |
/** |
| 108 |
* ob_get_level() is always available in supported PHP, but this class runs |
| 109 |
* inside fatal-error responders where the runtime is already degraded, so |
| 110 |
* an absent function must degrade to "no buffers" rather than fatal a |
| 111 |
* second time inside the handler for the first fatal. |
| 112 |
*/ |
| 113 |
public static function currentLevel(): int { |
| 114 |
return function_exists('ob_get_level') ? (int)ob_get_level() : 0; |
| 115 |
} |
| 116 |
} |
| 117 |
|