Skip to content

Stop the error handlers going fatal on a non-string event name - #1200

Merged
mastacontrola merged 1 commit into
working-1.6from
fix-event-name-in-catch
Aug 19, 2026
Merged

Stop the error handlers going fatal on a non-string event name#1200
mastacontrola merged 1 commit into
working-1.6from
fix-event-name-in-catch

Conversation

@mastacontrola

Copy link
Copy Markdown
Member

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:

if (!is_string($event)) {
    throw new Exception(_('Event must be a string'));
}

and both catches then render that same $event with %s:

} 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.ai/code/session_013mJVe4CpK3rRbi9H5GubXd

F-13 fixed one half of this: register()'s catch interpolated $listener[0],
so a listener that was an object and not an array raised "Cannot use object
of type X as array" from inside the handler. That is an \Error, catch
(\Exception) does not catch it, and registration runs in a hook constructor
during LoadGlobals -- HTTP 500 with an empty body on every entry point.

The other half was left live. Both register() and notify() throw when $event
is not a string, and both then render that same $event with %s. On an object
with no __toString that is the same \Error, from the same handler, for the
one input the guard exists to reject. An array name only warns; an object is
fatal.

_describeEvent() renders it the way _describeListener() renders a listener.
Gated in tests/hook-event-contract.test.php; reverting either call site fails
the suite. Recorded as F-27.

Co-Authored-By: Claude <noreply@anthropic.com>
@mastacontrola
mastacontrola merged commit 436e2e8 into working-1.6 Aug 19, 2026
3 checks passed
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
@mastacontrola
mastacontrola deleted the fix-event-name-in-catch branch August 19, 2026 10:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants