Repository navigation
Firefox does not resolve fetch() on held POST /run until a body byte #321
Description
Summary
POST /service/control/run holds the HTTP connection until the worker finishes (often 10–60s) and only then writes JSON via c.JSON. Chromium and WebKit treat flushed response headers as enough for fetch() to resolve. Firefox does not: fetch() stays pending until it sees at least one body byte (nsUnknownDecoder / no OnDataAvailable).
This is independent of upgrading Playwright. It still happens on current and newer Firefox builds.
Related: #315 (streaming preamble workaround), #318 (Playwright 1.62 upgrade; CI also saw flaky waitForResponse("**/service/control/run") on Firefox), #320 (live logs / job API, which would avoid a held POST).
What the product does today
- Browser
fetch("/service/control/run", { method: "POST", body: JSON })infrontend/src/utils.ts(runCode). - Control publishes one AMQP job to a one-shot worker pod and blocks the HTTP handler until
Subscribe()gets the single RPC reply (control-service/main.gohandleRun). - Then
c.JSON(...)with the fullWorkerResponsePayload. - Caddy
reverse_proxy /service/control/* control:8080with default buffering. - CI hits the stack over HTTP/1.1 (
http://127.0.0.1:nodePort), not HTTP/2/3.
The UI does not stream logs; it waits for the complete JSON. The Firefox bug is not “we need headers early for UX”. It is that if headers get flushed without a body (Echo/Caddy/chunked encoding), Firefox’s fetch() never resolves until a body byte (or EOS). A quiet stream can also surface as TypeError: NetworkError when attempting to fetch resource, then AbortError.
Upstream
- Mozilla 1544313 — fetch/response body stream buffers when sniffing;
fetch()does not resolve until ~512 bytes or EOS. Fixed in Firefox 150 for the missing-Content-Typecase only. - 1759996 — duplicate;
NetworkErroron a quiet chunked stream. Workarounds mentioned:Content-Typeand/or padding newlines. - 1793342 — same decoder on HTTP/3; even with
Content-Type, some people needed{ cache: "no-store" }.
The case that bites us is headers flushed + Content-Type: application/json + empty body until the worker is done. That is not the missing-Content-Type sniffing bug that landed in 150. Empty body ⇒ no OnDataAvailable ⇒ fetch() stays pending.
Playwright’s own library tests still comment: “In Firefox, |fetch| will be hanging until it receives |Content-Type| header from server” (page-network-response.spec.ts). They always write a body byte (hello ) after setting Content-Type.
Repro (no Caddy, HTTP/1.1)
Node server: Content-Type: application/json, writeHead(200), flushHeaders(), wait 1500ms, then end(JSON). Client in the page: fetch(url, { method: "POST", cache: "no-store" }). Measure time until fetch() resolves (not until resp.text()).
Optional mode=dummy: write "\n" immediately after flush.
Results (2026-09-03)
| Playwright | Browser | Headers only (flush, no body) | Dummy \\n then wait |
|---|---|---|---|
| 1.60.0 | Firefox 150.0.2 | ~1510ms (waits for JSON) | ~8ms |
| 1.62.1 | Firefox 153.0 | ~1507ms | ~5ms |
@next 1.63.0-alpha |
Firefox 155.0 | ~1507ms | ~7ms |
| any of the above | Chromium | ~2ms | ~2ms |
| any of the above | WebKit | ~1–2ms | ~1ms |
Same numbers for XMLHttpRequest: readyState === 2 (HEADERS_RECEIVED) also waits for the first body byte on Firefox. Switching runCode from fetch to XHR does not help.
cache: "no-store" was already on in the repro and did not unblock Firefox.
Control case hold-all (nothing written until JSON is ready, like today’s c.JSON at the end): both fetch and XHR wait ~1500ms and then succeed. That is correct. The failure mode is headers already on the wire, body still empty.
What does not fix it
- Upgrading Playwright / Firefox (still broken through Firefox 155).
fetch(..., { cache: "no-store" }).- Setting request
Content-Type(already set). - Replacing
fetchwithXMLHttpRequest. - Waiting for Mozilla 1544313 “fixed in 150” — that was missing Content-Type sniffing only.
Workarounds that do work
Smallest server fix (what #315 does)
After Publish:
- Set
Content-Type: application/json,200, write a JSON-whitespace"\n",Flush. That is the byte Firefox needs. - Optional:
"\n"every 1s while the worker runs (idle insurance for a 60s wait; not required to unblockfetch()once the preamble ran). - Encode the real object afterward (
json.Encoder/ leading whitespace is valid JSON). - Caddy:
flush_interval -1onreverse_proxy /service/control/*plus timeouts matchingEXECUTION_TIMEOUT, so the proxy does not hold the first byte.
Heartbeat without Caddy flush can still leave Firefox stuck if Caddy buffers the preamble.
Better product shape (do not hold POST)
The UI is fire-and-forget: one result blob (logs, files, duration). Professional HTTP APIs for 10–60s jobs usually:
-
Job + poll (best fit for current UX)
POST /runs→202 { id }as soon as the job is queued.
GET /runs/:iduntilsucceeded|failed.
Each response is a small complete JSON document. Firefox, Caddy, Cloudflare, and retries all behave. -
SSE if we want live logs (Live logs: stream worker stdout over a job API (consider Redis later) #320)
SamePOST /runs, thenGET /runs/:id/eventswith nativeEventSource(GET-only). Send: connected\n\nimmediately (idiomatic SSE, also the first-byte lesson). Do not implement SSE asfetch(POST)+response.body— that is the same Firefox path. -
WebSocket only if we need a session (cancel, stdin, REPL). Overkill for Run as it works today.
Holding /run and papering over Firefox with a preamble newline is a valid hotfix, not the long-term API.
CI symptom
On #318 e2e, Firefox (and sometimes Chromium) tests flaked with:
page.waitForResponse: Test timeout of 120000ms exceeded.
waiting for response "**/service/control/run"
Retries often passed. That matches a client fetch() that never sees a complete response (or sits until worker timeout) when headers were flushed without a body, or the connection went idle. Separate from the Firefox 153 Monaco ts.worker tab crash on the share test (fixed on #318).
Suggested next step
- Short term: land a minimal version of Keep /run streaming so Firefox fetch() sees a first byte #315 if we need CI/Firefox stability before a job API — preamble
\n+ Caddyflush_interval -1; add heartbeat only if idleNetworkErrors remain. - Medium term: job + poll (or SSE as in Live logs: stream worker stdout over a job API (consider Redis later) #320) so
/runis not a 60s held POST.
Activity
mxschmitt commentedon Sep 3, 2026
Extra field notes (CI /
6f31617)These came out of the Playwright CI flakiness work and are not fully in the body above.
Pref vs the short flake
Firefox
network.http.response.timeoutis 300s after traffic has started (Firefox 29+). That is not the 8–60s/runabort. Production/HTTPS + Caddy buffering is a real concern for whether the preamble byte actually leaves the proxy, but the 300s pref does not explain CI.What
6f31617actually changedCommit
6f31617(Flush /run headers so Firefox does not abort slow executions):Publish, write JSON headers plus a newline andFlush().reverse_proxy: 2m read/write/header timeouts andflush_interval -1.Message at the time: CI
--repeat-each=3still failed 7 tests. Control logs showed/runaccepted and finished, but FirefoxfetchthrewTypeError: NetworkError when attempting to fetch resourcewith resource time -1. Turnstile had already skipped.That matches “no response bytes until the worker returns” (example 7 ~8s, timeout test 60s). Chromium/WebKit wait; Firefox does not.
Residual after that flush
On
6f31617a later--repeat-eachpass was 144 passed / 6 failed, still Firefox only. Example 7:/runcame back in ~13s (no more 120s hang), thenAbortError: The operation was aborted.and 0 images. So the preamble unblocks TTFB /waitForResponse, but it does not replace retries or a job+poll API. Some/runcases still retried green; the flush is a hotfix, not the product shape.Today on
mainhandleRunstill waits onSubscribe()/EXECUTION_TIMEOUTand thenc.JSON— no preamble on the default branch. The streaming attempt lives on PR #315 (cbd8e76/cursor/firefox-run-keepalive-7b47).mxschmitt commentedon Sep 4, 2026
Not needed anymore since we stream logs now.