Skip to content

RDP session is torn down and auto-reconnected after the tab is hidden, even though the tunnel is alive #39

Closed

Description

@eov128

Repository: Termix-SSH/Plugin-Remote-Desktop
Version: plugin 1.3.1 / Termix 26.11.0
Where: src/frontend/tunnel-watch.ts L5-6 and L58-69; src/frontend/GuacamoleDisplay.tsx L851-861


What happens

With an RDP session open and working, switch to another browser tab (or minimize the window) for a
few seconds and come back. If nothing on the remote desktop changes during the following 8 seconds,
the session is declared dead: the display goes blank, Termix logs reconnecting after lost and opens
a brand-new guacd connection. After three such events it stops reconnecting and shows a connection
error instead.

Nothing was actually lost. The WebSocket is still open the whole time — the session is killed by a
local liveness heuristic, not by a network failure.

Observed matrix (one Termix instance, one RDP host, Chrome 141 on Linux/Windows):

Action after the tab becomes visible again Result
Move the mouse / type within 8 s session survives
Leave the desktop idle (cursor untouched, no window animation) torn down and reconnected
Switch tab, come back, immediately click into the session usually survives
Minimize the window, restore after ~10 s, wait almost always torn down
Keep the tab visible the whole time and never touch it session survives (the check only runs on visibilitychange)

The reconnect is visible in the session log panel as a warning
remoteDesktop.reconnectingAfterLost, and in docker logs termix-guacd as an extra
Creating new client for protocol "rdp" for a user that never closed the session.

Relation to #30 / #35 (not a duplicate)

#30 asks for a way to turn off auto-reconnect, and #35 (merged into dev-1.4.0) adds an
Automatically reconnect RDP toggle. That is a useful escape hatch, but it does not address what
this report is about: the watchdog fires on sessions that were never lost. With the toggle left
at its default — enabled, because #35 preserves existing behaviour — everything below still happens
on every tab switch, and the user's only remedy is to disable the recovery feature altogether.

If the toggle ships, the reproduction below still applies with the default setting, which is what
almost every user runs.

Why

tunnel-watch.ts counts Guacamole instructions received and treats "no instruction within
ALIVE_CHECK_MS" as a dead tunnel:

// src/frontend/tunnel-watch.ts L5-6
/** How long a page back from hiding waits for anything from the server. It sends a nop every 5 s. */
export const ALIVE_CHECK_MS = 8000;

// src/frontend/tunnel-watch.ts L57-69
return {
  checkAlive() {
    if (stopped) return;
    if (typeof tunnel.state === "number" && tunnel.state !== OPEN) {
      lost("stale");
      return;
    }
    const before = received;
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      if (received === before) lost("stale");   // ← fires on a quiet desktop
    }, checkMs);
  },

and GuacamoleDisplay.tsx starts that timer the moment the page becomes visible again:

// src/frontend/GuacamoleDisplay.tsx L851-861
const handleVisibilityChange = () => {
  windowFocusedRef.current =
    document.visibilityState === "visible" && document.hasFocus();
  if (document.visibilityState !== "visible") {
    hasKeyboardFocusRef.current = false;
  } else if (wasConnectedRef.current) {
    // The socket may have died while the tab was hidden or the screen locked.
    tunnelWatchRef.current?.checkAlive();
  }
  refreshKeyboardHandlers();
};

Three things make this a false positive on a normal session:

  1. nop cannot be used to prove liveness. The comment on L5 assumes a periodic nop produces
    traffic that will be observed. It does not. From the Guacamole protocol reference,
    "The 'nop' instruction does absolutely nothing, has no parameters, and is universally ignored
    by both Guacamole clients and servers
    . Its main use is as a keep-alive signal …"
    . Sending
    nop keeps NAT/firewall state alive but produces no inbound instruction, so received never
    increases because of it. In the shipped bundle PING_FREQUENCY is 500 (ms), not 5 s, which
    only makes the point sharper: the client is chattering every half second and received still
    stays frozen.

  2. A static RDP desktop legitimately produces no frames. guacd only sends img/blob/sync
    when something changes. An idle Windows desktop with no clock animation and no cursor movement
    emits nothing for arbitrary lengths of time. 8 seconds of silence is the normal case, not the
    failure case.

  3. Background throttling widens the gap. While the tab is hidden the browser throttles timers
    (Chrome: down to once per minute for hidden tabs), so queued instructions may not be dispatched
    until the page is visible again. The check then starts its 8 s window at exactly the moment the
    backlog is being processed, so it is racing the event loop rather than measuring the socket.

