PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.8.0
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.8.0
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / includes / Utils / Response / FatalGuard.php

FatalGuard.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! 3.8.0, at includes/Utils/Response/FatalGuard.php

239 lines 7.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Templately\Utils\Response;
4
5 use Templately\Utils\Log\Logger;
6
7 /**
8 * Shutdown-time PHP-fatal capture for import requests (Query-Monitor-style:
9 * full detail to the log, a safe envelope to the client).
10 *
11 * A fatal (OOM, undefined function in a dependency plugin, parse error in a
12 * just-activated plugin…) bypasses every try/catch and every REST filter —
13 * the client otherwise receives a blank 500 or an HTML error page it cannot
14 * parse. The only hook that still runs is a shutdown function, so this guard:
15 *
16 * 1. detects a fatal via error_get_last(),
17 * 2. records the FULL detail (message, file, line) through {@see Logger}
18 * — per spec 043 FR-008 that detail never travels to the client,
19 * 3. emits the standard 043 error envelope with {@see ErrorCode::FATAL_ERROR}
20 * (severity fatal, NOT retryable — the same request dies the same way).
21 *
22 * Two consumers, one detection+logging path:
23 * - single-import REST handlers call {@see arm()} and get the default JSON
24 * envelope emission,
25 * - the FSI SSE stream calls {@see detect_and_log()} from its own shutdown
26 * handler and emits over its own channel (`sse_error` + import status).
27 */
28 class FatalGuard {
29
30 /**
31 * Context string for the current armed request ('' = not armed).
32 *
33 * @var string
34 */
35 private static $context = '';
36
37 /**
38 * Whether the shutdown hook has been registered (once per request).
39 *
40 * @var bool
41 */
42 private static $registered = false;
43
44 /**
45 * Whether the envelope has already been written for this request's fatal
46 * (the wp_die hijack and our own shutdown handler must not both print it).
47 *
48 * @var bool
49 */
50 private static $emitted = false;
51
52 /**
53 * Arm the guard for the current request: on a fatal, log the detail and
54 * emit the standard JSON error envelope instead of WP core's "critical
55 * error" template.
56 *
57 * Two cooperating mechanisms, because WP core's fatal handler is
58 * registered at bootstrap — BEFORE any plugin code — and prints first:
59 *
60 * - Core's template goes out through `wp_die()`, which resolves its
61 * handler through a filter AT CALL TIME. When a fatal is in
62 * error_get_last(), {@see filter_die_handler()} swaps in
63 * {@see render_die()}, so core's own shutdown pass prints OUR envelope
64 * (core calls it with `'exit' => false`, so execution continues).
65 * - Our own shutdown handler then logs the full detail, and emits the
66 * envelope itself only when core's handler didn't run at all
67 * (WP_DISABLE_FATAL_ERROR_HANDLER, custom drop-in that bails, …).
68 *
69 * A pre-armed output buffer CANNOT solve this — PHP's fatal bailout
70 * discards all output buffers before shutdown functions run.
71 *
72 * Idempotent; later calls just update the context label.
73 *
74 * @param string $context Where we are ("single-import/insert" …) — log prefix.
75 * @return void
76 */
77 public static function arm( $context ) {
78 self::$context = (string) $context;
79
80 if ( self::$registered ) {
81 return;
82 }
83 self::$registered = true;
84
85 add_filter( 'wp_die_handler', [ self::class, 'filter_die_handler' ] );
86 add_filter( 'wp_die_json_handler', [ self::class, 'filter_die_handler' ] );
87 add_filter( 'wp_die_ajax_handler', [ self::class, 'filter_die_handler' ] );
88
89 register_shutdown_function( [ self::class, 'handle_shutdown' ] );
90 }
91
92 /**
93 * `wp_die_*_handler` filter: hijack ONLY the shutdown-time wp_die that WP
94 * core's fatal handler issues for the fatal we are armed against. Any
95 * ordinary wp_die during the request keeps its normal handler (a fatal
96 * ends execution, so a fatal in error_get_last() and a legitimate wp_die
97 * cannot coexist).
98 *
99 * @param callable $handler The resolved wp_die handler.
100 * @return callable
101 */
102 public static function filter_die_handler( $handler ) {
103 if ( '' === self::$context ) {
104 return $handler;
105 }
106
107 $last_error = error_get_last();
108 if ( is_array( $last_error ) && self::is_fatal( $last_error ) ) {
109 return [ self::class, 'render_die' ];
110 }
111
112 return $handler;
113 }
114
115 /**
116 * The hijacked wp_die handler: print the 043 envelope instead of core's
117 * HTML/JSON "critical error" template. Signature per wp_die contract.
118 *
119 * @param string|\WP_Error $message Ignored — core's generic message.
120 * @param string $title Ignored.
121 * @param array $args Only `exit` is honored (core passes false).
122 * @return void
123 */
124 public static function render_die( $message, $title = '', $args = [] ) {
125 self::emit_envelope();
126
127 // Core's fatal template passes `'exit' => false`; honor an explicit
128 // true from any other caller (wp_die's own default).
129 $exit = is_array( $args ) && array_key_exists( 'exit', $args ) ? (bool) $args['exit'] : true;
130 if ( $exit ) {
131 die();
132 }
133 }
134
135 /**
136 * Shutdown callback for {@see arm()}. Public only because PHP requires the
137 * callable to be; not part of the API.
138 *
139 * @return void
140 */
141 public static function handle_shutdown() {
142 if ( '' === self::$context ) {
143 return;
144 }
145
146 $fatal = self::detect_and_log( self::$context );
147 if ( null === $fatal ) {
148 return;
149 }
150
151 self::emit_envelope();
152 }
153
154 /**
155 * The shared half: did this request die of a fatal? If so, record the full
156 * detail in the Templately log and return it; null otherwise.
157 *
158 * @param string $context Log context label.
159 * @return array|null error_get_last() array when a fatal occurred.
160 */
161 public static function detect_and_log( $context ) {
162 $last_error = error_get_last();
163
164 if ( ! is_array( $last_error ) || ! self::is_fatal( $last_error ) ) {
165 return null;
166 }
167
168 Logger::error(
169 sprintf(
170 'PHP fatal (type %d): %s in %s:%d',
171 $last_error['type'],
172 isset( $last_error['message'] ) ? $last_error['message'] : '(no message)',
173 isset( $last_error['file'] ) ? $last_error['file'] : '(unknown file)',
174 isset( $last_error['line'] ) ? $last_error['line'] : 0
175 ),
176 $context
177 );
178
179 return $last_error;
180 }
181
182 /**
183 * Whether an error_get_last() record is a request-killing fatal (as opposed
184 * to a warning/notice/deprecation that happened to be the last error).
185 *
186 * @param array $error error_get_last()-shaped array.
187 * @return bool
188 */
189 public static function is_fatal( array $error ) {
190 if ( ! isset( $error['type'] ) ) {
191 return false;
192 }
193
194 $fatal_types = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR;
195
196 return (bool) ( $error['type'] & $fatal_types );
197 }
198
199 /**
200 * The client-safe error for a captured fatal — stable code + generic
201 * message, no server detail (that went to the log).
202 *
203 * @return TemplatelyError
204 */
205 public static function safe_error() {
206 return new TemplatelyError(
207 ErrorCode::FATAL_ERROR,
208 ErrorCode::default_message( ErrorCode::FATAL_ERROR )
209 );
210 }
211
212 /**
213 * Best-effort JSON envelope emission — once per request. Headers are set
214 * only while unsent; once a broken body has started (display_errors
215 * printed the fatal), nothing can unsend it and the client's transport
216 * falls back to its non-JSON classification.
217 *
218 * @return void
219 */
220 private static function emit_envelope() {
221 if ( self::$emitted ) {
222 return;
223 }
224 self::$emitted = true;
225
226 if ( ! headers_sent() ) {
227 // Discard any partial output so the envelope is the whole body.
228 while ( ob_get_level() > 0 ) {
229 @ob_end_clean();
230 }
231
232 status_header( 500 );
233 header( 'Content-Type: application/json; charset=' . get_option( 'blog_charset' ) );
234 }
235
236 echo wp_json_encode( Envelope::error( self::safe_error() ) );
237 }
238 }
239