Skip to content

Commit f56901f

Browse files
Merge pull request #74 from stellarwp/52-registration-after-the-pass
9 [2/4]. Report a registration that arrives after the load pass
2 parents 3b66cf1 + be9c5d1 commit f56901f

3 files changed

Lines changed: 287 additions & 4 deletions

File tree

src/Boot/Scheduler.php

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,46 @@ public function wire(): void {
142142
}
143143
}
144144

145+
/**
146+
* Whether plugins_loaded has already carried the dispatch past the load pass, so that a
147+
* registration made now is one the load pass will not see.
148+
*
149+
* Here rather than in `Registry\Reader`, which is what asks. What the answer turns on is this
150+
* library's own priorities and how far the hook it lives on has got — the two facts
151+
* `wiring_window_has_closed()` weighs a few lines below, off the same measurement. A registry
152+
* that read the hook for itself would hold a second copy of a rule that moves every time a
153+
* priority here does, and the copy that was not updated would be the one a host heard from.
154+
*
155+
* Static, because registration is. `Absorber::register()` resolves nothing, so the question it
156+
* asks on the way past cannot need a container answered first.
157+
*
158+
* Measured against the load pass because that is the last step in the sequence and the last read
159+
* of the registry there is; the wiring window is measured against the first. A step added behind
160+
* the load pass is the one change that would make this number the wrong one.
161+
*
162+
* The comparison is exclusive where the wiring window's is inclusive, because the two are not
163+
* the same question. A callback appended to the priority being dispatched lands on an array the
164+
* running loop already copied, so it can never fire whatever else sits in that priority. A
165+
* registration is read by a callback already in that priority — the load pass — and whether it
166+
* has run yet is its position within the priority, which nothing exposes. Where the answer
167+
* cannot be known, this says nothing rather than warning about a sub-plugin that loaded.
168+
*
169+
* It says nothing outside the dispatch either, and that is the deliberate limit of it. Before
170+
* plugins_loaded every registration is early. After it, a host that has not booted yet is not
171+
* late — `wire()` finds the window shut and runs the whole sequence inline, and that pass reads
172+
* the buffer like any other — and nothing here can tell that host from one whose load pass ran
173+
* five priorities ago.
174+
*
175+
* @since 1.0.0
176+
*
177+
* @return bool
178+
*/
179+
public static function registration_window_has_closed(): bool {
180+
$position = self::plugins_loaded_position();
181+
182+
return $position !== null && $position > self::LOAD_PRIORITY;
183+
}
184+
145185
/**
146186
* The plugins_loaded steps, in run order, as priority and callback.
147187
*
@@ -294,9 +334,32 @@ private function wiring_window_has_closed(): bool {
294334
return true;
295335
}
296336

337+
$position = self::plugins_loaded_position();
338+
339+
return $position !== null && $position >= min( array_column( $this->sequence(), 'priority' ) );
340+
}
341+
342+
/**
343+
* The plugins_loaded priority being dispatched, or null when the hook is not dispatching at all.
344+
*
345+
* The one place this library reads how far the hook has got, so that the two windows either side
346+
* of it differ in the priority they measure and in the comparison they make, and in nothing
347+
* else. Both used to reach into `$GLOBALS['wp_filter']` for themselves, which is a second
348+
* dialect of the same reading.
349+
*
350+
* `WP_Hook::current_priority()` answers `false` while the hook is not iterating, and that
351+
* covers both "not yet" and "over" — a caller that has to tell those two apart asks
352+
* `did_action()` as well.
353+
*
354+
* @since 1.0.0
355+
*
356+
* @return int|null
357+
*/
358+
private static function plugins_loaded_position(): ?int {
297359
$hook = $GLOBALS['wp_filter']['plugins_loaded'] ?? null;
298360

299-
return $hook instanceof WP_Hook
300-
&& $hook->current_priority() >= min( array_column( $this->sequence(), 'priority' ) );
361+
$priority = $hook instanceof WP_Hook ? $hook->current_priority() : false;
362+
363+
return is_int( $priority ) ? $priority : null;
301364
}
302365
}

src/Registry/Reader.php

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
namespace Nexcess\PluginAbsorber\Registry;
99

