Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ All notable changes to `pollora/hook` are documented in this file.
- `unique()`: merge identical triggers (same hook, same handler, same arguments) while the first one has not started running. The lock is an option that is not autoloaded, taken atomically with `add_option()`, released when the first attempt starts, and expiring after `unique(for: …)` seconds (one day by default) if never released; the daily maintenance task deletes expired locks. A lock that cannot be stored is a queuing failure, never a silent merge.
- `action-scheduler` driver, offered when a plugin bundling Action Scheduler is active and its data store is initialised (never a dependency). The payload travels whole as the action argument, the queue name becomes the group (`pollora` by default), and history is visible in Tools › Scheduled Actions. A payload too long for Action Scheduler's arguments (8,000 characters as JSON) is kept in an option that is not autoloaded and the action carries only its identifier.
- `Async::setAutoDrivers()`: the drivers `auto` tries, in order.
- Closures as asynchronous handlers, when `laravel/serializable-closure` is installed (suggested, not required). The closure is serialized with it and signed with an HMAC-SHA256 whose key derives from `wp_salt('auth')`, or is set with `Async::useClosureKey()`; the signature is checked before anything is unserialized, and an altered payload or a changed key fails with an explicit report. The closure is checked at registration but signed when its hook first fires, since `wp_salt()` is pluggable and not loaded while plugins load. A closure made from a named function or method (`strlen(...)`, `$object->method(...)`) travels by that name.
- Payloads record the driver that queued them, the queue, `asUser`, the backoff and the unique lock. These fields are optional, so payloads queued by 1.2.0 still run.

