From 887662b0ad5dba1b3b71d0ad6c9ac55e9a50aa03 Mon Sep 17 00:00:00 2001 From: Matt McClaskey Date: Mon, 3 Nov 2025 13:03:43 -0500 Subject: [PATCH] VNC-275 implement explicit keepalive (#172) * VNC-275 implement explicit keepalive * VNC-275 graceful disconnect --------- Co-authored-by: Matt McClaskey --- app/ui.js | 8 +++- core/encodings.js | 1 + core/rfb.js | 98 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/app/ui.js b/app/ui.js index ef5a2245..7c50f576 100644 --- a/app/ui.js +++ b/app/ui.js @@ -1579,7 +1579,7 @@ const UI = { }, 10000); } else { //send keep-alive - UI.rfb.sendKey(1, null, false); + UI.rfb.sendKeepAlive(); } } }, 5000); @@ -1856,7 +1856,11 @@ const UI = { }, disconnectedRx(event) { - parent.postMessage({ action: 'disconnectrx', value: event.detail.reason}, '*' ); + const detail = event.detail || {}; + if (detail.serverNotice && detail.serverNotice.graceful) { + return; + } + parent.postMessage({ action: 'disconnectrx', value: detail.reason}, '*' ); }, toggleNav(){ diff --git a/core/encodings.js b/core/encodings.js index af2f4493..a4d5f354 100644 --- a/core/encodings.js +++ b/core/encodings.js @@ -54,6 +54,7 @@ export const encodings = { pseudoEncodingVideoOutTimeLevel1: -1986, pseudoEncodingVideoOutTimeLevel100: -1887, pseudoEncodingQOI: -1886, + pseudoEncodingKasmDisconnectNotify: -1885, pseudoEncodingVMwareCursor: 0x574d5664, pseudoEncodingVMwareCursorPosition: 0x574d5666, diff --git a/core/rfb.js b/core/rfb.js index 73e6bc80..58de0fe6 100644 --- a/core/rfb.js +++ b/core/rfb.js @@ -43,6 +43,8 @@ import { toSignedRelative16bit } from './util/int.js'; // How many seconds to wait for a disconnect to finish const DISCONNECT_TIMEOUT = 3; const DEFAULT_BACKGROUND = 'rgb(40, 40, 40)'; +const CLIENT_MSG_TYPE_KEEPALIVE = 184; +const SERVER_MSG_TYPE_DISCONNECT_NOTIFY = 185; // Minimum wait (ms) between two mouse moves const MOUSE_MOVE_DELAY = 17; @@ -105,6 +107,10 @@ export default class RFB extends EventTargetMixin { this._rfbInitState = ''; this._rfbAuthScheme = -1; this._rfbCleanDisconnect = true; + this._disconnectReason = null; + this._disconnectCode = null; + this._serverDisconnectNotice = null; + this._lastServerDisconnectNotice = null; // Server capabilities this._rfbVersion = 0; @@ -996,6 +1002,16 @@ export default class RFB extends EventTargetMixin { } } + sendKeepAlive() { + if (this._rfbConnectionState !== 'connected') { return; } + + if (this._isPrimaryDisplay) { + RFB.messages.keepAlive(this._sock); + } else { + this._proxyRFBMessage('keepAlive', []); + } + } + focus() { this._keyboard.focus(); } @@ -1178,7 +1194,26 @@ export default class RFB extends EventTargetMixin { } msg += ")"; } - switch (this._rfbConnectionState) { + if (typeof e.code === 'number') { + this._disconnectCode = e.code; + } + + if (e.reason) { + this._disconnectReason = e.reason; + } + + if (this._serverDisconnectNotice) { + const notice = this._serverDisconnectNotice; + if (notice.reason && !this._disconnectReason) { + this._disconnectReason = notice.reason; + } + this._rfbCleanDisconnect = !!notice.graceful; + this._lastServerDisconnectNotice = notice; + this._serverDisconnectNotice = null; + } else if (e.wasClean === false || e.code === 1006) { + this._rfbCleanDisconnect = false; + } + switch (this._rfbConnectionState) { case 'connecting': this._fail("Connection closed " + msg); break; @@ -1723,6 +1758,14 @@ export default class RFB extends EventTargetMixin { Log.Debug("New state '" + state + "', was '" + oldstate + "'."); + if (state === 'connecting') { + this._disconnectReason = null; + this._disconnectCode = null; + this._serverDisconnectNotice = null; + this._lastServerDisconnectNotice = null; + this._rfbCleanDisconnect = true; + } + if (this._disconnTimer && state !== 'disconnecting') { Log.Debug("Clearing disconnect timer"); clearTimeout(this._disconnTimer); @@ -1756,7 +1799,13 @@ export default class RFB extends EventTargetMixin { case 'disconnected': this.dispatchEvent(new CustomEvent( "disconnect", { detail: - { clean: this._rfbCleanDisconnect } })); + { clean: this._rfbCleanDisconnect, + reason: this._disconnectReason, + code: this._disconnectCode, + serverNotice: this._lastServerDisconnectNotice } })); + this._disconnectReason = null; + this._disconnectCode = null; + this._lastServerDisconnectNotice = null; break; } } @@ -1781,6 +1830,9 @@ export default class RFB extends EventTargetMixin { Log.Error("RFB failure: " + details); break; } + this._disconnectReason = details; + this._disconnectCode = null; + this._serverDisconnectNotice = null; this._rfbCleanDisconnect = false; //This is sent to the UI // Transition to disconnected without waiting for socket to close @@ -1890,6 +1942,9 @@ export default class RFB extends EventTargetMixin { RFB.messages.pointerEvent(this._sock, this._mousePos.x, this._mousePos.y, 0, event.data.args[2], event.data.args[3]); this._setLastActive(); break; + case 'keepAlive': + RFB.messages.keepAlive(this._sock); + break; case 'keyEvent': RFB.messages.keyEvent(this._sock, ...event.data.args); this._setLastActive(); @@ -3163,6 +3218,7 @@ export default class RFB extends EventTargetMixin { encs.push(encodings.pseudoEncodingContinuousUpdates); encs.push(encodings.pseudoEncodingDesktopName); encs.push(encodings.pseudoEncodingExtendedClipboard); + encs.push(encodings.pseudoEncodingKasmDisconnectNotify); if (this._hasWebp()) encs.push(encodings.pseudoEncodingWEBP); if (this._enableQOI) @@ -3692,6 +3748,8 @@ export default class RFB extends EventTargetMixin { case 183: // KASM unix relay data return this._handleUnixRelay(); + case SERVER_MSG_TYPE_DISCONNECT_NOTIFY: // KASM disconnect notice + return this._handleDisconnectNotify(); case 248: // ServerFence return this._handleServerFenceMsg(); @@ -3857,6 +3915,32 @@ export default class RFB extends EventTargetMixin { processRelay && processRelay(payload); } + _handleDisconnectNotify() { + if (this._sock.rQwait("DisconnectNotify header", 8, 1)) { return false; } + const flags = this._sock.rQshift8(); + this._sock.rQskipBytes(3); + const reasonLength = this._sock.rQshift32(); + if (reasonLength > 0 && this._sock.rQwait("DisconnectNotify reason", reasonLength, 8)) { return false; } + + let reason = null; + if (reasonLength > 0) { + reason = this._sock.rQshiftStr(reasonLength); + } + + const graceful = (flags & 0x1) !== 0; + this._serverDisconnectNotice = { flags, reason, graceful }; + + if (reason !== null) { + this._disconnectReason = reason; + } + + if (graceful) { + this._rfbCleanDisconnect = true; + } + + return true; + } + _framebufferUpdate() { if (this._FBU.rects === 0) { if (this._sock.rQwait("FBU header", 3, 1)) { return false; } @@ -4425,6 +4509,16 @@ RFB.messages = { sock.flush(); }, + keepAlive(sock) { + const buff = sock._sQ; + const offset = sock._sQlen; + + buff[offset] = CLIENT_MSG_TYPE_KEEPALIVE; + + sock._sQlen += 1; + sock.flush(); + }, + // Used to build Notify and Request data. _buildExtendedClipboardFlags(actions, formats) { let data = new Uint8Array(4);