Skip to content

Commit 03f30b0

Browse files
committed
fix: unlock l.mu before OnPotentialDeadlock to support panic in callback
Copy stacks into l.order while l.mu is still held, before unlocking for the OnPotentialDeadlock callback. This prevents a data race where concurrent postUnlock calls can recycle the pooled stack buffer (bs.stack) during the unlock window, causing copyStack to race with pool writers. For order violations: print diagnostics, copy stacks, store in l.order, THEN unlock and call OnPotentialDeadlock. For non-violations: copy stacks and store while l.mu is held (no unlock needed). Also fixes the duplicate-lock detection path to unlock l.mu before calling OnPotentialDeadlock, allowing the callback to panic without leaving the mutex locked.
1 parent c1cf6f8 commit 03f30b0

2 files changed

Lines changed: 91 additions & 4 deletions

File tree

deadlock.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -427,8 +427,9 @@ func (l *lockOrder) preLock(stack []uintptr, p interface{}) {
427427
buf.Flush()
428428
}
429429
Opts.mu.Unlock()
430+
l.mu.Unlock()
430431
Opts.OnPotentialDeadlock()
431-
break
432+
return
432433
}
433434
}
434435
continue
@@ -454,11 +455,18 @@ func (l *lockOrder) preLock(stack []uintptr, p interface{}) {
454455
buf.Flush()
455456
}
456457
Opts.mu.Unlock()
458+
// Copy stacks while l.mu is still held, BEFORE unlocking for the
459+
// callback. The pooled buffers backing bs.stack can be recycled by
460+
// concurrent postUnlock calls during the unlock window.
461+
l.order[newBeforeAfter(b, p)] = ss{copyStack(bs.stack), copyStack(stack)}
462+
l.mu.Unlock()
457463
Opts.OnPotentialDeadlock()
464+
l.mu.Lock()
465+
} else {
466+
// No violation — still need to record the ordering.
467+
// l.mu is held, so copyStack is safe here.
468+
l.order[newBeforeAfter(b, p)] = ss{copyStack(bs.stack), copyStack(stack)}
458469
}
459-
// Copy both stacks: they're backed by pooled buffers that will be
460-
// recycled in postUnlock, but l.order entries persist until MaxMapSize.
461-
l.order[newBeforeAfter(b, p)] = ss{copyStack(bs.stack), copyStack(stack)}
462470
if len(l.order) == Opts.MaxMapSize { // Reset the map to keep memory footprint bounded.
463471
l.order = map[beforeAfter]ss{}
464472
}

deadlock_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,3 +393,82 @@ func TestLockDuplicate(t *testing.T) {
393393
t.Fatalf("expected 2 deadlocks, detected %d", deadlocks)
394394
}
395395
}
396+
397+
// TestOrderViolationCallbackPooledStackRace demonstrates a data race introduced
398+
// by PR #55's order-violation path: preLock unlocks lo.mu before calling
399+
// OnPotentialDeadlock and re-acquires it after, but then continues to read
400+
// bs.stack — a slice into a pooled stack buffer — and copyStack() it into
401+
// lo.order. During the unlock window, a concurrent cross-goroutine Unlock can
402+
// release that exact buffer back to stackBufPool, where another goroutine grabs
403+
// it from the pool and writes to it. The subsequent copyStack(bs.stack) then
404+
// races with that write.
405+
//
406+
// Run with `go test -race -run TestOrderViolationCallbackPooledStackRace` to
407+
// observe the data race report.
408+
func TestOrderViolationCallbackPooledStackRace(t *testing.T) {
409+
defer restore()()
410+
Opts.DeadlockTimeout = 0
411+
Opts.OnPotentialDeadlock = func() {} // placeholder, overridden below
412+
413+
var a, b Mutex
414+
415+
// Prime lo.order with the "a then b" ordering.
416+
a.Lock()
417+
b.Lock()
418+
b.Unlock()
419+
a.Unlock()
420+
421+
// Hold b in this goroutine, then snapshot the exact stack buffer that
422+
// preLock will later read through bs.stack. We close over this pointer in
423+
// the helper to write to it concurrently with preLock's copyStack().
424+
b.Lock()
425+
426+
lo.mu.Lock()
427+
if len(lo.cur[&b]) != 1 {
428+
lo.mu.Unlock()
429+
t.Fatalf("expected exactly 1 holder for b, got %d", len(lo.cur[&b]))
430+
}
431+
holderBuf := lo.cur[&b][0].buf
432+
lo.mu.Unlock()
433+
434+
if holderBuf == nil {
435+
t.Fatal("expected holder buf to be non-nil")
436+
}
437+
438+
callbackEntered := make(chan struct{})
439+
helperDone := make(chan struct{})
440+
441+
Opts.OnPotentialDeadlock = func() {
442+
// At this point in the post-PR code, lo.mu has already been released.
443+
// Signal the helper to start writing to the pooled buffer.
444+
close(callbackEntered)
445+
// Give the helper time to start churning so its writes overlap with
446+
// preLock's copyStack(bs.stack) after this callback returns and lo.mu
447+
// is re-acquired.
448+
time.Sleep(50 * time.Millisecond)
449+
}
450+
451+
go func() {
452+
defer close(helperDone)
453+
<-callbackEntered
454+
// Tight write loop directly on the same backing array that preLock's
455+
// bs.stack points into. preLock will re-acquire lo.mu after the
456+
// callback and execute copyStack(bs.stack), which calls
457+
// copy(c, bs.stack) — a read of this exact memory. Since the writes
458+
// here have no happens-before with that read (no shared mutex, no
459+
// channel), the race detector will flag the read.
460+
for i := 0; i < 5000000; i++ {
461+
holderBuf[0] = uintptr(i)
462+
}
463+
}()
464+
465+
// Trigger preLock's order-violation path. Post-PR this calls the callback,
466+
// re-acquires lo.mu, and runs copyStack(bs.stack) — racing the helper.
467+
a.Lock()
468+
a.Unlock()
469+
470+
<-helperDone
471+
472+
// b is still locked (we never unlocked it). Release it for cleanup.
473+
b.Unlock()
474+
}

0 commit comments

Comments
 (0)