We reproduced the intermittent tools/call failures on isolated GKE previews of real Odin code, isolated the cause, and A/B-tested the worker-mode fix. It was never request volume — it's per-request cost against a ~2-worker pool, plus a text/html response bug that breaks strict MCP clients.
tools/list silently dropped as empty 202 text/htmlFive things, backed by direct measurements on real Odin (Cloudflare bypassed to hit the pod).
/_mcp request held a worker ~7 s (it re-scanned all of src/ every time). With ~2 workers, a handful saturates; hundreds of ~0.5 s API calls don't.master, every tools/call succeeds up to 48 concurrent. The catastrophic ~50% failures Benjamin hit on 07-29 (pre-fix) are gone.text/html is real & load-independent — on prod too.odin.alpy.com/_mcp returns errors as text/html, not JSON-RPC. Strict clients reject before reading the body.*.alpy.fun previews sit behind Access. Prod /_mcp reaches Odin directly.tools/list under load are dropped and returned as empty 202 text/html — a non-atomic Redis session-store bug. Worker mode does not fix it.Why a few calls break it while hundreds are fine.
/_mcp as a shop with ~2 cashiers (FrankenPHP workers). Each request occupies one cashier start-to-finish. Whether the shop copes = time-per-customer × customers-at-once vs number of cashiers.
src/ re-scan). 3rd customer waits 7 s, 4th 14 s… line explodes. ❌202/no-session/text-html./_mcp from ~7 s to ~0.5 s. A handful no longer explodes the line. But still ~2 cashiers, one customer each → big bursts still grow latency.Both: 1 pod, 500m CPU / 2Gi, ~2 workers, same DB/OAuth/harness. The only variable is classic-per-request vs FrankenPHP worker loop (ARO-4256). Benjamin's exact sequence — initialize → tools/list → tools/call ×4 — per session, ramped 1→48 concurrent.
| Concurrency | Classic p50 | Worker p50 | Classic drop | Worker drop | tool-calls OK |
|---|
Worker mode is within noise of classic here (~13% faster only at c≥32) and the ~3% tools/list drop rate is unchanged — because the race lives in the shared Redis session store, and CPU (not kernel-boot) is the binding constraint once the scan fix is in.
Hitting Odin's kernel-booting /api path directly (Access-exempt) on the classic pod. Latency scales ~linearly with concurrency (requests serialize through ~1–2 workers), then the pod collapses.
text/html+202 symptom at ~his observed ~50% — proving the mechanism when per-request cost is high.
scan_dirs fix merged to master.Two independent paths on /_mcp leak text/html instead of JSON-RPC. Strict clients (Zendesk) reject on content-type before reading the body → "unsupported content type text/html".
202 (the dropped response)
// vendor/mcp/sdk/.../StreamableHttpTransport.php $out = getOutgoingMessages($sessionId); if (empty($out)) { // no Content-Type set → return createResponse(202); }Response is queued into a Redis session blob then read back via a separate reload. If the read is empty → header-less
202 → Symfony defaults to text/html, empty body.
401 (auth failure)
HTTP/2 401 content-type: text/html; charset=UTF-8 www-authenticate: Bearer ... Full authentication is required to access this resource.From Symfony's security firewall (before the MCP controller). Also HTML — nobody set
application/json.
SET (Psr16SessionStore). No atomic ops, no lock, no WATCH/MULTI — last-write-wins. Under concurrency, an interleaved session save clobbers the queued response → empty read → 202 text/html. Hits tools/list hardest (biggest payload, every handshake)./_mcp always answer JSON — never text/html.P1 · load-independentkernel.response listener on ^/_mcp forcing application/json (covers the header-less 202), plus a JSON auth entry-point for the 401. Turns an opaque failure into a well-formed, retryable JSON. This is Benjamin's #1 — fully in Odin's control.repro/ARO-4202-mcp-thread-saturation vs worker repro/ARO-4256-worker-loadtest · Cloudflare Access read-only + scoped bypass · generated for ARO-4202.