diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fefd1a..dc377f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) where applicable. +## Unreleased + +- Qualify native Symfony 8.2 service shutdown in the WordPress App lifecycle, with final cleanup, error continuation and shutdown-aware autoconfiguration. + ## 1.1.12 — 2026-10-24 - Keep the per-file source manifest of each container generation in `sources_.json` next to `meta.json`, so warm requests no longer parse one entry per kernel and bundle source file. Older metadata that still embeds `source_resources` stays readable; the immutable policy never reads the sidecar. diff --git a/docs/dependency-injection.md b/docs/dependency-injection.md index 313f83c..0d1e728 100644 --- a/docs/dependency-injection.md +++ b/docs/dependency-injection.md @@ -1234,3 +1234,44 @@ identity. The second builder is fresh, uses the final `kernel.container_class`, and must observe the same resource set before publication. Warm loads do not compile again. Explicit immutable deployment mode skips these checks under its required build-ID contract. + +## Final service shutdown (Symfony 8.2 qualification) + +SymPress explicitly integrates the native `Symfony\Contracts\Service\ShutdownInterface` +with its own kernel lifecycle. Merely upgrading DependencyInjection does not call +this lifecycle. With a native shutdown-aware `services_resetter`, App registers a +PHP shutdown callback after boot. WordPress has already registered its shutdown +action dispatcher at this point, so its shutdown actions run before final service +cleanup. A weak reference avoids retaining replaced App instances. An explicit +`KernelInterface::shutdown()` also performs final cleanup and is idempotent. + +Shutdown first calls bundles in reverse order, detaches their containers, and then +calls the native resetter with `reset(true)`. The first failure is rethrown after +remaining bundles and services have had an opportunity to clean up; App reports +errors from the PHP shutdown callback through its existing error handling. Kernel +state is cleared even on failure. Reentrant shutdown callbacks cannot repeat work. +If a post-boot application hook fails, native kernel cleanup runs immediately, +keeping a later boot attempt from retaining the failed container. The original +boot error remains the reported error. + +Services implementing the native ShutdownInterface receive `reset(false)` during +ordinary resets and `reset(true)` during final cleanup. Autoconfiguration adds the +`on_shutdown: flag` attribute to their existing `reset` tag without duplicating +it. Explicit tag attributes are preserved. Services with a separate close method +can use `kernel.reset: { method: close, on_shutdown: only }`; use this attribute +only with DependencyInjection 8.2. The native compiler/resetter owns validation, +lazy-service handling and non-shared service tracking. Uninitialized services are +not created for cleanup. No Symfony implementation is copied into SymPress. + +DependencyInjection 8.1 and Service Contracts 3.7 remain supported. On those +versions the new integration is inactive and ordinary ResetInterface behavior is +preserved. Service Contracts 3.8 alone is insufficient: the installed native +resetter must implement ShutdownInterface. Symfony 8.2 and Contracts 3.8 are still +unreleased as of 2026-10-23; publish only after stable versions and consumer checks +have confirmed the final contract. + +Framework consumers only gain shutdown behavior for services that their installed +bundle actually tags or autoconfigures. Doctrine connection closure must be +checked against the installed DoctrineBundle configuration. Mailer transports +must expose/tag their native final reset; upgrading this kernel does not invent +close callbacks or change delivery semantics. diff --git a/src/App.php b/src/App.php index fc3f01c..2c0723c 100644 --- a/src/App.php +++ b/src/App.php @@ -6,7 +6,10 @@ use SymPress\Kernel\Hook\HookLoader; use SymPress\Kernel\Kernel\KernelInterface; +use SymPress\Kernel\Kernel\NativeServiceShutdown; use SymPress\Kernel\Kernel\SiteKernel; +use Symfony\Component\DependencyInjection\ServicesResetter; +use WeakReference; final class App { @@ -26,6 +29,7 @@ final class App private ?Container $container = null; private bool $booted = false; private bool $booting = false; + private bool $nativeShutdownPending = false; private ?bool $debugEnabled = null; private function __construct( @@ -159,9 +163,11 @@ public function boot(): void $this->kernel->boot($container, $bundles); $this->booted = true; $this->booting = false; + $this->registerShutdown(); self::dispatchAction(self::ACTION_BOOTED, $container, $bundles); self::dispatchAction(self::LEGACY_ACTION_CONTAINER_LOADED, $container); } catch (\Throwable $throwable) { + $this->rollbackNativeBoot(); $this->booted = false; $this->booting = false; $this->container = null; @@ -200,6 +206,49 @@ public function resolve(string $id, mixed $default = null): mixed return $value; } + private function rollbackNativeBoot(): void + { + if (!$this->nativeShutdownPending) { + return; + } + + $this->nativeShutdownPending = false; + try { + $this->kernel->shutdown(); + } catch (\Throwable $error) { + // Preserve the boot exception; report cleanup failure without service data. + error_log('[sympress/kernel] Boot rollback cleanup failed: ' . $error::class); + } + } + + private function registerShutdown(): void + { + if (!NativeServiceShutdown::supports(ServicesResetter::class) || !$this->container?->has('services_resetter')) { + return; + } + if (!NativeServiceShutdown::supports($this->container->get('services_resetter'))) { + return; + } + + $this->nativeShutdownPending = true; + $reference = WeakReference::create($this); + register_shutdown_function(static function () use ($reference): void { + $app = $reference->get(); + if (!$app instanceof self || !$app->nativeShutdownPending) { + return; + } + $app->nativeShutdownPending = false; + try { + $app->kernel->shutdown(); + } catch (\Throwable $error) { + self::handleThrowable($error); + } finally { + $app->booted = false; + $app->container = null; + } + }); + } + private function initializeContainer(): Container { if ($this->container instanceof Container) { diff --git a/src/Kernel/AbstractKernel.php b/src/Kernel/AbstractKernel.php index 7ab9481..d9e0ca4 100644 --- a/src/Kernel/AbstractKernel.php +++ b/src/Kernel/AbstractKernel.php @@ -30,6 +30,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\HttpKernelInterface; +use Throwable; // phpcs:ignore SymPress.Classes.PropertyLimit.TooManyProperties -- The resolver is kept for one boot so the cache path is resolved once per request. abstract class AbstractKernel implements KernelInterface @@ -428,16 +429,52 @@ public function shutdown(): void return; } - foreach (array_reverse($this->bundles) as $bundle) { - $bundle->shutdown(); - $bundle->setContainer(null); + // Mark the lifecycle closed before invoking callbacks, including reentrant ones. + $this->booted = false; + $failure = null; + try { + foreach (array_reverse($this->bundles) as $bundle) { + try { + $bundle->shutdown(); + } catch (Throwable $error) { + $failure ??= $error; + } + try { + $bundle->setContainer(null); + } catch (Throwable $error) { + $failure ??= $error; + } + } + + try { + $this->shutdownServices(); + } catch (Throwable $error) { + $failure ??= $error; + } + } finally { + $this->container = null; + $this->bundleRegistry = null; + $this->bundles = []; + $this->configuration = null; } - $this->booted = false; - $this->container = null; - $this->bundleRegistry = null; - $this->bundles = []; - $this->configuration = null; + if ($failure !== null) { + throw $failure; + } + } + + private function shutdownServices(): void + { + if (!$this->container?->has('services_resetter')) { + return; + } + + $resetter = $this->container->get('services_resetter'); + if ($resetter === null) { + return; + } + + NativeServiceShutdown::reset($resetter); } /** @return array */ diff --git a/src/Kernel/CoreServiceRegistrar.php b/src/Kernel/CoreServiceRegistrar.php index 528b9b0..764b06f 100644 --- a/src/Kernel/CoreServiceRegistrar.php +++ b/src/Kernel/CoreServiceRegistrar.php @@ -234,6 +234,10 @@ static function (ChildDefinition $definition): void { PassConfig::TYPE_BEFORE_OPTIMIZATION, 200, ); + if (NativeServiceShutdown::supports(ServicesResetter::class)) { + $builder->addCompilerPass(new ShutdownServicePass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -31); + } + $builder->addCompilerPass(new ResettableServicePass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -32); } diff --git a/src/Kernel/NativeServiceShutdown.php b/src/Kernel/NativeServiceShutdown.php new file mode 100644 index 0000000..93a75b4 --- /dev/null +++ b/src/Kernel/NativeServiceShutdown.php @@ -0,0 +1,25 @@ +reset(true); + } +} diff --git a/src/Kernel/ShutdownServicePass.php b/src/Kernel/ShutdownServicePass.php new file mode 100644 index 0000000..8712cab --- /dev/null +++ b/src/Kernel/ShutdownServicePass.php @@ -0,0 +1,65 @@ +>> $taggedServices */ + $taggedServices = $container->findTaggedServiceIds('kernel.reset'); + foreach ($taggedServices as $id => $tags) { + $definition = $container->getDefinition($id); + $class = $container->getParameterBag()->resolveValue($definition->getClass()); + if (!is_string($class) || !is_a($class, ShutdownInterface::class, true)) { + continue; + } + + $definition->clearTag('kernel.reset'); + foreach ($this->normalizeTags($tags) as $attributes) { + $definition->addTag('kernel.reset', $attributes); + } + } + } + + /** + * @param list> $tags + * @return list> + */ + private function normalizeTags(array $tags): array + { + $explicitReset = false; + foreach ($tags as $attributes) { + $explicitReset = $explicitReset || (($attributes['method'] ?? null) === 'reset' + && isset($attributes['on_shutdown'])); + } + + $registered = []; + foreach ($tags as $attributes) { + if (($attributes['method'] ?? null) === 'reset') { + if ($explicitReset && !isset($attributes['on_shutdown'])) { + continue; + } + $attributes['on_shutdown'] ??= 'flag'; + } + if (in_array($attributes, $registered, true)) { + continue; + } + + $registered[] = $attributes; + } + + return $registered; + } +} diff --git a/tests/Kernel/NativeShutdownService.php b/tests/Kernel/NativeShutdownService.php new file mode 100644 index 0000000..787501e --- /dev/null +++ b/tests/Kernel/NativeShutdownService.php @@ -0,0 +1,18 @@ + */ + public array $calls = []; + + public function reset(bool $shutdown = false): void + { + $this->calls[] = $shutdown; + } +} diff --git a/tests/Kernel/ServiceShutdownTest.php b/tests/Kernel/ServiceShutdownTest.php new file mode 100644 index 0000000..9d6c13f --- /dev/null +++ b/tests/Kernel/ServiceShutdownTest.php @@ -0,0 +1,188 @@ +getNumberOfParameters()); + return; + } + require_once __DIR__ . '/NativeShutdownService.php'; + + $kernel = $this->kernel($this->tmpPath('shutdown')); + $container = $kernel->createContainer(); + $registry = new BundleRegistry(); + $files = $kernel->configureContainer($container->builder(), $container, $registry); + $container->builder()->register('shutdown.private', NativeShutdownService::class)->setAutoconfigured(true); + $container->builder()->register('shutdown.entry', ShutdownHolder::class) + ->setArguments([new Reference('shutdown.private')])->setPublic(true); + $container->builder()->register('shutdown.lazy', NativeShutdownService::class) + ->setAutoconfigured(true)->setLazy(true)->setPublic(true); + $container->builder()->register('shutdown.initialized', NativeShutdownService::class) + ->setAutoconfigured(true)->setLazy(true)->setPublic(true); + $container->builder()->register('shutdown.nonshared', NativeShutdownService::class) + ->setAutoconfigured(true)->setShared(false)->setPublic(true); + $container->builder()->register('shutdown.flag', NativeShutdownService::class) + ->setAutoconfigured(true)->setPublic(true) + ->addTag('kernel.reset', ['method' => 'reset', 'on_shutdown' => 'flag']); + $container->builder()->register('shutdown.explicit', NativeShutdownService::class) + ->setAutoconfigured(true)->setPublic(true) + ->addTag('kernel.reset', ['method' => 'reset', 'on_shutdown' => 'only']); + $container->builder()->register('shutdown.only', ShutdownResource::class) + ->setPublic(true)->addTag('kernel.reset', ['method' => 'close', 'on_shutdown' => 'only']); + $kernel->createRuntimeContainer($container, $registry, $files); + $service = $container->get('shutdown.entry')->service; + $initialized = $container->get('shutdown.initialized'); + $initialized->calls = []; + $nonshared = [$container->get('shutdown.nonshared'), $container->get('shutdown.nonshared')]; + $lazy = $container->get('shutdown.lazy'); + $resource = $container->get('shutdown.only'); + $explicit = $container->get('shutdown.explicit'); + $flag = $container->get('shutdown.flag'); + $resetter = $container->get('services_resetter'); + $kernel->boot($container, $registry); + + $resource->onClose = static fn () => $kernel->shutdown(); + $resetter->reset(); + self::assertSame([false], $service->calls); + self::assertSame(0, $resource->closed); + self::assertSame([], $explicit->calls); + self::assertTrue(new \ReflectionClass($lazy)->isUninitializedLazyObject($lazy)); + $kernel->shutdown(); + $kernel->shutdown(); + $resetter->reset(true); + + self::assertSame([false, true], $service->calls); + self::assertSame([false, true], $flag->calls); + self::assertSame(1, $resource->closed); + self::assertSame([false], $explicit->calls); + self::assertSame([false, true], $initialized->calls); + foreach ($nonshared as $instance) { + self::assertSame([false, true], $instance->calls); + } + self::assertTrue(new \ReflectionClass($lazy)->isUninitializedLazyObject($lazy)); + } + + public function testShutdownContinuesAfterBundleAndServiceErrorsAndCannotRepeat(): void + { + $kernel = $this->kernel($this->tmpPath('shutdown-errors')); + $bundleDir = $this->tmpPath('shutdown-bundle'); + mkdir($bundleDir, 0700, true); + $bundle = new class ($bundleDir) extends AbstractBundle { + public int $calls = 0; + + public function __construct(private readonly string $directory) + { + } + + public function path(): string + { + return $this->directory; + } + + public function shutdown(): void + { + ++$this->calls; + throw new RuntimeException('bundle failure'); + } + }; + $registry = $this->registryWithBundle($bundleDir, $bundle); + $container = $kernel->createContainer(); + $files = $kernel->configureContainer($container->builder(), $container, $registry); + foreach (['first' => true, 'last' => false] as $id => $fails) { + $container->builder()->register($id, ShutdownResource::class) + ->setArguments([$fails])->setPublic(true) + ->addTag('kernel.reset', ['method' => 'close', 'on_shutdown' => 'only']); + } + $kernel->createRuntimeContainer($container, $registry, $files); + $first = $container->get('first'); + $last = $container->get('last'); + $kernel->boot($container, $registry); + + try { + $kernel->shutdown(); + self::fail('The first shutdown error must be reported.'); + } catch (RuntimeException $error) { + self::assertSame('bundle failure', $error->getMessage()); + } + $kernel->shutdown(); + self::assertSame(1, $bundle->calls); + self::assertSame([], $kernel->getBundles()); + $expected = is_a(ServicesResetter::class, ShutdownInterface::class, true) ? 1 : 0; + self::assertSame($expected, $first->closed); + self::assertSame($expected, $last->closed); + } + + /** @return iterable */ + public static function bootScenarios(): iterable + { + yield 'normal boot' => [false]; + yield 'booted hook failure followed by retry' => [true]; + } + + #[DataProvider('bootScenarios')] + public function testAppInvokesNativeCleanupAtPhpShutdown(bool $retry): void + { + if (!is_a(ServicesResetter::class, ShutdownInterface::class, true)) { + self::assertSame(0, new \ReflectionMethod(ServicesResetter::class, 'reset')->getNumberOfParameters()); + return; + } + $root = $this->tmpPath('app-shutdown'); + mkdir($root . '/config', 0700, true); + file_put_contents($root . '/composer.json', '{}'); + file_put_contents($root . '/config/services.php', <<<'PHP' +services()->set('resource', ShutdownResource::class)->public() + ->tag('kernel.reset', ['method' => 'close', 'on_shutdown' => 'only']); +}; +PHP); + $script = <<<'PHP' +require $argv[1]; +$GLOBALS['failBootedHook'] = $argv[3] === 'retry'; +function add_action(string $hook, mixed $callback, int $priority = 10, int $accepted = 1): bool { return true; } +function add_filter(string $hook, mixed $callback, int $priority = 10, int $accepted = 1): bool { return true; } +function do_action(string $hook, mixed ...$arguments): void { + if ($hook === SymPress\Kernel\App::ACTION_BOOTED && $GLOBALS['failBootedHook']) { + $GLOBALS['failBootedHook'] = false; + SymPress\Kernel\App::make('resource')->onClose = static function (): void { echo "rollback cleanup|"; }; + throw new RuntimeException('booted hook failure'); + } +} +register_shutdown_function(static function (): void { echo "wordpress shutdown|"; }); +$kernel = new SymPress\Kernel\Kernel\SiteKernel($argv[2], 'test', false, + new SymPress\Kernel\Tests\Support\TestSiteConfig('test'), + SymPress\Kernel\WpContext::new()->force(SymPress\Kernel\WpContext::CORE)); +$app = SymPress\Kernel\App::new($kernel); +try { + $app->boot(); +} catch (RuntimeException $error) { + if ($error->getMessage() !== 'booted hook failure') { throw $error; } + echo "caught|"; + $app->boot(); +} +SymPress\Kernel\App::make('resource')->onClose = static function (): void { echo "native cleanup|"; }; +echo "request|"; +PHP; + $process = new Process([PHP_BINARY, '-r', $script, dirname(__DIR__, 2) . '/vendor/autoload.php', $root, $retry ? 'retry' : 'normal']); + $process->mustRun(); + $expected = ($retry ? 'rollback cleanup|caught|' : '') . 'request|wordpress shutdown|native cleanup|'; + self::assertSame($expected, $process->getOutput(), $process->getErrorOutput()); + } +} diff --git a/tests/Kernel/ShutdownHolder.php b/tests/Kernel/ShutdownHolder.php new file mode 100644 index 0000000..fa268d0 --- /dev/null +++ b/tests/Kernel/ShutdownHolder.php @@ -0,0 +1,12 @@ +closed; + ($this->onClose ?? static function (): void { + })(); + if ($this->fails) { + throw new RuntimeException('service failure'); + } + } +}