10+
use Nexcess\PluginAbsorber\Boot\Scheduler;
1011
use Nexcess\PluginAbsorber\Exceptions\Config_Exception;
1112
use Nexcess\PluginAbsorber\Registry\Contracts\Registrar_Interface;
1213
use Nexcess\PluginAbsorber\Sub_Plugin;
@@ -19,8 +20,8 @@
1920
* container to resolve a registrar from. What has to be decided is which class that costs — and it
2021
* is this one, not the facade. Everything that reads the registry (`Conflict\Detector`,
2122
* `Conflict\Resolver`, `Loader`, and `Conflict\Rewriter`) declares this
22-
* object in its constructor, so nothing but `Absorber` itself names `Absorber`, and the dependency
23-
* between the facade and the collaborators runs one way.
23+
* object in its constructor, so no collaborator reaches the registry through `Absorber`, and the
24+
* dependency between the facade and the collaborators runs one way.
2425
*
2526
* The buffer is deliberately shared across instances. It is one process's registrations, and a second
2627
* reader holding a second, emptier list is the bug `Provider` binds every collaborator as a singleton
@@ -64,6 +65,16 @@ public function __construct( Registrar_Interface $registrar ) {
6465
* a registration that reached a registrar before that point would go into the container being
6566
* thrown away. Buffering is what lets the container arrive at any point before boot.
6667
*
68+
* A registration that arrives after the load pass has gone by is buffered like any other and
69+
* reported, because a buffer nothing reads again leaves next to nothing behind to go on: no
70+
* notice, no skip, no missing file — a sub-plugin that simply is not there. The report is a
71+
* `_doing_it_wrong()`, which is the reach every other report in this library has and no further:
72+
* it prints where a site is debugging, and fires core's `doing_it_wrong_run` wherever it is not,
73+
* for a host that listens. `Absorber::boot()` has had a barrier for the same mistake since it was
74+
* written, and boot is the call a host is *less* likely to misplace: registration is what a
75+
* service provider tends to carry, and a provider runs whenever the host's bootstrap happens to
76+
* run it.
77+
*
6778
* @since 1.0.0
6879
*
6980
* @param Sub_Plugin $sub_plugin Sub-plugin to hold.
@@ -72,6 +83,33 @@ public function __construct( Registrar_Interface $registrar ) {
7283
*/
7384
public static function buffer( Sub_Plugin $sub_plugin ): void {
7485
self::$pending[] = $sub_plugin;
86+
87+
if ( ! Scheduler::registration_window_has_closed() ) {
88+
return;
89+
}
90+
91+
// Reported, and the report is the whole of the remedy. `boot()` can offer an inline fallback
92+
// because what it was late for had not happened yet: the sequence was still there to be run
93+
// by hand. Nothing is left to run here. The load pass has been and gone, this library has
94+
// nothing further on `plugins_loaded`, and requiring the file from a registration instead
95+
// would be a load pass of one that skipped every gate the real one applies and ran behind the
96+
// conflict step that decides whether a bundled copy may load at all. It would land on top of
97+
// a standalone nobody stood down, which is the re-declaration fatal this library exists to
98+
// prevent.
99+
//
100+
// Buffered first, and buffered regardless: this is a report, not a refusal. `Absorber::all()`
101+
// still answers with the registration, and a host whose own `boot()` is late enough to run
102+
// the sequence inline reads it from there -- with a report of its own about the boot.
103+
_doing_it_wrong(
104+
self::class . '::buffer',
105+
sprintf(
106+
'Absorber::register() ran after plugins_loaded had gone past the load pass, so "%s"'
107+
. ' arrived too late to be read. Register at plugin-file scope, or no later than'
108+
. ' plugins_loaded priority 5.',
109+
$sub_plugin->get_slug()
110+
),
111+
'1.0.0'
112+
);
75113
}
76114

77115
/**

tests/unit/Registry/ReaderTest.php

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
namespace Nexcess\PluginAbsorber\Tests\Unit\Registry;
99

1010
use Codeception\TestCase\WPTestCase;
11+
use Generator;
12+
use LogicException;
1113
use Nexcess\PluginAbsorber\Absorber;
14+
use Nexcess\PluginAbsorber\Boot\Scheduler;
1215
use Nexcess\PluginAbsorber\Config;
1316
use Nexcess\PluginAbsorber\Registry\Contracts\Registrar_Interface;
1417
use Nexcess\PluginAbsorber\Registry\Reader;
@@ -19,6 +22,7 @@
1922
use Nexcess\PluginAbsorber\Tests\Support\Test_Container;
2023
use Nexcess\PluginAbsorber\Tests\Support\Traits\WithContainer;
2124
use Nexcess\PluginAbsorber\Tests\Support\Traits\WithIncorrectUsage;
25+
use ReflectionClass;
2226
use RuntimeException;
2327
use Throwable;
2428

@@ -53,6 +57,17 @@ class ReaderTest extends WPTestCase {
5357
*/
5458
private $report_recorder = null;
5559

60+
/**
61+
* plugins_loaded callbacks these tests added, as [ callback, priority ] pairs.
62+
*
63+
* Tracked so tearDown can take back exactly what a test put there. `remove_all_actions()` would
64+
* strip the hook bare instead, discarding every callback WordPress and the rest of the suite have
65+
* on it for the remainder of the process.
66+
*
67+
* @var array<int,array{0:callable,1:int}>
68+
*/
69+
private $added_actions = [];
70+
5671
public function setUp(): void {
5772
parent::setUp();
5873

@@ -62,6 +77,13 @@ public function setUp(): void {
6277
}
6378

6479
public function tearDown(): void {
80+
// In tearDown rather than at the end of a test body: a failed assertion would otherwise leave
81+
// a callback that registers a sub-plugin on plugins_loaded for the rest of the process.
82+
foreach ( $this->added_actions as [ $callback, $priority ] ) {
83+
remove_action( 'plugins_loaded', $callback, $priority );
84+
}
85+
$this->added_actions = [];
86+
6587
$this->stop_recording_reports();
6688
$this->stop_expecting_incorrect_usage();
6789
Absorber_State::reset();
@@ -332,6 +354,166 @@ static function (): Registrar_Interface {
332354
);
333355
}
334356

357+
/**
358+
* The mistake with no symptom: a registration made after the load pass has gone by is read by
359+
* nothing, so the sub-plugin is simply absent — no notice, no skip, no missing file, and nothing
360+
* for a support engineer to pull on.
361+
*
362+
* `Absorber::boot()` has had a barrier for this since it was written. Registration is the call a
363+
* host is likelier to misplace, because a service provider is where a WordPress plugin usually
364+
* puts it and a provider runs whenever the host's bootstrap happens to run it.
365+
*/
366+
public function test_a_registration_past_the_load_pass_is_reported(): void {
367+
$this->set_up_container();
368+
$this->expect_incorrect_usage();
369+
370+
$this->register_from_plugins_loaded( self::load_priority() + 1 );
371+
372+
$this->assert_the_library_reported_incorrect_usage_saying(
373+
'"give-recurring"',
374+
'The report has to name the sub-plugin that will not load, or the host cannot find it.'
375+
);
376+
$this->assert_the_library_reported_incorrect_usage_saying(
377+
'after plugins_loaded had gone past the load pass',
378+
'A registration read by nothing is what failed, and the report has to say so rather than'
379+
. ' name some other gate.'
380+
);
381+
}
382+
383+
/**
384+
* Reported, not refused. The registration is buffered like any other, so a host reading
385+
* `Absorber::all()` still sees what it registered — and a `boot()` late enough to run the
386+
* sequence inline still has something to load.
387+
*/
388+
public function test_a_registration_past_the_load_pass_is_still_buffered(): void {
389+
$this->set_up_container();
390+
$this->expect_incorrect_usage();
391+
392+
$this->register_from_plugins_loaded( self::load_priority() + 1 );
393+
394+
$this->assertSame(
395+
[ 'give-recurring' ],
396+
array_keys( $this->reader()->all() ),
397+
'The guard reports a registration; it must not throw one away.'
398+
);
399+
$this->assert_the_library_reported_incorrect_usage();
400+
}
401+
402+
/**
403+
* The other side of the barrier, and the reason it is measured where it is. A host module
404+
* registering from its own `plugins_loaded` callback at the conflict pass's priority is a
405+
* documented shape — the load pass reads a priority later and loads it — and so is a
406+
* registration in the load pass's own priority, where whether the pass has run yet is the
407+
* position within that priority and nothing exposes it.
408+
*
409+
* @dataProvider priorities_the_load_pass_may_still_read
410+
*
411+
* @param int $priority plugins_loaded priority the host registers from.
412+
*/
413+
public function test_a_registration_the_load_pass_may_still_read_is_left_alone( int $priority ): void {
414+
$this->set_up_container();
415+
$this->expect_incorrect_usage();
416+
$this->record_reports();
417+
418+
$this->register_from_plugins_loaded( $priority );
419+
420+
$this->assertSame( [], $this->reports, 'A registration this early is not a mistake to report.' );
421+
422+
// The recorder has to be shown to work, or a guard that never ran at all satisfies the
423+
// assertion above however it had behaved.
424+
$this->register_from_plugins_loaded( self::load_priority() + 1, 'give-fee-recovery' );
425+
426+
$this->assertCount(
427+
1,
428+
$this->reports,
429+
'The recorder must catch a registration that really did arrive too late.'
430+
);
431+
}
432+
433+
/**
434+
* @return Generator<string,array{0:int}>
435+
*/
436+
public static function priorities_the_load_pass_may_still_read(): Generator {
437+
yield 'while the conflict pass is dispatching' => [ self::load_priority() - 1 ];
438+
yield 'in the load pass own priority' => [ self::load_priority() ];
439+
}
440+
441+
/**
442+
* The deliberate limit of the guard: outside a `plugins_loaded` dispatch it says nothing.
443+
*
444+
* Not an oversight, and not for want of knowing the hook is over. A host that has not booted yet
445+
* is not late — `Absorber::boot()` finds the wiring window shut and runs the whole sequence
446+
* inline, and that pass reads the buffer like any other — and from a static call that resolves
447+
* nothing there is no telling that host from one whose load pass ran already. A report that
448+
* fired on both would be wrong on the shape this library documents a rescue for.
449+
*/
450+
public function test_a_registration_made_outside_the_dispatch_is_left_alone(): void {
451+
$this->set_up_container();
452+
$this->expect_incorrect_usage();
453+
$this->record_reports();
454+
455+
$this->register( 'give-recurring' );
456+
457+
$this->assertSame(
458+
[],
459+
$this->reports,
460+
'Outside the dispatch a late boot can still rescue the registration, so nothing is said.'
461+
);
462+
463+
$this->register_from_plugins_loaded( self::load_priority() + 1, 'give-fee-recovery' );
464+
465+
$this->assertCount(
466+
1,
467+
$this->reports,
468+
'The recorder must catch a registration that really did arrive too late.'
469+
);
470+
}
471+
472+
/**
473+
* The priority the load pass is wired at, read from the scheduler rather than restated, so that
474+
* "one past it" goes on meaning that if the number ever moves.
475+
*
476+
* @throws LogicException When the constant is missing or not an int, rather than registering at
477+
* priority zero and passing for the wrong reason.
478+
*
479+
* @return int
480+
*/
481+
private static function load_priority(): int {
482+
$priority = ( new ReflectionClass( Scheduler::class ) )->getConstant( 'LOAD_PRIORITY' );
483+
484+
if ( ! is_int( $priority ) ) {
485+
throw new LogicException( 'Boot\Scheduler::LOAD_PRIORITY must be an int.' );
486+
}
487+
488+
return $priority;
489+
}
490+
491+
/**
492+
* Register one sub-plugin from a `plugins_loaded` callback at the given priority, and dispatch.
493+
*
494+
* The callback comes back off the hook as soon as the dispatch is over: a test that dispatches
495+
* twice would otherwise register the same sub-plugin again on the second pass, from a priority
496+
* it is no longer about.
497+
*
498+
* @param int $priority plugins_loaded priority to register from.
499+
* @param string $slug Slug to register under.
500+
*
501+
* @return void
502+
*/
503+
private function register_from_plugins_loaded( int $priority, string $slug = 'give-recurring' ): void {
504+
$callback = function () use ( $slug ): void {
505+
$this->register( $slug );
506+
};
507+
508+
$this->added_actions[] = [ $callback, $priority ];
509+
510+
add_action( 'plugins_loaded', $callback, $priority );
511+
512+
do_action( 'plugins_loaded' );
513+
514+
remove_action( 'plugins_loaded', $callback, $priority );
515+
}
516+
335517
/**
336518
* Count the library's reports for this test, so "reported once" can be told from "reported at
337519
* every read".

0 commit comments

Comments
 (0)