diff --git a/static/client.html b/static/client.html index 199db4b..47608a7 100644 --- a/static/client.html +++ b/static/client.html @@ -1840,6 +1840,36 @@

Session Ended

rgGo('/connections.html'); }); + var sessionEndStatus = null; + var sessionHasEnded = false; + function showSessionEnd(status) { + // Keep the first reported reason: subsequent tunnel closure is + // a consequence, not a new error. A clean end does not prove + // Windows logoff (guacd also uses SUCCESS for disconnect). + if (status && !sessionEndStatus) sessionEndStatus = status; + var firstEnd = !sessionHasEnded; + sessionHasEnded = true; + var code = sessionEndStatus && sessionEndStatus.code; + var normalEnd = !sessionEndStatus || code === Guacamole.Status.Code.SUCCESS + || code === Guacamole.Status.Code.SESSION_CLOSED + || code === Guacamole.Status.Code.SESSION_TIMEOUT + || code === Guacamole.Status.Code.SESSION_CONFLICT; + var message = (sessionEndStatus && sessionEndStatus.message) + || (code === Guacamole.Status.Code.SESSION_TIMEOUT ? 'The remote session timed out.' : '') + || (code === Guacamole.Status.Code.SESSION_CONFLICT ? 'Disconnected by another connection.' : '') + || (normalEnd ? 'The remote connection has ended.' : 'Connection lost'); + statusEl.textContent = normalEnd ? message : 'Error: ' + message; + statusEl.className = ''; + rgDisarmFullscreenGesture(); rgHideConnectCover(); + if (_thumbInterval) { clearInterval(_thumbInterval); _thumbInterval = null; } + if (firstEnd) teardownMonitorWindows('disconnect'); + if (embedded) { if (firstEnd) rgNotifyParent('end'); return; } + document.getElementById('disconnected-title').textContent = normalEnd ? 'Session Ended' : 'Connection Error'; + document.getElementById('disconnected-message').textContent = message; + document.getElementById('disconnected-overlay').className = 'visible'; + if (rgFullscreenEl()) rgExitFullscreen(); + } + client.onstatechange = function(state) { switch (state) { case Guacamole.Client.State.CONNECTING: statusEl.textContent = 'Connecting...'; statusEl.className = ''; rgGateStatus('Connecting...', false); break; @@ -1923,7 +1953,9 @@

Session Ended

} break; case Guacamole.Client.State.DISCONNECTING: - statusEl.textContent = 'Disconnecting...'; statusEl.className = ''; + if (!sessionHasEnded) { + statusEl.textContent = 'Disconnecting...'; statusEl.className = ''; + } // Final thumbnail capture before disconnect if (_thumbInterval) { clearInterval(_thumbInterval); @@ -1932,45 +1964,21 @@

Session Ended

} break; case Guacamole.Client.State.DISCONNECTED: - statusEl.textContent = 'Disconnected'; statusEl.className = ''; - rgDisarmFullscreenGesture(); rgHideConnectCover(); - if (_thumbInterval) { clearInterval(_thumbInterval); _thumbInterval = null; } - if (embedded) { rgNotifyParent('end'); break; } - document.getElementById('disconnected-overlay').className = 'visible'; - if (rgFullscreenEl()) rgExitFullscreen(); - // Reflect the disconnect in any open monitor pop-outs - // (freeze the stale blit + show a "Disconnected" overlay). - teardownMonitorWindows('disconnect'); + showSessionEnd(); break; } }; client.onerror = function(status) { - statusEl.textContent = 'Error: ' + (status.message || 'Unknown error'); - statusEl.className = ''; - rgDisarmFullscreenGesture(); rgHideConnectCover(); - // Stop the 10s thumbnail uploader; the session is dead and - // each XHR would otherwise 404 against a cleaned-up id. - if (_thumbInterval) { clearInterval(_thumbInterval); _thumbInterval = null; } - document.getElementById('disconnected-title').textContent = 'Connection Error'; - document.getElementById('disconnected-message').textContent = status.message || 'An error occurred.'; - document.getElementById('disconnected-overlay').className = 'visible'; - if (rgFullscreenEl()) rgExitFullscreen(); + showSessionEnd(status || { code: Guacamole.Status.Code.SERVER_ERROR }); + client.disconnect(); }; // Wire tunnel events back into the client. Upstream Guacamole's - // Client.js deliberately doesn't listen on tunnel.onerror / - // tunnel.onstatechange — the Apache webapp installs those - // handlers externally via AngularJS. Our lean client.html - // inherits Tunnel.js + Client.js from upstream but used to - // miss this glue, so when a mid-path middlebox silently - // dropped the WS and socket.onclose fired, the tunnel would - // transition to CLOSED, fire onerror into the void, and the - // Guacamole client would stay stuck in CONNECTED forever. - // Effect: the user saw a frozen tab, no "Session Ended" - // overlay, and the browser kept uploading thumbnails into - // 404 responses for hours. Forwarding tunnel events into the - // existing client.onerror closes that loop. + // Tunnel.js reports abnormal closure via onerror before CLOSED. + // CLOSED itself is not an error: it also follows a clean remote + // ending or client.disconnect(). Always finish client cleanup, + // without replacing a specific reason with "Connection lost". tunnel.onerror = function(status) { if (typeof console !== 'undefined') console.log('[rustguac] tunnel error:', @@ -1985,11 +1993,9 @@

