Fix RDP drop loop: echo guacamole-common-js tunnel ping server-side (#46)

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,<ts>;`) 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 <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
This commit is contained in:
Samuel James 2026-06-22 15:46:36 -04:00 committed by GitHub
parent 58a46553d9
commit 5f27943974
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -93,6 +93,26 @@ export async function guacamoleRoutes(app: FastifyInstance) {
}) })
const connectionId = randomUUID() 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,<ts>;`). 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( const clientConnection = new ClientConnection(
CLIENT_OPTIONS, CLIENT_OPTIONS,
connectionId, connectionId,