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
6 changes: 3 additions & 3 deletions docs/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ redirects before the load pass runs at all, so on that request no sub-plugin ann

`error` carries the sentence a developer needs, and the sub-plugin it belongs to when it belongs to
one — a duplicate slug, a broken bundled file, a sub-plugin whose own code threw, a conflict that
could not be resolved. It is `null` for a failure that belongs to no single registration: a boot
that came too late to wire, a pass that threw before it reached any sub-plugin, notices that could
not be rendered.
could not be resolved, a registration made after the load pass had gone by. It is `null` for a
failure that belongs to no single registration: a boot that came too late to wire, a pass that threw
before it reached any sub-plugin, notices that could not be rendered.

```php
add_action( 'give/plugin_absorber/error', function ( $message, $sub_plugin ) {
Expand Down
67 changes: 65 additions & 2 deletions src/Boot/Scheduler.php
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,46 @@ public function wire(): void {
}
}

/**
* Whether plugins_loaded has already carried the dispatch past the load pass, so that a
* registration made now is one the load pass will not see.
*
* Here rather than in `Registry\Reader`, which is what asks. What the answer turns on is this
* library's own priorities and how far the hook it lives on has got — the two facts
* `wiring_window_has_closed()` weighs a few lines below, off the same measurement. A registry
* that read the hook for itself would hold a second copy of a rule that moves every time a
* priority here does, and the copy that was not updated would be the one a host heard from.
*
* Static, because registration is. `Absorber::register()` resolves nothing, so the question it
* asks on the way past cannot need a container answered first.
*
* Measured against the load pass because that is the last step in the sequence and the last read
* of the registry there is; the wiring window is measured against the first. A step added behind
* the load pass is the one change that would make this number the wrong one.
*
* The comparison is exclusive where the wiring window's is inclusive, because the two are not
* the same question. A callback appended to the priority being dispatched lands on an array the
* running loop already copied, so it can never fire whatever else sits in that priority. A
* registration is read by a callback already in that priority — the load pass — and whether it
* has run yet is its position within the priority, which nothing exposes. Where the answer
* cannot be known, this says nothing rather than warning about a sub-plugin that loaded.
*
* It says nothing outside the dispatch either, and that is the deliberate limit of it. Before
* plugins_loaded every registration is early. After it, a host that has not booted yet is not
* late — `wire()` finds the window shut and runs the whole sequence inline, and that pass reads
* the buffer like any other — and nothing here can tell that host from one whose load pass ran
* five priorities ago.
*
* @since 1.0.0
*
* @return bool
*/
public static function registration_window_has_closed(): bool {
$position = self::plugins_loaded_position();

return $position !== null && $position > self::LOAD_PRIORITY;
}

/**
* The plugins_loaded steps, in run order, as priority and callback.
*
Expand Down Expand Up @@ -309,9 +349,32 @@ private function wiring_window_has_closed(): bool {
return true;
}

$position = self::plugins_loaded_position();

return $position !== null && $position >= min( array_column( $this->sequence(), 'priority' ) );
}

/**
* The plugins_loaded priority being dispatched, or null when the hook is not dispatching at all.
*
* The one place this library reads how far the hook has got, so that the two windows either side
* of it differ in the priority they measure and in the comparison they make, and in nothing
* else. Both used to reach into `$GLOBALS['wp_filter']` for themselves, which is a second
* dialect of the same reading.
*
* `WP_Hook::current_priority()` answers `false` while the hook is not iterating, and that
* covers both "not yet" and "over" — a caller that has to tell those two apart asks
* `did_action()` as well.
*
* @since 1.0.0
*
* @return int|null
*/
private static function plugins_loaded_position(): ?int {
$hook = $GLOBALS['wp_filter']['plugins_loaded'] ?? null;

return $hook instanceof WP_Hook
&& $hook->current_priority() >= min( array_column( $this->sequence(), 'priority' ) );
$priority = $hook instanceof WP_Hook ? $hook->current_priority() : false;

return is_int( $priority ) ? $priority : null;
}
}
39 changes: 37 additions & 2 deletions src/Registry/Reader.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

namespace Nexcess\PluginAbsorber\Registry;

use Nexcess\PluginAbsorber\Boot\Scheduler;
use Nexcess\PluginAbsorber\Exceptions\Config_Exception;
use Nexcess\PluginAbsorber\Registry\Contracts\Registrar_Interface;
use Nexcess\PluginAbsorber\Sub_Plugin;
Expand All @@ -20,8 +21,8 @@
* container to resolve a registrar from. What has to be decided is which class that costs — and it
* is this one, not the facade. Everything that reads the registry (`Conflict\Detector`,
* `Conflict\Resolver`, `Loader`, and `Conflict\Rewriter`) declares this
* object in its constructor, so nothing but `Absorber` itself names `Absorber`, and the dependency
* between the facade and the collaborators runs one way.
* object in its constructor, so no collaborator reaches the registry through `Absorber`, and the
* dependency between the facade and the collaborators runs one way.
*
* The buffer is deliberately shared across instances. It is one process's registrations, and a second
* reader holding a second, emptier list is the bug `Provider` binds every collaborator as a singleton
Expand Down Expand Up @@ -67,6 +68,13 @@ public function __construct( Registrar_Interface $registrar ) {
* a registration that reached a registrar before that point would go into the container being
* thrown away. Buffering is what lets the container arrive at any point before boot.
*
* A registration that arrives after the load pass has gone by is buffered like any other and
* reported, because a buffer nothing reads again is the one failure in this library with no
* symptom at all: no notice, no skip, no missing file — a sub-plugin that simply is not there.
* `Absorber::boot()` has had a barrier for the same mistake since it was written, and boot is the
* call a host is *less* likely to misplace: registration is what a service provider tends to
* carry, and a provider runs whenever the host's bootstrap happens to run it.
*
* @since 1.0.0
*
* @param Sub_Plugin $sub_plugin Sub-plugin to hold.
Expand All @@ -75,6 +83,33 @@ public function __construct( Registrar_Interface $registrar ) {
*/
public static function buffer( Sub_Plugin $sub_plugin ): void {
self::$pending[] = $sub_plugin;

if ( ! Scheduler::registration_window_has_closed() ) {
return;
}

// Reported, and the report is the whole of the remedy. `boot()` can offer an inline fallback
// because what it was late for had not happened yet: the sequence was still there to be run
// by hand. Nothing is left to run here. The load pass has been and gone, this library has
// nothing further on `plugins_loaded`, and requiring the file from a registration instead
// would be a load pass of one that skipped every gate the real one applies and ran behind the
// conflict step that decides whether a bundled copy may load at all. It would land on top of
// a standalone nobody stood down, which is the re-declaration fatal this library exists to
// prevent.
//
// Buffered first, and buffered regardless: this is a report, not a refusal. `Absorber::all()`
// still answers with the registration, and a host whose own `boot()` is late enough to run
// the sequence inline reads it from there -- with a report of its own about the boot.
self::report_error(
self::class,
sprintf(
'Absorber::register() ran after plugins_loaded had gone past the load pass, so "%s"'
. ' arrived too late to be read. Register at plugin-file scope, or no later than'
. ' plugins_loaded priority 5.',
$sub_plugin->get_slug()
),
$sub_plugin
);
}

/**
Expand Down
Loading
Loading