Skip to content

Detect stream EOF in reader loop instead of spinning#16

Merged
jkschneider merged 1 commit into
mainfrom
detect-stream-eof
May 6, 2026
Merged

Detect stream EOF in reader loop instead of spinning#16
jkschneider merged 1 commit into
mainfrom
detect-stream-eof

Conversation

@jkschneider
Copy link
Copy Markdown
Member

Summary

When an RPC peer dies, its stdout pipe goes to permanent EOF. The previous reader loop treated read() == -1 as an empty header line, synthesized a JsonRpcError with no id, constructed a JsonRpcException (paying full fillInStackTrace), looped, and repeated -- pegging one core for the lifetime of the JVM.

This was observed in production: recipe-marketplace ran npx --package=@openrewrite/rewrite@<version> rewrite-rpc at boot. The pinned npm version had not been published, so npx exited 1 immediately. The JVM-side reader thread spun for the full 20.5h of marketplace uptime, with 76% of total CPU in Throwable.fillInStackTrace.

Changes:

  • MessageHandler.receive now declares throws IOException so handlers can surface stream-closed cleanly instead of swallowing it as a recoverable parse error.
  • HeaderDelimitedMessageHandler and NewLineDelimitedMessageHandler: throw EOFException on first-byte EOF (between messages) and on mid-message EOF, instead of returning "" / a malformed-content error.
  • JsonRpc.bind(): catch EOFException once, fail any open requests, set shutdown = true, exit the loop.
  • Belt-and-suspenders: the no-id error broadcast in JsonRpc.bind() now skips the JsonRpcException allocation entirely when openRequests is empty -- so a single noisy non-RPC line on the wire (still possible for live but chatty peers) costs nothing when nothing is in flight.

API impact: MessageHandler is a small, library-internal interface; the only implementors live in this repo. Verified that org.openrewrite:rewrite-core (the only upstream non-library consumer of this API) does not implement MessageHandler and does not call .receive(...) directly, so this is a transparent bump for downstream.

Test plan

  • ./gradlew test passes
  • New HeaderDelimitedMessageHandlerTest covers: EOF before any byte, malformed-but-non-empty header still returns recoverable JsonRpcError, mid-message EOF surfaces as EOFException
  • New JsonRpcTest.readerLoopExitsCleanlyOnEof covers the full reader-loop teardown path: an in-flight request fails (rather than hangs) when the peer closes the stream

When an RPC peer dies, its stdout pipe goes to permanent EOF. The
previous reader loop treated EOF as an empty header line, synthesized a
JsonRpcError with no id, constructed a JsonRpcException, looped, and
repeated -- pegging a CPU core for the lifetime of the JVM. Observed in
production where rewrite-marketplace burned ~76% of one core for 20+
hours filling stack traces after npx exited at startup.

The handlers now distinguish "stream closed" (EOFException) from
"malformed message on a live stream" (recoverable JsonRpcError). The
JsonRpc reader loop catches EOFException once, fails any in-flight
requests, and exits.

Also guards the no-id error broadcast: skip the JsonRpcException
allocation entirely when there are no open requests to fail.
@jkschneider jkschneider merged commit 5bec8c3 into main May 6, 2026
1 check passed
@jkschneider jkschneider deleted the detect-stream-eof branch May 6, 2026 20:53
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