Skip to content

fix(proxy): stop shedding release-asset cold loads onto the client - #3420

Merged
kojiwakayama merged 3 commits into
mainfrom
fix/proxy-asset-cold-load-shedding
Aug 6, 2026
Merged

kojiwakayama merged 3 commits into
mainfrom
fix/proxy-asset-cold-load-shedding

Conversation

@kojiwakayama

@kojiwakayama kojiwakayama commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Description

A page load requests every asset in its module graph at once. On a cold pod the release-asset handler admitted only 4 concurrent loads plus 16 queued, so the 21st distinct hash was rejected outright with a 503. Browser import() never retries, so one shed asset killed hydration for the entire page.

Observed in production on j-agent.production.veryfront.com: one load 503'd 31 assets, and the next reload 503'd 11 different, deeper hashes — dca28b9f… was a 503 victim in the first log and a successful referrer in the second. That is a per-pod cache warming one module-graph level per reload and hitting the same wall on the next fan-out level.

What changed

Two of the three bounds were guarding nothing:

  • MAX_QUEUED_COLD_LOADS (16) turned "busy" into "this module does not exist". A queue cap here is a hard page failure by construction, because import() has no retry and Retry-After: 1 is decoration.
  • MAX_COLD_LOAD_WAITERS (64) rejected followers of a load that had already succeeded — callers whose bytes were sitting in memory, byte-identical for all of them. Nothing was being protected.

Both are removed. PermitSemaphore keeps its own 10,000-entry backstop, and callers were already bounded by their own deadline (timeoutMs) and the producer ceiling (MAX_UPSTREAM_TIMEOUT_MS). A saturated proxy now surfaces as latency and finally an honest 504, never a phantom 503.

MAX_CONCURRENT_COLD_LOADS = 4 is unchanged. It bounds the one real resource — each in-flight load buffers a whole asset in memory, so peak bytes is this count × RELEASE_ASSET_MAX_SIZE_BYTES. Retuning it is a performance question, not this bug.

MAX_CACHED_ASSETS 100 → 2000. Measured against a real page (65 assets, 185KB total): mean asset is ~2.9KB, so 100 entries could only ever hold ~285KB of the 32MB byte budget. The entry cap did 100% of the evicting and the byte budget was ~115× out of reach. At 100 entries a single page nearly filled the per-pod cache — shared across every project on that pod — which made cold-load storms steady state rather than a deploy-time event.

Related Issue(s)

Type of Change

  • Bug fix (non-breaking change that fixes an issue)

Checklist

  • I have added tests that prove my fix is effective or that my feature works

Both updated tests had pinned the defect as intended behaviour:

Test Before After
50 concurrent distinct assets 19 × 200, 30 × 503 49 × 200, 0 × 503 (1 × 499 = deliberately disconnected caller)
80 followers of one same-hash load 64 × 200, 16 × 503 80 × 200, 0 × 503, still calls === 1

maxActive is still asserted at 4, confirming queueing did not widen the bound that guards real memory. The existing byte-weight eviction test still passes, so the 32MB budget still binds.

Verification: proxy suite 49 passed / 482 steps / 0 failed; full pre-push suite 3798 passed / 27834 steps / 0 failed; deno check, deno lint, deno fmt --check clean.

Notes for review

  • I could not reproduce a live 503 — the assets are warm now, and cache hits return before the admission gate. The diagnosis rests on the code paths plus the progressive-warming pattern across the two production logs. Real confirmation is watching a cold pod after deploy.
  • MAX_CONCURRENT_COLD_LOADS = 4 is still a guess at what /release-assets/{hash} will absorb. If cold loads feel slow after this, that is the number to revisit — and it is now the only one left to tune.
  • Deliberately not included: a circuit breaker for upstream-down fast-fail, and bounded retry-with-jitter in the hydration runtime. Both are defensible later; neither is needed to fix this.

Summary by CodeRabbit

  • Bug Fixes
    • Asset requests exceeding the active loading limit now wait in a queue instead of immediately returning an error.
    • Queued requests can still be canceled if the caller aborts.
    • Concurrent requests for the same asset now all receive the shared successful response, without unnecessary rejections.
    • The system continues to limit active upstream asset loads to four at a time.

A page load requests every asset in its module graph at once. On a cold
pod the asset handler admitted only 4 concurrent loads plus 16 queued, so
the 21st distinct hash was rejected outright with a 503. Browser
`import()` never retries, so one shed asset killed hydration for the whole
page.

Two of the three bounds were guarding nothing. The queue cap turned "busy"
into "this module does not exist", and the waiter cap rejected followers of
a load that had already succeeded -- callers whose bytes were sitting in
memory, identical for all of them. Both are removed. PermitSemaphore keeps
its own 10,000-entry backstop, and callers were already bounded by their
own deadline and the producer ceiling, so a saturated proxy now surfaces as
latency and finally an honest 504.

MAX_CONCURRENT_COLD_LOADS stays as-is: it bounds the one real resource,
since each in-flight load buffers a whole asset in memory.

Raise MAX_CACHED_ASSETS 100 -> 2000. Measured against a real page, assets
average ~2.9KB, so 100 entries could only ever hold ~285KB of the 32MB
byte budget -- the entry cap did all the evicting and the byte budget was
unreachable. At 100 entries a single page nearly filled the per-pod cache,
which made cold-load storms steady state rather than a deploy-time event.

Both updated tests had pinned the defect as intended behaviour: 50
concurrent distinct assets asserted 30 x 503, and 80 same-hash followers
asserted 16 x 503.
@kojiwakayama
kojiwakayama requested a review from kwakayama as a code owner August 6, 2026 07:51
Copilot AI review requested due to automatic review settings August 6, 2026 07:51
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@kwakayama, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9a18c5b7-52bb-4aa7-811f-756615c81782

📥 Commits

Reviewing files that changed from the base of the PR and between 713f00c and 0c24ce8.

📒 Files selected for processing (2)
  • src/platform/compat/dynamic-import.test.ts
  • src/platform/compat/dynamic-import.ts
📝 Walkthrough

Walkthrough

Changes

Asset loading

Layer / File(s) Summary
Semaphore-based cold-load queuing
src/proxy/asset-handler.ts
Cold loads now use an unbounded semaphore queue. Queue-size and waiter-count rejection logic was removed. The cache remains limited to 2,000 entries, 32 MiB, and four active upstream loads.
Queued and shared-load test expectations
src/proxy/asset-handler.test.ts
Tests now verify successful queued callers, 499 responses for aborted queued callers, four active upstream loads, and successful responses for all same-hash followers from one fetch.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: kwakayama, copilot

Sequence Diagram(s)

sequenceDiagram
  participant Request
  participant AssetHandler
  participant Semaphore
  participant Upstream
  Request->>AssetHandler: Request cold asset
  AssetHandler->>Semaphore: Acquire load slot or queue
  Semaphore->>Upstream: Start upstream load
  Upstream-->>AssetHandler: Return asset
  AssetHandler-->>Request: Return shared response or request failure
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: preventing proxy cold loads from being rejected by shedding them onto clients.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/proxy-asset-cold-load-shedding

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the release-asset proxy handler to avoid failing page hydration during cold-cache fan-out by removing hard 503-shedding limits and relying on queueing plus per-caller/per-producer timeouts instead.

Changes:

  • Increased the in-memory LRU entry cap for cached immutable assets (while keeping the existing 32MB byte budget).
  • Removed the explicit cold-load queue and waiter caps so requests queue behind MAX_CONCURRENT_COLD_LOADS rather than being rejected with 503.
  • Updated proxy tests to assert queueing behavior (no 503 shedding) and unlimited same-hash followers (still single upstream fetch).

Verification

  • Not run as part of this automated review; PR description reports proxy suite + full suite passing, plus deno check/lint/fmt --check clean.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/proxy/asset-handler.ts Removes cold-load shedding limits, keeps concurrency bound, and raises the cached-asset entry cap to reduce cold-load storms.
