Skip to content

Async actions: signed closures - #13

Merged
ogorzalka merged 1 commit into
mainfrom
feat/async-closures
Oct 7, 2026
Merged

ogorzalka merged 1 commit into
mainfrom
feat/async-closures

Conversation

@ogorzalka

Copy link
Copy Markdown
Member

Phase 2, step E: closures as asynchronous handlers.

Action::add('save_post_event', static function (int $postId) use ($endpoint): void {
    // …
})->async();

How

  • Optional dependency: laravel/serializable-closure in require-dev (tests) and suggest. Without it, a closure is rejected at registration and the message says what to install.
  • Our own signature, not the library's SerializableClosure::setSecretKey(), which is global to the process and could clash with another plugin using the library. Descriptor: closure:{hmac-sha256}:{base64(serialize(SerializableClosure))}.
  • Key: hash_hmac('sha256', 'pollora/hook:async-closures', wp_salt('auth')), or Async::useClosureKey() (the framework will pass its app key in phase 3).
  • On execution:
    • the HMAC is checked with hash_equals() before anything is unserialized;
    • an altered payload, or a key that changed (salts rotated), fails with an explicit report and pollora/async/failed, and nothing runs;
    • even with a valid signature, anything other than a SerializableClosure is refused.
  • First-class callables (strlen(...), $object->method(...), Class::method(...)) travel by name like any handler: nothing serialized, and the current code version runs.

Found by the real-WordPress tests: wp_salt() is pluggable

wp_salt() is loaded after mu-plugins and plugins, so a plugin calling ->async() on a closure while it loads had no key, and its registration failed. Now:

  • at registration, only serializability is checked (describeOrDefer()), so it still fails early on an object or generator that cannot travel, with a message that suggests static fn and IDs;
  • the closure is signed when its hook first fires (by then wp_salt() exists), then memoized in QueuedHandler;
  • reports name such a handler closure without computing its descriptor, and the loop-guard key moved inside the fallback.

Tests

  • Unit: 219 passed, 13 new or updated: signing round trip with bound variables, altered payload, rotated key, signed non-closure, malformed descriptor, missing key, unserializable closure, first-class callables, deferred signing at registration.
  • Real WordPress: 2 new, 12 in total. A closure declared by the mu-plugin runs in wp-cron.php, signed with the real salts. A payload whose signature is altered does not run and fires pollora/async/failed.
  • PHPStan level 5 and Pint pass.

@ogorzalka
ogorzalka merged commit 795afd9 into main Oct 7, 2026
5 checks passed
@ogorzalka
ogorzalka deleted the feat/async-closures branch October 7, 2026 13:27
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