## [1.2.0] - 2026-10-07
Expand Down
4 changes: 4 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@
},
"require-dev": {
"laravel/pint": "^v1.22.1",
"laravel/serializable-closure": "^2.0",
"mockery/mockery": "^1.6",
"pestphp/pest": "^v3.8.2",
"phpstan/phpstan": "^2.1.16"
},
"suggest": {
"laravel/serializable-closure": "Queue closures as asynchronous handlers, signed with a key derived from wp_salt()."
},
"autoload": {
"psr-4": {
"Pollora\\Hook\\": "src/"
Expand Down
2 changes: 1 addition & 1 deletion src/Adapter/Out/WordPress/Action.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public function async(string|array $except = []): PendingAsync
$this->lastRegistrations = [];

$descriptor = new CallableDescriptor;
$descriptors = array_map(fn (array $registration): string => $descriptor->describe($registration['callback']), $registrations);
$descriptors = array_map(fn (array $registration): ?string => $descriptor->describeOrDefer($registration['callback']), $registrations);

$pending = new PendingAsync($this->makeSynchronous(...));

Expand Down
31 changes: 31 additions & 0 deletions src/Async/Async.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Pollora\Hook\Async\Drivers\SyncDriver;
use Pollora\Hook\Async\Drivers\WpCronDriver;
use Pollora\Hook\Async\Exceptions\DriverUnavailable;
use Pollora\Hook\Async\Exceptions\UnresolvableHandler;
use Pollora\Hook\Domain\Contract\CallbackResolverInterface;

/**
Expand Down Expand Up @@ -53,6 +54,8 @@ final class Async

private static ?CallbackResolverInterface $resolver = null;

private static ?string $closureKey = null;

private static ?ArgumentNormalizer $normalizer = null;

private static ?AsyncDispatcher $dispatcher = null;
Expand Down Expand Up @@ -163,6 +166,33 @@ public static function useCallbackResolver(?CallbackResolverInterface $resolver)
self::$runner = null;
}

/**
* Sign queued closures with this key instead of one derived from the WordPress salts.
* The framework passes its application key. Null to derive it again.
*/
public static function useClosureKey(?string $key): void
{
self::$closureKey = $key === null || $key === '' ? null : $key;
}

/**
* Key that signs queued closures.
*
* @throws UnresolvableHandler When no key was set and the WordPress salts are not available
*/
public static function closureKey(): string
{
if (self::$closureKey !== null) {
return self::$closureKey;
}

if (! function_exists('wp_salt')) {
throw UnresolvableHandler::closureKey();
}

return hash_hmac('sha256', 'pollora/hook:async-closures', wp_salt('auth'));
}

/**
* Listen to the internal hooks: execution, and recovery of lost WP-Cron events. Safe to call more than once.
*/
Expand Down Expand Up @@ -284,6 +314,7 @@ public static function flush(): void
self::$defaultDriver = null;
self::$autoDrivers = ['action-scheduler', 'wp-cron'];
self::$resolver = null;
self::$closureKey = null;
self::$normalizer = null;
self::$dispatcher = null;
self::$runner = null;
Expand Down
16 changes: 8 additions & 8 deletions src/Async/AsyncDispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@ public function __construct(
*/
public function dispatch(QueuedHandler $handler, array $arguments): void
{
if (Async::runner()->isRunning($handler->key())) {
return;
}

// Outside the fallback below: a condition that throws fails as it would synchronously
$condition = $handler->options->condition();
if ($condition instanceof \Closure && $condition(...$arguments) !== true) {
Expand All @@ -40,12 +36,16 @@ public function dispatch(QueuedHandler $handler, array $arguments): void
$uniqueKey = null;

try {
if (Async::runner()->isRunning($handler->key())) {
return;
}

[$driverName, $driver] = $this->driverFor($handler);
$normalizedArguments = $this->normalizer->normalize($arguments);

$uniqueFor = $handler->options->uniqueFor();
if ($uniqueFor !== null) {
$key = UniqueLock::key($handler->hook, $handler->descriptor, $normalizedArguments);
$key = UniqueLock::key($handler->hook, $handler->descriptor(), $normalizedArguments);

if (! UniqueLock::acquire($key, $uniqueFor)) {
return;
Expand All @@ -57,7 +57,7 @@ public function dispatch(QueuedHandler $handler, array $arguments): void
$payload = new AsyncPayload(
id: AsyncPayload::newId(),
hook: $handler->hook,
handler: $handler->descriptor,
handler: $handler->descriptor(),
priority: $handler->priority,
arguments: $normalizedArguments,
origin: self::origin(),
Expand All @@ -81,7 +81,7 @@ public function dispatch(QueuedHandler $handler, array $arguments): void
throw $throwable;
}

Async::report($throwable, ['hook' => $handler->hook, 'handler' => $handler->descriptor]);
Async::report($throwable, ['hook' => $handler->hook, 'handler' => $handler->label()]);

$origin = self::origin();
Async::runner()->call($handler->handler, $arguments, new AsyncContext(
Expand Down Expand Up @@ -151,7 +151,7 @@ private function driverFor(QueuedHandler $handler): array
throw $driverUnavailable;
}

Async::report($driverUnavailable, ['hook' => $handler->hook, 'handler' => $handler->descriptor]);
Async::report($driverUnavailable, ['hook' => $handler->hook, 'handler' => $handler->label()]);
}
}

Expand Down
117 changes: 116 additions & 1 deletion src/Async/CallableDescriptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Pollora\Hook\Async;

use Laravel\SerializableClosure\SerializableClosure;
use Pollora\Hook\Async\Exceptions\UnresolvableHandler;
use Pollora\Hook\Domain\Contract\CallbackResolverInterface;

Expand All @@ -16,9 +17,17 @@
* Descriptors:
* 'acme_sync_event' a function
* 'App\Hooks\Sync@handle' a class method, static or not
* 'closure:{hmac}:{base64}' a closure, when laravel/serializable-closure is installed
*
* A closure is serialized with laravel/serializable-closure and signed with an
* HMAC (Async::closureKey()); the signature is checked before anything is
* unserialized. A closure made from a named function or method (strlen(...),
* $object->method(...)) is described by that name instead.
*/
final readonly class CallableDescriptor
{
private const string CLOSURE_PREFIX = 'closure:';

private const string NAME_PATTERN = '/^\\\\?[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*(\\\\[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*)*$/';

public function __construct(
Expand All @@ -35,7 +44,7 @@ public function __construct(
public function describe(callable|string|array $callback): string
{
if ($callback instanceof \Closure) {
throw UnresolvableHandler::closure();
return $this->describeClosure($callback);
}

if (is_string($callback)) {
Expand All @@ -54,6 +63,29 @@ public function describe(callable|string|array $callback): string
throw UnresolvableHandler::unsupported($callback);
}

/**
* Describe a handler now, or check that a closure can be described later.
*
* A closure is signed with a key derived from the WordPress salts, which a
* plugin registering its hooks while it loads does not have yet: wp_salt()
* is pluggable. Its serialization is checked at once; it is signed when its
* hook first fires.
*
* @return string|null The descriptor, or null for a closure to describe later
*
* @throws UnresolvableHandler When the handler can never be described
*/
public function describeOrDefer(callable|string|array $callback): ?string
{
if ($callback instanceof \Closure && $this->isClosureLiteral($callback)) {
$this->serializeClosure($callback);

return null;
}

return $this->describe($callback);
}

/**
* Resolve a descriptor back to a callable.
*
Expand All @@ -64,6 +96,10 @@ public function describe(callable|string|array $callback): string
*/
public function resolve(string $descriptor): callable
{
if (str_starts_with($descriptor, self::CLOSURE_PREFIX)) {
return $this->resolveClosure($descriptor);
}

if (! str_contains($descriptor, '@')) {
if (! preg_match(self::NAME_PATTERN, $descriptor)) {
throw UnresolvableHandler::invalidDescriptor($descriptor);
Expand Down Expand Up @@ -106,6 +142,85 @@ public function resolve(string $descriptor): callable
return [$instance, $method];
}

private function describeClosure(\Closure $closure): string
{
$reflection = new \ReflectionFunction($closure);

// A first-class callable of a named function or method travels by that name
if (! $this->isClosureLiteral($closure)) {
$object = $reflection->getClosureThis();
$scope = $reflection->getClosureScopeClass();

return match (true) {
$object !== null => $this->describeMethod($object, $reflection->getName()),
$scope !== null => $this->describeMethod($scope->getName(), $reflection->getName()),
default => $this->describeString($reflection->getName()),
};
}

$encoded = $this->serializeClosure($closure);

return self::CLOSURE_PREFIX.hash_hmac('sha256', $encoded, Async::closureKey()).':'.$encoded;
}

/**
* @return string The serialized closure, base64-encoded
*/
private function serializeClosure(\Closure $closure): string
{
if (! class_exists(SerializableClosure::class)) {
throw UnresolvableHandler::closure();
}

try {
return base64_encode(serialize(new SerializableClosure($closure)));
} catch (\Throwable $throwable) {
throw UnresolvableHandler::unserializableClosure($throwable->getMessage());
}
}

/**
* Whether a closure is written as one, rather than made from a named function or method.
*/
private function isClosureLiteral(\Closure $closure): bool
{
return str_starts_with((new \ReflectionFunction($closure))->getName(), '{closure');
}

private function resolveClosure(string $descriptor): \Closure
{
$parts = explode(':', $descriptor, 3);

if (count($parts) !== 3 || ! preg_match('/^[0-9a-f]{64}$/', $parts[1])) {
throw UnresolvableHandler::invalidDescriptor(substr($descriptor, 0, 80));
}

[, $signature, $encoded] = $parts;

if (! hash_equals(hash_hmac('sha256', $encoded, Async::closureKey()), $signature)) {
throw UnresolvableHandler::invalidClosureSignature();
}

if (! class_exists(SerializableClosure::class)) {
throw UnresolvableHandler::unreadableClosure('laravel/serializable-closure is no longer installed.');
}

$serialized = base64_decode($encoded, true);

try {
// Signed by us above: the payload is the one this site queued
$closure = $serialized === false ? null : unserialize($serialized);
} catch (\Throwable $throwable) {
throw UnresolvableHandler::unreadableClosure($throwable->getMessage());
}

if (! $closure instanceof SerializableClosure) {
throw UnresolvableHandler::unreadableClosure('the payload does not hold a serialized closure.');
}

return $closure->getClosure();
}

private function describeString(string $callback): string
{
if (str_contains($callback, '::')) {
Expand Down
26 changes: 25 additions & 1 deletion src/Async/Exceptions/UnresolvableHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,31 @@ final class UnresolvableHandler extends AsyncException
{
public static function closure(): self
{
return new self('A closure cannot be made asynchronous: pass a named function or a class method.');
return new self('A closure can be made asynchronous only when laravel/serializable-closure is installed: install it, or pass a named function or a class method.');
}

public static function closureKey(): self
{
return new self('A closure cannot be made asynchronous without a signing key: WordPress salts (wp_salt()) are not available and no key was set with Async::useClosureKey().');
}

public static function unserializableClosure(string $reason): self
{
return new self(sprintf(
'This closure cannot be made asynchronous: %s. A closure carries the object it is bound to and the variables it uses; '
.'declare it static (static fn …) and pass IDs rather than objects, or use a class method.',
rtrim($reason, '.'),
));
}

public static function invalidClosureSignature(): self
{
return new self('The signature of a queued closure is invalid: the payload was altered, or the signing key (WordPress salts) changed since it was queued.');
}

public static function unreadableClosure(string $reason): self
{
return new self('A queued closure cannot be read back: '.$reason);
}

public static function anonymousClass(): self
Expand Down
Loading
Loading