The net effect: the watchdog cannot distinguish "quiet desktop" from "dead socket", and it fires in
the direction of destroying a healthy session.

Steps to reproduce

  1. Open an RDP host in Termix (either engine; the watchdog is frontend-only).
  2. Wait for the desktop to finish painting and stop interacting with it — no cursor movement, no
    open animation/marquee, no clock with seconds.
  3. Switch to another tab or minimise the window for ~10 s.
  4. Return to the Termix tab and do not touch anything.
  5. Within ~8 s the display clears and the session reconnects. The session log shows
    reconnecting after lost.
  6. Repeat twice more — after MAX_AUTO_RECONNECTS = 3 the session stops recovering by itself.

Counter-check that isolates the heuristic from the network: keep the tab visible and idle for the
same 10 s. Nothing happens, because checkAlive() only runs from visibilitychange.

What I expected

Coming back to a tab leaves a working session working. A watchdog should only tear down a session
when the transport is actually gone, and a quiet desktop is not evidence that it is.

Suggested fix

  1. Do not use "instructions received" as the liveness signal. The transport already knows:
    tunnel.state becomes CLOSED on a real close, and the onstatechange wrapper at L51-55
    already handles that case. Keeping a frozen screen behind a dead socket only matters when the
    socket died without a close frame, which is what a round-trip probe is for.
  2. If a probe is wanted, use one that is answered. sync is the protocol round-trip: the
    client sends sync <timestamp> and the server replies with sync <timestamp>. Waiting for a
    sync echo would distinguish "quiet" from "dead" correctly, and nop explicitly cannot.
  3. At minimum, stop running checkAlive() from visibilitychange. Re-arming the timer on every
    tab switch guarantees that a single missed frame is enough to lose the session; the stale-socket
    case the comment names (L857) is already covered by onstatechange.
  4. Add a console.debug/log line on the stale verdict including received, tunnel.state and
    the measured interval — right now the only user-visible trace is the reconnect itself.

Environment

  • Termix 26.11.0, Plugin Remote Desktop 1.3.1, Docker on Linux (ghcr.io/termix-ssh/termix:latest)
  • termix-guacd guacamole/guacd:1.6.0 as an external sidecar in network_mode: container:<termix-core>, GUACD_HOST=127.0.0.1
  • Target: Windows Server 2016 over RDP, 1280×720, resize-method: display-update
  • Browsers: Chrome 141 and Edge 141 on Linux and Windows 11; Firefox reproduces identically
  • Page served over plain HTTP (irrelevant here — the watchdog is scheme-independent)

Anything else

I read main rather than guessing: the line numbers above are from
src/frontend/tunnel-watch.ts (ALIVE_CHECK_MS = 8000, MAX_AUTO_RECONNECTS = 3) and
src/frontend/GuacamoleDisplay.tsx on main today. The shipped 1.3.1 bundle contains the same
logic, so this is not a stale-build artefact.

The intent of the watchdog is clear and the failure it guards against — a socket that dies while the
tab is hidden, leaving a frozen screen behind a dead transport — is real. The fix is only to use a
signal that can actually tell silence from death.

Happy to test a patch or open a PR.

Activity

  1. termix-triage-bot commented on Nov 7, 2026

    @termix-triage-bot

    Auto-triage

    RDP sessions are incorrectly torn down and reconnected when the browser tab is hidden and then restored, if the desktop is idle. The maintainer has confirmed this is fixed in the dev branch.

    Field Value
    Type Bug
    Difficulty 1/5 (Trivial)
    Priority Low

    A maintainer will take a look. If anything above looks wrong, just say so.

  2. LukeGus commented on Nov 7, 2026

    @LukeGus
    Member

    Fixed on dev-1.4.0. A quiet desktop is no longer read as a dead connection when you come back to the tab. Only a tunnel that is really closed triggers a reconnect now.

  3. moved this from Bug Backlog to Ready for Release in Termix Roadmapon Nov 7, 2026
  4. termix-triage-bot commented on Nov 9, 2026

    @termix-triage-bot

    This was fixed in v1.4.0. Update this plugin to the latest version to get it. Thanks for the report!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions