Skip to content

fix(peer): skip empty octet-stream messages so a pending read can't stall - #162

Merged
dinwwwh merged 3 commits into
mainfrom
claude/trusting-mayer-muso9f
Oct 13, 2026
Merged

dinwwwh merged 3 commits into
mainfrom
claude/trusting-mayer-muso9f

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 13, 2026

Copy link
Copy Markdown
Member

Summary

toOctetStream could leave a pending body read hanging forever. A peer could send two octet-stream messages in a row that had neither binary nor close ({ kind: 'octet-stream', json: {} }, which the wire validator accepts). That left reader.read() pending with no pull() waiting on the queue. After that, nothing reached the reader: later chunks, close: true, ServerPeer.close(), ClientPeer.close() and the request AbortSignal all had no effect.

  • Server: a handler reading a request body never resumed and its finally never ran, so any locks or transactions it held leaked. ServerPeer.message() never settled.
  • Client: a response body read ignored its own AbortSignal timeout and ClientPeer.close().

Our own OctetStreamTransmitter never sends such a message, because an empty chunk is a zero-length Uint8Array, which is truthy. Only a buggy or malicious peer can trigger this.

Root cause

A ReadableStream calls pull again only if a read arrived while the previous pull was still running. pull() handled one message per call, and for an empty message it returned without enqueuing, closing or erroring. The first empty message was absorbed by that re-call, but the second left the stream idle. Queue.close() and Queue.abort() only reject pulls that are pending at that moment, and there were none.

Fix

pull() now keeps calling queue.pull() until it gets a message with binary or close, then runs the existing enqueue/close code, which is unchanged. toAsyncIteratorObject already handles event streams the same way. While a read is outstanding, a queue.pull() is now always waiting, so data, close and abort reach the reader again.

There is no public API change. The wire validator, Queue and the peer lifecycle are untouched.

Tests

  • octet-stream.test.ts:
    • Empty messages are skipped: the read yields [1,2,3], then done, and cleanup reports success.
    • After two empty messages, a pending read rejects on queue.close(err) and on queue.abort(err).
  • server.test.ts: an octet-stream request, two empty messages, then peer.close(). The handler's read rejects with AbortError, the handler finishes and message() settles.
  • client.test.ts: an octet-stream response and two empty messages. Aborting the request signal, or calling peer.close(), rejects the pending read.

All 6 new tests time out without the fix and pass with it. vitest run packages/peer (279 tests) and pnpm run check pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_016R6UEWNWXpmbcpXs2d1sTq


Generated by Claude Code

…y nor close

`toOctetStream` resolved `pull()` without enqueuing or closing when a
message carried neither `binary` nor `close`. The stream only calls `pull`
again if a read arrived meanwhile, so two such messages in a row left a
pending read with nothing waiting on the queue: queue close/abort, later
chunks, `ServerPeer.close()`, `ClientPeer.close()` and the request
`AbortSignal` never reached the reader. A malicious peer could hang a
handler's body read (its `finally` never ran) or a client's response read.

`pull()` now loops until it enqueues a chunk or closes the stream, as
`toAsyncIteratorObject` already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R6UEWNWXpmbcpXs2d1sTq
…sion test

Skip messages with neither `binary` nor `close` up front with one
`continue` instead of re-checking `binary` to decide when to return, and
drop the redundant `finally` spy from the server test: `peer.message()`
settling already proves the handler ran to completion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R6UEWNWXpmbcpXs2d1sTq
…ctet-stream messages

Wrap just the `queue.pull()` in the skip loop, so the enqueue/close body
stays as it was before the fix and needs no `continue` or trailing
`return`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R6UEWNWXpmbcpXs2d1sTq
@pkg-pr-new

pkg-pr-new Bot commented Oct 13, 2026

Copy link
Copy Markdown
@standard-server/aws-lambda

npm i https://pkg.pr.new/@standard-server/aws-lambda@162

@standard-server/core

npm i https://pkg.pr.new/@standard-server/core@162

@standard-server/fastify

npm i https://pkg.pr.new/@standard-server/fastify@162

@standard-server/fetch

npm i https://pkg.pr.new/@standard-server/fetch@162

@standard-server/node

npm i https://pkg.pr.new/@standard-server/node@162

@standard-server/peer

npm i https://pkg.pr.new/@standard-server/peer@162

@standard-server/shared

npm i https://pkg.pr.new/@standard-server/shared@162

commit: 36fe3d4

@codspeed

codspeed Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

⚠️ 5 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

✅ 26 untouched benchmarks
⏩ 108 skipped benchmarks1


Comparing claude/trusting-mayer-muso9f (36fe3d4) with main (637e700)

Open in CodSpeed

Footnotes

  1. 108 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@codecov

codecov Bot commented Oct 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@pullfrog pullfrog Bot left a comment

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.

✅ No new issues found.

Reviewed changes

Reviewed the full diff: the toOctetStream skip loop and its unit plus client/server regression tests.

  • toOctetStream loops past empty messages — pull() now keeps calling queue.pull() until a message carries binary or json.close, so while a read is outstanding a queue.pull() is always pending and later chunks, close, abort and peer.close() reach the reader again. Mirrors the existing event-stream loop in toAsyncIteratorObject.
  • Regression tests — octet-stream.test.ts covers skip-then-yield plus pending-read rejection on queue.close/queue.abort; client.test.ts covers signal abort and peer.close() on an octet response body; server.test.ts covers peer.close() rejecting the handler's pending read so message() settles.

Verified locally: the touched suites pass (127 tests), the three new octet-stream tests time out without the fix, and pnpm run check is green.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@dinwwwh
dinwwwh merged commit 99f8bda into main Oct 13, 2026
11 checks passed
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.

2 participants