Fix the hook and event dispatch contract - #1194
Merged
Merged
Conversation
The hook system is the plugin ABI: every bundled plugin, and every plugin neither of us has read, registers through EventManager::register() and is dispatched through HookManager::processEvent(). So the bar for changing it is not "the tests pass", it is "no plugin needs editing" -- and the only way to hold that bar is to write down what happens now, wrong parts included, so a behaviour change shows up as a case somebody had to edit on purpose rather than as a silent difference. Nothing changes here. The cases that pin defects say so, and say that the fix is to rewrite the case rather than to leave it green. Pinned, with the fact-ledger entries they correspond to: - registering [Hook, method] works; a pair whose first element is not a Hook, and a pair naming a method that does not exist, are both swallowed and logged, and the caller cannot tell - a Closure listener raises \Error *out of* register(), because the catch that exists to swallow the failure interpolates $listener[0] (F-13). In production that is HTTP 500 with an empty body on every entry point, and docs/plugin-development.md documents that exact shape three times (F-14) - the one diagnostic a swallowed failure produces names neither the class nor the event, thanks to a literal $s where a specifier was meant and seven arguments for six specifiers - Hook extends Event, so EventManager::register() accepts a hook as an event listener, and Event::onEvent()'s default prints into the response (F-18) - HookManager inherits notify(), which cannot read its own listener shape (F-17); pinned structurally, since notify() queries before a test can reach it - activation is a regex over source text, so it turns on whitespace and case and cannot tell a comment from code -- six variants, two of which disagree with the property in opposite directions (F-15). The pattern is read out of the shipped file rather than copied, so replacing it fails this case - load() picks its file extension by an ordering accident, HookManager satisfying both instanceof checks (F-22) - processEvent() force-activates any listener whose path contains "plugins", so a plugin hook declaring $active = false runs anyway, while the same hook off a plugin path does not (F-16) - hasListeners() is deliberately blind to active No database: the managers and fixtures are built with newInstanceWithoutConstructor() -- Event::__construct() dereferences self::$FOGUser, which no test has -- and $knownEvents is seeded by reflection so processEvent() never asks the hookevent table anything. Mutation-verified. Widening the regex's \s?, reordering the instanceof blocks, deleting the force-activation, making the catch safe, and emptying Event::onEvent() are each caught. Findings: docs/refactor-facts.md F-11..F-26. Proposal: docs/hook-event-plan.md. Co-Authored-By: Claude <noreply@anthropic.com>
register() catches a bad listener, logs it and returns -- except that the catch block interpolated $listener[0]. Hand it an object that is not an array and that line raised "Cannot use object of type X as array", an \Error, which catch (\Exception) does not catch. Registration runs inside a hook constructor during LoadGlobals, so the \Error escaped base.inc.php and the whole application answered HTTP 500 with a zero-byte body -- every entry point, every request, until the file was deleted from disk. Indistinguishable in a browser from the autoload collision at commons/init.php:242-250. An error handler must not be able to fail harder than the error it reports, so name the listener through a describer that copes with every shape: an array (name its first element), an object (name it), anything else (its type). The message itself was no use either. The format carried a literal $s where a specifier was meant, and supplied seven arguments for six specifiers, so $listener[0] -- the only field saying which class failed -- was dropped before it was ever printed. What you got was: Could not register: Error: Class must extend hook, $s: Event, X: Class and what you get now is: Could not register: Error: Class must extend hook, Event: X, Class: stdClass Names go through shortName(): this is log text, not a class reference (ADR 0013, tests/class-name-derivation.test.php). This changes behaviour only for a listener shape that takes the server down today, so the affected population is installs that are currently offline. What changes for them is that the server stays up with one hook unregistered. The characterization cases that pinned the fatal are rewritten to assert that nothing escapes, for five listener shapes, and that both the class and the event are named. Verified by reverting the fix: six cases fail. Refs F-13, F-14 in docs/refactor-facts.md. Co-Authored-By: Claude <noreply@anthropic.com>
docs/plugin-development.md has documented the closure form for the three
Phase 2 authentication seams since ADR 0014:
self::$HookManager->register('API_PLUGIN_ROUTES', function ($args) { ... });
register() has never accepted one. Before the previous commit it took the
server down; after it, it logs and returns. Either way the documented way to
contribute a route, a session-less page node or a login button did not work,
and nothing in fog-plugins follows the guide -- the OIDC plugin uses
registerInstalled() -- which is why nobody had noticed.
Make the documentation true rather than deleting it. A hook listener is now
either [Hook, method] or a Closure, and that needed no new activation rule,
because both shapes have an owner and the owner is what carries $active:
[$hook, 'method'] owner is $hook
a closure owner is whatever $this it was written inside, which
for one declared in a hook constructor is that hook --
ReflectionFunction::getClosureThis()
a static closure no owner, so always active; registering it is the opt-in
Anything else is still refused: a bare function name and [Class::class,
'staticMethod'] have no owner, and an owner is what a listener needs in order
to have an $active at all. The array form's instanceof Hook guard is unchanged.
Storage is unchanged for existing entries -- an array listener is stored as
the array it is today and a closure as itself -- so nothing that already
works moves. Verified that nothing outside the two manager classes reads
$data, in packages/web, packages/service or fog-plugins (F-26), so the mixed
array is not observable.
The dispatch loop now resolves owner and callable once and calls the callable,
which also fixes the argument by reference: the merged payload stays in a
variable, because only a variable can bind to a listener declaring its
parameter by reference. No shipped callback does, but a plugin is free to.
Mutation-verified: dropping the Closure branch, ignoring the closure's owner,
and inlining the merge into the call are each caught.
Refs F-14, F-25, F-26 in docs/refactor-facts.md.
Co-Authored-By: Claude <noreply@anthropic.com>
load() decided whether a non-plugin hook or event ran by scanning the file
line by line for the literal text `$active = true;`. \s? is zero-or-one, the
match was case-sensitive, and the scan could not tell a comment from code, so
what actually ran turned on a file's whitespace:
public $active = true; ran
public $active = true; (two spaces) did NOT run
public $active = true; (two after the =) did NOT run
public $active=true; ran
public $active = TRUE; did NOT run
public $active = false; with `= true;` in a
comment above it -- the obvious way to
document the toggle ran
Ask the class instead. _declaresActive() reads the declared default of $active
through ReflectionClass::getDefaultProperties(), which is what the regex was
approximating: a value assigned in a constructor is still not consulted,
exactly as before.
No shipped file changes verdict. All eleven core hooks and events declare
`public $active = false;`, and none of the 87 bundled plugin hook and event
files disagrees with the old regex either (F-12) -- though plugin files never
reached this path anyway, being merged in unconditionally further down.
Two things do change, both on purpose:
- spacing and case stop deciding anything, which is the point;
- a file declaring no $active at all now inherits Event's default of true and
runs, where the regex found no literal and skipped it. Asking the class is
the whole idea, and the class genuinely is active.
Truthiness rather than identity, so this agrees with the check
processEvent() makes at dispatch. One notion of active, read the same way at
both ends.
An unresolvable class name is skipped rather than reflected on: load() runs
inside LoadGlobals, so a ReflectionException there is a 500 rather than one
hook not starting.
Mutation-verified: restoring the regex in load(), and dropping the
class_exists guard, are each caught.
Refs F-11, F-12, F-15 in docs/refactor-facts.md.
Co-Authored-By: Claude <noreply@anthropic.com>
load() chose its file extension and directory with two sequential instanceof checks, EventManager first and HookManager second. HookManager extends EventManager, so it satisfied both, and reached .hook.php only because the second assignment overwrote the first. Reordering those two blocks would have made every HookManager load .event.php files. Nothing in PHP and nothing in the suite would have said so; the symptom would have been every hook in the system quietly not registering, which is the failure the comment above register()'s switch records happening once already during the namespace migration. A parent that has to identify its own children by instanceof, in an order that matters, is the thing to remove -- not the ordering. $fileExtension and $fileDirectory are declared on EventManager and overridden by HookManager, so each class answers for itself and load() just reads them. No behaviour change: the pair each class declares is exactly what the check order produced. Refs F-22 in docs/refactor-facts.md. Co-Authored-By: Claude <noreply@anthropic.com>
register() switched on self::shortName($this), with a case per subclass and a default arm that threw. The comment above it recorded what that shape costs: during the namespace migration every call landed on the default, so no hook and no event registered anywhere -- and because the throw is caught and logged, the application went on serving pages with every hook silently absent. A parent that enumerates its children by name has the same failure available to it every time either name changes, and it also means a subclass of either manager -- something a plugin is free to write -- registers nothing at all and is told so only in a log line. acceptListener() is the override point instead. EventManager refuses anything that is not an Event; HookManager refuses anything that is neither [Hook, method] nor a Closure. Neither knows the other exists, and a subclass inherits whichever it does not override rather than falling off the end. Also: HookManager::$data defaults to [] rather than null, so appending to it no longer relies on auto-vivification. Nothing outside the two managers reads it (F-26). No behaviour change for any valid caller. A subclass of either manager stops silently registering nothing, which is an improvement nobody can be relying on the absence of. Co-Authored-By: Claude <noreply@anthropic.com>
…wice Two problems in one method, both the same shape as bugs already fixed next door in HookManager. Nobody listening was an exception. notify() threw "Event and data are not set", caught it, logged it and returned false -- while processEvent() handles the identical condition with a bare return. It is not an error condition: of the five names core notifies, only three have a listener in any bundled plugin, and HOST_CHECKIN has never had one anywhere. So a stock server threw and logged on every host checkin, and FOGBase::log() calls logHistory(), which writes a history row once an admin is signed in. And the name bookkeeping was uncached. processEvent() keeps the hookevent name list in a static because asking per fire cost 2000 round trips on a 1000-host tasking (GH-707); notify() still ran an exists() SELECT on every call -- measured at 0.155ms of the 0.178ms a listener-less notify() costs, 87% of the work being bookkeeping for a discovery aid. Same fix, same staleness argument: nothing removes names, so at worst one redundant upsert. It also ran that query *before* the guards, so a caller passing something that was not an event name got it written to the database and was then told it was invalid. Also fixes the failure message, which carried a literal $s where a specifier was meant and so dropped the event name it was trying to report -- the same typo the register() half had. No caller can notice: the return value is unchanged (false still means nothing was notified) and no core call site reads it. Refs F-19, F-21 in docs/refactor-facts.md. Co-Authored-By: Claude <noreply@anthropic.com>
notify() is EventManager's, and it iterates listeners as objects: $element->active, then $element->onEvent(). HookManager stores listeners as [object, method] arrays and Closures. Under PHP 8 reading a property off an array is a warning that yields null, so every listener was skipped -- and the method returned TRUE, having invoked nothing. Nothing in core, packages/service or fog-plugins calls it, so the only code this reaches is third-party code whose listeners have never fired. It now says so and names processEvent(), instead of quietly reporting success. The friendlier fix -- delegate to processEvent() so the caller gets what they meant -- is deliberately not taken. The two are not two spellings of one thing: processEvent() merges an `event` key into the payload and calls a named method that can mutate its arguments through references, while notify() passes a copy to a fixed method and discards the result. Making one quietly behave as the other would blur exactly the boundary the next commit sharpens, where EventManager::register() stops accepting a Hook as an event listener. Also corrects the two existing self::log() calls in EventManager, which passed $this as $logbrow and 0 as $obj. FOGBase::log() reads neither, so this is inert today -- but it is wrong, and both sites are in a file already open. Refs F-17 in docs/refactor-facts.md. Co-Authored-By: Claude <noreply@anthropic.com>
EventManager::register() guards with `$listener instanceof Event`. Hook extends Event, so that guard -- the only type check separating the two -- accepts a hook. What follows is not theoretical. notify() then calls Event::onEvent() on it, hooks do not implement onEvent(), and the inherited default printed the event name into the response. On a page that is stray text; on a client protocol endpoint it is arbitrary output in front of a reply the fog-client parses positionally. So: refuse a Hook where an event listener is expected, and make Event::onEvent()'s default do nothing. Every bundled plugin event overrides it. lib/events/hostlist.event.php, the one core event, does not -- being inactive is the only reason the default was never reached in production. Not fixed here: making Hook extend FOGBase directly, so hooks and events are genuine peers sharing a base class rather than one being a kind of the other. That is the honest modelling change and it changes the answer to `$obj instanceof Event` for every hook in existence, which needs its own issue and its own blast-radius argument rather than riding along with a bug fix. The one-line guard closes the defect either way. Refs F-18 in docs/refactor-facts.md. Co-Authored-By: Claude <noreply@anthropic.com>
processEvent() reflected on every listener's class, took its filename, and force-set active = true whenever the path contained the substring "plugins" -- so a plugin hook declaring $active = false ran anyway. Verified by experiment, not inferred: fileitems() does filter plugin files to installed and enabled plugins before this point, but that is a different question from whether the hook's own flag is read. The reason for the net has expired. When capone was the only plugin, plugins had no hooks of their own; hooks were adopted into the plugin system by copying core's example hooks, and every core example declares $active = false. Force- truthing anything on a plugin path made a copied example work without its author noticing the flag. Plugins have shipped their own hooks for years and all 87 bundled hook and event files set $active = true, so there is nothing left to catch. It was never free either. It made $active decorative for every plugin hook, so a plugin could not turn one of its own hooks off; and being a bare stripos over the whole path, an install whose base directory contained the string would have force-activated core's hooks too. $active is an intended flag. Set it false and the hook does not run, wherever the file lives. Blast radius, stated plainly: a third-party plugin hook that declares $active = false and relied on this to run anyway stops running. Event::$active defaults to true, so a hook that simply omits the property is unaffected -- only an explicit false is, and writing false while expecting the hook to run is not a coherent intent. The per-listener ReflectionClass and getFileName() go with it; they existed only to feed the substring test. That is the whole measured reflection cost of the subsystem removed by deletion rather than by optimization. The loop also stops iterating by reference, since nothing writes through it now. docs/adr/0017 records the five decisions this work settled, four of which leave no trace in the code: the two listener shapes and how a closure gets an owner, where activation is read from, this deletion and why the net existed, that hooks and events are peers, and that notify() on a HookManager is an error. Refs F-16, F-20, F-24 in docs/refactor-facts.md. Co-Authored-By: Claude <noreply@anthropic.com>
The guide's §7 examples register closures, and until this branch register() did not accept one -- so the documented way to contribute a route, a session-less page node or a login button never worked. Now that it does, say so in §4.5 where hooks are introduced, rather than leaving the closure form to appear only in the authentication section. Also states the activation rule, which was documented nowhere: $active is inherited as true from Event, read from the declaration at load and from the property at dispatch, and a closure obeys the $active of the hook it was written inside. With a note that a hook on a plugin path used to be force-activated regardless, since anyone reading old plugin code will see hooks that set the flag and hooks that do not and wonder which mattered. And a line on the failure mode nobody expects: a registration that cannot be satisfied is logged and swallowed, so a typo in a method name costs a hook that silently never fires. Check the log before you check the event name. Refs docs/adr/0017-hook-dispatch-contract.md. Co-Authored-By: Claude <noreply@anthropic.com>
Both were written outside the tree while the work was being scoped, and both are now cited from things that are in it: docs/adr/0017 refers to F-11..F-26 by number, and nine of this branch's commit messages do the same. A reference to a file nobody else has is not a reference. docs/refactor-facts.md is the append-only ledger the modernization work keeps: one claim per entry, each with the command that proved it, and a rule that a claim you cannot write a command for belongs in a plan's INFERRED section instead. F-01..F-10 predate this branch and describe the Composer and vendor work already in the tree; F-11..F-26 are this scan. docs/hook-event-plan.md is the proposal the ADR condenses -- the defect list as found, the measurement, the alternatives, and the five decisions with what each option would have cost. Kept because the ADR states conclusions and this states the reasoning, including the parts that turned out to be wrong: the first draft leaned four times on "ADR 0013 froze the plugin ABI for all of 1.6", which is not what that ADR says and could not apply anyway to a 1.6.0 that has not been released. The two brief files that drove the work are deliberately not tracked. They are instructions to an agent, not project documentation. Co-Authored-By: Claude <noreply@anthropic.com>
mastacontrola
added a commit
that referenced
this pull request
Aug 19, 2026
`EventManager::register()` catches a bad listener, logs it and returns — except that the `catch` block interpolated `$listener[0]`. Hand it an object that is not an array and that line raises `Cannot use object of type X as array`, an `Error`, which `catch (Exception)` does not catch. Registration runs inside a hook constructor during `LoadGlobals`, so it escapes `base.inc.php`: **HTTP 500 with a zero-byte body, every entry point, every request, until the file is deleted from disk.** In a browser it is indistinguishable from an autoloader collision. An error handler must not be able to fail harder than the error it is reporting. The message it produced was no use either — the format carried a literal `$s` where a specifier was meant, and supplied seven arguments for six specifiers, so `$listener[0]`, the only field saying *which* class failed, was dropped before it was ever printed: ``` before: Could not register: Error: Class must extend hook, $s: Event, X: Class after: Could not register: Error: Class must extend hook, Event: X, Class: stdClass ``` `tests/register-failure-not-fatal.test.php` covers it — no database — and is mutation-verified: restoring `$listener[0]` fails seven cases. Suite is 11 passed, 0 failed. ## Scope Found by a scan of the whole subsystem on `working-1.6` (#1194, `docs/adr/0017-hook-dispatch-contract.md`). **This is the safety half only.** That branch also replaces the source-text activation regex with reading the property, deletes the force-activation of any listener whose file path contains the substring `plugins`, and refuses a `Hook` where an event listener is expected — all of which change *which* listeners run. Every one of those defects is present here too, but on a released line with a plugin population that is neither small nor enumerable they are a separate decision, and this PR is deliberately not it. Nothing that works today changes. The only behaviour that changes belongs to installs that are currently returning 500. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013mJVe4CpK3rRbi9H5GubXd
This was referenced Aug 19, 2026
mastacontrola
added a commit
that referenced
this pull request
Aug 19, 2026
Follow-up to #1194, which fixed one half of this and left the other half live. ## The defect `EventManager::register()` and `EventManager::notify()` both start by rejecting a `$event` that is not a string: ```php if (!is_string($event)) { throw new Exception(_('Event must be a string')); } ``` and both catches then render that same `$event` with `%s`: ```php } catch (\Exception $e) { $string = sprintf( '%s: %s: %s, %s: %s, %s: %s', _('Could not register'), _('Error'), $e->getMessage(), _('Event'), $event, // <- here _('Class'), self::_describeListener($listener) ); ``` `%s` on an object with no `__toString` is an `\Error`, and `catch (\Exception)` does not catch an `\Error`: ``` $ php -r 'try { echo sprintf("%s", new stdClass); } catch (\Exception $e) { echo "caught\n"; } catch (\Error $e) { echo "ESCAPED: ".$e->getMessage()."\n"; }' ESCAPED: Object of class stdClass could not be converted to string ``` So the handler goes fatal on precisely the input the guard exists to reject. `register()` runs from hook constructors during `LoadGlobals`, so it escapes to the top: HTTP 500 with a zero-byte body, on every entry point, until the offending file is removed from disk. This is exactly the shape #1194 fixed for the *listener* argument (F-13) — the same handler, one argument along. Fixing the listener half left this half untouched on both branches. An **array** event name renders as `"Array"` with a warning and is survivable; an **object** is the fatal case. The existing contract test covered the array only, which is why this got through. ## The fix `_describeEvent()` renders the event name the way `_describeListener()` already renders a listener — `is_string($event) ? $event : gettype($event)` — so the log line still identifies the bad call without the handler being able to fail harder than the error it is reporting. ## Verification Two cases added to `tests/hook-event-contract.test.php`, one per catch. Mutation-verified — reverting either call site to the raw `$event` fails the suite: ``` --- register catch reverted: FAIL: 2 problem(s): - register() goes fatal reporting a non-string event name - the register failure message does not say what the event name was... --- notify catch reverted: FAIL: 2 problem(s): - notify() goes fatal reporting a non-string event name: Error - the notify failure message does not say what the event name was ``` Full suite: `66 passed, 0 failed`. Recorded as F-27 in `docs/refactor-facts.md`. ## Blast radius None for plugin authors. Nothing changes about which listeners register or run, or what they receive — only the text of a diagnostic, and whether producing it can take the request down. No route change, so no OpenAPI change. The same defect is present on `dev-branch` and is fixed there in a companion PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013mJVe4CpK3rRbi9H5GubXd
mastacontrola
added a commit
that referenced
this pull request
Aug 19, 2026
…1201) Follow-up to #1196, which was the fatal-catch fix alone. **Scoped deliberately.** Every change here is wrong today, and none of them changes which listeners register, which ones fire, or what they receive. The activation regex, the path-substring force-activation and the acceptance of a `Hook` where an event listener is expected are all still here and still untouched — those decide *which* listeners run, and on a released line with a plugin population that is neither small nor enumerable that is a separate decision, not this PR. ## 1. Both error handlers went fatal on a non-string event name #1196 fixed the listener half: the catch interpolated `$listener[0]`, so an object that was not an array raised an `Error`, which `catch (Exception)` does not catch, and registration runs in a hook constructor during `LoadGlobals` — HTTP 500 with an empty body on every entry point. The event half was left live. `register()` and `notify()` both start with: ```php if (!is_string($event)) { throw new Exception(_('Event must be a string')); } ``` and both catches then render that same `$event` with `%s`: ``` $ php -r 'try { echo sprintf("%s", new stdClass); } catch (Exception $e) { echo "caught\n"; } catch (Error $e) { echo "ESCAPED: ".$e->getMessage()."\n"; }' ESCAPED: Object of class stdClass could not be converted to string ``` Same uncaught `Error`, same handler, for the one input the guard exists to reject. An **array** name renders as `"Array"` with a warning and survives; an **object** is the fatal case — which is why the array-name test in #1196 did not catch it. ## 2. `notify()` recorded the event name before validating it The `NotifyEventManager` lookup and the `NotifyEvent` save sat *above* the `try`: ```php $exists = self::getClass('NotifyEventManager')->exists($event, '', 'name'); if (!$exists) { self::getClass('NotifyEvent')->set('name', $event)->save(); } try { if (!is_string($event)) { throw new Exception(_('Event must be a string')); } ``` So a caller passing an array or an object had that value written into the discovery table, and then rejected by the guard one line later. It was also a database round trip on **every** call — `notify()` is reached from the snapin client protocol (`snapinclient.class.php`, four call sites), from `taskqueue`, and from `user.class.php` on every failed login. That is the same mistake `processEvent()` had; this is the same fix and the same cache rationale, and the docblock points at `HookManager::$knownEvents` for it. ## 3. `load()` chose its paths with two sequential `if`s ```php if ($this instanceof self) { /* event regex, dir, offset */ } if ($this instanceof HookManager) { /* hook regex, dir, offset */ } ``` `HookManager extends EventManager`, so a HookManager satisfies **both**. The hook branch was reached only because it ran second and overwrote what the event branch had just assigned — swap the two blocks and every hook silently loads as an event and finds nothing. Now one decision, most-specific first. The generalized `sprintf`s produce byte-identical output to the strings they replace: ``` event regex=#^.+/events/.*\.event\.php$# dir=/events/ strlen=-10 hook regex=#^.+/hooks/.*\.hook\.php$# dir=/hooks/ strlen=-9 orig regex=#^.+/events/.*\.event\.php$# dir=/events/ strlen=-10 orig regex=#^.+/hooks/.*\.hook\.php$# dir=/hooks/ strlen=-9 ``` ## 4. `load()` built a lookup of every declared class and threw it away ```php $decClasses = get_declared_classes(); foreach ((array)$decClasses as $key => &$classExist) { $exists[$classExist] = 1; unset($classExist); } $exists = class_exists($className, false); // <- overwrites it, unread ``` Once per hook or event file. Removed. Also fixes the malformed `notify()` format string, which carried a literal `$s` where a specifier was meant and so dropped the failing event's name from the one diagnostic it produces. ## Verification All four gated in `tests/register-failure-not-fatal.test.php` and mutation-verified — reverting any one fails the suite: ``` --- M1 register catch reverted: register() goes fatal reporting a non-string event name --- M2 notify catch reverted: notify() goes fatal reporting a non-string event name: Error --- M3 record moved back above the guard: notify() still reaches the database for a name it has already seen --- M4 load() ordering restored: load() decides between events and hooks by statement order again --- M5 dead loop restored: load() builds a lookup of every declared class again ``` Full suite: `11 passed, 0 failed`. ## Blast radius None for plugin authors. No listener shape is newly accepted or newly refused, no listener's activation changes, and no payload changes. The only externally visible differences are that a diagnostic can no longer take the request down with it, and that the `notifyEvents` table stops collecting rows for values that were never event names. No route change, so no OpenAPI change. The companion `working-1.6` PR for defect 1 is #1200; defects 2–4 were already fixed there by #1194. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013mJVe4CpK3rRbi9H5GubXd
This was referenced Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The hook system is the plugin ABI. A scan of
eventmanager,hookmanager,eventandhookfound thirteen defects; this fixes them, pins the behaviourfirst so every change is deliberate, and records the five that were decisions
in
docs/adr/0017-hook-dispatch-contract.md.The one that matters
register()'s error handler was itself fatal. It interpolated$listener[0]inside the
catchmeant to swallow a bad listener, so a Closure — or anynon-array object — raised
Error: Cannot use object of type X as array, whichcatch (\Exception)does not catch. Registration runs in a hook constructorduring
LoadGlobals, so it escapedbase.inc.php: HTTP 500, zero-byte body,every entry point, until the file was deleted from disk.
And
docs/plugin-development.mddocumented that exact shape three times, inthe §7 examples for the Phase 2 authentication seams. Nothing in
fog-pluginsfollows the guide — the real OIDC plugin uses
registerInstalled()— which iswhy it survived. Anyone writing a third-party identity provider from the docs
took their server down.
Also found
in
lib/hooks/lib/eventsare$active = falseand none matched theactivation regex. Every live listener on every install comes from a plugin.
\s?is zero-or-one, the matchwas case-sensitive, and it could not tell a comment from code, so
public $active = true;with two spaces was inactive,TRUEwas inactive,and
$active = false;with= true;in the comment above it was active.$activewas decorative for every plugin hook. Dispatch force-setactive = truefor any listener whose file path contained the substringplugins, so a plugin could not turn one of its own hooks off. Verified byexperiment, not inferred.
HookManager::notify()returnedtruehaving invoked nothing — ititerates listeners as objects while HookManager stores arrays.
Hook extends Event, soinstanceof Eventaccepted a hook as an eventlistener, and
Event::onEvent()'s default printed the event name into theresponse.
notify()treated "nobody listening" as an exception and logged it —which writes a
historyrow once an admin is signed in — on every hostcheckin, for an event nothing has ever listened to. It also ran an uncached
exists()SELECT per call: the The Scheduled Tasks feature is not functioning in version 1.6.0-beta.2167 #707 shapeprocessEvent()was fixed for.load()told the two managers apart by an ordering accident, HookManagersatisfying both
instanceofchecks.register()switched onself::shortName($this)with a throwing default— the shape whose comment records it taking every hook in the system down
once already, during the namespace migration.
What changes for plugin authors
register()now accepts a Closure as well as[Hook, 'method']. Both havean owner — for a closure, whatever
$thisit was written inside, recoveredwith
ReflectionFunction::getClosureThis()— and the owner carries$active,so admitting closures needed no new activation rule. The documented seam
examples work as written.
$activedecides, wherever the file lives.Blast radius: one thing can break — a third-party plugin hook that declares
$active = falseand relied on the force-activation to run anyway. All 87bundled hook and event files set it true, and
Event::$activedefaults totrue, so a hook that omits the property is unaffected. Only an explicitfalseis, and writingfalsewhile expecting the hook to run is not acoherent intent.
Verification
Twelve commits, tree green after each (
sh tests/run-all.sh, 66 passed).tests/hook-event-contract.test.phppins the whole contract — 684 lines, nodatabase — and was written first, asserting the broken behaviour, so every
fix shows up as a case somebody had to rewrite. Each fix is mutation-verified:
widening the activation regex, reordering the
instanceofblocks, restoringthe force-activation, making the catch safe, emptying
Event::onEvent(),dropping the Closure branch, ignoring a closure's owner, inlining the payload
merge, restoring the
notify()throw, and dropping the name cache are eachcaught.
End to end against the live 1.6 lab install with six plugins enabled (
ldap location ntfy oidc ou windowskey), nine authenticated pages: a closure ownedby an active plugin hook, a static closure and a
[$this, 'method']pair allfired on every page; neither the pair nor the closure belonging to a hook
declaring
$active = falsefired at all.Performance was measured before proposing anything, and the answer was to stop:
the whole subsystem is 1–2 ms of an 11–543 ms page render, and the
per-listener
ReflectionClasseveryone assumes is expensive totalled 14–168 µsper request. Nothing here was done for speed. The reflection is gone anyway —
it existed only to feed the path substring — and the activation scan dropped
from 400–490 µs to 77–167 µs per request as a side effect of asking the class
instead of grepping the file.
Not in this PR
HOST_IMAGE_FAILandHOST_IMAGEUP_COMPLETEhave listeners inslack,ntfyandpushbulletand are notified by nothing in core, so thosenotifications have never fired. That is making a feature work for the first
time rather than refactoring a subsystem; it needs its own issue and test.
Making
HookextendFOGBasedirectly, so hooks and events are genuine peers,is the honest modelling change and changes
$obj instanceof Eventfor everyhook — its own issue too.
Downstream
None. No route class changes, so FogApi's hardcoded class list is unaffected.
Findings are recorded as F-11 … F-26 in
docs/refactor-facts.md; the reasoning,the alternatives and what each decision would have cost are in
docs/hook-event-plan.md.🤖 Generated with Claude Code
https://claude.ai/code/session_013mJVe4CpK3rRbi9H5GubXd