Skip to content

Commit 9b6d87b

Browse files
drpcstream: gate data-frame sends on the per-stream send window
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. 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, so a send parked on credit wakes with the termination error -- matching the write path's existing preemption semantics. 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>
1 parent 6ff7baf commit 9b6d87b

2 files changed

Lines changed: 106 additions & 0 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright (C) 2026 Cockroach Labs.
2+
// See LICENSE for copying information.
3+
4+
package drpcstream
5+
6+
import (
7+
"context"
8+
"testing"
9+
"time"
10+
11+
"github.com/zeebo/assert"
12+
"github.com/zeebo/errs"
13+
14+
"storj.io/drpc/drpcwire"
15+
)
16+
17+
// newGateStream builds a stream writing to io.Discard with an explicit
18+
// SplitSize so small payloads are a single frame.
19+
func newGateStream(t *testing.T) *Stream {
20+
mw := testMuxWriter(t)
21+
return NewWithOptions(context.Background(), 1, mw, NewBufferPool(), Options{SplitSize: 64 << 10})
22+
}
23+
24+
// By default no send window is installed, so data writes are ungated
25+
// (unlimited) and behavior is unchanged.
26+
func TestStream_SendWindowDefaultUngated(t *testing.T) {
27+
st := newGateStream(t)
28+
assert.That(t, st.sendw == nil)
29+
assert.NoError(t, st.RawWrite(drpcwire.KindMessage, []byte("hello")))
30+
}
31+
32+
// With a finite send window, a data write blocks until enough credit is
33+
// granted.
34+
func TestStream_SendWindowGatesDataWrite(t *testing.T) {
35+
st := newGateStream(t)
36+
st.sendw = newSendWindow(4) // 4 bytes of credit
37+
38+
done := make(chan error, 1)
39+
go func() { done <- st.RawWrite(drpcwire.KindMessage, []byte("hello")) }() // 5 bytes > 4
40+
41+
select {
42+
case <-done:
43+
t.Fatal("data write returned before sufficient credit")
44+
case <-time.After(blockShort):
45+
}
46+
47+
st.sendw.grant(1) // 4 + 1 = 5 >= 5
48+
49+
select {
50+
case err := <-done:
51+
assert.NoError(t, err)
52+
case <-time.After(time.Second):
53+
t.Fatal("data write did not complete after grant")
54+
}
55+
}
56+
57+
// Control kinds (here, invoke) are not flow-controlled: they proceed even with
58+
// zero send credit.
59+
func TestStream_SendWindowControlKindsBypassGate(t *testing.T) {
60+
st := newGateStream(t)
61+
st.sendw = newSendWindow(0) // no credit at all
62+
63+
assert.NoError(t, st.WriteInvoke("service.Method", nil))
64+
}
65+
66+
// Terminating the stream wakes a send parked on credit.
67+
func TestStream_SendWindowTerminateWakesParkedWrite(t *testing.T) {
68+
st := newGateStream(t)
69+
st.sendw = newSendWindow(0) // send will park immediately
70+
71+
done := make(chan error, 1)
72+
go func() { done <- st.RawWrite(drpcwire.KindMessage, []byte("data")) }()
73+
74+
select {
75+
case <-done:
76+
t.Fatal("data write returned before termination")
77+
case <-time.After(blockShort):
78+
}
79+
80+
st.Cancel(errs.New("boom"))
81+
82+
select {
83+
case err := <-done:
84+
assert.Error(t, err)
85+
case <-time.After(time.Second):
86+
t.Fatal("parked data write did not wake on termination")
87+
}
88+
}

drpcstream/stream.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ type Stream struct {
5555
recvQueue ringBuffer
5656
wbuf []byte
5757

58+
// sendw is the per-stream send-side flow-control window. It is nil when
59+
// flow control is not enabled, in which case data writes are ungated.
60+
sendw *sendWindow
61+
5862
mu sync.Mutex // protects state transitions
5963
sigs struct {
6064
send drpcsignal.Signal // set when done sending messages
@@ -352,6 +356,9 @@ func (s *Stream) terminate(err error) {
352356
s.sigs.recv.Set(err)
353357
s.sigs.term.Set(err)
354358
s.recvQueue.Close(err)
359+
if s.sendw != nil {
360+
s.sendw.close(err)
361+
}
355362
s.checkFinished()
356363
}
357364

@@ -405,6 +412,17 @@ func (s *Stream) rawWriteLocked(kind drpcwire.Kind, data []byte) (err error) {
405412
drpcopts.GetStreamStats(&s.opts.Internal).AddWritten(uint64(len(fr.Data)))
406413
s.log("SEND", fr.String)
407414

415+
// Flow control: a data frame must acquire send credit before it goes on
416+
// the wire. Only KindMessage is flow-controlled; control frames (e.g.
417+
// invoke/metadata) bypass. When no window is installed (the default),
418+
// sends are ungated. acquire blocks until credit arrives and is
419+
// interruptible by stream termination, which closes the window.
420+
if kind == drpcwire.KindMessage && s.sendw != nil {
421+
if err := s.sendw.acquire(s.Context(), int64(len(fr.Data))); err != nil {
422+
return err
423+
}
424+
}
425+
408426
// Pass the send signal, the write side's own stop signal, so a parked
409427
// WriteFrame is woken when sending ends (cancel, error, close) and
410428
// returns that signal's error directly. That is io.EOF in the cancel and

0 commit comments

Comments
 (0)