From 3fcf736c7887c780d50decce97b9ad8550c2fcbc Mon Sep 17 00:00:00 2001 From: Adhik Joshi Date: Sun, 18 Oct 2026 14:40:14 +0530 Subject: [PATCH] Give each coroutine its own Blade compiler and Blade engine The cloned view factory still held the worker's EngineResolver, so every coroutine in a worker rendered through one CompilerEngine and one BladeCompiler. BladeCompiler::compile() stores the view it is compiling in $this->path, reads the source (a hooked file call, so the coroutine yields), then writes to getCompiledPath($this->path). A concurrent request that compiled another view in between moved the path, and one view's compiled PHP was written into the other view's cache file. The bad file stayed until the compiled views were cleared. On redcell.sh this put Inertia's code into the cache file of , so every page printed its head tags where the app root belongs: a white screen until the container restarted. Inline component views whose source and compiled file share an mtime second are rechecked by each new worker, so every max_requests restart reopened the window. RequestScope now binds 'blade.compiler' to a clone of the boot-time compiler with empty per-compile state, and gives the coroutine's view factory a cloned EngineResolver whose Blade engine is a clone of the worker's engine pointed at that compiler, with empty render and expiry caches. Boot-time directives, components and precompilers carry over, and an engine subclass from a package is kept. The worker's compiler, engine and resolver are never changed. The new test renders ten pairs of new inline templates concurrently with SWOOLE_HOOK_ALL. Without the fix 19 of 20 renders fail (missing compiled file or another view's code); with it all 20 render their own output. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XPrqAsHV6fVfweg9jbkyhM --- src/Swoole/Coroutine/RequestScope.php | 98 +++++++++ ...RequestScopeBladeCompilerIsolationTest.php | 188 ++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 tests/Unit/RequestScopeBladeCompilerIsolationTest.php diff --git a/src/Swoole/Coroutine/RequestScope.php b/src/Swoole/Coroutine/RequestScope.php index 50cb3b5..0acb3d1 100644 --- a/src/Swoole/Coroutine/RequestScope.php +++ b/src/Swoole/Coroutine/RequestScope.php @@ -374,6 +374,7 @@ protected function resolveSpecializedBinding(string $key, Application $sandbox) \Illuminate\Session\Middleware\StartSession::class => $this->createStartSessionMiddleware($sandbox), 'url' => $this->createUrlGenerator($sandbox), \Illuminate\Foundation\Vite::class => $this->createVite(), + 'blade.compiler', \Illuminate\View\Compilers\BladeCompiler::class => $this->createBladeCompiler(), '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), @@ -666,6 +667,11 @@ protected function rememberAliasBindings(string $key, $resolved): void 'view', \Illuminate\Contracts\View\Factory::class, ], $resolved), + 'blade.compiler', + \Illuminate\View\Compilers\BladeCompiler::class => $this->storeScopedAliases([ + 'blade.compiler', + \Illuminate\View\Compilers\BladeCompiler::class, + ], $resolved), 'translator', \Illuminate\Contracts\Translation\Translator::class, \Illuminate\Translation\Translator::class => $this->storeScopedAliases([ @@ -1527,9 +1533,101 @@ protected function createViewFactory(Application $sandbox): \Illuminate\Contract $view->share('app', $sandbox); $view->flushState(); + if (method_exists($view, 'getEngineResolver')) { + $this->setObjectProperty($view, 'engines', $this->createEngineResolver($view->getEngineResolver(), $sandbox)); + } + return $view; } + /** + * Create an isolated Blade compiler for the current coroutine. + * + * BladeCompiler keeps the view it is compiling in $path. compile() sets the + * path, reads the source file, then writes to getCompiledPath($this->path). + * The read is a hooked file call, so the coroutine yields in between. On the + * shared compiler a concurrent request that compiled another view meanwhile + * moved the path, and one view's compiled PHP was written into the other + * view's cache file, where it stayed until the cache was cleared. The clone + * keeps boot-time directives, components and precompilers, and starts with + * empty per-compile state. + * + * @return \Illuminate\View\Compilers\BladeCompiler|null + */ + protected function createBladeCompiler(): ?\Illuminate\View\Compilers\BladeCompiler + { + if (! $this->app->bound('blade.compiler')) { + return null; + } + + $compiler = $this->app->make('blade.compiler'); + + if (! $compiler instanceof \Illuminate\View\Compilers\BladeCompiler) { + return null; + } + + $compiler = clone $compiler; + + foreach ([ + 'path' => null, + 'footer' => [], + 'rawBlocks' => [], + 'firstCaseInSwitch' => true, + 'forElseCounter' => 0, + 'lastSection' => null, + 'lastFragment' => null, + ] as $property => $value) { + $this->setObjectProperty($compiler, $property, $value); + } + + return $compiler; + } + + /** + * Create an engine resolver whose Blade engine uses the coroutine's compiler. + * + * The cloned view factory still pointed at the worker's EngineResolver, so + * every coroutine rendered through one CompilerEngine and one BladeCompiler. + * The engine also tracks the views it is rendering (for error messages) and + * which compiled views it has already checked. The Blade engine is built + * lazily from the worker's engine, so a package's engine subclass is kept, + * and the worker's resolver is never changed. Other engines stay shared: + * the PHP and file engines hold no per-render state. + * + * @param \Illuminate\View\Engines\EngineResolver $baseResolver + * @param \Illuminate\Foundation\Application $sandbox + * @return \Illuminate\View\Engines\EngineResolver + */ + protected function createEngineResolver(\Illuminate\View\Engines\EngineResolver $baseResolver, Application $sandbox): \Illuminate\View\Engines\EngineResolver + { + $resolvers = $this->getObjectProperty($baseResolver, 'resolvers'); + + if (! is_array($resolvers) || ! isset($resolvers['blade'])) { + return $baseResolver; + } + + $resolver = clone $baseResolver; + + $resolver->register('blade', function () use ($baseResolver, $sandbox) { + // The worker's engine is only a template. Resolving it once (instead of + // calling the framework's resolver per request) keeps Laravel 10, whose + // resolver registers a terminating callback on the worker app, from + // adding one callback for every request. + $engine = clone $baseResolver->resolve('blade'); + + if ($engine instanceof \Illuminate\View\Engines\CompilerEngine + && ($compiler = $sandbox->make('blade.compiler')) instanceof \Illuminate\View\Compilers\CompilerInterface) { + $this->setObjectProperty($engine, 'compiler', $compiler); + $this->setObjectProperty($engine, 'lastCompiled', []); + $this->setObjectProperty($engine, 'compiledOrNotExpired', []); + } + + return $engine; + }); + + return $resolver; + } + /** * Create an isolated log manager for the current coroutine. * diff --git a/tests/Unit/RequestScopeBladeCompilerIsolationTest.php b/tests/Unit/RequestScopeBladeCompilerIsolationTest.php new file mode 100644 index 0000000..d571907 --- /dev/null +++ b/tests/Unit/RequestScopeBladeCompilerIsolationTest.php @@ -0,0 +1,188 @@ +path. compile() sets + * the path, reads the source (a hooked file read, so the coroutine yields), + * then writes to getCompiledPath($this->getPath()). With one compiler shared + * by every coroutine in a worker, a second request that compiled another view + * meanwhile moved the path, and one view's compiled PHP landed in the other + * view's cache file. In production Inertia's got + * 's code, so every page printed its head tags where the app + * root belongs, until the container restarted. + */ +class RequestScopeBladeCompilerIsolationTest extends TestCase +{ + private string $directory; + + protected function setUp(): void + { + parent::setUp(); + + $this->directory = sys_get_temp_dir().'/octane-blade-'.bin2hex(random_bytes(6)); + + mkdir($this->directory.'/views', 0755, true); + mkdir($this->directory.'/compiled', 0755, true); + } + + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->directory); + + parent::tearDown(); + } + + public function test_blade_compiler_is_request_scoped_and_keeps_boot_time_directives(): void + { + $base = $this->applicationWithViews(); + $baseCompiler = $base->make('blade.compiler'); + $baseCompiler->directive('shout', fn (string $expression): string => ""); + + $sandbox = new CoroutineApplication($base); + + Context::set('octane.request_scope', new RequestScope($base)); + + try { + $scopedCompiler = $sandbox->make('blade.compiler'); + + $this->assertInstanceOf(BladeCompiler::class, $scopedCompiler); + $this->assertNotSame($baseCompiler, $scopedCompiler); + $this->assertSame($scopedCompiler, $sandbox->make(BladeCompiler::class)); + $this->assertArrayHasKey('shout', $scopedCompiler->getCustomDirectives()); + + $engine = $sandbox->make('view')->getEngineResolver()->resolve('blade'); + + $this->assertInstanceOf(CompilerEngine::class, $engine); + $this->assertSame($scopedCompiler, $engine->getCompiler()); + $this->assertSame($baseCompiler, $base->make('view')->getEngineResolver()->resolve('blade')->getCompiler()); + } finally { + Context::clear(); + } + + Context::set('octane.request_scope', new RequestScope($base)); + + try { + $this->assertNotSame($scopedCompiler, $sandbox->make('blade.compiler')); + } finally { + Context::clear(); + } + } + + public function test_concurrent_requests_compiling_different_inline_views_keep_their_own_compiled_code(): void + { + if (! class_exists(Coroutine::class) || ! class_exists(Channel::class) || ! class_exists(Runtime::class)) { + $this->markTestSkipped('Swoole coroutine support is required.'); + } + + $base = $this->applicationWithViews(); + $sandbox = new CoroutineApplication($base); + $results = []; + + // Wire the globals the way OnWorkerStart does, with every hook on, so the + // compiler's file reads and writes yield like they do in a worker. + $previousContainer = Container::getInstance(); + $previousFacadeApp = Facade::getFacadeApplication(); + $cached = new ReflectionProperty(Facade::class, 'cached'); + $previousCached = $cached->getValue(); + $previousHookFlags = Runtime::getHookFlags(); + + Container::setInstance($sandbox); + Facade::setFacadeApplication($sandbox); + Facade::clearResolvedInstances(); + $cached->setValue(null, false); + Runtime::enableCoroutine(SWOOLE_HOOK_ALL); + + try { + Coroutine\run(function () use ($base, &$results) { + $done = new Channel(20); + + // Ten pairs of requests: each pair renders two inline templates + // (the shape of Inertia's head and app components) that nothing + // has compiled yet, at the same time. + for ($round = 0; $round < 10; $round++) { + foreach (['head', 'app'] as $part) { + Coroutine::create(function () use ($base, $done, $round, $part) { + $scope = new RequestScope($base); + Context::set('octane.request_scope', $scope); + + $template = $part === 'head' + ? "head {$round}{{ \$marker }}" + : "
app {$round}
{{ \$marker }}"; + + try { + $output = Blade::render($template, ['marker' => "{$part}-{$round}"]); + } catch (\Throwable $e) { + $output = get_class($e).': '.$e->getMessage(); + } + + $done->push(["{$part}-{$round}" => $output]); + + $scope->clear(); + Context::clear(); + }); + } + } + + for ($i = 0; $i < 20; $i++) { + $results += $done->pop(); + } + }); + } finally { + Runtime::setHookFlags($previousHookFlags); + $cached->setValue(null, $previousCached); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($previousFacadeApp); + Container::setInstance($previousContainer); + } + + $expected = []; + + for ($round = 0; $round < 10; $round++) { + $expected["head-{$round}"] = "head {$round}head-{$round}"; + $expected["app-{$round}"] = "
app {$round}
app-{$round}"; + } + + ksort($expected); + ksort($results); + + $this->assertSame($expected, $results); + } + + private function applicationWithViews(): Application + { + $app = new Application($this->directory); + + $app->instance('config', new Repository([ + 'view' => [ + 'paths' => [$this->directory.'/views'], + 'compiled' => $this->directory.'/compiled', + ], + ])); + $app->instance('files', new Filesystem); + $app->singleton('events', fn ($app) => new Dispatcher($app)); + + (new ViewServiceProvider($app))->register(); + + return $app; + } +}