fix: prevent session timeout when jiggler is active

The jiggler sends keep-alive packets every 50ms to prevent keyboard
auto-release, but wasn't updating the session's LastActive timestamp.
This caused the backend to timeout and demote the primary session after
5 minutes (default primaryTimeout), even with active jiggler.

Primary fix:
- Add UpdateLastActive call to handleHidRPCKeepressKeepAlive() in hidrpc.go
- Ensures jiggler packets prevent session timeout

Defensive enhancement:
- Add WebSocket fallback for emergency promotion signals in session_manager.go
- Store WebSocket reference in Session struct (webrtc.go)
- Handle connectionModeChanged via WebSocket in devices.$id.tsx
- Provides reliable signaling when WebRTC data channel is stale
This commit is contained in:
Alex P 2025-10-13 13:10:12 +03:00
parent 5a0100478b
commit 8d51aaa8eb
4 changed files with 52 additions and 18 deletions

View File

@ -134,14 +134,15 @@ const baseExtension = expectedRate + maxLateness // 100ms extension on perfect t
const maxStaleness = 225 * time.Millisecond // discard ancient packets outright
func handleHidRPCKeypressKeepAlive(session *Session) error {
// Update LastActive to prevent session timeout (jiggler sends every 50ms)
sessionManager.UpdateLastActive(session.ID)
session.keepAliveJitterLock.Lock()
defer session.keepAliveJitterLock.Unlock()
now := time.Now()
// 1) Staleness guard: ensures packets that arrive far beyond the life of a valid key hold
// (e.g. after a network stall, retransmit burst, or machine sleep) are ignored outright.
// This prevents “zombie” keepalives from reviving a key that should already be released.
// Staleness guard: discard ancient packets after network stall/machine sleep
if !session.lastTimerResetTime.IsZero() && now.Sub(session.lastTimerResetTime) > maxStaleness {
return nil
}

View File

@ -1159,28 +1159,26 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf
// The caller (AddSession, RemoveSession, etc.) will validate after we return
// sm.validateSinglePrimary() // REMOVED to prevent recursion
// Handle WebRTC connection state for promoted sessions
// When a session changes from observer to primary, the existing WebRTC connection
// was established for observer mode and needs to be re-negotiated for primary mode
// Send reconnection signal for emergency promotions via WebSocket (more reliable than RPC when channel is stale)
if toExists && (transferType == "emergency_timeout_promotion" || transferType == "emergency_auto_promotion") {
go func() {
// Small delay to ensure session mode changes are committed
time.Sleep(100 * time.Millisecond)
// Send connection reset signal to the promoted session
writeJSONRPCEvent("connectionModeChanged", map[string]interface{}{
eventData := map[string]interface{}{
"sessionId": toSessionID,
"newMode": string(toSession.Mode),
"reason": "session_promotion",
"action": "reconnect_required",
"timestamp": time.Now().Unix(),
}, toSession)
}
sm.logger.Info().
Str("sessionId", toSessionID).
Str("newMode", string(toSession.Mode)).
Str("transferType", transferType).
Msg("Sent WebRTC reconnection signal to promoted session")
err := toSession.sendWebSocketSignal("connectionModeChanged", eventData)
if err != nil {
sm.logger.Warn().Err(err).Str("sessionId", toSessionID).Msg("WebSocket signal failed, using RPC")
writeJSONRPCEvent("connectionModeChanged", eventData, toSession)
}
sm.logger.Info().Str("sessionId", toSessionID).Str("transferType", transferType).Msg("Sent reconnection signal")
}()
}

View File

@ -395,6 +395,24 @@ export default function KvmIdRoute() {
peerConnection.addIceCandidate(candidate).catch(error => {
console.warn("[Websocket] Failed to add ICE candidate:", error);
});
} else if (parsedMessage.type === "connectionModeChanged") {
// Handle mode changes via WebSocket (fallback when RPC channel stale)
const { newMode, action } = parsedMessage.data;
if (action === "reconnect_required" && newMode) {
console.log(`[Websocket] Mode changed to ${newMode}, reconnecting...`);
if (currentSessionId) {
setCurrentSession(currentSessionId, newMode);
}
handleRpcEvent("connectionModeChanged", parsedMessage.data);
setTimeout(() => {
peerConnection?.close();
setupPeerConnection();
}, 500);
}
}
},
},

View File

@ -51,9 +51,9 @@ type Session struct {
RPCChannel *webrtc.DataChannel
HidChannel *webrtc.DataChannel
shouldUmountVirtualMedia bool
flushCandidates func() // Callback to flush buffered ICE candidates
rpcQueue chan webrtc.DataChannelMessage
flushCandidates func() // Callback to flush buffered ICE candidates
ws *websocket.Conn // WebSocket for critical signaling when RPC unavailable
rpcQueue chan webrtc.DataChannelMessage
hidRPCAvailable bool
lastKeepAliveArrivalTime time.Time // Track when last keep-alive packet arrived
@ -116,6 +116,22 @@ func (s *Session) resetKeepAliveTime() {
s.lastTimerResetTime = time.Time{} // Reset auto-release timer tracking
}
// sendWebSocketSignal sends critical state changes via WebSocket (fallback when RPC channel stale)
func (s *Session) sendWebSocketSignal(messageType string, data map[string]interface{}) error {
if s == nil || s.ws == nil {
return nil
}
err := wsjson.Write(context.Background(), s.ws, gin.H{"type": messageType, "data": data})
if err != nil {
webrtcLogger.Debug().Err(err).Str("sessionId", s.ID).Msg("Failed to send WebSocket signal")
return err
}
webrtcLogger.Info().Str("sessionId", s.ID).Str("messageType", messageType).Msg("Sent WebSocket signal")
return nil
}
type hidQueueMessage struct {
webrtc.DataChannelMessage
channel string
@ -299,6 +315,7 @@ func newSession(config SessionConfig) (*Session, error) {
session := &Session{
peerConnection: peerConnection,
Browser: extractBrowserFromUserAgent(config.UserAgent),
ws: config.ws,
}
session.rpcQueue = make(chan webrtc.DataChannelMessage, 256)
session.initQueues()