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; + } +}