Skip to content

Give each coroutine its own Blade compiler and Blade engine - #25

Open
adhikjoshi wants to merge 1 commit into
mainfrom
fix/scope-blade-compiler-per-coroutine
Open

adhikjoshi wants to merge 1 commit into
mainfrom
fix/scope-blade-compiler-per-coroutine

Conversation

@adhikjoshi

@adhikjoshi adhikjoshi commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Incident

On 2026-10-18 redcell.sh (Octane coroutine worker, maxScale=1) showed a white screen on every page for hours. The HTML had the SSR head, but where <x-inertia::app /> should print <script data-page> + <div id="app">, it printed the head tags again (body == head). The Laravel side and the Node SSR server were both fine: the same page JSON rendered correctly through a local SSR server. A fresh revision (new container, empty compiled-view cache) fixed it.

Cause

RequestScope::createViewFactory() shallow-clones the view Factory, so every coroutine in a worker still shares one EngineResolver → CompilerEngine → BladeCompiler.

BladeCompiler::compile($path) does:

  1. setPath($path) (shared $this->path)
  2. $this->files->get($this->getPath()): a hooked file read under SWOOLE_HOOK_ALL, so the coroutine yields
  3. getCompiledPath($this->getPath()) → write

If a second request compiles another view during step 2, it moves $this->path. One view's compiled PHP is then written into the other view's cache file. Nothing recompiles it after that, because the bad write makes the compiled file newer than its source. Inertia's two inline components (Head, App) compile back to back on the first render, so App's cache file got Head's code: @if($response) {!! $response->head !!}.

Compiler::isExpired() uses >= on mtimes. Inline component views whose .blade.php and compiled .php were written in the same second count as expired for each new worker, so every max_requests restart reopens the window. This explains why a container that worked for two days broke at a random worker restart.

The shared CompilerEngine also mixes lastCompiled (the view named in error messages) and compiledOrNotExpired across requests.

Fix

Same pattern as the Vite change in #23:

  • blade.compiler / BladeCompiler::class are request-scoped. Each coroutine gets a clone of the boot-time compiler, with empty per-compile state (path, footer, rawBlocks, firstCaseInSwitch, forElseCounter, lastSection, lastFragment). Boot-time directives, components, precompilers and echo handlers carry over. Blade::render() / Blade::compileString() in a request use the clone, because facades are uncached and resolve through the proxy.
  • The coroutine's cloned view factory gets a cloned EngineResolver. Its blade engine is built lazily as a clone of the worker's engine (so a package's CompilerEngine subclass is kept), pointed at the coroutine's compiler, with empty lastCompiled / compiledOrNotExpired. The worker's engine is resolved once as a template. This avoids running the framework's resolver for each request, which on Laravel 10 would add a terminating callback to the worker app for every request. Other engines (php, file) stay shared because they hold no per-render state.
  • The worker's compiler, engine and resolver are never changed. Outside coroutine context nothing changes.

I chose a per-coroutine clone over a worker-level mutex around compile(). Shared state is not only in compile(): compileString() and CompilerEngine bookkeeping are also shared, and ComponentTagCompiler does hooked filesystem checks mid-compile. A lock would have to cover all of those and would serialize compiles with I/O inside the lock. The clone matches how this package already isolates view, Vite, translator and the managers.

Test

tests/Unit/RequestScopeBladeCompilerIsolationTest.php

  • Scoping test: the compiler is per request, keeps a boot-time directive, the scoped view factory's Blade engine uses the scoped compiler, and the worker factory keeps the worker compiler.
  • Race test: with SWOOLE_HOOK_ALL, it renders ten pairs of new inline templates (the shape of Inertia's head and app) concurrently through Blade::render().
    • Without the fix: 19 of 20 renders fail with File does not exist at path .../compiled/<hash>.php, and the one that renders has stray code (<title>head 9</title>head-9/ ?>).
    • With the fix: all 20 return their own output.

Full suite with PHP 8.5 + Swoole 6.2.2: 227 tests OK. The 29 skips and the deprecations are unchanged from main.

Not changed (noted)

  • Illuminate\View\Component::$factory is static, so the first coroutine's factory serves exists() / addNamespace() for others. Both go to the shared finder, so I found no wrong output from it.
  • CompilesComponents::$componentHashStack is static, but it is only pushed and popped within compileStatements(), which does not yield.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XPrqAsHV6fVfweg9jbkyhM


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

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 <x-inertia::head> code into the cache file
of <x-inertia::app />, 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPrqAsHV6fVfweg9jbkyhM
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant