Skip to content

Commit b0550c3

Browse files
author
Rafael Uzarowski
committed
feat(WebSocket): auto-reconnect after backend restart, harden Origin checks, add status tooltip
- Wire Socket.IO `cors_allowed_origins` to `validate_ws_origin()` so the transport enforces Origin allowlisting. - Harden `validate_ws_origin()` to accept reverse-proxy setups by checking Host + `X-Forwarded-Host`/`X-Forwarded-Proto` candidates and handling `SERVER_PORT` robustly. - Frontend: ensure CSRF is initialized before starting the Engine.IO handshake, and auto-retry connects after `connect_error` with exponential backoff (fixes long-lived tabs after restart). - Sync: always re-send `state_request` on every Socket.IO connect so per-sid projections are re-established (prevents “healthy but stalled” tabs after sleep/suspend). - UI: add native hover tooltip for the sync indicator and make hover reliable by applying the tooltip on the wrapper and disabling pointer events on the inner SVG.
1 parent e35c2dd commit b0550c3

File tree

5 files changed

+110
-16
lines changed

5 files changed

+110
-16
lines changed

python/helpers/websocket.py

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,27 +73,64 @@ def validate_ws_origin(environ: dict[str, Any]) -> tuple[bool, str | None]:
7373
if origin_host is None or origin_port is None:
7474
return False, "invalid_origin"
7575

76+
# Build candidate request host/port pairs. Prefer explicit Host header, fall back to
77+
# forwarded headers (reverse proxies) and finally SERVER_NAME.
7678
raw_host = environ.get("HTTP_HOST")
7779
req_host, req_port = _parse_host_header(raw_host)
7880
if not req_host:
7981
req_host = environ.get("SERVER_NAME")
82+
8083
if req_port is None:
8184
server_port_raw = environ.get("SERVER_PORT")
82-
if isinstance(server_port_raw, str) and server_port_raw.isdigit():
83-
req_port = int(server_port_raw)
85+
try:
86+
server_port = int(server_port_raw) if server_port_raw is not None else None
87+
except (TypeError, ValueError):
88+
server_port = None
89+
if server_port is not None and server_port > 0:
90+
req_port = server_port
91+
8492
if req_host:
8593
req_host = req_host.lower()
86-
87-
if not req_host:
88-
return False, "missing_host"
8994
if req_port is None:
9095
req_port = origin_port
9196

92-
if origin_host != req_host:
97+
forwarded_host_raw = environ.get("HTTP_X_FORWARDED_HOST")
98+
forwarded_host = None
99+
forwarded_port = None
100+
if isinstance(forwarded_host_raw, str) and forwarded_host_raw.strip():
101+
first = forwarded_host_raw.split(",")[0].strip()
102+
forwarded_host, forwarded_port = _parse_host_header(first)
103+
if forwarded_host:
104+
forwarded_host = forwarded_host.lower()
105+
106+
forwarded_proto_raw = environ.get("HTTP_X_FORWARDED_PROTO")
107+
forwarded_scheme = None
108+
if isinstance(forwarded_proto_raw, str) and forwarded_proto_raw.strip():
109+
forwarded_scheme = forwarded_proto_raw.split(",")[0].strip().lower()
110+
forwarded_scheme = forwarded_scheme or origin_parsed.scheme
111+
forwarded_port = (
112+
forwarded_port
113+
if forwarded_port is not None
114+
else _default_port_for_scheme(forwarded_scheme) or origin_port
115+
)
116+
117+
candidates: list[tuple[str, int]] = []
118+
if req_host:
119+
candidates.append((req_host, int(req_port)))
120+
if forwarded_host:
121+
candidates.append((forwarded_host, int(forwarded_port)))
122+
123+
if not candidates:
124+
return False, "missing_host"
125+
126+
for host, port in candidates:
127+
if origin_host == host and origin_port == port:
128+
return True, None
129+
130+
# Preserve the original mismatch semantics for debugging.
131+
if origin_host not in {host for host, _ in candidates}:
93132
return False, "origin_host_mismatch"
94-
if origin_port != req_port:
95-
return False, "origin_port_mismatch"
96-
return True, None
133+
return False, "origin_port_mismatch"
97134

98135

99136
class SingletonInstantiationError(RuntimeError):

run_ui.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454

5555
socketio_server = socketio.AsyncServer(
5656
async_mode="asgi",
57-
cors_allowed_origins=[],
57+
cors_allowed_origins=lambda _origin, environ: validate_ws_origin(environ)[0],
5858
logger=False,
5959
engineio_logger=False,
6060
ping_interval=25, # explicit default to avoid future lib changes

webui/components/sync/sync-status.html

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
align-items: center;
1313
}
1414

