Skip to content

Commit 5568509

Browse files
authored
Fix/synctest compatibility (#42)
* Add testing/synctest compatibility for go-deadlock Support testing/synctest: - DisableTimerPool option to make sure times stay within the bubble - channel-based mutex/rwmutex for * fmt * Fix synctest compatibility - ChannelMutex/ChannelRWMutex for synctest compatibility - Fixes timer pool handling: uses non-blocking drain to avoid deadlocks in synctest bubbles - Support Go 1.25 native synctest tests Fixes: #42 Manually tested with: dunglas/mercure#1099 * Add GODEBUG=asynctimerchan=0 to CI for Go 1.25 synctest compatibility * Add build tag system with synctest compatibility and disable mode + sync_disable.go for zero-overhead mode (-tags=deadlock_disable) + synctest compatibility mode (-tags=goexperiment.synctest) + buildtag_test.go and scripts/test-buildtags.sh
1 parent b089b4f commit 5568509

21 files changed

Lines changed: 1056 additions & 14 deletions

.github/workflows/go.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ jobs:
1717
- "1.20"
1818
- "1.22"
1919
- "1.23"
20+
- "1.24"
2021
- "1.25"
2122
steps:
2223
- uses: actions/checkout@v4
@@ -31,3 +32,20 @@ jobs:
3132

3233
- name: Test
3334
run: go test -v -bench=. -coverprofile=coverage.txt ./...
35+
env:
36+
GODEBUG: asynctimerchan=0
37+
38+
- name: Test with deadlock_disable tag
39+
run: go test -v -tags=deadlock_disable ./...
40+
41+
- name: Test with goexperiment.synctest tag (Go 1.24+)
42+
if: matrix.go == '1.24' || matrix.go == '1.25'
43+
run: go test -v -tags=goexperiment.synctest ./...
44+
env:
45+
GODEBUG: asynctimerchan=0
46+
47+
- name: Test with deadlock_synctest tag (Go 1.25+)
48+
if: matrix.go == '1.25'
49+
run: go test -v -tags=deadlock_synctest ./...
50+
env:
51+
GODEBUG: asynctimerchan=0

Readme.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,16 +172,45 @@ func main() {
172172
rlockTwice()
173173
}
174174
```
175+
## Build Tags and Compatibility Modes
176+
177+
go-deadlock supports multiple build configurations for different use cases:
178+
179+
* **Normal mode** (default): Full deadlock detection with timer pooling
180+
* **Synctest mode**: Compatible with Go's `testing/synctest` package - use either:
181+
* `-tags=deadlock_synctest` (recommended for Go 1.25+)
182+
* `-tags=goexperiment.synctest` (for experimental synctest)
183+
* **Disabled mode** (`-tags=deadlock_disable`): Zero overhead, no detection
184+
185+
**Why synctest mode?** `sync.Mutex` is not durably blocking in synctest bubbles, but channels are. Synctest mode uses channel-based mutexes to ensure proper behavior with `testing/synctest`.
186+
187+
### Quick Examples
188+
189+
```bash
190+
# Normal development/testing
191+
go test ./...
192+
193+
# Testing with synctest (Go 1.25+, recommended)
194+
GODEBUG=asynctimerchan=0 go test -tags=deadlock_synctest ./...
195+
196+
# Testing with experimental synctest
197+
GODEBUG=asynctimerchan=0 go test -tags=goexperiment.synctest ./...
198+
199+
# Production build with zero overhead
200+
go build -tags=deadlock_disable ./...
201+
```
202+
175203
## Configuring go-deadlock
176204

177205
Have a look at [Opts](https://pkg.go.dev/github.com/sasha-s/go-deadlock#pkg-variables).
178206

179-
* `Opts.Disable`: disables deadlock detection altogether
207+
* `Opts.Disable`: disables deadlock detection altogether (runtime option; see also `deadlock_disable` build tag)
180208
* `Opts.DisableLockOrderDetection`: disables lock order based deadlock detection.
181209
* `Opts.DeadlockTimeout`: blocking on mutex for longer than DeadlockTimeout is considered a deadlock. ignored if negative
182210
* `Opts.OnPotentialDeadlock`: callback for then deadlock is detected
183211
* `Opts.MaxMapSize`: size of happens before // happens after table
184212
* `Opts.PrintAllCurrentGoroutines`: dump stacktraces of all goroutines when inconsistent locking is detected, verbose
185213
* `Opts.LogBuf`: where to write deadlock info/stacktraces
214+
* `Opts.TimerPool`: controls timer pooling behavior (auto-configured based on build tags)
215+
186216

187-

buildtag_test.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
//go:build go1.18
2+
3+
package deadlock
4+
5+
import (
6+
"sync"
7+
"testing"
8+
"time"
9+
)
10+
11+
// TestBuildTagMutexWorks tests that mutexes work correctly regardless of build tags
12+
func TestBuildTagMutexWorks(t *testing.T) {
13+
var mu Mutex
14+
var counter int
15+
16+
// Simple lock/unlock
17+
mu.Lock()
18+
counter++
19+
mu.Unlock()
20+
21+
if counter != 1 {
22+
t.Errorf("Expected counter to be 1, got %d", counter)
23+
}
24+
25+
// Concurrent access
26+
done := make(chan bool)
27+
for i := 0; i < 10; i++ {
28+
go func() {
29+
mu.Lock()
30+
counter++
31+
mu.Unlock()
32+
done <- true
33+
}()
34+
}
35+
36+
for i := 0; i < 10; i++ {
37+
<-done
38+
}
39+
40+
if counter != 11 {
41+
t.Errorf("Expected counter to be 11, got %d", counter)
42+
}
43+
}
44+
45+
// TestBuildTagRWMutexWorks tests that RWMutexes work correctly regardless of build tags
46+
func TestBuildTagRWMutexWorks(t *testing.T) {
47+
var mu RWMutex
48+
var counter int
49+
50+
// Write lock
51+
mu.Lock()
52+
counter++
53+
mu.Unlock()
54+
55+
// Read lock
56+
mu.RLock()
57+
_ = counter
58+
mu.RUnlock()
59+
60+
// Multiple readers
61+
done := make(chan bool)
62+
for i := 0; i < 5; i++ {
63+
go func() {
64+
mu.RLock()
65+
_ = counter
66+
time.Sleep(1 * time.Millisecond)
67+
mu.RUnlock()
68+
done <- true
69+
}()
70+
}
71+
72+
for i := 0; i < 5; i++ {
73+
<-done
74+
}
75+
76+
// Writer after readers
77+
mu.Lock()
78+
counter++
79+
mu.Unlock()
80+
81+
if counter != 2 {
82+
t.Errorf("Expected counter to be 2, got %d", counter)
83+
}
84+
}
85+
86+
// TestBuildTagTryLock tests TryLock functionality
87+
func TestBuildTagTryLock(t *testing.T) {
88+
var mu Mutex
89+
90+
// TryLock should succeed when unlocked
91+
if !mu.TryLock() {
92+
// For Go < 1.18, TryLock panics, so we skip this test
93+
if testing.Short() {
94+
t.Skip("TryLock not available in this Go version")
95+
}
96+
t.Error("TryLock should succeed on unlocked mutex")
97+
}
98+
99+
// TryLock should fail when locked
100+
result := make(chan bool, 1)
101+
go func() {
102+
result <- mu.TryLock()
103+
}()
104+
105+
select {
106+
case r := <-result:
107+
if r {
108+
t.Error("TryLock should fail on locked mutex")
109+
}
110+
case <-time.After(100 * time.Millisecond):
111+
// This is okay - TryLock might block briefly
112+
}
113+
114+
mu.Unlock()
115+
}
116+
117+
// TestBuildTagRWMutexTryLock tests RWMutex TryLock/TryRLock
118+
func TestBuildTagRWMutexTryLock(t *testing.T) {
119+
var mu RWMutex
120+
121+
// TryRLock should succeed when unlocked
122+
if !mu.TryRLock() {
123+
if testing.Short() {
124+
t.Skip("TryRLock not available in this Go version")
125+
}
126+
t.Error("TryRLock should succeed on unlocked RWMutex")
127+
}
128+
129+
// Another TryRLock from a different goroutine should also succeed (multiple readers allowed)
130+
done := make(chan bool)
131+
go func() {
132+
if !mu.TryRLock() {
133+
t.Error("TryRLock should succeed when already read-locked by another goroutine")
134+
}
135+
mu.RUnlock()
136+
done <- true
137+
}()
138+
<-done
139+
140+
mu.RUnlock()
141+
142+
// TryLock should succeed when unlocked
143+
if !mu.TryLock() {
144+
t.Error("TryLock should succeed on unlocked RWMutex")
145+
}
146+
147+
mu.Unlock()
148+
}
149+
150+
// TestCompatibilityWithStdlib tests that our types are compatible with stdlib expectations
151+
func TestCompatibilityWithStdlib(t *testing.T) {
152+
var _ sync.Locker = &Mutex{}
153+
154+
var rwmu RWMutex
155+
var _ sync.Locker = &rwmu
156+
var _ sync.Locker = rwmu.RLocker()
157+
}

deadlock.go

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@ import (
1212
"github.com/petermattis/goid"
1313
)
1414

15+
// TimerPoolMode controls timer pooling behavior
16+
type TimerPoolMode int
17+
18+
const (
19+
// TimerPoolDefault automatically chooses based on build environment
20+
TimerPoolDefault TimerPoolMode = iota
21+
// TimerPoolEnabled always uses timer pooling for performance
22+
TimerPoolEnabled
23+
// TimerPoolDisabled disables timer pooling (required for testing/synctest)
24+
TimerPoolDisabled
25+
)
26+
1527
// Opts control how deadlock detection behaves.
1628
// Options are supposed to be set once at a startup (say, when parsing flags).
1729
var Opts = struct {
@@ -31,7 +43,12 @@ var Opts = struct {
3143
MaxMapSize int
3244
// Will dump stacktraces of all goroutines when inconsistent locking is detected.
3345
PrintAllCurrentGoroutines bool
34-
mu *sync.Mutex // Protects the LogBuf.
46+
// Controls timer pooling behavior.
47+
// TimerPoolDefault: Automatically choose based on build environment
48+
// TimerPoolEnabled: Always use timer pooling
49+
// TimerPoolDisabled: Never use timer pooling
50+
TimerPool TimerPoolMode
51+
mu *sync.Mutex // Protects the LogBuf.
3552
// Will print deadlock info to log buffer.
3653
LogBuf io.Writer
3754
}{
@@ -75,7 +92,7 @@ var NewCond = sync.NewCond
7592
// A Mutex is a drop-in replacement for sync.Mutex.
7693
// Performs deadlock detection unless disabled in Opts.
7794
type Mutex struct {
78-
mu sync.Mutex
95+
mu StandardMutex
7996
}
8097

8198
// Lock locks the mutex.
@@ -104,7 +121,7 @@ func (m *Mutex) Unlock() {
104121
// An RWMutex is a drop-in replacement for sync.RWMutex.
105122
// Performs deadlock detection unless disabled in Opts.
106123
type RWMutex struct {
107-
mu sync.RWMutex
124+
mu StandardRWMutex
108125
}
109126

110127
// Lock locks rw for writing.
@@ -155,7 +172,7 @@ func (m *RWMutex) RUnlock() {
155172
// RLocker returns a Locker interface that implements
156173
// the Lock and Unlock methods by calling RLock and RUnlock.
157174
func (m *RWMutex) RLocker() sync.Locker {
158-
return (*rlocker)(m)
175+
return m.mu.RLocker()
159176
}
160177

161178
func preLock(stack []uintptr, p interface{}) {
@@ -194,6 +211,10 @@ func lock(lockFn func(), ptr interface{}) {
194211
var timersPool sync.Pool
195212

196213
func acquireTimer(d time.Duration) *time.Timer {
214+
if shouldDisableTimerPool() {
215+
return time.NewTimer(Opts.DeadlockTimeout)
216+
}
217+
197218
t, ok := timersPool.Get().(*time.Timer)
198219
if ok {
199220
_ = t.Reset(d)
@@ -203,9 +224,22 @@ func acquireTimer(d time.Duration) *time.Timer {
203224
}
204225

205226
func releaseTimer(t *time.Timer) {
206-
if !t.Stop() {
207-
<-t.C
227+
stopped := t.Stop()
228+
229+
// Skip timer pooling if disabled
230+
if shouldDisableTimerPool() {
231+
return
232+
}
233+
234+
// Use non-blocking drain to avoid hanging in synctest bubbles.
235+
// Blocking on t.C can deadlock when testing/synctest virtualizes time.
236+
if !stopped {
237+
select {
238+
case <-t.C:
239+
default:
240+
}
208241
}
242+
209243
timersPool.Put(t)
210244
}
211245

@@ -356,11 +390,6 @@ func (l *lockOrder) postUnlock(p interface{}) {
356390
l.mu.Unlock()
357391
}
358392

359-
type rlocker RWMutex
360-
361-
func (r *rlocker) Lock() { (*RWMutex)(r).RLock() }
362-
func (r *rlocker) Unlock() { (*RWMutex)(r).RUnlock() }
363-
364393
// Under lo.mu Locked.
365394
func (l *lockOrder) other(ptr interface{}) {
366395
empty := true

deadlock_map.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
//go:build go1.9
12
// +build go1.9
23

34
package deadlock

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
module github.com/sasha-s/go-deadlock
22

33
require github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe
4+
5+
replace github.com/sasha-s/go-deadlock => /src/go-deadlock

mutex.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package deadlock
2+
3+
import "sync"
4+
5+
// MutexImpl defines the interface for mutex implementations
6+
type MutexImpl interface {
7+
Lock()
8+
Unlock()
9+
TryLock() bool
10+
}
11+
12+
// RWMutexImpl defines the interface for rwmutex implementations
13+
type RWMutexImpl interface {
14+
Lock()
15+
Unlock()
16+
RLock()
17+
RUnlock()
18+
TryLock() bool
19+
TryRLock() bool
20+
RLocker() sync.Locker
21+
}

0 commit comments

Comments
 (0)