Skip to content

Re-authenticate on reconnect to recover from server-side session expiry#161

Open
l-qing wants to merge 2 commits into
Sathvik-Rao:mainfrom
l-qing:fix/session-relogin
Open

Re-authenticate on reconnect to recover from server-side session expiry#161
l-qing wants to merge 2 commits into
Sathvik-Rao:mainfrom
l-qing:fix/session-relogin

Conversation

@l-qing

@l-qing l-qing commented Jul 9, 2026

Copy link
Copy Markdown

Summary

After the server restarts (or its session store is otherwise cleared), the desktop client can never reconnect on its own: it loops forever logging scheme http is invalid, and even an explicit Disconnect → Connect from the tray does not help. The only way to recover today is to fully quit and relaunch the client.

This PR makes the client detect an expired session and re-authenticate during (re)connect, so it recovers automatically. It also stops an unintended reconnect that happens when the user disconnects while an auto-reconnect is pending.

Symptom

Desktop client log, repeating roughly every 10s and never recovering:

ERROR - scheme http is invalid - goodbye
ERROR - Failed to connect websocket: Connection to wss://<host>/clipsocket timed out
  • Clipboard sync stops.
  • Clicking Disconnect then Connect in the tray does not restore it.
  • Only quitting and relaunching the app restores sync.

Steps to reproduce

  1. Log in with the desktop client; confirm clipboard sync works.
  2. Restart the ClipCascade server. It keeps HTTP sessions in memory (Spring Security SessionRegistryImpl), so a restart invalidates every existing JSESSIONID.
  3. The client's WebSocket drops and it starts auto-reconnecting, but now loops on scheme http is invalid and never reconnects.
  4. In the tray, Disconnect then Connect: still fails.
  5. Quit and relaunch the client: sync works again.

Any server-side session invalidation reproduces this; a server restart is simply the easiest trigger.

Root cause

Two layers:

1. The client reuses a dead session on every reconnect path.
STOMPManager.connect() builds the WebSocket with the stored config.data["cookie"] and never re-authenticates. This holds for both auto-reconnect (_on_closeconnect()) and manual reconnect (manual_reconnect()connect()). Only launching the app re-authenticates, because only authenticate_and_connect() performs a fresh login(). That is exactly why "Disconnect + Connect" does not help but "restart the app" does.

2. A dead session turns the handshake into an unrecoverable error.
Once the session is gone, Spring Security answers the WebSocket handshake with a 302 redirect to the login page. websocket-client follows that redirect and calls parse_url() on the target, which is not a ws/wss URL, so it raises scheme http is invalid. (The scheme is http/https depending on the server's TLS/reverse-proxy setup; any non-ws scheme fails the same way.) The attempt then times out and the loop repeats forever with the same dead cookie.

Fix

1. Detect an expired session — new RequestManager.is_session_valid().
Probe an authenticated endpoint (/csrf-token) with allow_redirects=False: 200 = alive, 3xx = expired. Disabling redirect-following is the crucial detail — otherwise requests silently follows the 302 to the login page (which returns 200) and a dead session looks alive. Network/other errors return True, so transient connectivity issues fall through to the normal WebSocket retry path instead of forcing an unnecessary re-login.

2. Re-authenticate before reconnecting — new STOMPManager._try_relogin(), called from connect().
When the session is expired and credentials are stored (Save Password), silently login() and refresh the cookie/CSRF token in place, so the following handshake uses a live session and sync resumes with no user action. Without stored credentials, notify the user once ("session expired, please re-login") instead of looping silently. Because this runs on every connect() outside the initial login phase, both auto-reconnect and manual reconnect now recover — no app restart needed.

3. Respect an explicit disconnect — if self.disconnected: return at the top of connect().
connect() previously only checked self.disconnected after opening the socket. An auto-reconnect thread that was already sleeping in _on_close when the user pressed Disconnect would wake and call connect() anyway. With fix #2 the re-login makes that racing reconnect succeed, so it would connect and then immediately tear itself down (via the existing post-connect disconnected check), producing a visible connect/disconnect churn on an intentional disconnect. The early guard makes a user's Disconnect stick; manual_reconnect() clears the flag before calling, so intentional reconnects still work.

Why this works

  • Recovery no longer depends on relaunching the app: every reconnect first checks whether the session is still valid and refreshes it if not, so the handshake never reaches the 302 → scheme http is invalid dead end.
  • Detection is a deterministic auth probe, independent of websocket-client internals and of the redirect's scheme, so it is robust across reverse-proxy / TLS setups.
  • Intentional disconnects are honored, so the new re-login path cannot reconnect behind the user's back.

Behavior notes

  • Fully automatic recovery requires Save Password, since the client needs credentials to re-authenticate unattended. Without it, the user gets a single "session expired, please re-login" notification and logs in manually — still an improvement over the previous silent infinite loop, and no change for password-less users beyond that one notification.
  • No new dependencies; ~76 added lines across two files.

Testing

  • python -m py_compile on both changed files.
  • Reproduced a real dead session by restarting the server, then verified end-to-end against the live server: is_session_valid() returns False for the stale cookie, login() returns a fresh cookie, and is_session_valid() then returns True.
  • Verified in the running desktop build that after a server restart the client re-logs-in and reconnects on its own (Session had expired; silently re-logged inWebsocket connected), and that an explicit Disconnect no longer triggers a reconnect.

l-qing added 2 commits July 9, 2026 11:09
- Re-authenticate when a saved cookie has expired after a server restart
- Persist refreshed cookie and CSRF token before reconnecting
- Notify the user once when stored credentials are unavailable
- Prevent sleeping auto-reconnect loops from logging back in after a user disconnects
- Return the disconnected state before refreshing the session or retrying the websocket
- Keep intentional reconnects working because manual_reconnect() clears the flag first
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Docker] Use "latest" tag for Docker image

1 participant