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