Session Ended

if (state === Guacamole.Tunnel.State.CLOSED) { if (typeof console !== 'undefined') console.log('[rustguac] tunnel closed'); - if (client.onerror) client.onerror({ - code: 519, - message: 'Connection lost' - }); - } else if (state === Guacamole.Tunnel.State.UNSTABLE) { + client.disconnect(); + showSessionEnd(); + } else if (state === Guacamole.Tunnel.State.UNSTABLE && !sessionHasEnded) { if (typeof console !== 'undefined') console.log('[rustguac] tunnel unstable'); statusEl.textContent = 'Connection unstable...'; diff --git a/tests/client-session-endings.mjs b/tests/client-session-endings.mjs new file mode 100644 index 0000000..a494b1d --- /dev/null +++ b/tests/client-session-endings.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +// Exercise the actual client.html handlers with Guacamole's status codes and +// the same error -> CLOSED -> client DISCONNECTED ordering as Tunnel.js. +// Run: node tests/client-session-endings.mjs (also covered by CI's tests/*.mjs). +import { readFile } from 'node:fs/promises'; +import { dirname, resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import vm from 'node:vm'; +import assert from 'node:assert/strict'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const html = await readFile(join(root, 'static/client.html'), 'utf8'); +const start = html.indexOf('var sessionEndStatus = null;'); +const end = html.indexOf('// Disconnected overlay buttons', start); +assert.ok(start !== -1 && end > start, 'session-ending handlers must exist'); +const handlers = html.slice(start, end); +const statusSource = await readFile(join(root, 'static/guac/Status.js'), 'utf8'); + +function setup(embedded = false) { + const elements = new Map(); + let state = 3; + const ctx = { + embedded, statusEl: {}, _thumbInterval: 42, cleared: [], notifications: [], monitors: [], + console: { log() {} }, + document: { getElementById(id) { + if (!elements.has(id)) elements.set(id, {}); + return elements.get(id); + } }, + rgDisarmFullscreenGesture() {}, rgHideConnectCover() {}, + rgFullscreenEl() { return false; }, + rgNotifyParent(event) { ctx.notifications.push(event); }, + teardownMonitorWindows(reason) { ctx.monitors.push(reason); }, + clearInterval(id) { ctx.cleared.push(id); }, + captureAndUploadThumbnail() {}, + client: { disconnect() { + if (state === 4 || state === 5) return; + state = 4; + ctx.client.onstatechange(state); + ctx.tunnel.onstatechange(ctx.Guacamole.Tunnel.State.CLOSED); + state = 5; + ctx.client.onstatechange(state); + } }, + tunnel: {} + }; + vm.createContext(ctx); + vm.runInContext(statusSource, ctx); + ctx.Guacamole.Client = { State: { + CONNECTING: 1, WAITING: 2, CONNECTED: 3, DISCONNECTING: 4, DISCONNECTED: 5 + } }; + ctx.Guacamole.Tunnel = { State: { CLOSED: 2, UNSTABLE: 3 } }; + vm.runInContext(handlers, ctx); + return { + ctx, elements, codes: ctx.Guacamole.Status.Code, + closed() { ctx.tunnel.onstatechange(ctx.Guacamole.Tunnel.State.CLOSED); }, + title() { return elements.get('disconnected-title')?.textContent; }, + message() { return elements.get('disconnected-message')?.textContent; }, + cleanup() { + assert.equal(state, 5, 'client must leave CONNECTED'); + assert.equal(ctx._thumbInterval, null); + assert.deepEqual(ctx.cleared, [42]); + assert.deepEqual(ctx.monitors, ['disconnect']); + } + }; +} + +// Clean remote ending and explicit client disconnect are not connection errors. +// Neither proves Windows logoff: don't invent that distinction in the UI. +for (const localDisconnect of [false, true]) { + const h = setup(); + if (localDisconnect) h.ctx.client.disconnect(); else h.closed(); + assert.equal(h.title(), 'Session Ended'); + assert.equal(h.message(), 'The remote connection has ended.'); + assert.doesNotMatch(h.message(), /log(?:ged)?[ -]?off|sign(?:ed)?[ -]?out/i); + h.cleanup(); +} + +// Guacd's specific reason survives subsequent client/tunnel closure events. +for (const [codeName, message, title] of [ + ['SESSION_CLOSED', 'Logged off.', 'Session Ended'], + ['SESSION_CLOSED', 'Forcibly disconnected.', 'Session Ended'], + ['SESSION_TIMEOUT', 'Idle session time limit exceeded.', 'Session Ended'], + ['SESSION_CONFLICT', 'Disconnected by other connection.', 'Session Ended'], + ['CLIENT_UNAUTHORIZED', 'Authentication failure (invalid credentials?)', 'Connection Error'], + ['UPSTREAM_NOT_FOUND', 'DNS lookup failed (incorrect hostname?)', 'Connection Error'] +]) { + const h = setup(); + h.ctx.client.onerror({ code: h.codes[codeName], message }); + h.closed(); + h.ctx.tunnel.onerror({ code: h.codes.SERVER_ERROR, message: 'Connection lost' }); + assert.equal(h.title(), title); + assert.equal(h.message(), message); + assert.ok(h.ctx.statusEl.textContent.endsWith(message)); + h.cleanup(); +} + +// Abnormal transport closure still reports an error and performs cleanup. +for (const message of [undefined, 'Server timeout.']) { + const h = setup(); + h.ctx.tunnel.onerror({ code: h.codes.SERVER_TIMEOUT, message }); + h.closed(); + assert.equal(h.title(), 'Connection Error'); + assert.equal(h.message(), message || 'Connection lost'); + h.cleanup(); +} + +// Missing statuses are errors; explicit SUCCESS is not. +for (const success of [false, true]) { + const h = setup(); + h.ctx.client.onerror(success ? { code: h.codes.SUCCESS } : undefined); + assert.equal(h.title(), success ? 'Session Ended' : 'Connection Error'); + h.cleanup(); +} + +// Embedded launcher receives one end notification, including for errors. +for (const error of [false, true]) { + const h = setup(true); + if (error) h.ctx.tunnel.onerror({ code: h.codes.SERVER_ERROR, message: 'Connection lost' }); + h.closed(); + h.closed(); + assert.deepEqual(h.ctx.notifications, ['end']); + assert.equal(h.title(), undefined, 'embedded sessions use their launcher'); + h.cleanup(); +} + +console.log('client-session-endings: ok'); diff --git a/tests/e2e/specs/session-endings.spec.mjs b/tests/e2e/specs/session-endings.spec.mjs new file mode 100644 index 0000000..7467b88 --- /dev/null +++ b/tests/e2e/specs/session-endings.spec.mjs @@ -0,0 +1,49 @@ +import { test, expect } from '@playwright/test'; +import { installApi } from '../lib/api.mjs'; + +function instruction(opcode, ...args) { + return [opcode, ...args].map(value => `${String(value).length}.${value}`).join(',') + ';'; +} + +async function connect(page) { + await installApi(page); + let socket; + await page.routeWebSocket('**/ws/**', ws => { + socket = ws; + ws.send('4.sync,1.0;'); + }); + await page.goto('/client.html?session=11111111-1111-4111-8111-111111111111'); + await expect.poll(() => !!socket).toBe(true); + await expect(page.locator('#status')).toHaveText('Connected'); + return socket; +} + +test('a clean guacd disconnect is a neutral session ending', async ({ page }) => { + const socket = await connect(page); + socket.send(instruction('disconnect')); + await expect(page.locator('#disconnected-overlay')).toBeVisible(); + await expect(page.locator('#disconnected-title')).toHaveText('Session Ended'); + await expect(page.locator('#disconnected-message')).toHaveText('The remote connection has ended.'); +}); + +for (const [code, message, title] of [ + [523, 'Logged off.', 'Session Ended'], + [522, 'Idle session time limit exceeded.', 'Session Ended'], + [769, 'Authentication failure (invalid credentials?)', 'Connection Error'] +]) { + test(`guacd reason survives tunnel closure: ${message}`, async ({ page }) => { + const socket = await connect(page); + socket.send(instruction('error', message, code) + instruction('disconnect')); + await expect(page.locator('#disconnected-overlay')).toBeVisible(); + await expect(page.locator('#disconnected-title')).toHaveText(title); + await expect(page.locator('#disconnected-message')).toHaveText(message); + }); +} + +test('an abnormal WebSocket closure is still a connection error', async ({ page }) => { + const socket = await connect(page); + socket.close({ code: 1011 }); + await expect(page.locator('#disconnected-overlay')).toBeVisible(); + await expect(page.locator('#disconnected-title')).toHaveText('Connection Error'); + await expect(page.locator('#disconnected-message')).toHaveText('Connection lost'); +}); diff --git a/tests/e2e/specs/zero-click-fullscreen.spec.mjs b/tests/e2e/specs/zero-click-fullscreen.spec.mjs index 445a6c8..1688911 100644 --- a/tests/e2e/specs/zero-click-fullscreen.spec.mjs +++ b/tests/e2e/specs/zero-click-fullscreen.spec.mjs @@ -6,6 +6,14 @@ import { test, expect } from '@playwright/test'; import { installApi, settle } from '../lib/api.mjs'; +// HTTP fixtures do not provide a guacd WebSocket. Keep a simulated session +// alive so these launcher tests don't race genuine disconnect cleanup. +test.beforeEach(async ({ page }) => { + await page.routeWebSocket('**/ws/**', socket => { + socket.send('4.sync,1.0;'); + }); +}); + test('Connect on a fullscreen entry launches the embedded frame with the measured size', async ({ page }) => { const api = await installApi(page); await page.goto('/connections.html');