From 3f93f3442da6ed32a6b88692810fbd5d7573b9de Mon Sep 17 00:00:00 2001 From: Adhik Joshi Date: Thu, 9 Apr 2026 08:45:33 +0530 Subject: [PATCH 1/2] fix pool --- bin/swoole-server | 69 +-- .../GiveNewRequestInstanceToApplication.php | 10 +- src/Swoole/Coroutine/CoroutineApplication.php | 141 ++++- src/Swoole/Coroutine/RequestScope.php | 390 +++++++++++++ src/Swoole/Handlers/OnWorkerStart.php | 56 +- src/Worker.php | 34 +- tests/Coroutine/PoolFreeIsolationTest.php | 512 ++++++++++++++++++ tests/Coroutine/RequestScopeTest.php | 167 ++++++ .../Feature/WorkerCoroutineIsolationTest.php | 63 +++ tests/Unit/OnWorkerStartPoolFreeTest.php | 112 ++++ 10 files changed, 1417 insertions(+), 137 deletions(-) create mode 100644 src/Swoole/Coroutine/RequestScope.php create mode 100644 tests/Coroutine/PoolFreeIsolationTest.php create mode 100644 tests/Coroutine/RequestScopeTest.php create mode 100644 tests/Unit/OnWorkerStartPoolFreeTest.php diff --git a/bin/swoole-server b/bin/swoole-server index f2951cd..3cc5e4d 100755 --- a/bin/swoole-server +++ b/bin/swoole-server @@ -128,58 +128,7 @@ $server->on('request', function ($request, $response) use ($server, $workerState usleep(10000); // 10ms } - // Get a worker from the pool with a timeout (Queuing mechanism) - $worker = null; - if ($workerState->clientPool) { - $poolConfig = $serverState['octaneConfig']['swoole']['pool'] ?? []; - $waitTimeout = $poolConfig['wait_timeout'] ?? 30.0; - $rejectOnFull = $poolConfig['reject_on_full'] ?? false; - $overloadStatus = $poolConfig['overload_status'] ?? 503; - $retryAfter = $poolConfig['overload_retry_after'] ?? 5; - - if (!is_numeric($waitTimeout)) { - $waitTimeout = 30.0; - } - - $waitTimeout = (float) $waitTimeout; - try { - if ($workerState->workerPool) { - $worker = $workerState->workerPool->acquire((float) $waitTimeout, (bool) $rejectOnFull); - } else { - $worker = $workerState->clientPool->pop(0.001); - - if ($worker === false && ! $rejectOnFull && $waitTimeout > 0) { - $worker = $workerState->clientPool->pop((float) $waitTimeout); - } - } - } catch (\Throwable $e) { - error_log("❌ Failed to acquire worker from pool: " . $e->getMessage()); - $worker = null; - } - - if (! $worker) { - $errCode = $workerState->clientPool->errCode; - error_log("❌ Pool pop failed! ErrCode: {$errCode}, Stats: " . json_encode($workerState->clientPool->stats())); - - // Timeout occurred - pool is exhausted and queue is full/slow - $response->status((int) $overloadStatus); - $response->header('Content-Type', 'application/json'); - if (is_numeric($retryAfter) && (int) $retryAfter > 0) { - $response->header('Retry-After', (string) $retryAfter); - } - $response->end(json_encode([ - 'error' => 'Service Unavailable', - 'message' => 'Server is too busy, please try again later', - 'debug_pool_stats' => $workerState->clientPool->stats(), - 'debug_err_code' => $errCode, - 'debug_worker_pool' => $workerState->workerPool ? $workerState->workerPool->stats() : null, - ])); - return; - } - } else { - // Fallback if pool not initialized (shouldn't happen with new OnWorkerStart) - $worker = $workerState->worker; - } + $worker = $workerState->worker; $cid = Coroutine::getCid(); $context = Coroutine::getContext(); @@ -244,22 +193,6 @@ $server->on('request', function ($request, $response) use ($server, $workerState Monitor::unregisterRequestCoroutine($cid); - // Return worker to the pool so it remains available for subsequent requests. - if ($workerState->workerPool && $worker) { - try { - $kept = $workerState->workerPool->release($worker); - } catch (\Throwable $e) { - $kept = false; - error_log('⚠️ Worker pool release failed - terminating worker: '.$e->getMessage()); - } - - if (! $kept) { - $worker->terminate(); - } - } elseif ($workerState->clientPool && $worker) { - $workerState->clientPool->push($worker, 0.001); - } - // Ensure coroutine context is cleared (Swoole does this automatically, but explicit is safe) Context::clear(); } diff --git a/src/Listeners/GiveNewRequestInstanceToApplication.php b/src/Listeners/GiveNewRequestInstanceToApplication.php index 962e154..395b745 100644 --- a/src/Listeners/GiveNewRequestInstanceToApplication.php +++ b/src/Listeners/GiveNewRequestInstanceToApplication.php @@ -2,6 +2,9 @@ namespace Laravel\Octane\Listeners; +use Laravel\Octane\Swoole\Coroutine\Context; +use Laravel\Octane\Swoole\Coroutine\CoroutineApplication; + class GiveNewRequestInstanceToApplication { /** @@ -11,7 +14,12 @@ class GiveNewRequestInstanceToApplication */ public function handle($event): void { - $event->app->instance('request', $event->request); $event->sandbox->instance('request', $event->request); + + if (Context::inCoroutine() || $event->sandbox instanceof CoroutineApplication) { + return; + } + + $event->app->instance('request', $event->request); } } diff --git a/src/Swoole/Coroutine/CoroutineApplication.php b/src/Swoole/Coroutine/CoroutineApplication.php index 98535d8..0bb571a 100644 --- a/src/Swoole/Coroutine/CoroutineApplication.php +++ b/src/Swoole/Coroutine/CoroutineApplication.php @@ -52,7 +52,7 @@ protected function getCurrentApp() if (Coroutine::getCid() > 0) { $app = Context::get('octane.app'); - if ($app) { + if ($app && $app !== $this) { return $app; } } @@ -61,6 +61,55 @@ protected function getCurrentApp() return $this->baseApp; } + /** + * Get the current coroutine request scope if one exists. + */ + protected function getRequestScope(): ?RequestScope + { + $scope = Context::get('octane.request_scope'); + + return $scope instanceof RequestScope ? $scope : null; + } + + /** + * Normalize an abstract using the base application's alias table. + * + * @param mixed $abstract + * @return mixed + */ + protected function normalizeAbstract($abstract) + { + if (! is_string($abstract)) { + return $abstract; + } + + return $this->baseApp->getAlias($abstract); + } + + /** + * Determine if the given abstract has a coroutine-scoped instance. + * + * @param mixed $abstract + */ + protected function hasScopedInstance($abstract): bool + { + $scope = $this->getRequestScope(); + + return $scope instanceof RequestScope + && is_string($abstract = $this->normalizeAbstract($abstract)) + && $scope->has($abstract); + } + + /** + * Determine if the given abstract should resolve to the proxy itself. + * + * @param mixed $abstract + */ + protected function resolvesToProxy($abstract): bool + { + return is_string($abstract) && $this->normalizeAbstract($abstract) === 'app'; + } + /** * Get the base application (used for worker-level operations). * @@ -84,6 +133,24 @@ public function getBaseApplication(): Application */ public function make($abstract, array $parameters = []) { + if ($this->resolvesToProxy($abstract)) { + return $this; + } + + $scope = $this->getRequestScope(); + + if ($scope instanceof RequestScope && is_string($abstract = $this->normalizeAbstract($abstract))) { + if ($scope->has($abstract)) { + return $scope->get($abstract); + } + + $resolved = $scope->resolve($abstract, $this); + + if ($scope->has($abstract)) { + return $resolved; + } + } + return $this->getCurrentApp()->make($abstract, $parameters); } @@ -95,6 +162,10 @@ public function make($abstract, array $parameters = []) */ public function bound($abstract) { + if ($this->resolvesToProxy($abstract) || $this->hasScopedInstance($abstract)) { + return true; + } + return $this->getCurrentApp()->bound($abstract); } @@ -106,6 +177,10 @@ public function bound($abstract) */ public function resolved($abstract) { + if ($this->resolvesToProxy($abstract) || $this->hasScopedInstance($abstract)) { + return true; + } + return $this->getCurrentApp()->resolved($abstract); } @@ -153,6 +228,18 @@ public function bind($abstract, $concrete = null, $shared = false) */ public function instance($abstract, $instance) { + if ($this->resolvesToProxy($abstract)) { + return $this; + } + + $scope = $this->getRequestScope(); + + if ($scope instanceof RequestScope && is_string($abstract = $this->normalizeAbstract($abstract))) { + $scope->set($abstract, $instance); + + return $instance; + } + return $this->getCurrentApp()->instance($abstract, $instance); } @@ -331,6 +418,18 @@ public function forgetExtenders($abstract) */ public function forgetInstance($abstract) { + if ($this->resolvesToProxy($abstract)) { + return; + } + + $scope = $this->getRequestScope(); + + if ($scope instanceof RequestScope && is_string($abstract = $this->normalizeAbstract($abstract))) { + $scope->forget($abstract); + + return; + } + $this->getCurrentApp()->forgetInstance($abstract); } @@ -341,6 +440,12 @@ public function forgetInstance($abstract) */ public function forgetInstances() { + if (($scope = $this->getRequestScope()) instanceof RequestScope) { + $scope->clear(); + + return; + } + $this->getCurrentApp()->forgetInstances(); } @@ -351,6 +456,12 @@ public function forgetInstances() */ public function forgetScopedInstances() { + if (($scope = $this->getRequestScope()) instanceof RequestScope) { + $scope->clear(); + + return; + } + $this->getCurrentApp()->forgetScopedInstances(); } @@ -361,6 +472,12 @@ public function forgetScopedInstances() */ public function flush() { + if (($scope = $this->getRequestScope()) instanceof RequestScope) { + $scope->clear(); + + return; + } + $this->getCurrentApp()->flush(); } @@ -376,7 +493,7 @@ public function flush() */ public function offsetGet($key): mixed { - return $this->getCurrentApp()->offsetGet($key); + return $this->make($key); } /** @@ -388,6 +505,12 @@ public function offsetGet($key): mixed */ public function offsetSet($key, $value): void { + if ($this->getRequestScope() instanceof RequestScope && ! $value instanceof Closure) { + $this->instance($key, $value); + + return; + } + $this->getCurrentApp()->offsetSet($key, $value); } @@ -399,7 +522,7 @@ public function offsetSet($key, $value): void */ public function offsetExists($key): bool { - return $this->getCurrentApp()->offsetExists($key); + return $this->bound($key); } /** @@ -410,7 +533,7 @@ public function offsetExists($key): bool */ public function offsetUnset($key): void { - $this->getCurrentApp()->offsetUnset($key); + $this->forgetInstance($key); } // ========================================================================= @@ -1185,7 +1308,7 @@ public static function flushMacros() public function get(string $id) { - return $this->getCurrentApp()->get($id); + return $this->make($id); } public function getBootstrapProvidersPath() @@ -1215,7 +1338,7 @@ public function handleRequest(\Illuminate\Http\Request $request) public function has(string $id): bool { - return $this->getCurrentApp()->has($id); + return $this->bound($id); } public function hasDebugModeEnabled() @@ -1245,6 +1368,10 @@ public function isAlias($name) public function isShared($abstract) { + if ($this->resolvesToProxy($abstract) || $this->hasScopedInstance($abstract)) { + return true; + } + return $this->getCurrentApp()->isShared($abstract); } @@ -1260,7 +1387,7 @@ public static function macro($name, $macro) public function makeWith($abstract, array $parameters = []) { - return $this->getCurrentApp()->makeWith($abstract, $parameters); + return $this->make($abstract, $parameters); } public static function mixin($mixin, $replace = true) diff --git a/src/Swoole/Coroutine/RequestScope.php b/src/Swoole/Coroutine/RequestScope.php new file mode 100644 index 0000000..1bd708b --- /dev/null +++ b/src/Swoole/Coroutine/RequestScope.php @@ -0,0 +1,390 @@ + + */ + private array $bindings = []; + + /** + * The base application instance (shared, read-only reference). + * + * @var \Illuminate\Foundation\Application + */ + private Application $app; + + /** + * Whether the config has been cloned for copy-on-write. + * + * @var bool + */ + private bool $configCloned = false; + + /** + * Create a new RequestScope instance. + * + * @param \Illuminate\Foundation\Application $app + * @param \Illuminate\Http\Request|null $request + * @return void + */ + public function __construct(Application $app, ?Request $request = null) + { + $this->app = $app; + + if ($request !== null) { + $this->bindings['request'] = $request; + } + } + + /** + * Check if a binding exists in this request scope. + * + * @param string $key + * @return bool + */ + public function has(string $key): bool + { + return array_key_exists($key, $this->bindings); + } + + /** + * Get a binding from this request scope. + * + * @param string $key + * @return mixed + */ + public function get(string $key) + { + return $this->bindings[$key] ?? null; + } + + /** + * Set a binding in this request scope. + * + * For 'config', this triggers a copy-on-write clone on first mutation + * so that config changes in one coroutine don't leak to others. + * + * @param string $key + * @param mixed $value + * @return void + */ + public function set(string $key, $value): void + { + $this->bindings[$key] = $value; + + if ($key === 'config') { + $this->configCloned = true; + } + } + + /** + * Remove a binding from this request scope. + * + * @param string $key + * @return void + */ + public function forget(string $key): void + { + unset($this->bindings[$key]); + } + + /** + * Ensure config is cloned for copy-on-write isolation. + * + * Call this before any config()->set() operation to ensure + * the mutation only affects the current coroutine's config. + * + * @return void + */ + public function ensureConfigCloned(): void + { + if ($this->configCloned) { + return; + } + + $this->bindings['config'] = $this->cloneConfig(); + $this->configCloned = true; + } + + /** + * Lazily resolve a request-scoped binding for the current coroutine. + * + * @param string $key + * @param \Illuminate\Foundation\Application $sandbox + * @return mixed + */ + public function resolve(string $key, Application $sandbox) + { + if ($this->has($key)) { + return $this->get($key); + } + + $resolved = match ($key) { + 'auth' => $this->createAuthManager($sandbox), + 'auth.driver' => $this->createAuthDriver($sandbox), + 'config' => $this->cloneConfig(), + 'cookie' => $this->createCookieJar(), + 'redirect' => $this->createRedirector($sandbox), + 'request' => $this->get('request'), + 'session' => $this->createSessionManager($sandbox), + 'session.store' => $this->createSessionStore($sandbox), + 'url' => $this->createUrlGenerator($sandbox), + \Illuminate\Routing\Contracts\CallableDispatcher::class => new \Illuminate\Routing\CallableDispatcher($sandbox), + \Illuminate\Routing\Contracts\ControllerDispatcher::class => new \Illuminate\Routing\ControllerDispatcher($sandbox), + \Illuminate\Contracts\Routing\ResponseFactory::class => $this->createResponseFactory($sandbox), + default => null, + }; + + if ($resolved !== null || $key === 'request') { + $this->bindings[$key] = $resolved; + } + + return $resolved; + } + + /** + * Check if config has been cloned in this scope. + * + * @return bool + */ + public function isConfigCloned(): bool + { + return $this->configCloned; + } + + /** + * Get all bindings in this request scope. + * + * @return array + */ + public function all(): array + { + return $this->bindings; + } + + /** + * Clear all bindings and release references. + * + * @return void + */ + public function clear(): void + { + $this->bindings = []; + $this->configCloned = false; + } + + /** + * Get the base application reference. + * + * @return \Illuminate\Foundation\Application + */ + public function getApp(): Application + { + return $this->app; + } + + /** + * Clone the current configuration repository for copy-on-write semantics. + * + * @return mixed + */ + protected function cloneConfig() + { + return clone $this->app->make('config'); + } + + /** + * Create an isolated auth manager bound to the coroutine sandbox. + * + * @param \Illuminate\Foundation\Application $sandbox + * @return mixed + */ + protected function createAuthManager(Application $sandbox) + { + $auth = clone $this->app->make('auth'); + + if (method_exists($auth, 'setApplication')) { + $auth->setApplication($sandbox); + } + + if (method_exists($auth, 'forgetGuards')) { + $auth->forgetGuards(); + } + + return $auth; + } + + /** + * Create the coroutine-local default auth guard. + * + * @param \Illuminate\Foundation\Application $sandbox + * @return mixed + */ + protected function createAuthDriver(Application $sandbox) + { + $auth = $this->resolve('auth', $sandbox); + + return $auth?->guard(); + } + + /** + * Create an isolated cookie jar for the current coroutine. + * + * @return mixed + */ + protected function createCookieJar() + { + $cookie = clone $this->app->make('cookie'); + + if (method_exists($cookie, 'flushQueuedCookies')) { + $cookie->flushQueuedCookies(); + } + + return $cookie; + } + + /** + * Create an isolated session manager bound to the coroutine sandbox. + * + * @param \Illuminate\Foundation\Application $sandbox + * @return mixed + */ + protected function createSessionManager(Application $sandbox) + { + $session = clone $this->app->make('session'); + + if (method_exists($session, 'setContainer')) { + $session->setContainer($sandbox); + } + + if (method_exists($session, 'forgetDrivers')) { + $session->forgetDrivers(); + } + + $this->setObjectProperty($session, 'config', $sandbox->make('config')); + + return $session; + } + + /** + * Create the coroutine-local session store via the isolated session manager. + * + * @param \Illuminate\Foundation\Application $sandbox + * @return mixed + */ + protected function createSessionStore(Application $sandbox) + { + $session = $this->resolve('session', $sandbox); + + return $session?->driver(); + } + + /** + * Create a redirector bound to the coroutine sandbox. + * + * @param \Illuminate\Foundation\Application $sandbox + * @return \Illuminate\Routing\Redirector + */ + protected function createRedirector(Application $sandbox): \Illuminate\Routing\Redirector + { + $redirector = new \Illuminate\Routing\Redirector($sandbox->make('url')); + + if ($sandbox->bound('session.store')) { + $redirector->setSession($sandbox->make('session.store')); + } + + return $redirector; + } + + /** + * Create a response factory bound to the coroutine sandbox. + * + * @param \Illuminate\Foundation\Application $sandbox + * @return \Illuminate\Routing\ResponseFactory + */ + protected function createResponseFactory(Application $sandbox): \Illuminate\Routing\ResponseFactory + { + return new \Illuminate\Routing\ResponseFactory( + $sandbox->make(\Illuminate\Contracts\View\Factory::class), + $sandbox->make('redirect'), + ); + } + + /** + * Create an isolated URL generator for the current coroutine. + * + * @param \Illuminate\Foundation\Application $sandbox + * @return mixed + */ + protected function createUrlGenerator(Application $sandbox) + { + $url = clone $this->app->make('url'); + + if (($request = $this->get('request')) instanceof Request && method_exists($url, 'setRequest')) { + $url->setRequest($request); + } + + if (method_exists($url, 'setSessionResolver')) { + $url->setSessionResolver(static fn () => $sandbox->make('session')); + } + + if (method_exists($url, 'setKeyResolver')) { + $url->setKeyResolver(static function () use ($sandbox) { + $config = $sandbox->make('config'); + + return [$config->get('app.key'), ...($config->get('app.previous_keys') ?? [])]; + }); + } + + return $url; + } + + /** + * Set a protected property on an object and its parent classes. + * + * @param object $object + * @param string $property + * @param mixed $value + * @return void + */ + protected function setObjectProperty(object $object, string $property, $value): void + { + try { + $reflection = new ReflectionClass($object); + + while (! $reflection->hasProperty($property) && $reflection->getParentClass()) { + $reflection = $reflection->getParentClass(); + } + + if (! $reflection->hasProperty($property)) { + return; + } + + $instanceProperty = $reflection->getProperty($property); + $instanceProperty->setAccessible(true); + $instanceProperty->setValue($object, $value); + } catch (ReflectionException) { + // If the implementation changes upstream, fall back gracefully. + } + } +} diff --git a/src/Swoole/Handlers/OnWorkerStart.php b/src/Swoole/Handlers/OnWorkerStart.php index f43d4cc..067ff41 100644 --- a/src/Swoole/Handlers/OnWorkerStart.php +++ b/src/Swoole/Handlers/OnWorkerStart.php @@ -5,15 +5,12 @@ use Laravel\Octane\ApplicationFactory; use Laravel\Octane\Stream; use Laravel\Octane\Swoole\Coroutine\Context; -use Laravel\Octane\Swoole\Coroutine\ChannelPoolLock; use Laravel\Octane\Swoole\Coroutine\CoordinatorManager; use Laravel\Octane\Swoole\Coroutine\FacadeCache; -use Laravel\Octane\Swoole\Coroutine\WorkerPool; use Laravel\Octane\Swoole\SwooleClient; use Laravel\Octane\Swoole\SwooleExtension; use Laravel\Octane\Swoole\WorkerState; use Laravel\Octane\Worker; -use Swoole\Coroutine\Channel; use Swoole\Http\Server; use Throwable; use Laravel\Octane\Swoole\Coroutine\CoroutineApplication; @@ -141,7 +138,7 @@ protected function bootTaskWorker($server, int $workerId) } /** - * Boot an HTTP worker with a pool for concurrent request handling. + * Boot an HTTP worker with a single shared worker. * * @param \Swoole\Http\Server $server * @param int $workerId @@ -149,49 +146,9 @@ protected function bootTaskWorker($server, int $workerId) */ protected function bootHttpWorker($server, int $workerId) { - $poolConfig = $this->serverState['octaneConfig']['swoole']['pool'] ?? []; - // Pool size determines concurrent requests per Swoole worker - // Each pool member is a Worker with its own Laravel app (~50-100MB each) - // Trade-off: higher = more concurrency but more memory - $poolSize = (int) ($poolConfig['size'] ?? 10); - $minSize = (int) ($poolConfig['min_size'] ?? 1); - $maxSize = (int) ($poolConfig['max_size'] ?? 100); - $idleTimeout = (int) ($poolConfig['idle_timeout'] ?? 10); - - if ($minSize < 0) { - $minSize = 0; - } - - if ($maxSize < $minSize) { - $maxSize = $minSize; - } - - if ($maxSize < 1) { - $maxSize = 1; - } - - $poolSize = max($minSize, min($maxSize, $poolSize)); - - error_log("🏊 Creating worker pool with size: {$poolSize} (min: {$minSize}, max: {$maxSize})"); - - $channel = new Channel($maxSize); - $poolLock = new ChannelPoolLock(new Channel(1)); - $workerPool = new WorkerPool( - $channel, - $minSize, - $maxSize, - fn (int $poolIndex) => $this->createPoolWorker($server, $workerId, $poolIndex), - $poolLock, - $idleTimeout - ); - $workerPool->seed($poolSize); - - $this->workerState->clientPool = $channel; - $this->workerState->workerPool = $workerPool; - - // Keep the first worker as the default for backward compatibility - $this->workerState->worker = $this->workerState->clientPool->pop(); - $this->workerState->clientPool->push($this->workerState->worker); + $this->workerState->workerPool = null; + $this->workerState->clientPool = null; + $this->workerState->worker = $this->createPoolWorker($server, $workerId, 0); $this->workerState->client = $this->workerState->worker->getClient() ?? new SwooleClient; // Install CoroutineApplication proxy as the global container instance @@ -205,13 +162,10 @@ protected function bootHttpWorker($server, int $workerId) Facade::setFacadeApplication($coroutineApp); FacadeCache::disable(); - // Store worker pool in context for easy access - Context::set('octane.worker_pool', $this->workerState->clientPool); Context::set('octane.worker_id', $workerId); Context::set('octane.worker_pid', $this->workerState->workerPid); - Context::set('octane.pool_size', $poolSize); - error_log("✅ Worker pool created successfully with {$poolSize} instances"); + error_log("✅ HTTP worker #{$workerId} initialized with coroutine application proxy"); $this->warnIfDatabasePoolMinExceedsMaxConnections($this->workerState->worker, $server); diff --git a/src/Worker.php b/src/Worker.php index 9e397bb..fd0f4d0 100644 --- a/src/Worker.php +++ b/src/Worker.php @@ -3,6 +3,7 @@ namespace Laravel\Octane; use Closure; +use Illuminate\Container\Container; use Illuminate\Foundation\Application; use Illuminate\Http\Request; use Laravel\Octane\Contracts\Client; @@ -20,6 +21,8 @@ use RuntimeException; use Throwable; use Laravel\Octane\Swoole\Coroutine\Context; +use Laravel\Octane\Swoole\Coroutine\CoroutineApplication; +use Laravel\Octane\Swoole\Coroutine\RequestScope; use Swoole\Coroutine; use Illuminate\Support\Facades\Facade; @@ -76,16 +79,27 @@ public function handle(Request $request, RequestContext $context): void // not cached instances from previous requests that hold stale container references. Facade::clearResolvedInstances(); - // We will clone the application instance so that we have a clean copy to switch - // back to once the request has been handled. This allows us to easily delete - // certain instances that got resolved / mutated during a previous request. - $sandbox = clone $this->app; + $scope = null; + $sandbox = null; + $inCoroutine = class_exists(Context::class) && Coroutine::getCid() > 0; + + if ($inCoroutine) { + $scope = new RequestScope($this->app, $request); + Context::set('octane.request_scope', $scope); + + $sandbox = Container::getInstance(); + + if (! $sandbox instanceof Application) { + $sandbox = new CoroutineApplication($this->app); + Container::setInstance($sandbox); + } - // In coroutine mode, we store the sandbox in the coroutine context. - // The global Container instance is a proxy that delegates to this context. - if (class_exists(Context::class) && Coroutine::getCid() > 0) { - Context::set('octane.app', $sandbox); + Facade::setFacadeApplication($sandbox); } else { + // We will clone the application instance so that we have a clean copy to switch + // back to once the request has been handled. This allows us to easily delete + // certain instances that got resolved / mutated during a previous request. + $sandbox = clone $this->app; CurrentApplication::set($sandbox); } @@ -125,7 +139,7 @@ public function handle(Request $request, RequestContext $context): void $this->app->make('view.engine.resolver')->forget('blade'); $this->app->make('view.engine.resolver')->forget('php'); - if (class_exists(Context::class) && Coroutine::getCid() > 0) { + if ($inCoroutine) { // Release database connections if ($sandbox->bound('db')) { $db = $sandbox->make('db'); @@ -142,7 +156,7 @@ public function handle(Request $request, RequestContext $context): void // After the request handling process has completed we will unset some variables // plus reset the current application state back to its original state before // it was cloned. Then we will be ready for the next worker iteration loop. - unset($gateway, $sandbox, $context, $request, $response, $octaneResponse, $output); + unset($gateway, $sandbox, $scope, $context, $request, $response, $octaneResponse, $output); } } diff --git a/tests/Coroutine/PoolFreeIsolationTest.php b/tests/Coroutine/PoolFreeIsolationTest.php new file mode 100644 index 0000000..c987c0f --- /dev/null +++ b/tests/Coroutine/PoolFreeIsolationTest.php @@ -0,0 +1,512 @@ +markTestSkipped('Swoole coroutine support is required.'); + } + } + + /** + * Test that request-scoped bindings are isolated between concurrent coroutines. + * + * Each coroutine stores a unique value in 'request' scope and sleeps, + * then verifies its value hasn't been overwritten by another coroutine. + * + * @requires extension swoole + */ + public function test_request_scoped_bindings_are_isolated_between_coroutines(): void + { + $this->requiresSwoole(); + + $results = []; + + \Swoole\Coroutine\run(function () use (&$results) { + $baseApp = $this->app; + $done = new Channel(3); + + for ($i = 0; $i < 3; $i++) { + $id = "coroutine-{$i}"; + + Coroutine::create(function () use ($baseApp, $id, $done) { + $scope = new RequestScope($baseApp); + $scope->set('request', "request-{$id}"); + $scope->set('auth', "auth-{$id}"); + $scope->set('session', "session-{$id}"); + + Context::set('octane.request_scope', $scope); + + // Yield to let other coroutines run + Coroutine::sleep(0.01); + + // After yield, verify our scope is still intact + $myScope = Context::get('octane.request_scope'); + $done->push([ + 'id' => $id, + 'request' => $myScope->get('request'), + 'auth' => $myScope->get('auth'), + 'session' => $myScope->get('session'), + ]); + }); + } + + for ($i = 0; $i < 3; $i++) { + $results[] = $done->pop(); + } + }); + + $this->assertCount(3, $results); + + foreach ($results as $result) { + $id = $result['id']; + $this->assertSame("request-{$id}", $result['request'], + "Request binding leaked for {$id}"); + $this->assertSame("auth-{$id}", $result['auth'], + "Auth binding leaked for {$id}"); + $this->assertSame("session-{$id}", $result['session'], + "Session binding leaked for {$id}"); + } + } + + /** + * Test that process-scoped bindings (like router) are shared, not duplicated. + * + * @requires extension swoole + */ + public function test_process_scoped_bindings_are_shared(): void + { + $this->requiresSwoole(); + + $results = []; + + \Swoole\Coroutine\run(function () use (&$results) { + $baseApp = $this->app; + $done = new Channel(2); + + for ($i = 0; $i < 2; $i++) { + Coroutine::create(function () use ($baseApp, $done) { + // Process-scoped bindings should all return the same instance + $router = $baseApp->make('router'); + $done->push(spl_object_id($router)); + }); + } + + for ($i = 0; $i < 2; $i++) { + $results[] = $done->pop(); + } + }); + + // Both coroutines should get the exact same router instance + $this->assertCount(2, $results); + $this->assertSame($results[0], $results[1], + 'Process-scoped binding "router" should be shared across coroutines'); + } + + /** + * Test that config mutations in one coroutine don't leak to another. + * + * @requires extension swoole + */ + public function test_config_mutations_do_not_leak_between_coroutines(): void + { + $this->requiresSwoole(); + + $results = []; + + \Swoole\Coroutine\run(function () use (&$results) { + $baseApp = $this->app; + $done = new Channel(2); + + // Coroutine 1: mutates config + Coroutine::create(function () use ($baseApp, $done) { + $scope = new RequestScope($baseApp); + Context::set('octane.request_scope', $scope); + + // Copy-on-write: clone config before mutation + $scope->ensureConfigCloned(); + $config = $scope->get('config'); + $config->set('test.isolation', 'mutated-value'); + + Coroutine::sleep(0.01); + + $myConfig = Context::get('octane.request_scope')->get('config'); + $done->push([ + 'coroutine' => 1, + 'value' => $myConfig->get('test.isolation'), + ]); + }); + + // Coroutine 2: reads config (should not see mutation) + Coroutine::create(function () use ($baseApp, $done) { + $scope = new RequestScope($baseApp); + Context::set('octane.request_scope', $scope); + + Coroutine::sleep(0.02); + + // Should NOT have the mutated value since config wasn't cloned here + $baseConfig = $baseApp->make('config'); + $done->push([ + 'coroutine' => 2, + 'value' => $baseConfig->get('test.isolation'), + ]); + }); + + for ($i = 0; $i < 2; $i++) { + $results[] = $done->pop(); + } + }); + + $this->assertCount(2, $results); + + // Sort by coroutine number + usort($results, fn ($a, $b) => $a['coroutine'] <=> $b['coroutine']); + + // Coroutine 1 should see its own mutation + $this->assertSame('mutated-value', $results[0]['value']); + + // Coroutine 2 should NOT see coroutine 1's mutation (config was cloned COW) + $this->assertNull($results[1]['value']); + } + + /** + * Test that auth state doesn't leak between coroutines. + * + * @requires extension swoole + */ + public function test_auth_state_does_not_leak_between_coroutines(): void + { + $this->requiresSwoole(); + + $results = []; + + \Swoole\Coroutine\run(function () use (&$results) { + $baseApp = $this->app; + $done = new Channel(2); + + Coroutine::create(function () use ($baseApp, $done) { + $scope = new RequestScope($baseApp); + $scope->set('auth', 'user-alice'); + $scope->set('auth.driver', 'driver-alice'); + Context::set('octane.request_scope', $scope); + + Coroutine::sleep(0.01); + + $s = Context::get('octane.request_scope'); + $done->push(['auth' => $s->get('auth'), 'driver' => $s->get('auth.driver')]); + }); + + Coroutine::create(function () use ($baseApp, $done) { + $scope = new RequestScope($baseApp); + $scope->set('auth', 'user-bob'); + $scope->set('auth.driver', 'driver-bob'); + Context::set('octane.request_scope', $scope); + + Coroutine::sleep(0.01); + + $s = Context::get('octane.request_scope'); + $done->push(['auth' => $s->get('auth'), 'driver' => $s->get('auth.driver')]); + }); + + for ($i = 0; $i < 2; $i++) { + $results[] = $done->pop(); + } + }); + + $this->assertCount(2, $results); + + // Each coroutine should have its own auth state + $auths = array_column($results, 'auth'); + $this->assertContains('user-alice', $auths); + $this->assertContains('user-bob', $auths); + + // Verify each result is internally consistent + foreach ($results as $result) { + if ($result['auth'] === 'user-alice') { + $this->assertSame('driver-alice', $result['driver']); + } else { + $this->assertSame('driver-bob', $result['driver']); + } + } + } + + /** + * Test that session state doesn't leak between coroutines. + * + * @requires extension swoole + */ + public function test_session_state_does_not_leak_between_coroutines(): void + { + $this->requiresSwoole(); + + $results = []; + + \Swoole\Coroutine\run(function () use (&$results) { + $baseApp = $this->app; + $done = new Channel(2); + + Coroutine::create(function () use ($baseApp, $done) { + $scope = new RequestScope($baseApp); + $scope->set('session', 'session-for-user-1'); + Context::set('octane.request_scope', $scope); + + Coroutine::sleep(0.01); + + $done->push(Context::get('octane.request_scope')->get('session')); + }); + + Coroutine::create(function () use ($baseApp, $done) { + $scope = new RequestScope($baseApp); + $scope->set('session', 'session-for-user-2'); + Context::set('octane.request_scope', $scope); + + Coroutine::sleep(0.01); + + $done->push(Context::get('octane.request_scope')->get('session')); + }); + + for ($i = 0; $i < 2; $i++) { + $results[] = $done->pop(); + } + }); + + $this->assertCount(2, $results); + $this->assertContains('session-for-user-1', $results); + $this->assertContains('session-for-user-2', $results); + } + + /** + * Test that no 503 is returned regardless of concurrent request count. + * + * With the pool-free model there is no pool to exhaust, so any number + * of concurrent coroutines should succeed (no 503 response). + * + * @requires extension swoole + */ + public function test_no_503_regardless_of_concurrent_request_count(): void + { + $this->requiresSwoole(); + $this->app['config']->set('octane.swoole.pool.size', 1); + + $this->app['router']->get('/pool-free', function () { + Coroutine::sleep(0.01); + return response()->json(['ok' => true]); + }); + + $client = new FakeClient([]); + $worker = $this->createWorker($client); + $worker->boot(); + + $concurrentCount = 50; + + \Swoole\Coroutine\run(function () use ($worker, $concurrentCount) { + $done = new Channel($concurrentCount); + + for ($i = 0; $i < $concurrentCount; $i++) { + Coroutine::create(function () use ($worker, $done) { + $request = Request::create('/pool-free', 'GET'); + $context = new RequestContext(['request' => $request]); + $worker->handle($request, $context); + $done->push(true); + }); + } + + for ($i = 0; $i < $concurrentCount; $i++) { + $done->pop(); + } + }); + + // All requests should complete successfully -- no 503s + $this->assertCount($concurrentCount, $client->responses); + $this->assertEmpty($client->errors); + + foreach ($client->responses as $response) { + $this->assertSame(200, $response->getStatusCode()); + } + } + + /** + * Test that RequestScope is properly cleared after each request. + * + * @requires extension swoole + */ + public function test_request_scope_is_cleared_after_request(): void + { + $this->requiresSwoole(); + + $scopeCleared = []; + + \Swoole\Coroutine\run(function () use (&$scopeCleared) { + $baseApp = $this->app; + $done = new Channel(2); + + for ($i = 0; $i < 2; $i++) { + Coroutine::create(function () use ($baseApp, $done, $i) { + $scope = new RequestScope($baseApp); + $scope->set('request', "request-{$i}"); + $scope->set('auth', "auth-{$i}"); + Context::set('octane.request_scope', $scope); + + // Simulate request processing + Coroutine::sleep(0.01); + + // Clean up (as Worker::handle() does in finally block) + $scope->clear(); + Context::clear(); + + // After cleanup, scope should be empty + $done->push([ + 'scope_empty' => count($scope->all()) === 0, + 'context_empty' => ! Context::has('octane.request_scope'), + ]); + }); + } + + for ($i = 0; $i < 2; $i++) { + $scopeCleared[] = $done->pop(); + } + }); + + foreach ($scopeCleared as $result) { + $this->assertTrue($result['scope_empty'], 'RequestScope should be empty after clear'); + $this->assertTrue($result['context_empty'], 'Context should not have request_scope after clear'); + } + } + + /** + * Test that CoroutineApplication.make() routes request-scoped keys through RequestScope. + * + * @requires extension swoole + */ + public function test_coroutine_application_routes_request_scoped_keys_through_scope(): void + { + $this->requiresSwoole(); + + $results = []; + + \Swoole\Coroutine\run(function () use (&$results) { + $baseApp = $this->app; + $proxy = new CoroutineApplication($baseApp); + $done = new Channel(2); + + Coroutine::create(function () use ($proxy, $baseApp, $done) { + $scope = new RequestScope($baseApp); + $scope->set('request', 'request-from-scope-A'); + Context::set('octane.request_scope', $scope); + + // make('request') should return from RequestScope, not base app + $done->push($proxy->make('request')); + }); + + Coroutine::create(function () use ($proxy, $baseApp, $done) { + $scope = new RequestScope($baseApp); + $scope->set('request', 'request-from-scope-B'); + Context::set('octane.request_scope', $scope); + + $done->push($proxy->make('request')); + }); + + for ($i = 0; $i < 2; $i++) { + $results[] = $done->pop(); + } + }); + + $this->assertCount(2, $results); + $this->assertContains('request-from-scope-A', $results); + $this->assertContains('request-from-scope-B', $results); + } + + /** + * Test that CoroutineApplication.instance() stores request-scoped keys in RequestScope. + * + * @requires extension swoole + */ + public function test_coroutine_application_instance_stores_in_scope(): void + { + $this->requiresSwoole(); + + $result = null; + + \Swoole\Coroutine\run(function () use (&$result) { + $baseApp = $this->app; + $proxy = new CoroutineApplication($baseApp); + + Coroutine::create(function () use ($proxy, $baseApp, &$result) { + $scope = new RequestScope($baseApp); + Context::set('octane.request_scope', $scope); + + // instance() for request-scoped key should store in scope + $proxy->instance('auth', 'stored-auth-instance'); + + // Verify it's in the scope + $result = [ + 'in_scope' => $scope->has('auth'), + 'from_make' => $proxy->make('auth'), + 'from_bound' => $proxy->bound('auth'), + ]; + }); + }); + + $this->assertTrue($result['in_scope']); + $this->assertSame('stored-auth-instance', $result['from_make']); + $this->assertTrue($result['from_bound']); + } + + /** + * Test full request lifecycle with pool-free Worker::handle(). + * + * @requires extension swoole + */ + public function test_full_request_lifecycle_with_pool_free_worker(): void + { + $this->requiresSwoole(); + + $this->app['router']->get('/lifecycle', function (Request $request) { + return response()->json([ + 'path' => $request->path(), + 'helper_path' => app('request')->path(), + 'method' => $request->method(), + ]); + }); + + $client = new FakeClient([]); + $worker = $this->createWorker($client); + $worker->boot(); + + \Swoole\Coroutine\run(function () use ($worker) { + $done = new Channel(1); + + Coroutine::create(function () use ($worker, $done) { + $request = Request::create('/lifecycle', 'GET'); + $context = new RequestContext(['request' => $request]); + $worker->handle($request, $context); + $done->push(true); + }); + + $done->pop(); + }); + + $this->assertCount(1, $client->responses); + $payload = json_decode($client->responses[0]->getContent(), true); + $this->assertSame('lifecycle', $payload['path']); + $this->assertSame('lifecycle', $payload['helper_path']); + $this->assertSame('GET', $payload['method']); + } +} diff --git a/tests/Coroutine/RequestScopeTest.php b/tests/Coroutine/RequestScopeTest.php new file mode 100644 index 0000000..17dde65 --- /dev/null +++ b/tests/Coroutine/RequestScopeTest.php @@ -0,0 +1,167 @@ +createMock(Application::class); + + return $app; + } + + public function test_set_and_get_binding(): void + { + $app = $this->createMockApp(); + $scope = new RequestScope($app); + + $scope->set('request', 'fake-request'); + + $this->assertTrue($scope->has('request')); + $this->assertSame('fake-request', $scope->get('request')); + } + + public function test_has_returns_false_for_missing_key(): void + { + $app = $this->createMockApp(); + $scope = new RequestScope($app); + + $this->assertFalse($scope->has('nonexistent')); + } + + public function test_get_returns_null_for_missing_key(): void + { + $app = $this->createMockApp(); + $scope = new RequestScope($app); + + $this->assertNull($scope->get('nonexistent')); + } + + public function test_forget_removes_binding(): void + { + $app = $this->createMockApp(); + $scope = new RequestScope($app); + + $scope->set('session', 'fake-session'); + $this->assertTrue($scope->has('session')); + + $scope->forget('session'); + $this->assertFalse($scope->has('session')); + $this->assertNull($scope->get('session')); + } + + public function test_clear_removes_all_bindings(): void + { + $app = $this->createMockApp(); + $scope = new RequestScope($app); + + $scope->set('request', 'req1'); + $scope->set('session', 'sess1'); + $scope->set('auth', 'auth1'); + + $this->assertCount(3, $scope->all()); + + $scope->clear(); + + $this->assertCount(0, $scope->all()); + $this->assertFalse($scope->has('request')); + $this->assertFalse($scope->has('session')); + $this->assertFalse($scope->has('auth')); + } + + public function test_clearing_one_scope_does_not_affect_another(): void + { + $app = $this->createMockApp(); + + $scope1 = new RequestScope($app); + $scope2 = new RequestScope($app); + + $scope1->set('request', 'req-alpha'); + $scope2->set('request', 'req-bravo'); + + $scope1->clear(); + + // scope1 is cleared + $this->assertFalse($scope1->has('request')); + $this->assertNull($scope1->get('request')); + + // scope2 is unaffected + $this->assertTrue($scope2->has('request')); + $this->assertSame('req-bravo', $scope2->get('request')); + } + + public function test_all_returns_all_bindings(): void + { + $app = $this->createMockApp(); + $scope = new RequestScope($app); + + $scope->set('request', 'r'); + $scope->set('auth', 'a'); + + $all = $scope->all(); + + $this->assertArrayHasKey('request', $all); + $this->assertArrayHasKey('auth', $all); + $this->assertSame('r', $all['request']); + $this->assertSame('a', $all['auth']); + } + + public function test_get_app_returns_base_application(): void + { + $app = $this->createMockApp(); + $scope = new RequestScope($app); + + $this->assertSame($app, $scope->getApp()); + } + + public function test_config_cloned_flag_is_initially_false(): void + { + $app = $this->createMockApp(); + $scope = new RequestScope($app); + + $this->assertFalse($scope->isConfigCloned()); + } + + public function test_clear_resets_config_cloned_flag(): void + { + $app = $this->createMockApp(); + $scope = new RequestScope($app); + + // Manually set config to simulate cloning + $scope->set('config', 'cloned-config'); + + $scope->clear(); + + $this->assertFalse($scope->isConfigCloned()); + $this->assertFalse($scope->has('config')); + } + + public function test_overwriting_binding_replaces_value(): void + { + $app = $this->createMockApp(); + $scope = new RequestScope($app); + + $scope->set('auth', 'user-1'); + $this->assertSame('user-1', $scope->get('auth')); + + $scope->set('auth', 'user-2'); + $this->assertSame('user-2', $scope->get('auth')); + } + + public function test_has_returns_true_for_null_value(): void + { + $app = $this->createMockApp(); + $scope = new RequestScope($app); + + // array_key_exists returns true even for null values + $scope->set('cookie', null); + + $this->assertTrue($scope->has('cookie')); + $this->assertNull($scope->get('cookie')); + } +} diff --git a/tests/Feature/WorkerCoroutineIsolationTest.php b/tests/Feature/WorkerCoroutineIsolationTest.php index 3a398e6..fdc699f 100644 --- a/tests/Feature/WorkerCoroutineIsolationTest.php +++ b/tests/Feature/WorkerCoroutineIsolationTest.php @@ -85,4 +85,67 @@ public function test_concurrent_requests_do_not_leak_state(): void $this->assertSame([true, true], $contextCleared); } + + /** + * @requires extension swoole + */ + public function test_route_injected_request_matches_helper_per_coroutine(): void + { + if (! class_exists(Coroutine::class) || ! function_exists('Swoole\\Coroutine\\run')) { + $this->markTestSkipped('Swoole coroutine support is required.'); + } + + $this->app['router']->get('/request-check', function (Request $request) { + Coroutine::sleep(0.01); + + return response()->json([ + 'injected' => [ + 'path' => $request->path(), + 'request_id' => $request->query('request_id'), + 'header' => $request->header('X-Test-Id'), + ], + 'helper' => [ + 'path' => request()->path(), + 'request_id' => request()->query('request_id'), + 'header' => request()->header('X-Test-Id'), + ], + ]); + }); + + $client = new FakeClient([]); + $worker = $this->createWorker($client); + $worker->boot(); + + $requests = [ + Request::create('/request-check?request_id=alpha', 'GET', [], [], [], ['HTTP_X_TEST_ID' => 'alpha']), + Request::create('/request-check?request_id=bravo', 'GET', [], [], [], ['HTTP_X_TEST_ID' => 'bravo']), + ]; + + \Swoole\Coroutine\run(function () use ($worker, $requests) { + $done = new Channel(count($requests)); + + foreach ($requests as $request) { + Coroutine::create(function () use ($worker, $request, $done) { + $context = new RequestContext(['request' => $request]); + $worker->handle($request, $context); + $done->push(true); + }); + } + + for ($i = 0; $i < count($requests); $i++) { + $done->pop(); + } + }); + + $this->assertCount(2, $client->responses); + + foreach ($client->responses as $response) { + $payload = json_decode($response->getContent(), true); + + $this->assertIsArray($payload); + $this->assertSame($payload['injected']['path'], $payload['helper']['path']); + $this->assertSame($payload['injected']['request_id'], $payload['helper']['request_id']); + $this->assertSame($payload['injected']['header'], $payload['helper']['header']); + } + } } diff --git a/tests/Unit/OnWorkerStartPoolFreeTest.php b/tests/Unit/OnWorkerStartPoolFreeTest.php new file mode 100644 index 0000000..525999d --- /dev/null +++ b/tests/Unit/OnWorkerStartPoolFreeTest.php @@ -0,0 +1,112 @@ +markTestSkipped('Swoole coroutine support is required.'); + } + } + + public function test_http_worker_boot_uses_single_worker_and_does_not_create_pools_even_when_pool_config_exists(): void + { + $this->requiresSwoole(); + + $workerState = new \Laravel\Octane\Swoole\WorkerState(); + $client = new FakeClient([]); + + $fakeWorker = $this->createMock(Worker::class); + $fakeWorker->method('application')->willReturn($this->app); + $fakeWorker->method('getClient')->willReturn($client); + + $handler = new TestableOnWorkerStart( + new SwooleExtension(), + $this->app->basePath(), + [ + 'appName' => 'octane-coroutine-test', + 'octaneConfig' => [ + 'swoole' => [ + 'pool' => ['size' => 32], + ], + ], + ], + $workerState, + false, + $fakeWorker, + ); + + $server = (object) ['setting' => ['worker_num' => 4]]; + $bootedWorker = null; + + \Swoole\Coroutine\run(function () use ($handler, $server, &$bootedWorker) { + $bootedWorker = $handler->bootHttpWorkerForTest($server, 1); + }); + + $this->assertSame($fakeWorker, $bootedWorker); + $this->assertSame([[1, 0]], $handler->createPoolWorkerInvocations); + $this->assertSame($fakeWorker, $workerState->worker); + $this->assertSame($client, $workerState->client); + $this->assertTrue($workerState->ready); + $this->assertNull($workerState->workerPool); + $this->assertNull($workerState->clientPool); + + $container = Container::getInstance(); + + $this->assertInstanceOf(CoroutineApplication::class, $container); + $this->assertSame($container, Facade::getFacadeApplication()); + $this->assertSame($this->app, $container->getBaseApplication()); + } +} + +class TestableOnWorkerStart extends OnWorkerStart +{ + public array $createPoolWorkerInvocations = []; + + public function __construct( + SwooleExtension $extension, + string $basePath, + array $serverState, + $workerState, + bool $shouldSetProcessName, + private Worker $fakeWorker, + ) { + parent::__construct($extension, $basePath, $serverState, $workerState, $shouldSetProcessName); + } + + public function bootHttpWorkerForTest($server, int $workerId): ?Worker + { + return $this->bootHttpWorker($server, $workerId); + } + + public function createPoolWorker($server, int $workerId, int $poolIndex): Worker + { + $this->createPoolWorkerInvocations[] = [$workerId, $poolIndex]; + + return $this->fakeWorker; + } + + protected function warnIfDatabasePoolMinExceedsMaxConnections(Worker $worker, $server): void + { + } +} From 8e3d2e9b12e46d21369fb21a2bb9d1c651522116 Mon Sep 17 00:00:00 2001 From: Adhik Joshi Date: Thu, 9 Apr 2026 17:06:06 +0530 Subject: [PATCH 2/2] fixed issues --- .../ProvidesDefaultConfigurationOptions.php | 48 +++ src/Swoole/Coroutine/RequestScope.php | 325 +++++++++++++++++- src/Swoole/Coroutine/ScopedRouter.php | 105 ++++++ src/Swoole/Handlers/OnWorkerStart.php | 19 + .../Feature/WorkerCoroutineIsolationTest.php | 60 ++++ tests/Unit/CoroutineConfigurationTest.php | 64 ++++ tests/Unit/OnWorkerStartPoolFreeTest.php | 14 + .../RequestScopeHttpKernelIsolationTest.php | 62 ++++ tests/Unit/RequestScopeLogIsolationTest.php | 64 ++++ tests/Unit/RequestScopeRedisIsolationTest.php | 97 ++++++ ...estScopeSessionMiddlewareIsolationTest.php | 75 ++++ tests/Unit/RequestScopeViewIsolationTest.php | 64 ++++ 12 files changed, 990 insertions(+), 7 deletions(-) create mode 100644 src/Swoole/Coroutine/ScopedRouter.php create mode 100644 tests/Unit/CoroutineConfigurationTest.php create mode 100644 tests/Unit/RequestScopeHttpKernelIsolationTest.php create mode 100644 tests/Unit/RequestScopeLogIsolationTest.php create mode 100644 tests/Unit/RequestScopeRedisIsolationTest.php create mode 100644 tests/Unit/RequestScopeSessionMiddlewareIsolationTest.php create mode 100644 tests/Unit/RequestScopeViewIsolationTest.php diff --git a/src/Concerns/ProvidesDefaultConfigurationOptions.php b/src/Concerns/ProvidesDefaultConfigurationOptions.php index fa38da8..72f83d7 100644 --- a/src/Concerns/ProvidesDefaultConfigurationOptions.php +++ b/src/Concerns/ProvidesDefaultConfigurationOptions.php @@ -4,6 +4,22 @@ trait ProvidesDefaultConfigurationOptions { + /** + * Determine if the runtime is using the Swoole coroutine application proxy. + */ + protected static function usingSwooleCoroutineMode(): bool + { + return env('OCTANE_SERVER', 'roadrunner') === 'swoole' && extension_loaded('swoole'); + } + + /** + * Determine if a listener should run once at worker boot in coroutine mode. + */ + protected static function isCoroutineBootOnlyListener(string $listener): bool + { + return str_starts_with($listener, 'Laravel\\Octane\\Listeners\\GiveNewApplicationInstanceTo'); + } + /** * Get the listeners that will prepare the Laravel application for a new request. */ @@ -25,6 +41,38 @@ public static function prepareApplicationForNextRequest(): array * Get the listeners that will prepare the Laravel application for a new operation. */ public static function prepareApplicationForNextOperation(): array + { + $listeners = static::prepareApplicationForNextOperationListeners(); + + if (! static::usingSwooleCoroutineMode()) { + return $listeners; + } + + return array_values(array_filter( + $listeners, + fn (string $listener) => ! static::isCoroutineBootOnlyListener($listener) + )); + } + + /** + * Get the listeners that should run once at worker boot in coroutine mode. + */ + public static function prepareApplicationForCoroutineBoot(): array + { + if (! static::usingSwooleCoroutineMode()) { + return []; + } + + return array_values(array_filter( + static::prepareApplicationForNextOperationListeners(), + fn (string $listener) => static::isCoroutineBootOnlyListener($listener) + )); + } + + /** + * Get the full operation listener list before coroutine-mode filtering. + */ + protected static function prepareApplicationForNextOperationListeners(): array { return [ \Laravel\Octane\Listeners\CreateConfigurationSandbox::class, diff --git a/src/Swoole/Coroutine/RequestScope.php b/src/Swoole/Coroutine/RequestScope.php index 1bd708b..cb402bb 100644 --- a/src/Swoole/Coroutine/RequestScope.php +++ b/src/Swoole/Coroutine/RequestScope.php @@ -11,11 +11,12 @@ * Lightweight per-request state storage for coroutine isolation. * * Instead of cloning the entire Application (~3-5MB) per request, - * this class holds only the ~10 request-scoped bindings that need + * this class holds only the request-scoped bindings that need * per-coroutine isolation (request, session, auth, config, url, cookie). * - * Process-scoped bindings (router, db, cache, etc.) remain on the - * shared base Application and are accessed directly. + * Most process-scoped bindings remain on the shared base application. + * Redis-backed managers are scoped because shared phpredis sockets are + * not safe to reuse across concurrent coroutines in the same worker. */ class RequestScope { @@ -139,27 +140,113 @@ public function resolve(string $key, Application $sandbox) return $this->get($key); } - $resolved = match ($key) { + $resolved = $this->resolveSpecializedBinding($key, $sandbox); + + if ($resolved !== null || $key === 'request') { + $this->bindings[$key] = $resolved; + $this->rememberAliasBindings($key, $resolved); + } + + return $resolved; + } + + /** + * Resolve bindings that need specialized coroutine-aware instances. + * + * @param string $key + * @param \Illuminate\Foundation\Application $sandbox + * @return mixed + */ + protected function resolveSpecializedBinding(string $key, Application $sandbox) + { + if ($this->isHttpKernelBinding($key)) { + return $this->createHttpKernel($sandbox); + } + + return match ($key) { 'auth' => $this->createAuthManager($sandbox), 'auth.driver' => $this->createAuthDriver($sandbox), + 'cache', \Illuminate\Contracts\Cache\Factory::class => $this->createCacheManager($sandbox), + 'cache.store', \Illuminate\Contracts\Cache\Repository::class => $this->createCacheStore($sandbox), 'config' => $this->cloneConfig(), 'cookie' => $this->createCookieJar(), + \Inertia\ResponseFactory::class => $this->createInertiaResponseFactory(), + 'log', \Psr\Log\LoggerInterface::class => $this->createLogManager($sandbox), 'redirect' => $this->createRedirector($sandbox), + 'redis', \Illuminate\Contracts\Redis\Factory::class => $this->createRedisManager($sandbox), 'request' => $this->get('request'), + 'router', \Illuminate\Routing\Router::class, \Illuminate\Contracts\Routing\BindingRegistrar::class, \Illuminate\Contracts\Routing\Registrar::class => $this->createRouter($sandbox), 'session' => $this->createSessionManager($sandbox), 'session.store' => $this->createSessionStore($sandbox), + \Illuminate\Session\Middleware\StartSession::class => $this->createStartSessionMiddleware($sandbox), 'url' => $this->createUrlGenerator($sandbox), + 'view', \Illuminate\Contracts\View\Factory::class => $this->createViewFactory($sandbox), \Illuminate\Routing\Contracts\CallableDispatcher::class => new \Illuminate\Routing\CallableDispatcher($sandbox), \Illuminate\Routing\Contracts\ControllerDispatcher::class => new \Illuminate\Routing\ControllerDispatcher($sandbox), \Illuminate\Contracts\Routing\ResponseFactory::class => $this->createResponseFactory($sandbox), default => null, }; + } - if ($resolved !== null || $key === 'request') { - $this->bindings[$key] = $resolved; + /** + * Determine if the binding is an HTTP kernel implementation. + */ + protected function isHttpKernelBinding(string $key): bool + { + return $key === \Illuminate\Contracts\Http\Kernel::class + || $key === \Illuminate\Foundation\Http\Kernel::class + || (class_exists($key) && is_subclass_of($key, \Illuminate\Foundation\Http\Kernel::class)); + } + + /** + * Store equivalent aliases for scoped objects so repeated resolution + * within one request returns the same request-local instance. + * + * @param string $key + * @param mixed $resolved + * @return void + */ + protected function rememberAliasBindings(string $key, $resolved): void + { + if ($this->isHttpKernelBinding($key) && is_object($resolved)) { + $this->bindings[\Illuminate\Contracts\Http\Kernel::class] = $resolved; + $this->bindings[\Illuminate\Foundation\Http\Kernel::class] = $resolved; + $this->bindings[$resolved::class] = $resolved; + + return; } - return $resolved; + match ($key) { + 'router', + \Illuminate\Routing\Router::class, + \Illuminate\Contracts\Routing\BindingRegistrar::class, + \Illuminate\Contracts\Routing\Registrar::class => $this->storeScopedAliases([ + 'router', + \Illuminate\Routing\Router::class, + \Illuminate\Contracts\Routing\BindingRegistrar::class, + \Illuminate\Contracts\Routing\Registrar::class, + ], $resolved), + 'view', + \Illuminate\Contracts\View\Factory::class => $this->storeScopedAliases([ + 'view', + \Illuminate\Contracts\View\Factory::class, + ], $resolved), + default => null, + }; + } + + /** + * Store a resolved instance under multiple equivalent keys. + * + * @param array $aliases + * @param mixed $resolved + * @return void + */ + protected function storeScopedAliases(array $aliases, $resolved): void + { + foreach ($aliases as $alias) { + $this->bindings[$alias] = $resolved; + } } /** @@ -299,6 +386,170 @@ protected function createSessionStore(Application $sandbox) return $session?->driver(); } + /** + * Create an isolated StartSession middleware bound to the coroutine sandbox. + * + * StartSession is registered as a singleton in Laravel, so resolving it from the + * shared base container would pin a worker-level SessionManager into the web stack. + * + * @param \Illuminate\Foundation\Application $sandbox + * @return \Illuminate\Session\Middleware\StartSession + */ + protected function createStartSessionMiddleware(Application $sandbox): \Illuminate\Session\Middleware\StartSession + { + return new \Illuminate\Session\Middleware\StartSession( + $this->resolve('session', $sandbox), + static fn () => $sandbox->make(\Illuminate\Contracts\Cache\Factory::class), + ); + } + + /** + * Create an isolated HTTP kernel bound to the coroutine sandbox and router. + * + * The framework kernel is registered as a singleton and captures the shared + * router in its constructor. In coroutine mode we need a per-request clone + * so kernel request timestamps and router state are not shared. + * + * @param \Illuminate\Foundation\Application $sandbox + * @return mixed + */ + protected function createHttpKernel(Application $sandbox) + { + $kernel = clone $this->app->make(\Illuminate\Contracts\Http\Kernel::class); + + if (method_exists($kernel, 'setApplication')) { + $kernel->setApplication($sandbox); + } else { + $this->setObjectProperty($kernel, 'app', $sandbox); + } + + $this->setObjectProperty($kernel, 'router', $sandbox->make('router')); + $this->setObjectProperty($kernel, 'requestStartedAt', null); + $this->invokeObjectMethod($kernel, 'syncMiddlewareToRouter'); + + return $kernel; + } + + /** + * Create an isolated router bound to the coroutine sandbox. + * + * The shared router stores the current request and current route on mutable + * instance properties, so concurrent requests must not reuse the same object. + * + * @param \Illuminate\Foundation\Application $sandbox + * @return \Illuminate\Routing\Router + */ + protected function createRouter(Application $sandbox): \Illuminate\Routing\Router + { + return new ScopedRouter($this->app->make('router'), $sandbox); + } + + /** + * Create an isolated Redis manager for the current coroutine. + * + * phpredis persistent sockets are process-shared by persistent_id. + * In coroutine mode that means concurrent requests in one worker can + * contend on the same socket. The coroutine-local manager disables + * persistence so each request gets its own short-lived connection. + * + * @param \Illuminate\Foundation\Application $sandbox + * @return mixed + */ + protected function createRedisManager(Application $sandbox) + { + $redis = clone $this->app->make('redis'); + + $this->setObjectProperty($redis, 'app', $sandbox); + $this->setObjectProperty($redis, 'connections', []); + $this->setObjectProperty($redis, 'config', $this->createCoroutineRedisConfig($sandbox)); + + return $redis; + } + + /** + * Create an isolated cache manager for the current coroutine. + * + * @param \Illuminate\Foundation\Application $sandbox + * @return mixed + */ + protected function createCacheManager(Application $sandbox) + { + $cache = clone $this->app->make('cache'); + + $this->setObjectProperty($cache, 'app', $sandbox); + $this->setObjectProperty($cache, 'stores', []); + + return $cache; + } + + /** + * Create the coroutine-local default cache repository. + * + * @param \Illuminate\Foundation\Application $sandbox + * @return mixed + */ + protected function createCacheStore(Application $sandbox) + { + $cache = $this->resolve('cache', $sandbox); + + return $cache?->store(); + } + + /** + * Create an isolated Inertia response factory for the current coroutine. + * + * @return \Inertia\ResponseFactory + */ + protected function createInertiaResponseFactory(): \Inertia\ResponseFactory + { + return new \Inertia\ResponseFactory; + } + + /** + * Create an isolated view factory for the current coroutine. + * + * The base view factory keeps shared data and render-state arrays on the + * instance. Cloning preserves boot-time composers and shared globals while + * isolating per-request calls to share() and Blade render bookkeeping. + * + * @param \Illuminate\Foundation\Application $sandbox + * @return \Illuminate\Contracts\View\Factory + */ + protected function createViewFactory(Application $sandbox): \Illuminate\Contracts\View\Factory + { + /** @var \Illuminate\View\Factory $view */ + $view = clone $this->app->make('view'); + $view->setContainer($sandbox); + $view->share('app', $sandbox); + $view->flushState(); + + return $view; + } + + /** + * Create an isolated log manager for the current coroutine. + * + * Log::shareContext() mutates worker-level state on the shared manager. + * Reset resolved channels and shared context so each coroutine gets a + * clean logger view while reusing the worker-level logging config. + * + * @param \Illuminate\Foundation\Application $sandbox + * @return mixed + */ + protected function createLogManager(Application $sandbox) + { + $log = clone $this->app->make('log'); + + if (method_exists($log, 'setApplication')) { + $log->setApplication($sandbox); + } + + $this->setObjectProperty($log, 'channels', []); + $this->setObjectProperty($log, 'sharedContext', []); + + return $log; + } + /** * Create a redirector bound to the coroutine sandbox. * @@ -359,6 +610,36 @@ protected function createUrlGenerator(Application $sandbox) return $url; } + /** + * Prepare Redis configuration for a coroutine-local manager. + * + * @param \Illuminate\Foundation\Application $sandbox + * @return array + */ + protected function createCoroutineRedisConfig(Application $sandbox): array + { + $redisConfig = $sandbox->make('config')->get('database.redis'); + + if (! is_array($redisConfig)) { + return []; + } + + foreach ($redisConfig as $name => $connection) { + if (! is_array($connection) || in_array($name, ['client', 'options', 'clusters'], true)) { + continue; + } + + if (($connection['persistent'] ?? false) !== true) { + continue; + } + + $redisConfig[$name]['persistent'] = false; + unset($redisConfig[$name]['persistent_id']); + } + + return $redisConfig; + } + /** * Set a protected property on an object and its parent classes. * @@ -387,4 +668,34 @@ protected function setObjectProperty(object $object, string $property, $value): // If the implementation changes upstream, fall back gracefully. } } + + /** + * Invoke a protected method on an object when upstream does not expose it. + * + * @param object $object + * @param string $method + * @param array $parameters + * @return mixed + */ + protected function invokeObjectMethod(object $object, string $method, array $parameters = []) + { + try { + $reflection = new ReflectionClass($object); + + while (! $reflection->hasMethod($method) && $reflection->getParentClass()) { + $reflection = $reflection->getParentClass(); + } + + if (! $reflection->hasMethod($method)) { + return null; + } + + $instanceMethod = $reflection->getMethod($method); + $instanceMethod->setAccessible(true); + + return $instanceMethod->invokeArgs($object, $parameters); + } catch (ReflectionException) { + return null; + } + } } diff --git a/src/Swoole/Coroutine/ScopedRouter.php b/src/Swoole/Coroutine/ScopedRouter.php new file mode 100644 index 0000000..f6c6281 --- /dev/null +++ b/src/Swoole/Coroutine/ScopedRouter.php @@ -0,0 +1,105 @@ +readProperty($router, 'events'), $container); + + $this->routes = $router->getRoutes(); + $this->middleware = $router->getMiddleware(); + $this->middlewareGroups = $router->getMiddlewareGroups(); + $this->middlewarePriority = $router->middlewarePriority; + $this->binders = $this->readProperty($router, 'binders') ?? []; + $this->patterns = $this->readProperty($router, 'patterns') ?? []; + $this->groupStack = $this->readProperty($router, 'groupStack') ?? []; + $this->implicitBindingCallback = $this->readProperty($router, 'implicitBindingCallback'); + } + + /** + * Return the response returned by the given route. + * + * @param string $name + * @return \Symfony\Component\HttpFoundation\Response + */ + public function respondWithRoute($name) + { + $route = tap($this->cloneRoute($this->routes->getByName($name)))->bind($this->currentRequest); + + return $this->runRoute($this->currentRequest, $route); + } + + /** + * Find the route matching a given request. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Routing\Route + */ + protected function findRoute($request) + { + $this->events->dispatch(new Routing($request)); + + $this->current = $route = $this->cloneRoute($this->routes->match($request)); + + $this->container->instance(Route::class, $route); + + return $route; + } + + /** + * Clone the matched route so per-request parameter and container state + * does not mutate the shared route definitions collection. + * + * @param \Illuminate\Routing\Route|null $route + * @return \Illuminate\Routing\Route + */ + protected function cloneRoute(?Route $route): Route + { + $route = clone $route; + $route->setRouter($this); + $route->setContainer($this->container); + + return $route; + } + + /** + * Read a protected property from the base router. + * + * @param object $object + * @param string $property + * @return mixed + */ + protected function readProperty(object $object, string $property) + { + $reflection = new \ReflectionClass($object); + + while (! $reflection->hasProperty($property) && $reflection->getParentClass()) { + $reflection = $reflection->getParentClass(); + } + + if (! $reflection->hasProperty($property)) { + return null; + } + + $instanceProperty = $reflection->getProperty($property); + $instanceProperty->setAccessible(true); + + return $instanceProperty->getValue($object); + } +} diff --git a/src/Swoole/Handlers/OnWorkerStart.php b/src/Swoole/Handlers/OnWorkerStart.php index 067ff41..156fdd7 100644 --- a/src/Swoole/Handlers/OnWorkerStart.php +++ b/src/Swoole/Handlers/OnWorkerStart.php @@ -2,7 +2,9 @@ namespace Laravel\Octane\Swoole\Handlers; +use Illuminate\Foundation\Application; use Laravel\Octane\ApplicationFactory; +use Laravel\Octane\Octane; use Laravel\Octane\Stream; use Laravel\Octane\Swoole\Coroutine\Context; use Laravel\Octane\Swoole\Coroutine\CoordinatorManager; @@ -162,6 +164,8 @@ protected function bootHttpWorker($server, int $workerId) Facade::setFacadeApplication($coroutineApp); FacadeCache::disable(); + $this->prepareCoroutineApplicationForWorkerBoot($baseApp, $coroutineApp); + Context::set('octane.worker_id', $workerId); Context::set('octane.worker_pid', $this->workerState->workerPid); @@ -207,6 +211,21 @@ public function createPoolWorker(Server $server, int $workerId, int $poolIndex): return $worker; } + /** + * Run listeners that only need to wire the shared coroutine proxy once. + */ + protected function prepareCoroutineApplicationForWorkerBoot(Application $app, Application $sandbox): void + { + $event = (object) [ + 'app' => $app, + 'sandbox' => $sandbox, + ]; + + foreach (Octane::prepareApplicationForCoroutineBoot() as $listener) { + $app->make($listener)->handle($event); + } + } + /** * Ensure Redis connections are safe for concurrent coroutines. * diff --git a/tests/Feature/WorkerCoroutineIsolationTest.php b/tests/Feature/WorkerCoroutineIsolationTest.php index fdc699f..67e855c 100644 --- a/tests/Feature/WorkerCoroutineIsolationTest.php +++ b/tests/Feature/WorkerCoroutineIsolationTest.php @@ -148,4 +148,64 @@ public function test_route_injected_request_matches_helper_per_coroutine(): void $this->assertSame($payload['injected']['header'], $payload['helper']['header']); } } + + /** + * @requires extension swoole + */ + public function test_router_and_view_factory_are_isolated_per_coroutine(): void + { + if (! class_exists(Coroutine::class) || ! function_exists('Swoole\\Coroutine\\run')) { + $this->markTestSkipped('Swoole coroutine support is required.'); + } + + $this->app['router']->get('/router-view-check', function (Request $request) { + $requestId = (string) $request->query('request_id'); + $view = app('view'); + + $view->share('request_id', $requestId); + + Coroutine::sleep(0.05); + + return response()->json([ + 'request_id' => $requestId, + 'router_request_id' => app('router')->getCurrentRequest()?->query('request_id'), + 'view_request_id' => $view->shared('request_id'), + ]); + }); + + $client = new FakeClient([]); + $worker = $this->createWorker($client); + $worker->boot(); + + $requests = [ + Request::create('/router-view-check?request_id=alpha', 'GET'), + Request::create('/router-view-check?request_id=bravo', 'GET'), + ]; + + \Swoole\Coroutine\run(function () use ($worker, $requests) { + $done = new Channel(count($requests)); + + foreach ($requests as $request) { + Coroutine::create(function () use ($worker, $request, $done) { + $context = new RequestContext(['request' => $request]); + $worker->handle($request, $context); + $done->push(true); + }); + } + + for ($i = 0; $i < count($requests); $i++) { + $done->pop(); + } + }); + + $this->assertCount(2, $client->responses); + + foreach ($client->responses as $response) { + $payload = json_decode($response->getContent(), true); + + $this->assertIsArray($payload); + $this->assertSame($payload['request_id'], $payload['router_request_id']); + $this->assertSame($payload['request_id'], $payload['view_request_id']); + } + } } diff --git a/tests/Unit/CoroutineConfigurationTest.php b/tests/Unit/CoroutineConfigurationTest.php new file mode 100644 index 0000000..41d5b68 --- /dev/null +++ b/tests/Unit/CoroutineConfigurationTest.php @@ -0,0 +1,64 @@ +previousOctaneServer = getenv('OCTANE_SERVER'); + } + + protected function tearDown(): void + { + if ($this->previousOctaneServer === false) { + putenv('OCTANE_SERVER'); + } else { + putenv('OCTANE_SERVER='.$this->previousOctaneServer); + } + + parent::tearDown(); + } + + public function test_swoole_coroutine_mode_moves_application_rebinding_listeners_to_worker_boot(): void + { + if (! extension_loaded('swoole')) { + $this->markTestSkipped('Swoole extension is required.'); + } + + putenv('OCTANE_SERVER=swoole'); + + $perRequestListeners = Octane::prepareApplicationForNextOperation(); + $bootListeners = Octane::prepareApplicationForCoroutineBoot(); + + $this->assertNotContains(GiveNewApplicationInstanceToRouter::class, $perRequestListeners); + $this->assertNotContains(GiveNewApplicationInstanceToHttpKernel::class, $perRequestListeners); + $this->assertContains(CreateConfigurationSandbox::class, $perRequestListeners); + + $this->assertContains(GiveNewApplicationInstanceToRouter::class, $bootListeners); + $this->assertContains(GiveNewApplicationInstanceToHttpKernel::class, $bootListeners); + $this->assertNotContains(CreateConfigurationSandbox::class, $bootListeners); + } + + public function test_non_swoole_modes_keep_application_rebinding_listeners_in_per_request_pipeline(): void + { + putenv('OCTANE_SERVER=roadrunner'); + + $perRequestListeners = Octane::prepareApplicationForNextOperation(); + $bootListeners = Octane::prepareApplicationForCoroutineBoot(); + + $this->assertContains(GiveNewApplicationInstanceToRouter::class, $perRequestListeners); + $this->assertContains(GiveNewApplicationInstanceToHttpKernel::class, $perRequestListeners); + $this->assertSame([], $bootListeners); + } +} diff --git a/tests/Unit/OnWorkerStartPoolFreeTest.php b/tests/Unit/OnWorkerStartPoolFreeTest.php index 525999d..2a3e2d7 100644 --- a/tests/Unit/OnWorkerStartPoolFreeTest.php +++ b/tests/Unit/OnWorkerStartPoolFreeTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit; use Illuminate\Container\Container; +use Illuminate\Foundation\Application; use Illuminate\Support\Facades\Facade; use Laravel\Octane\Swoole\Coroutine\CoroutineApplication; use Laravel\Octane\Swoole\Handlers\OnWorkerStart; @@ -70,18 +71,24 @@ public function test_http_worker_boot_uses_single_worker_and_does_not_create_poo $this->assertTrue($workerState->ready); $this->assertNull($workerState->workerPool); $this->assertNull($workerState->clientPool); + $this->assertSame(1, $handler->prepareCoroutineApplicationForWorkerBootCalls); + $this->assertSame($this->app, $handler->preparedApp); $container = Container::getInstance(); $this->assertInstanceOf(CoroutineApplication::class, $container); $this->assertSame($container, Facade::getFacadeApplication()); $this->assertSame($this->app, $container->getBaseApplication()); + $this->assertSame($container, $handler->preparedSandbox); } } class TestableOnWorkerStart extends OnWorkerStart { public array $createPoolWorkerInvocations = []; + public int $prepareCoroutineApplicationForWorkerBootCalls = 0; + public ?Application $preparedApp = null; + public ?Application $preparedSandbox = null; public function __construct( SwooleExtension $extension, @@ -109,4 +116,11 @@ public function createPoolWorker($server, int $workerId, int $poolIndex): Worker protected function warnIfDatabasePoolMinExceedsMaxConnections(Worker $worker, $server): void { } + + protected function prepareCoroutineApplicationForWorkerBoot(Application $app, Application $sandbox): void + { + $this->prepareCoroutineApplicationForWorkerBootCalls++; + $this->preparedApp = $app; + $this->preparedSandbox = $sandbox; + } } diff --git a/tests/Unit/RequestScopeHttpKernelIsolationTest.php b/tests/Unit/RequestScopeHttpKernelIsolationTest.php new file mode 100644 index 0000000..54658ff --- /dev/null +++ b/tests/Unit/RequestScopeHttpKernelIsolationTest.php @@ -0,0 +1,62 @@ +createMock(Dispatcher::class); + $router = new Router($events, $base); + $kernel = new Kernel($base, $router); + + $base->instance('events', $events); + $base->instance('router', $router); + $base->instance(HttpKernelContract::class, $kernel); + + $scope = new RequestScope($base); + $sandbox = new CoroutineApplication($base); + + Context::set('octane.request_scope', $scope); + + try { + /** @var \Illuminate\Foundation\Http\Kernel $scopedKernel */ + $scopedKernel = $sandbox->make(HttpKernelContract::class); + $scopedRouter = $sandbox->make('router'); + + $this->assertNotSame($kernel, $scopedKernel); + $this->assertSame($sandbox, $this->readProperty($scopedKernel, 'app')); + $this->assertSame($scopedRouter, $this->readProperty($scopedKernel, 'router')); + $this->assertNotSame($router, $scopedRouter); + $this->assertSame($sandbox, $this->readProperty($scopedRouter, 'container')); + } finally { + Context::clear(); + } + } + + private function readProperty(object $object, string $property) + { + $reflection = new ReflectionClass($object); + + while (! $reflection->hasProperty($property) && $reflection->getParentClass()) { + $reflection = $reflection->getParentClass(); + } + + $instanceProperty = $reflection->getProperty($property); + $instanceProperty->setAccessible(true); + + return $instanceProperty->getValue($object); + } +} diff --git a/tests/Unit/RequestScopeLogIsolationTest.php b/tests/Unit/RequestScopeLogIsolationTest.php new file mode 100644 index 0000000..f10f07a --- /dev/null +++ b/tests/Unit/RequestScopeLogIsolationTest.php @@ -0,0 +1,64 @@ +instance('config', new ConfigRepository([ + 'logging' => [ + 'default' => 'stack', + 'channels' => [ + 'stack' => ['driver' => 'stack', 'channels' => ['single']], + 'single' => ['driver' => 'single', 'path' => __DIR__.'/test.log'], + ], + ], + ])); + $base->instance('log', new LogManager($base)); + + $sandbox = new CoroutineApplication($base); + + $firstScope = new RequestScope($base); + Context::set('octane.request_scope', $firstScope); + + try { + /** @var \Illuminate\Log\LogManager $firstLog */ + $firstLog = $sandbox->make('log'); + $firstLog->shareContext(['request_id' => 'req-1']); + + $this->assertSame(['request_id' => 'req-1'], $firstLog->sharedContext()); + $this->assertSame([], $base->make('log')->sharedContext()); + $this->assertSame($firstLog, $sandbox->make(LoggerInterface::class)); + } finally { + Context::clear(); + } + + $secondScope = new RequestScope($base); + Context::set('octane.request_scope', $secondScope); + + try { + /** @var \Illuminate\Log\LogManager $secondLog */ + $secondLog = $sandbox->make('log'); + + $this->assertSame([], $secondLog->sharedContext()); + + $secondLog->shareContext(['request_id' => 'req-2']); + + $this->assertSame(['request_id' => 'req-2'], $secondLog->sharedContext()); + $this->assertSame([], $base->make('log')->sharedContext()); + } finally { + Context::clear(); + } + } +} diff --git a/tests/Unit/RequestScopeRedisIsolationTest.php b/tests/Unit/RequestScopeRedisIsolationTest.php new file mode 100644 index 0000000..68ee68c --- /dev/null +++ b/tests/Unit/RequestScopeRedisIsolationTest.php @@ -0,0 +1,97 @@ + [ + 'redis' => [ + 'client' => 'phpredis', + 'options' => [], + 'default' => [ + 'host' => '127.0.0.1', + 'port' => 6379, + 'persistent' => true, + 'persistent_id' => 'worker-default', + ], + 'session' => [ + 'host' => '127.0.0.1', + 'port' => 6379, + 'persistent' => true, + 'persistent_id' => 'worker-session', + ], + ], + ], + 'cache' => [ + 'default' => 'array', + 'stores' => [ + 'array' => ['driver' => 'array'], + 'redis' => ['driver' => 'redis', 'connection' => 'default'], + ], + ], + ]); + + $base->instance('config', $config); + $base->instance('redis', new RedisManager($base, 'phpredis', $config->get('database.redis'))); + + $cache = new CacheManager($base); + $cache->store('array'); + $base->instance('cache', $cache); + + $scope = new RequestScope($base); + $sandbox = new CoroutineApplication($base); + + $scopedRedis = $scope->resolve('redis', $sandbox); + $scopedCache = $scope->resolve('cache', $sandbox); + + $this->assertNotSame($base->make('redis'), $scopedRedis); + $this->assertNotSame($base->make('cache'), $scopedCache); + + $this->assertSame($sandbox, $this->readProperty($scopedRedis, 'app')); + $this->assertSame([], $this->readProperty($scopedRedis, 'connections')); + $this->assertSame($sandbox, $this->readProperty($scopedCache, 'app')); + $this->assertSame([], $this->readProperty($scopedCache, 'stores')); + + $scopedRedisConfig = $this->readProperty($scopedRedis, 'config'); + + $this->assertFalse($scopedRedisConfig['default']['persistent']); + $this->assertFalse($scopedRedisConfig['session']['persistent']); + $this->assertArrayNotHasKey('persistent_id', $scopedRedisConfig['default']); + $this->assertArrayNotHasKey('persistent_id', $scopedRedisConfig['session']); + + $baseRedisConfig = $this->readProperty($base->make('redis'), 'config'); + + $this->assertTrue($baseRedisConfig['default']['persistent']); + $this->assertSame('worker-default', $baseRedisConfig['default']['persistent_id']); + $this->assertTrue($baseRedisConfig['session']['persistent']); + $this->assertSame('worker-session', $baseRedisConfig['session']['persistent_id']); + } + + private function readProperty(object $object, string $property) + { + $reflection = new ReflectionClass($object); + + while (! $reflection->hasProperty($property) && $reflection->getParentClass()) { + $reflection = $reflection->getParentClass(); + } + + $instanceProperty = $reflection->getProperty($property); + $instanceProperty->setAccessible(true); + + return $instanceProperty->getValue($object); + } +} diff --git a/tests/Unit/RequestScopeSessionMiddlewareIsolationTest.php b/tests/Unit/RequestScopeSessionMiddlewareIsolationTest.php new file mode 100644 index 0000000..a0b866d --- /dev/null +++ b/tests/Unit/RequestScopeSessionMiddlewareIsolationTest.php @@ -0,0 +1,75 @@ + [ + 'driver' => 'array', + 'lottery' => [0, 100], + ], + 'cache' => [ + 'default' => 'array', + 'stores' => [ + 'array' => ['driver' => 'array'], + ], + ], + ]); + + $base->instance('config', $config); + $base->instance('session', new SessionManager($base)); + $base->instance('cache', new CacheManager($base)); + + $scope = new RequestScope($base); + $sandbox = new CoroutineApplication($base); + + Context::set('octane.request_scope', $scope); + + try { + $middleware = $sandbox->make(StartSession::class); + $scopedSessionManager = $sandbox->make('session'); + $scopedCacheManager = $sandbox->make('cache'); + + $this->assertInstanceOf(StartSession::class, $middleware); + $this->assertSame($scopedSessionManager, $this->readProperty($middleware, 'manager')); + $this->assertNotSame($base->make('session'), $this->readProperty($middleware, 'manager')); + + $cacheFactoryResolver = $this->readProperty($middleware, 'cacheFactoryResolver'); + + $this->assertIsCallable($cacheFactoryResolver); + $this->assertSame($scopedCacheManager, $cacheFactoryResolver()); + } finally { + Context::clear(); + } + } + + private function readProperty(object $object, string $property) + { + $reflection = new ReflectionClass($object); + + while (! $reflection->hasProperty($property) && $reflection->getParentClass()) { + $reflection = $reflection->getParentClass(); + } + + $instanceProperty = $reflection->getProperty($property); + $instanceProperty->setAccessible(true); + + return $instanceProperty->getValue($object); + } +} diff --git a/tests/Unit/RequestScopeViewIsolationTest.php b/tests/Unit/RequestScopeViewIsolationTest.php new file mode 100644 index 0000000..3a575e6 --- /dev/null +++ b/tests/Unit/RequestScopeViewIsolationTest.php @@ -0,0 +1,64 @@ +createMock(ViewFinderInterface::class), + new Dispatcher($base) + ); + $view->setContainer($base); + $view->share('boot_only', 'global'); + + $base->instance('view', $view); + $base->instance(ViewFactoryContract::class, $view); + + $sandbox = new CoroutineApplication($base); + + Context::set('octane.request_scope', new RequestScope($base)); + + try { + /** @var \Illuminate\View\Factory $firstView */ + $firstView = $sandbox->make('view'); + $firstView->share('request_id', 'alpha'); + + $this->assertSame('alpha', $firstView->shared('request_id')); + $this->assertSame('global', $firstView->shared('boot_only')); + $this->assertSame($sandbox, $firstView->shared('app')); + $this->assertNull($base->make('view')->shared('request_id')); + } finally { + Context::clear(); + } + + Context::set('octane.request_scope', new RequestScope($base)); + + try { + /** @var \Illuminate\View\Factory $secondView */ + $secondView = $sandbox->make('view'); + + $this->assertSame('global', $secondView->shared('boot_only')); + $this->assertNull($secondView->shared('request_id')); + $this->assertNull($base->make('view')->shared('request_id')); + $this->assertSame($secondView, $sandbox->make(ViewFactoryContract::class)); + } finally { + Context::clear(); + } + } +}