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 / diagnostics / HookCallbackInstrumenter.php

HookCallbackInstrumenter.php in 404 Solution trunk, at includes/diagnostics/HookCallbackInstrumenter.php

499 lines 18.2 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 * Reversible WP_Hook callback instrumentation that preserves signatures.
9 *
10 * Ordinary callbacks can be replaced by a variadic wrapper. A callback with a
11 * reference parameter or reference return cannot: changing that signature can
12 * change caller-visible behavior. Those callbacks remain registered exactly as
13 * supplied, with a value-preserving marker immediately before their WP_Hook
14 * entry. Their end records are written only when the owning tracer scope
15 * completes successfully. This avoids adding a post-callback filter step,
16 * which could overwrite a callback's by-reference mutation.
17 *
18 * Registry mutation is lifecycle-traced here; callers own callback records.
19 *
20 * allow-no-test-found: exercised through real AJAX hook dispatch in tests/OptionPersistenceTracerTest.php, tests/AjaxHookCallbackAttributionTest.php, and tests/TableRendererPreludeTracerTest.php
21 *
22 * @phpstan-type CallbackIdentity array{callback: string, source: string, has_reference: bool}
23 * The registration shapes are defined by the class that stores them, so a
24 * change to what a registration carries cannot leave the two files disagreeing.
25 *
26 * @phpstan-import-type WrapperRegistration from ABJ_404_Solution_HookInstrumentationRegistry
27 * @phpstan-import-type MarkerRegistration from ABJ_404_Solution_HookInstrumentationRegistry
28 * @phpstan-import-type Registration from ABJ_404_Solution_HookInstrumentationRegistry
29 * @phpstan-type LifecycleToken array{operation_id: string, phase: string, component: string, hook: string, priority: int|null, callback_ordinal: int}
30 * @phpstan-type InstrumentationCounts array{callbacks_wrapped: int, callbacks_marked: int, callbacks_unavailable: int, registry_status: string, registry_reason: string}
31 * @template TToken
32 */
33 final class ABJ_404_Solution_HookCallbackInstrumenter {
34
35 /** @var callable(string, string, int, CallbackIdentity, int): TToken */
36 private $start;
37
38 /** @var callable(TToken): void */
39 private $end;
40
41 /** @var array<int, TToken> */
42 private $pendingTokens = array();
43
44 /** @var ABJ_404_Solution_HookInstrumentationRegistry */
45 private $registry;
46
47 /** @var ABJ_404_Solution_HookRegistryInspectionLedger */
48 private $inspections;
49
50 /** @var ABJ_404_Solution_HookInstrumentationLifecycleTracer */
51 private $lifecycleTracer;
52
53 /**
54 * @param callable(string, string, int, CallbackIdentity, int): TToken $start
55 * @param callable(TToken): void $end
56 */
57 public function __construct(
58 callable $start,
59 callable $end,
60 ABJ_404_Solution_HookInstrumentationLifecycleTracer $lifecycleTracer
61 ) {
62 $this->start = $start;
63 $this->end = $end;
64 $this->lifecycleTracer = $lifecycleTracer;
65 $this->registry = new ABJ_404_Solution_HookInstrumentationRegistry();
66 $this->inspections = new ABJ_404_Solution_HookRegistryInspectionLedger();
67 }
68
69 /**
70 * Ensure every callable entry currently registered on one hook is
71 * instrumented, and report what this call added.
72 *
73 * Idempotent for a given registry shape: an `all` observer calls this once
74 * per hook FIRING, and a scope's hooks fire tens of thousands of times, so a
75 * hook whose registry has not changed since its last inspection is answered
76 * from ABJ_404_Solution_HookRegistryInspectionLedger without a walk. The
77 * counts are of what this call newly instrumented, which is why the
78 * unchanged answer is zeros rather than the earlier call's totals.
79 *
80 * @return InstrumentationCounts registry_status is 'unchanged' when the
81 * registry was already instrumented and has not changed since.
82 */
83 public function instrument(string $hookName): array {
84 $counts = array(
85 'callbacks_wrapped' => 0,
86 'callbacks_marked' => 0,
87 'callbacks_unavailable' => 0,
88 'registry_status' => 'ready',
89 'registry_reason' => '',
90 );
91 if (!$this->inspections->needsInspection($hookName)) {
92 $counts['registry_status'] = 'unchanged';
93 return $counts;
94 }
95 $this->inspectRegistry($hookName, $counts);
96 $this->inspections->recordInspected($hookName);
97 return $counts;
98 }
99
100 /**
101 * Walk one hook's live registry and instrument what is not instrumented yet.
102 *
103 * Registry lookup is owned here so the lifecycle start is durable before
104 * the first global or hook-object access.
105 *
106 * @param InstrumentationCounts $counts
107 */
108 private function inspectRegistry(string $hookName, array &$counts): void {
109 $lifecycleToken = $this->lifecycleTracer->begin('install', $hookName);
110 $filters = $GLOBALS['wp_filter'] ?? null;
111 if (!is_array($filters)) {
112 $counts['registry_status'] = 'unavailable';
113 $counts['registry_reason'] = 'hook_registry_unavailable';
114 $this->lifecycleTracer->complete(
115 $lifecycleToken,
116 'unavailable',
117 'hook_registry_unavailable'
118 );
119 return;
120 }
121 if (!array_key_exists($hookName, $filters)) {
122 $counts['registry_status'] = 'absent';
123 $this->lifecycleTracer->complete($lifecycleToken, 'absent', 'hook_not_registered');
124 return;
125 }
126 $hookObject = $filters[$hookName];
127 if (!$hookObject instanceof ArrayAccess || !$hookObject instanceof Traversable) {
128 $counts['callbacks_unavailable']++;
129 $counts['registry_status'] = 'malformed';
130 $counts['registry_reason'] = 'hook_object_unavailable';
131 $this->lifecycleTracer->complete(
132 $lifecycleToken,
133 'malformed',
134 'hook_object_unavailable'
135 );
136 return;
137 }
138
139 $this->discardStaleRegistrations($hookName, $hookObject, $lifecycleToken);
140 foreach ($hookObject as $priority => $entries) {
141 $lifecycleToken = $this->lifecycleTracer->advance(
142 $lifecycleToken,
143 (int)$priority,
144 0
145 );
146 if (!is_array($entries)) {
147 $counts['callbacks_unavailable']++;
148 continue;
149 }
150 $instrumented = $this->instrumentPriority(
151 $hookName,
152 (int)$priority,
153 $entries,
154 $counts,
155 $lifecycleToken
156 );
157 $lifecycleToken = $this->lifecycleTracer->advance(
158 $lifecycleToken,
159 (int)$priority,
160 count($entries)
161 );
162 $hookObject[$priority] = $instrumented;
163 }
164 $this->lifecycleTracer->complete($lifecycleToken);
165 }
166
167 /**
168 * @param ArrayAccess<mixed, mixed>&Traversable<mixed, mixed> $hookObject
169 * @param LifecycleToken $lifecycleToken
170 */
171 private function discardStaleRegistrations(
172 string $hookName,
173 object $hookObject,
174 array &$lifecycleToken
175 ): void {
176 foreach ($this->registry->forHook($hookName) as $key => $registration) {
177 $lifecycleToken = $this->lifecycleTracer->advance(
178 $lifecycleToken,
179 $registration['priority'],
180 $registration['ordinal']
181 );
182 $entries = isset($hookObject[$registration['priority']])
183 ? $hookObject[$registration['priority']]
184 : null;
185 if (!is_array($entries)) {
186 $this->registry->forget($key);
187 continue;
188 }
189 $entry = $entries[$registration['id']] ?? null;
190 $callback = is_array($entry) ? ($entry['function'] ?? null) : null;
191 $beforeEntry = $registration['mode'] === 'marker'
192 ? ($entries[$registration['before_id']] ?? null)
193 : null;
194 $valid = $registration['mode'] === 'wrapper'
195 ? $callback === $registration['wrapper']
196 : $callback === $registration['original']
197 && is_array($beforeEntry)
198 && ($beforeEntry['function'] ?? null) === $registration['before'];
199 if ($valid) {
200 continue;
201 }
202 if ($registration['mode'] === 'marker') {
203 $this->removeMarker($entries, $registration);
204 $hookObject[$registration['priority']] = $entries;
205 }
206 $this->registry->forget($key);
207 }
208 }
209
210 /**
211 * Restore wrappers and remove markers without overwriting foreign changes.
212 */
213 public function restore(bool $scopeCompleted = true): void {
214 foreach ($this->registry->all() as $registration) {
215 $lifecycleToken = $this->lifecycleTracer->begin('restore', $registration['hook']);
216 $lifecycleToken = $this->lifecycleTracer->advance(
217 $lifecycleToken,
218 $registration['priority'],
219 $registration['ordinal']
220 );
221 $filters = $GLOBALS['wp_filter'] ?? null;
222 if (!is_array($filters)) {
223 $this->lifecycleTracer->complete(
224 $lifecycleToken,
225 'unavailable',
226 'hook_registry_unavailable'
227 );
228 continue;
229 }
230 $hookObject = $filters[$registration['hook']] ?? null;
231 if (!$hookObject instanceof ArrayAccess
232 || !isset($hookObject[$registration['priority']])) {
233 $this->lifecycleTracer->complete(
234 $lifecycleToken,
235 'absent',
236 'hook_or_priority_absent'
237 );
238 continue;
239 }
240 $entries = $hookObject[$registration['priority']];
241 if (!is_array($entries)) {
242 $this->lifecycleTracer->complete(
243 $lifecycleToken,
244 'malformed',
245 'priority_entries_unavailable'
246 );
247 continue;
248 }
249 if ($registration['mode'] === 'wrapper') {
250 $this->restoreWrapper($entries, $registration);
251 } else {
252 $this->removeMarker($entries, $registration);
253 }
254 $hookObject[$registration['priority']] = $entries;
255 $this->lifecycleTracer->complete($lifecycleToken);
256 }
257 $this->registry->clear();
258 $this->inspections->clear();
259 $tokens = $this->pendingTokens;
260 $this->pendingTokens = array();
261 if ($scopeCompleted) {
262 foreach ($tokens as $token) {
263 call_user_func($this->end, $token);
264 }
265 }
266 }
267
268 /**
269 * @param array<array-key, mixed> $entries
270 * @param InstrumentationCounts $counts
271 * @param LifecycleToken $lifecycleToken
272 * @return array<array-key, mixed>
273 */
274 private function instrumentPriority(
275 string $hookName,
276 int $priority,
277 array $entries,
278 array &$counts,
279 array &$lifecycleToken
280 ): array {
281 $result = array();
282 $callbackOrdinal = 0;
283 foreach ($entries as $id => $entry) {
284 $callbackOrdinal++;
285 $lifecycleToken = $this->lifecycleTracer->advance(
286 $lifecycleToken,
287 $priority,
288 $callbackOrdinal
289 );
290 if ($this->registry->ownsCallback(is_array($entry) ? ($entry['function'] ?? null) : null)) {
291 $result[$id] = $entry;
292 continue;
293 }
294 if ($this->isInstalledEntry($hookName, $priority, (string)$id, $entry, $entries)) {
295 $result[$id] = $entry;
296 continue;
297 }
298 if (!is_array($entry)) {
299 $result[$id] = $entry;
300 $counts['callbacks_unavailable']++;
301 continue;
302 }
303 $callback = $entry['function'] ?? null;
304 if (!is_callable($callback)) {
305 $result[$id] = $entry;
306 $counts['callbacks_unavailable']++;
307 continue;
308 }
309 if (self::isInternalDiagnosticObserver($callback)) {
310 $result[$id] = $entry;
311 continue;
312 }
313 $identity = ABJ_404_Solution_HookCallbackIdentity::describe($callback);
314 if ($identity['has_reference']) {
315 $this->addMarkedEntry(
316 $result,
317 $entries,
318 $hookName,
319 $priority,
320 (string)$id,
321 $entry,
322 $callback,
323 $identity,
324 $callbackOrdinal
325 );
326 $counts['callbacks_marked']++;
327 continue;
328 }
329 $result[$id] = $this->wrappedEntry(
330 $hookName,
331 $priority,
332 (string)$id,
333 $entry,
334 $callback,
335 $identity,
336 $callbackOrdinal
337 );
338 $counts['callbacks_wrapped']++;
339 }
340 return $result;
341 }
342
343 /**
344 * @param mixed $entry
345 * @param array<array-key, mixed> $entries
346 */
347 private function isInstalledEntry(
348 string $hook,
349 int $priority,
350 string $id,
351 $entry,
352 array $entries
353 ): bool {
354 $key = ABJ_404_Solution_HookInstrumentationRegistration::key($hook, $priority, $id);
355 $registration = $this->registry->get($key);
356 if ($registration === null || !is_array($entry)) {
357 return false;
358 }
359 $callback = $entry['function'] ?? null;
360 if ($registration['mode'] === 'wrapper') {
361 return $callback === $registration['wrapper'];
362 }
363 $beforeEntry = $entries[$registration['before_id']] ?? null;
364 return $callback === $registration['original']
365 && is_array($beforeEntry)
366 && ($beforeEntry['function'] ?? null) === $registration['before'];
367 }
368
369 /** @param callable $callback */
370 private static function isInternalDiagnosticObserver($callback): bool {
371 return is_array($callback)
372 && is_object($callback[0] ?? null)
373 && $callback[0] instanceof ABJ_404_Solution_DiagnosticInternalHookObserver;
374 }
375
376 /**
377 * @param array<string, mixed> $entry
378 * @param callable $callback
379 * @param CallbackIdentity $identity
380 * @return array<string, mixed>
381 */
382 private function wrappedEntry(
383 string $hook,
384 int $priority,
385 string $id,
386 array $entry,
387 callable $callback,
388 array $identity,
389 int $ordinal
390 ): array {
391 $wrapper = function (...$args) use ($hook, $priority, $callback, $identity, $ordinal) {
392 $actualHook = self::actualHook($hook, $args);
393 $token = call_user_func($this->start, $hook, $actualHook, $priority, $identity, $ordinal);
394 $result = call_user_func_array($callback, $args);
395 call_user_func($this->end, $token);
396 return $result;
397 };
398 $entry['function'] = $wrapper;
399 $key = ABJ_404_Solution_HookInstrumentationRegistration::key($hook, $priority, $id);
400 $this->registry->add($key, array(
401 'mode' => 'wrapper',
402 'hook' => $hook,
403 'priority' => $priority,
404 'ordinal' => $ordinal,
405 'id' => $id,
406 'original' => $callback,
407 'wrapper' => $wrapper,
408 ));
409 return $entry;
410 }
411
412 /**
413 * @param array<array-key, mixed> $result
414 * @param array<array-key, mixed> $existing
415 * @param array<string, mixed> $entry
416 * @param callable $callback
417 * @param CallbackIdentity $identity
418 */
419 private function addMarkedEntry(
420 array &$result,
421 array $existing,
422 string $hook,
423 int $priority,
424 string $id,
425 array $entry,
426 callable $callback,
427 array $identity,
428 int $ordinal
429 ): void {
430 $before = function ($value = null, ...$args) use (
431 $hook,
432 $priority,
433 $identity, $ordinal
434 ) {
435 $actualHook = self::actualHook($hook, array_merge(array($value), $args));
436 $token = call_user_func($this->start, $hook, $actualHook, $priority, $identity, $ordinal);
437 if ($token !== null) {
438 $this->pendingTokens[] = $token;
439 }
440 return $value;
441 };
442 $beforeId = ABJ_404_Solution_HookInstrumentationRegistration::markerId(
443 'before',
444 $hook,
445 $priority,
446 $id,
447 $existing,
448 $result
449 );
450 $result[$beforeId] = array('function' => $before, 'accepted_args' => 1);
451 $result[$id] = $entry;
452 $key = ABJ_404_Solution_HookInstrumentationRegistration::key($hook, $priority, $id);
453 $this->registry->add($key, array(
454 'mode' => 'marker',
455 'hook' => $hook,
456 'priority' => $priority,
457 'ordinal' => $ordinal,
458 'id' => $id,
459 'original' => $callback,
460 'before_id' => $beforeId,
461 'before' => $before,
462 ));
463 }
464
465 /**
466 * @param array<array-key, mixed> $entries
467 * @param WrapperRegistration $registration
468 */
469 private function restoreWrapper(array &$entries, array $registration): void {
470 $entry = $entries[$registration['id']] ?? null;
471 if (is_array($entry) && ($entry['function'] ?? null) === $registration['wrapper']) {
472 $entry['function'] = $registration['original'];
473 $entries[$registration['id']] = $entry;
474 }
475 }
476
477 /**
478 * @param array<array-key, mixed> $entries
479 * @param MarkerRegistration $registration
480 */
481 private function removeMarker(array &$entries, array $registration): void {
482 $id = $registration['before_id'];
483 $entry = $entries[$id] ?? null;
484 if (is_array($entry) && ($entry['function'] ?? null) === $registration['before']) {
485 unset($entries[$id]);
486 }
487 }
488
489 /**
490 * @param array<int, mixed> $args
491 */
492 private static function actualHook(string $registeredHook, array $args): string {
493 return $registeredHook === 'all' && is_string($args[0] ?? null)
494 ? $args[0]
495 : $registeredHook;
496 }
497
498 }
499