src/proxy/asset-handler.test.ts Updates concurrency/follower tests to reflect queueing semantics and the removal of 503 shedding.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +48 to +54
* Excess demand queues rather than failing. A page's module graph is a fan-out
* this proxy itself produced by serving the HTML, so shedding it would reject
* the predictable consequence of our own response — and browser `import()`
* never retries, which turns one shed asset into a dead page. Callers are
* already bounded by their own deadline (`timeoutMs`) and the producer ceiling
* (`MAX_UPSTREAM_TIMEOUT_MS`); a saturated proxy therefore shows up as latency
* and finally an honest 504, never a phantom 503.
…ration

Project pages are served with `script-src 'self' 'nonce-...' https://esm.sh`.
That has no 'unsafe-eval', so the `new Function("specifier", ...)` in
dynamicImport throws EvalError in the browser and hydration dies before first
paint, leaving the page on its skeleton loaders.

The module is reachable from three client entry points, all converging on
platform/compat/process/command.ts:

  chat/index.ts     -> chat/stream-watchdog.ts -> agent/streaming/lifecycle/
                       watchdog-compat-adapter.ts -> platform/compat/process.ts
  mdx/index.ts      -> react/components/MDXProvider.tsx -> types/index.ts ->
                       types/server.ts -> config/loader.ts -> ...
  workflow/react/   -> use-workflow.ts -> workflow/types.ts -> agent/types.ts ->
                       tool/index.ts -> tool/context7.ts -> ...

The `new Function` was there to keep the import opaque to bundlers and
`deno compile`. It was not needed: the specifier is a runtime parameter, so
neither can resolve it to a concrete module. Verified with `deno compile` on a
bare parameterized `import()` — it bundles only the local files, does not trace
into the specifier, and the compiled binary still resolves it at runtime.
src/platform/adapters/fs/veryfront/default-invalidation-callbacks.ts already
uses the same bare form.

This is the second half of the blank-page failure. Even with the release-asset
503 shedding fixed, loads that served every asset cleanly still failed to
hydrate on this EvalError.
Copilot AI review requested due to automatic review settings August 6, 2026 07:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kwakayama

Copy link
Copy Markdown
Contributor

Pushed 0c24ce8 onto this branch. Two things this adds: a live reproduction of the 503 (the PR notes it could not be reproduced), and a fix for a second, independent bug that also blanks the page.

Live 503 reproduction

Reproduced on support-agent-vkmo7d.production.veryfront.com against current prod, so the shedding is still reachable and not just warm-cache-hidden.

One page load requests 143 distinct asset hashes against MAX_CACHED_ASSETS = 100. The cache cannot hold a single page's module graph, so it thrashes and re-cold-loads every time. Five consecutive loads:

load 503s
1 16
2 19
3 5
4 0
5 0

The 143-vs-100 measurement supports the MAX_CACHED_ASSETS 100 → 2000 change directly — the entry cap, not the byte budget, is what evicts.

Worth noting the failure needs the browser's concurrent HTTP/2 burst. Every "503" asset returns 200 when fetched on its own — 50/50 sequential and 30/30 parallel curls all succeeded. Anyone checking with curl will conclude the assets are fine.

Second bug: CSP blocks the hydration runtime's own new Function

This is deterministic and this PR did not cover it. Loads 4 and 5 above shed zero assets and still failed to hydrate:

[Veryfront] Client initialization error: EvalError: Evaluating a string as JavaScript
violates the following Content Security Policy directive because 'unsafe-eval' is not
an allowed source of script: script-src 'self' 'nonce-...' https://esm.sh
    at new Function (<anonymous>)
    at .../_vf/assets/bfbed417...js:1:9
[Veryfront] Hydration failed signal received

That asset is src/platform/compat/dynamic-import.ts compiled as its own client chunk:

const i=new Function("specifier","return import(specifier)");export{i as dynamicImport};

The document CSP has no 'unsafe-eval', so it throws before hydration starts. Page stays on skeleton loaders permanently; document.querySelectorAll('*').length is 61 and the text is Loading messages....

