fix: clean up stale active sessions on startup

After an unclean shutdown, sessions could be left with disconnected_at
NULL, appearing permanently active. Add CloseActiveSessions to the Store
interface and call it at startup to close any leftover sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 00:16:48 +01:00
parent d78d461236
commit 86786c9d05
5 changed files with 82 additions and 0 deletions

View File

@@ -65,6 +65,13 @@ func run() error {
}
defer store.Close()
// Clean up sessions left active by a previous unclean shutdown.
if n, err := store.CloseActiveSessions(context.Background(), time.Now()); err != nil {
return fmt.Errorf("close stale sessions: %w", err)
} else if n > 0 {
logger.Info("closed stale sessions from previous run", "count", n)
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()

View File

@@ -286,6 +286,21 @@ func (m *MemoryStore) GetRecentSessions(_ context.Context, limit int, activeOnly
return sessions, nil
}
func (m *MemoryStore) CloseActiveSessions(_ context.Context, disconnectedAt time.Time) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
var count int64
t := disconnectedAt.UTC()
for _, s := range m.Sessions {
if s.DisconnectedAt == nil {
s.DisconnectedAt = &t
count++
}
}
return count, nil
}
func (m *MemoryStore) Close() error {
return nil
}

View File

@@ -351,6 +351,16 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn
return sessions, rows.Err()
}
func (s *SQLiteStore) CloseActiveSessions(ctx context.Context, disconnectedAt time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx, `
UPDATE sessions SET disconnected_at = ? WHERE disconnected_at IS NULL`,
disconnectedAt.UTC().Format(time.RFC3339))
if err != nil {
return 0, fmt.Errorf("closing active sessions: %w", err)
}
return res.RowsAffected()
}
func (s *SQLiteStore) Close() error {
return s.db.Close()
}

View File

@@ -108,6 +108,11 @@ type Store interface {
// GetSessionEvents returns all events for a session ordered by id.
GetSessionEvents(ctx context.Context, sessionID string) ([]SessionEvent, error)
// CloseActiveSessions sets disconnected_at for all sessions that are
// still marked as active. This should be called at startup to clean up
// sessions left over from a previous unclean shutdown.
CloseActiveSessions(ctx context.Context, disconnectedAt time.Time) (int64, error)
// Close releases any resources held by the store.
Close() error
}

View File

@@ -316,6 +316,51 @@ func TestSessionEvents(t *testing.T) {
})
}
func TestCloseActiveSessions(t *testing.T) {
testStores(t, func(t *testing.T, newStore storeFactory) {
t.Run("no active sessions", func(t *testing.T) {
store := newStore(t)
ctx := context.Background()
n, err := store.CloseActiveSessions(ctx, time.Now())
if err != nil {
t.Fatalf("CloseActiveSessions: %v", err)
}
if n != 0 {
t.Errorf("closed %d, want 0", n)
}
})
t.Run("closes only active sessions", func(t *testing.T) {
store := newStore(t)
ctx := context.Background()
// Create 3 sessions: end one, leave two active.
id1, _ := store.CreateSession(ctx, "10.0.0.1", "root", "bash")
store.CreateSession(ctx, "10.0.0.2", "admin", "bash")
store.CreateSession(ctx, "10.0.0.3", "test", "bash")
store.EndSession(ctx, id1, time.Now())
n, err := store.CloseActiveSessions(ctx, time.Now())
if err != nil {
t.Fatalf("CloseActiveSessions: %v", err)
}
if n != 2 {
t.Errorf("closed %d, want 2", n)
}
// Verify no active sessions remain.
active, err := store.GetRecentSessions(ctx, 10, true)
if err != nil {
t.Fatalf("GetRecentSessions: %v", err)
}
if len(active) != 0 {
t.Errorf("active sessions = %d, want 0", len(active))
}
})
})
}
func TestGetRecentSessions(t *testing.T) {
testStores(t, func(t *testing.T, newStore storeFactory) {
t.Run("empty", func(t *testing.T) {