diff --git a/config/octane.php b/config/octane.php index 8cfba0114..2d4d824d8 100644 --- a/config/octane.php +++ b/config/octane.php @@ -19,6 +19,7 @@ use Laravel\Octane\Listeners\FlushOnce; use Laravel\Octane\Listeners\FlushTemporaryContainerInstances; use Laravel\Octane\Listeners\FlushUploadedFiles; +use Laravel\Octane\Listeners\PropagateAttributeSingletons; use Laravel\Octane\Listeners\ReportException; use Laravel\Octane\Listeners\StopWorkerIfNecessary; use Laravel\Octane\Octane; @@ -103,6 +104,7 @@ ], OperationTerminated::class => [ + PropagateAttributeSingletons::class, FlushOnce::class, FlushTemporaryContainerInstances::class, // DisconnectFromDatabases::class, diff --git a/src/Listeners/PropagateAttributeSingletons.php b/src/Listeners/PropagateAttributeSingletons.php new file mode 100644 index 000000000..9e8a9276f --- /dev/null +++ b/src/Listeners/PropagateAttributeSingletons.php @@ -0,0 +1,105 @@ +sandbox; + $app = $event->app; + + // The checkedForSingletonOrScopedAttributes property was introduced in + // Laravel 11 alongside the #[Singleton] attribute. On older versions + // there is nothing to propagate, so bail out early. + if (! property_exists($sandbox, 'checkedForSingletonOrScopedAttributes')) { + return; + } + + // Access the protected attribute cache from the sandbox via reflection. + $checkedAttributes = $this->getProtectedProperty($sandbox, 'checkedForSingletonOrScopedAttributes'); + + if (empty($checkedAttributes)) { + return; + } + + // For each class that was detected as a singleton via the attribute, + // register the binding and instance on the root app if not already present. + foreach ($checkedAttributes as $className => $type) { + if ($type !== 'singleton') { + continue; + } + + // Skip if the root app already has this as a shared binding or instance. + if (isset($app[$className]) && $app->isShared($className)) { + continue; + } + + // Register as singleton on root app. + if (! $app->bound($className)) { + $app->singleton($className); + } + + // If the sandbox resolved an instance, copy it to the root app + // so clones won't need to rebuild it. + if ($sandbox->resolved($className)) { + $instances = $this->getProtectedProperty($sandbox, 'instances'); + + if (array_key_exists($className, $instances)) { + $app->instance($className, $instances[$className]); + } + } + } + + // Also propagate the attribute cache itself so the root app skips + // reflection on future clones. + $rootChecked = $this->getProtectedProperty($app, 'checkedForSingletonOrScopedAttributes'); + $merged = array_merge($rootChecked, $checkedAttributes); + $this->setProtectedProperty($app, 'checkedForSingletonOrScopedAttributes', $merged); + } + + /** + * Get a protected property from an object via reflection. + * + * @param object $object + * @param string $property + * @return mixed + */ + protected function getProtectedProperty(object $object, string $property): mixed + { + $reflection = new \ReflectionProperty($object, $property); + + return $reflection->getValue($object); + } + + /** + * Set a protected property on an object via reflection. + * + * @param object $object + * @param string $property + * @param mixed $value + */ + protected function setProtectedProperty(object $object, string $property, mixed $value): void + { + $reflection = new \ReflectionProperty($object, $property); + + $reflection->setValue($object, $value); + } +} diff --git a/src/OctaneServiceProvider.php b/src/OctaneServiceProvider.php index b8abd2c51..726249736 100644 --- a/src/OctaneServiceProvider.php +++ b/src/OctaneServiceProvider.php @@ -150,6 +150,7 @@ protected function bindListeners() $this->app->singleton(Listeners\PrepareLivewireForNextOperation::class); $this->app->singleton(Listeners\PrepareScoutForNextOperation::class); $this->app->singleton(Listeners\PrepareSocialiteForNextOperation::class); + $this->app->singleton(Listeners\PropagateAttributeSingletons::class); $this->app->singleton(Listeners\ReportException::class); $this->app->singleton(Listeners\StopWorkerIfNecessary::class); } diff --git a/tests/AttributeSingletonPropagationTest.php b/tests/AttributeSingletonPropagationTest.php new file mode 100644 index 000000000..7e5733349 --- /dev/null +++ b/tests/AttributeSingletonPropagationTest.php @@ -0,0 +1,153 @@ +markTestSkipped('Requires Laravel 11+ for #[Singleton] attribute support.'); + } + } + + public function test_singleton_attribute_classes_persist_across_requests() + { + [$app, $worker, $client] = $this->createOctaneContext([ + Request::create('/first', 'GET'), + Request::create('/first', 'GET'), + Request::create('/first', 'GET'), + ]); + + $app['router']->get('/first', function (Application $app) { + $instance = $app->make(AttributeSingletonService::class); + + return spl_object_hash($instance); + }); + + $worker->run(); + + // All three requests should receive the same singleton instance + // because the PropagateAttributeSingletons listener copies the + // binding and instance back to the root application. + $this->assertEquals( + $client->responses[0]->original, + $client->responses[1]->original, + ); + + $this->assertEquals( + $client->responses[1]->original, + $client->responses[2]->original, + ); + } + + public function test_singleton_attribute_constructor_only_called_once() + { + [$app, $worker, $client] = $this->createOctaneContext([ + Request::create('/first', 'GET'), + Request::create('/first', 'GET'), + ]); + + // Reset static counter. + AttributeSingletonWithCounter::$constructCount = 0; + + $app['router']->get('/first', function (Application $app) { + $app->make(AttributeSingletonWithCounter::class); + + return AttributeSingletonWithCounter::$constructCount; + }); + + $worker->run(); + + // First request: constructor called once. + $this->assertEquals(1, $client->responses[0]->original); + // Second request: still only called once (same instance reused). + $this->assertEquals(1, $client->responses[1]->original); + } + + public function test_multiple_resolves_in_same_request_return_same_instance() + { + [$app, $worker, $client] = $this->createOctaneContext([ + Request::create('/first', 'GET'), + ]); + + $app['router']->get('/first', function (Application $app) { + $a = $app->make(AttributeSingletonService::class); + $b = $app->make(AttributeSingletonService::class); + $c = $app->make(AttributeSingletonService::class); + + return [ + spl_object_hash($a), + spl_object_hash($b), + spl_object_hash($c), + ]; + }); + + $worker->run(); + + $hashes = $client->responses[0]->original; + $this->assertEquals($hashes[0], $hashes[1]); + $this->assertEquals($hashes[1], $hashes[2]); + } + + public function test_scoped_attribute_classes_are_not_propagated_to_root_app() + { + [$app, $worker, $client] = $this->createOctaneContext([ + Request::create('/first', 'GET'), + Request::create('/first', 'GET'), + Request::create('/first', 'GET'), + ]); + + $app['router']->get('/first', function (Application $app) { + $instance = $app->make(AttributeScopedService::class); + + return spl_object_hash($instance); + }); + + $worker->run(); + + // Each request should get a fresh scoped instance because the + // PropagateAttributeSingletons listener intentionally skips + // classes with the #[Scoped] attribute. + $this->assertNotEquals( + $client->responses[0]->original, + $client->responses[1]->original, + ); + + $this->assertNotEquals( + $client->responses[1]->original, + $client->responses[2]->original, + ); + } +} + +#[Scoped] +class AttributeScopedService +{ + // +} + +#[Singleton] +class AttributeSingletonService +{ + // +} + +#[Singleton] +class AttributeSingletonWithCounter +{ + public static int $constructCount = 0; + + public function __construct() + { + static::$constructCount++; + } +}