@@ -18,6 +18,7 @@ package service
1818
1919import (
2020 "testing"
21+ "time"
2122
2223 jumpstarterdevv1alpha1 "github.com/jumpstarter-dev/jumpstarter-controller/api/v1alpha1"
2324 pb "github.com/jumpstarter-dev/jumpstarter-controller/internal/protocol/jumpstarter/v1"
@@ -299,6 +300,106 @@ func TestSyncOnlineConditionWithStatus(t *testing.T) {
299300 }
300301}
301302
303+ // TestListenQueueTimerCleanup verifies that the listen queue is NOT removed
304+ // immediately when a transient stream error occurs, giving a reconnecting
305+ // exporter time to inherit the queue (and any buffered Dial token), and that
306+ // the queue IS removed once the cleanup timer fires.
307+ func TestListenQueueTimerCleanup (t * testing.T ) {
308+ // Shorten the delay so the test completes quickly.
309+ original := listenQueueCleanupDelay
310+ listenQueueCleanupDelay = 50 * time .Millisecond
311+ t .Cleanup (func () { listenQueueCleanupDelay = original })
312+
313+ svc := & ControllerService {}
314+ leaseName := "test-lease"
315+
316+ // Seed the queue as Listen() would via LoadOrStore.
317+ ch := make (chan * pb.ListenResponse , 8 )
318+ svc .listenQueues .Store (leaseName , ch )
319+
320+ // Simulate the stream-error path: schedule deferred cleanup.
321+ t .Run ("queue survives transient error" , func (t * testing.T ) {
322+ timer := time .AfterFunc (listenQueueCleanupDelay , func () {
323+ svc .listenQueues .Delete (leaseName )
324+ svc .listenTimers .Delete (leaseName )
325+ })
326+ svc .listenTimers .Store (leaseName , timer )
327+
328+ // Queue must still be present immediately after the error.
329+ if _ , ok := svc .listenQueues .Load (leaseName ); ! ok {
330+ t .Fatal ("listen queue was removed immediately after stream error — Dial token would be lost" )
331+ }
332+ })
333+
334+ t .Run ("reconnecting exporter cancels cleanup timer" , func (t * testing.T ) {
335+ // Simulate Listen() reconnect: cancel the timer and call LoadOrStore.
336+ if raw , ok := svc .listenTimers .LoadAndDelete (leaseName ); ok {
337+ raw .(* time.Timer ).Stop ()
338+ }
339+ got , _ := svc .listenQueues .LoadOrStore (leaseName , make (chan * pb.ListenResponse , 8 ))
340+ if got != ch {
341+ t .Fatal ("reconnecting Listen() did not inherit the existing queue" )
342+ }
343+
344+ // Wait well past the original delay — the queue must still be present
345+ // because the timer was stopped.
346+ time .Sleep (listenQueueCleanupDelay * 4 )
347+ if _ , ok := svc .listenQueues .Load (leaseName ); ! ok {
348+ t .Fatal ("listen queue was removed even though cleanup timer was cancelled" )
349+ }
350+ })
351+
352+ t .Run ("timer fires and removes queue when exporter does not reconnect" , func (t * testing.T ) {
353+ // Re-arm the timer without cancelling it this time.
354+ timer := time .AfterFunc (listenQueueCleanupDelay , func () {
355+ svc .listenQueues .Delete (leaseName )
356+ svc .listenTimers .Delete (leaseName )
357+ })
358+ svc .listenTimers .Store (leaseName , timer )
359+
360+ // Wait for the timer to fire.
361+ time .Sleep (listenQueueCleanupDelay * 4 )
362+ if _ , ok := svc .listenQueues .Load (leaseName ); ok {
363+ t .Fatal ("listen queue was not removed after cleanup timer fired" )
364+ }
365+ })
366+ }
367+
368+ // TestListenQueueCleanShutdown verifies that a clean context cancellation
369+ // (lease end / server stop) removes the queue immediately without waiting for
370+ // the cleanup timer.
371+ func TestListenQueueCleanShutdown (t * testing.T ) {
372+ original := listenQueueCleanupDelay
373+ listenQueueCleanupDelay = 2 * time .Minute // keep long — must NOT fire during test
374+ t .Cleanup (func () { listenQueueCleanupDelay = original })
375+
376+ svc := & ControllerService {}
377+ leaseName := "test-lease-shutdown"
378+
379+ ch := make (chan * pb.ListenResponse , 8 )
380+ svc .listenQueues .Store (leaseName , ch )
381+
382+ // Arm a timer that should be cancelled before it fires.
383+ timer := time .AfterFunc (listenQueueCleanupDelay , func () {
384+ svc .listenQueues .Delete (leaseName )
385+ svc .listenTimers .Delete (leaseName )
386+ })
387+ svc .listenTimers .Store (leaseName , timer )
388+
389+ // Simulate the ctx.Done() path in Listen().
390+ if raw , ok := svc .listenTimers .LoadAndDelete (leaseName ); ok {
391+ raw .(* time.Timer ).Stop ()
392+ }
393+ svc .listenQueues .Delete (leaseName )
394+
395+ if _ , ok := svc .listenQueues .Load (leaseName ); ok {
396+ t .Fatal ("listen queue was not removed on clean shutdown" )
397+ }
398+ if _ , ok := svc .listenTimers .Load (leaseName ); ok {
399+ t .Fatal ("cleanup timer was not cancelled on clean shutdown" )
400+ }
401+ }
402+
302403// contains checks if substr is contained in s
303404func contains (s , substr string ) bool {
304405 return len (s ) >= len (substr ) && (s == substr || len (substr ) == 0 ||
0 commit comments