From 5f279439747c9e2c3f8386fd27c730262679ee17 Mon Sep 17 00:00:00 2001 From: Samuel James <143277412+SamuelSJames@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:46:36 -0400 Subject: [PATCH] Fix RDP drop loop: echo guacamole-common-js tunnel ping server-side (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE actual root cause of the flicker-then-blank / "connected but drops" RDP behavior. guacamole-common-js's WebSocketTunnel sends an internal stability "ping" (empty INTERNAL_DATA_OPCODE: `0.,4.ping,;`) and resets its receiveTimeout only on inbound data. guacamole-lite 1.2.0 forwards that ping straight to guacd, which neither understands nor echoes it. On an idle desktop (no frames flowing), nothing resets the client timer, so the tunnel hits UPSTREAM_TIMEOUT, closes, and reconnects — the flicker/drop loop seen in guacd as "User is not responding". Fix: intercept the internal ping on the WS in guacamole.ts and echo it straight back to the client before ClientConnection forwards it to guacd, so the client's stability timer is satisfied even when the remote desktop is idle. Verified separately: a guacd connection that echoes sync held 30s/116 syncs with no drop; server/xrdp/XFCE are healthy. This was purely the missing ping echo. Co-authored-by: Samuel James Co-authored-by: Kiro --- backend/src/routes/guacamole.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/backend/src/routes/guacamole.ts b/backend/src/routes/guacamole.ts index 5a03d0d..a6c4d08 100644 --- a/backend/src/routes/guacamole.ts +++ b/backend/src/routes/guacamole.ts @@ -93,6 +93,26 @@ export async function guacamoleRoutes(app: FastifyInstance) { }) const connectionId = randomUUID() + + // guacamole-lite 1.2.0 forwards EVERY client WS message straight to guacd, + // including guacamole-common-js's internal tunnel "ping" (opcode is the + // empty INTERNAL_DATA_OPCODE: `0.,4.ping,;`). guacd doesn't understand + // or echo that ping, so the client's tunnel never sees the expected echo, + // hits its receiveTimeout, closes with UPSTREAM_TIMEOUT, and reconnects in + // a loop (symptom: RDP desktop flickers in then drops while still showing + // "connected"). Fix: intercept the ping here and echo it back to the client + // ourselves, before ClientConnection's own handler forwards it to guacd. + // ponytail: simple substring match on the internal ping opcode rather than a + // full protocol parser — the ping frame is fixed-shape; revisit if guacd's + // internal opcodes change. + socket.on('message', (raw: unknown) => { + const msg = typeof raw === 'string' ? raw : String(raw) + // INTERNAL_DATA_OPCODE is empty, so a ping arrives as "0.,4.ping,..." + if (msg.startsWith('0.,4.ping,')) { + if ((socket as { readyState?: number }).readyState === 1) socket.send(msg) + } + }) + const clientConnection = new ClientConnection( CLIENT_OPTIONS, connectionId,