PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / services / OutputBufferDrain.php

OutputBufferDrain.php in 404 Solution trunk, at includes/services/OutputBufferDrain.php

117 lines 5.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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