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` diff --git a/README.md b/README.md index 3a83a88..3c67a7b 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Foundation is a StellarWP Composer monorepo for reusable PHP packages intended f - [stellarwp/foundation-container](https://github.com/stellarwp/foundation-container) - [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 7595932..f123dbd 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "stellarwp/foundation-container": "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", @@ -47,6 +48,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/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 @@ + [!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. + } +} + +final class CloseRequestLog implements Terminable +{ + public function terminate(): void { + // Close the request log after the response is sent. + } +} +``` + +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` binds the `ShutdownRunner` contract to the ordered task runner, +decorated by `ResponseFinishingRunner`, and attaches it to WordPress's `shutdown` +action at the latest priority. When supported, it finishes the response with +`fastcgi_finish_request()` or `litespeed_finish_request()` before running the +contributed tasks. The PHP worker remains occupied until those tasks finish, so +long-running work still belongs in a proper background queue. + +The runner is resolved lazily when the action fires, so features may contribute +tasks after the provider is registered. + +With the default provider registered, applications may also invoke the configured +runner chain directly: + +```php +use StellarWP\Foundation\Shutdown\Contracts\ShutdownRunner; + +$container->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. + +## 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, and actual exception so +compatible loggers retain its message and stack trace. Logger failures are isolated +so diagnostics cannot interrupt termination work. + +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 new file mode 100644 index 0000000..95ecdab --- /dev/null +++ b/src/Shutdown/ShutdownProvider.php @@ -0,0 +1,42 @@ +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->singletonDecorators(ShutdownRunnerContract::class, [ + ResponseFinishingRunner::class, + ShutdownRunner::class, + ]); + + add_action( + 'shutdown', + $this->container->callback(ShutdownRunnerContract::class, 'terminate'), + PHP_INT_MAX + ); + } +} diff --git a/src/Shutdown/ShutdownRunner.php b/src/Shutdown/ShutdownRunner.php new file mode 100644 index 0000000..59ef64d --- /dev/null +++ b/src/Shutdown/ShutdownRunner.php @@ -0,0 +1,106 @@ + */ + 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, + ]); + } + } + } + + /** + * 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": "^2.0" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\Shutdown\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.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/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 @@ +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'; + + $GLOBALS['foundation_shutdown_calls'] = []; + + $runner = $this->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 new file mode 100644 index 0000000..fd77909 --- /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]); + $failure = new Error('Expected test failure.', 42); + $failing = new CallbackTerminable(static function () use ($failure): void { + throw $failure; + }); + + $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' => $failure, + ], $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..53690b3 --- /dev/null +++ b/tests/wpunit/Shutdown/ShutdownProviderTest.php @@ -0,0 +1,101 @@ +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(ShutdownRunnerContract::class)) { + remove_action( + 'shutdown', + $this->container->callback(ShutdownRunnerContract::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(ShutdownRunnerContract::class); + + $this->assertInstanceOf(ResponseFinishingRunner::class, $runner); + $this->assertSame($runner, $this->container->get(ShutdownRunnerContract::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(ShutdownRunnerContract::class); + + $this->container->register(ShutdownProvider::class); + + $this->assertSame($runner, $this->container->get(ShutdownRunnerContract::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(ShutdownRunnerContract::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(ShutdownRunnerContract::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); + } +}