Skip to content

Commit b4a50fa

Browse files
committed
drpcwire: introduce MuxWriter for stream multiplexing
Replace per-stream drpcwire.Writer with a shared MuxWriter that uses a dedicated drain goroutine. This decouples stream writes from transport I/O, enabling true concurrent stream multiplexing. Core Architecture: MuxWriter (drpcwire/mux_writer.go): - Single instance per Manager, shared across all streams - Dedicated goroutine continuously drains buffered frames to transport - Non-blocking WriteFrame: appends to buffer under lock, signals goroutine - Double-buffer swap (buf/spare) minimizes time spent under lock Stream changes (drpcstream/stream.go): - wr field: *drpcwire.Writer -> *drpcwire.MuxWriter - Removed: ManualFlush option, RawFlush, rawFlushLocked, checkRecvFlush - sendPacketLocked/rawWriteLocked: WriteFrame only, no Flush Manager integration (drpcmanager/manager.go): - Creates MuxWriter with onError: m.terminate - terminate(): wr.Stop() THEN tr.Close() — Stop makes WriteFrame reject immediately; transport close unblocks any in-flight Write in the drain goroutine - Close(): <-wr.Done() to wait for drain goroutine exit Key Design Decisions: 1. No explicit flush needed. The drain goroutine continuously pulls from the buffer. Natural batching occurs because appends accumulate while the goroutine is mid-Write. The old cork pattern (delay flush until first recv) is unnecessary — appending is a memcpy under lock, and the goroutine controls when transport I/O happens. 2. sync.Cond over channels. Signal coalescing: multiple WriteFrame calls while run() is in Write produce a single wakeup. No allocation overhead. Stop uses closed bool + Broadcast. Consistent with packetQueue. 3. Two-phase shutdown (Stop/Done split to avoid deadlock). Stop() is non-blocking: sets closed, Broadcast, returns immediately. Done() returns a channel that closes when run() exits. This split is critical for the onError path: run() -> Write fails -> sets closed -> onError -> terminate -> Stop (finds closed=true, noop) -> run() returns. If Stop blocked until run() exited, this path would self-join. 4. run() owns its lifecycle on write failure. When Write fails, run() sets closed=true itself before calling onError. The subsequent onError -> terminate -> Stop path finds closed already set. No coordination needed; the flag is idempotent. 5. No per-stream FrameWriter wrapper. Initially considered a per-stream FrameWriter wrapping *MuxWriter, but the only value was a closed check before append. That check lives in MuxWriter.WriteFrame directly. Streams hold *MuxWriter and call WriteFrame. What this unlocks: - Concurrent multiplexing: streams no longer serialize on writes - Simplified stream: all flush/cork complexity removed - Natural batching from continuous drain - Direct error propagation: transport write failures fire manager termination via onError callback Breaking changes: - drpcstream.Options.ManualFlush removed - Stream.RawFlush(), SetManualFlush() removed - Stream constructor: *drpcwire.Writer -> *drpcwire.MuxWriter Test coverage: 8 concurrency tests for MuxWriter covering concurrent WriteFrame, write errors, onError->Stop deadlock path, blocked Write unblocked by Close, concurrent Stop, abort semantics (Stop discards buffered data), and write-during-active-drain. A data race in the initial implementation (reading buf capacity without lock) was caught by these tests and fixed.
1 parent b695ff5 commit b4a50fa

14 files changed

Lines changed: 536 additions & 447 deletions

File tree

