Skip to content

Commit 3d8dad4

Browse files
committed
Fix resource leak in session storage cleanup
Implements fix by calling Close() on sessions that implement io.Closer before deleting them from storage during cleanup. This prevents connection leaks when vMCP sessions with backend clients expire. Closes: #3871
1 parent affba91 commit 3d8dad4

File tree

2 files changed

+181
-6
lines changed

2 files changed

+181
-6
lines changed

pkg/transport/session/storage_local.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ package session
66
import (
77
"context"
88
"fmt"
9+
"io"
10+
"log/slog"
911
"sync"
1012
"time"
1113
)
@@ -66,9 +68,12 @@ func (s *LocalStorage) Delete(_ context.Context, id string) error {
6668

6769
// DeleteExpired removes all sessions that haven't been updated since the given time.
6870
func (s *LocalStorage) DeleteExpired(ctx context.Context, before time.Time) error {
69-
var toDelete []string
71+
var toDelete []struct {
72+
id string
73+
session Session
74+
}
7075

71-
// First pass: collect IDs of expired sessions
76+
// First pass: collect expired sessions
7277
s.sessions.Range(func(key, val any) bool {
7378
// Check for context cancellation
7479
select {
@@ -80,16 +85,27 @@ func (s *LocalStorage) DeleteExpired(ctx context.Context, before time.Time) erro
8085
if session, ok := val.(Session); ok {
8186
if session.UpdatedAt().Before(before) {
8287
if id, ok := key.(string); ok {
83-
toDelete = append(toDelete, id)
88+
toDelete = append(toDelete, struct {
89+
id string
90+
session Session
91+
}{id, session})
8492
}
8593
}
8694
}
8795
return true
8896
})
8997

90-
// Second pass: delete expired sessions
91-
for _, id := range toDelete {
92-
s.sessions.Delete(id)
98+
// Second pass: close and delete expired sessions
99+
for _, item := range toDelete {
100+
// Call Close() on sessions that implement io.Closer
101+
if closer, ok := item.session.(io.Closer); ok {
102+
if err := closer.Close(); err != nil {
103+
slog.Warn("failed to close session during cleanup",
104+
"session_id", item.id,
105+
"error", err)
106+
}
107+
}
108+
s.sessions.Delete(item.id)
93109
}
94110

95111
return nil

pkg/transport/session/storage_test.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package session
55

66
import (
77
"context"
8+
"errors"
89
"fmt"
910
"testing"
1011
"time"
@@ -13,6 +14,24 @@ import (
1314
"github.com/stretchr/testify/require"
1415
)
1516

17+
// mockClosableSession is a test session that implements io.Closer
18+
type mockClosableSession struct {
19+
*ProxySession
20+
closeCalled bool
21+
closeError error
22+
}
23+
24+
func newMockClosableSession(id string) *mockClosableSession {
25+
return &mockClosableSession{
26+
ProxySession: NewProxySession(id),
27+
}
28+
}
29+
30+
func (m *mockClosableSession) Close() error {
31+
m.closeCalled = true
32+
return m.closeError
33+
}
34+
1635
// TestLocalStorage tests the LocalStorage implementation
1736
func TestLocalStorage(t *testing.T) {
1837
t.Parallel()
@@ -302,6 +321,146 @@ func TestLocalStorage(t *testing.T) {
302321
// Should not error, just stop early
303322
assert.NoError(t, err)
304323
})
324+
325+
t.Run("DeleteExpired calls Close on io.Closer sessions", func(t *testing.T) {
326+
t.Parallel()
327+
storage := NewLocalStorage()
328+
defer storage.Close()
329+
330+
ctx := context.Background()
331+
332+
// Create a closable session (implements io.Closer)
333+
closableSession := newMockClosableSession("closable-session")
334+
closableSession.updated = time.Now().Add(-2 * time.Hour)
335+
336+
// Create a regular session (does not implement io.Closer)
337+
regularSession := NewProxySession("regular-session")
338+
regularSession.updated = time.Now().Add(-2 * time.Hour)
339+
340+
// Store both sessions
341+
err := storage.Store(ctx, closableSession)
342+
require.NoError(t, err)
343+
err = storage.Store(ctx, regularSession)
344+
require.NoError(t, err)
345+
346+
// Delete sessions older than 1 hour
347+
cutoff := time.Now().Add(-1 * time.Hour)
348+
err = storage.DeleteExpired(ctx, cutoff)
349+
require.NoError(t, err)
350+
351+
// Both sessions should be deleted
352+
_, err = storage.Load(ctx, "closable-session")
353+
assert.Equal(t, ErrSessionNotFound, err)
354+
_, err = storage.Load(ctx, "regular-session")
355+
assert.Equal(t, ErrSessionNotFound, err)
356+
357+
// Close() should have been called on the closable session
358+
assert.True(t, closableSession.closeCalled, "Close() should have been called on closable session")
359+
})
360+
361+
t.Run("DeleteExpired continues deletion even if Close fails", func(t *testing.T) {
362+
t.Parallel()
363+
storage := NewLocalStorage()
364+
defer storage.Close()
365+
366+
ctx := context.Background()
367+
368+
// Create a closable session that returns an error on Close()
369+
failingSession := newMockClosableSession("failing-session")
370+
failingSession.closeError = errors.New("close failed")
371+
failingSession.updated = time.Now().Add(-2 * time.Hour)
372+
373+
// Store the session
374+
err := storage.Store(ctx, failingSession)
375+
require.NoError(t, err)
376+
377+
// Delete expired sessions
378+
cutoff := time.Now().Add(-1 * time.Hour)
379+
err = storage.DeleteExpired(ctx, cutoff)
380+
require.NoError(t, err)
381+
382+
// Session should still be deleted despite Close() error
383+
_, err = storage.Load(ctx, "failing-session")
384+
assert.Equal(t, ErrSessionNotFound, err)
385+
386+
// Close() should have been called
387+
assert.True(t, failingSession.closeCalled, "Close() should have been called")
388+
})
389+
390+
t.Run("DeleteExpired handles non-io.Closer sessions without error", func(t *testing.T) {
391+
t.Parallel()
392+
storage := NewLocalStorage()
393+
defer storage.Close()
394+
395+
ctx := context.Background()
396+
397+
// Create multiple regular sessions (do not implement io.Closer)
398+
for i := 0; i < 5; i++ {
399+
session := NewProxySession(fmt.Sprintf("session-%d", i))
400+
session.updated = time.Now().Add(-2 * time.Hour)
401+
err := storage.Store(ctx, session)
402+
require.NoError(t, err)
403+
}
404+
405+
// Delete expired sessions
406+
cutoff := time.Now().Add(-1 * time.Hour)
407+
err := storage.DeleteExpired(ctx, cutoff)
408+
require.NoError(t, err)
409+
410+
// All sessions should be deleted
411+
for i := 0; i < 5; i++ {
412+
_, err := storage.Load(ctx, fmt.Sprintf("session-%d", i))
413+
assert.Equal(t, ErrSessionNotFound, err)
414+
}
415+
})
416+
417+
t.Run("DeleteExpired with mixed session types", func(t *testing.T) {
418+
t.Parallel()
419+
storage := NewLocalStorage()
420+
defer storage.Close()
421+
422+
ctx := context.Background()
423+
424+
// Create a mix of closable and regular expired sessions
425+
closable1 := newMockClosableSession("closable-1")
426+
closable1.updated = time.Now().Add(-2 * time.Hour)
427+
closable2 := newMockClosableSession("closable-2")
428+
closable2.updated = time.Now().Add(-2 * time.Hour)
429+
430+
regular1 := NewProxySession("regular-1")
431+
regular1.updated = time.Now().Add(-2 * time.Hour)
432+
regular2 := NewProxySession("regular-2")
433+
regular2.updated = time.Now().Add(-2 * time.Hour)
434+
435+
// Store all sessions
436+
err := storage.Store(ctx, closable1)
437+
require.NoError(t, err)
438+
err = storage.Store(ctx, closable2)
439+
require.NoError(t, err)
440+
err = storage.Store(ctx, regular1)
441+
require.NoError(t, err)
442+
err = storage.Store(ctx, regular2)
443+
require.NoError(t, err)
444+
445+
// Delete expired sessions
446+
cutoff := time.Now().Add(-1 * time.Hour)
447+
err = storage.DeleteExpired(ctx, cutoff)
448+
require.NoError(t, err)
449+
450+
// All sessions should be deleted
451+
_, err = storage.Load(ctx, "closable-1")
452+
assert.Equal(t, ErrSessionNotFound, err)
453+
_, err = storage.Load(ctx, "closable-2")
454+
assert.Equal(t, ErrSessionNotFound, err)
455+
_, err = storage.Load(ctx, "regular-1")
456+
assert.Equal(t, ErrSessionNotFound, err)
457+
_, err = storage.Load(ctx, "regular-2")
458+
assert.Equal(t, ErrSessionNotFound, err)
459+
460+
// Close() should have been called on closable sessions
461+
assert.True(t, closable1.closeCalled, "Close() should have been called on closable-1")
462+
assert.True(t, closable2.closeCalled, "Close() should have been called on closable-2")
463+
})
305464
}
306465

307466
// TestManagerWithStorage tests the Manager with the Storage interface

0 commit comments

Comments
 (0)