Abort and retry a base-realm fetch that stalls before response headers#5600
Abort and retry a base-realm fetch that stalls before response headers#5600habdelra wants to merge 7 commits into
Conversation
The in-app fetch-retry net for base-realm artifacts that occasionally vanish in CI recovers only from a thrown `TypeError: Failed to fetch`. A stall where the response headers never arrive is the same phenomenon in a different shape: the fetch neither resolves nor rejects, so it is never caught, has no bound, and hangs the fetcher test-waiter until QUnit's global timeout — which also cascades into the following tests as their mock realm is torn down mid-flight. Bound how long a retryable base-realm fetch may wait for response headers in the browser test suite and abort past that bound, turning the stall into the retryable failure the existing net already recovers from (a fresh connection gets its headers). Each attempt uses its own abort signal so one stalled attempt doesn't poison the next, and the timer is cleared the moment the fetch settles, so a resolved response's body stream is never aborted. Scoped to the browser test environment: a slow response in node / worker / env-mode (e.g. a heavy `_search`) is never aborted and retried. The per-attempt retry log now carries the error so a future failure shows which shape it was. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dc026b868d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The host reaches the base realm through a virtual-to-real URL mapping, and buildRequest reconstructed the mapped request without copying its signal. The per-attempt header-stall timeout is attached before the mapping runs, so the native fetch for a base artifact addressed at the virtual host (e.g. https://cardstack.com/base/_info) never saw the abort and could still hang. Carry the signal across the remap, and cover the mapped path in the header-stall recovery test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The header-stall recovery test's stub returned a bare Response whose empty url tripped the virtual network's url bookkeeping once a URL mapping was in play. Give the stub a resolved (configurable) url like a real fetch, keep the recovery test on the direct fetch path, and cover the virtual-to-real signal preservation with a focused buildRequest test instead — buildRequest is now exported for that. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR prevents browser-based host tests from hanging indefinitely when a retryable base-realm fetch stalls before response headers arrive, by adding a per-attempt header timeout that aborts and retries the request (test environment only) and by ensuring abort signals survive virtual-to-real URL remapping.
Changes:
- Extend
VirtualNetwork/withRetriesto optionally apply a per-attempt “response headers must arrive by” timeout (gated to browser +__environment === 'test'+ already-retryable URLs), aborting and retrying stalled attempts. - Ensure per-attempt abort signals are attached to the underlying native fetch even when the request URL is remapped (
buildRequestnow preservessignal;runFetchmerges caller + per-attempt signals). - Add unit tests that pin the timeout gating and the stall → abort → retry → success path, plus signal propagation through
buildRequest.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| packages/runtime-common/virtual-network.ts | Adds injectable header-timeout plumbing + per-attempt abort signals in retry loop; preserves abort signals through request remapping. |
| packages/host/tests/unit/virtual-network-retry-test.ts | Adds unit coverage for timeout gating, header-stall recovery, and remapped-request signal propagation. |
Comments suppressed due to low confidence (4)
packages/runtime-common/virtual-network.ts:743
- [Claude Code 🤖] The retry log line reads a bit ungrammatically ("Encountered fetch failed for …"). Since this is newly edited logging, it’s a good spot to tighten the message for easier grepping and readability.
`Encountered fetch failed for ${url.href} (${err?.name ?? 'Error'}: ${
packages/host/tests/unit/virtual-network-retry-test.ts:123
- [Claude Code 🤖] The new assertion message mentions a CI-specific hang. Since the predicate is a general contract (what gets bounded), the message can be phrased without environment-specific history.
'the base _info fetch that hung in CI is bounded',
packages/host/tests/unit/virtual-network-retry-test.ts:168
- [Claude Code 🤖] These new comments describe a past CI failure ("Mirrors the CI failure…") and use past-tense wording. Rewording them to describe the modeled failure mode keeps the comment evergreen.
// fetch does), then succeeds. Mirrors the CI failure where base/_info headers
// never arrived and the unbounded fetch hung until QUnit's global timeout.
packages/host/tests/unit/virtual-network-retry-test.ts:184
- [Claude Code 🤖] In the stalled-attempt stub, the
!signalbranch returns without resolving or rejecting, which can hang the entire unit test suite if the signal wiring regresses. It’s safer to fail fast here so the test fails quickly (and then the latersignalSeen()assertion still catches the regression) rather than timing out the whole run.
if (!signal) {
return; // no signal wired => hang (fix broken); QUnit will time out
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
State the condition the bound handles — a fetch whose response headers never arrive — directly, rather than framing it as a specific observed occurrence. Comments-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
qunit-testing.md now ships in the built boxel skill, so it no longer belongs in PENDING_BOXEL_REFERENCES — the skill-loader validation test fails while a shipped reference is still listed as pending. Empty the list and let the "pending references are still pending" test pass cleanly when nothing is pending. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The QUnit lint rule forbids returning early from a test. Declare the assertion count up front — expect(0) accepts the empty pending list without running an assertion — instead of short-circuiting. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Reviewed the retry/abort changes. The core fix is well-constructed — per-attempt One thing worth fixing before merge:
} catch (err: any) {
// A caller-initiated abort isn't transient — surface it immediately.
if (err?.name === 'AbortError') {
throw err;
}
if (!shouldRetryFetch(url) || ++attempt > maxAttempts) { ... }The per-attempt timeout abort sets Minor / optional: the 🤖 Reviewed with Claude Code |
Host acceptance shards intermittently hang on a base-realm fetch — most visibly the Spec preview tests — when a request to the base realm's
_info(or another base artifact) is issued but its response headers never arrive. The fetch neither resolves nor rejects, so thefetchertest-waiter never settles,settled()blocks, and the test burns all the way to QUnit's global timeout. Because the wedged request is still in flight when the timeout fires, teardown pulls the mock realm out from under the next test or two, so they cascade withTypeError: Failed to fetch.The virtual network already carries a retry net for the closely-related case where base-realm artifact fetches "vanish" with a thrown
TypeError: Failed to fetch. That net only engages on a thrown error, so the stall variant — headers that never come — slips past it: there is nothing to catch and no bound on the wait.Fetch handling —
packages/runtime-common/virtual-network.tsAbortController, so a stalled attempt's abort never poisons the next. The timer is cleared the instant the fetch settles, so a resolved streaming response is never aborted —withRetriesonly awaits the response's headers, not its body._search) is never aborted and retried. Production fetch behavior is unchanged.buildRequestcarries the abort signal across the virtual-to-real URL remap, so a base artifact addressed at the virtualhttps://cardstack.com/base/...host still sees the abort once the host maps it to the resolved realm URL.VirtualNetwork(default 10s) so the unit tests can drive the stall → abort → retry → success path deterministically.TypeError: Failed to fetchor an aborted header stall (FetchHeaderTimeout).Tests pin which URLs the bound covers, that the signal survives the URL remap, and the end-to-end recovery.
Unrelated fixup —
packages/software-factoryqunit-testing.mdnow ships in the builtboxelskill, so it no longer belongs inPENDING_BOXEL_REFERENCES— the skill-loader validation test fails while a shipped reference is still listed as pending. The list is emptied, and the "pending references are still pending" test accepts the empty case viaexpect().