You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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).
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 full WorkerResponsePayload.
Caddy reverse_proxy /service/control/* control:8080 with 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-Type case only.
1759996 — duplicate; NetworkError on a quiet chunked stream. Workarounds mentioned: Content-Type and/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 fetch with XMLHttpRequest.
Waiting for Mozilla 1544313 “fixed in 150” — that was missing Content-Type sniffing only.
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 unblock fetch() once the preamble ran).
Encode the real object afterward (json.Encoder / leading whitespace is valid JSON).
Caddy: flush_interval -1 on reverse_proxy /service/control/* plus timeouts matching EXECUTION_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/:id until succeeded|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)
Same POST /runs, then GET /runs/:id/events with native EventSource (GET-only). Send : connected\n\n immediately (idiomatic SSE, also the first-byte lesson). Do not implement SSE as fetch(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).
Summary
POST /service/control/runholds the HTTP connection until the worker finishes (often 10–60s) and only then writes JSON viac.JSON. Chromium and WebKit treat flushed response headers as enough forfetch()to resolve. Firefox does not:fetch()stays pending until it sees at least one body byte (nsUnknownDecoder/ noOnDataAvailable).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
fetch("/service/control/run", { method: "POST", body: JSON })infrontend/src/utils.ts(runCode).Subscribe()gets the single RPC reply (control-service/main.gohandleRun).c.JSON(...)with the fullWorkerResponsePayload.reverse_proxy /service/control/* control:8080with default buffering.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 asTypeError: NetworkError when attempting to fetch resource, thenAbortError.Upstream
fetch()does not resolve until ~512 bytes or EOS. Fixed in Firefox 150 for the missing-Content-Typecase only.NetworkErroron a quiet chunked stream. Workarounds mentioned:Content-Typeand/or padding newlines.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-Typesniffing bug that landed in 150. Empty body ⇒ noOnDataAvailable⇒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, thenend(JSON). Client in the page:fetch(url, { method: "POST", cache: "no-store" }). Measure time untilfetch()resolves (not untilresp.text()).Optional
mode=dummy: write"\n"immediately after flush.Results (2026-09-03)
\\nthen wait@next1.63.0-alphaSame numbers for
XMLHttpRequest:readyState === 2(HEADERS_RECEIVED) also waits for the first body byte on Firefox. SwitchingrunCodefromfetchto 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.JSONat the end): bothfetchand 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
fetch(..., { cache: "no-store" }).Content-Type(already set).fetchwithXMLHttpRequest.Workarounds that do work
Smallest server fix (what #315 does)
After
Publish:Content-Type: application/json,200, write a JSON-whitespace"\n",Flush. That is the byte Firefox needs."\n"every 1s while the worker runs (idle insurance for a 60s wait; not required to unblockfetch()once the preamble ran).json.Encoder/ leading whitespace is valid JSON).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)
Same
POST /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
/runand 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:
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
\n+ Caddyflush_interval -1; add heartbeat only if idleNetworkErrors remain./runis not a 60s held POST.