Skip to content

fix(jsonl): stop the reverse reader looping on a chunk's leading newline - #675

Open
eric-engberg wants to merge 2 commits into
sirmalloc:mainfrom
eric-engberg:fix/jsonl-reverse-reader-loop
Open

eric-engberg wants to merge 2 commits into
sirmalloc:mainfrom
eric-engberg:fix/jsonl-reverse-reader-loop

Conversation

@eric-engberg

Copy link
Copy Markdown
Contributor

What

The status line no longer hangs when Thinking Effort falls back to reading the transcript and the transcript has a newline at the start of one of the reader's 1 MiB chunks (for example, a transcript that starts with an empty line). Before, the process spun at full CPU and never printed anything; now it renders as usual.

Why

iterateJsonlLinesReverseSync reads the file backwards in 1 MiB chunks and walks each chunk with chunk.lastIndexOf(0x0a, end - 1). When the newline it just consumed was at index 0, end became 0 and the next search ran with an offset of -1. Buffer.lastIndexOf counts a negative offset from the end of the buffer, so the reader jumped back to the chunk's last newline and yielded the same lines again, forever.

That happens when a transcript starts with an empty line, or when the byte at size - k × 1 MiB is \n. Thinking Effort uses this reader whenever the payload has an effort.level it doesn't accept, such as an empty string:

{ "transcript_path": "…/session.jsonl", "model": { "id": "claude-opus-4-6" }, "effort": { "level": "" } }

With a 1.1 MB transcript whose last chunk starts with a newline, main was still running after 5 s under both Bun and Node and had to be killed, with no output. The same happened with a three-line transcript starting with \n. With an /effort record further back in the file, the reader never got past the last chunk to find it. Session Name's transcript lookup goes through the same reader.

How

Once end reaches the start of the chunk there is nothing left to search, so the reader stops looking for newlines in that chunk instead of passing a negative offset. Everything else stays the same: lines that span chunks, CRLF, the BOM on the first record, and empty lines are handled as before. The forward readers never had this problem: they use indexOf, which only moves forward.

Demo

The same payload piped through main and this branch, timed and capped at 4 s: effort.level is "" and the 1.1 MB transcript is sample data, generated so that its last 1 MiB chunk starts with a newline and its /effort high record is older than that chunk. main uses all its CPU until it's killed; this branch prints the line in about 0.15 s.

Powerline: before

Piped render hanging until killed on main, Powerline mode

Powerline: after

Piped render printing Thinking: high on this branch, Powerline mode

Plain: before

Piped render hanging until killed on main, plain mode

Plain: after

Piped render printing Thinking: high on this branch, plain mode

Testing

  • New tests in jsonl-lines.test.ts: a file that starts with an empty line, and a file just over 1 MiB whose last chunk starts with a newline. Each collects at most 10 lines, so the old behavior fails with repeated lines instead of hanging the suite. Both failed on main (the same lines repeated ten times) and pass here.
  • bun test: 2784 pass, 0 fail. bun run lint passes.
  • The changed test file under Node 26.10.0 (Vitest): 13 pass, including both new tests. The one failure is the existing readFile spy test, which fails the same way on main because Node doesn't allow spying on built-ins.
  • Built CLI under Bun 1.4.2 and Node 26.10.0 with both looping transcripts: main is still running at 5 s under both and gets killed; this branch renders Model: Opus 4.6 | Thinking: high (or Thinking: default for the short one) in 0.1–0.25 s.
  • runtime-check.sh -c thinking-effort with a transcript that doesn't loop: Bun and Node agree in plain and Powerline modes, the output is identical to main, and the TUI opens and exits cleanly under both.

iterateJsonlLinesReverseSync walks each chunk backwards with
chunk.lastIndexOf(0x0a, end - 1). When the newline it just consumed sat
at index 0, end became 0 and the next search ran with an offset of -1,
which Buffer.lastIndexOf counts from the end of the buffer. The reader
jumped back to the chunk's last newline and yielded the same lines
again, forever.

That happens whenever a transcript starts with an empty line, or when
the byte at size - k * 1 MiB is a newline. Thinking Effort falls back to
this reader when the payload's effort.level is present but not a level
it accepts (for example ""), so a matching transcript kept the status
line spinning at full CPU and never printing.

Stop searching once end reaches the start of the chunk.
The reverse reader searched for the previous newline in two places, and
only the one inside the loop needed the guard against an offset of -1.
lastNewlineBefore() holds the guard and the reason for it, so both
searches behave the same and the loop body stays as simple as it was
before the fix.
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.

1 participant