Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,10 @@ that drives the whole of it against a real WordPress is `tests/unit/Scenario/`.
| `src/Absorber.php` | Static facade: registration, `boot()`, the accessors, and the two notice trampolines. Holds no collaborator's state. |
| `src/Provider.php` | Binds every collaborator; the only file that names a default implementation. |
| `src/Boot/Scheduler.php` | Hook wiring and boot timing: the sequence, the priorities, and the fallback for a host that boots too late. |
| `src/Loader.php` | The load pass: the gate chain, the `require_once`, the activation callback. |
| `src/Loader.php` | The load pass: the gate chain, the `require_once`, the activation callback, and the `loaded`/`skipped` announcements. |
| `src/Sub_Plugin.php` | Value object; validates config and answers what it can without a container-bound collaborator. |
| `src/Conflict_Policy.php` | The three policy constants, `default()`, `is_valid()`. |
| `src/Skip_Reason.php` | The five reasons the `skipped` action carries, one per gate in the load pass. |
| `src/Plugin/` | `Deactivator` (turns the standalone off), `Checker` (answers whether a plugin is active in either scope, and whether it is network-active — `is_plugin_active_for_network()`, which is `false` off a network so no caller needs an `is_multisite()` guard), `Loads_Plugin_Functions` (pulls in `wp-admin/includes/plugin.php`), `Contracts\Deactivator_Interface`, `Contracts\Checker_Interface`. The only files that touch WordPress plugin functions. |
| `src/Registry/` | `Registrar` (holds registered `Sub_Plugin` objects), `Reader` (the registration buffer, drained into the registrar on the way past; the object every pass reads the registry through), `Contracts\Registrar_Interface`. |
| `src/Activator.php` | Runs a sub-plugin's activation callback once ever, recorded in one option. |
Expand Down Expand Up @@ -262,6 +263,13 @@ is_readable()`, not `file_exists()`: that last is true for a directory and for a
permission, and `require_once` fatals on both. Only the dependency gate queues a notice; an
unreadable file is a broken build in the host plugin and reports through `_doing_it_wrong()`.

**Each gate's reason is a `Skip_Reason` constant, not a constant on `Loader`.** What the five values
belong to is the `skipped` action rather than the pass that fires it: `Loader` is bound by class name
and a host may replace it outright, and a replacement announcing the same vocabulary should not have
to import the implementation it swapped out to name one — the reason `Conflict_Policy` sits at the
root rather than on `Conflict\Resolver`. Unlike `Conflict_Policy` it carries no behaviour: the library
only ever emits a reason, never receives one, so an `is_valid()` there would have no caller.

The activation callback is the last of those and runs through `Activator_Interface`, which `Loader`
takes as a constructor argument like the writer and the registry reader. Last, because a bundled
plugin is included rather
Expand Down Expand Up @@ -403,8 +411,11 @@ runnable inline as well as wirable.
`{$hook_prefix}/plugin_absorber/conflict_notice_message`,
`{$hook_prefix}/plugin_absorber/dependency_notice_message` and
`{$hook_prefix}/plugin_absorber/stranding_notice_message` (all four `Sub_Plugin`)
- Actions: `{$hook_prefix}/plugin_absorber/error` (`Traits\Reports_Errors`, from every reporting site
in the library)
- Actions: `{$hook_prefix}/plugin_absorber/loaded` and `{$hook_prefix}/plugin_absorber/skipped`
(`Loader`, one per sub-plugin the load pass finished with; the skip reasons are `Skip_Reason`
constants),
`{$hook_prefix}/plugin_absorber/error` (`Traits\Reports_Errors`, from every reporting site in the
library)
- Options: `{$option_prefix}_plugin_absorber_activations` (`Activator`),
`{$option_prefix}_plugin_absorber_notices` (`Notices\Store`)

Expand Down Expand Up @@ -529,9 +540,12 @@ against real WordPress state. `Bootstrap_Test_Case.php` is the abstract parent o
somebody else's code — `enabled`, `dependency_check`, `activation_callback`, `conflict_policy`, the
notice messages, the `should_load` filter, the bundled file a `require` runs top to bottom, the
standalone's own deactivation hook, and every listener on the actions this library fires.
`Traits\Reports_Errors` is the exception that catches its own: reporting a failure may not raise a
second one, so a throw from an `error` listener is swallowed there rather than handed back up to
the step that was already failing. The one failure none of this can catch is a re-declaration
Two of those catch their own rather than falling to the enclosing one. `Traits\Reports_Errors`
swallows what an `error` listener throws, because reporting a failure may not raise a second one.
`Loader::announce()` swallows what a `loaded` or `skipped` listener throws, because by then the
require has happened and the activation callback has run — left to the per-sub-plugin catch, a
listener's bug would be reported as the sub-plugin having been abandoned, on the channel a host
built its health check on. The one failure none of this can catch is a re-declaration
fatal, which PHP does not raise as a `Throwable`; the guard constant, checked before the require, is
what prevents that one.
- **The guard constant and the standalone basename are two separate keys.** No constant does double
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ two sub-plugins, every optional key.
releases.
- [Conflict handling][conflicts] — the policies, when they run, and the guard's limits.
- [Filters][filters] — the runtime overrides for policies and notice text.
- [Actions][actions] — the failures the library announces, and what each one carries.
- [Actions][actions] — what the library reports as it loads, skips and fails.
- [Notices][notices] — where the queue lives, who may see it, and how to render it yourself.
- [Extending][extending] — swapping out a piece of the library.
- [Tests][tests] — running the suite, the fixtures and traits it offers, and every scenario it drives
Expand Down
80 changes: 70 additions & 10 deletions docs/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,60 @@ you override. `{prefix}` is the value passed to `Config::set_hook_prefix()`.

| Action | Arguments | Fires when |
|---|---|---|
| `{prefix}/plugin_absorber/loaded` | `Sub_Plugin $sub_plugin` | A bundled file was required, and its activation callback has already run. |
| `{prefix}/plugin_absorber/skipped` | `Sub_Plugin $sub_plugin`, `string $reason` | A gate turned a sub-plugin away. |
| `{prefix}/plugin_absorber/error` | `string $message`, `Sub_Plugin\|null $sub_plugin` | Something went wrong that a developer has to fix. |

Everything announced here also goes to `_doing_it_wrong()`, which is silent unless `WP_DEBUG` is on.
This is the channel that is not — reach for it for a log line, a health check, or a support tool.
`error` also goes to `_doing_it_wrong()`, which is silent unless `WP_DEBUG` is on; the action is the
channel that is not, which is what makes it worth listening to on a production site. `loaded` and
`skipped` are announcements rather than failures and go nowhere else. Reach for any of them for a log
line, a health check, or a support tool.

## Loading

`loaded` is the answer to "is this sub-plugin here?" without a `defined()` check of your own, and it
is where code that builds on a sub-plugin belongs:

```php
add_action( 'give/plugin_absorber/loaded', function ( $sub_plugin ) {
if ( $sub_plugin->get_slug() === 'give-recurring' ) {
My_Recurring_Bridge::init();
}
} );
```

It fires **after** the activation callback, so on a first-ever load the tables and options that
callback creates are already there.

## Skip reasons

The second argument is one of five values. Compare against the constant, not the string:

| Constant | Value | Meaning |
|---|---|---|
| `Skip_Reason::DISABLED` | `disabled` | The `enabled` key, or the callable behind it, said no. |
| `Skip_Reason::ALREADY_LOADED` | `already_loaded` | The guard constant was already defined — usually a standalone copy that loaded first. |
| `Skip_Reason::DEPENDENCIES_UNMET` | `dependencies_unmet` | `dependency_check` said the requirements are not met. The one skip that also queues a [notice](notices.md). |
| `Skip_Reason::FILE_UNREADABLE` | `file_unreadable` | `bundled_plugin_file` does not name a readable file. |
| `Skip_Reason::FILTERED` | `filtered` | The [`should_load` filter](filters.md#the-load-gate) returned something falsy. |

```php
use Nexcess\PluginAbsorber\Skip_Reason;

