Skip to content

Commit 5dc7460

Browse files
authored
fix: make Close() idempotent and add Close tests (#161)
* fix: make Close() idempotent and add Close tests - Add sync.Once to podEventLogger to ensure Close() is safe to call multiple times - Add TestCloseIdempotent to verify double-close doesn't panic - Add TestCloseDuringProcessing to verify close during active processing * fix: cleanup retry timers when work loop exits Stop all active retry timers in logQueuer.cleanup() when the context is canceled. This prevents retry timers from continuing to fire after the podEventLogger is closed, which was causing integration tests to hang waiting for httptest.Server to close. * fix: wait for work goroutine to exit in Close() Address review feedback: Close() now waits for the work goroutine to fully exit before returning. Added doneChan that is closed when the work loop exits, and Close() blocks on receiving from it. Also moved work goroutine startup to newPodEventLogger to ensure it's only started once (not per-namespace). * fix: reorder defers so cleanup runs before closing done channel * fix: cleanup goroutine on initialization error If initNamespace fails, cancel the context and wait for the work goroutine to exit to prevent goroutine leaks.
1 parent 5e742fe commit 5dc7460

2 files changed

Lines changed: 126 additions & 10 deletions

File tree

logger.go

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,16 +86,24 @@ func newPodEventLogger(ctx context.Context, opts podEventLoggerOptions) (*podEve
8686
},
8787
maxRetries: opts.maxRetries,
8888
},
89+
doneChan: make(chan struct{}),
8990
}
9091

