fix: correct grace period protection during primary reconnection

The session manager had backwards logic that prevented sessions from
restoring their primary status when reconnecting within the grace period.
This caused browser refreshes to demote primary sessions to observers.

Changes:
- Fix conditional in AddSession to allow primary restoration within grace
- Remove excessive debug logging throughout session manager
- Clean up unused decrActiveSessions function
- Remove unnecessary leading newline in NewSessionManager
- Update lastPrimaryID handling to support WebRTC reconnections
- Preserve grace periods during transfers to allow browser refreshes

The fix ensures that when a primary session refreshes their browser:
1. RemoveSession adds a grace period entry
2. New connection checks wasWithinGracePeriod and wasPreviouslyPrimary
3. Session correctly reclaims primary status

Blacklist system prevents demoted sessions from immediate re-promotion
while grace periods allow legitimate reconnections.
This commit is contained in:
Alex P 2025-10-10 19:33:49 +03:00
parent 309126bef6
commit 825299257d
2 changed files with 45 additions and 237 deletions

View File

@ -88,12 +88,6 @@ type SessionManager struct {
// NewSessionManager creates a new session manager // NewSessionManager creates a new session manager
func NewSessionManager(logger *zerolog.Logger) *SessionManager { func NewSessionManager(logger *zerolog.Logger) *SessionManager {
// DEBUG: Log every time a new SessionManager is created
if logger != nil {
logger.Warn().
Msg("CREATING NEW SESSION MANAGER - This should only happen once at startup!")
}
// Use configuration values if available // Use configuration values if available
maxSessions := 10 maxSessions := 10
primaryTimeout := 5 * time.Minute primaryTimeout := 5 * time.Minute
@ -138,9 +132,6 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
return errors.New("session cannot be nil") return errors.New("session cannot be nil")
} }
sm.logger.Debug().
Str("sessionID", session.ID).
Msg("AddSession ENTRY")
// Validate nickname if provided (matching frontend validation) // Validate nickname if provided (matching frontend validation)
if session.Nickname != "" { if session.Nickname != "" {
if len(session.Nickname) < 2 { if len(session.Nickname) < 2 {
@ -169,25 +160,17 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
wasPreviouslyPending = (reconnectInfo.Mode == SessionModePending) wasPreviouslyPending = (reconnectInfo.Mode == SessionModePending)
} }
} }
delete(sm.reconnectGrace, session.ID)
} }
// Check if a session with this ID already exists (reconnection) // Check if a session with this ID already exists (reconnection)
if existing, exists := sm.sessions[session.ID]; exists { if existing, exists := sm.sessions[session.ID]; exists {
sm.logger.Debug().
Str("sessionID", session.ID).
Msg("AddSession: session ID already exists - RECONNECTION PATH")
// SECURITY: Verify identity matches to prevent session hijacking // SECURITY: Verify identity matches to prevent session hijacking
if existing.Identity != session.Identity || existing.Source != session.Source { if existing.Identity != session.Identity || existing.Source != session.Source {
return fmt.Errorf("session ID already in use by different user (identity mismatch)") return fmt.Errorf("session ID already in use by different user (identity mismatch)")
} }
// CRITICAL: Close old connection to prevent multiple active connections for same session ID // Close old connection to prevent multiple active connections for same session ID
if existing.peerConnection != nil { if existing.peerConnection != nil {
sm.logger.Info().
Str("sessionID", session.ID).
Msg("Closing old peer connection for session reconnection")
existing.peerConnection.Close() existing.peerConnection.Close()
} }
@ -211,42 +194,22 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
// If this was the primary, try to restore primary status // If this was the primary, try to restore primary status
if existing.Mode == SessionModePrimary { if existing.Mode == SessionModePrimary {
// Check if this session is still the reserved primary AND not blacklisted
isBlacklisted := sm.isSessionBlacklisted(session.ID) isBlacklisted := sm.isSessionBlacklisted(session.ID)
if sm.lastPrimaryID == session.ID && !isBlacklisted { if sm.lastPrimaryID == session.ID && !isBlacklisted {
// This is the rightful primary reconnecting within grace period
sm.primarySessionID = session.ID sm.primarySessionID = session.ID
sm.lastPrimaryID = "" // Clear since primary successfully reconnected sm.lastPrimaryID = ""
delete(sm.reconnectGrace, session.ID) // Clear grace period delete(sm.reconnectGrace, session.ID)
sm.logger.Debug().
Str("sessionID", session.ID).
Msg("Primary session successfully reconnected within grace period")
} else { } else {
// This session was primary but grace period expired, another took over, or is blacklisted // Grace period expired or another session took over
session.Mode = SessionModeObserver session.Mode = SessionModeObserver
sm.logger.Debug().
Str("sessionID", session.ID).
Str("currentPrimaryID", sm.primarySessionID).
Bool("isBlacklisted", isBlacklisted).
Msg("Former primary session reconnected but grace period expired, another took over, or session is blacklisted - demoting to observer")
} }
} }
// NOTE: Skip validation during reconnection to preserve grace period
// validateSinglePrimary() would clear primary slot during reconnection window
sm.logger.Debug().
Str("sessionID", session.ID).
Msg("AddSession: RETURNING from reconnection path")
go sm.broadcastSessionListUpdate() go sm.broadcastSessionListUpdate()
return nil return nil
} }
if len(sm.sessions) >= sm.maxSessions { if len(sm.sessions) >= sm.maxSessions {
sm.logger.Warn().
Int("currentSessions", len(sm.sessions)).
Int("maxSessions", sm.maxSessions).
Msg("AddSession: MAX SESSIONS REACHED")
return ErrMaxSessionsReached return ErrMaxSessionsReached
} }
@ -255,15 +218,6 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
session.ID = uuid.New().String() session.ID = uuid.New().String()
} }
// Clean up any grace period entries for this session since it's reconnecting
if wasWithinGracePeriod {
delete(sm.reconnectGrace, session.ID)
delete(sm.reconnectInfo, session.ID)
sm.logger.Info().
Str("sessionID", session.ID).
Msg("Session reconnected within grace period - cleaned up grace period entries")
}
// Set nickname from client settings if provided // Set nickname from client settings if provided
if clientSettings != nil && clientSettings.Nickname != "" { if clientSettings != nil && clientSettings.Nickname != "" {
session.Nickname = clientSettings.Nickname session.Nickname = clientSettings.Nickname
@ -272,9 +226,6 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
// Use global settings for requirements (not client-provided) // Use global settings for requirements (not client-provided)
globalSettings := currentSessionSettings globalSettings := currentSessionSettings
// Set mode based on current state and global settings
// ATOMIC CHECK AND ASSIGN: Check if there's currently no primary session
// and assign primary status atomically to prevent race conditions
primaryExists := sm.primarySessionID != "" && sm.sessions[sm.primarySessionID] != nil primaryExists := sm.primarySessionID != "" && sm.sessions[sm.primarySessionID] != nil
// Check if there's an active grace period for a primary session (different from this session) // Check if there's an active grace period for a primary session (different from this session)
@ -291,65 +242,28 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
} }
} }
// Check if this session was recently demoted via transfer
isBlacklisted := sm.isSessionBlacklisted(session.ID) isBlacklisted := sm.isSessionBlacklisted(session.ID)
sm.logger.Debug(). // Determine if this session should become primary
Str("newSessionID", session.ID). // If there's no primary AND this is the ONLY session, ALWAYS promote regardless of blacklist
Str("nickname", session.Nickname). isOnlySession := len(sm.sessions) == 0
Str("currentPrimarySessionID", sm.primarySessionID). shouldBecomePrimary := (wasWithinGracePeriod && wasPreviouslyPrimary && !primaryExists && !hasActivePrimaryGracePeriod) ||
Bool("primaryExists", primaryExists). (!wasWithinGracePeriod && !hasActivePrimaryGracePeriod && !primaryExists && (!isBlacklisted || isOnlySession))
Bool("hasActivePrimaryGracePeriod", hasActivePrimaryGracePeriod).
Int("totalSessions", len(sm.sessions)).
Bool("wasWithinGracePeriod", wasWithinGracePeriod).
Bool("wasPreviouslyPrimary", wasPreviouslyPrimary).
Bool("wasPreviouslyPending", wasPreviouslyPending).
Bool("isBlacklisted", isBlacklisted).
Msg("AddSession state analysis")
// Become primary only if:
// 1. Was previously primary (within grace) AND no current primary AND no other session has grace period, OR
// 2. There's no primary at all AND not recently transferred away AND no active grace period
// Never allow primary promotion if already restored within grace period or another session has grace period
shouldBecomePrimary := !wasWithinGracePeriod && !hasActivePrimaryGracePeriod && ((wasPreviouslyPrimary && !primaryExists) || (!primaryExists && !isBlacklisted))
if wasWithinGracePeriod {
sm.logger.Debug().
Str("sessionID", session.ID).
Bool("wasPreviouslyPrimary", wasPreviouslyPrimary).
Bool("primaryExists", primaryExists).
Str("currentPrimarySessionID", sm.primarySessionID).
Msg("Session within grace period - skipping primary promotion logic")
}
if shouldBecomePrimary { if shouldBecomePrimary {
// Double-check primary doesn't exist (race condition prevention)
if sm.primarySessionID == "" || sm.sessions[sm.primarySessionID] == nil { if sm.primarySessionID == "" || sm.sessions[sm.primarySessionID] == nil {
// Since we now generate nicknames automatically when required,
// we can always promote to primary when no primary exists
session.Mode = SessionModePrimary session.Mode = SessionModePrimary
sm.primarySessionID = session.ID sm.primarySessionID = session.ID
sm.lastPrimaryID = "" // Clear since we have a new primary sm.lastPrimaryID = ""
// Clear all existing grace periods when a new primary is established // Clear all existing grace periods when a new primary is established
// This prevents multiple sessions from fighting for primary status via grace period for oldSessionID := range sm.reconnectGrace {
if len(sm.reconnectGrace) > 0 || len(sm.reconnectInfo) > 0 { delete(sm.reconnectGrace, oldSessionID)
sm.logger.Debug(). }
Int("clearedGracePeriods", len(sm.reconnectGrace)). for oldSessionID := range sm.reconnectInfo {
Int("clearedReconnectInfo", len(sm.reconnectInfo)). delete(sm.reconnectInfo, oldSessionID)
Str("newPrimarySessionID", session.ID).
Msg("Clearing all existing grace periods for new primary session in AddSession")
// Clear all existing grace periods and reconnect info
for oldSessionID := range sm.reconnectGrace {
delete(sm.reconnectGrace, oldSessionID)
}
for oldSessionID := range sm.reconnectInfo {
delete(sm.reconnectInfo, oldSessionID)
}
} }
// Reset HID availability to force re-handshake for input functionality
session.hidRPCAvailable = false session.hidRPCAvailable = false
} else { } else {
session.Mode = SessionModeObserver session.Mode = SessionModeObserver
@ -387,24 +301,25 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
session.CreatedAt = time.Now() session.CreatedAt = time.Now()
session.LastActive = time.Now() session.LastActive = time.Now()
// Add session to sessions map BEFORE primary checks
// This ensures that primary existence checks work correctly during restoration
sm.sessions[session.ID] = session sm.sessions[session.ID] = session
sm.logger.Info(). sm.logger.Info().
Str("sessionID", session.ID). Str("sessionID", session.ID).
Str("mode", string(session.Mode)). Str("mode", string(session.Mode)).
Int("totalSessions", len(sm.sessions)). Int("totalSessions", len(sm.sessions)).
Str("sm_pointer", fmt.Sprintf("%p", sm)).
Str("sm.sessions_pointer", fmt.Sprintf("%p", sm.sessions)).
Msg("Session added to manager") Msg("Session added to manager")
// Ensure session has auto-generated nickname if needed // Ensure session has auto-generated nickname if needed
sm.ensureNickname(session) sm.ensureNickname(session)
// Validate sessions but respect grace periods
sm.validateSinglePrimary() sm.validateSinglePrimary()
// Clean up grace period after validation completes
if wasWithinGracePeriod {
delete(sm.reconnectGrace, session.ID)
delete(sm.reconnectInfo, session.ID)
}
// Notify all sessions about the new connection // Notify all sessions about the new connection
go sm.broadcastSessionListUpdate() go sm.broadcastSessionListUpdate()
@ -418,9 +333,6 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
session, exists := sm.sessions[sessionID] session, exists := sm.sessions[sessionID]
if !exists { if !exists {
sm.logger.Debug().
Str("sessionID", sessionID).
Msg("RemoveSession called but session not found in map")
return return
} }
@ -439,12 +351,8 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
// Check if this session was marked for immediate removal (intentional logout) // Check if this session was marked for immediate removal (intentional logout)
isIntentionalLogout := false isIntentionalLogout := false
if graceTime, exists := sm.reconnectGrace[sessionID]; exists { if graceTime, exists := sm.reconnectGrace[sessionID]; exists {
// If grace period is already expired, this was intentional logout
if time.Now().After(graceTime) { if time.Now().After(graceTime) {
isIntentionalLogout = true isIntentionalLogout = true
sm.logger.Info().
Str("sessionID", sessionID).
Msg("Detected intentional logout - skipping grace period")
delete(sm.reconnectGrace, sessionID) delete(sm.reconnectGrace, sessionID)
delete(sm.reconnectInfo, sessionID) delete(sm.reconnectInfo, sessionID)
} }
@ -458,12 +366,9 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
// Only add grace period if this is NOT an intentional logout // Only add grace period if this is NOT an intentional logout
if !isIntentionalLogout { if !isIntentionalLogout {
// Add a grace period for reconnection for all sessions // Limit grace period entries to prevent memory exhaustion
const maxGraceEntries = 10
// Limit grace period entries to prevent memory exhaustion (DoS protection)
const maxGraceEntries = 10 // Reduced from 20 to limit memory usage
for len(sm.reconnectGrace) >= maxGraceEntries { for len(sm.reconnectGrace) >= maxGraceEntries {
// Find and remove the oldest grace period entry
var oldestID string var oldestID string
var oldestTime time.Time var oldestTime time.Time
for id, graceTime := range sm.reconnectGrace { for id, graceTime := range sm.reconnectGrace {
@ -476,7 +381,7 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
delete(sm.reconnectGrace, oldestID) delete(sm.reconnectGrace, oldestID)
delete(sm.reconnectInfo, oldestID) delete(sm.reconnectInfo, oldestID)
} else { } else {
break // Safety check to prevent infinite loop break
} }
} }
@ -508,14 +413,8 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
sm.lastPrimaryID = sessionID // Remember this was the primary for grace period sm.lastPrimaryID = sessionID // Remember this was the primary for grace period
sm.primarySessionID = "" // Clear primary slot so other sessions can be promoted sm.primarySessionID = "" // Clear primary slot so other sessions can be promoted
// Clear all blacklists to allow emergency promotion after grace period expires // Clear all blacklists to allow promotion after grace period expires
// The blacklist is meant to prevent immediate re-promotion during manual transfers,
// but should not block emergency promotion after accidental disconnects
if len(sm.transferBlacklist) > 0 { if len(sm.transferBlacklist) > 0 {
sm.logger.Info().
Int("clearedBlacklistEntries", len(sm.transferBlacklist)).
Str("disconnectedPrimaryID", sessionID).
Msg("Clearing transfer blacklist to allow grace period promotion")
sm.transferBlacklist = make([]TransferBlacklistEntry, 0) sm.transferBlacklist = make([]TransferBlacklistEntry, 0)
} }
@ -528,11 +427,6 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
// Trigger validation for potential promotion // Trigger validation for potential promotion
if len(sm.sessions) > 0 { if len(sm.sessions) > 0 {
sm.logger.Debug().
Str("removedPrimaryID", sessionID).
Bool("intentionalLogout", isIntentionalLogout).
Int("remainingSessions", len(sm.sessions)).
Msg("Triggering immediate validation for potential promotion")
sm.validateSinglePrimary() sm.validateSinglePrimary()
} }
} }
@ -669,13 +563,6 @@ func (sm *SessionManager) GetAllSessions() []SessionData {
// This was causing immediate demotion during transfers and page refreshes // This was causing immediate demotion during transfers and page refreshes
// Validation should only run during state changes, not data queries // Validation should only run during state changes, not data queries
// DEBUG: Log pointer addresses to verify we're using the same instance
sm.logger.Debug().
Int("sessions_count", len(sm.sessions)).
Str("sm_pointer", fmt.Sprintf("%p", sm)).
Str("sm.sessions_pointer", fmt.Sprintf("%p", sm.sessions)).
Msg("GetAllSessions called")
infos := make([]SessionData, 0, len(sm.sessions)) infos := make([]SessionData, 0, len(sm.sessions))
for _, session := range sm.sessions { for _, session := range sm.sessions {
infos = append(infos, SessionData{ infos = append(infos, SessionData{
@ -980,28 +867,8 @@ func (sm *SessionManager) UpdateLastActive(sessionID string) {
// validateSinglePrimary ensures there's only one primary session and fixes any inconsistencies // validateSinglePrimary ensures there's only one primary session and fixes any inconsistencies
func (sm *SessionManager) validateSinglePrimary() { func (sm *SessionManager) validateSinglePrimary() {
// CRITICAL DEBUG: Check if we actually hold the lock
// The caller should already hold sm.mu.Lock()
primarySessions := make([]*Session, 0) primarySessions := make([]*Session, 0)
// Capture session keys BEFORE logging to avoid lazy evaluation issues
sessionKeys := make([]string, 0, len(sm.sessions))
sessionPointers := make([]string, 0, len(sm.sessions))
for k, v := range sm.sessions {
sessionKeys = append(sessionKeys, k)
sessionPointers = append(sessionPointers, fmt.Sprintf("%s=%p", k[:8], v))
}
// DEBUG: Add pointer address to verify we're using the right manager instance
sm.logger.Debug().
Int("sm.sessions_len_before_loop", len(sm.sessions)).
Strs("sm.sessions_keys", sessionKeys).
Strs("sm.session_pointers", sessionPointers).
Str("sm_pointer", fmt.Sprintf("%p", sm)).
Str("sm.sessions_map_pointer", fmt.Sprintf("%p", sm.sessions)).
Msg("validateSinglePrimary: checking sm.sessions map")
// Find all sessions that think they're primary // Find all sessions that think they're primary
for _, session := range sm.sessions { for _, session := range sm.sessions {
if session.Mode == SessionModePrimary { if session.Mode == SessionModePrimary {
@ -1009,26 +876,18 @@ func (sm *SessionManager) validateSinglePrimary() {
} }
} }
// If we have multiple primaries, this is a critical bug - fix it // If we have multiple primaries, fix it
if len(primarySessions) > 1 { if len(primarySessions) > 1 {
sm.logger.Error(). sm.logger.Error().
Int("primaryCount", len(primarySessions)). Int("primaryCount", len(primarySessions)).
Msg("CRITICAL BUG: Multiple primary sessions detected, fixing...") Msg("Multiple primary sessions detected, fixing")
// Keep the first one as primary, demote the rest // Keep the first one as primary, demote the rest
for i, session := range primarySessions { for i, session := range primarySessions {
if i == 0 { if i == 0 {
// Keep this as primary and update manager state
sm.primarySessionID = session.ID sm.primarySessionID = session.ID
sm.logger.Info().
Str("keptPrimaryID", session.ID).
Msg("Kept session as primary")
} else { } else {
// Demote all others
session.Mode = SessionModeObserver session.Mode = SessionModeObserver
sm.logger.Info().
Str("demotedSessionID", session.ID).
Msg("Demoted duplicate primary session")
} }
} }
} }
@ -1043,25 +902,14 @@ func (sm *SessionManager) validateSinglePrimary() {
} }
// Don't clear primary slot if there's a grace period active // Don't clear primary slot if there's a grace period active
// This prevents instant promotion during primary session reconnection
if len(primarySessions) == 0 && sm.primarySessionID != "" { if len(primarySessions) == 0 && sm.primarySessionID != "" {
// Check if the current primary is in grace period waiting to reconnect
if sm.lastPrimaryID == sm.primarySessionID { if sm.lastPrimaryID == sm.primarySessionID {
if graceTime, exists := sm.reconnectGrace[sm.primarySessionID]; exists { if graceTime, exists := sm.reconnectGrace[sm.primarySessionID]; exists {
if time.Now().Before(graceTime) { if time.Now().Before(graceTime) {
// Primary is in grace period, DON'T clear the slot yet return // Keep primary slot reserved during grace period
sm.logger.Info().
Str("gracePrimaryID", sm.primarySessionID).
Msg("Primary slot preserved - session in grace period")
return // Exit validation, keep primary slot reserved
} }
} }
} }
// No grace period, safe to clear orphaned primary
sm.logger.Warn().
Str("orphanedPrimaryID", sm.primarySessionID).
Msg("Cleared orphaned primary ID")
sm.primarySessionID = "" sm.primarySessionID = ""
} }
@ -1072,30 +920,12 @@ func (sm *SessionManager) validateSinglePrimary() {
if reconnectInfo, hasInfo := sm.reconnectInfo[sessionID]; hasInfo { if reconnectInfo, hasInfo := sm.reconnectInfo[sessionID]; hasInfo {
if reconnectInfo.Mode == SessionModePrimary { if reconnectInfo.Mode == SessionModePrimary {
hasActivePrimaryGracePeriod = true hasActivePrimaryGracePeriod = true
sm.logger.Debug().
Str("gracePrimaryID", sessionID).
Dur("remainingGrace", time.Until(graceTime)).
Msg("Active grace period detected for primary session - blocking auto-promotion")
break break
} }
} }
} }
} }
// Build session IDs list for debugging
sessionIDs := make([]string, 0, len(sm.sessions))
for id := range sm.sessions {
sessionIDs = append(sessionIDs, id)
}
sm.logger.Debug().
Int("primarySessionCount", len(primarySessions)).
Str("primarySessionID", sm.primarySessionID).
Int("totalSessions", len(sm.sessions)).
Strs("sessionIDs", sessionIDs).
Bool("hasActivePrimaryGracePeriod", hasActivePrimaryGracePeriod).
Msg("validateSinglePrimary state check")
// Auto-promote if there are NO primary sessions at all AND no active grace period // Auto-promote if there are NO primary sessions at all AND no active grace period
if len(primarySessions) == 0 && sm.primarySessionID == "" && len(sm.sessions) > 0 && !hasActivePrimaryGracePeriod { if len(primarySessions) == 0 && sm.primarySessionID == "" && len(sm.sessions) > 0 && !hasActivePrimaryGracePeriod {
// Find a session to promote to primary // Find a session to promote to primary
@ -1117,13 +947,6 @@ func (sm *SessionManager) validateSinglePrimary() {
sm.logger.Warn(). sm.logger.Warn().
Msg("No eligible session found for emergency auto-promotion") Msg("No eligible session found for emergency auto-promotion")
} }
} else {
sm.logger.Debug().
Int("primarySessions", len(primarySessions)).
Str("primarySessionID", sm.primarySessionID).
Bool("hasSessions", len(sm.sessions) > 0).
Bool("hasActivePrimaryGracePeriod", hasActivePrimaryGracePeriod).
Msg("Emergency auto-promotion conditions not met")
} }
} }
@ -1188,14 +1011,10 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf
toSession.hidRPCAvailable = false // Force re-handshake toSession.hidRPCAvailable = false // Force re-handshake
sm.primarySessionID = toSessionID sm.primarySessionID = toSessionID
// Only set lastPrimaryID for grace period scenarios, NOT for manual transfers // ALWAYS set lastPrimaryID to the new primary to support WebRTC reconnections
// Manual transfers should clear lastPrimaryID to prevent reconnection conflicts // This allows the newly promoted session to handle page refreshes correctly
if transferType == "emergency_auto_promotion" || transferType == "emergency_promotion_deadlock_prevention" || // The blacklist system prevents unwanted takeovers during manual transfers
transferType == "emergency_timeout_promotion" || transferType == "initial_promotion" { sm.lastPrimaryID = toSessionID
sm.lastPrimaryID = toSessionID // Allow grace period recovery for emergency promotions
} else {
sm.lastPrimaryID = "" // Clear for manual transfers to prevent reconnection conflicts
}
// Clear input state // Clear input state
sm.clearInputState() sm.clearInputState()
@ -1235,15 +1054,19 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf
} }
} }
// Clear all grace periods to prevent conflicts // DON'T clear grace periods during transfers!
if len(sm.reconnectGrace) > 0 || len(sm.reconnectInfo) > 0 { // Grace periods and blacklisting serve different purposes:
for oldSessionID := range sm.reconnectGrace { // - Grace periods: Allow disconnected sessions to reconnect and reclaim their role
delete(sm.reconnectGrace, oldSessionID) // - Blacklisting: Prevent recently demoted sessions from immediately taking primary again
} //
for oldSessionID := range sm.reconnectInfo { // When a primary session is transferred to another session:
delete(sm.reconnectInfo, oldSessionID) // 1. The newly promoted session should be able to refresh its browser without losing primary
} // 2. When it refreshes, RemoveSession is called, which adds a grace period
} // 3. When it reconnects, it should find itself in lastPrimaryID and reclaim primary
//
// The blacklist prevents the OLD primary from immediately reclaiming control,
// while the grace period allows the NEW primary to safely refresh its browser.
// These mechanisms complement each other and should not interfere.
sm.logger.Info(). sm.logger.Info().
Str("fromSessionID", fromSessionID). Str("fromSessionID", fromSessionID).
@ -1880,14 +1703,12 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
// Run validation immediately if a grace period expired, otherwise run periodically // Run validation immediately if a grace period expired, otherwise run periodically
if gracePeriodExpired { if gracePeriodExpired {
sm.logger.Debug().Msg("Running immediate validation after grace period expiration")
sm.validateSinglePrimary() sm.validateSinglePrimary()
} else { } else {
// Periodic validateSinglePrimary to catch deadlock states // Periodic validateSinglePrimary to catch deadlock states
validationCounter++ validationCounter++
if validationCounter >= 10 { // Every 10 seconds if validationCounter >= 10 { // Every 10 seconds
validationCounter = 0 validationCounter = 0
sm.logger.Debug().Msg("Running periodic session validation to catch deadlock states")
sm.validateSinglePrimary() sm.validateSinglePrimary()
} }
} }
@ -1911,11 +1732,6 @@ var (
func initSessionManager() { func initSessionManager() {
sessionManagerOnce.Do(func() { sessionManagerOnce.Do(func() {
sessionManager = NewSessionManager(websocketLogger) sessionManager = NewSessionManager(websocketLogger)
if sessionManager != nil && websocketLogger != nil {
websocketLogger.Error().
Str("pointer", fmt.Sprintf("%p", sessionManager)).
Msg("!!! GLOBAL sessionManager VARIABLE INITIALIZED - THIS SHOULD ONLY HAPPEN ONCE !!!")
}
}) })
} }

View File

@ -78,14 +78,6 @@ func incrActiveSessions() int {
return actionSessions return actionSessions
} }
func decrActiveSessions() int {
activeSessionsMutex.Lock()
defer activeSessionsMutex.Unlock()
actionSessions--
return actionSessions
}
func getActiveSessions() int { func getActiveSessions() int {
activeSessionsMutex.Lock() activeSessionsMutex.Lock()
defer activeSessionsMutex.Unlock() defer activeSessionsMutex.Unlock()