Skip to content

Commit 4dd6a29

Browse files
authored
streaming: prevent reader Close from deadlocking on a fatal read error (#78)
* streaming: prevent reader Close from deadlocking on fatal read error On a fatal read error (e.g. the underlying stream key being destroyed) the reader read loop called Close synchronously. Close waits on the read goroutine via wait.Wait, so calling it from that same goroutine deadlocked and leaked the reader and its Redis connection; any external Close blocked forever too. Trigger the shutdown asynchronously so the read goroutine can return and release the wait group. Close is already idempotent via sync.Once, so this is safe alongside a concurrent external Close. Adds a regression test that drives the real read loop through a simulated fatal error (via a small xreadFn seam) and asserts the reader closes instead of hanging. The test hangs on pre-fix code. * streaming: make reader read seam a package var Move the test-only read hook from a Reader struct field to a package variable so it no longer pollutes Reader. Behavior is unchanged; it defaults to (*Reader).xread and is only overridden in tests.
1 parent cfaf76b commit 4dd6a29

2 files changed

Lines changed: 38 additions & 2 deletions

File tree

streaming/reader.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,19 +224,28 @@ func (r *Reader) start() {
224224
})
225225
}
226226

227+
// xreadFn fetches the next batch of events for a reader. It is a package
228+
// variable (rather than a struct field) so tests can simulate read errors
229+
// without polluting Reader; it defaults to (*Reader).xread.
230+
var xreadFn = (*Reader).xread
231+
227232
// read reads events from the streams and sends them to the reader channel.
228233
func (r *Reader) read() {
229234
ctx := context.Background()
230235
defer r.cleanup()
231236
for {
232-
streamsEvents, err := r.xread(ctx)
237+
streamsEvents, err := xreadFn(r, ctx)
233238
if r.isClosing() {
234239
return
235240
}
236241
if err != nil {
237242
if err := handleReadError(err, r.logger); err != nil {
238243
r.logger.Error(fmt.Errorf("fatal error while reading events: %w, stopping", err))
239-
r.Close()
244+
// Close waits on this goroutine via wait.Wait, so calling it
245+
// synchronously here would deadlock and leak the reader and its
246+
// Redis connection. Trigger the shutdown asynchronously and let
247+
// this goroutine return so cleanup can release the wait group.
248+
pulse.Go(r.logger, r.Close)
240249
return
241250
}
242251
continue

streaming/reader_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package streaming
22

33
import (
4+
"context"
5+
"fmt"
46
"strconv"
57
"strings"
68
"testing"
@@ -124,6 +126,31 @@ func TestCleanupReader(t *testing.T) {
124126
assert.Eventually(t, func() bool { return rdb.Exists(ctx, s.key).Val() == 0 }, max, delay)
125127
}
126128

129+
func TestReaderCloseOnFatalReadError(t *testing.T) {
130+
testName := strings.Replace(t.Name(), "/", "_", -1)
131+
rdb := ptesting.NewRedisClient(t)
132+
defer ptesting.CleanupRedis(t, rdb, true, testName)
133+
ctx := ptesting.NewTestContext(t)
134+
s, err := NewStream(testName, rdb, options.WithStreamLogger(pulse.ClueLogger(ctx)))
135+
require.NoError(t, err)
136+
// Simulate a fatal read error (e.g. the underlying stream key being
137+
// destroyed) before the read goroutine starts. The read loop reacts to a
138+
// fatal error by closing the reader; because Close waits on the read
139+
// goroutine, it must run asynchronously or it would deadlock and leak the
140+
// reader and its Redis connection.
141+
defer func(orig func(*Reader, context.Context) ([]redis.XStream, error)) { xreadFn = orig }(xreadFn)
142+
xreadFn = func(*Reader, context.Context) ([]redis.XStream, error) {
143+
return nil, fmt.Errorf("stream key no longer exists")
144+
}
145+
146+
reader, err := s.NewReader(ctx, options.WithReaderBlockDuration(testBlockDuration))
147+
require.NoError(t, err)
148+
reader.Subscribe()
149+
150+
require.Eventually(t, func() bool { return reader.IsClosed() }, max, delay,
151+
"reader did not close after a fatal read error (Close likely deadlocked on its own read goroutine)")
152+
}
153+
127154
func TestAddReaderStream(t *testing.T) {
128155
testName := strings.Replace(t.Name(), "/", "_", -1)
129156
rdb := ptesting.NewRedisClient(t)

0 commit comments

Comments
 (0)