Skip to content

12: Conflict resolver - #12

Open
nikolaystrikhar wants to merge 6 commits into
11-loader-load-pathfrom
12-conflict-resolver
Open

12: Conflict resolver#12
nikolaystrikhar wants to merge 6 commits into
11-loader-load-pathfrom
12-conflict-resolver

Conversation

@nikolaystrikhar

@nikolaystrikhar nikolaystrikhar commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Adds Conflict\Resolver (detect an active standalone, act on the policy), Loader::resolver(), and a plugins_loaded @1 hook ahead of the @2 load loop.

Stacked on #11.

  • Resolution requires a GET. It ran on every request type: a checkout POST became a 302 that dropped the order, wp-login.php POSTs bounced to a blank form, wp-cron.php aborted, WP-CLI ended silently. is_admin() plus wp_doing_ajax() was not enough: admin-post.php and options.php define WP_ADMIN, not DOING_AJAX.
  • The $network_wide argument is dropped. Core's default null takes both the network and blog branches; a computed true skips the blog branch, leaving a stale active_plugins entry.
  • Deactivation is silent now. Otherwise core fires the standalone's register_deactivation_hook() at plugins_loaded, where a flush_rewrite_rules() rebuilds rules before any post type is registered, 404ing every permalink.
  • The inline-update guard never fired. It compared wp_get_referer() against admin_url() with strpos(), but the referer is usually a path from _wp_http_referer, so plugins.php bulk actions missed it. It parses the URL now.
  • boot()'s too-late guard trips at the earlier of the two hook priorities. Wiring is all-or-nothing.

The resolve() test helper never asserted the halt arrived; it takes an explicit expectation now.

Verify: slic run unit — 190 tests, 314 assertions, green (1 multisite-only skip). --env multisite green. composer test:analysis[OK] No errors.

Not covered: convergence with two conflicting standalones.

Conflict resolution deactivates a plugin and ends the request, and it ran
at plugins_loaded on every request type with no gate. Known issue B logged
this as a logged-out visitor bouncing to the login screen, which is the
mildest case. The same path turns a visitor's checkout POST into a 302 that
silently drops the order, bounces a login POST back to a blank form, aborts
wp-cron before its event loop, and ends a WP-CLI command with status 0 and
no output, since header() does nothing under the CLI SAPI. Now gated on
is_admin() plus not cron, AJAX or WP-CLI -- is_admin() alone is not enough,
because admin-ajax.php and admin-post.php both define WP_ADMIN.

deactivate_plugins() is now silent and passes no $network_wide. Verified
against core: the default is null, not false, and core takes the network
branch on 'false !== $network_wide' and the blog branch on
'true !== $network_wide', so null covers both. The infinite-redirect
justification for computing the flag never existed, and passing true skips
the blog branch, stranding an entry that needs a second request and a second
deactivation hook to clear. Silent because the standalone's own deactivation
callback would otherwise run at plugins_loaded, where a routine
flush_rewrite_rules() rebuilds the rules before any post type is registered
and every custom permalink 404s. Core's automatic deactivations are silent
for the same reason.

redirect_destination() matches the screen rather than a substring of an
absolute URL. wp_get_referer() prefers the _wp_http_referer field that every
nonce-bearing admin form carries, and that holds a bare path -- so the
'never interrupt an inline update' guard missed every admin form POST, the
whole network admin, and any site behind a TLS-terminating proxy.

The shared resolve() test helper caught the halt exception without asserting
it arrived, so four tests passed whether or not the redirect happened at
all. Two network-flag tests now run against real core instead of a stub,
because that claim is the only thing the argument rested on.
The context gate let every admin form submission through: admin-post.php
and options.php define WP_ADMIN and never define DOING_AJAX, so a POST to
either was deactivated and 302'd, and the browser followed with a GET that
had no body. Resolution now requires a GET and waits for the next page view.

boot()'s too-late guard still measured against the load priority, so booting
at plugins_loaded priority 1 wired both hooks, ran the load loop, and dropped
conflict resolution without reporting anything.
…ugin

Sub_Plugin named DEACTIVATE as its own fallback, which put "which policy
applies when none is configured" in the object that only holds one
sub-plugin's config. Conflict_Policy::default() states it now.

The two fallbacks stay different, and say why: unconfigured means the
sub-plugin accepted the default, whereas an unrecognised policy is a value
nobody chose, and reading a typo as consent to deactivate is the outcome
worth refusing.

Plugin_State_Interface becomes the library's only route to WordPress's
plugin functions. Sub_Plugin was a config value object that also queried
global plugin state and required wp-admin/includes/plugin.php; Resolver
required the same file guarded on a different function. One gateway, one
include, one guard -- on deactivate_plugins, a function the library still
calls, and still not is_plugin_active, whose third-party shims would
short-circuit the require.

is_standalone_plugin_active() is gone rather than delegated: its only
caller already reaches collaborators through Loader, so forwarding would
have bought Sub_Plugin a dependency on Loader to answer a question that
was never about its configuration. is_standalone_plugin_network_active()
had no production callers at all and is deleted.

Sub_Plugin now makes no global WordPress calls beyond the defined() that
is intrinsic to it, and its tests no longer stub is_plugin_active.
Also marks two PR 7 deviations superseded: the network-active predicate has
moved off Sub_Plugin, and the include guard names deactivate_plugins now
that the old guard function is one the library no longer calls.
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.

1 participant