add_action( 'give/plugin_absorber/skipped', function ( $sub_plugin, $reason ) {
if ( $reason === Skip_Reason::ALREADY_LOADED ) {
my_log( sprintf( '%s deferred to a standalone copy.', $sub_plugin->get_slug() ) );
}
}, 10, 2 );
```

The values are fixed API and will not change. New reasons may be added, so treat one you do not
recognise as a plain skip rather than as an error.

`loaded` and `skipped` do not add up to everything registered, so do not count on them to. A
sub-plugin whose `enabled`, `dependency_check` or `should_load` callable throws, or whose bundled
file throws as it is required, announces `error` and neither of these; and a `DEACTIVATE` conflict
redirects before the load pass runs at all, so on that request no sub-plugin announces anything.

## Errors

Expand All @@ -24,14 +74,24 @@ add_action( 'give/plugin_absorber/error', function ( $message, $sub_plugin ) {
}, 10, 2 );
```

**A bootstrap with no hook prefix cannot be announced.** The prefix is what names this action, so
the one failure `error` can never carry is a missing `Config::set_hook_prefix()`. That one goes to
`_doing_it_wrong()` alone.
Two things to know:

- **`file_unreadable` fires both.** A bundled file that is not there is a build to fix *and* a
sub-plugin that will not be present, so it is announced as an `error` and as a `skipped`. Listen
to both and you will see it twice.
- **A bootstrap with no hook prefix cannot be announced.** The prefix is what names these hooks, so
the one failure `error` can never carry is a missing `Config::set_hook_prefix()`. That one goes to
`_doing_it_wrong()` alone.

