Skip to content

Commit 9896675

Browse files
committed
feat(testutil): add lazy timeout context with location-based reset
It's common to create a context early in a test body, then do setup work unrelated to that context. By the time the context is actually used, it may have already timed out. This was detected as test failures in #21091. The new Context() function returns a context that resets its timeout when accessed from new lines in the test file. The timeout does not begin until the context is first used (lazy initialization). This is useful for integration tests that pass contexts through many subsystems, where each subsystem should get a fresh timeout window. Key behaviors: - Timer starts on first Done(), Deadline(), or Err() call - Value() does not trigger initialization (used for tracing/logging) - Each unique line in a _test.go file gets a fresh timeout window - Same-line access (e.g., in loops) does not reset - Expired contexts cannot be resurrected Limitations: - Wrapping with a child context (e.g., context.WithCancel) prevents resets since the child's methods don't call through to the parent - Storing the Done() channel prevents resets on subsequent accesses The original fixed-timeout behavior is available via ContextFixed().
1 parent 770fdb3 commit 9896675

File tree

3 files changed

+422
-1
lines changed

3 files changed

+422
-1
lines changed

testutil/ctx.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,33 @@ import (
66
"time"
77
)
88

9-
func Context(t testing.TB, dur time.Duration) context.Context {
9+
// Context returns a context that resets its timeout when accessed from new
10+
// locations in the test file. The timeout does not begin until the context is
11+
// first used.
12+
//
13+
// This is useful for integration tests that pass contexts through many
14+
// subsystems, where each subsystem should get a fresh timeout window.
15+
//
16+
// Note: Each call to Done(), Deadline(), or Err() from a new line in the test
17+
// file resets the timeout. If you need to prevent resets (e.g., to test actual
18+
// timeout behavior), store the channel:
19+
//
20+
// done := ctx.Done() // Timeout starts, channel stored
21+
// // ... do work ...
22+
// select {
23+
// case <-done: // No reset, using stored channel
24+
// // handle timeout
25+
// }
26+
//
27+
// Wrapping with a child context (e.g., context.WithCancel) will also prevent
28+
// resets since the child's methods don't call through to the parent.
29+
func Context(t testing.TB, timeout time.Duration) context.Context {
30+
return newLazyTimeoutContext(t, timeout)
31+
}
32+
33+
// ContextFixed returns a context with a fixed timeout that starts immediately.
34+
// Use Context() instead for contexts that should reset on new package access.
35+
func ContextFixed(t testing.TB, dur time.Duration) context.Context {
1036
ctx, cancel := context.WithTimeout(context.Background(), dur)
1137
t.Cleanup(cancel)
1238
return ctx

testutil/lazy_ctx.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package testutil
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"runtime"
7+
"strings"
8+
"sync"
9+
"testing"
10+
"time"
11+
)
12+
13+
// lazyTimeoutContext is a context.Context that resets its timeout when accessed
14+
// from new locations in the test file. The timeout does not begin until the
15+
// context is first used.
16+
type lazyTimeoutContext struct {
17+
t testing.TB
18+
timeout time.Duration
19+
20+
mu sync.Mutex
21+
started bool
22+
deadline time.Time
23+
timer *time.Timer
24+
done chan struct{}
25+
err error
26+
seenLocations map[string]struct{}
27+
}
28+
29+
func newLazyTimeoutContext(t testing.TB, timeout time.Duration) context.Context {
30+
ctx := &lazyTimeoutContext{
31+
t: t,
32+
timeout: timeout,
33+
done: make(chan struct{}),
34+
seenLocations: make(map[string]struct{}),
35+
}
36+
t.Cleanup(ctx.cancel)
37+
return ctx
38+
}
39+
40+
// Deadline returns the current deadline, if any. The deadline is set lazily
41+
// on first access and may be extended when accessed from new locations.
42+
func (c *lazyTimeoutContext) Deadline() (deadline time.Time, ok bool) {
43+
c.maybeResetForLocation()
44+
45+
c.mu.Lock()
46+
defer c.mu.Unlock()
47+
if !c.started {
48+
return time.Time{}, false
49+
}
50+
return c.deadline, true
51+
}
52+
53+
// Done returns a channel that's closed when the context is canceled.
54+
func (c *lazyTimeoutContext) Done() <-chan struct{} {
55+
c.maybeResetForLocation()
56+
return c.done
57+
}
58+
59+
// Err returns the error indicating why this context was canceled.
60+
func (c *lazyTimeoutContext) Err() error {
61+
c.maybeResetForLocation()
62+
63+
c.mu.Lock()
64+
defer c.mu.Unlock()
65+
return c.err
66+
}
67+
68+
// Value returns nil; this context carries no values.
69+
// Note: Value() does NOT trigger lazy initialization or timeout reset.
70+
func (*lazyTimeoutContext) Value(any) any {
71+
return nil
72+
}
73+
74+
// maybeResetForLocation starts the timer on first access and resets it when
75+
// accessed from a new location in the test file.
76+
func (c *lazyTimeoutContext) maybeResetForLocation() {
77+
loc := callerLocation()
78+
79+
c.mu.Lock()
80+
defer c.mu.Unlock()
81+
82+
// Don't reset if already canceled.
83+
if c.err != nil {
84+
return
85+
}
86+
87+
// Always start the timer on first access, regardless of location.
88+
if !c.started {
89+
c.startLocked()
90+
if loc != "" {
91+
c.seenLocations[loc] = struct{}{}
92+
}
93+
if testing.Verbose() {
94+
c.t.Logf("lazyTimeoutContext: started timeout for location: %s", loc)
95+
}
96+
return
97+
}
98+
99+
// Only reset for known test file locations.
100+
if loc == "" {
101+
return
102+
}
103+
104+
if _, seen := c.seenLocations[loc]; seen {
105+
return
106+
}
107+
c.seenLocations[loc] = struct{}{}
108+
109+
// Reset deadline.
110+
c.deadline = time.Now().Add(c.timeout)
111+
if c.timer != nil && c.timer.Stop() {
112+
c.timer.Reset(c.timeout)
113+
}
114+
115+
if testing.Verbose() {
116+
c.t.Logf("lazyTimeoutContext: reset timeout for new location: %s", loc)
117+
}
118+
}
119+
120+
// startLocked initializes the timer. Must be called with mu held.
121+
func (c *lazyTimeoutContext) startLocked() {
122+
c.started = true
123+
c.deadline = time.Now().Add(c.timeout)
124+
c.timer = time.AfterFunc(c.timeout, func() {
125+
c.mu.Lock()
126+
defer c.mu.Unlock()
127+
if c.err == nil {
128+
c.err = context.DeadlineExceeded
129+
close(c.done)
130+
}
131+
})
132+
}
133+
134+
// cancel stops the timer and marks the context as canceled.
135+
func (c *lazyTimeoutContext) cancel() {
136+
c.mu.Lock()
137+
defer c.mu.Unlock()
138+
if c.timer != nil {
139+
c.timer.Stop()
140+
}
141+
if c.err == nil {
142+
c.err = context.Canceled
143+
close(c.done)
144+
}
145+
}
146+
147+
// callerLocation walks the stack to find the line in a test file that
148+
// initiated the call. Returns empty string if not called from a test file.
149+
func callerLocation() string {
150+
// Skip: runtime.Callers, callerLocation, maybeResetForLocation,
151+
// Done/Deadline/Err, and we want to find the caller of those.
152+
pc := make([]uintptr, 50)
153+
n := runtime.Callers(4, pc)
154+
if n == 0 {
155+
return ""
156+
}
157+
158+
frames := runtime.CallersFrames(pc[:n])
159+
for {
160+
frame, more := frames.Next()
161+
162+
// Look for frames in _test.go files.
163+
if strings.HasSuffix(frame.File, "_test.go") {
164+
return fmt.Sprintf("%s:%d", frame.File, frame.Line)
165+
}
166+
167+
if !more {
168+
break
169+
}
170+
}
171+
172+
return ""
173+
}

0 commit comments

Comments
 (0)