diff --git a/CHANGELOG.md b/CHANGELOG.md index a0e5584..324b47b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/composer.json b/composer.json index ef56fc9..09d5a55 100644 --- a/composer.json +++ b/composer.json @@ -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/" diff --git a/src/Adapter/Out/WordPress/Action.php b/src/Adapter/Out/WordPress/Action.php index c1ae049..8f724b8 100644 --- a/src/Adapter/Out/WordPress/Action.php +++ b/src/Adapter/Out/WordPress/Action.php @@ -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(...)); diff --git a/src/Async/Async.php b/src/Async/Async.php index 17b10b1..19c11c5 100644 --- a/src/Async/Async.php +++ b/src/Async/Async.php @@ -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; /** @@ -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; @@ -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. */ @@ -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; diff --git a/src/Async/AsyncDispatcher.php b/src/Async/AsyncDispatcher.php index 989bfff..df3be89 100644 --- a/src/Async/AsyncDispatcher.php +++ b/src/Async/AsyncDispatcher.php @@ -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) { @@ -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; @@ -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(), @@ -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( @@ -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()]); } } diff --git a/src/Async/CallableDescriptor.php b/src/Async/CallableDescriptor.php index 7a560ee..8e652f5 100644 --- a/src/Async/CallableDescriptor.php +++ b/src/Async/CallableDescriptor.php @@ -4,6 +4,7 @@ namespace Pollora\Hook\Async; +use Laravel\SerializableClosure\SerializableClosure; use Pollora\Hook\Async\Exceptions\UnresolvableHandler; use Pollora\Hook\Domain\Contract\CallbackResolverInterface; @@ -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( @@ -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)) { @@ -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. * @@ -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); @@ -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, '::')) { diff --git a/src/Async/Exceptions/UnresolvableHandler.php b/src/Async/Exceptions/UnresolvableHandler.php index 403ae05..8ea7ef4 100644 --- a/src/Async/Exceptions/UnresolvableHandler.php +++ b/src/Async/Exceptions/UnresolvableHandler.php @@ -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 diff --git a/src/Async/QueuedHandler.php b/src/Async/QueuedHandler.php index 5b3549e..fb143fa 100644 --- a/src/Async/QueuedHandler.php +++ b/src/Async/QueuedHandler.php @@ -9,12 +9,12 @@ * * @internal */ -final readonly class QueuedHandler +final class QueuedHandler { /** * @param string $hook Hook the handler is registered on * @param int $priority Priority of the registration - * @param string $descriptor Handler descriptor, see CallableDescriptor + * @param string|null $descriptor Handler descriptor, see CallableDescriptor; null for a closure, signed when its hook first fires * @param callable|string|array $handler The handler as registered, run in place when queuing fails * @param int $registeredArgs Argument count of the original registration * @param int $acceptedArgs Hook arguments the handler takes, AsyncContext left out @@ -22,14 +22,14 @@ * @param callable|null $defaultCapture The handler class's public capture() method, used when capture() is not called */ public function __construct( - public string $hook, - public int $priority, - public string $descriptor, - public mixed $handler, - public int $registeredArgs, - public int $acceptedArgs, - public PendingAsync $options, - public mixed $defaultCapture = null, + public readonly string $hook, + public readonly int $priority, + private ?string $descriptor, + public readonly mixed $handler, + public readonly int $registeredArgs, + public readonly int $acceptedArgs, + public readonly PendingAsync $options, + public readonly mixed $defaultCapture = null, ) {} public function __invoke(mixed ...$arguments): void @@ -37,12 +37,30 @@ public function __invoke(mixed ...$arguments): void Async::dispatcher()->dispatch($this, $arguments); } + /** + * The handler descriptor, computed the first time it is needed. + * + * @throws Exceptions\UnresolvableHandler When a closure cannot be signed + */ + public function descriptor(): string + { + return $this->descriptor ??= (new CallableDescriptor)->describe($this->handler); + } + + /** + * Names the handler in reports, without computing its descriptor. + */ + public function label(): string + { + return $this->descriptor ?? 'closure'; + } + /** * Identifies the registration, for the loop guard. */ public function key(): string { - return self::keyFor($this->hook, $this->descriptor, $this->priority); + return self::keyFor($this->hook, $this->descriptor(), $this->priority); } public static function keyFor(string $hook, string $descriptor, int $priority): string diff --git a/tests/Unit/Async/AsyncActionTest.php b/tests/Unit/Async/AsyncActionTest.php index e42eaa1..35c95a8 100644 --- a/tests/Unit/Async/AsyncActionTest.php +++ b/tests/Unit/Async/AsyncActionTest.php @@ -88,15 +88,49 @@ expect(fn () => $this->action->async())->toThrow(LogicException::class); }); - it('rejects a closure at registration and leaves the registration untouched', function (): void { - $closure = function (int $postId): void {}; + it('signs a closure when its hook first fires, the WordPress salts not being loaded at registration', function (): void { + $this->action->add('save_post', static function (int $postId): void {})->async()->via('recording'); - expect(fn () => $this->action->add('save_post', $closure)->async())->toThrow(UnresolvableHandler::class, 'closure'); + expect($GLOBALS['wp_actions'][0]['callback'])->toBeInstanceOf(QueuedHandler::class) + ->and(fn () => wp_stub_fire('save_post', 1))->toThrow(UnresolvableHandler::class, 'without a signing key'); + + Async::useClosureKey('test-signing-key'); + wp_stub_fire('save_post', 1); + + expect($this->driver->queued[0]['payload']->handler)->toStartWith('closure:'); + }); + + it('rejects at registration a closure that cannot be serialized, leaving the registration untouched', function (): void { + $rows = (static function (): Generator { + yield 1; + })(); + $closure = static function (int $postId) use ($rows): void { + $rows->current(); + }; + + expect(fn () => $this->action->add('save_post', $closure)->async())->toThrow(UnresolvableHandler::class, 'declare it static'); expect($GLOBALS['wp_actions'])->toHaveCount(1) ->and($GLOBALS['wp_actions'][0]['callback'])->toBe($closure); }); + it('queues a closure and runs it, with its bound variables, through the sync driver', function (): void { + Async::useClosureKey('test-signing-key'); + $received = []; + $suffix = '!'; + $this->action->add('save_post', function (int $postId, AsyncContext $context) use (&$received, $suffix): void { + $received[] = $postId.$suffix.' '.$context->hook; + })->async()->via('recording'); + + wp_stub_fire('save_post', 42); + + expect($this->driver->queued[0]['payload']->handler)->toStartWith('closure:'); + $this->driver->runAll(); + + // The closure runs as a copy rebuilt from the payload: a reference bound with use (&) is not shared + expect($received)->toBe([]); + }); + it('is chained from the static facade', function (): void { expect(ActionFacade::add('save_post', [RecordingHandler::class, 'handle']))->toBeInstanceOf(Action::class); }); diff --git a/tests/Unit/Async/CallableDescriptorTest.php b/tests/Unit/Async/CallableDescriptorTest.php index 2b2e18e..ea0835a 100644 --- a/tests/Unit/Async/CallableDescriptorTest.php +++ b/tests/Unit/Async/CallableDescriptorTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Pollora\Hook\Async\Async; use Pollora\Hook\Async\CallableDescriptor; use Pollora\Hook\Async\Exceptions\UnresolvableHandler; use Pollora\Hook\Domain\Contract\CallbackResolverInterface; @@ -34,8 +35,7 @@ function pollora_test_async_handler(int $postId): void {} it('rejects a handler that has no name to be resolved by later', function (mixed $callback, string $message): void { expect(fn () => $this->descriptor->describe($callback))->toThrow(UnresolvableHandler::class, $message); })->with([ - 'closure' => [fn (): null => null, 'closure'], - 'first-class callable' => [pollora_test_async_handler(...), 'closure'], + 'closure without a signing key' => [static fn (): null => null, 'without a signing key'], 'anonymous class' => [[new class { public function handle(): void {} @@ -44,6 +44,83 @@ public function handle(): void {} ]); }); +describe('Closures', function (): void { + beforeEach(function (): void { + Async::flush(); + Async::useClosureKey('test-signing-key'); + }); + + afterEach(function (): void { + Async::flush(); + }); + + it('describes a first-class callable by the name it was made from', function (Closure $callable, string $expected): void { + expect($this->descriptor->describe($callable))->toBe($expected); + })->with([ + 'function' => [pollora_test_async_handler(...), 'pollora_test_async_handler'], + 'instance method' => [(new InvoiceHandler)->send(...), InvoiceHandler::class.'@send'], + 'static method' => [InvoiceHandler::report(...), InvoiceHandler::class.'@report'], + ]); + + it('serializes and signs a closure, then runs it back with its bound variables', function (): void { + $prefix = 'order-'; + $descriptor = $this->descriptor->describe(fn (int $id): string => $prefix.$id); + + expect($descriptor)->toMatch('/^closure:[0-9a-f]{64}:/') + ->and($this->descriptor->resolve($descriptor)(42))->toBe('order-42'); + }); + + it('refuses a closure whose payload was altered', function (): void { + $descriptor = $this->descriptor->describe(fn (): string => 'genuine'); + [$prefix, $signature, $encoded] = explode(':', $descriptor, 3); + $altered = base64_encode(str_replace('genuine', 'forged!', (string) base64_decode($encoded))); + + expect(fn () => $this->descriptor->resolve("{$prefix}:{$signature}:{$altered}")) + ->toThrow(UnresolvableHandler::class, 'signature of a queued closure is invalid'); + }); + + it('refuses a closure signed with another key', function (): void { + $descriptor = $this->descriptor->describe(fn (): string => 'genuine'); + Async::useClosureKey('rotated-key'); + + expect(fn () => $this->descriptor->resolve($descriptor)) + ->toThrow(UnresolvableHandler::class, 'signature of a queued closure is invalid'); + }); + + it('never unserializes anything but a closure, even correctly signed', function (): void { + $encoded = base64_encode(serialize(new ArrayObject)); + $descriptor = 'closure:'.hash_hmac('sha256', $encoded, Async::closureKey()).':'.$encoded; + + expect(fn () => $this->descriptor->resolve($descriptor)) + ->toThrow(UnresolvableHandler::class, 'does not hold a serialized closure'); + }); + + it('rejects a malformed closure descriptor', function (): void { + expect(fn () => $this->descriptor->resolve('closure:not-a-signature:abc')) + ->toThrow(UnresolvableHandler::class, 'not a valid handler descriptor'); + }); + + it('explains why a closure cannot be serialized', function (): void { + $rows = (static function (): Generator { + yield 1; + })(); + + $handler = static function () use ($rows): Generator { + return $rows; + }; + + expect(fn () => $this->descriptor->describe($handler)) + ->toThrow(UnresolvableHandler::class, 'declare it static (static fn …) and pass IDs rather than objects'); + }); + + it('requires a signing key', function (): void { + Async::useClosureKey(null); + + expect(fn () => $this->descriptor->describe(static fn (): null => null)) + ->toThrow(UnresolvableHandler::class, 'without a signing key'); + }); +}); + describe('resolve()', function (): void { it('resolves a function', function (): void { expect($this->descriptor->resolve('pollora_test_async_handler'))->toBe('pollora_test_async_handler'); diff --git a/tests/WordPress/AsyncWpCronTest.php b/tests/WordPress/AsyncWpCronTest.php index 39b5cf6..0626047 100644 --- a/tests/WordPress/AsyncWpCronTest.php +++ b/tests/WordPress/AsyncWpCronTest.php @@ -198,3 +198,29 @@ function fixtureRuns(): array ->and(fixtureRuns()[0]['cron'])->toBeFalse() ->and(scheduledExecutions())->toBe([]); }); + +it('runs a closure, signed with the WordPress salts, through WP-Cron', function (): void { + do_action('pollora_fixture_closure', 3); + + [$id] = scheduledExecutions()[0]['args']; + expect(json_decode(get_option(WpCronDriver::OPTION_PREFIX.$id), true)['handler'])->toStartWith('closure:'); + + runWpCron(); + + expect(fixtureRuns())->toBe([['key' => 'closure', 'id' => 3, 'cron' => true]]); +}); + +it('refuses to run a closure whose signature does not match', function (): void { + do_action('pollora_fixture_closure', 3); + [$id] = scheduledExecutions()[0]['args']; + $option = WpCronDriver::OPTION_PREFIX.$id; + $payload = json_decode(get_option($option), true); + $payload['handler'] = preg_replace('/^closure:[0-9a-f]{64}:/', 'closure:'.str_repeat('0', 64).':', $payload['handler']); + update_option($option, json_encode($payload)); + + runWpCron(); + + $runs = fixtureRuns(); + expect(array_column($runs, 'key'))->toBe(['failed']) + ->and($runs[0]['message'])->toContain('signature of a queued closure is invalid'); +}); diff --git a/tests/WordPress/Fixtures/async-actions.php b/tests/WordPress/Fixtures/async-actions.php index f7bbf42..96b9407 100644 --- a/tests/WordPress/Fixtures/async-actions.php +++ b/tests/WordPress/Fixtures/async-actions.php @@ -84,6 +84,13 @@ public function loop(int $id): void ->capture(fn (int $id): array => ['report' => str_repeat('x', 9000)]); Action::add('pollora_fixture_flaky', [PolloraHookFixture::class, 'flaky'])->async()->tries(2)->backoff(0); +// A closure, signed with a key derived from the WordPress salts +$closureLabel = 'closure'; +Action::add('pollora_fixture_closure', static function (int $id, AsyncContext $context) use ($closureLabel): void { + PolloraHookFixture::record($closureLabel, ['id' => $id, 'cron' => defined('DOING_CRON') && DOING_CRON]); +})->async(); +unset($closureLabel); + // WP-Cron unless a test chooses another driver; the POLLORA_ASYNC_DRIVER constant still comes first add_filter('pollora/hook/async_driver', fn (): string => (string) get_option(PolloraHookFixture::DRIVER, 'wp-cron'));