Skip to content

Commit 20e0cd1

Browse files
Fix flaky TestConn_ExecContext: don't cancel a finished operation
The sentinel Watch loop had a race: once StatusFn reported Done, it spawned the OnDoneFn processor and looped back into its select with two potentially-ready cases at once -- the processor result (resCh) and ctx.Done(). Go's select picks randomly among ready cases, so a context cancellation arriving at the same instant as completion could win and trigger OnCancelFn, cancelling an operation that had already finished. TestConn_ExecContext/"ExecContext uses new context to close operation" cancels the context inside GetOperationStatus while returning FINISHED, then asserted cancelOperationCount == 1 -- i.e. it asserted on the losing side of that random select. The Go scheduler version only shifts the probability; the race is in the design, not the language version. Fix: once Done is observed, the operation has logically completed and its outcome is authoritative. Drain the OnDoneFn result via a new waitForDone helper that selects only on resCh/errCh, no longer on ctx.Done()/timeout. A finished operation is now never cancelled, deterministically. Update the connection test to assert the correct deterministic behaviour (success, cancelOperationCount == 0, CloseOperation still runs on a fresh context) and add a sentinel regression test that pre-cancels the context in the same StatusFn call that reports Done, looped 1000x. Verified: TestConn_ExecContext -count=300 green; sentinel suite green under -race -count=5. Co-authored-by: Isaac
1 parent c3be94a commit 20e0cd1

3 files changed

Lines changed: 71 additions & 5 deletions

File tree

connection_test.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1576,11 +1576,20 @@ func TestConn_ExecContext(t *testing.T) {
15761576
ctx, cancel = context.WithCancel(ctx)
15771577
defer cancel()
15781578
res, err := testConn.ExecContext(ctx, "insert 10", []driver.NamedValue{})
1579-
assert.Error(t, err)
1580-
assert.Nil(t, res)
1579+
// GetOperationStatus reports FINISHED in the same call that cancels the
1580+
// context. Because the operation completed, the sentinel must report
1581+
// success and must NOT cancel a finished operation, regardless of
1582+
// scheduler timing — this assertion previously raced on cancelOperationCount.
1583+
assert.NoError(t, err)
1584+
assert.NotNil(t, res)
1585+
rowsAffected, _ := res.RowsAffected()
1586+
assert.Equal(t, int64(10), rowsAffected)
15811587
assert.Equal(t, 1, executeStatementCount)
1582-
assert.Equal(t, 1, cancelOperationCount)
1588+
assert.Equal(t, 0, cancelOperationCount)
15831589
assert.Equal(t, 1, getOperationStatusCount)
1590+
// CloseOperation must still run, on a fresh (non-cancelled) context, even
1591+
// though the context passed to ExecContext was cancelled mid-poll. Its
1592+
// FnCloseOperation asserts ctx.Err() == nil.
15841593
assert.Equal(t, 1, closeOperationCount)
15851594
})
15861595
}

internal/sentinel/sentinel.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,16 @@ func (s Sentinel) Watch(ctx context.Context, interval, timeout time.Duration) (W
114114
if done() {
115115
intervalTimer.Stop()
116116
if s.OnDoneFn != nil {
117+
// The operation has completed, so the OnDoneFn result is the
118+
// authoritative outcome. Run the processor and drain its
119+
// result without selecting on ctx.Done()/timeout: once we've
120+
// observed completion, a concurrent cancellation or timeout
121+
// must not race the success path and trigger an unnecessary
122+
// cancel of a finished operation.
117123
go processor(statusResp)
118-
} else {
119-
return WatchSuccess, statusResp, nil
124+
return s.waitForDone(resCh, errCh)
120125
}
126+
return WatchSuccess, statusResp, nil
121127
}
122128
case err := <-errCh:
123129
return WatchErr, nil, err
@@ -136,3 +142,16 @@ func (s Sentinel) Watch(ctx context.Context, interval, timeout time.Duration) (W
136142
}
137143
}
138144
}
145+
146+
// waitForDone blocks until the asynchronous OnDoneFn processor reports its
147+
// result. The operation has already been observed as complete by the time this
148+
// is called, so the outcome is determined solely by OnDoneFn and is no longer
149+
// subject to cancellation or timeout.
150+
func (s Sentinel) waitForDone(resCh chan any, errCh chan error) (WatchStatus, any, error) {
151+
select {
152+
case err := <-errCh:
153+
return WatchErr, nil, err
154+
case res := <-resCh:
155+
return WatchSuccess, res, nil
156+
}
157+
}

internal/sentinel/sentinel_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,44 @@ func TestWatch(t *testing.T) {
257257
assert.Nil(t, res)
258258
assert.ErrorContains(t, err, "failed")
259259
})
260+
t.Run("it should not cancel when context is canceled concurrently with Done", func(t *testing.T) {
261+
// Regression test for a race where, once StatusFn reports Done, a
262+
// context cancellation that arrives at the same time could win the
263+
// select against the OnDoneFn result and trigger OnCancelFn for an
264+
// operation that has already completed. A finished operation must
265+
// never be canceled, regardless of scheduler timing.
266+
//
267+
// We pre-cancel the context and report Done in the same StatusFn call,
268+
// so both the success path and ctx.Done() are ready when Watch
269+
// re-enters its select. The outcome must deterministically be success
270+
// with no cancellation, on every run.
271+
for i := 0; i < 1000; i++ {
272+
ctx, cancel := context.WithCancel(context.Background())
273+
cancelFnCalls := 0
274+
s := Sentinel{
275+
StatusFn: func() (Done, any, error) {
276+
// Cancel the context right as we report completion.
277+
cancel()
278+
return func() bool {
279+
return true
280+
}, "completed", nil
281+
},
282+
OnCancelFn: func() (any, error) {
283+
cancelFnCalls++
284+
return nil, nil
285+
},
286+
OnDoneFn: func(statusResp any) (any, error) {
287+
return statusResp, nil
288+
},
289+
}
290+
status, res, err := s.Watch(ctx, 0, 0)
291+
assert.Equal(t, WatchSuccess, status)
292+
assert.Equal(t, "completed", res)
293+
assert.Equal(t, 0, cancelFnCalls, "OnCancelFn must not be called for a completed operation (iteration %d)", i)
294+
assert.NoError(t, err)
295+
cancel()
296+
}
297+
})
260298
t.Run("it should return statusFn error", func(t *testing.T) {
261299
statusFnCalls := 0
262300
cancelFnCalls := 0

0 commit comments

Comments
 (0)