Skip to content

Commit c9238e2

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 d351821 commit c9238e2

File tree

3 files changed

+404
-1
lines changed

3 files changed

+404
-1
lines changed

testutil/ctx.go

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

9-
func Context(t testing.TB, dur time.Duration) context.Context {
9+
// Context returns a context with a timeout that starts on first use and resets
10+
// when accessed from new lines in test files. Each call to Done, Deadline, or
11+
// Err from a new line resets the deadline.
12+
//
13+
// To prevent resets, store the Done channel or wrap with a child context:
14+
//
15+
// done := ctx.Done()
16+
// <-done // Uses stored channel, no reset.
17+
func Context(t testing.TB, timeout time.Duration) context.Context {
18+
return newLazyTimeoutContext(t, timeout)
19+
}
20+
21+
// ContextFixed returns a context with a timeout that starts immediately and
22+
// does not reset.
23+
func ContextFixed(t testing.TB, dur time.Duration) context.Context {
1024
ctx, cancel := context.WithTimeout(context.Background(), dur)
1125
t.Cleanup(cancel)
1226
return ctx

testutil/lazy_ctx.go

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

0 commit comments

Comments
 (0)