Skip to content

Commit dcd5eb8

Browse files
Merge pull request #57 from stellarwp/39-registry-survives-a-collision
9 [1/4]. Keep the registry readable when one slug is registered twice
2 parents dba4bde + ee53c1c commit dcd5eb8

16 files changed

Lines changed: 335 additions & 211 deletions

File tree

AGENTS.md

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -242,12 +242,14 @@ whenever the host's bootstrap happens to run it. This is also why `Absorber::reg
242242
resolves nothing — registration at plugin-file scope is a shape a host is entitled to use, and it
243243
would otherwise register into the throwaway.
244244

245-
**A duplicate slug is `Registry\Registrar::register()`'s exception, not `Absorber::register()`'s.** What
245+
**A duplicate slug is `Registry\Registrar::register()`'s refusal, not `Absorber::register()`'s.** What
246246
`Absorber::register()` throws is config validation, from the `Sub_Plugin` constructor, in the call
247-
the host can see in its own stack trace. The buffer reaches the registrar at the first read —
248-
`plugins_loaded` priority 5 on a request that passes the gatekeeper, priority 6 otherwise — so the
249-
collision surfaces from inside a core action. Both are `Config_Exception`; only one of them can name
250-
the line the host wrote.
247+
the host can see in its own stack trace. A collision cannot be found there: the buffer reaches the
248+
registrar at the first read — `plugins_loaded` priority 5 on a request that passes the gatekeeper,
249+
priority 6 otherwise — long after both `register()` calls returned. So the registrar throws, and
250+
`Registry\Reader::flush()` catches it per entry and reports it through `_doing_it_wrong()` naming the
251+
registration that was discarded. The first registration under the slug stands, the second is dropped,
252+
and everything registered behind it still reaches the registrar.
251253

252254
**The too-late barrier measures against the first step in the sequence, not the last.**
253255
`Boot\Scheduler` compares the priority `plugins_loaded` is already dispatching against the lowest
@@ -296,12 +298,15 @@ constructed with rather than through the registrar they could resolve for themse
296298
drains the pending registrations before it reads and a registrar asked directly would miss anything
297299
registered since the last flush.
298300

299-
**Both passes also catch `Config_Exception` around that read.** A duplicate slug is only found when
300-
the buffer reaches the registrar, which is a read — long after both `register()` calls returned — and
301-
it arrives inside `plugins_loaded`, the hook that exists to prevent a fatal, so this is the last place
302-
allowed to cause one. The conflict pass needs the guard more than the load pass, not less: its request
303-
gate means the only requests reaching it are admin page views, so an escaping throw lands on exactly
304-
the screens the mistaken registration would have to be corrected from.
301+
**Neither pass guards that read, because the read no longer raises.** The one exception it used to
302+
carry was the duplicate slug, and that is now refused and reported inside `Registry\Reader::flush()`,
303+
where it is found. A guard at the read was the wrong altitude for it: the first pass to read caught
304+
it and stood down whole — the load pass loading nothing at all on the front end, the conflict pass
305+
resolving nothing in wp-admin — over a registry that was intact and readable the entire time. One
306+
mistaken registration is one sub-plugin's problem and the sub-plugins around it still have to load.
307+
What remains are the backstops that were always the right altitude for an unexpected throw: the
308+
`Throwable` catch on each `plugins_loaded` step in `Boot\Scheduler`, and the per-sub-plugin catch
309+
inside `Loader::load_all()` and `Conflict\Resolver::resolve_all()`.
305310

306311
The container is no longer the other half of that. A pass is handed a reader that already holds its
307312
registrar, so a container that cannot supply one fails while the *pass* is being built — where an

docs/configuration.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,10 @@ container that was never taught about this library. Set the container once, befo
6161
Sub-plugins load in **registration order**, so register a dependency before anything that
6262
extends it at include time, and register each slug exactly once. A config array the library
6363
cannot use throws `Config_Exception` on the spot, in the call you can see in your own stack
64-
trace; a duplicate slug is the exception that surfaces later, on `plugins_loaded`, since
65-
registrations are buffered until the first read.
64+
trace. A duplicate slug is found later, at the first read of the registry — normally on
65+
`plugins_loaded` — since registrations are buffered until then: it is refused there and reported
66+
through `_doing_it_wrong()`, the first registration under the slug stands, and the second is
67+
discarded.
6668

6769
Register unconditionally and put anything you cannot decide up front — a licence, a setting the
6870
site owner can change — in `enabled`, which is re-evaluated on every load. See

docs/recipes.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,11 @@ foreach ( $sub_plugins as $slug => $constant ) {
6565
```
6666

6767
An entry the library cannot use throws `Config_Exception` out of the `Absorber::register()` call it
68-
is in, so a typo names itself in a stack trace pointing at your loop. A duplicate `slug` surfaces
69-
later: registrations are buffered, and the collision is raised at the first read on
70-
`plugins_loaded`.
68+
is in, so a typo names itself in a stack trace pointing at your loop. A duplicate `slug` is found
69+
later, at the first read of the registry — normally on `plugins_loaded` — because registrations are
70+
buffered until then. It is refused there and reported through `_doing_it_wrong()`: the first
71+
registration under the slug stands, the second is discarded, and every other sub-plugin loads as
72+
normal.
7173

7274
## Choose a policy, and know what the site owner sees
7375

src/Absorber.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,7 @@ public static function register( array $config ): void {
139139
*
140140
* @since 1.0.0
141141
*
142-
* @throws Config_Exception When no container has been set, or two sub-plugins were registered
143-
* under one slug.
142+
* @throws Config_Exception When no container has been set, or its binding is unusable.
144143
*
145144
* @return array<string,Sub_Plugin>
146145
*/

src/Boot/Scheduler.php

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
use Nexcess\PluginAbsorber\Conflict\Contracts\Resolver_Interface;
1212
use Nexcess\PluginAbsorber\Conflict\Detector;
1313
use Nexcess\PluginAbsorber\Conflict\Gatekeeper;
14-
use Nexcess\PluginAbsorber\Exceptions\Config_Exception;
1514
use Nexcess\PluginAbsorber\Loader;
1615
use StellarWP\ContainerContract\ContainerInterface;
1716
use Throwable;
@@ -219,19 +218,6 @@ private static function resolve_conflicts( ContainerInterface $container ): void
219218
// its own cannot drop one by omission -- and asking them first means a resolver is built
220219
// only on the request that goes on to use it.
221220
$container->get( Resolver_Interface::class )->resolve_all();
222-
} catch ( Config_Exception $exception ) {
223-
// Reading the registry is where a duplicate slug surfaces, and this step reads it a
224-
// priority ahead of the load pass that has always guarded the same read. Named separately
225-
// from the catch below because it is the one failure here a developer can act on directly,
226-
// and the message says which.
227-
_doing_it_wrong(
228-
self::class,
229-
sprintf(
230-
'The registered sub-plugins could not be read, so no conflict was resolved: %s',
231-
$exception->getMessage()
232-
),
233-
'1.0.0'
234-
);
235221
} catch ( Throwable $thrown ) {
236222
// The backstop, and the promise the whole library rests on: plugins_loaded fires on every
237223
// request a site serves, so a throw out of a step is a white screen on all of them. What

src/Conflict/Rewriter.php

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,7 @@ public function __construct( Reader $registry ) {
6161
*
6262
* @param string $markup Notice markup WordPress is about to print.
6363
*
64-
* @throws Config_Exception When no hook prefix has been set, or two sub-plugins were registered
65-
* under one slug.
64+
* @throws Config_Exception When no hook prefix has been set.
6665
*
6766
* @return string
6867
*/
@@ -156,8 +155,6 @@ public function rewrite( string $markup ): string {
156155
*
157156
* @param string $basename Standalone plugin basename named by the request.
158157
*
159-
* @throws Config_Exception When two sub-plugins were registered under one slug.
160-
*
161158
* @return Sub_Plugin|null
162159
*/
163160
private function find_by_standalone_basename( string $basename ): ?Sub_Plugin {

src/Loader.php

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,6 @@ public function __construct(
6767
/**
6868
* @since 1.0.0
6969
*
70-
* @throws Config_Exception From loading a sub-plugin, which reads the hook prefix the guard
71-
* above has already established is set.
72-
*
7370
* @return void
7471
*/
7572
public function load_all(): void {
@@ -82,30 +79,11 @@ public function load_all(): void {
8279

8380
// The reader rather than the registrar directly: it drains the registrations still buffered
8481
// on the facade before it reads, and a registrar asked on its own would miss anything
85-
// registered since the last read.
86-
try {
87-
$sub_plugins = $this->registry->all();
88-
} catch ( Config_Exception $exception ) {
89-
// The flush is where a duplicate slug is caught, and reading the registrar is where a
90-
// missing container or an unusable binding is. All three are bootstrap mistakes, and
91-
// all three arrive inside plugins_loaded: letting one out would fatal every request,
92-
// front end and admin alike, and lock the developer out of the screen where the
93-
// registration could be corrected. The hook this runs on exists to prevent a fatal, so
94-
// it is the last place that may cause one -- the mistake is reported to the developer
95-
// and the load is abandoned instead.
96-
_doing_it_wrong(
97-
self::class,
98-
sprintf(
99-
'The registered sub-plugins could not be read, so none were loaded: %s',
100-
$exception->getMessage()
101-
),
102-
'1.0.0'
103-
);
104-
105-
return;
106-
}
107-
108-
foreach ( $sub_plugins as $sub_plugin ) {
82+
// registered since the last read. Unguarded, because the read answers with whatever the
83+
// registrar legitimately holds: a duplicate slug is refused and reported where it is found,
84+
// so the sub-plugins around it still reach this loop rather than a host's one mistaken
85+
// registration costing the site every bundled plugin it has.
86+
foreach ( $this->registry->all() as $sub_plugin ) {
10987
// Everything past this line is somebody else's code: the enabled and dependency_check
11088
// callables, the host's should_load filter, and the bundled file itself, which a require
11189
// runs from top to bottom. Any of it may throw, and this loop runs inside plugins_loaded

src/Registry/Reader.php

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,12 @@ public static function buffer( Sub_Plugin $sub_plugin ): void {
8282
* registered since the last read, a host registering from its own `plugins_loaded` callback
8383
* included.
8484
*
85-
* @since 1.0.0
85+
* A read always answers with what the registrar legitimately holds. A duplicate slug is refused
86+
* and reported as it drains, never raised out of here: every caller is inside `plugins_loaded`,
87+
* and one host bootstrap mistake about one sub-plugin must not stand down a pass that had every
88+
* other sub-plugin to get on with.
8689
*
87-
* @throws Config_Exception When two sub-plugins were registered under one slug.
90+
* @since 1.0.0
8891
*
8992
* @return array<string,Sub_Plugin>
9093
*/
@@ -124,16 +127,25 @@ static function ( $sub_plugin ): bool {
124127
* while this object is being built, with the registrations still buffered for the read that comes
125128
* after the host has fixed its bindings.
126129
*
127-
* That same emptying is why a duplicate slug is caught per entry rather than allowed to end the
128-
* loop. The registrar refuses the collision, and letting the throw out of the loop would leave
129-
* every sub-plugin registered *behind* the colliding one in no registrar and in no buffer — the
130-
* host would get a report naming the two that collided and silently lose the rest, on both
131-
* passes, for the rest of the process. Registering the whole batch and throwing afterwards costs
132-
* the collision nothing: it still surfaces from the read, where both passes catch it.
130+
* A collision the registrar refuses is reported here, with the discarded registration named, and
131+
* goes no further. Throwing it on made one mistaken registration decide what a whole pass did: the
132+
* first pass to read caught it and stood down — the load pass loading nothing at all on the front
133+
* end, the conflict pass resolving nothing in wp-admin — while the registry it was standing down
134+
* over was intact and readable the entire time. A slug registered twice is one sub-plugin's
135+
* problem, and the sub-plugins around it still have to load.
133136
*
134-
* @since 1.0.0
137+
* Reported as it is discovered, which is once per process and therefore once per request, since
138+
* registration runs at plugin-file scope on every one: the host sees it in the log for as long as
139+
* the duplicate exists, and the load pass does not repeat a sentence the conflict pass has
140+
* already printed a priority earlier in the same request. A registration that arrives after a
141+
* read — a host module registering from its own `plugins_loaded` callback — is checked when it
142+
* drains, so a later collision still reports.
143+
*
144+
* Every collision is reported, not just the first. They are separate mistakes naming separate
145+
* slugs, and hiding the second behind the first only means the host fixes one and gets the next
146+
* on the following request.
135147
*
136-
* @throws Config_Exception When two sub-plugins were registered under one slug.
148+
* @since 1.0.0
137149
*
138150
* @return void
139151
*/
@@ -146,25 +158,28 @@ public function flush(): void {
146158

147159
self::$pending = [];
148160

149-
// The first collision, not the last, so that a buffer containing two of them reports the one
150-
// the host wrote first and keeps reporting the same one until it is fixed. The exception is
151-
// rethrown as the registrar raised it: it names the slug and both bundled files, which is the
152-
// mistake the host has to go and correct, and what this method did with the rest of the batch
153-
// is nothing they can act on.
154-
$collision = null;
155-
156161
foreach ( $pending as $sub_plugin ) {
157162
try {
158163
$this->registrar->register( $sub_plugin );
159164
} catch ( Config_Exception $exception ) {
160-
if ( $collision === null ) {
161-
$collision = $exception;
162-
}
165+
// The registrar's own sentence, unwrapped: it names the slug and both bundled files,
166+
// which is the whole of what the host has to go and correct. One clause is added,
167+
// because the registrar refuses a registration without saying what became of it, and
168+
// what became of it is now the consequence -- the site runs one of those two files
169+
// and silently does not run the other. Every other report in this library says what
170+
// the outcome was; this one has to as well. The clause names the loser as "the
171+
// duplicate" rather than by path alone, because the two registrations may well name
172+
// the same file, and a bare path then reads as if the surviving one went too.
173+
_doing_it_wrong(
174+
self::class,
175+
sprintf(
176+
'%1$s The original registration was kept; the duplicate %2$s was discarded.',
177+
$exception->getMessage(),
178+
$sub_plugin->get_bundled_plugin_file()
179+
),
180+
'1.0.0'
181+
);
163182
}
164183
}
165-
166-
if ( $collision !== null ) {
167-
throw $collision;
168-
}
169184
}
170185
}

tests/README.md

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -644,13 +644,14 @@ sequenceDiagram
644644
```
645645

646646
**A duplicate slug is reported, and what was registered behind it still loads.**
647-
The collision is the registrar's exception and it is raised long after both
647+
The collision is the registrar's refusal and it is found long after both
648648
`Absorber::register()` calls returned, from inside `plugins_loaded` — the hook
649-
this library exists to keep a site off the floor on. Both passes guard the read,
650-
so the conflict pass reports it and the load pass, finding the buffer already
651-
drained, gets on with the load. The whole batch is registered before the
652-
collision is rethrown, which is what keeps a host from silently losing every
653-
sub-plugin it registered after the mistake.
649+
this library exists to keep a site off the floor on. So it is reported where it
650+
is found and nothing is raised out of the read: the conflict pass reports it and
651+
resolves what it has, and the load pass behind it loads the registry that read
652+
left standing. Only the colliding entry is refused, which is what keeps a host
653+
from silently losing every sub-plugin it registered after the mistake — or,
654+
back when the read still threw, every sub-plugin it registered at all.
654655

655656
```mermaid
656657
sequenceDiagram
@@ -664,8 +665,8 @@ sequenceDiagram
664665
Note over R: first read — the conflict pass, priority 5
665666
R->>Reg: A, then A again, then B
666667
Reg-->>R: the second A collides
667-
R-->>R: whole batch registered, the first collision rethrown after it
668-
Note over R: the pass reports it and abandons its own step
668+
R-->>R: the second A refused and reported; A and B kept
669+
Note over R: the pass carries on with the registry it has
669670
Note over R: second read — priority 6, buffer already drained
670671
R-->>L: A and B
671672
L->>L: both load
@@ -767,9 +768,9 @@ sequenceDiagram
767768
**The request after a deactivation does not loop.** The failure mode a merge
768769
notice queued on every request would produce: a redirect loop, or a screen
769770
reporting the same deactivation for ever. Nothing is re-registered between the
770-
two requests — a duplicate slug throws — because this is the next page view, not
771-
a second bootstrap. The second request must *not* halt, and the helper fails the
772-
test if it does.
771+
two requests — a duplicate slug is refused — because this is the next page view,
772+
not a second bootstrap. The second request must *not* halt, and the helper fails
773+
the test if it does.
773774

774775
```mermaid
775776
sequenceDiagram

tests/unit/AbsorberTest.php

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -445,10 +445,13 @@ public function test_reading_twice_does_not_register_twice(): void {
445445

446446
/**
447447
* Deferring registration moves the duplicate-slug report from the second register() call to the
448-
* first read. It still names both bundled files, which is what the host needs to find them.
448+
* first read, where it is reported rather than raised: what a host asks for here is the list of
449+
* sub-plugins, and the second registration under a slug is no reason to hand back none of them.
450+
* The report still names both bundled files, which is what the host needs to find them.
449451
*/
450452
public function test_a_duplicate_slug_is_refused_at_the_first_read(): void {
451453
$this->set_up_container();
454+
$this->expect_incorrect_usage();
452455

453456
Absorber::register( $this->sub_plugin_config( 'give-recurring' ) );
454457
Absorber::register(
@@ -459,13 +462,22 @@ public function test_a_duplicate_slug_is_refused_at_the_first_read(): void {
459462
]
460463
);
461464

462-
try {
463-
Absorber::all();
464-
$this->fail( 'Expected a Config_Exception.' );
465-
} catch ( Config_Exception $exception ) {
466-
$this->assertStringContainsString( 'give-recurring', $exception->getMessage() );
467-
$this->assertStringContainsString( '/tmp/other/other.php', $exception->getMessage() );
468-
}
465+
$all = Absorber::all();
466+
467+
$this->assertSame( [ 'give-recurring' ], array_keys( $all ) );
468+
$this->assertSame(
469+
'/tmp/give-recurring/give-recurring.php',
470+
$all['give-recurring']->get_bundled_plugin_file(),
471+
'The registration that arrived first under a slug is the one that stands.'
472+
);
473+
$this->assert_the_library_reported_incorrect_usage_saying(
474+
'Two sub-plugins are registered under the slug "give-recurring"',
475+
'The collision is what failed, and the report has to say so rather than name some other gate.'
476+
);
477+
$this->assert_the_library_reported_incorrect_usage_saying(
478+
'/tmp/other/other.php',
479+
'The report has to name the registration that lost, or the host cannot find it.'
480+
);
469481
}
470482

471483
public function test_all_is_empty_before_anything_is_registered(): void {

0 commit comments

Comments
 (0)