drpcstream: gate data-frame sends on the per-stream send window#71
drpcstream: gate data-frame sends on the per-stream send window#71suj-krishnan wants to merge 3 commits into
Conversation
50c697f to
a4ad9e8
Compare
58a5fed to
30ffb52
Compare
a4ad9e8 to
6ff7baf
Compare
30ffb52 to
9b6d87b
Compare
6ff7baf to
7fb0331
Compare
9b6d87b to
2512021
Compare
7fb0331 to
9c3bc23
Compare
2512021 to
c086e23
Compare
9c3bc23 to
efc77b4
Compare
c086e23 to
b14cb1f
Compare
efc77b4 to
d88576b
Compare
b14cb1f to
6594c14
Compare
b1f5883 to
4d08d0b
Compare
fe9d197 to
228866a
Compare
228866a to
de7b9dc
Compare
fcfa2e4 to
10b863f
Compare
4d08d0b to
deefd7b
Compare
7d03098 to
336c61c
Compare
deefd7b to
af4331b
Compare
336c61c to
ef342ce
Compare
…or/CloseSend Close, SendError, and CloseSend used to hold s.mu while waiting for the write lock. The writer holding that lock can be blocked for a long time (transport backpressure today; send credit once flow control lands), and holding s.mu while waiting wedges every path that needs it -- Cancel, terminate, and the connection reader delivering inbound terminal frames. A mixed order is also an ABBA deadlock waiting to happen between any two of these paths. All three now release s.mu, wait for the write lock, then re-lock and re-check stream state before proceeding (returning nil if another path terminated meanwhile, matching their existing no-op contracts). The stream's lock order is uniform: s.write before s.mu; never wait on s.write while holding s.mu. The regression test parks a send on a full write buffer, queues CloseSend behind it, and verifies Cancel still unwedges everything -- with the old ordering, Cancel blocks on s.mu held by CloseSend and the parked send is never freed. Co-Authored-By: roachdev-claude <roachdev-claude-bot@cockroachlabs.com>
Wire the sendWindow credit gate into rawWriteLocked: a KindMessage frame now acquires len(frame) bytes of per-stream send credit before it is handed to the writer. Control frames (invoke/metadata) bypass the gate, and write stats are recorded only after credit is acquired, next to the WriteFrame they describe. The window is opt-in: a stream has no send window by default, so data writes stay ungated (unlimited) and behavior is unchanged until one is installed. terminate closes the window with the send-side error (sigs.send is first-wins, holding io.EOF when a cancel/error path pre-set it), so a send parked on credit returns the same error as one parked in WriteFrame or a later send. A send parked on credit is freed only by terminate (grants, ctx cancel, or window close), so Close and SendError close the send window before touching the write lock, mirroring SendCancel's signal-first ordering; otherwise they would stall behind the parked send waiting on a grant a slow consumer may never send. CloseSend deliberately does not preempt: it is a graceful half-close, waiting (without s.mu, per the previous commit) for a parked send to complete once credit arrives. Per-stream only; the connection-level window and the enablement path that installs the window come in later commits. Co-Authored-By: roachdev-claude <roachdev-claude-bot@cockroachlabs.com>
ef342ce to
0d5cf31
Compare
shubhamdhama
left a comment
There was a problem hiding this comment.
These are the comments for the first commit.
| defer s.checkFinished() | ||
|
|
||
| // Wait for the write lock without holding s.mu (see Close). | ||
| // Wait for the write lock without holding s.mu, matching CloseSend's |
There was a problem hiding this comment.
This comment is related to the other PR.
| s.mu.Lock() | ||
| if s.sigs.term.IsSet() { | ||
| s.mu.Unlock() | ||
| return nil | ||
| } | ||
|
|
||
| // Close the send window before taking the write lock so a send parked on | ||
| // credit wakes and releases the lock (same ordering as Close). io.EOF to | ||
| // match the sigs.send error set below, so parked and later sends agree. | ||
| if s.sendw != nil { | ||
| s.sendw.close(io.EOF) | ||
| } | ||
| s.mu.Unlock() |
There was a problem hiding this comment.
This doesn't need to be wrapped in s.mu locks. Both are concurrency safe.
| s.mu.Lock() | ||
| if s.sigs.term.IsSet() { | ||
| s.mu.Unlock() | ||
| return nil | ||
| } | ||
|
|
||
| // Close the send window before taking the write lock so a send parked on | ||
| // credit wakes and releases the lock, instead of stalling the close on a | ||
| // grant that may never come (mirrors SendCancel's signal-first ordering). | ||
| if s.sendw != nil { | ||
| s.sendw.close(termClosed) | ||
| } | ||
| s.mu.Unlock() |
There was a problem hiding this comment.
Same here: This doesn't need to be wrapped in s.mu locks. Both are concurrency safe.
| s.mu.Unlock() | ||
| return nil | ||
| } | ||
| s.mu.Unlock() |
There was a problem hiding this comment.
This one too: This doesn't need to be wrapped in s.mu locks. This is concurrency safe.
| if s.sigs.term.IsSet() { | ||
| s.mu.Unlock() | ||
| return nil | ||
| } |
There was a problem hiding this comment.
Also from a correctness point of view this isn't required, but we can keep it as a "fast-path". So not asking for any change here.
| // disabled) leaves sends ungated. acquire parks until credit arrives, | ||
| // the ctx is canceled, or the window closes (stream termination). | ||
| if kind == drpcwire.KindMessage && s.sendw != nil { | ||
| if err := s.sendw.acquire(s.Context(), int64(len(fr.Data))); err != nil { |
There was a problem hiding this comment.
s.Context() is useless here. close does the trick of aborting. I would recommend just keep the n and remove the context.
| if s.sigs.send.IsSet() || s.sigs.term.IsSet() { | ||
| s.mu.Unlock() | ||
| return nil | ||
| } |
There was a problem hiding this comment.
Close, SendError, and CloseSend now all have nearly identical lock unlocks, maybe we should add in a small helper that could reduce the duplication?
CloseSend's first termination check held s.mu around what is only an atomic signal read (drpcsignal.Signal is internally synchronized). It is a best-effort fast path -- the re-check under s.mu after the write lock is the authoritative guard -- so the lock there does no correctness work. Drop it and read the signals locklessly; CloseSend mutates no state before that authoritative re-check. Close and SendError keep s.mu around their fast-path check because it is atomic with their early sendw.close(): the lock ensures a concurrent terminator already holding s.mu wins and stamps the send window with its own send-side error, rather than a losing Close/SendError overwriting a parked writer's terminal error under the window's first-writer-wins close. The lock is released before the write lock, so it does not reintroduce the ABBA deadlock the write-then-mu ordering avoids. Co-Authored-By: roachdev-claude <roachdev-claude-bot@cockroachlabs.com>
Two commits:
1.
drpcstream: take the write lock without holding s.mu— a standalone locking change:Close/SendError/CloseSendreleases.mu, wait fors.write, then re-lock and re-check state. Holdings.muwhile waiting deadlocks every path that needs it (Cancel, terminate, the connection reader) behind a slow writer, and a mixed order is an ABBA deadlock. Uniform rule:s.writebefores.mu; never wait ons.writeholdings.mu. Regression test uses transport backpressure (no flow control involved).2.
drpcstream: gate data-frame sends on the per-stream send window— wires thesendWindowcredit gate into the stream write path:rawWriteLocked, aKindMessageframe acquireslen(frame)bytes of per-stream send credit before it is handed to the writer.terminatecloses the window with the send-side error (sigs.send.Err()— first-wins, soio.EOFwhen a cancel/error path pre-set it): a send parked on credit returns the same error as one parked inMuxWriter.WriteFrameor a later send.SendError's pre-close usesio.EOFandClose's usestermClosedfor the same parked/unparked agreement.CloseandSendErrorclose the send window before touching the write lock (signal-first, likeSendCancel), so a credit-parked send wakes and releases the lock instead of stalling them on a grant that may never come.Preemption matrix for the four callers that take the write lock:
SendCancelCloseSendErrorCloseSendCommit 3 —
drop the redundant lock on the fast-path termination checkThe first termination check in
Close,SendError, andCloseSendhelds.muaround what is only an atomic signal read.drpcsignal.Signalis internally synchronized (an atomic status word, with an internal mutex only on the slow paths ofSet/Signal), soIsSet()is race-free withouts.mu. That first check is a best-effort fast path; the authoritative guard against a concurrent termination is the re-check unders.muafter the write lock is acquired — which is unchanged. So the lock on the fast path does no correctness work, and the check now reads the signals locklessly.For
CloseandSendError, the earlysendw.close()also moves out of that lock:sendWindowis self-synchronized and idempotent (first-writer-wins), so it needs nos.mueither.No behavior change: the signals are monotonic (set-once), so a race on the fast path at worst does a little extra work before the authoritative re-check returns.
s.muis still held, as before, across every check that gates a state transition — those compound read-then-mutate sequences (e.g.terminateIfBothClosed,terminate,checkFinished) are what the mutex actually protects, not the individual signal reads.