Skip to content

Give each coroutine its own Vite so preload links stay per request - #23

Merged
adhikjoshi merged 1 commit into
mainfrom
fix/scope-vite-per-request
Oct 15, 2026
Merged

adhikjoshi merged 1 commit into
mainfrom
fix/scope-vite-per-request

Conversation

@adhikjoshi

@adhikjoshi adhikjoshi commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Illuminate\Foundation\Vite is a container singleton on the shared base application, so every coroutine in a worker shared one $preloadedAssets list. Blade's @vite appends to it, AddLinkHeadersForPreloadedAssets builds the Link header from it, and FlushVite (RequestReceived) empties it whenever another request starts. Under concurrency a response's Link header carried another page's preload links, or none.

v0.8.34 (#22) made every Link value reach the client, which exposed this. Production impact (modelslab.com, 2026-10-15 19:45 UTC): 23 of 60 parallel fresh renders had a wrong preload header (7 empty, 12 with other pages' assets, 4 mixed). The app's full-page cache stores that list on a MISS and replays it for 60–300 s.

Fix

RequestScope resolves Illuminate\Foundation\Vite per coroutine: a clone of the boot-time instance (build directory, nonce, resolvers, prefetch settings are kept) with an empty preload list. @vite (app()), the Vite facade (caching is off in coroutine mode) and FlushVite all go through the scope.

The list is reset directly instead of with Vite::flush(), which only exists from Laravel 12.10 — calling it would throw on Laravel 10/11/12.0–12.9 and, because FlushVite resolves Vite on every request, turn every request into a 500.

Verification

  • Unit (tests/Unit/RequestScopeViteIsolationTest.php): the clone keeps boot config and starts empty; and the production interleaving — request A renders, request B starts (FlushVite) and renders, A builds its header — read through the Vite facade and app() with the worker's global wiring. Both tests fail with the fix removed.
  • Matrix (Swoole 6.3.0RC1, PHP 8.3.35): full suite green on Laravel 11.57 (no Vite::flush), 12.39 and 13.36.
  • Real app under Swoole (modelslab-frontend-v2 production image at a0b8a39e0, Octane with the prod flags — 8 workers, 2 task workers, --max-requests=500 — against throwaway MySQL/Redis/Mongo; this branch bind-mounted over vendor/.../src):
    • 16 pages rendered fresh, 16 in parallel: v0.8.34 84/160 wrong headers; this branch 0/480 wrong (every header equals the page's own @vite assets).
    • Cost: clone + reset 0.063 µs/request; simulated request lifecycle 4.8–5.1 µs vs 5.5 µs before (noise).
    • Memory: 100k simulated requests → 0 bytes growth. Real app with recycling off (mixed load, 32–36k requests): v0.8.34 +9.7 KB/request, this branch +10.0 KB/request — same pre-existing growth, nothing added.
    • wrk (16 connections): within run-to-run noise (±20% on this machine); with order reversed the branch was faster.

Known limits (unchanged by this PR)

  • A binding cached on the base container (e.g. a scoped() CSP-nonce generator) is still shared across concurrent requests; a nonce set on one request's Vite clone is not seen by another. Before this PR the nonce lived on the shared Vite and raced instead.
  • An app that aliases Illuminate\Foundation\Vite to its own class, or injects Vite into a boot-time singleton, still gets the shared instance.

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

Vite is a container singleton, so every coroutine in a worker shared one
preloaded-assets list. Concurrent requests appended to it, and FlushVite
emptied it whenever another request started, so a response's Link header
carried another page's preload links or none. RequestScope now hands each
request a clone of the boot-time Vite with an empty list.

The list is reset directly because Vite::flush() only exists from
Laravel 12.10.
@adhikjoshi
adhikjoshi merged commit 4d18de5 into main Oct 15, 2026
8 checks passed
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