This is an automated email from the ASF dual-hosted git repository.

nvazquez pushed a commit to branch 4.20
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to refs/heads/4.20 by this push:
     new e6f193db80b Backport: Honour consoleproxy.session.timeout for noVNC 
console sessions (#13058)
e6f193db80b is described below

commit e6f193db80b5f355e01a22414a3658687a9b0986
Author: Dheeraj Bansal <[email protected]>
AuthorDate: Tue Nov 10 02:16:01 2026 +0530

    Backport: Honour consoleproxy.session.timeout for noVNC console sessions 
(#13058)
    
    * Fix console proxy idle timeout and noVNC session handling
    
    * Honor consoleproxy.session.timeout in console proxy GC
    
    * Apply suggestion
    
    * Apply suggestion
    
    * Apply suggestions from code review
    
    Co-authored-by: Copilot <[email protected]>
    Co-authored-by: dahn <[email protected]>
    
    * Apply suggestion
    
    * Apply suggestions
    
    Co-authored-by: dahn <[email protected]>
    
    * half the white lines removed
    
    Co-authored-by: dahn <[email protected]>
    
    * white lines
    
    Co-authored-by: dahn <[email protected]>
    
    * more white lines
    
    Co-authored-by: dahn <[email protected]>
    
    * Apply suggestions from code review
    
    Co-authored-by: dahn <[email protected]>
    
    * Forward console proxy session timeout to CPVM
    
    * Fix idle timeout tracking for noVNC sessions
    
    * Fix noVNC console session activity tracking
    
    * Add test for noVNC activity timestamp
    
    * Close noVNC WebSocket when console session times out
    
    ---------
    
    Co-authored-by: dahn <[email protected]>
    Co-authored-by: Copilot <[email protected]>
---
 .../consoleproxy/ConsoleProxyManagerImpl.java      |   1 +
 .../java/com/cloud/consoleproxy/ConsoleProxy.java  | 148 +++++++++++++--------
 .../cloud/consoleproxy/ConsoleProxyGCThread.java   |  41 +++---
 .../consoleproxy/ConsoleProxyNoVNCHandler.java     |  62 +++++++--
 .../consoleproxy/ConsoleProxyNoVncClient.java      |  16 ++-
 .../consoleproxy/ConsoleProxyNoVncClientTest.java  |  27 ++++
 systemvm/agent/noVNC/core/rfb.js                   |   4 +
 systemvm/agent/noVNC/core/websock.js               |  15 +++
 8 files changed, 224 insertions(+), 90 deletions(-)

diff --git 
a/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyManagerImpl.java 
b/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyManagerImpl.java
index f1e422ec36c..f9b2912b83d 100644
--- a/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyManagerImpl.java
+++ b/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyManagerImpl.java
@@ -1259,6 +1259,7 @@ public class ConsoleProxyManagerImpl extends ManagerBase 
implements ConsoleProxy
         buf.append(" pod=").append(dest.getPod().getId());
         buf.append(" guid=Proxy.").append(profile.getId());
         buf.append(" proxy_vm=").append(profile.getId());
+        buf.append(" 
consoleproxy.session.timeout=").append(proxySessionTimeoutValue);
         if (disableRpFilter) {
             buf.append(" disable_rp_filter=true");
         }
diff --git 
a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxy.java
 
b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxy.java
index 0befe392b4e..99e9e30e092 100644
--- 
a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxy.java
+++ 
b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxy.java
@@ -36,6 +36,8 @@ import java.util.concurrent.Executor;
 
 import org.apache.commons.lang3.ArrayUtils;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
 import org.apache.logging.log4j.core.config.Configurator;
 import org.eclipse.jetty.websocket.api.Session;
 
@@ -43,8 +45,6 @@ import com.cloud.utils.PropertiesUtil;
 import com.google.gson.Gson;
 import com.sun.net.httpserver.HttpServer;
 
-import org.apache.logging.log4j.LogManager;
-import org.apache.logging.log4j.Logger;
 
 /**
  *
@@ -58,6 +58,11 @@ public class ConsoleProxy {
 
     public static final int VIEWER_LINGER_SECONDS = 180;
 
+    // New: default and effective session timeout (milliseconds) honoured from 
consoleproxy.session.timeout
+    public static final long DEFAULT_SESSION_TIMEOUT_MILLIS = 300000;
+    public static volatile long sessionTimeoutMillis = 
DEFAULT_SESSION_TIMEOUT_MILLIS;
+
+
     public static Object context;
 
     // this has become more ugly, to store keystore info passed from 
management server (we now use management server managed keystore to support
@@ -82,7 +87,8 @@ public class ConsoleProxy {
     static boolean standaloneStart = false;
 
     static String encryptorPassword = "Dummy";
-    static final String[] skipProperties = new String[]{"certificate", 
"cacertificate", "keystore_password", "privatekey"};
+    static final String[] skipProperties = new String[] {"certificate", 
"cacertificate", "keystore_password", "privatekey"};
+
 
     static Set<String> allowedSessions = new HashSet<>();
 
@@ -93,11 +99,13 @@ public class ConsoleProxy {
     private static void configLog4j() {
         final ClassLoader loader = 
Thread.currentThread().getContextClassLoader();
         URL configUrl = loader.getResource("/conf/log4j-cloud.xml");
-        if (configUrl == null)
+        if (configUrl == null) {
             configUrl = ClassLoader.getSystemResource("log4j-cloud.xml");
+        }
 
-        if (configUrl == null)
+        if (configUrl == null) {
             configUrl = ClassLoader.getSystemResource("conf/log4j-cloud.xml");
+        }
 
         if (configUrl != null) {
             try {
@@ -114,7 +122,6 @@ public class ConsoleProxy {
             } catch (URISyntaxException e) {
                 System.out.println("Unable to convert log4j configuration Url 
to URI");
             }
-            // DOMConfigurator.configure(configUrl);
         } else {
             System.out.println("Configure log4j with default properties");
         }
@@ -122,20 +129,21 @@ public class ConsoleProxy {
 
     private static void configProxy(Properties conf) {
         LOGGER.info("Configure console proxy...");
-        for (Object key : conf.keySet()) {
-            LOGGER.info("Property " + (String)key + ": " + 
conf.getProperty((String)key));
-            if (!ArrayUtils.contains(skipProperties, key)) {
-                LOGGER.info("Property " + (String)key + ": " + 
conf.getProperty((String)key));
+        if (conf != null) {
+            for (Object key : conf.keySet()) {
+                if (!ArrayUtils.contains(skipProperties, key)) {
+                    LOGGER.info("Property " + (String) key + ": " + 
conf.getProperty((String) key));
+                }
             }
         }
 
-        String s = conf.getProperty("consoleproxy.httpListenPort");
+        String s = conf != null ? 
conf.getProperty("consoleproxy.httpListenPort") : null;
         if (s != null) {
             httpListenPort = Integer.parseInt(s);
             LOGGER.info("Setting httpListenPort=" + s);
         }
 
-        s = conf.getProperty("premium");
+        s = conf != null ? conf.getProperty("premium") : null;
         if (s != null && s.equalsIgnoreCase("true")) {
             LOGGER.info("Premium setting will override settings from 
consoleproxy.properties, listen at port 443");
             httpListenPort = 443;
@@ -144,36 +152,55 @@ public class ConsoleProxy {
             factoryClzName = ConsoleProxyBaseServerFactoryImpl.class.getName();
         }
 
-        s = conf.getProperty("consoleproxy.httpCmdListenPort");
+        s = conf != null ? conf.getProperty("consoleproxy.httpCmdListenPort") 
: null;
         if (s != null) {
             httpCmdListenPort = Integer.parseInt(s);
             LOGGER.info("Setting httpCmdListenPort=" + s);
         }
 
-        s = conf.getProperty("consoleproxy.reconnectMaxRetry");
+        s = conf != null ? conf.getProperty("consoleproxy.reconnectMaxRetry") 
: null;
         if (s != null) {
             reconnectMaxRetry = Integer.parseInt(s);
             LOGGER.info("Setting reconnectMaxRetry=" + reconnectMaxRetry);
         }
 
-        s = conf.getProperty("consoleproxy.readTimeoutSeconds");
+        s = conf != null ? conf.getProperty("consoleproxy.readTimeoutSeconds") 
: null;
         if (s != null) {
             readTimeoutSeconds = Integer.parseInt(s);
             LOGGER.info("Setting readTimeoutSeconds=" + readTimeoutSeconds);
         }
 
-        s = conf.getProperty("consoleproxy.defaultBufferSize");
+        s = conf != null ? conf.getProperty("consoleproxy.defaultBufferSize") 
: null;
         if (s != null) {
             defaultBufferSize = Integer.parseInt(s);
             LOGGER.info("Setting defaultBufferSize=" + defaultBufferSize);
         }
+
+        s = conf != null ? conf.getProperty("consoleproxy.session.timeout") : 
null;
+        if (s != null) {
+            try {
+                long value = Long.parseLong(s);
+                if (value <= 0) {
+                    LOGGER.warn("consoleproxy.session.timeout={} is <= 0, 
using default {} ms",
+                            value, DEFAULT_SESSION_TIMEOUT_MILLIS);
+                    sessionTimeoutMillis = DEFAULT_SESSION_TIMEOUT_MILLIS;
+                } else {
+                    sessionTimeoutMillis = value;
+                }
+            } catch (NumberFormatException e) {
+                LOGGER.warn("Invalid value for consoleproxy.session.timeout: 
{}, using default {} ms",
+                        s, DEFAULT_SESSION_TIMEOUT_MILLIS, e);
+                sessionTimeoutMillis = DEFAULT_SESSION_TIMEOUT_MILLIS;
+            }
+        }
+        LOGGER.info("Effective consoleproxy.session.timeout={} ms", 
sessionTimeoutMillis);
     }
 
     public static ConsoleProxyServerFactory getHttpServerFactory() {
         try {
             Class<?> clz = Class.forName(factoryClzName);
             try {
-                ConsoleProxyServerFactory factory = 
(ConsoleProxyServerFactory)clz.newInstance();
+                ConsoleProxyServerFactory factory = 
(ConsoleProxyServerFactory) clz.newInstance();
                 factory.init(ConsoleProxy.ksBits, ConsoleProxy.ksPassword);
                 return factory;
             } catch (InstantiationException e) {
@@ -197,12 +224,11 @@ public class ConsoleProxy {
         authResult.setHost(param.getClientHostAddress());
         authResult.setPort(param.getClientHostPort());
 
-        if 
(org.apache.commons.lang3.StringUtils.isNotBlank(param.getExtraSecurityToken()))
 {
+        if (StringUtils.isNotBlank(param.getExtraSecurityToken())) {
             String extraToken = param.getExtraSecurityToken();
             String clientProvidedToken = 
param.getClientProvidedExtraSecurityToken();
-            LOGGER.debug(String.format("Extra security validation for the 
console access, provided %s " +
-                    "to validate against %s", clientProvidedToken, 
extraToken));
-
+            LOGGER.debug(String.format("Extra security validation for the 
console access, provided %s to validate against %s",
+                    clientProvidedToken, extraToken));
             if (!extraToken.equals(clientProvidedToken)) {
                 LOGGER.error("The provided extra token does not match the 
expected value for this console endpoint");
                 authResult.setSuccess(false);
@@ -233,20 +259,21 @@ public class ConsoleProxy {
             Object result;
             try {
                 result =
-                        authMethod.invoke(ConsoleProxy.context, 
param.getClientHostAddress(), String.valueOf(param.getClientHostPort()), 
param.getClientTag(),
-                                param.getClientHostPassword(), 
param.getTicket(), reauthentication, param.getSessionUuid());
+                        authMethod.invoke(ConsoleProxy.context, 
param.getClientHostAddress(), String.valueOf(param.getClientHostPort()),
+                                param.getClientTag(), 
param.getClientHostPassword(), param.getTicket(), reauthentication,
+                                param.getSessionUuid());
             } catch (IllegalAccessException e) {
-                LOGGER.error("Unable to invoke authenticateConsoleAccess due 
to IllegalAccessException" + " for vm: " + param.getClientTag(), e);
+                LOGGER.error("Unable to invoke authenticateConsoleAccess due 
to IllegalAccessException for vm: " + param.getClientTag(), e);
                 authResult.setSuccess(false);
                 return authResult;
             } catch (InvocationTargetException e) {
-                LOGGER.error("Unable to invoke authenticateConsoleAccess due 
to InvocationTargetException " + " for vm: " + param.getClientTag(), e);
+                LOGGER.error("Unable to invoke authenticateConsoleAccess due 
to InvocationTargetException for vm: " + param.getClientTag(), e);
                 authResult.setSuccess(false);
                 return authResult;
             }
 
             if (result != null && result instanceof String) {
-                authResult = new Gson().fromJson((String)result, 
ConsoleProxyAuthenticationResult.class);
+                authResult = new Gson().fromJson((String) result, 
ConsoleProxyAuthenticationResult.class);
             } else {
                 LOGGER.error("Invalid authentication return object " + result 
+ " for vm: " + param.getClientTag() + ", decline the access");
                 authResult.setSuccess(false);
@@ -286,7 +313,8 @@ public class ConsoleProxy {
         }
     }
 
-    public static void startWithContext(Properties conf, Object context, 
byte[] ksBits, String ksPassword, String password, Boolean 
isSourceIpCheckEnabled) {
+    public static void startWithContext(Properties conf, Object context, 
byte[] ksBits, String ksPassword,
+                                        String password, Boolean 
isSourceIpCheckEnabled) {
         setEncryptorPassword(password);
         configLog4j();
         LOGGER.info("Start console proxy with context");
@@ -326,35 +354,40 @@ public class ConsoleProxy {
         Properties props = new Properties();
         if (confs == null) {
             final File file = 
PropertiesUtil.findConfigFile("consoleproxy.properties");
-            if (file == null)
+            if (file == null) {
                 LOGGER.info("Can't load consoleproxy.properties from 
classpath, will use default configuration");
-            else
+            } else {
                 try {
                     confs = new FileInputStream(file);
                 } catch (FileNotFoundException e) {
                     LOGGER.info("Ignoring file not found exception and using 
defaults");
                 }
+            }
         }
         if (confs != null) {
             try {
                 props.load(confs);
 
+                if (conf == null) {
+                    conf = new Properties();
+                }
                 for (Object key : props.keySet()) {
                     // give properties passed via context high priority, treat 
properties from consoleproxy.properties
                     // as default values
-                    if (conf.get(key) == null)
+                    if (conf.get(key) == null) {
                         conf.put(key, props.get(key));
+                    }
                 }
             } catch (Exception e) {
                 LOGGER.error(e.toString(), e);
+            } finally {
+                try {
+                    confs.close();
+                } catch (IOException e) {
+                    LOGGER.error("Failed to close consoleproxy.properties : " 
+ e.toString(), e);
+                }
             }
         }
-        try {
-            confs.close();
-        } catch (IOException e) {
-            LOGGER.error("Failed to close consolepropxy.properties : " + 
e.toString(), e);
-        }
-
         start(conf);
     }
 
@@ -474,8 +507,8 @@ public class ConsoleProxy {
                 LOGGER.info("The rfb thread died, reinitializing the viewer " 
+ viewer);
                 viewer.initClient(param);
             } else if 
(!param.getClientHostPassword().equals(viewer.getClientHostPassword())) {
-                LOGGER.warn("Bad sid detected(VNC port may be reused). sid in 
session: " + viewer.getClientHostPassword() + ", sid in request: " +
-                        param.getClientHostPassword());
+                LOGGER.warn("Bad sid detected(VNC port may be reused). sid in 
session: " + viewer.getClientHostPassword() +
+                        ", sid in request: " + param.getClientHostPassword());
                 viewer.initClient(param);
             }
         }
@@ -484,8 +517,9 @@ public class ConsoleProxy {
             ConsoleProxyClientStatsCollector statsCollector = 
getStatsCollector();
             String loadInfo = statsCollector.getStatsReport();
             reportLoadInfo(loadInfo);
-            if (LOGGER.isDebugEnabled())
+            if (LOGGER.isDebugEnabled()) {
                 LOGGER.debug("Report load change : " + loadInfo);
+            }
         }
 
         return viewer;
@@ -509,13 +543,15 @@ public class ConsoleProxy {
                 // protected against malicious attack by modifying URL content
                 if (ajaxSession != null) {
                     long ajaxSessionIdFromUrl = Long.parseLong(ajaxSession);
-                    if (ajaxSessionIdFromUrl != viewer.getAjaxSessionId())
+                    if (ajaxSessionIdFromUrl != viewer.getAjaxSessionId()) {
                         throw new AuthenticationException("Cannot use the 
existing viewer " + viewer + ": modified AJAX session id");
+                    }
                 }
 
-                if (param.getClientHostPassword() == null || 
param.getClientHostPassword().isEmpty() ||
-                        
!param.getClientHostPassword().equals(viewer.getClientHostPassword()))
+                if (param.getClientHostPassword() == null || 
param.getClientHostPassword().isEmpty()
+                        || 
!param.getClientHostPassword().equals(viewer.getClientHostPassword())) {
                     throw new AuthenticationException("Cannot use the existing 
viewer " + viewer + ": bad sid");
+                }
 
                 if (!viewer.isFrontEndAlive()) {
 
@@ -529,8 +565,9 @@ public class ConsoleProxy {
                 ConsoleProxyClientStatsCollector statsCollector = 
getStatsCollector();
                 String loadInfo = statsCollector.getStatsReport();
                 reportLoadInfo(loadInfo);
-                if (LOGGER.isDebugEnabled())
+                if (LOGGER.isDebugEnabled()) {
                     LOGGER.debug("Report load change : " + loadInfo);
+                }
             }
             return viewer;
         }
@@ -566,9 +603,11 @@ public class ConsoleProxy {
         ConsoleProxyAuthenticationResult authResult = 
authenticateConsoleAccess(param, false);
 
         if (authResult == null || !authResult.isSuccess()) {
-            LOGGER.warn("External authenticator failed authentication request 
for vm " + param.getClientTag() + " with sid " + param.getClientHostPassword());
+            LOGGER.warn("External authenticator failed authentication request 
for vm " + param.getClientTag()
+                    + " with sid " + param.getClientHostPassword());
 
-            throw new AuthenticationException("External authenticator failed 
request for vm " + param.getClientTag() + " with sid " + 
param.getClientHostPassword());
+            throw new AuthenticationException("External authenticator failed 
request for vm " + param.getClientTag()
+                    + " with sid " + param.getClientHostPassword());
         }
     }
 
@@ -596,9 +635,11 @@ public class ConsoleProxy {
     }
 
     public static ConsoleProxyNoVncClient 
getNoVncViewer(ConsoleProxyClientParam param, String ajaxSession,
-            Session session) throws AuthenticationException {
+                                                         Session session) 
throws AuthenticationException {
         boolean reportLoadChange = false;
         String clientKey = param.getClientMapKey();
+        LOGGER.debug("Getting NoVNC viewer for {}. Client tag: {}. session 
UUID: {}",
+                clientKey, param.getClientTag(), param.getSessionUuid());
         synchronized (connectionMap) {
             ConsoleProxyClient viewer = connectionMap.get(clientKey);
             if (viewer == null || viewer.getClass() != 
ConsoleProxyNoVncClient.class) {
@@ -609,19 +650,19 @@ public class ConsoleProxy {
                 connectionMap.put(clientKey, viewer);
                 reportLoadChange = true;
             } else {
-                if (param.getClientHostPassword() == null || 
param.getClientHostPassword().isEmpty() ||
-                        
!param.getClientHostPassword().equals(viewer.getClientHostPassword()))
+                if (param.getClientHostPassword() == null || 
param.getClientHostPassword().isEmpty()
+                        || 
!param.getClientHostPassword().equals(viewer.getClientHostPassword())) {
                     throw new AuthenticationException("Cannot use the existing 
viewer " + viewer + ": bad sid");
-
+                }
                 try {
                     authenticationExternally(param);
                 } catch (Exception e) {
-                    LOGGER.error("Authentication failed for param: " + param);
+                    LOGGER.error("Authentication failed for param: {}", param, 
e);
                     return null;
                 }
                 LOGGER.info("Initializing new novnc client and disconnecting 
existing session");
                 try {
-                    
((ConsoleProxyNoVncClient)viewer).getSession().disconnect();
+                    ((ConsoleProxyNoVncClient) 
viewer).getSession().disconnect();
                 } catch (IOException e) {
                     LOGGER.error("Exception while disconnect session of novnc 
viewer object: " + viewer, e);
                 }
@@ -636,10 +677,11 @@ public class ConsoleProxy {
                 ConsoleProxyClientStatsCollector statsCollector = 
getStatsCollector();
                 String loadInfo = statsCollector.getStatsReport();
                 reportLoadInfo(loadInfo);
-                if (LOGGER.isDebugEnabled())
+                if (LOGGER.isDebugEnabled()) {
                     LOGGER.debug("Report load change : " + loadInfo);
+                }
             }
-            return (ConsoleProxyNoVncClient)viewer;
+            return (ConsoleProxyNoVncClient) viewer;
         }
     }
 }
diff --git 
a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyGCThread.java
 
b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyGCThread.java
index 0e8f576cf6d..3dea97e89e8 100644
--- 
a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyGCThread.java
+++ 
b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyGCThread.java
@@ -18,7 +18,7 @@ package com.cloud.consoleproxy;
 
 import java.io.File;
 import java.util.ArrayList;
-import java.util.Iterator;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -32,9 +32,8 @@ import org.apache.logging.log4j.LogManager;
  * management software
  */
 public class ConsoleProxyGCThread extends Thread {
-    protected Logger logger = LogManager.getLogger(ConsoleProxyGCThread.class);
+    private static final Logger logger = 
LogManager.getLogger(ConsoleProxyGCThread.class);
 
-    private final static int MAX_SESSION_IDLE_SECONDS = 180;
 
     private final Map<String, ConsoleProxyClient> connMap;
     private final Set<String> removedSessionsSet;
@@ -46,21 +45,20 @@ public class ConsoleProxyGCThread extends Thread {
     }
 
     private void cleanupLogging() {
-        if (lastLogScan != 0 && System.currentTimeMillis() - lastLogScan < 
3600000)
+        if (lastLogScan != 0 && System.currentTimeMillis() - lastLogScan < 
3600000) {
             return;
-
+        }
         lastLogScan = System.currentTimeMillis();
 
         File logDir = new File("./logs");
-        File files[] = logDir.listFiles();
+        File[] files = logDir.listFiles();
         if (files != null) {
             for (File file : files) {
                 if (System.currentTimeMillis() - file.lastModified() >= 
86400000L) {
                     try {
                         file.delete();
                     } catch (Throwable e) {
-                        logger.info("[ignored]"
-                                + "failed to delete file: " + 
e.getLocalizedMessage());
+                        logger.info("[ignored] failed to delete file: " + 
e.getLocalizedMessage());
                     }
                 }
             }
@@ -78,21 +76,25 @@ public class ConsoleProxyGCThread extends Thread {
             bReportLoad = false;
 
             if (logger.isDebugEnabled()) {
-                logger.debug(String.format("connMap=%s, removedSessions=%s", 
connMap, removedSessionsSet));
+                logger.debug(String.format("ConsoleProxyGCThread loop: 
connMap=%s, removedSessions=%s", connMap, removedSessionsSet));
             }
-            Set<String> e = connMap.keySet();
-            Iterator<String> iterator = e.iterator();
-            while (iterator.hasNext()) {
-                String key;
+            List<String> keys;
+            synchronized (connMap) {
+                 keys = new ArrayList<>(connMap.keySet());
+             }
+             for (String key : keys) {
                 ConsoleProxyClient client;
 
                 synchronized (connMap) {
-                    key = iterator.next();
                     client = connMap.get(key);
                 }
 
-                long seconds_unused = (System.currentTimeMillis() - 
client.getClientLastFrontEndActivityTime()) / 1000;
-                if (seconds_unused < MAX_SESSION_IDLE_SECONDS) {
+                if (client == null) {
+                    continue;
+                }
+
+                long millisecondsUnused = System.currentTimeMillis() - 
client.getClientLastFrontEndActivityTime();
+                if (millisecondsUnused < ConsoleProxy.sessionTimeoutMillis) {
                     continue;
                 }
 
@@ -102,12 +104,13 @@ public class ConsoleProxyGCThread extends Thread {
                 }
 
                 // close the server connection
-                logger.info("Dropping " + client + " which has not been used 
for " + seconds_unused + " seconds");
+                logger.info("Dropping " + client + " which has not been used 
for " + millisecondsUnused
+                        + " ms (configured timeout: " + 
ConsoleProxy.sessionTimeoutMillis + " ms)");
                 client.closeClient();
             }
 
             if (bReportLoad || System.currentTimeMillis() - lastReportTick > 
5000) {
-                // report load changes
+                // report load changes, including removed sessions since last 
report
                 ConsoleProxyClientStatsCollector collector = new 
ConsoleProxyClientStatsCollector(connMap);
                 collector.setRemovedSessions(new 
ArrayList<>(removedSessionsSet));
                 String loadInfo = collector.getStatsReport();
@@ -125,7 +128,7 @@ public class ConsoleProxyGCThread extends Thread {
             try {
                 Thread.sleep(5000);
             } catch (InterruptedException ex) {
-                logger.debug("[ignored] Console proxy was interrupted during 
GC.");
+                logger.debug("[ignored] Console proxy GC thread interrupted.", 
ex);
             }
         }
     }
diff --git 
a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVNCHandler.java
 
b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVNCHandler.java
index a9639d0b32e..bff3f7c4376 100644
--- 
a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVNCHandler.java
+++ 
b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVNCHandler.java
@@ -17,6 +17,8 @@
 package com.cloud.consoleproxy;
 
 import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
 import java.util.Map;
 
 import javax.servlet.ServletException;
@@ -34,14 +36,18 @@ import 
org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketFrame;
 import org.eclipse.jetty.websocket.api.annotations.WebSocket;
 import org.eclipse.jetty.websocket.api.extensions.Frame;
+import org.eclipse.jetty.websocket.api.extensions.Frame.Type;
 import org.eclipse.jetty.websocket.server.WebSocketHandler;
 import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
 
 @WebSocket
 public class ConsoleProxyNoVNCHandler extends WebSocketHandler {
 
+    private static final Logger logger = 
LogManager.getLogger(ConsoleProxyNoVNCHandler.class);
+
+    private static final String ACTIVITY_SIGNAL = "CLOUDSTACK_ACTIVITY";
+
     private ConsoleProxyNoVncClient viewer = null;
-    protected Logger logger = LogManager.getLogger(getClass());
 
     public ConsoleProxyNoVNCHandler() {
         super();
@@ -73,6 +79,10 @@ public class ConsoleProxyNoVNCHandler extends 
WebSocketHandler {
 
     @OnWebSocketConnect
     public void onConnect(final Session session) throws IOException, 
InterruptedException {
+        session.setIdleTimeout(ConsoleProxy.sessionTimeoutMillis);
+        logger.debug("Set WebSocket idle timeout to {} ms for session from 
{}.",
+                ConsoleProxy.sessionTimeoutMillis, session.getRemoteAddress());
+
         String queries = session.getUpgradeRequest().getQueryString();
         Map<String, String> queryMap = 
ConsoleProxyHttpHandlerHelper.getQueryMap(queries);
 
@@ -94,14 +104,14 @@ public class ConsoleProxyNoVNCHandler extends 
WebSocketHandler {
         String sessionUuid = queryMap.get("sessionUuid");
         String clientIp = 
session.getRemoteAddress().getAddress().getHostAddress();
 
-        if (tag == null)
+        if (tag == null) {
             tag = "";
+        }
 
-        long ajaxSessionId = 0;
         int port;
-
-        if (host == null || portStr == null || sid == null)
-            throw new IllegalArgumentException();
+        if (host == null || portStr == null || sid == null) {
+            throw new IllegalArgumentException("Missing required console 
connection parameters");
+        }
 
         try {
             port = Integer.parseInt(portStr);
@@ -112,7 +122,7 @@ public class ConsoleProxyNoVNCHandler extends 
WebSocketHandler {
 
         if (ajaxSessionIdStr != null) {
             try {
-                ajaxSessionId = Long.parseLong(ajaxSessionIdStr);
+                Long.parseLong(ajaxSessionIdStr);
             } catch (NumberFormatException e) {
                 logger.error("Invalid ajaxSessionId (sess) value in query 
string: {}. Expected a number.", ajaxSessionIdStr, e);
                 throw new IllegalArgumentException(e);
@@ -151,7 +161,7 @@ public class ConsoleProxyNoVNCHandler extends 
WebSocketHandler {
             viewer = ConsoleProxy.getNoVncViewer(param, ajaxSessionIdStr, 
session);
             logger.info("Viewer has been created successfully [session UUID: 
{}, client IP: {}].", sessionUuid, clientIp);
         } catch (Exception e) {
-            logger.error("Failed to create viewer [session UUID: {}, client 
IP: {}] due to {}.", sessionUuid, clientIp, e.getMessage(), e);
+            logger.error("Failed to create viewer [session UUID: {}, client 
IP: {}].", sessionUuid, clientIp, e);
             return;
         } finally {
             if (viewer == null) {
@@ -160,7 +170,7 @@ public class ConsoleProxyNoVNCHandler extends 
WebSocketHandler {
         }
     }
 
-    private boolean checkSessionSourceIp(final Session session, final String 
sourceIP, String sessionSourceIP) throws IOException {
+    private boolean checkSessionSourceIp(final Session session, final String 
sourceIP, final String sessionSourceIP) throws IOException {
         logger.info("Verifying session source IP {} from WebSocket connection 
request.", sessionSourceIP);
         if (ConsoleProxy.isSourceIpCheckEnabled && (sessionSourceIP == null || 
!sessionSourceIP.equals(sourceIP))) {
             logger.warn("Failed to access console as the source IP to request 
the console is {}.", sourceIP);
@@ -174,7 +184,7 @@ public class ConsoleProxyNoVNCHandler extends 
WebSocketHandler {
     @OnWebSocketClose
     public void onClose(Session session, int statusCode, String reason) throws 
IOException, InterruptedException {
         String sessionSourceIp = 
session.getRemoteAddress().getAddress().getHostAddress();
-        logger.debug("Closing WebSocket session [source IP: {}, status code: 
{}].", sessionSourceIp, statusCode);
+        logger.debug("Closing WebSocket session [source IP: {}, status code: 
{}, reason: {}].", sessionSourceIp, statusCode, reason);
         if (viewer != null) {
             ConsoleProxy.removeViewer(viewer);
         }
@@ -183,12 +193,40 @@ public class ConsoleProxyNoVNCHandler extends 
WebSocketHandler {
 
     @OnWebSocketFrame
     public void onFrame(Frame f) throws IOException {
-        logger.trace("Sending client [ID: {}] frame of {} bytes.", 
viewer.getClientId(), f.getPayloadLength());
+        if (viewer == null) {
+            logger.debug("Ignoring WebSocket frame because viewer is not 
initialized yet.");
+            return;
+        }
+
+        if (f.getType() == Type.TEXT) {
+            ByteBuffer payload = f.getPayload().slice();
+            byte[] data = new byte[payload.remaining()];
+            payload.get(data);
+
+            String message = new String(data, StandardCharsets.UTF_8);
+            if (ACTIVITY_SIGNAL.equals(message)) {
+                viewer.updateFrontEndActivityTime();
+            } else {
+                logger.debug("Ignoring unexpected text WebSocket frame.");
+            }
+            return;
+        }
+
+        if (f.getType() != Type.BINARY) {
+            return;
+        }
+
+        logger.trace("Sending client [ID: {}] frame of {} bytes.",
+                viewer.getClientId(), f.getPayloadLength());
         viewer.sendClientFrame(f);
     }
 
     @OnWebSocketError
     public void onError(Throwable cause) {
-        logger.error("Error on WebSocket [client ID: {}, session UUID: {}].", 
cause, viewer.getClientId(), viewer.getSessionUuid());
+        if (viewer != null) {
+            logger.error("Error on WebSocket [client ID: {}, session UUID: 
{}].", viewer.getClientId(), viewer.getSessionUuid(), cause);
+        } else {
+            logger.error("Error on WebSocket before viewer initialization.", 
cause);
+        }
     }
 }
diff --git 
a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVncClient.java
 
b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVncClient.java
index 85a2e5c541f..9c3cba0bbb1 100644
--- 
a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVncClient.java
+++ 
b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVncClient.java
@@ -118,9 +118,6 @@ public class ConsoleProxyNoVncClient implements 
ConsoleProxyClient {
                     while (connectionAlive) {
                         logger.trace("Connection with client [{}] [IP: {}] is 
alive.", clientId, clientSourceIp);
                         if (client.isVncOverWebSocketConnection()) {
-                            if (client.isVncOverWebSocketConnectionOpen()) {
-                                updateFrontEndActivityTime();
-                            }
                             connectionAlive = session.isOpen();
                             sleepTime = 1;
                         } else if (client.isVncOverNioSocket()) {
@@ -128,7 +125,6 @@ public class ConsoleProxyNoVncClient implements 
ConsoleProxyClient {
                             int bytesRead = 
client.readAvailableDataIntoBuffer(buffer, buffer.remaining());
 
                             if (bytesRead > 0) {
-                                updateFrontEndActivityTime();
                                 consecutiveZeroReads = 0; // Reset counter on 
successful read
 
                                 sleepTime = 0; // Still no sleep to catch any 
remaining data quickly
@@ -185,7 +181,6 @@ public class ConsoleProxyNoVncClient implements 
ConsoleProxyClient {
         try {
             // Buffer is already prepared for reading by flip()
             session.getRemote().sendBytes(buffer);
-            updateFrontEndActivityTime();
         } catch (WebSocketException | IOException e) {
             logger.error("VNC server connection exception.", e);
             return false;
@@ -367,7 +362,16 @@ public class ConsoleProxyNoVncClient implements 
ConsoleProxyClient {
         this.connectionAlive = false;
         // Clear buffer reference to allow GC when client disconnects
         this.readBuffer = null;
-        ConsoleProxy.removeViewer(this);
+
+        try {
+            if (session != null && session.isOpen()) {
+                session.disconnect();
+            }
+        } catch (IOException e) {
+            logger.warn("Failed to disconnect WebSocket session for client 
[{}].", clientId, e);
+        } finally {
+            ConsoleProxy.removeViewer(this);
+        }
     }
 
     @Override
diff --git 
a/services/console-proxy/server/src/test/java/com/cloud/consoleproxy/ConsoleProxyNoVncClientTest.java
 
b/services/console-proxy/server/src/test/java/com/cloud/consoleproxy/ConsoleProxyNoVncClientTest.java
index 56a87373711..d805031caad 100644
--- 
a/services/console-proxy/server/src/test/java/com/cloud/consoleproxy/ConsoleProxyNoVncClientTest.java
+++ 
b/services/console-proxy/server/src/test/java/com/cloud/consoleproxy/ConsoleProxyNoVncClientTest.java
@@ -16,8 +16,10 @@
 // under the License.
 package com.cloud.consoleproxy;
 
+import org.eclipse.jetty.websocket.api.Session;
 import org.junit.Assert;
 import org.junit.Test;
+import org.mockito.Mockito;
 
 public class ConsoleProxyNoVncClientTest {
     @Test
@@ -29,4 +31,29 @@ public class ConsoleProxyNoVncClientTest {
         byte[] expectedBytes = new byte[]{4, 0, 3, 0, 32, 24, 0, 1, 0, -1, 0, 
-1, 0, -1, 16, 8, 0, 0, 0, 0, 0, 0, 0, 22, 115, 101, 114, 118, 101, 114, 49, 
50, 51, 44, 32, 98, 97, 99, 107, 101, 110, 100, 58, 84, 76, 83};
         Assert.assertArrayEquals(newServerInit, expectedBytes);
     }
+
+    @Test
+    public void updateFrontEndActivityTimeUpdatesTimestamp() throws 
InterruptedException {
+        ConsoleProxyNoVncClient client = new ConsoleProxyNoVncClient(null);
+
+        long previousActivityTime = client.getClientLastFrontEndActivityTime();
+
+        Thread.sleep(10);
+        client.updateFrontEndActivityTime();
+
+        long updatedActivityTime = client.getClientLastFrontEndActivityTime();
+        Assert.assertTrue(updatedActivityTime > previousActivityTime);
+    }
+
+    @Test
+    public void closeClientDisconnectsOpenWebSocketSession() throws Exception {
+        Session session = Mockito.mock(Session.class);
+        Mockito.when(session.isOpen()).thenReturn(true);
+
+        ConsoleProxyNoVncClient client = new ConsoleProxyNoVncClient(session);
+
+        client.closeClient();
+
+        Mockito.verify(session).disconnect();
+    }
 }
diff --git a/systemvm/agent/noVNC/core/rfb.js b/systemvm/agent/noVNC/core/rfb.js
index 228340a2dec..113dca230aa 100644
--- a/systemvm/agent/noVNC/core/rfb.js
+++ b/systemvm/agent/noVNC/core/rfb.js
@@ -516,6 +516,8 @@ export default class RFB extends EventTargetMixin {
             return;
         }
 
+        this._sock.sendActivity();
+
         const scancode = XtScancode[code];
 
         if (keysym === KeyTable.XK_Shift_L || keysym === KeyTable.XK_Shift_R) {
@@ -1210,6 +1212,8 @@ export default class RFB extends EventTargetMixin {
         if (this._rfbConnectionState !== 'connected') { return; }
         if (this._viewOnly) { return; } // View only, skip mouse events
 
+        this._sock.sendActivity();
+
         RFB.messages.pointerEvent(this._sock, this._display.absX(x),
                                   this._display.absY(y), mask);
     }
diff --git a/systemvm/agent/noVNC/core/websock.js 
b/systemvm/agent/noVNC/core/websock.js
index 37b33fcc585..dddf280cc5e 100644
--- a/systemvm/agent/noVNC/core/websock.js
+++ b/systemvm/agent/noVNC/core/websock.js
@@ -203,6 +203,21 @@ export default class Websock {
         this.flush();
     }
 
+    sendActivity() {
+        if (this._websocket === null || this.readyState !== 'open') {
+            return;
+        }
+
+        const now = Date.now();
+        if (this._lastActivitySignal &&
+            now - this._lastActivitySignal < 500) {
+            return;
+        }
+
+        this._lastActivitySignal = now;
+        this._websocket.send("CLOUDSTACK_ACTIVITY");
+    }
+
     sendString(str) {
         this.send(str.split('').map(chr => chr.charCodeAt(0)));
     }

Reply via email to