Files
go-redis/hooks_current_race_test.go
sadd amr3eandNedyalko Dyakov 124b5384a9 fix(hooks): data race on hook state during AddHook (#3868)
* fix(hooks): data race on hooksMixin.current during AddHook

hooksMixin.current holds the composed hook chain. AddHook rewrote all
four current fields in place while command execution read them
unsynchronized, so calling AddHook on a live client raced on those
two-word func values. The unlocked slice append in AddHook was racy
across concurrent AddHook callers too.

Fold slice + current into an immutable hooksState published through a
single atomic.Pointer. The read paths (dial/process/pipeline/txPipeline)
become a lock-free Load, dropping the RWMutex from the hot path entirely.
AddHook is copy-on-write under a plain Mutex used only by writers, which
also makes concurrent AddHook safe. Covered by a -race test exercising
concurrent AddHook writers against the command-path readers.

Signed-off-by: Saddam <saddamr3e@gmail.com>

* test(hooks): cover withProcessHook slice readers in race test

Signed-off-by: Saddam <saddamr3e@gmail.com>

---------

Signed-off-by: Saddam <saddamr3e@gmail.com>
Co-authored-by: Nedyalko Dyakov <1547186+ndyakov@users.noreply.github.com>
2026-06-29 14:50:17 +03:00

73 lines
1.9 KiB
Go

package redis
import (
"context"
"sync"
"testing"
)
type noopRaceHook struct{}
func (noopRaceHook) DialHook(next DialHook) DialHook { return next }
func (noopRaceHook) ProcessHook(next ProcessHook) ProcessHook { return next }
func (noopRaceHook) ProcessPipelineHook(next ProcessPipelineHook) ProcessPipelineHook {
return next
}
// AddHook publishes a fresh hooks snapshot via hs.state, while the command-path
// getters (processHook, processPipelineHook, processTxPipelineHook), the slice
// readers (withProcessHook, withProcessPipelineHook) and the AddHook slice
// append all touch the same hook state. Exercise concurrent AddHook writers
// against both reader groups; run with -race to catch a torn read of the
// published snapshot or an unsynchronized slice append.
func TestHooksMixinCurrentConcurrent(t *testing.T) {
var hs hooksMixin
hs.initHooks(hooks{})
ctx := context.Background()
cmd := NewCmd(ctx, "ping")
cmds := []Cmder{cmd}
const writers = 2
var wg sync.WaitGroup
wg.Add(writers + 1)
done := make(chan struct{})
// Writers: concurrent AddHook calls, each replacing hs.state copy-on-write.
for i := 0; i < writers; i++ {
go func() {
defer wg.Done()
for {
select {
case <-done:
return
default:
hs.AddHook(noopRaceHook{})
}
}
}()
}
noopProcess := func(context.Context, Cmder) error { return nil }
noopPipeline := func(context.Context, []Cmder) error { return nil }
// Reader: the command-path getters Load hs.state lock-free; the slice
// readers (withProcessHook, withProcessPipelineHook) iterate the published
// snapshot's hook slice.
go func() {
defer wg.Done()
defer close(done)
for i := 0; i < 3000; i++ {
_ = hs.processHook(ctx, cmd)
_ = hs.processPipelineHook(ctx, cmds)
_ = hs.processTxPipelineHook(ctx, cmds)
_ = hs.withProcessHook(ctx, cmd, noopProcess)
_ = hs.withProcessPipelineHook(ctx, cmds, noopPipeline)
}
}()
wg.Wait()
}