mirror of https://github.com/jetkvm/kvm.git
feat: add configurable max sessions and observer cleanup timeout
Add two new configurable session settings to improve multi-session management: 1. Maximum Concurrent Sessions (1-20, default: 10) - Controls the maximum number of simultaneous connections - Configurable via settings UI with validation - Applied in session manager during session creation 2. Observer Cleanup Timeout (30-600 seconds, default: 120) - Automatically removes inactive observer sessions with closed RPC channels - Prevents accumulation of zombie observer sessions - Runs during periodic cleanup checks - Configurable timeout displayed in minutes in UI Backend changes: - Add MaxSessions and ObserverTimeout fields to SessionSettings struct - Update setSessionSettings RPC handler to persist new settings - Implement observer cleanup logic in cleanupInactiveSessions - Apply maxSessions limit in NewSessionManager with proper fallback chain Frontend changes: - Add numeric input controls for both settings in multi-session settings page - Include validation and user-friendly error messages - Display friendly units (sessions, seconds/minutes) - Maintain consistent styling with existing settings Also includes defensive nil checks in writeJSONRPCEvent to prevent "No HDMI Signal" errors when RPC channels close during reconnection.
This commit is contained in:
parent
16509188b0
commit
5a0100478b
21
jsonrpc.go
21
jsonrpc.go
|
|
@ -81,6 +81,11 @@ func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSONRPCEvent(event string, params any, session *Session) {
|
func writeJSONRPCEvent(event string, params any, session *Session) {
|
||||||
|
// Defensive checks: skip if session or RPC channel is not ready
|
||||||
|
if session == nil || session.RPCChannel == nil {
|
||||||
|
return // Channel not ready or already closed - this is expected during cleanup
|
||||||
|
}
|
||||||
|
|
||||||
request := JSONRPCEvent{
|
request := JSONRPCEvent{
|
||||||
JSONRPC: "2.0",
|
JSONRPC: "2.0",
|
||||||
Method: event,
|
Method: event,
|
||||||
|
|
@ -91,10 +96,6 @@ func writeJSONRPCEvent(event string, params any, session *Session) {
|
||||||
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC event")
|
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC event")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if session == nil || session.RPCChannel == nil {
|
|
||||||
jsonRpcLogger.Info().Msg("RPC channel not available")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
requestString := string(requestBytes)
|
requestString := string(requestBytes)
|
||||||
scopedLogger := jsonRpcLogger.With().
|
scopedLogger := jsonRpcLogger.With().
|
||||||
|
|
@ -105,7 +106,8 @@ func writeJSONRPCEvent(event string, params any, session *Session) {
|
||||||
|
|
||||||
err = session.RPCChannel.SendText(requestString)
|
err = session.RPCChannel.SendText(requestString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
scopedLogger.Warn().Err(err).Msg("error sending JSONRPC event")
|
// Only log at debug level - closed pipe errors are expected during reconnection
|
||||||
|
scopedLogger.Debug().Err(err).Str("event", event).Msg("Could not send JSONRPC event (channel may be closing)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -327,6 +329,15 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||||
if privateKeystrokes, ok := settings["privateKeystrokes"].(bool); ok {
|
if privateKeystrokes, ok := settings["privateKeystrokes"].(bool); ok {
|
||||||
currentSessionSettings.PrivateKeystrokes = privateKeystrokes
|
currentSessionSettings.PrivateKeystrokes = privateKeystrokes
|
||||||
}
|
}
|
||||||
|
if maxRejectionAttempts, ok := settings["maxRejectionAttempts"].(float64); ok {
|
||||||
|
currentSessionSettings.MaxRejectionAttempts = int(maxRejectionAttempts)
|
||||||
|
}
|
||||||
|
if maxSessions, ok := settings["maxSessions"].(float64); ok {
|
||||||
|
currentSessionSettings.MaxSessions = int(maxSessions)
|
||||||
|
}
|
||||||
|
if observerTimeout, ok := settings["observerTimeout"].(float64); ok {
|
||||||
|
currentSessionSettings.ObserverTimeout = int(observerTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger nickname auto-generation for sessions when RequireNickname changes
|
// Trigger nickname auto-generation for sessions when RequireNickname changes
|
||||||
if sessionManager != nil {
|
if sessionManager != nil {
|
||||||
|
|
|
||||||
|
|
@ -102,9 +102,14 @@ func NewSessionManager(logger *zerolog.Logger) *SessionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override with session settings if available
|
// Override with session settings if available
|
||||||
if currentSessionSettings != nil && currentSessionSettings.PrimaryTimeout > 0 {
|
if currentSessionSettings != nil {
|
||||||
|
if currentSessionSettings.PrimaryTimeout > 0 {
|
||||||
primaryTimeout = time.Duration(currentSessionSettings.PrimaryTimeout) * time.Second
|
primaryTimeout = time.Duration(currentSessionSettings.PrimaryTimeout) * time.Second
|
||||||
}
|
}
|
||||||
|
if currentSessionSettings.MaxSessions > 0 {
|
||||||
|
maxSessions = currentSessionSettings.MaxSessions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sm := &SessionManager{
|
sm := &SessionManager{
|
||||||
sessions: make(map[string]*Session),
|
sessions: make(map[string]*Session),
|
||||||
|
|
@ -1674,6 +1679,27 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up observer sessions with closed RPC channels (stale connections)
|
||||||
|
// This prevents accumulation of zombie observer sessions that are no longer connected
|
||||||
|
observerTimeout := 2 * time.Minute // Default: 2 minutes
|
||||||
|
if currentSessionSettings != nil && currentSessionSettings.ObserverTimeout > 0 {
|
||||||
|
observerTimeout = time.Duration(currentSessionSettings.ObserverTimeout) * time.Second
|
||||||
|
}
|
||||||
|
for id, session := range sm.sessions {
|
||||||
|
if session.Mode == SessionModeObserver {
|
||||||
|
// Check if RPC channel is nil/closed AND session has been inactive
|
||||||
|
if session.RPCChannel == nil && now.Sub(session.LastActive) > observerTimeout {
|
||||||
|
sm.logger.Info().
|
||||||
|
Str("sessionId", id).
|
||||||
|
Dur("inactiveFor", now.Sub(session.LastActive)).
|
||||||
|
Dur("observerTimeout", observerTimeout).
|
||||||
|
Msg("Removing inactive observer session with closed RPC channel")
|
||||||
|
delete(sm.sessions, id)
|
||||||
|
needsBroadcast = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check primary session timeout (every 30 iterations = 30 seconds)
|
// Check primary session timeout (every 30 iterations = 30 seconds)
|
||||||
if sm.primarySessionID != "" {
|
if sm.primarySessionID != "" {
|
||||||
if primary, exists := sm.sessions[sm.primarySessionID]; exists {
|
if primary, exists := sm.sessions[sm.primarySessionID]; exists {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ export default function SessionsSettings() {
|
||||||
const [reconnectGrace, setReconnectGrace] = useState(10);
|
const [reconnectGrace, setReconnectGrace] = useState(10);
|
||||||
const [primaryTimeout, setPrimaryTimeout] = useState(300);
|
const [primaryTimeout, setPrimaryTimeout] = useState(300);
|
||||||
const [privateKeystrokes, setPrivateKeystrokes] = useState(false);
|
const [privateKeystrokes, setPrivateKeystrokes] = useState(false);
|
||||||
|
const [maxSessions, setMaxSessions] = useState(10);
|
||||||
|
const [observerTimeout, setObserverTimeout] = useState(120);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getSessionSettings", {}, (response: JsonRpcResponse) => {
|
send("getSessionSettings", {}, (response: JsonRpcResponse) => {
|
||||||
|
|
@ -43,6 +45,8 @@ export default function SessionsSettings() {
|
||||||
primaryTimeout?: number;
|
primaryTimeout?: number;
|
||||||
privateKeystrokes?: boolean;
|
privateKeystrokes?: boolean;
|
||||||
maxRejectionAttempts?: number;
|
maxRejectionAttempts?: number;
|
||||||
|
maxSessions?: number;
|
||||||
|
observerTimeout?: number;
|
||||||
};
|
};
|
||||||
setRequireSessionApproval(settings.requireApproval);
|
setRequireSessionApproval(settings.requireApproval);
|
||||||
setRequireSessionNickname(settings.requireNickname);
|
setRequireSessionNickname(settings.requireNickname);
|
||||||
|
|
@ -58,6 +62,12 @@ export default function SessionsSettings() {
|
||||||
if (settings.maxRejectionAttempts !== undefined) {
|
if (settings.maxRejectionAttempts !== undefined) {
|
||||||
setMaxRejectionAttempts(settings.maxRejectionAttempts);
|
setMaxRejectionAttempts(settings.maxRejectionAttempts);
|
||||||
}
|
}
|
||||||
|
if (settings.maxSessions !== undefined) {
|
||||||
|
setMaxSessions(settings.maxSessions);
|
||||||
|
}
|
||||||
|
if (settings.observerTimeout !== undefined) {
|
||||||
|
setObserverTimeout(settings.observerTimeout);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [send, setRequireSessionApproval, setRequireSessionNickname, setMaxRejectionAttempts]);
|
}, [send, setRequireSessionApproval, setRequireSessionNickname, setMaxRejectionAttempts]);
|
||||||
|
|
@ -69,6 +79,8 @@ export default function SessionsSettings() {
|
||||||
primaryTimeout: number;
|
primaryTimeout: number;
|
||||||
privateKeystrokes: boolean;
|
privateKeystrokes: boolean;
|
||||||
maxRejectionAttempts: number;
|
maxRejectionAttempts: number;
|
||||||
|
maxSessions: number;
|
||||||
|
observerTimeout: number;
|
||||||
}>) => {
|
}>) => {
|
||||||
if (!canModifySettings) {
|
if (!canModifySettings) {
|
||||||
notify.error("Only the primary session can change this setting");
|
notify.error("Only the primary session can change this setting");
|
||||||
|
|
@ -83,6 +95,8 @@ export default function SessionsSettings() {
|
||||||
primaryTimeout: primaryTimeout,
|
primaryTimeout: primaryTimeout,
|
||||||
privateKeystrokes: privateKeystrokes,
|
privateKeystrokes: privateKeystrokes,
|
||||||
maxRejectionAttempts: maxRejectionAttempts,
|
maxRejectionAttempts: maxRejectionAttempts,
|
||||||
|
maxSessions: maxSessions,
|
||||||
|
observerTimeout: observerTimeout,
|
||||||
...updates
|
...updates
|
||||||
}
|
}
|
||||||
}, (response: JsonRpcResponse) => {
|
}, (response: JsonRpcResponse) => {
|
||||||
|
|
@ -248,6 +262,65 @@ export default function SessionsSettings() {
|
||||||
</div>
|
</div>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title="Maximum Concurrent Sessions"
|
||||||
|
description="Maximum number of sessions that can connect simultaneously"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
value={maxSessions}
|
||||||
|
disabled={!canModifySettings}
|
||||||
|
onChange={e => {
|
||||||
|
const newValue = parseInt(e.target.value) || 10;
|
||||||
|
if (newValue < 1 || newValue > 20) {
|
||||||
|
notify.error("Max sessions must be between 1 and 20");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMaxSessions(newValue);
|
||||||
|
updateSessionSettings({ maxSessions: newValue });
|
||||||
|
notify.success(
|
||||||
|
`Maximum concurrent sessions set to ${newValue}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="w-20 px-2 py-1.5 border rounded-md bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">sessions</span>
|
||||||
|
</div>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title="Observer Cleanup Timeout"
|
||||||
|
description="Time to wait before cleaning up inactive observer sessions with closed connections"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="30"
|
||||||
|
max="600"
|
||||||
|
step="30"
|
||||||
|
value={observerTimeout}
|
||||||
|
disabled={!canModifySettings}
|
||||||
|
onChange={e => {
|
||||||
|
const newValue = parseInt(e.target.value) || 120;
|
||||||
|
if (newValue < 30 || newValue > 600) {
|
||||||
|
notify.error("Timeout must be between 30 and 600 seconds");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setObserverTimeout(newValue);
|
||||||
|
updateSessionSettings({ observerTimeout: newValue });
|
||||||
|
notify.success(
|
||||||
|
`Observer cleanup timeout set to ${Math.round(newValue / 60)} minute${Math.round(newValue / 60) === 1 ? '' : 's'}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="w-20 px-2 py-1.5 border rounded-md bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">seconds</span>
|
||||||
|
</div>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
title="Private Keystrokes"
|
title="Private Keystrokes"
|
||||||
description="When enabled, only the primary session can see keystroke events"
|
description="When enabled, only the primary session can see keystroke events"
|
||||||
|
|
|
||||||
4
web.go
4
web.go
|
|
@ -50,7 +50,9 @@ type SessionSettings struct {
|
||||||
PrimaryTimeout int `json:"primaryTimeout,omitempty"` // Inactivity timeout in seconds for primary session
|
PrimaryTimeout int `json:"primaryTimeout,omitempty"` // Inactivity timeout in seconds for primary session
|
||||||
Nickname string `json:"nickname,omitempty"`
|
Nickname string `json:"nickname,omitempty"`
|
||||||
PrivateKeystrokes bool `json:"privateKeystrokes,omitempty"` // If true, only primary session sees keystroke events
|
PrivateKeystrokes bool `json:"privateKeystrokes,omitempty"` // If true, only primary session sees keystroke events
|
||||||
MaxRejectionAttempts int `json:"maxRejectionAttempts,omitempty"`
|
MaxRejectionAttempts int `json:"maxRejectionAttempts,omitempty"` // Number of times denied session can retry before modal hides
|
||||||
|
MaxSessions int `json:"maxSessions,omitempty"` // Maximum number of concurrent sessions (default: 10)
|
||||||
|
ObserverTimeout int `json:"observerTimeout,omitempty"` // Time in seconds to wait before cleaning up inactive observer sessions (default: 120)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SetPasswordRequest struct {
|
type SetPasswordRequest struct {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue