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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<key>.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.
Expand Down
41 changes: 41 additions & 0 deletions docs/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
49 changes: 49 additions & 0 deletions src/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
53 changes: 45 additions & 8 deletions src/Kernel/AbstractKernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, BundleInterface> */
Expand Down
4 changes: 4 additions & 0 deletions src/Kernel/CoreServiceRegistrar.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
25 changes: 25 additions & 0 deletions src/Kernel/NativeServiceShutdown.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace SymPress\Kernel\Kernel;

use Symfony\Contracts\Service\ShutdownInterface;

/** @internal Capability bridge while Symfony 8.1 and 8.2 are both supported. */
final class NativeServiceShutdown
{
public static function supports(object|string|null $resetter): bool
{
return $resetter !== null && interface_exists(ShutdownInterface::class) && is_a($resetter, ShutdownInterface::class, true);
}

public static function reset(object $resetter): void
{
if (!self::supports($resetter) || !method_exists($resetter, 'reset')) {
return;
}

$resetter->reset(true);
}
}
65 changes: 65 additions & 0 deletions src/Kernel/ShutdownServicePass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace SymPress\Kernel\Kernel;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Contracts\Service\ShutdownInterface;

/** @internal Adds the native flag to existing ResetInterface autoconfiguration. */
final class ShutdownServicePass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!interface_exists(ShutdownInterface::class)) {
return;
}

/** @var array<string, list<array<string, mixed>>> $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<array<string, mixed>> $tags
* @return list<array<string, mixed>>
*/
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;
}
}
18 changes: 18 additions & 0 deletions tests/Kernel/NativeShutdownService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace SymPress\Kernel\Tests\Kernel;

use Symfony\Contracts\Service\ShutdownInterface;

final class NativeShutdownService implements ShutdownInterface
{
/** @var list<bool> */
public array $calls = [];

public function reset(bool $shutdown = false): void
{
$this->calls[] = $shutdown;
}
}
Loading
Loading