diff --git a/request_coalescing.go b/request_coalescing.go index 411a503..e9f1769 100644 --- a/request_coalescing.go +++ b/request_coalescing.go @@ -138,6 +138,15 @@ func (rc *RequestCoalescer) Do(key string, fn func() (*CoalescedResponse, error) rc.inflightCount.Add(1) defer rc.inflightCount.Add(-1) + // Always release waiters and clear the map entry, even if fn() panics. + // Without this, a panicking fn() leaves the key in the inflight map and + // the WaitGroup counter positive, so every coalesced waiter for that key + // blocks on wg.Wait() forever (permanent deadlock + leak). + defer func() { + rc.inflight.Delete(key) + inflight.wg.Done() + }() + // Execute the request response, err := fn() @@ -152,10 +161,6 @@ func (rc *RequestCoalescer) Do(key string, fn func() (*CoalescedResponse, error) } inflight.mu.Unlock() - // Clean up and notify waiters - rc.inflight.Delete(key) - inflight.wg.Done() - // Log statistics waiters := inflight.waiters.Load() duration := time.Since(inflight.createdAt) diff --git a/request_coalescing_test.go b/request_coalescing_test.go index df9728d..4f7f9ad 100644 --- a/request_coalescing_test.go +++ b/request_coalescing_test.go @@ -405,3 +405,40 @@ func TestMin(t *testing.T) { assert.Equal(t, tt.expected, result) } } + +// TestRequestCoalescer_PanicSafety verifies that when the primary fn() panics, +// the coalescer still releases waiters and clears the inflight key instead of +// permanently deadlocking that key (waiters hang on wg.Wait and the entry +// leaks). +func TestRequestCoalescer_PanicSafety(t *testing.T) { + rc := NewRequestCoalescer(true, nil, nil) + + // First call's fn() panics. Recover in a goroutine to emulate the + // caller catching/handling the panic. + panicDone := make(chan struct{}) + go func() { + defer close(panicDone) + defer func() { _ = recover() }() + _, _ = rc.Do("key", func() (*CoalescedResponse, error) { + panic("boom") + }) + }() + <-panicDone + + // A subsequent call for the same key must complete (not hang), proving the + // key was released and the entry dropped. + done := make(chan struct{}) + go func() { + defer close(done) + resp, err := rc.Do("key", func() (*CoalescedResponse, error) { + return &CoalescedResponse{StatusCode: 200}, nil + }) + assert.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("coalescer key still blocked after primary fn() panic") + } +}