## Your listener cannot take the site down

`error` fires from inside `plugins_loaded`, and from inside the handlers that keep a failing
sub-plugin from white-screening the site, so a listener that throws is caught rather than allowed
out. A throw from it costs nothing at all and is itself reported through `_doing_it_wrong()`. That
is a backstop, not a licence — a listener here runs on every request the site serves, so keep it
cheap and keep it quiet.
These fire from inside `plugins_loaded` and from inside the handlers that keep a failing sub-plugin
from white-screening the site, so a listener that throws is caught rather than allowed out. A throw
from `loaded` or `skipped` costs nothing: by the time `loaded` fires the file is required and the
activation callback has run, and a `skipped` announcement is the last thing that happens to that
sub-plugin either way. The throw is reported on `error` as what it is — a listener, named by the
hook it is on — rather than as the sub-plugin having failed, so a health check watching `error` does
not read your own bug as a broken load. A throw from `error` costs nothing either and is reported
through `_doing_it_wrong()` alone, since announcing it again is how a listener that always throws
would recurse. That is a backstop, not a licence — a listener here runs on every request the site
serves, so keep it cheap and keep it quiet.
93 changes: 87 additions & 6 deletions src/Loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,13 @@ public function load_all(): void {
// A re-declaration is the one failure this cannot catch, because PHP does not raise it as
// a Throwable -- which is what the guard constant, checked before any of this, is for.
//
// Reporting the failure cannot add one of its own: report_error() swallows whatever a
// listener on the error action throws, so the announcement of a sub-plugin that died
// cannot be what kills the request.
// The loaded and skipped actions run host code too, but they catch their own throws
// rather than falling to this one: by the time `loaded` fires the require has happened,
// the guard constant is defined and the activation callback has run, so a listener's
// throw arriving here would report a sub-plugin that is loaded and healthy as one that
// was abandoned -- on the channel a host built its log line and its health check on.
// Reporting a failure cannot add one of its own either way: report_error() swallows
// whatever a listener on the error action throws.
try {
$this->load( $sub_plugin );
} catch ( Throwable $thrown ) {
Expand Down Expand Up @@ -129,6 +133,8 @@ public function load_all(): void {
*/
private function load( Sub_Plugin $sub_plugin ): void {
if ( ! $sub_plugin->is_enabled() ) {
$this->announce_skip( $sub_plugin, Skip_Reason::DISABLED );

return;
}

Expand All @@ -137,11 +143,14 @@ private function load( Sub_Plugin $sub_plugin ): void {
// means "the plugin is already running" -- warning that requirements are unmet for a
// plugin the admin can see working would be worse than useless.
if ( $sub_plugin->is_already_loaded() ) {
$this->announce_skip( $sub_plugin, Skip_Reason::ALREADY_LOADED );

return;
}

if ( ! $sub_plugin->are_dependencies_met() ) {
$this->notices->queue_dependency_notice( $sub_plugin );
$this->announce_skip( $sub_plugin, Skip_Reason::DEPENDENCIES_UNMET );

return;
}
Expand All @@ -154,9 +163,10 @@ private function load( Sub_Plugin $sub_plugin ): void {
$file = $sub_plugin->get_bundled_plugin_file();

if ( ! is_file( $file ) || ! is_readable( $file ) ) {
// The sub-plugin travels with the sentence, because this failure belongs to exactly one
// registration: a listener told only that a bundled file is missing would have to parse
// the path back out to know which of them to act on.
// Both channels, because this gate is two things at once: a build the host has to fix,
// which is what `error` carries, and a sub-plugin that is not going to be there, which is
// what anything watching `skipped` is counting. A host listening to both sees this one
// twice, and that is said plainly in the docs rather than solved by picking one.
self::report_error(
self::class,
sprintf(
Expand All @@ -166,6 +176,7 @@ private function load( Sub_Plugin $sub_plugin ): void {
),
$sub_plugin
);
$this->announce_skip( $sub_plugin, Skip_Reason::FILE_UNREADABLE );

return;
}
Expand All @@ -176,6 +187,8 @@ private function load( Sub_Plugin $sub_plugin ): void {
$should_load = apply_filters( Config::get_hook_name( 'should_load' ), true, $sub_plugin );

if ( ! $should_load ) {
$this->announce_skip( $sub_plugin, Skip_Reason::FILTERED );

return;
}

Expand All @@ -191,5 +204,73 @@ private function load( Sub_Plugin $sub_plugin ): void {
// for a sub-plugin that was skipped would be worse: the schema would appear for a plugin
// that is not loaded.
$this->activator->maybe_run( $sub_plugin );

// Behind the activation callback, not in front of it. A listener here is host code that will
// reach into the sub-plugin it was just told about, and on the first-ever load the tables and
// options that code expects are the activation callback's work -- so announcing first would
// hand a host a plugin that is loaded but not yet set up. It also keeps a throwing listener
// off the callback: from here announce() catches the throw with the require and the
// activation already done, where from in front of it the throw would skip the callback
// entirely and leave it to be retried, silently, on every request for ever.
$this->announce( Config::get_hook_name( 'loaded' ), [ $sub_plugin ], $sub_plugin );
}

/**
* Say that a gate turned this sub-plugin away, and which gate it was.
*
* The hook name is built in one place for the reason `Config` gives for owning the prefix at all.
*
* @since 1.0.0
*
* @param Sub_Plugin $sub_plugin Sub-plugin that was skipped.
* @param string $reason One of the `Skip_Reason` constants.
*
* @throws Config_Exception When no hook prefix has been set.
*
* @return void
*/
private function announce_skip( Sub_Plugin $sub_plugin, string $reason ): void {
$this->announce( Config::get_hook_name( 'skipped' ), [ $sub_plugin, $reason ], $sub_plugin );
}

/**
* Fire one lifecycle action, and keep what a listener throws out of the load pass's own report.
*
* The throw is caught here rather than a frame up, because `load_all()`'s per-sub-plugin catch
* has exactly one sentence and it is "threw while loading, so it was abandoned". For a listener
* on `loaded` that sentence is false in both halves: the require happened, the guard constant is
* defined and the activation callback has already run, so the sub-plugin is loaded and nothing
* about it was abandoned. A host listening to both channels would get `loaded` and `error` for
* the same sub-plugin in the same pass, and the log line, health check and support tool that
* `error` exists for would each read a successful load as a failed one. The same is true of a
* listener on `skipped`, which reports a skip that really did happen.
*
* So the failure is announced as what it is -- somebody's listener, named by the hook it is on --
* in the sentence `Traits\Reports_Errors` already uses for a listener on the error action. It
* still costs nothing behind it: the sub-plugin is finished with either way, and the loop moves
* on to the next.
*
* @since 1.0.0
*
* @param string $hook Fully qualified hook name.
* @param array<int,mixed> $arguments Arguments to pass to the listeners.
* @param Sub_Plugin $sub_plugin Sub-plugin the announcement is about.
*
* @return void
*/
private function announce( string $hook, array $arguments, Sub_Plugin $sub_plugin ): void {
try {
do_action_ref_array( $hook, $arguments );
} catch ( Throwable $thrown ) {
self::report_error(
self::class,
sprintf(
'A listener on %s threw, and was abandoned: %s',
$hook,
$thrown->getMessage()
),
$sub_plugin
);
}
}
}
Loading
Loading