Repository navigation
fix(peer): skip empty octet-stream messages so a pending read can't stall - #162
Conversation
…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
@standard-server/aws-lambda
@standard-server/core
@standard-server/fastify
@standard-server/fetch
@standard-server/node
@standard-server/peer
@standard-server/shared
commit: |
Merging this PR will not alter performance
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Reviewed the full diff: the toOctetStream skip loop and its unit plus client/server regression tests.
toOctetStreamloops past empty messages —pull()now keeps callingqueue.pull()until a message carriesbinaryorjson.close, so while a read is outstanding aqueue.pull()is always pending and later chunks,close,abortandpeer.close()reach the reader again. Mirrors the existing event-stream loop intoAsyncIteratorObject.- Regression tests —
octet-stream.test.tscovers skip-then-yield plus pending-read rejection onqueue.close/queue.abort;client.test.tscovers signal abort andpeer.close()on an octet response body;server.test.tscoverspeer.close()rejecting the handler's pending read somessage()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.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Summary
toOctetStreamcould leave a pending body read hanging forever. A peer could send two octet-stream messages in a row that had neitherbinarynorclose({ kind: 'octet-stream', json: {} }, which the wire validator accepts). That leftreader.read()pending with nopull()waiting on the queue. After that, nothing reached the reader: later chunks,close: true,ServerPeer.close(),ClientPeer.close()and the requestAbortSignalall had no effect.finallynever ran, so any locks or transactions it held leaked.ServerPeer.message()never settled.AbortSignaltimeout andClientPeer.close().Our own
OctetStreamTransmitternever sends such a message, because an empty chunk is a zero-lengthUint8Array, which is truthy. Only a buggy or malicious peer can trigger this.Root cause
A
ReadableStreamcallspullagain only if a read arrived while the previouspullwas 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()andQueue.abort()only reject pulls that are pending at that moment, and there were none.Fix
pull()now keeps callingqueue.pull()until it gets a message withbinaryorclose, then runs the existing enqueue/close code, which is unchanged.toAsyncIteratorObjectalready handles event streams the same way. While a read is outstanding, aqueue.pull()is now always waiting, so data, close and abort reach the reader again.There is no public API change. The wire validator,
Queueand the peer lifecycle are untouched.Tests
octet-stream.test.ts:[1,2,3], thendone, and cleanup reports success.queue.close(err)and onqueue.abort(err).server.test.ts: an octet-stream request, two empty messages, thenpeer.close(). The handler's read rejects withAbortError, the handler finishes andmessage()settles.client.test.ts: an octet-stream response and two empty messages. Aborting the request signal, or callingpeer.close(), rejects the pending read.All 6 new tests time out without the fix and pass with it.
vitest run packages/peer(279 tests) andpnpm run checkpass.🤖 Generated with Claude Code
https://claude.ai/code/session_016R6UEWNWXpmbcpXs2d1sTq
Generated by Claude Code