The plugin that killed the site on activation

Title card: The plugin that killed the site on activation

Dear reader, there is a particular flavour of dread reserved for the bug you built yourself. Not the flaky host, not some snippet you copied off a forum in 2014. Your own code. The thing you wrote to make life easier, quietly arranging for it to be much, much worse.

This is a story about an update checker that worked beautifully right up until the moment it took a site down. And the fix, when I finally found it, was one line.

A bit of background

Across the sites I look after there’s a fleet of private plugins and themes — the kind that never go anywhere near the WordPress.org repository. They still need updates though. When I ship a new version, I want every install to show that friendly “update available” nudge in the admin, same as any plugin off the .org repo.

So I built a small system around the Plugin Update Checker library (PUC, if you’ve used it). Two moving parts matter here. Each plugin, when it loads, calls a little bootstrap:

PrivateUpdateChecker::init( __FILE__ );

That registers the plugin’s slug with PUC and wires up the “go check for updates” machinery. Simple enough.

The second part is a must-use plugin. Its job is to cover the plugins and themes that aren’t active — so an admin can still see there’s an update waiting before they switch something on. It runs on the `init` hook, scans every `puc-config.json` it can find, and registers those slugs with PUC too.

You can probably already see where this is going. I could not, at the time, which is rather the point of the story.

The symptom

A site went white. Not on some page load out in the wild — on plugin activation, in the admin with `WP_DEBUG` switched on. Toggle the plugin on and the whole thing fell over with a fatal error. Toggle it off and everything was fine. That on/off cleanliness is usually a gift, because it tells you the trigger precisely. It just doesn’t tell you why.

The error, once I stopped panicking and actually read it, pointed at PUC complaining that a slug was already in use.

Already in use. By what? By me, it turns out. Twice.

The load order that does the damage

Here’s the sequence, laid out flat, because the whole bug lives in the timing:

  1. WordPress loads the active plugins. This happens *before* `init` fires.
  2. The active plugin runs `PrivateUpdateChecker::init( __FILE__ )`, which builds a PUC update checker for its slug — say `example-ssl-manager` — and, as a side effect, adds a filter named `puc_is_slug_in_use-{slug}`.
  3. WordPress fires `init`.
  4. My mu-plugin wakes up, scans the config files, and cheerfully tries to register the *same slug* a second time.
  5. PUC checks whether that slug is already taken. It is. So it calls `trigger_error( ‘slug already in use’, E_USER_ERROR )`.

And `E_USER_ERROR` isn’t a gentle notice. With debugging on, it’s fatal. The mu-plugin, the thing I wrote to be helpful, was standing on the active plugin’s foot and then reporting the collision by burning the house down.

The mu-plugin was only ever meant to handle the *inactive* ones. For anything already active, the plugin had registered itself moments earlier. The mu-plugin should have looked, seen the slug was taken, and walked on by. Instead it barged in.

The fix

PUC is actually kind enough to leave you a breadcrumb. When it builds a checker, it adds that `puc_is_slug_in_use-{slug}` filter. If the slug’s already registered, the filter returns something truthy. If it isn’t, there’s no filter and you get back your default.

So the mu-plugin just needs to ask before it acts:

// Register update checkers
foreach ( $checkers as $config ) {

// Skip if the active plugin/theme already claimed this slug.
if ( apply_filters( 'puc_is_slug_in_use-' . $config['slug'], false ) ) {
continue;
}

// ... register as normal for the inactive ones
}

That’s the whole idea. If the plugin’s active, it registered first, the filter returns truthy, and the mu-plugin skips it — exactly the behaviour I wanted all along. If the plugin’s inactive, no filter exists, the check returns `false`, and the mu-plugin registers it. Which is, again, precisely the point of the mu-plugin.

I put the same guard at the top of `init()` too, for the edge case where the hooks fire in the opposite order. Belt and suspenders. When a bug has already made you look this silly, you don’t gamble on load order being consistent.

public static function init( string $mainFile, ?string $slug = null ): void {

$slug = $slug ?? basename( dirname( $mainFile ) );

// Already registered elsewhere? Don't register twice.
if ( apply_filters( 'puc_is_slug_in_use-' . $slug, false ) ) {
return;
}

// ... carry on and build the checker
}

The part I’m quietly pleased about

Bump the mu-plugin’s version header and the auto-installer notices the change and overwrites the old copy on the server the next time an admin loads a page. So the fix propagates itself. I updated one package, and every site that pulls it gets the corrected mu-plugin without me SSHing into anything. After an afternoon of feeling thoroughly outwitted by my own architecture, that bit felt like a small win.

The lesson, such as it is

Two pieces of code that both “just register the slug” is fine until they both try it for the same slug in the same request. The bug wasn’t in PUC. It wasn’t really even in either file on its own. It was in the assumption — never written down anywhere — that only one of them would ever run for a given plugin. Timing bugs love an unstated assumption.

If you’re building anything that registers things on hooks, ask the cheap question first: has someone already done this? A truthy filter check is a lot less dramatic than a fatal error on activation.

And if the thing that bit you is a thing you built — well. At least you know exactly who to blame.

Leave a Reply

Your email address will not be published. Required fields are marked *