From be9a6057cfefe9467e6da6674a34ecae01f47b7b Mon Sep 17 00:00:00 2001 From: Etienne Scherrer Date: Tue, 3 Nov 2026 23:17:47 +0100 Subject: [PATCH] fix(core): reset request state behind a plugin interface proxy --- packages/core/src/RequestStateResetter.php | 27 +++- .../tests/Unit/RequestStateResetterTest.php | 124 ++++++++++++++++++ .../docs-markdown/docs/packages/testing.md | 2 +- 3 files changed, 147 insertions(+), 6 deletions(-) diff --git a/packages/core/src/RequestStateResetter.php b/packages/core/src/RequestStateResetter.php index 4f1a17a8d..06e1fc056 100644 --- a/packages/core/src/RequestStateResetter.php +++ b/packages/core/src/RequestStateResetter.php @@ -6,6 +6,7 @@ use Marko\Core\Container\ContainerInterface; use Marko\Core\Contracts\ResettableInterface; +use Marko\Core\Plugin\PluginInterceptedInterface; /** * Clears request-scoped state between requests served by one booted @@ -38,16 +39,32 @@ public function __construct( public function reset( ResettableInterface ...$except, ): void { - /** @var array $resettables */ - $resettables = $this->container->resolvedInstances(ResettableInterface::class); - ksort($resettables); + $instances = $this->container->resolvedInstances(); + ksort($instances); - foreach ($resettables as $resettable) { - if (in_array($resettable, $except, true)) { + foreach ($instances as $instance) { + $resettable = $this->resettableBehind($instance); + + if ($resettable === null || in_array($resettable, $except, true)) { continue; } $resettable->reset(); } } + + /** + * A plugin interface proxy implements only the plugged interface, so the + * resettable service it wraps would otherwise keep one request's state. + * A concrete subclass proxy is itself resettable and holds the state. + */ + private function resettableBehind( + object $instance, + ): ?ResettableInterface { + while ($instance instanceof PluginInterceptedInterface && !$instance instanceof ResettableInterface) { + $instance = $instance->getPluginTarget(); + } + + return $instance instanceof ResettableInterface ? $instance : null; + } } diff --git a/packages/core/tests/Unit/RequestStateResetterTest.php b/packages/core/tests/Unit/RequestStateResetterTest.php index 9720e69cb..c0cc0175f 100644 --- a/packages/core/tests/Unit/RequestStateResetterTest.php +++ b/packages/core/tests/Unit/RequestStateResetterTest.php @@ -4,8 +4,79 @@ use Marko\Core\Container\Container; use Marko\Core\Contracts\ResettableInterface; +use Marko\Core\Plugin\InterceptorClassGenerator; +use Marko\Core\Plugin\PluginDefinition; +use Marko\Core\Plugin\PluginInterceptedInterface; +use Marko\Core\Plugin\PluginInterceptor; +use Marko\Core\Plugin\PluginRegistry; use Marko\Core\RequestStateResetter; +interface ResetterCurrentUserInterface +{ + public function name(): ?string; + + public function remember( + string $name, + ): void; +} + +/** + * Holds per-request state behind an interface, the way SessionGuard does. + */ +class ResetterCurrentUser implements ResetterCurrentUserInterface, ResettableInterface +{ + private ?string $name = null; + + public function name(): ?string + { + return $this->name; + } + + public function remember( + string $name, + ): void { + $this->name = $name; + } + + public function reset(): void + { + $this->name = null; + } +} + +class ResetterCurrentUserPlugin +{ + public function beforeName(): ?array + { + return null; + } +} + +/** + * A container that wraps the shared $targetClass service in a plugin proxy: + * an interface wrapper for an interface, a subclass for a class. + * + * @param class-string $targetClass + */ +function resetterContainerPlugging( + string $targetClass, +): Container { + $container = new Container(); + $registry = new PluginRegistry(); + $registry->register(new PluginDefinition( + pluginClass: ResetterCurrentUserPlugin::class, + targetClass: $targetClass, + beforeMethods: ['name' => ['pluginMethod' => 'beforeName', 'sortOrder' => 10]], + )); + $container->setPluginInterceptor( + new PluginInterceptor($container, $registry, new InterceptorClassGenerator()), + ); + $container->bind(ResetterCurrentUserInterface::class, ResetterCurrentUser::class); + $container->singleton($targetClass); + + return $container; +} + function requestStateResetterSpy( array &$log, string $label, @@ -74,6 +145,59 @@ public function reset(): void new RequestStateResetter($container)->reset(); })->throws(RuntimeException::class, 'reset failed'); + it('resets a resettable service that a plugin interface proxy wraps', function (): void { + $container = resetterContainerPlugging(ResetterCurrentUserInterface::class); + $user = $container->get(ResetterCurrentUserInterface::class); + $user->remember('Etienne Aaron Scherrer'); + + new RequestStateResetter($container)->reset(); + + expect($user)->toBeInstanceOf(PluginInterceptedInterface::class) + ->and($user->name())->toBeNull(); + }); + + it('resets a resettable service that a plugin subclass proxy replaces', function (): void { + $container = resetterContainerPlugging(ResetterCurrentUser::class); + $user = $container->get(ResetterCurrentUser::class); + $user->remember('Etienne Aaron Scherrer'); + + new RequestStateResetter($container)->reset(); + + expect($user)->toBeInstanceOf(PluginInterceptedInterface::class) + ->and($user->name())->toBeNull(); + }); + + it('resets a resettable service behind a proxy that wraps another proxy', function (): void { + $container = new Container(); + $registry = new PluginRegistry(); + $registry->register(new PluginDefinition( + pluginClass: ResetterCurrentUserPlugin::class, + targetClass: ResetterCurrentUserInterface::class, + beforeMethods: ['name' => ['pluginMethod' => 'beforeName', 'sortOrder' => 10]], + )); + $interceptor = new PluginInterceptor($container, $registry, new InterceptorClassGenerator()); + $user = new ResetterCurrentUser(); + $inner = $interceptor->createProxy(ResetterCurrentUserInterface::class, ResetterCurrentUser::class, $user); + $outer = $interceptor->createProxy(ResetterCurrentUserInterface::class, $inner::class, $inner); + $container->instance(ResetterCurrentUserInterface::class, $outer); + $user->remember('Etienne Aaron Scherrer'); + + new RequestStateResetter($container)->reset(); + + expect($outer->getPluginTarget())->toBe($inner) + ->and($user->name())->toBeNull(); + }); + + it('skips a wrapped service passed as an exception', function (): void { + $container = resetterContainerPlugging(ResetterCurrentUserInterface::class); + $user = $container->get(ResetterCurrentUserInterface::class); + $user->remember('Kira Spielmann'); + + new RequestStateResetter($container)->reset($user->getPluginTarget()); + + expect($user->name())->toBe('Kira Spielmann'); + }); + it('skips the instances passed as exceptions', function (): void { $log = []; $container = new Container(); diff --git a/packages/docs-markdown/docs/packages/testing.md b/packages/docs-markdown/docs/packages/testing.md index b712c5b94..5d57d0b17 100644 --- a/packages/docs-markdown/docs/packages/testing.md +++ b/packages/docs-markdown/docs/packages/testing.md @@ -634,7 +634,7 @@ $this->http->actingAs($apiUser, 'api')->getJson('/api/me')->assertOk(); ### Request-scoped state and exceptions -Before each request, the client calls `reset()` on every resolved `ResettableInterface` service (`Marko\Core\RequestStateResetter`, the same code the RoadRunner worker runs), so the session, the session guard and similar request-scoped state never leak from one request to the next. +Before each request, the client calls `reset()` on every resolved `ResettableInterface` service, including one a plugin proxy wraps (`Marko\Core\RequestStateResetter`, the same code the RoadRunner worker runs), so the session, the session guard and similar request-scoped state never leak from one request to the next. An exception thrown by a controller or middleware propagates out of `$client->get()`, so the test shows the real stack trace. HTTP exceptions (`HttpExceptionInterface`, e.g. `HttpException::notFound()`) are rendered into a response by the router, exactly as in production, so `assertNotFound()` and friends work on them.