15+
.status-icon svg {
16+
pointer-events: none;
17+
}
18+
1519
.pending-ring {
1620
animation: pendingPulse 1.2s infinite ease-in-out;
1721
}
@@ -38,7 +42,17 @@
3842
<body>
3943
<div x-data>
4044
<template x-if="$store.sync">
41-
<div class="status-icon" x-create="$store.sync.init()">
45+
<div
46+
class="status-icon"
47+
x-create="$store.sync.init()"
48+
:title="$store.sync.mode === 'HEALTHY'
49+
? 'Connected (push sync healthy)'
50+
: $store.sync.mode === 'HANDSHAKE_PENDING'
51+
? 'Connecting (waiting for state handshake)'
52+
: $store.sync.mode === 'DEGRADED'
53+
? 'Degraded (polling fallback)'
54+
: 'Disconnected (waiting to reconnect)'"
55+
>
4256
<svg viewBox="0 0 30 30" width="20" height="20" aria-label="sync status">
4357
<!-- HEALTHY (filled circle) -->
4458
<circle x-show="$store.sync.mode === 'HEALTHY'" cx="15" cy="15" r="8" fill="#00c340" />

webui/components/sync/sync-store.js

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,15 @@ const model = {
106106
this._pendingReconnectToast = runtimeChanged ? "restart" : "reconnect";
107107
}
108108

109-
if (this.needsHandshake) {
110-
this.sendStateRequest({ forceFull: true }).catch((error) => {
111-
console.error("[syncStore] reconnect handshake failed:", error);
112-
});
113-
}
109+
// Always re-handshake on every Socket.IO connect.
110+
//
111+
// The backend StateMonitor tracking is per-sid and starts with seq_base=0 on a
112+
// newly connected sid. If a tab misses the 'disconnect' event (e.g. browser
113+
// suspended overnight) it can look HEALTHY locally while never sending a
114+
// fresh state_request, so pushes are gated and logs appear to stall.
115+
this.sendStateRequest({ forceFull: true }).catch((error) => {
116+
console.error("[syncStore] connect handshake failed:", error);
117+
});
114118
});
115119

116120
websocket.onDisconnect(() => {

webui/js/websocket.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,37 @@ class WebSocketClient {
249249
this._hasConnectedOnce = false;
250250
this._lastRuntimeId = null;
251251
this._csrfInvalidatedForConnectError = false;
252+
this._connectErrorRetryTimer = null;
253+
this._connectErrorRetryAttempt = 0;
254+
}
255+
256+
_clearConnectErrorRetryTimer() {
257+
if (this._connectErrorRetryTimer) {
258+
clearTimeout(this._connectErrorRetryTimer);
259+
this._connectErrorRetryTimer = null;
260+
}
261+
}
262+
263+
_scheduleConnectErrorRetry(reason) {
264+
if (this._manualDisconnect) return;
265+
if (this.connected) return;
266+
if (!this.socket) return;
267+
if (this.socket.connected) return;
268+
if (this._connectErrorRetryTimer) return;
269+
270+
const attempt = Math.max(0, Number(this._connectErrorRetryAttempt) || 0);
271+
const baseMs = 250;
272+
const capMs = 10000;
273+
const delayMs = Math.min(capMs, baseMs * 2 ** attempt);
274+
this._connectErrorRetryAttempt = attempt + 1;
275+
276+
this.debugLog("schedule connect retry", { reason, attempt, delayMs });
277+
this._connectErrorRetryTimer = setTimeout(() => {
278+
this._connectErrorRetryTimer = null;
279+
if (this._manualDisconnect) return;
280+
if (this.connected) return;
281+
this.connect().catch(() => {});
282+
}, delayMs);
252283
}
253284

254285
buildPayload(data) {
@@ -318,6 +349,11 @@ class WebSocketClient {
318349

319350
if (this.socket.connected) return;
320351

352+
// Ensure the current runtime-bound session + CSRF cookies exist before initiating
353+
// the Engine.IO handshake. This is required for seamless reconnect after backend
354+
// restarts that rotate runtime_id and session cookie names.
355+
await getCsrfToken();
356+
321357
await new Promise((resolve, reject) => {
322358
const onConnect = () => {
323359
this.socket.off("connect_error", onError);
@@ -677,6 +713,8 @@ class WebSocketClient {
677713
this.socket.on("connect", () => {
678714
this.connected = true;
679715
this._csrfInvalidatedForConnectError = false;
716+
this._connectErrorRetryAttempt = 0;
717+
this._clearConnectErrorRetryTimer();
680718

681719
const runtimeId = window.runtimeInfo?.id || null;
682720
const runtimeChanged = Boolean(
@@ -722,6 +760,7 @@ class WebSocketClient {
722760
this._csrfInvalidatedForConnectError = true;
723761
invalidateCsrfToken();
724762
}
763+
this._scheduleConnectErrorRetry("connect_error");
725764
});
726765

727766
this.socket.on("error", (error) => {

0 commit comments

Comments
 (0)