drpcconn/conn_test.go

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,31 +40,32 @@ func TestConn_InvokeFlushesSendClose(t *testing.T) {
4040
invokeDone := make(chan struct{})
4141

4242
ctx.Run(func(ctx context.Context) {
43-
wr := drpcwire.NewWriter(ps, 64)
43+
wr := drpcwire.NewMuxWriter(ps, nil)
44+
defer wr.StopWait()
4445
rd := drpcwire.NewReader(ps)
4546

4647
_, _ = rd.ReadFrame() // Invoke
4748
_, _ = rd.ReadFrame() // Message
4849
pkt, _ := rd.ReadFrame() // CloseSend
4950

50-
_ = wr.WritePacket(drpcwire.Packet{
51+
_ = wr.WriteFrame(drpcwire.Frame{
5152
Data: []byte("qux"),
5253
ID: drpcwire.ID{Stream: pkt.ID.Stream, Message: 1},
5354
Kind: drpcwire.KindMessage,
55+
Done: true,
5456
})
55-
_ = wr.Flush()
5657

5758
_, _ = rd.ReadFrame() // Close
5859
<-invokeDone // wait for invoke to return
5960

6061
// ensure that any later packets are dropped by writing one
6162
// before closing the transport.
62-
for i := 0; i < 5; i++ {
63-
_ = wr.WritePacket(drpcwire.Packet{
63+
for range 5 {
64+
_ = wr.WriteFrame(drpcwire.Frame{
6465
ID: drpcwire.ID{Stream: pkt.ID.Stream, Message: 2},
6566
Kind: drpcwire.KindCloseSend,
67+
Done: true,
6668
})
67-
_ = wr.Flush()
6869
}
6970

7071
_ = ps.Close()
@@ -78,7 +79,7 @@ func TestConn_InvokeFlushesSendClose(t *testing.T) {
7879

7980
invokeDone <- struct{}{} // signal invoke has returned
8081

81-
// we should eventually notice the transport is closed
82+
// we should eventually notice the transport is closed due to ps.Close()
8283
select {
8384
case <-conn.Closed():
8485
case <-time.After(1 * time.Second):
@@ -95,7 +96,8 @@ func TestConn_InvokeSendsGrpcAndDrpcMetadata(t *testing.T) {
9596
defer func() { assert.NoError(t, ps.Close()) }()
9697

9798
ctx.Run(func(ctx context.Context) {
98-
wr := drpcwire.NewWriter(ps, 64)
99+
wr := drpcwire.NewMuxWriter(ps, nil)
100+
defer wr.StopWait()
99101
rd := drpcwire.NewReader(ps)
100102

101103
md, err := rd.ReadFrame() // Metadata
@@ -114,12 +116,12 @@ func TestConn_InvokeSendsGrpcAndDrpcMetadata(t *testing.T) {
114116
_, _ = rd.ReadFrame() // Message
115117
pkt, _ := rd.ReadFrame() // CloseSend
116118

117-
_ = wr.WritePacket(drpcwire.Packet{
119+
_ = wr.WriteFrame(drpcwire.Frame{
118120
Data: []byte("qux"),
119121
ID: drpcwire.ID{Stream: pkt.ID.Stream, Message: 1},
120122
Kind: drpcwire.KindMessage,
123+
Done: true,
121124
})
122-
_ = wr.Flush()
123125

124126
_, _ = rd.ReadFrame() // Close
125127
})
@@ -181,6 +183,10 @@ func TestConn_NewStreamSendsGrpcAndDrpcMetadata(t *testing.T) {
181183
s, err := conn.NewStream(ctx, "/com.example.Foo/Bar", testEncoding{})
182184
assert.NoError(t, err)
183185
_ = s.CloseSend()
186+
187+
// Wait for the server goroutine to read all frames before defers
188+
// close the pipe. With MuxWriter, writes are asynchronous.
189+
ctx.Wait()
184190
}
185191

186192
func TestConn_encodeMetadata(t *testing.T) {

drpcmanager/active_streams_test.go

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package drpcmanager
66
import (
77
"context"
88
"errors"
9+
"io"
910
"testing"
1011

1112
"github.com/zeebo/assert"
@@ -14,13 +15,19 @@ import (
1415
"storj.io/drpc/drpcwire"
1516
)
1617

17-
func testStream(id uint64) *drpcstream.Stream {
18-
return drpcstream.New(context.Background(), id, &drpcwire.Writer{})
18+
func testMuxWriter(t *testing.T) *drpcwire.MuxWriter {
19+
mw := drpcwire.NewMuxWriter(io.Discard, func(error) {})
20+
t.Cleanup(func() { mw.Stop(); <-mw.Done() })
21+
return mw
22+
}
23+
24+
func testStream(t *testing.T, id uint64) *drpcstream.Stream {
25+
return drpcstream.New(context.Background(), id, testMuxWriter(t))
1926
}
2027

2128
func TestActiveStreams_AddAndGet(t *testing.T) {
2229
streams := newActiveStreams()
23-
s := testStream(1)
30+
s := testStream(t, 1)
2431

2532
assert.NoError(t, streams.Add(1, s))
2633

@@ -39,7 +46,7 @@ func TestActiveStreams_GetMissing(t *testing.T) {
3946

4047
func TestActiveStreams_Remove(t *testing.T) {
4148
streams := newActiveStreams()
42-
s := testStream(1)
49+
s := testStream(t, 1)
4350

4451
assert.NoError(t, streams.Add(1, s))
4552
assert.Equal(t, streams.Len(), 1)
@@ -60,8 +67,8 @@ func TestActiveStreams_RemoveIdempotent(t *testing.T) {
6067

6168
func TestActiveStreams_DuplicateAdd(t *testing.T) {
6269
streams := newActiveStreams()
63-
s1 := testStream(1)
64-
s2 := testStream(1)
70+
s1 := testStream(t, 1)
71+
s2 := testStream(t, 1)
6572

6673
assert.NoError(t, streams.Add(1, s1))
6774
assert.Error(t, streams.Add(1, s2))
@@ -76,13 +83,13 @@ func TestActiveStreams_AddAfterClose(t *testing.T) {
7683
streams := newActiveStreams()
7784
streams.Close(errors.New("closed"))
7885

79-
err := streams.Add(1, testStream(1))
86+
err := streams.Add(1, testStream(t, 1))
8087
assert.Error(t, err)
8188
}
8289

8390
func TestActiveStreams_RemoveAfterClose(t *testing.T) {
8491
streams := newActiveStreams()
85-
s := testStream(1)
92+
s := testStream(t, 1)
8693
assert.NoError(t, streams.Add(1, s))
8794

8895
streams.Close(errors.New("closed"))
@@ -95,13 +102,12 @@ func TestActiveStreams_Len(t *testing.T) {
95102
streams := newActiveStreams()
96103
assert.Equal(t, streams.Len(), 0)
97104

98-
assert.NoError(t, streams.Add(1, testStream(1)))
105+
assert.NoError(t, streams.Add(1, testStream(t, 1)))
99106
assert.Equal(t, streams.Len(), 1)
100107

101-
assert.NoError(t, streams.Add(2, testStream(2)))
108+
assert.NoError(t, streams.Add(2, testStream(t, 2)))
102109
assert.Equal(t, streams.Len(), 2)
103110

104111
streams.Remove(1)
105112
assert.Equal(t, streams.Len(), 1)
106113
}
107-

drpcmanager/manager.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,6 @@ var managerClosed = errs.Class("manager closed")
3030

3131
// Options controls configuration settings for a manager.
3232
type Options struct {
33-
// WriterBufferSize controls the size of the buffer that we will fill before
34-
// flushing. Normal writes to streams typically issue a flush explicitly.
35-
WriterBufferSize int
36-
3733
// Reader are passed to any readers the manager creates.
3834
Reader drpcwire.ReaderOptions
3935

@@ -55,7 +51,7 @@ type Options struct {
5551
// to the appropriate stream.
5652
type Manager struct {
5753
tr drpc.Transport
58-
wr *drpcwire.Writer
54+
wr *drpcwire.MuxWriter
5955
rd *drpcwire.Reader
6056
opts Options
6157

@@ -114,14 +110,15 @@ func New(tr drpc.Transport, kind ManagerKind) *Manager {
114110
func NewWithOptions(tr drpc.Transport, kind ManagerKind, opts Options) *Manager {
115111
m := &Manager{
116112
tr: tr,
117-
wr: drpcwire.NewWriter(tr, opts.WriterBufferSize),
118113
rd: drpcwire.NewReaderWithOptions(tr, opts.Reader),
119114
opts: opts,
120115

121116
invokes: make(chan invokeInfo),
122117
kind: kind,
123118
}
124119

120+
m.wr = drpcwire.NewMuxWriter(tr, m.terminate)
121+
125122
// a buffer of size 1 allows NewServerStream to signal it is done creating a
126123
// new server stream without having to coordinate with manageReader.
127124
m.pdone.Make(1)
@@ -148,10 +145,14 @@ func (m *Manager) log(what string, cb func() string) {
148145
}
149146

150147
// terminate puts the Manager into a terminal state and closes any resources
151-
// that need to be closed to signal the state change.
148+
// that need to be closed to signal the state change. The mux writer is stopped
149+
// before closing the transport so that WriteFrame immediately rejects new
150+
// writes; the subsequent transport close unblocks any in-flight Write in the
151+
// drain goroutine.
152152
func (m *Manager) terminate(err error) {
153153
if m.sigs.term.Set(err) {
154154
m.log("TERM", func() string { return fmt.Sprint(err) })
155+
m.wr.Stop()
155156
m.sigs.tport.Set(m.tr.Close())
156157
if errors.Is(err, io.EOF) {
157158
err = context.Canceled
@@ -317,8 +318,9 @@ func (m *Manager) Unblocked() <-chan struct{} {
317318
func (m *Manager) Close() error {
318319
m.terminate(managerClosed.New("Close called"))
319320

320-
m.wg.Wait() // wait for all stream goroutines
321-
m.sigs.read.Wait()
321+
<-m.wr.Done() // wait for writer goroutine to exit
322+
m.wg.Wait() // wait for all stream goroutines
323+
m.sigs.read.Wait() // wait for reader goroutine to exit
322324
m.sigs.tport.Wait()
323325

324326
return m.sigs.tport.Err()

drpcmanager/manager_test.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ func TestDrpcMetadata(t *testing.T) {
4848
assert.NoError(t, stream.RawWrite(drpcwire.KindInvokeMetadata, buf))
4949
assert.NoError(t, stream.RawWrite(drpcwire.KindInvoke, []byte("invoke")))
5050
assert.NoError(t, stream.RawWrite(drpcwire.KindMessage, []byte("message")))
51-
assert.NoError(t, stream.RawFlush())
5251
assert.NoError(t, stream.Close())
5352
})
5453

@@ -105,7 +104,6 @@ func TestDrpcMetadataWithGRPCMetadataCompatMode(t *testing.T) {
105104
assert.NoError(t, stream.RawWrite(drpcwire.KindInvokeMetadata, buf))
106105
assert.NoError(t, stream.RawWrite(drpcwire.KindInvoke, []byte("invoke")))
107106
assert.NoError(t, stream.RawWrite(drpcwire.KindMessage, []byte("message")))
108-
assert.NoError(t, stream.RawFlush())
109107
assert.NoError(t, stream.Close())
110108
})
111109

drpcmanager/random_test.go

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,24 +45,26 @@ func (rc *randClient) newSteam(ctx context.Context, man *Manager) (*drpcstream.S
4545
return stream, err
4646
}
4747

48-
func (rc *randClient) execute(t *testing.T, wr *drpcwire.Writer, op byte) {
48+
func (rc *randClient) execute(t *testing.T, wr *drpcwire.MuxWriter, op byte) {
4949
cmd, arg, done := parseOp(op)
5050

5151
if !rc.active {
52-
assert.NoError(t, wr.WritePacket(drpcwire.Packet{
52+
assert.NoError(t, wr.WriteFrame(drpcwire.Frame{
5353
Data: make([]byte, arg),
5454
ID: rc.id.incMessage(),
5555
Kind: drpcwire.KindInvoke,
56+
Done: true,
5657
}))
5758
rc.active = true
5859
}
5960

6061
switch cmd {
6162
case 0: // new invoke
6263
if rc.active {
63-
assert.NoError(t, wr.WritePacket(drpcwire.Packet{
64+
assert.NoError(t, wr.WriteFrame(drpcwire.Frame{
6465
ID: rc.id.incMessage(),
6566
Kind: drpcwire.KindClose,
67+
Done: true,
6668
}))
6769
}
6870

@@ -99,10 +101,11 @@ func (rc *randClient) execute(t *testing.T, wr *drpcwire.Writer, op byte) {
99101
}))
100102

101103
case 2: // cause the remote side to close
102-
assert.NoError(t, wr.WritePacket(drpcwire.Packet{
104+
assert.NoError(t, wr.WriteFrame(drpcwire.Frame{
103105
Data: []byte("remote-close"),
104106
ID: rc.id.incMessage(),
105107
Kind: drpcwire.KindMessage,
108+
Done: true,
106109
}))
107110

108111
case 3, 4, 5, 6, 7: // send normal message
@@ -130,7 +133,7 @@ func (rs *randServer) newSteam(ctx context.Context, man *Manager) (*drpcstream.S
130133
return man.NewClientStream(ctx, "rpc")
131134
}
132135

133-
func (rs *randServer) execute(t *testing.T, wr *drpcwire.Writer, op byte) {
136+
func (rs *randServer) execute(t *testing.T, wr *drpcwire.MuxWriter, op byte) {
134137
cmd, arg, done := parseOp(op)
135138

136139
switch cmd {
@@ -160,10 +163,11 @@ func (rs *randServer) execute(t *testing.T, wr *drpcwire.Writer, op byte) {
160163
}))
161164

162165
case 2: // cause the remote side to close
163-
assert.NoError(t, wr.WritePacket(drpcwire.Packet{
166+
assert.NoError(t, wr.WriteFrame(drpcwire.Frame{
164167
Data: []byte("remote-close"),
165168
ID: rs.id.incMessage(),
166169
Kind: drpcwire.KindMessage,
170+
Done: true,
167171
}))
168172

169173
case 3, 4, 5, 6, 7: // send random message
@@ -185,7 +189,7 @@ func (rs *randServer) execute(t *testing.T, wr *drpcwire.Writer, op byte) {
185189

186190
type runner interface {
187191
newSteam(ctx context.Context, man *Manager) (*drpcstream.Stream, error)
188-
execute(t *testing.T, wr *drpcwire.Writer, op byte)
192+
execute(t *testing.T, wr *drpcwire.MuxWriter, op byte)
189193
}
190194

191195
func runRandomized(t *testing.T, prog []byte, r runner) {
@@ -196,7 +200,9 @@ func runRandomized(t *testing.T, prog []byte, r runner) {
196200
defer func() { _ = pc.Close() }()
197201
defer func() { _ = ps.Close() }()
198202

199-
wr := drpcwire.NewWriter(pc, 0)
203+
wr := drpcwire.NewMuxWriter(pc, func(error) {})
204+
defer func() { wr.Stop(); <-wr.Done() }()
205+
200206
man := New(ps, Server)
201207
defer func() { _ = man.Close() }()
202208

@@ -223,7 +229,6 @@ func runRandomized(t *testing.T, prog []byte, r runner) {
223229

224230
for _, op := range prog {
225231
r.execute(t, wr, op)
226-
assert.NoError(t, wr.Flush())
227232
}
228233

229234
assert.NoError(t, man.Close())

0 commit comments

Comments
 (0)