| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* The instrumenter's memory of what it changed, indexed for O(1) answers. |
| 9 |
* |
| 10 |
* ABJ_404_Solution_HookCallbackInstrumenter owns registry MUTATION: wrapping a |
| 11 |
* callback, marking one it must not wrap, putting the original back. This |
| 12 |
* collaborator owns the bookkeeping that mutation needs to be reversible, and |
| 13 |
* the three questions asked of it on the hot path: |
| 14 |
* |
| 15 |
* - which registrations belong to hook X (staleness check, once per walk); |
| 16 |
* - is this exact entry one I installed (per entry examined); |
| 17 |
* - is this entry a wrapper or marker closure of mine (per entry examined). |
| 18 |
* |
| 19 |
* All three used to be linear scans over every registration the instrumenter |
| 20 |
* held, and the last two ran INSIDE the per-entry loop, so inspecting one hook |
| 21 |
* cost O(entries x registrations). On the owner's plugin-heavy localhost (4,024 |
| 22 |
* registered callbacks) that is the difference between a bounded cost and a |
| 23 |
* request that takes tens of seconds with debug_mode on. Keeping the indexes |
| 24 |
* beside the table -- rather than deriving them per question -- is what makes |
| 25 |
* the quadratic shape unrepresentable here. |
| 26 |
* |
| 27 |
* The registration shapes live here rather than in the instrumenter because |
| 28 |
* this class is what stores them; the instrumenter imports them back, so there |
| 29 |
* is one definition of what a registration IS and a `mode` discriminator that |
| 30 |
* tells wrapper and marker apart wherever one is read. |
| 31 |
* |
| 32 |
* @phpstan-type WrapperRegistration array{mode: 'wrapper', hook: string, priority: int, ordinal: int, id: string, original: callable, wrapper: callable} |
| 33 |
* @phpstan-type MarkerRegistration array{mode: 'marker', hook: string, priority: int, ordinal: int, id: string, original: callable, before_id: string, before: callable} |
| 34 |
* @phpstan-type Registration WrapperRegistration|MarkerRegistration |
| 35 |
*/ |
| 36 |
final class ABJ_404_Solution_HookInstrumentationRegistry { |
| 37 |
|
| 38 |
/** @var array<string, Registration> Registration by collision-safe key. */ |
| 39 |
private $registrations = array(); |
| 40 |
|
| 41 |
/** @var array<string, array<string, true>> Registration keys by hook name. */ |
| 42 |
private $keysByHook = array(); |
| 43 |
|
| 44 |
/** |
| 45 |
* Object ids of every wrapper/marker closure currently registered. |
| 46 |
* |
| 47 |
* spl_object_id() recycles ids once an object is freed, which would make a |
| 48 |
* stale entry here answer "yes, mine" about an unrelated object. It cannot |
| 49 |
* go stale: the closure is reachable from its own registration for exactly |
| 50 |
* as long as the id is in this set, so the id cannot be reissued while the |
| 51 |
* set still holds it. |
| 52 |
* |
| 53 |
* @var array<int, true> |
| 54 |
*/ |
| 55 |
private $ownedCallbackIds = array(); |
| 56 |
|
| 57 |
/** @param Registration $registration */ |
| 58 |
public function add(string $key, array $registration): void { |
| 59 |
$this->forget($key); |
| 60 |
$this->registrations[$key] = $registration; |
| 61 |
$hook = $registration['hook']; |
| 62 |
$this->keysByHook[$hook][$key] = true; |
| 63 |
foreach (array('wrapper', 'before') as $field) { |
| 64 |
$callback = $registration[$field] ?? null; |
| 65 |
if (is_object($callback)) { |
| 66 |
$this->ownedCallbackIds[spl_object_id($callback)] = true; |
| 67 |
} |
| 68 |
} |
| 69 |
} |
| 70 |
|
| 71 |
public function forget(string $key): void { |
| 72 |
if (!array_key_exists($key, $this->registrations)) { |
| 73 |
return; |
| 74 |
} |
| 75 |
$registration = $this->registrations[$key]; |
| 76 |
$hook = $registration['hook']; |
| 77 |
unset($this->keysByHook[$hook][$key], $this->registrations[$key]); |
| 78 |
if (($this->keysByHook[$hook] ?? null) === array()) { |
| 79 |
unset($this->keysByHook[$hook]); |
| 80 |
} |
| 81 |
foreach (array('wrapper', 'before') as $field) { |
| 82 |
$callback = $registration[$field] ?? null; |
| 83 |
if (is_object($callback)) { |
| 84 |
unset($this->ownedCallbackIds[spl_object_id($callback)]); |
| 85 |
} |
| 86 |
} |
| 87 |
} |
| 88 |
|
| 89 |
/** @return Registration|null */ |
| 90 |
public function get(string $key): ?array { |
| 91 |
return $this->registrations[$key] ?? null; |
| 92 |
} |
| 93 |
|
| 94 |
/** @return array<string, Registration> Registrations of one hook, keyed by key. */ |
| 95 |
public function forHook(string $hook): array { |
| 96 |
$found = array(); |
| 97 |
foreach (array_keys($this->keysByHook[$hook] ?? array()) as $key) { |
| 98 |
if (array_key_exists($key, $this->registrations)) { |
| 99 |
$found[$key] = $this->registrations[$key]; |
| 100 |
} |
| 101 |
} |
| 102 |
return $found; |
| 103 |
} |
| 104 |
|
| 105 |
/** @return array<string, Registration> Every registration, keyed by key. */ |
| 106 |
public function all(): array { |
| 107 |
return $this->registrations; |
| 108 |
} |
| 109 |
|
| 110 |
/** |
| 111 |
* Is this WP_Hook entry's callable one of the closures we installed? |
| 112 |
* |
| 113 |
* @param mixed $callback |
| 114 |
*/ |
| 115 |
public function ownsCallback($callback): bool { |
| 116 |
return is_object($callback) |
| 117 |
&& isset($this->ownedCallbackIds[spl_object_id($callback)]); |
| 118 |
} |
| 119 |
|
| 120 |
public function clear(): void { |
| 121 |
$this->registrations = array(); |
| 122 |
$this->keysByHook = array(); |
| 123 |
$this->ownedCallbackIds = array(); |
| 124 |
} |
| 125 |
} |
| 126 |
|