Skip to content

fix(server): frame Codex app-server JSONL on LF only#2162

Open
jasonhnd wants to merge 6 commits into
getpaseo:mainfrom
jasonhnd:fix/2021-codex-u2028-jsonl
Open

fix(server): frame Codex app-server JSONL on LF only#2162
jasonhnd wants to merge 6 commits into
getpaseo:mainfrom
jasonhnd:fix/2021-codex-u2028-jsonl

Conversation

@jasonhnd

Copy link
Copy Markdown
Contributor

Problem

Codex app-server JSONL was framed with node:readline, which treats literal
U+2028/U+2029 characters inside JSON strings as line boundaries. This split
valid JSON-RPC messages and caused session resume requests to time out.

Solution

  • replace node:readline framing with an LF-only stdout buffer
  • preserve U+2028/U+2029 as JSON string data
  • retain CRLF compatibility
  • add deterministic regression coverage

Testing

  • npx vitest run packages/server/src/server/agent/providers/codex/app-server-transport.test.ts --bail=1
  • npm run lint -- <changed files>
  • npm run typecheck --workspace=@getpaseo/server

Closes #2021

node:readline treats literal U+2028/U+2029 as line boundaries on Node 24 /
Electron, which splits valid Codex JSON-RPC records and times out session
resume. Frame stdout on LF only, preserve those characters as JSON string
data, keep CRLF compatibility, and add regression coverage.
@greptile-apps

greptile-apps Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a bug where node:readline's treatment of U+2028/U+2029 as line boundaries split valid JSONL messages from the Codex app-server, causing session-resume timeouts. The fix replaces readline with a manual LF-only buffer that accumulates stdout chunks and splits only on , with stripped for CRLF compatibility.

  • Transport fix (app-server-transport.ts): removes the readline.Interface, adds stdoutBuffer, and routes all stdout through a new handleStdoutChunk method that splits exclusively on LF.
  • Regression tests (app-server-transport.test.ts): three new tests verify that literal U+2028/U+2029 survive framing, that records split across two chunks are reassembled, and that CRLF termination still works.

Confidence Score: 4/5

Safe to merge with a one-line fix to the chunk decoder; the core logic is correct but has a latent corruption path.

The framing logic is correct and the intent is sound, but chunk.toString() decodes each Buffer in isolation without a stateful UTF-8 decoder. When U+2028 or U+2029 happen to be split across two pipe reads at the byte level, the decoding produces replacement characters and the JSONL message is silently dropped or corrupted. The old readline path was immune to this via its internal StringDecoder. The regression tests split records at JavaScript character boundaries, so this byte-boundary path is not exercised.

The data listener in app-server-transport.ts around line 172–174 — specifically the chunk.toString() call — needs to be replaced with StringDecoder.write() or child.stdout.setEncoding('utf8').

Important Files Changed

Filename Overview
packages/server/src/server/agent/providers/codex/app-server-transport.ts Replaces readline with a manual LF-only buffer; correctly fixes the U+2028/U+2029 framing bug, but chunk.toString() does not use StringDecoder, so multi-byte characters split across OS pipe reads can still be corrupted.
packages/server/src/server/agent/providers/codex/app-server-transport.test.ts Adds three deterministic regression tests for U+2028/U+2029 passthrough, CRLF termination, and chunked reassembly; splits are at character boundaries so byte-boundary corruption is not exercised.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant CP as Codex Child Process
    participant ST as child.stdout (stream)
    participant BUF as stdoutBuffer
    participant HL as handleLine()
    participant NH as notificationHandler / pending

    CP->>ST: write(chunk with U+2028/U+2029)
    ST-->>BUF: data event → handleStdoutChunk(chunk.toString())
    BUF->>BUF: append chunk, scan for LF
    BUF->>BUF: slice line, strip trailing CR
    BUF->>HL: handleLine(line)
    HL->>HL: JSON.parse(line)
    alt notification
        HL->>NH: notificationHandler(method, params)
    else response
        HL->>NH: pending.resolve(result)
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant CP as Codex Child Process
    participant ST as child.stdout (stream)
    participant BUF as stdoutBuffer
    participant HL as handleLine()
    participant NH as notificationHandler / pending

    CP->>ST: write(chunk with U+2028/U+2029)
    ST-->>BUF: data event → handleStdoutChunk(chunk.toString())
    BUF->>BUF: append chunk, scan for LF
    BUF->>BUF: slice line, strip trailing CR
    BUF->>HL: handleLine(line)
    HL->>HL: JSON.parse(line)
    alt notification
        HL->>NH: notificationHandler(method, params)
    else response
        HL->>NH: pending.resolve(result)
    end
Loading

Reviews (6): Last reviewed commit: "Merge branch 'main' into fix/2021-codex-..." | Re-trigger Greptile

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 09990a8283

ℹ️ 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".

// JSONL framing must use LF only. node:readline also treats U+2028/U+2029 as
// line boundaries (Node 24+ / Electron), which splits valid JSON strings.
child.stdout.on("data", (chunk) => {
this.handleStdoutChunk(chunk.toString());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve UTF-8 state across stdout chunks

When a child-process pipe splits a multibyte UTF-8 character between data events, converting each Buffer independently with chunk.toString() replaces the partial bytes with U+FFFD before handleStdoutChunk can reassemble the JSONL record. This silently corrupts Unicode in Codex responses and notifications (especially large records); the added chunk test does not cover this because it splits a JavaScript string rather than encoded bytes. Use a stateful UTF-8 decoder, such as StringDecoder or stream setEncoding("utf8"), before LF framing.

Useful? React with 👍 / 👎.

);

// Stream consumers process the write after the current turn.
await new Promise<void>((resolve) => setImmediate(resolve));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Inline event-waiting in test bodies

The three new tests each place await new Promise<void>((resolve) => setImmediate(resolve)) directly in the test body — the same pattern the team's test-shape rules flag as "no inline promise/event waiting; mechanics live in helpers." The setImmediate yield is also unnecessary for the notification path because handleLine reaches notificationHandler?.(...) before its first await, so it runs synchronously inside the "data" event handler. Consider extracting a small helper such as waitForNotification(client) that returns the next notification and encapsulates any needed yield, the same way existing tests expose codex.nextResponse(). The pattern appears in all three new tests.

Rule Used: # Code Review Pattern Reference: Slop, Tests, Feat... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: Codex session resume times out when a prompt contains U+2028

1 participant