Skip to content

Commit c7e505c

Browse files
authored
fix(configcheck): prevent memory leak by replacing time.After with time.NewTimer (kaasops#207)
* test(configcheck): add test demonstrating time.After memory leak Add test that demonstrates the memory leak caused by using time.After() in a select loop. Each iteration allocates ~280 bytes that are not freed until the timer fires. Benchmark results: - time.After(): 283 B/op, 3 allocs/op - time.NewTimer with Stop/Reset: 0 B/op, 0 allocs/op This test will fail after the fix is applied (allocations will be 0). * fix(configcheck): prevent memory leak by replacing time.After with time.NewTimer time.After() in select loop allocates ~280 bytes per iteration that are not freed until the timer fires. Replace with time.NewTimer + Reset pattern.
1 parent 30ddb92 commit c7e505c

2 files changed

Lines changed: 137 additions & 3 deletions

File tree

internal/config/configcheck/configcheck.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,12 @@ func (cc *ConfigCheck) getCheckResult(ctx context.Context, pod *corev1.Pod) (rea
225225

226226
defer watcher.Stop()
227227

228+
// Use time.NewTimer instead of time.After to prevent memory leak.
229+
// time.After() creates a new timer on each select iteration that won't be GC'd
230+
// until it fires, causing ~280 bytes leak per iteration.
231+
timer := time.NewTimer(cc.ConfigCheckTimeout)
232+
defer timer.Stop()
233+
228234
for {
229235
select {
230236
case e := <-watcher.ResultChan():
@@ -253,11 +259,17 @@ func (cc *ConfigCheck) getCheckResult(ctx context.Context, pod *corev1.Pod) (rea
253259
return reason, ValidationError
254260
}
255261
}
262+
// Reset timer after processing event to restart timeout window
263+
if !timer.Stop() {
264+
select {
265+
case <-timer.C:
266+
default:
267+
}
268+
}
269+
timer.Reset(cc.ConfigCheckTimeout)
256270
case <-ctx.Done():
257-
watcher.Stop()
258271
return "", nil
259-
case <-time.After(cc.ConfigCheckTimeout):
260-
watcher.Stop()
272+
case <-timer.C:
261273
return "", ConfigcheckTimeoutError
262274
}
263275
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/*
2+
Copyright 2024.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package configcheck
18+
19+
import (
20+
"testing"
21+
"time"
22+
)
23+
24+
// TestTimeAfterLeakAllocations demonstrates memory leak when using time.After() in a loop.
25+
// Each call to time.After() allocates ~280 bytes that won't be freed until the timer fires.
26+
//
27+
// This test shows the problem with the current implementation in getCheckResult():
28+
// - time.After() in select loop: 283 B/op, 3 allocs/op
29+
// - time.NewTimer with Stop/Reset: 0 B/op, 0 allocs/op
30+
//
31+
// In a long-running operator with frequent watch events, this causes continuous memory growth.
32+
func TestTimeAfterLeakAllocations(t *testing.T) {
33+
eventChan := make(chan struct{}, 1)
34+
timeout := 10 * time.Second
35+
36+
// Measure allocations for time.After() pattern (problematic)
37+
leakyAllocs := testing.AllocsPerRun(100, func() {
38+
eventChan <- struct{}{}
39+
select {
40+
case <-eventChan:
41+
case <-time.After(timeout):
42+
}
43+
})
44+
45+
// Measure allocations for time.NewTimer pattern (correct)
46+
timer := time.NewTimer(timeout)
47+
defer timer.Stop()
48+
49+
fixedAllocs := testing.AllocsPerRun(100, func() {
50+
eventChan <- struct{}{}
51+
select {
52+
case <-eventChan:
53+
if !timer.Stop() {
54+
select {
55+
case <-timer.C:
56+
default:
57+
}
58+
}
59+
timer.Reset(timeout)
60+
case <-timer.C:
61+
}
62+
})
63+
64+
t.Logf("time.After() allocations per op: %.1f", leakyAllocs)
65+
t.Logf("time.NewTimer allocations per op: %.1f", fixedAllocs)
66+
67+
// time.After() should allocate ~3 objects per call
68+
if leakyAllocs < 2 {
69+
t.Errorf("Expected time.After() to allocate >= 2 objects per call, got %.1f", leakyAllocs)
70+
}
71+
72+
// time.NewTimer with Stop/Reset should allocate 0 objects
73+
if fixedAllocs > 0.5 {
74+
t.Errorf("Expected time.NewTimer pattern to allocate ~0 objects per call, got %.1f", fixedAllocs)
75+
}
76+
77+
// The leak: each iteration wastes ~280 bytes until timer fires
78+
// With 1000 events and 60s timeout, that's 280KB of leaked memory per minute
79+
t.Logf("LEAK IMPACT: %.0f allocations leaked per iteration", leakyAllocs-fixedAllocs)
80+
t.Logf("With 1000 events and 60s ConfigCheckTimeout: ~%.0f KB leaked until timers fire",
81+
leakyAllocs*280*1000/1024)
82+
}
83+
84+
// BenchmarkTimeAfterLeak benchmarks memory allocation with leaky time.After()
85+
func BenchmarkTimeAfterLeak(b *testing.B) {
86+
eventChan := make(chan struct{}, 1)
87+
timeout := 1 * time.Hour // Very long timeout
88+
89+
b.ResetTimer()
90+
for i := 0; i < b.N; i++ {
91+
eventChan <- struct{}{}
92+
select {
93+
case <-eventChan:
94+
case <-time.After(timeout):
95+
}
96+
}
97+
}
98+
99+
// BenchmarkTimeAfterFixed benchmarks memory allocation with fixed timer pattern
100+
func BenchmarkTimeAfterFixed(b *testing.B) {
101+
eventChan := make(chan struct{}, 1)
102+
timeout := 1 * time.Hour
103+
104+
timer := time.NewTimer(timeout)
105+
defer timer.Stop()
106+
107+
b.ResetTimer()
108+
for i := 0; i < b.N; i++ {
109+
eventChan <- struct{}{}
110+
select {
111+
case <-eventChan:
112+
if !timer.Stop() {
113+
select {
114+
case <-timer.C:
115+
default:
116+
}
117+
}
118+
timer.Reset(timeout)
119+
case <-timer.C:
120+
}
121+
}
122+
}

0 commit comments

Comments
 (0)