It reaches the client from three entry points, all converging on platform/compat/process/command.ts (a node:child_process module):

chat/index.ts    -> stream-watchdog.ts -> watchdog-compat-adapter.ts -> compat/process.ts
mdx/index.ts     -> MDXProvider.tsx -> types/index.ts -> types/server.ts -> config/loader.ts -> ...
workflow/react/  -> use-workflow.ts -> workflow/types.ts -> agent/types.ts -> tool/context7.ts -> ...

The new Function existed to keep the import opaque to bundlers and deno compile. It was not needed — the specifier is a runtime parameter, so neither can resolve it. Verified with deno compile on the bare form: it bundles only the local files, does not trace into the specifier, and the binary still resolves it at runtime. platform/adapters/fs/veryfront/default-invalidation-callbacks.ts already uses the same bare form.

Regression test asserts the source has no new Function; confirmed red before the fix, green after.

Verification: src/platform/compat/, runtime-owner, script-handlers, and this PR's asset-handler tests — 87 passed / 995 steps / 0 failed. deno check, deno lint, deno fmt --check clean.

Scope note

Happy to split the CSP commit into its own PR if you would rather keep this one purely about proxy admission — it touches a different subsystem. It is one commit and cherry-picks cleanly. Landing them together does mean the page actually loads, which neither fix achieves alone.

Also unaddressed by both fixes: /_vf_modules/pages/index.js 404s on every load (the loadPageModuleWithIndexFallback probe). Harmless but it is noise in every production console.

Copilot AI review requested due to automatic review settings August 6, 2026 08:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kwakayama

Copy link
Copy Markdown
Contributor

Correction to my previous comment — I reverted the CSP commit (466ac09). This branch is back to exactly its original two files.

My fix was wrong and it broke src/release-assets/scaffolded-project-build.test.ts (5 scaffolds). Sorry for the red.

I checked that removing new Function was safe for deno compile and it is — but that was only half the reason it exists. The release-asset builder rejects it for its own reason:

specifier=veryfront/chat  sourceKey=platform/compat/dynamic-import
error="Release module contains a non-literal dynamic import"
→ dependency-transform-failed:veryfront<path>  module-rewrite-failed:app<path>

Every client import has to be rewritable to a content-addressed /_vf/assets/{hash}.js URL. A runtime-variable specifier cannot be rewritten, so new Function is what keeps this module out of the rewriter's way. It is load-bearing in the build, not just in deno compile. Confirmed by bisect: the test passes on 713f00cc and on main, fails only with my commit.

The two findings in my previous comment still stand. The 503 reproduction is unaffected (143 distinct assets vs MAX_CACHED_ASSETS = 100; 16/19/5/0/0 across five loads), and it still supports the 100 → 2000 change here.

The CSP bug is also still real and still blanks the page — loads 4 and 5 shed zero assets and died on EvalError before hydration. It just needs a different fix than the one I attempted. The right shape looks like breaking client reachability rather than touching the helper: platform/compat/process/command.ts is a node:child_process module and has no business in a browser bundle, but three client entries reach it —

chat/index.ts    -> stream-watchdog.ts -> watchdog-compat-adapter.ts -> compat/process.ts
mdx/index.ts     -> MDXProvider.tsx -> types/index.ts -> types/server.ts -> config/loader.ts -> ...
workflow/react/  -> use-workflow.ts -> workflow/types.ts -> agent/types.ts -> tool/context7.ts -> ...

Cutting those (lazy import at the call site, or splitting the type-only imports) removes the eval from the client graph without touching the helper's build behaviour. That is a bigger change than belongs on this PR, so I am leaving it off. Happy to open a separate issue or PR for it — say the word.

Nothing here blocks this PR; it is back to what you had.

@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit 540509c Aug 6, 2026
31 checks passed
@kojiwakayama
kojiwakayama deleted the fix/proxy-asset-cold-load-shedding branch August 6, 2026 08:24
@kwakayama kwakayama mentioned this pull request Aug 6, 2026
1 task
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.

3 participants