92+
// Start the work goroutine once
93+
go reporter.lq.work(reporter.ctx, reporter.doneChan)
94+
9195
// If no namespaces are provided, we listen for events in all namespaces.
9296
if len(opts.namespaces) == 0 {
9397
if err := reporter.initNamespace(""); err != nil {
98+
reporter.cancelFunc()
99+
<-reporter.doneChan
94100
return nil, fmt.Errorf("init namespace: %w", err)
95101
}
96102
} else {
97103
for _, namespace := range opts.namespaces {
98104
if err := reporter.initNamespace(namespace); err != nil {
105+
reporter.cancelFunc()
106+
<-reporter.doneChan
99107
return nil, err
100108
}
101109
}
@@ -119,6 +127,11 @@ type podEventLogger struct {
119127

120128
// hasSyncedFuncs tracks informer cache sync functions for testing
121129
hasSyncedFuncs []cache.InformerSynced
130+
131+
// closeOnce ensures Close() is idempotent
132+
closeOnce sync.Once
133+
// doneChan is closed when the work goroutine exits
134+
doneChan chan struct{}
122135
}
123136

124137
// resolveEnvValue resolves the value of an environment variable, supporting both
@@ -161,8 +174,6 @@ func (p *podEventLogger) initNamespace(namespace string) error {
161174
// This is to prevent us from sending duplicate events.
162175
startTime := time.Now()
163176

164-
go p.lq.work(p.ctx)
165-
166177
podFactory := informers.NewSharedInformerFactoryWithOptions(p.client, 0, informers.WithNamespace(namespace), informers.WithTweakListOptions(func(lo *v1.ListOptions) {
167178
lo.FieldSelector = p.fieldSelector
168179
lo.LabelSelector = p.labelSelector
@@ -411,10 +422,15 @@ func (p *podEventLogger) sendDelete(token string) {
411422
}
412423

413424
// Close stops the pod event logger and releases all resources.
425+
// Close is idempotent and safe to call multiple times.
414426
func (p *podEventLogger) Close() error {
415-
p.cancelFunc()
416-
close(p.stopChan)
417-
close(p.errChan)
427+
p.closeOnce.Do(func() {
428+
p.cancelFunc()
429+
close(p.stopChan)
430+
close(p.errChan)
431+
})
432+
// Wait for the work goroutine to exit
433+
<-p.doneChan
418434
return nil
419435
}
420436

@@ -503,7 +519,10 @@ type logQueuer struct {
503519
maxRetries int
504520
}
505521

506-
func (l *logQueuer) work(ctx context.Context) {
522+
func (l *logQueuer) work(ctx context.Context, done chan struct{}) {
523+
defer close(done)
524+
defer l.cleanup()
525+
507526
for ctx.Err() == nil {
508527
select {
509528
case log := <-l.q:
@@ -521,6 +540,19 @@ func (l *logQueuer) work(ctx context.Context) {
521540
}
522541
}
523542

543+
// cleanup stops all retry timers and cleans up resources when the work loop exits.
544+
func (l *logQueuer) cleanup() {
545+
l.mu.Lock()
546+
defer l.mu.Unlock()
547+
548+
for token, rs := range l.retries {
549+
if rs != nil && rs.timer != nil {
550+
rs.timer.Stop()
551+
}
552+
delete(l.retries, token)
553+
}
554+
}
555+
524556
func (l *logQueuer) newLogger(ctx context.Context, log agentLog) (agentLoggerLifecycle, error) {
525557
client := agentsdk.New(l.coderURL, agentsdk.WithFixedToken(log.agentToken))
526558
logger := l.logger.With(slog.F("resource_name", log.resourceName))

logger_test.go

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -675,7 +675,7 @@ func Test_logQueuer(t *testing.T) {
675675

676676
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
677677
defer cancel()
678-
go lq.work(ctx)
678+
go lq.work(ctx, make(chan struct{}))
679679

680680
ch <- agentLog{
681681
op: opLog,
@@ -742,7 +742,7 @@ func Test_logQueuer(t *testing.T) {
742742

743743
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
744744
defer cancel()
745-
go lq.work(ctx)
745+
go lq.work(ctx, make(chan struct{}))
746746

747747
token := "retry-token"
748748
ch <- agentLog{
@@ -905,7 +905,7 @@ func Test_logQueuer(t *testing.T) {
905905

906906
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
907907
defer cancel()
908-
go lq.work(ctx)
908+
go lq.work(ctx, make(chan struct{}))
909909

910910
token := "max-retry-token"
911911
ch <- agentLog{
@@ -1111,7 +1111,7 @@ func Test_logCache(t *testing.T) {
11111111

11121112
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
11131113
defer cancel()
1114-
go lq.work(ctx)
1114+
go lq.work(ctx, make(chan struct{}))
11151115

11161116
token := "test-token"
11171117

@@ -1179,6 +1179,90 @@ func Test_logCache(t *testing.T) {
11791179
})
11801180
}
11811181

1182+
func TestCloseIdempotent(t *testing.T) {
1183+
t.Parallel()
1184+
1185+
api := newFakeAgentAPI(t)
1186+
1187+
ctx := testutil.Context(t, testutil.WaitShort)
1188+
agentURL, err := url.Parse(api.server.URL)
1189+
require.NoError(t, err)
1190+
namespace := "test-namespace"
1191+
1192+
client := fake.NewSimpleClientset()
1193+
1194+
cMock := quartz.NewMock(t)
1195+
reporter, err := newPodEventLogger(ctx, podEventLoggerOptions{
1196+
client: client,
1197+
coderURL: agentURL,
1198+
namespaces: []string{namespace},
1199+
logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
1200+
logDebounce: 5 * time.Second,
1201+
clock: cMock,
1202+
})
1203+
require.NoError(t, err)
1204+
1205+
// First close should succeed
1206+
err = reporter.Close()
1207+
require.NoError(t, err)
1208+
1209+
// Second close should not panic (idempotent)
1210+
err = reporter.Close()
1211+
require.NoError(t, err)
1212+
}
1213+
1214+
func TestCloseDuringProcessing(t *testing.T) {
1215+
t.Parallel()
1216+
1217+
api := newFakeAgentAPI(t)
1218+
1219+
ctx := testutil.Context(t, testutil.WaitShort)
1220+
agentURL, err := url.Parse(api.server.URL)
1221+
require.NoError(t, err)
1222+
namespace := "test-namespace"
1223+
1224+
client := fake.NewSimpleClientset()
1225+
1226+
cMock := quartz.NewMock(t)
1227+
reporter, err := newPodEventLogger(ctx, podEventLoggerOptions{
1228+
client: client,
1229+
coderURL: agentURL,
1230+
namespaces: []string{namespace},
1231+
logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
1232+
logDebounce: 5 * time.Second,
1233+
clock: cMock,
1234+
})
1235+
require.NoError(t, err)
1236+
1237+
// Create a pod to trigger processing
1238+
pod := &corev1.Pod{
1239+
ObjectMeta: v1.ObjectMeta{
1240+
Name: "test-pod-close",
1241+
Namespace: namespace,
1242+
CreationTimestamp: v1.Time{
1243+
Time: time.Now().Add(time.Hour),
1244+
},
1245+
},
1246+
Spec: corev1.PodSpec{
1247+
Containers: []corev1.Container{{
1248+
Env: []corev1.EnvVar{{
1249+
Name: "CODER_AGENT_TOKEN",
1250+
Value: "test-token",
1251+
}},
1252+
}},
1253+
},
1254+
}
1255+
_, err = client.CoreV1().Pods(namespace).Create(ctx, pod, v1.CreateOptions{})
1256+
require.NoError(t, err)
1257+
1258+
// Wait for log source to be registered
1259+
_ = testutil.RequireReceive(ctx, t, api.logSource)
1260+
1261+
// Close while processing is active
1262+
err = reporter.Close()
1263+
require.NoError(t, err)
1264+
}
1265+
11821266
func newFakeAgentAPI(t *testing.T) *fakeAgentAPI {
11831267
logger := slogtest.Make(t, nil)
11841268
mux := drpcmux.New()

0 commit comments

Comments
 (0)