#9036·pi

openai-codex SSE parser buffers the whole response in one string, fatal heap OOM

Author: RooseveltAdvisorsCreated Sep 3, 2026Updated Sep 17, 2026

What happened?

pi aborted with a fatal V8 out-of-memory error while reading a Codex response stream:

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory

Environment: macOS, Node 26.7.0 (Homebrew), provider openai-codex, model gpt-5.6-luna, reasoning effort max. The heap limit was reached at roughly 3915 MB, Node's default old-space cap. The native frames read off the dump were StringSlowFlatten and StringPrototypeTrimStart, which is V8 flattening a very large rope string, the shape that repeated s += chunk produces.

parseSSE in packages/ai/src/api/openai-codex-responses.ts (line 786 at 4e69b0c) accumulates the response into a single buffer string and only drains it where buffer.indexOf("\n\n") matches. There is no upper bound, so a stream in which that boundary is never found grows one string until the process aborts.

There is a concrete way to reach that state. The WHATWG event stream spec lets a line end with CRLF, LF or CR, so a CRLF stream is valid SSE. indexOf("\n\n") never matches \r\n\r\n, so no event is ever parsed and the entire response body accumulates. Anything that rewrites line endings in transit produces this, and the SSE path is a normal fallback whenever the WebSocket transport is unavailable. The repo's three other SSE parsers (anthropic-messages.ts, mistral-conversations.ts, pi-messages.ts) all accept CRLF; only the Codex one does not.

The same indexOf also restarts at offset 0 on every read, so the scan is quadratic in the accumulated size.

This is the same failure #3327 reported (openai-codex, transport: sse, fatal heap OOM, no longer reproducible after switching to websocket). That issue was auto-closed and never diagnosed, and the code path is unchanged. The other closed OOM issues are different paths: #8746 and #2644 are session and message-history growth, #4583 and #5044 are resume, #7290 is --mode json stdout.

Steps to reproduce

Drive the real parseSSE with a synthetic Codex SSE body. I used a script that stubs fetch and calls stream() from packages/ai/src/api/openai-codex-responses.ts with transport: "sse", feeding a ReadableStream of a chosen size, then reports process.memoryUsage().heapUsed.

Feeding 128 MB of well-formed SSE events that differ only in line ending:

body events parsed peak heapUsed wall time
LF (\n\n boundaries) all 24 MB 0.5 s
CRLF (\r\n\r\n boundaries) none 275 MB 39 s

Heap tracks the bytes streamed one for one, and the boundary scan is quadratic: a body with no recognised boundary takes 0.1 s, 0.5 s and 2.3 s at 8 MB, 16 MB and 32 MB.

Reaching the actual abort, with node --max-old-space-size=512 and a 1 GB body:

fed=480MB heapUsed=482.4MB rss=576.2MB
fed=500MB heapUsed=502.4MB rss=592.6MB
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory

A heap snapshot taken with --heapsnapshot-near-heap-limit=1 at that point holds a single 508 MB string beginning data: xxxx..., 98 percent of the 516 MB of strings in the heap and the only object over 5 MB. Its retainer is a live concatenated string, which is buffer mid-append.

Expected behavior

Codex SSE parsing should recognise CRLF and CR event boundaries like the other providers, and the pending buffer should have an upper bound so a malformed or rewritten stream fails with a clear error instead of aborting the process. Truncating a model response silently would be worse than the crash, so the bound should throw.

I would like to implement this myself.

Version

0.84.4 (upstream 4e69b0c28060f0f02fbe38bfa7c21a2e2eb25057)