Skip to content

Simulate write-side flow control on request - #27

Merged
dhruvl merged 3 commits into
mainfrom
flow-control
Aug 4, 2026
Merged

Simulate write-side flow control on request#27
dhruvl merged 3 commits into
mainfrom
flow-control

Conversation

@dhruvl

@dhruvl dhruvl commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Write-side flow control was a stated limitation — drain() never blocked, write buffers were unbounded, the peer could not pause your writes. It is now simulated, on request: loop.net.set_flow_control() arms it, and a stream transport's write buffer then holds every byte the peer's protocol has not received yet — on the wire, held by a partition, or parked behind the peer's pause_reading(). Crossing the high-water mark calls pause_writing() synchronously from write(), falling back to the low mark calls resume_writing() at the moment the peer's protocol takes the bytes, and StreamWriter.drain() genuinely waits. A backpressure deadlock is now something a seed can find: the suite plants one and explore() names the failing seed.

Decisions worth reviewing:

  • No credit packets. The sim is one process, so the receiving end credits the sender directly at delivery time — no new packets, no new scheduled callbacks, no draws. The rejected alternative (a receive window with acks on the wire) would have moved the trace hash of every stream workload ever recorded.
  • Off by default, stdlib numbers once on. anyio calls set_write_buffer_limits(0) on every stream and websockets sets limits on every connection, so arming on that call would change those workloads on upgrade — anyio streams would become rendezvous channels. The network switch keeps the promise literal: a run that never calls set_flow_control is byte-identical to before, pinned by digests for three seeds of the reference network workload recorded before the source changed. A transport's own set_write_buffer_limits records numbers without enforcing them until the switch is on; the docs say so loudly. (anyio and websockets rows in docs/compatibility.md are unchanged only because of this default.)
  • Honest divergence, documented: the buffer drains when the peer's application-side protocol receives the bytes, with no read-ahead and no kernel ack, so simulated backpressure is strictly tighter than TCP's. A crashed peer sends no reset, so its writer stays paused until its own timeout — deliberately.

Fault edges covered by tests: partition applies backpressure and heal lifts it, peer reset fails a waiting drain, a crashed peer leaves the writer to its own timeout, a crashing writer resolves its own drain, close-while-draining wakes the waiter on the stdlib branch it names.

Rebased over the loopback-keying fix; the credit lookup addresses the peer end by (conn, host, port), so flow control routes correctly across a self-connection too.

516 tests, the slow replay-stability suite, and strict mypy green; per-step benchmark unmoved (4.43–4.49 µs against the published ~4.4 µs).

dhruvl added 3 commits August 4, 2026 14:16
A stream transport now charges every byte it writes and is credited when
the receiving end hands those bytes to its protocol, so what is on the
wire, held by a partition or parked behind a paused reader all weigh on
the writer. Crossing the high mark pauses the protocol and falling back
to the low one resumes it, both synchronously, the way the standard
library's own transports do it.

None of it applies until net.set_flow_control() arms it. Libraries set
write-buffer limits uninvited, so arming on their call would change runs
nobody touched; unarmed, nothing is charged and the reported buffer size
stays zero.
The reference network workload never asks for flow control, so its two
digests for seeds 0, 1 and 2 are pinned from before the feature existed:
a user's recorded seed has to keep replaying.

The rest covers the armed side — watermark crossings, a drain that waits
for the peer's read, teardown and fault edges, and both deadlock shapes.
The searched one answers after a short read window rather than a fixed
byte count, so how much of the body the server took before its own
response filled its buffer follows the latency draws, and seed 2 is where
that leaves both ends waiting on each other.
Flow control moves off the cut list and into the network table, with the
model stated plainly: what the buffer holds, that pause and resume are
synchronous and add no scheduling event of their own, and that the switch
is what keeps library-set limits inert.

The divergence is stated rather than glossed. The buffer drains when the
peer's application receives the bytes, with no read-ahead, so simulated
backpressure is tighter than the real thing — which is the point, and is
why the standard library's numbers ship inside an opt-in mode.
@dhruvl
dhruvl merged commit fa26d51 into main Aug 4, 2026
9 checks passed
@dhruvl
dhruvl deleted the flow-control branch August 4, 2026 08:57
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