Skip to content

Add advisory backpressure to Writable#392

Merged
jackyzha0 merged 4 commits into
mainfrom
claude/writable-backpressure-prototype-d95faf
Jul 24, 2026
Merged

Add advisory backpressure to Writable#392
jackyzha0 merged 4 commits into
mainfrom
claude/writable-backpressure-prototype-d95faf

Conversation

@jackyzha0

Copy link
Copy Markdown
Member

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 returns boolean instead of undefinedfalse means the session's send buffer is at/above the new sendBufferHighWaterMark transport option (default 1024 messages). The signal is purely advisory: values are still buffered and delivered, exactly like node's stream.Writable.write(). No existing caller used the return value, so this is source-compatible.
  • New 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 via isWritable() or the next write() throwing.

Canonical producer loop:

while (writable.isWritable()) {
  if (!writable.write(next())) await writable.waitForWriteReady();
}

Implementation

  • Drain waiters are a Set<() => void> on the session, carried across state-machine transitions alongside sendBuffer itself. They're woken at the only two points pressure releases: SessionConnected.updateBookkeeping (acks trimming the buffer) and IdentifiedSession._handleClose.
  • Routers reach the signal via a new transport.getSessionBackpressure(to, sessionId) accessor returning a SessionBackpressure interface (isSendBufferFull / waitForSendBufferDrain). Unlike getSessionBoundSendFn it never throws after the session scope ends, since writables are torn down separately via session status events.
  • WritableImpl takes an optional backpressure: 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.
  • Multiple streams on one session share the pressure; drain wakes all waiters.

Known limitation

Pressure derives from transport-level acks (receipt), not app-level consumption on the peer — a slow for await consumer whose transport keeps acking won't create pressure, because the receiver's ReadableImpl queue is still unbounded. Fixing that needs protocol-level credits; the SessionBackpressure interface is the seam to swap that in later.

Testing

  • Unit tests for WritableImpl backpressure semantics (__tests__/streams.test.ts).
  • Session-level tests: fullness/drain reflect buffer size, waiters survive state transitions and resolve on ack-trim and on close (stateMachine.test.ts).
  • New __tests__/backpressure.test.ts integration suite across the ws/mock × naive/binary matrix: deterministic over-HWM writes return false and recover after acks; messages written under pressure while disconnected survive a transparent reconnect in order; a parked waitForWriteReady resolves on transport close.
  • Full suite: 768 passed, 1 skipped (pre-existing). Typecheck, eslint, and prettier clean.

🤖 Generated with Claude Code

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>
@jackyzha0
jackyzha0 requested a review from a team as a code owner July 23, 2026 23:45
@jackyzha0
jackyzha0 requested review from Monkatraz and removed request for a team July 23, 2026 23:45
expect.objectContaining returns any; use the same inline disable as
disconnects.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@masad-frost masad-frost left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

Acks, the gift that keeps on giving.

Comment thread transport/sessionStateMachine/common.ts Outdated
Comment thread router/streams.ts
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
jackyzha0 force-pushed the claude/writable-backpressure-prototype-d95faf branch from 1982fe6 to cdfafff Compare July 24, 2026 17:12
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>
@jackyzha0
jackyzha0 merged commit d1a37a2 into main Jul 24, 2026
7 checks passed
@jackyzha0
jackyzha0 deleted the claude/writable-backpressure-prototype-d95faf branch July 24, 2026 17:35
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