From 4f0247d2d8e692e05a70c8c3eadee27f1f8ec7bd Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 18 Aug 2026 13:57:51 -0600 Subject: [PATCH 1/4] Add foundation-shutdown --- README.md | 1 + composer.json | 2 + src/Shutdown/.gitattributes | 7 + .../.github/workflows/close-pull-request.yml | 13 ++ src/Shutdown/.gitignore | 2 + src/Shutdown/Contracts/Terminable.php | 11 ++ src/Shutdown/README.md | 118 ++++++++++++++ src/Shutdown/ShutdownProvider.php | 35 ++++ src/Shutdown/ShutdownRunner.php | 107 ++++++++++++ src/Shutdown/ShutdownTask.php | 17 ++ src/Shutdown/composer.json | 25 +++ .../Fixtures/Shutdown/CallbackTerminable.php | 18 ++ tests/Unit/Shutdown/ShutdownRunnerTest.php | 154 ++++++++++++++++++ .../wpunit/Shutdown/ShutdownProviderTest.php | 99 +++++++++++ 14 files changed, 609 insertions(+) create mode 100644 src/Shutdown/.gitattributes create mode 100644 src/Shutdown/.github/workflows/close-pull-request.yml create mode 100644 src/Shutdown/.gitignore create mode 100644 src/Shutdown/Contracts/Terminable.php create mode 100644 src/Shutdown/README.md create mode 100644 src/Shutdown/ShutdownProvider.php create mode 100644 src/Shutdown/ShutdownRunner.php create mode 100644 src/Shutdown/ShutdownTask.php create mode 100644 src/Shutdown/composer.json create mode 100644 tests/Support/Fixtures/Shutdown/CallbackTerminable.php create mode 100644 tests/Unit/Shutdown/ShutdownRunnerTest.php create mode 100644 tests/wpunit/Shutdown/ShutdownProviderTest.php diff --git a/README.md b/README.md index 3380a10..7595167 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Foundation is a StellarWP Composer monorepo for reusable PHP packages intended f - [stellarwp/foundation-container-wordpress](https://github.com/stellarwp/foundation-container-wordpress) - [stellarwp/foundation-pipeline](https://github.com/stellarwp/foundation-pipeline) - [stellarwp/foundation-log](https://github.com/stellarwp/foundation-log) +- [stellarwp/foundation-shutdown](https://github.com/stellarwp/foundation-shutdown) - [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli) - [stellarwp/foundation-cli](https://github.com/stellarwp/foundation-cli) diff --git a/composer.json b/composer.json index 25cd000..d6b6942 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,7 @@ "stellarwp/foundation-container-wordpress": "self.version", "stellarwp/foundation-log": "self.version", "stellarwp/foundation-pipeline": "self.version", + "stellarwp/foundation-shutdown": "self.version", "stellarwp/foundation-wpcli": "self.version" }, "minimum-stability": "dev", @@ -49,6 +50,7 @@ "StellarWP\\Foundation\\Container\\": "src/Container/", "StellarWP\\Foundation\\Log\\": "src/Log/", "StellarWP\\Foundation\\Pipeline\\": "src/Pipeline/", + "StellarWP\\Foundation\\Shutdown\\": "src/Shutdown/", "StellarWP\\Foundation\\WPCli\\": "src/WPCli/" }, "exclude-from-classmap": [ diff --git a/src/Shutdown/.gitattributes b/src/Shutdown/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/Shutdown/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/Shutdown/.github/workflows/close-pull-request.yml b/src/Shutdown/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..6bfbabe --- /dev/null +++ b/src/Shutdown/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/Shutdown/.gitignore b/src/Shutdown/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/Shutdown/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/Shutdown/Contracts/Terminable.php b/src/Shutdown/Contracts/Terminable.php new file mode 100644 index 0000000..a6ef41f --- /dev/null +++ b/src/Shutdown/Contracts/Terminable.php @@ -0,0 +1,11 @@ + [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +Run application termination work once, in a predictable order, without allowing +one failed task to prevent the remaining tasks from running. + +## Installation + +```shell +composer require stellarwp/foundation-shutdown +``` + +## Register the provider + +Register `ShutdownProvider` through your application's normal Foundation provider +list: + +```php +use StellarWP\Foundation\Shutdown\ShutdownProvider; + +private array $providers = [ + ShutdownProvider::class, +]; +``` + +The provider has no custom constructor and uses the application's existing +Foundation container and configuration. Package installation alone has no side +effects; consumers may omit this provider and construct the public runner directly +or supply their own provider. + +## Create and contribute tasks + +Termination work implements the small `Terminable` contract: + +```php +use StellarWP\Foundation\Shutdown\Contracts\Terminable; + +final class FlushTelemetry implements Terminable +{ + public function terminate(): void { + // Flush bounded application telemetry. + } +} +``` + +Contribute an application's termination work from one provider. Resolve the +concrete tasks lazily so all providers can finish registering before termination +services are constructed. Contributions must be registered before the runner is +resolved: + +```php +use lucatume\DI52\Container; +use StellarWP\Foundation\Container\Contracts\Provider; +use StellarWP\Foundation\Shutdown\ShutdownProvider as FoundationShutdownProvider; +use StellarWP\Foundation\Shutdown\ShutdownTask; + +final class ApplicationShutdownProvider extends Provider +{ + public function register(): void { + $this->container->singleton(CloseRequestLog::class); + $this->container->singleton(FlushTelemetry::class); + + $this->container->mergeArrayVar( + FoundationShutdownProvider::TASKS, + static fn (Container $container): array => [ + new ShutdownTask($container->get(CloseRequestLog::class), 10), + new ShutdownTask($container->get(FlushTelemetry::class), 100), + ] + ); + } +} +``` + +Register both providers through the application's provider list: + +```php +private array $providers = [ + FoundationShutdownProvider::class, + ApplicationShutdownProvider::class, +]; +``` + +Lower priority values run first. Tasks with the same priority retain their +registration order. + +## WordPress shutdown + +`ShutdownProvider` automatically attaches the runner to WordPress's `shutdown` +action at the latest priority. The runner is resolved lazily when the action fires, +so features may contribute tasks after the provider is registered. + +Applications that need a different lifecycle boundary may omit the default provider +and register their own provider or invoke the runner directly: + +```php +use StellarWP\Foundation\Shutdown\ShutdownRunner; + +$container->get(ShutdownRunner::class)->terminate(); +``` + +Each runner instance executes only once, including when termination is invoked +recursively. A `Throwable` from one task is isolated so later tasks still run. + +## Logging + +`ShutdownRunner` accepts an optional PSR-3 logger. When the application binds a +`Psr\Log\LoggerInterface`—including through `foundation-log`—the container injects +it automatically. Applications without a logger require no additional setup. + +The runner logs the task count and each task at `debug` level. Task failures are +logged at `error` level with the task class, priority, exception class, and code. +Logger failures are isolated so diagnostics cannot interrupt termination work. + +Framework hooks, response finishing, output-buffer management, hard task timeouts, +and asynchronous execution beyond the default WordPress shutdown action belong to +the consuming application or a dedicated framework integration. diff --git a/src/Shutdown/ShutdownProvider.php b/src/Shutdown/ShutdownProvider.php new file mode 100644 index 0000000..28d7b4f --- /dev/null +++ b/src/Shutdown/ShutdownProvider.php @@ -0,0 +1,35 @@ +container->has(self::REGISTERED)) { + return; + } + + $this->container->singleton(self::REGISTERED, true); + + $this->container->when(ShutdownRunner::class) + ->needs('$tasks') + ->give(static fn (Container $container): array => $container->getVar(self::TASKS, [])); + + $this->container->singleton(ShutdownRunner::class); + + add_action( + 'shutdown', + $this->container->callback(ShutdownRunner::class, 'terminate'), + PHP_INT_MAX + ); + } +} diff --git a/src/Shutdown/ShutdownRunner.php b/src/Shutdown/ShutdownRunner.php new file mode 100644 index 0000000..ba9d425 --- /dev/null +++ b/src/Shutdown/ShutdownRunner.php @@ -0,0 +1,107 @@ + */ + private array $tasks; + + private bool $terminated = false; + + /** + * @param array $tasks + */ + public function __construct( + array $tasks = [], + private readonly ?LoggerInterface $logger = null + ) { + foreach ($tasks as $task) { + if (! $task instanceof ShutdownTask) { + throw new InvalidArgumentException('Shutdown tasks must be instances of ShutdownTask.'); + } + } + + $this->tasks = array_values($tasks); + } + + public function terminate(): void { + if ($this->terminated) { + return; + } + + $this->terminated = true; + + $tasks = $this->orderedTasks(); + + $this->log(LogLevel::DEBUG, 'Running shutdown tasks.', [ + 'task_count' => count($tasks), + ]); + + foreach ($tasks as $task) { + $context = [ + 'task' => $task->terminable::class, + 'priority' => $task->priority, + ]; + + $this->log(LogLevel::DEBUG, 'Running shutdown task.', $context); + + try { + $task->terminable->terminate(); + } catch (Throwable $exception) { + $this->log(LogLevel::ERROR, 'Shutdown task failed.', $context + [ + 'exception' => $exception::class, + 'code' => $exception->getCode(), + ]); + } + } + } + + /** + * Logging must not interrupt application termination. + * + * @param array $context + */ + private function log(string $level, string $message, array $context = []): void { + try { + $this->logger?->log($level, $message, $context); + } catch (Throwable) { + // The remaining termination work is more important than diagnostics. + } + } + + /** + * @return list + */ + private function orderedTasks(): array { + /** @var list $indexedTasks */ + $indexedTasks = []; + + foreach ($this->tasks as $index => $task) { + $indexedTasks[] = [ + 'index' => $index, + 'task' => $task, + ]; + } + + usort( + $indexedTasks, + static fn (array $left, array $right): int => ($left['task']->priority <=> $right['task']->priority) + ?: ($left['index'] <=> $right['index']) + ); + + return array_map( + static fn (array $entry): ShutdownTask => $entry['task'], + $indexedTasks + ); + } +} diff --git a/src/Shutdown/ShutdownTask.php b/src/Shutdown/ShutdownTask.php new file mode 100644 index 0000000..39ba450 --- /dev/null +++ b/src/Shutdown/ShutdownTask.php @@ -0,0 +1,17 @@ +=8.3", + "psr/log": ">=1.0", + "stellarwp/foundation-container": "^1.4" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\Shutdown\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "1.4.x-dev" + } + } +} diff --git a/tests/Support/Fixtures/Shutdown/CallbackTerminable.php b/tests/Support/Fixtures/Shutdown/CallbackTerminable.php new file mode 100644 index 0000000..5ea7b66 --- /dev/null +++ b/tests/Support/Fixtures/Shutdown/CallbackTerminable.php @@ -0,0 +1,18 @@ +callback)(); + } +} diff --git a/tests/Unit/Shutdown/ShutdownRunnerTest.php b/tests/Unit/Shutdown/ShutdownRunnerTest.php new file mode 100644 index 0000000..daec649 --- /dev/null +++ b/tests/Unit/Shutdown/ShutdownRunnerTest.php @@ -0,0 +1,154 @@ +recordingTask($calls, 'last', 100), + $this->recordingTask($calls, 'second', 10), + $this->recordingTask($calls, 'third', 10), + $this->recordingTask($calls, 'first', 0), + ]); + + $runner->terminate(); + + $this->assertSame(['first', 'second', 'third', 'last'], $calls); + } + + public function test_it_runs_each_task_only_once(): void { + $calls = []; + $runner = new ShutdownRunner([$this->recordingTask($calls, 'task')]); + + $runner->terminate(); + $runner->terminate(); + + $this->assertSame(['task'], $calls); + } + + public function test_it_is_safe_to_invoke_recursively(): void { + $calls = []; + $runner = new ShutdownRunner(); + + $recursive = new CallbackTerminable(static function () use (&$calls, &$runner): void { + $calls[] = 'recursive'; + $runner->terminate(); + }); + + $runner = new ShutdownRunner([ + new ShutdownTask($recursive), + $this->recordingTask($calls, 'next'), + ]); + + $runner->terminate(); + + $this->assertSame(['recursive', 'next'], $calls); + } + + public function test_a_failed_task_does_not_prevent_later_tasks(): void { + $calls = []; + + $failing = new CallbackTerminable(static function () use (&$calls): void { + $calls[] = 'failed'; + + throw new Error('Expected test failure.'); + }); + + $runner = new ShutdownRunner([ + new ShutdownTask($failing), + $this->recordingTask($calls, 'completed'), + ]); + + $runner->terminate(); + + $this->assertSame(['failed', 'completed'], $calls); + } + + public function test_it_logs_task_execution_and_failures_when_a_logger_is_available(): void { + $handler = new TestHandler(); + $logger = new Logger('shutdown', [$handler]); + $failing = new CallbackTerminable(static function (): void { + throw new Error('Expected test failure.', 42); + }); + + $runner = new ShutdownRunner([ + new ShutdownTask($failing, 10), + ], $logger); + + $runner->terminate(); + + $records = $handler->getRecords(); + + $this->assertCount(3, $records); + $this->assertSame('Running shutdown tasks.', $records[0]['message']); + $this->assertSame(['task_count' => 1], $records[0]['context']); + $this->assertSame('Running shutdown task.', $records[1]['message']); + $this->assertSame([ + 'task' => CallbackTerminable::class, + 'priority' => 10, + ], $records[1]['context']); + $this->assertSame('Shutdown task failed.', $records[2]['message']); + $this->assertSame([ + 'task' => CallbackTerminable::class, + 'priority' => 10, + 'exception' => Error::class, + 'code' => 42, + ], $records[2]['context']); + } + + public function test_a_failed_logger_does_not_prevent_termination_work(): void { + $calls = []; + $logger = $this->createMock(LoggerInterface::class); + + $logger->method('log')->willThrowException(new Error('Expected logger failure.')); + + $runner = new ShutdownRunner([ + $this->recordingTask($calls, 'completed'), + ], $logger); + + $runner->terminate(); + + $this->assertSame(['completed'], $calls); + } + + public function test_it_accepts_an_empty_task_list(): void { + $runner = new ShutdownRunner(); + + $runner->terminate(); + $runner->terminate(); + + $this->addToAssertionCount(1); + } + + public function test_it_rejects_invalid_task_contributions(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Shutdown tasks must be instances of ShutdownTask.'); + + $runner = new ShutdownRunner(['invalid']); + } + + /** + * @param list $calls + */ + private function recordingTask(array &$calls, string $name, int $priority = 0): ShutdownTask { + return new ShutdownTask( + new CallbackTerminable(static function () use (&$calls, $name): void { + $calls[] = $name; + }), + $priority + ); + } +} diff --git a/tests/wpunit/Shutdown/ShutdownProviderTest.php b/tests/wpunit/Shutdown/ShutdownProviderTest.php new file mode 100644 index 0000000..6855044 --- /dev/null +++ b/tests/wpunit/Shutdown/ShutdownProviderTest.php @@ -0,0 +1,99 @@ +container = new ContainerAdapter(new DI52Container()); + $this->container->bind(Container::class, $this->container); + $this->container->singleton(Dot::class, new Dot()); + } + + protected function tearDown(): void { + if ($this->container->has(ShutdownRunner::class)) { + remove_action( + 'shutdown', + $this->container->callback(ShutdownRunner::class, 'terminate'), + PHP_INT_MAX + ); + } + + parent::tearDown(); + } + + public function test_it_registers_a_singleton_runner_with_contributed_tasks(): void { + $calls = []; + + $this->container->register(ShutdownProvider::class); + $this->container->mergeArrayVar(ShutdownProvider::TASKS, [ + new ShutdownTask(new CallbackTerminable(static function () use (&$calls): void { + $calls[] = 'terminated'; + })), + ]); + + $runner = $this->container->get(ShutdownRunner::class); + + $this->assertSame($runner, $this->container->get(ShutdownRunner::class)); + + $runner->terminate(); + + $this->assertSame(['terminated'], $calls); + } + + public function test_duplicate_provider_registration_does_not_replace_the_runner(): void { + $this->container->register(ShutdownProvider::class); + $runner = $this->container->get(ShutdownRunner::class); + + $this->container->register(ShutdownProvider::class); + + $this->assertSame($runner, $this->container->get(ShutdownRunner::class)); + } + + public function test_it_injects_a_registered_psr_logger(): void { + $handler = new TestHandler(); + + $this->container->singleton(LoggerInterface::class, new Logger('shutdown', [$handler])); + $this->container->register(ShutdownProvider::class); + + $this->container->get(ShutdownRunner::class)->terminate(); + + $this->assertTrue($handler->hasDebugThatMatches('/Running shutdown tasks\./')); + } + + public function test_it_runs_contributed_tasks_on_wordpress_shutdown(): void { + $calls = []; + + $this->container->register(ShutdownProvider::class); + $callback = $this->container->callback(ShutdownRunner::class, 'terminate'); + + $this->container->mergeArrayVar(ShutdownProvider::TASKS, [ + new ShutdownTask(new CallbackTerminable(static function () use (&$calls): void { + $calls[] = 'terminated'; + })), + ]); + + $this->assertSame(PHP_INT_MAX, has_action('shutdown', $callback)); + + $callback(); + + $this->assertSame(['terminated'], $calls); + } +} From a053cc8a8b87579eb6795b28bf7014c9047ee8d2 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 18 Aug 2026 14:04:44 -0600 Subject: [PATCH 2/4] Add foundation-shutdown to AGENTS.md --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index b07274f..7bc74ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,11 +4,12 @@ Foundation is a StellarWP Composer monorepo for reusable PHP packages intended for libraries and WordPress plugin ecosystems. -Initial packages: +Split packages: - `stellarwp/foundation-container` - `stellarwp/foundation-log` - `stellarwp/foundation-pipeline` +- `stellarwp/foundation-shutdown` - `stellarwp/foundation-wpcli` - `stellarwp/foundation-cli` From db530beb9aca7784e9add74dbced8991f4ab2565 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 18 Aug 2026 15:09:56 -0600 Subject: [PATCH 3/4] Add decorators, add fastcgi_finish_request, litespeed_finish_request in ResponseFinishingRunner --- src/Shutdown/Contracts/ShutdownRunner.php | 10 +++ src/Shutdown/README.md | 40 ++++++--- src/Shutdown/ResponseFinishingRunner.php | 43 ++++++++++ src/Shutdown/ShutdownProvider.php | 11 ++- src/Shutdown/ShutdownRunner.php | 7 +- src/Shutdown/composer.json | 2 +- .../Shutdown/finish-request-functions.php | 21 +++++ .../Shutdown/litespeed-finish-request.php | 9 ++ .../Shutdown/ResponseFinishingRunnerTest.php | 84 +++++++++++++++++++ tests/Unit/Shutdown/ShutdownRunnerTest.php | 8 +- .../wpunit/Shutdown/ShutdownProviderTest.php | 20 +++-- 11 files changed, 224 insertions(+), 31 deletions(-) create mode 100644 src/Shutdown/Contracts/ShutdownRunner.php create mode 100644 src/Shutdown/ResponseFinishingRunner.php create mode 100644 tests/Support/Fixtures/Shutdown/finish-request-functions.php create mode 100644 tests/Support/Fixtures/Shutdown/litespeed-finish-request.php create mode 100644 tests/Unit/Shutdown/ResponseFinishingRunnerTest.php diff --git a/src/Shutdown/Contracts/ShutdownRunner.php b/src/Shutdown/Contracts/ShutdownRunner.php new file mode 100644 index 0000000..d222d02 --- /dev/null +++ b/src/Shutdown/Contracts/ShutdownRunner.php @@ -0,0 +1,10 @@ +get(ShutdownRunner::class)->terminate(); ``` +Applications that omit the default provider must bind the `ShutdownRunner` contract +in their own provider or construct the concrete +`StellarWP\Foundation\Shutdown\ShutdownRunner` with their desired tasks. + Each runner instance executes only once, including when termination is invoked recursively. A `Throwable` from one task is isolated so later tasks still run. @@ -110,9 +127,10 @@ recursively. A `Throwable` from one task is isolated so later tasks still run. it automatically. Applications without a logger require no additional setup. The runner logs the task count and each task at `debug` level. Task failures are -logged at `error` level with the task class, priority, exception class, and code. -Logger failures are isolated so diagnostics cannot interrupt termination work. +logged at `error` level with the task class, priority, and actual exception so +compatible loggers retain its message and stack trace. Logger failures are isolated +so diagnostics cannot interrupt termination work. -Framework hooks, response finishing, output-buffer management, hard task timeouts, -and asynchronous execution beyond the default WordPress shutdown action belong to -the consuming application or a dedicated framework integration. +Output-buffer management, hard task timeouts, and asynchronous execution beyond +the default WordPress shutdown action belong to the consuming application or a +dedicated framework integration. diff --git a/src/Shutdown/ResponseFinishingRunner.php b/src/Shutdown/ResponseFinishingRunner.php new file mode 100644 index 0000000..49a6b39 --- /dev/null +++ b/src/Shutdown/ResponseFinishingRunner.php @@ -0,0 +1,43 @@ +terminated) { + return; + } + + $this->terminated = true; + + foreach (['fastcgi_finish_request', 'litespeed_finish_request'] as $finishRequest) { + if (! function_exists($finishRequest)) { + continue; + } + + try { + if ($finishRequest()) { + break; + } + } catch (Throwable) { + // Response finishing is best-effort and must not block termination work. + } + } + + $this->runner->terminate(); + } +} diff --git a/src/Shutdown/ShutdownProvider.php b/src/Shutdown/ShutdownProvider.php index 28d7b4f..95ecdab 100644 --- a/src/Shutdown/ShutdownProvider.php +++ b/src/Shutdown/ShutdownProvider.php @@ -3,10 +3,14 @@ namespace StellarWP\Foundation\Shutdown; use lucatume\DI52\Container; +use StellarWP\Foundation\Container\ContainerAdapter; use StellarWP\Foundation\Container\Contracts\Provider; +use StellarWP\Foundation\Shutdown\Contracts\ShutdownRunner as ShutdownRunnerContract; /** * Registers the default shutdown runner and task contribution point. + * + * @property-read ContainerAdapter $container */ final class ShutdownProvider extends Provider { @@ -24,11 +28,14 @@ public function register(): void { ->needs('$tasks') ->give(static fn (Container $container): array => $container->getVar(self::TASKS, [])); - $this->container->singleton(ShutdownRunner::class); + $this->container->singletonDecorators(ShutdownRunnerContract::class, [ + ResponseFinishingRunner::class, + ShutdownRunner::class, + ]); add_action( 'shutdown', - $this->container->callback(ShutdownRunner::class, 'terminate'), + $this->container->callback(ShutdownRunnerContract::class, 'terminate'), PHP_INT_MAX ); } diff --git a/src/Shutdown/ShutdownRunner.php b/src/Shutdown/ShutdownRunner.php index ba9d425..59ef64d 100644 --- a/src/Shutdown/ShutdownRunner.php +++ b/src/Shutdown/ShutdownRunner.php @@ -5,13 +5,13 @@ use InvalidArgumentException; use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; -use StellarWP\Foundation\Shutdown\Contracts\Terminable; +use StellarWP\Foundation\Shutdown\Contracts\ShutdownRunner as ShutdownRunnerContract; use Throwable; /** * Runs contributed termination tasks once in deterministic priority order. */ -final class ShutdownRunner implements Terminable +final class ShutdownRunner implements ShutdownRunnerContract { /** @var list */ private array $tasks; @@ -59,8 +59,7 @@ public function terminate(): void { $task->terminable->terminate(); } catch (Throwable $exception) { $this->log(LogLevel::ERROR, 'Shutdown task failed.', $context + [ - 'exception' => $exception::class, - 'code' => $exception->getCode(), + 'exception' => $exception, ]); } } diff --git a/src/Shutdown/composer.json b/src/Shutdown/composer.json index abb7190..7122744 100644 --- a/src/Shutdown/composer.json +++ b/src/Shutdown/composer.json @@ -1,7 +1,7 @@ { "name": "stellarwp/foundation-shutdown", "type": "library", - "description": "Foundation Shutdown package.", + "description": "Run application shutdown tasks once in deterministic priority order.", "license": "GPL-2.0-or-later", "config": { "vendor-dir": "vendor", diff --git a/tests/Support/Fixtures/Shutdown/finish-request-functions.php b/tests/Support/Fixtures/Shutdown/finish-request-functions.php new file mode 100644 index 0000000..9ef3f06 --- /dev/null +++ b/tests/Support/Fixtures/Shutdown/finish-request-functions.php @@ -0,0 +1,21 @@ +runner(); + + $runner->terminate(); + $runner->terminate(); + + $this->assertSame(['litespeed', 'task'], $GLOBALS['foundation_shutdown_calls']); + + unset($GLOBALS['foundation_shutdown_calls']); + } + + #[RunInSeparateProcess] + public function test_it_prefers_fastcgi_response_finishing(): void { + require dirname(__DIR__, 2) . '/Support/Fixtures/Shutdown/finish-request-functions.php'; + + $GLOBALS['foundation_shutdown_calls'] = []; + + $this->runner()->terminate(); + + $this->assertSame(['fastcgi', 'task'], $GLOBALS['foundation_shutdown_calls']); + + unset($GLOBALS['foundation_shutdown_calls']); + } + + #[RunInSeparateProcess] + public function test_a_response_finishing_failure_does_not_prevent_shutdown_tasks(): void { + require dirname(__DIR__, 2) . '/Support/Fixtures/Shutdown/finish-request-functions.php'; + + $GLOBALS['foundation_shutdown_calls'] = []; + $GLOBALS['foundation_shutdown_fastcgi_failure'] = true; + + $this->runner()->terminate(); + + $this->assertSame(['fastcgi', 'litespeed', 'task'], $GLOBALS['foundation_shutdown_calls']); + + unset( + $GLOBALS['foundation_shutdown_calls'], + $GLOBALS['foundation_shutdown_fastcgi_failure'] + ); + } + + #[RunInSeparateProcess] + public function test_it_falls_back_when_fastcgi_does_not_finish_the_response(): void { + require dirname(__DIR__, 2) . '/Support/Fixtures/Shutdown/finish-request-functions.php'; + + $GLOBALS['foundation_shutdown_calls'] = []; + $GLOBALS['foundation_shutdown_fastcgi_false'] = true; + + $this->runner()->terminate(); + + $this->assertSame(['fastcgi', 'litespeed', 'task'], $GLOBALS['foundation_shutdown_calls']); + + unset( + $GLOBALS['foundation_shutdown_calls'], + $GLOBALS['foundation_shutdown_fastcgi_false'] + ); + } + + private function runner(): ResponseFinishingRunner { + return new ResponseFinishingRunner(new ShutdownRunner([ + new ShutdownTask(new CallbackTerminable(static function (): void { + $GLOBALS['foundation_shutdown_calls'][] = 'task'; + })), + ])); + } +} diff --git a/tests/Unit/Shutdown/ShutdownRunnerTest.php b/tests/Unit/Shutdown/ShutdownRunnerTest.php index daec649..fd77909 100644 --- a/tests/Unit/Shutdown/ShutdownRunnerTest.php +++ b/tests/Unit/Shutdown/ShutdownRunnerTest.php @@ -80,8 +80,9 @@ public function test_a_failed_task_does_not_prevent_later_tasks(): void { public function test_it_logs_task_execution_and_failures_when_a_logger_is_available(): void { $handler = new TestHandler(); $logger = new Logger('shutdown', [$handler]); - $failing = new CallbackTerminable(static function (): void { - throw new Error('Expected test failure.', 42); + $failure = new Error('Expected test failure.', 42); + $failing = new CallbackTerminable(static function () use ($failure): void { + throw $failure; }); $runner = new ShutdownRunner([ @@ -104,8 +105,7 @@ public function test_it_logs_task_execution_and_failures_when_a_logger_is_availa $this->assertSame([ 'task' => CallbackTerminable::class, 'priority' => 10, - 'exception' => Error::class, - 'code' => 42, + 'exception' => $failure, ], $records[2]['context']); } diff --git a/tests/wpunit/Shutdown/ShutdownProviderTest.php b/tests/wpunit/Shutdown/ShutdownProviderTest.php index 6855044..53690b3 100644 --- a/tests/wpunit/Shutdown/ShutdownProviderTest.php +++ b/tests/wpunit/Shutdown/ShutdownProviderTest.php @@ -9,8 +9,9 @@ use Psr\Log\LoggerInterface; use StellarWP\Foundation\Container\ContainerAdapter; use StellarWP\Foundation\Container\Contracts\Container; +use StellarWP\Foundation\Shutdown\Contracts\ShutdownRunner as ShutdownRunnerContract; +use StellarWP\Foundation\Shutdown\ResponseFinishingRunner; use StellarWP\Foundation\Shutdown\ShutdownProvider; -use StellarWP\Foundation\Shutdown\ShutdownRunner; use StellarWP\Foundation\Shutdown\ShutdownTask; use StellarWP\Foundation\Tests\Support\Fixtures\Shutdown\CallbackTerminable; use StellarWP\Foundation\Tests\WPUnitSupport\WPTestCase; @@ -28,10 +29,10 @@ protected function setUp(): void { } protected function tearDown(): void { - if ($this->container->has(ShutdownRunner::class)) { + if ($this->container->has(ShutdownRunnerContract::class)) { remove_action( 'shutdown', - $this->container->callback(ShutdownRunner::class, 'terminate'), + $this->container->callback(ShutdownRunnerContract::class, 'terminate'), PHP_INT_MAX ); } @@ -49,9 +50,10 @@ public function test_it_registers_a_singleton_runner_with_contributed_tasks(): v })), ]); - $runner = $this->container->get(ShutdownRunner::class); + $runner = $this->container->get(ShutdownRunnerContract::class); - $this->assertSame($runner, $this->container->get(ShutdownRunner::class)); + $this->assertInstanceOf(ResponseFinishingRunner::class, $runner); + $this->assertSame($runner, $this->container->get(ShutdownRunnerContract::class)); $runner->terminate(); @@ -60,11 +62,11 @@ public function test_it_registers_a_singleton_runner_with_contributed_tasks(): v public function test_duplicate_provider_registration_does_not_replace_the_runner(): void { $this->container->register(ShutdownProvider::class); - $runner = $this->container->get(ShutdownRunner::class); + $runner = $this->container->get(ShutdownRunnerContract::class); $this->container->register(ShutdownProvider::class); - $this->assertSame($runner, $this->container->get(ShutdownRunner::class)); + $this->assertSame($runner, $this->container->get(ShutdownRunnerContract::class)); } public function test_it_injects_a_registered_psr_logger(): void { @@ -73,7 +75,7 @@ public function test_it_injects_a_registered_psr_logger(): void { $this->container->singleton(LoggerInterface::class, new Logger('shutdown', [$handler])); $this->container->register(ShutdownProvider::class); - $this->container->get(ShutdownRunner::class)->terminate(); + $this->container->get(ShutdownRunnerContract::class)->terminate(); $this->assertTrue($handler->hasDebugThatMatches('/Running shutdown tasks\./')); } @@ -82,7 +84,7 @@ public function test_it_runs_contributed_tasks_on_wordpress_shutdown(): void { $calls = []; $this->container->register(ShutdownProvider::class); - $callback = $this->container->callback(ShutdownRunner::class, 'terminate'); + $callback = $this->container->callback(ShutdownRunnerContract::class, 'terminate'); $this->container->mergeArrayVar(ShutdownProvider::TASKS, [ new ShutdownTask(new CallbackTerminable(static function () use (&$calls): void { From 0bbe799e1f861b00c5b9fdb7378da5ef7c36eaa8 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 18 Aug 2026 15:36:42 -0600 Subject: [PATCH 4/4] Skip response finisher mocks when native functions exist --- tests/Unit/Shutdown/ResponseFinishingRunnerTest.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/Unit/Shutdown/ResponseFinishingRunnerTest.php b/tests/Unit/Shutdown/ResponseFinishingRunnerTest.php index 6cbebcb..e205dc1 100644 --- a/tests/Unit/Shutdown/ResponseFinishingRunnerTest.php +++ b/tests/Unit/Shutdown/ResponseFinishingRunnerTest.php @@ -11,6 +11,14 @@ final class ResponseFinishingRunnerTest extends TestCase { + protected function setUp(): void { + parent::setUp(); + + if (function_exists('fastcgi_finish_request') || function_exists('litespeed_finish_request')) { + $this->markTestSkipped('Native response-finishing functions cannot be replaced by test fixtures.'); + } + } + #[RunInSeparateProcess] public function test_it_finishes_the_response_and_runs_shutdown_tasks_once(): void { require dirname(__DIR__, 2) . '/Support/Fixtures/Shutdown/litespeed-finish-request.php';