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:
@@ -65,6 +65,13 @@ func run() error {
|
|||||||
}
|
}
|
||||||
defer store.Close()
|
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)
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
|||||||
@@ -286,6 +286,21 @@ func (m *MemoryStore) GetRecentSessions(_ context.Context, limit int, activeOnly
|
|||||||
return sessions, nil
|
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 {
|
func (m *MemoryStore) Close() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,6 +351,16 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn
|
|||||||
return sessions, rows.Err()
|
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 {
|
func (s *SQLiteStore) Close() error {
|
||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ type Store interface {
|
|||||||
// GetSessionEvents returns all events for a session ordered by id.
|
// GetSessionEvents returns all events for a session ordered by id.
|
||||||
GetSessionEvents(ctx context.Context, sessionID string) ([]SessionEvent, error)
|
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 releases any resources held by the store.
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestGetRecentSessions(t *testing.T) {
|
||||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||||
t.Run("empty", func(t *testing.T) {
|
t.Run("empty", func(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user