Add advisory backpressure to Writable#392
Merged
Merged
Conversation
Writable.write() now returns a boolean instead of undefined: false means
the session's send buffer is at or above the new sendBufferHighWaterMark
option (default 1024 messages). The signal is purely advisory - values
are still buffered and delivered, exactly like node's stream.Writable.
A new Writable.waitForWriteReady() promise is the drain signal: it
resolves once acks trim the send buffer back below the high-water mark,
immediately when there is no pressure, and always resolves (never
rejects) when the session or transport closes so producers can't hang.
The canonical producer loop:
while (writable.isWritable()) {
if (!writable.write(next())) await writable.waitForWriteReady();
}
Drain waiters live on the session and are carried across state machine
transitions alongside sendBuffer. Routers reach them via a new
transport.getSessionBackpressure(to, sessionId) accessor which, unlike
getSessionBoundSendFn, never throws after the session scope ends since
writables are torn down separately via session status events.
Note: pressure derives from transport-level acks (receipt), not
app-level consumption on the peer. Receiver-side pressure would need
protocol-level credits; the SessionBackpressure interface leaves room
to swap that in later.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
expect.objectContaining returns any; use the same inline disable as disconnects.test.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
masad-frost
approved these changes
Jul 24, 2026
Review feedback: instead of registering a resolver per waitForSendBufferDrain caller, the session lazily creates one shared promise when the buffer first fills and hands it to every waiter, clearing it on drain. Hoists the existing createPromiseWithResolvers helper out of router/streams into transport/promises so the session state machine can use it too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jackyzha0
force-pushed
the
claude/writable-backpressure-prototype-d95faf
branch
from
July 24, 2026 17:12
1982fe6 to
cdfafff
Compare
Audit findings: a promise already pending when the Writable closes is resolved by the session (drain or close), not by the writable close itself; and a high-water mark of 0 can never drain, so document the minimum of 1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Adds node-style cooperative backpressure to river's
Writable, sender-side only, no wire/protocol changes. Bumps the minor version to 0.220.0.Writable.write()now returnsbooleaninstead ofundefined—falsemeans the session's send buffer is at/above the newsendBufferHighWaterMarktransport option (default 1024 messages). The signal is purely advisory: values are still buffered and delivered, exactly like node'sstream.Writable.write(). No existing caller used the return value, so this is source-compatible.Writable.waitForWriteReady(): Promise<void>is the drain signal (river streams have no EventEmitter, so a promise replaces node's'drain'event). It resolves once acks trim the buffer back below the high-water mark, immediately when there's no pressure, and always resolves (never rejects) when the session/transport closes so producers can't hang — they exit viaisWritable()or the nextwrite()throwing.Canonical producer loop:
Implementation
Set<() => void>on the session, carried across state-machine transitions alongsidesendBufferitself. They're woken at the only two points pressure releases:SessionConnected.updateBookkeeping(acks trimming the buffer) andIdentifiedSession._handleClose.transport.getSessionBackpressure(to, sessionId)accessor returning aSessionBackpressureinterface (isSendBufferFull/waitForSendBufferDrain). UnlikegetSessionBoundSendFnit never throws after the session scope ends, since writables are torn down separately via session status events.WritableImpltakes an optionalbackpressure: SessionBackpressure, defaulting to an inert implementation (keeps the pre-closed stub and unit tests unchanged). All four construction sites (router + protobuf, client + server) pass the session-scoped one.Known limitation
Pressure derives from transport-level acks (receipt), not app-level consumption on the peer — a slow
for awaitconsumer whose transport keeps acking won't create pressure, because the receiver'sReadableImplqueue is still unbounded. Fixing that needs protocol-level credits; theSessionBackpressureinterface is the seam to swap that in later.Testing
WritableImplbackpressure semantics (__tests__/streams.test.ts).stateMachine.test.ts).__tests__/backpressure.test.tsintegration suite across the ws/mock × naive/binary matrix: deterministic over-HWM writes returnfalseand recover after acks; messages written under pressure while disconnected survive a transparent reconnect in order; a parkedwaitForWriteReadyresolves on transport close.🤖 Generated with Claude Code