realm-server: reset retrieveIndexHTML cache when work throws#4859
Merged
Conversation
The Deferred-based cache assigned promiseForIndexHTML before the rewrite work ran, so a throw inside getIndexHTML or the meta-rewrite block left the cache permanently pointing at a never-resolving promise. Subsequent requests awaited the dead promise instead of retrying, hanging index.html for the lifetime of the process. Replace the manual Deferred with an IIFE promise cached only after the work is kicked off, with a catch hook that clears the slot on rejection so the next caller re-runs getIndexHTML. Adds tests for the async getIndexHTML throw path, the sync rewrite throw path, and the single-flight memoization invariant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Fixes a latent bug where retrieveIndexHTML cached an unresolved Deferred.promise before the work began. If getIndexHTML() or the meta-rewrite (JSON.parse(decodeURIComponent(...))) threw, the slot was permanently set to a pending promise, causing the realm-server to hang on / and host-app HTML routes forever. The fix replaces the Deferred with an IIFE promise that is cached only after work starts, and uses work.catch(...) to clear the cache on rejection.
Changes:
- Replace
Deferred<string>with an IIFE-producedPromise<string>that is cached only after the work starts; clear cache on rejection so retries are possible. - Expose
retrieveIndexHTMLfromcreateServeIndexfor isolated testing. - Add new tests covering async failure recovery, synchronous rewrite-step failure recovery, and the single-flight memoization invariant.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| packages/realm-server/handlers/serve-index.ts | Removes the broken Deferred-based cache and introduces a self-clearing IIFE promise to fix the hang on rewrite/getIndexHTML failures. |
| packages/realm-server/tests/serve-index-test.ts | New test file verifying cache-clear on async/sync failure and single-flight memoization on success. |
| packages/realm-server/tests/index.ts | Registers the new serve-index-test in the realm-server live test runner. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Contributor
lukemelia
approved these changes
May 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes CS-11159.
Fixes a latent bug in
packages/realm-server/handlers/serve-index.tsthat can wedge the realm-server: a single transient throw insideretrieveIndexHTMLpoisons the in-process cache so that every subsequent request to/(and any host-app HTML route) hangs forever until the process is restarted. The bug ships every deploy; it just doesn't fire unless the rewrite work happens to throw.The bug
retrieveIndexHTMLbuilds the production HTML shell once and memoizes it. The shape (pre-fix):The cache slot is set to a
Deferred<string>.promisebefore the work that will resolve it runs. If anything between that assignment anddeferred.fulfill(...)throws, the exception bubbles out ofretrieveIndexHTMLand the request handler returns 500 — butpromiseForIndexHTMLis now permanently set to a pending promise that nothing will ever resolve or reject. Every subsequent caller takes theif (!isDev && promiseForIndexHTML) return promiseForIndexHTMLbranch andawaits that dead promise. The request hangs until process restart.Throw sources that can trigger this
getIndexHTML()itself — reads the host-app shell from disk / assets URL; any I/O or asset-fetch failure throws.JSON.parse(decodeURIComponent(g2))inside the meta-rewrite replacer if the embedded@cardstack/host/config/environmentcontent is malformed.import('crypto')failing on an exotic Node build (extremely unlikely but technically possible).How it surfaced
Copilot flagged the pattern on #4846 (thread). The reachability claim was checked against the code — this is a real reachable bug, not a defensive guard for an impossible state. The same shape existed in
server.tsbefore the #4846 refactor and was moved verbatim, so this is not a regression — just finally being addressed.The fix
Drop the manually-controlled
Deferredand cache the in-flightPromisedirectly via an IIFE. Hookwork.catch(...)to clear the cache slot on rejection so the next caller retriesgetIndexHTML()instead of awaiting a permanently-broken promise. TheDeferredwrapper only existed to bridge "I want to set the cache before the work has resolved" — which is exactly the requirement that creates the bug.Single-flight behaviour is preserved: concurrent callers still share one in-flight
getIndexHTML()run, and repeat callers reuse the cached value on success. The only behavioural change is that a thrown error no longer poisons the cache forever.Tests
New
packages/realm-server/tests/serve-index-test.ts:getIndexHTMLthrows on the first call and succeeds on the second; the firstretrieveIndexHTML()rejects, the second resolves, andgetIndexHTMLis invoked twice.getIndexHTMLreturns HTML with a malformed embedded config soJSON.parse(decodeURIComponent(...))throws inside the replacer; same recovery shape.retrieveIndexHTML()callers share one cached promise (getIndexHTMLinvoked exactly once); a subsequent call still reuses the cache.Test plan
pnpm --filter @cardstack/realm-server test(filtered toserve-index-test+ the existingserver-config-testthat also exercisesretrieveIndexHTML) — 4/4 passingpnpm --filter @cardstack/realm-server lint:jscleanprettier --checkclean on changed filesgetIndexHTMLin dev to throw once, verify the realm-server doesn't enter a permanent hang on/🤖 Generated with Claude Code