Skip to content

Commit bfa4cbe

Browse files
drpcmanager: cap concurrent streams with MaxStreams
Add Options.MaxStreams, a per-connection cap on how many concurrent streams a peer may open against this manager. When the cap is reached an inbound stream is refused in the read path -- before any per-stream state is allocated -- with a stream-level KindError, leaving the connection up. 0 means unlimited. This bounds the per-stream bookkeeping (goroutines, stream-table entries, window counters) that the byte-based flow-control windows do not, giving DoS resistance against a peer that opens many zero-byte streams. It is the third flow-control bound alongside the per-stream and connection windows, and is independent of the credit path. Co-Authored-By: roachdev-claude <roachdev-claude-bot@cockroachlabs.com>
1 parent 8c03c23 commit bfa4cbe

2 files changed

Lines changed: 106 additions & 0 deletions

File tree

drpcmanager/manager.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ import (
2828

2929
var managerClosed = errs.Class("manager closed")
3030

31+
// maxStreamsExceeded is returned to a peer whose inbound stream is refused
32+
// because the manager is already at its concurrent-stream cap.
33+
var maxStreamsExceeded = errs.Class("too many concurrent streams")
34+
3135
// Options controls configuration settings for a manager.
3236
type Options struct {
3337
// Reader are passed to any readers the manager creates.
@@ -46,6 +50,13 @@ type Options struct {
4650
// handling. When enabled, the server stream will decode incoming metadata
4751
// into grpc metadata in the context.
4852
GRPCMetadataCompatMode bool
53+
54+
// MaxStreams caps the number of concurrent streams the peer may open against
55+
// this manager. When the cap is reached, further inbound streams are refused
56+
// with a stream-level error rather than admitted, bounding the per-stream
57+
// bookkeeping (goroutines, stream-table entries) that byte windows do not.
58+
// 0 means unlimited.
59+
MaxStreams int
4960
}
5061

5162
// Manager handles the logic of managing a transport for a drpc client or
@@ -246,6 +257,16 @@ func (m *Manager) handleInvokeFrame(fr drpcwire.Frame) error {
246257
return nil
247258
}
248259

260+
// Enforce the concurrent-stream cap before admitting the stream. Refuse it
261+
// here, in the read path, so the per-stream state the cap protects is never
262+
// allocated; the peer learns via a stream-level error and the connection
263+
// stays up.
264+
if m.opts.MaxStreams > 0 && m.streams.Len() >= m.opts.MaxStreams {
265+
m.rejectStream(pkt.ID.Stream)
266+
delete(m.pendingStreams, fr.ID.Stream)
267+
return nil
268+
}
269+
249270
// Invoke packet completes the sequence. Send to NewServerStream.
250271
select {
251272
case m.invokes <- invokeInfo{sid: pkt.ID.Stream, data: pkt.Data, metadata: ps.metadata}:
@@ -260,6 +281,23 @@ func (m *Manager) handleInvokeFrame(fr drpcwire.Frame) error {
260281
return nil
261282
}
262283

284+
// rejectStream refuses an inbound stream that would exceed MaxStreams by
285+
// sending a stream-level KindError to the peer. The control bit is set so the
286+
// frame is appended immediately without blocking the reader goroutine on write
287+
// backpressure (as with an abortive cancel). No stream is created.
288+
func (m *Manager) rejectStream(sid uint64) {
289+
err := maxStreamsExceeded.New("refused: at most %d concurrent streams", m.opts.MaxStreams)
290+
// Message id 1: this is the first (and only) frame the peer receives on the
291+
// refused stream, and its receive assembler expects ids to start at 1.
292+
_ = m.wr.WriteFrame(drpcwire.Frame{
293+
ID: drpcwire.ID{Stream: sid, Message: 1},
294+
Kind: drpcwire.KindError,
295+
Data: drpcwire.MarshalError(err),
296+
Done: true,
297+
Control: true,
298+
}, nil)
299+
}
300+
263301
//
264302
// manage streams
265303
//

drpcmanager/max_streams_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (C) 2026 Cockroach Labs.
2+
// See LICENSE for copying information.
3+
4+
package drpcmanager
5+
6+
import (
7+
"context"
8+
"net"
9+
"strings"
10+
"testing"
11+
12+
"github.com/zeebo/assert"
13+
14+
"storj.io/drpc/drpctest"
15+
"storj.io/drpc/drpcwire"
16+
)
17+
18+
// A server with MaxStreams set refuses an inbound stream beyond the cap with a
19+
// stream-level error, without tearing down the connection or admitting the
20+
// stream.
21+
func TestManager_MaxStreamsRefusesExcessInbound(t *testing.T) {
22+
ctx := drpctest.NewTracker(t)
23+
defer ctx.Close()
24+
25+
cconn, sconn := net.Pipe()
26+
defer func() { _ = cconn.Close() }()
27+
defer func() { _ = sconn.Close() }()
28+
29+
cman := New(cconn, Client)
30+
defer func() { _ = cman.Close() }()
31+
sman := NewWithOptions(sconn, Server, Options{MaxStreams: 1})
32+
defer func() { _ = sman.Close() }()
33+
34+
accepted := make(chan struct{})
35+
release := make(chan struct{})
36+
37+
// Server accepts exactly one stream and holds it open (so it keeps counting
38+
// toward the cap) until released.
39+
ctx.Run(func(ctx context.Context) {
40+
s1, _, err := sman.NewServerStream(ctx)
41+
assert.NoError(t, err)
42+
close(accepted)
43+
<-release
44+
_ = s1.Close()
45+
})
46+
47+
ctx.Run(func(ctx context.Context) {
48+
// First stream is admitted.
49+
c1, err := cman.NewClientStream(ctx, "rpc")
50+
assert.NoError(t, err)
51+
assert.NoError(t, c1.RawWrite(drpcwire.KindInvoke, []byte("rpc")))
52+
<-accepted // ensure the first stream is active before opening the second
53+
54+
// Second stream: the server is at its cap, so it is refused with a
55+
// stream-level error surfaced on the next receive.
56+
c2, err := cman.NewClientStream(ctx, "rpc")
57+
assert.NoError(t, err)
58+
assert.NoError(t, c2.RawWrite(drpcwire.KindInvoke, []byte("rpc")))
59+
_, err = c2.RawRecv()
60+
assert.Error(t, err)
61+
assert.That(t, strings.Contains(err.Error(), "concurrent streams"))
62+
63+
close(release)
64+
_ = c1.Close()
65+
})
66+
67+
ctx.Wait()
68+
}

0 commit comments

Comments
 (0)