From ad96b518d0879c861c9ab4e9047b60d3f61d054a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 17:25:27 +0000 Subject: [PATCH 1/2] Add opt-in HTTP probe + reload recovery for dead WebSockets (#1389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Mesop app sits behind a corporate auth proxy and the SSO session expires, the proxy intercepts the WebSocket reconnect with an HTTP 302. The browser hides handshake-time HTTP responses from JS, so the client just sees an opaque error, exhausts its 3 reconnect attempts, and gives up silently — leaving the user disconnected with no way to re-auth. This is a short-term, opt-in fix: - New env var MESOP_WEBSOCKETS_RELOAD_ON_DISCONNECT (default: false). - New /__health__ HTTP endpoint as a probe target. - After WS reconnect attempts are exhausted, the client probes the endpoint. Non-2xx, redirected, cross-origin, or fetch error -> reload the page so the proxy's SSO redirect can run as a normal navigation. A 60s sessionStorage throttle prevents reload loops. The longer-term fix (a connection-state API + session resumption that also covers #1312) is tracked separately. --- mesop/env/env.py | 12 ++++ mesop/server/server.py | 11 ++++ mesop/server/static_file_serving.py | 2 + mesop/web/src/services/channel.ts | 60 +++++++++++++++++++ mesop/web/src/services/experiment_service.ts | 6 ++ .../src/services/experiment_service_spec.ts | 11 ++++ 6 files changed, 102 insertions(+) diff --git a/mesop/env/env.py b/mesop/env/env.py index d9bb28a3e..dd17250b8 100644 --- a/mesop/env/env.py +++ b/mesop/env/env.py @@ -48,6 +48,18 @@ def get_base_url_path() -> str: os.environ.get("MESOP_WEBSOCKETS_ENABLED", "false").lower() == "true" ) +# When the WebSocket reconnect attempts are exhausted, the client probes a +# lightweight HTTP endpoint to distinguish a transient network failure from an +# auth-proxy redirect (which the browser hides from JS for cross-origin WS +# handshakes). If the probe indicates the underlying HTTP layer is unhealthy +# (non-2xx, cross-origin redirect, or fetch error), the client reloads the +# page so the proxy's SSO redirect can run as a normal HTTP navigation. +# See: https://github.com/mesop-dev/mesop/issues/1389 +MESOP_WEBSOCKETS_RELOAD_ON_DISCONNECT = ( + os.environ.get("MESOP_WEBSOCKETS_RELOAD_ON_DISCONNECT", "false").lower() + == "true" +) + MESOP_HTTP_CACHE_JS_BUNDLE = ( os.environ.get("MESOP_HTTP_CACHE_JS_BUNDLE", "false").lower() == "true" ) diff --git a/mesop/server/server.py b/mesop/server/server.py index bf9cb1456..5d2d3a5d7 100755 --- a/mesop/server/server.py +++ b/mesop/server/server.py @@ -51,6 +51,7 @@ UI_PATH = prefix_base_url("/__ui__") APPLY_COOKIES_PATH = prefix_base_url("/__apply-cookies") +HEALTH_PATH = prefix_base_url("/__health__") logger = logging.getLogger(__name__) @@ -507,6 +508,16 @@ def apply_cookies() -> Response: ) return resp + @flask_app.route(HEALTH_PATH, methods=["GET"]) + def health() -> Response: + # Lightweight liveness probe used by the client to distinguish a transient + # WebSocket failure from an auth-proxy redirect that the browser hides + # from JS. A reverse proxy enforcing auth will intercept this with its own + # 3xx/4xx response, which is exactly what the client needs to detect. + resp = make_response("ok", 200) + resp.headers["Cache-Control"] = "no-store" + return resp + @flask_app.teardown_request def teardown_clear_stale_state_sessions(error=None): runtime().context().clear_stale_state_sessions() diff --git a/mesop/server/static_file_serving.py b/mesop/server/static_file_serving.py index 3bd370873..1a69f9a5f 100644 --- a/mesop/server/static_file_serving.py +++ b/mesop/server/static_file_serving.py @@ -19,6 +19,7 @@ MESOP_HTTP_CACHE_JS_BUNDLE, MESOP_WEB_COMPONENTS_HTTP_CACHE_KEY, MESOP_WEBSOCKETS_ENABLED, + MESOP_WEBSOCKETS_RELOAD_ON_DISCONNECT, get_app_base_path, ) from mesop.exceptions import MesopException @@ -102,6 +103,7 @@ def retrieve_index_html() -> io.BytesIO | str: ): experiment_settings = { "websocketsEnabled": MESOP_WEBSOCKETS_ENABLED, + "websocketsReloadOnDisconnect": MESOP_WEBSOCKETS_RELOAD_ON_DISCONNECT, "webComponentsCacheKey": MESOP_WEB_COMPONENTS_HTTP_CACHE_KEY, } lines[i] = f""" diff --git a/mesop/web/src/services/channel.ts b/mesop/web/src/services/channel.ts index 1f71246ba..77a3b42c1 100644 --- a/mesop/web/src/services/channel.ts +++ b/mesop/web/src/services/channel.ts @@ -256,11 +256,71 @@ export class Channel { const refreshRequest = this.createInitRequest(); this.initWebSocket(initParams, refreshRequest); }, backoffDelay); + } else if (this.experimentService.websocketsReloadOnDisconnect) { + // Reconnect attempts are exhausted. The browser hides handshake-time + // HTTP responses (e.g. an auth proxy returning 302) from JS, so we + // probe a same-origin HTTP endpoint to distinguish auth/proxy + // failure from a real server-side WS issue. If the probe shows the + // HTTP layer is unhealthy, reload so the proxy's SSO redirect can + // run as a normal navigation. + // See: https://github.com/mesop-dev/mesop/issues/1389 + this.maybeReloadAfterDisconnect(); } }); }; } + private async maybeReloadAfterDisconnect() { + // Throttle reloads via sessionStorage so a server returning auth failures + // for both WS and the probe doesn't put the page into a reload loop. + const RELOAD_THROTTLE_KEY = 'mesop_ws_last_reload_ms'; + const RELOAD_THROTTLE_MS = 60_000; + try { + const last = Number(sessionStorage.getItem(RELOAD_THROTTLE_KEY) ?? '0'); + if (Number.isFinite(last) && Date.now() - last < RELOAD_THROTTLE_MS) { + console.warn( + 'WebSocket disconnect recovery: skipping reload (throttled).', + ); + return; + } + } catch { + // sessionStorage may be unavailable (e.g. private mode); fall through. + } + + let shouldReload = false; + try { + const response = await fetch(prefixBasePath('/__health__'), { + method: 'GET', + credentials: 'include', + cache: 'no-store', + redirect: 'follow', + }); + // Non-2xx, or a redirect that landed on a different origin (typical SSO + // flow), means the HTTP layer can't reach Mesop — reload so the user + // can re-authenticate. + const responseUrl = new URL(response.url, window.location.href); + const crossOrigin = responseUrl.origin !== window.location.origin; + if (!response.ok || response.redirected || crossOrigin) { + shouldReload = true; + } + } catch { + // Network error / opaque redirect — treat as unhealthy and reload. + shouldReload = true; + } + + if (shouldReload) { + try { + sessionStorage.setItem(RELOAD_THROTTLE_KEY, String(Date.now())); + } catch { + // ignore + } + console.warn( + 'WebSocket disconnect recovery: reloading page to recover connection.', + ); + window.location.reload(); + } + } + private async handleUiResponse( request: UiRequest, uiResponse: UiResponse, diff --git a/mesop/web/src/services/experiment_service.ts b/mesop/web/src/services/experiment_service.ts index 5a62f75a6..c16ceb8ef 100644 --- a/mesop/web/src/services/experiment_service.ts +++ b/mesop/web/src/services/experiment_service.ts @@ -2,6 +2,7 @@ import {Injectable} from '@angular/core'; interface ExperimentSettings { readonly websocketsEnabled: boolean; + readonly websocketsReloadOnDisconnect: boolean; readonly webComponentsCacheKey: string | null; } @@ -15,6 +16,8 @@ export class ExperimentService { const windowSettings = (window as any)['__MESOP_EXPERIMENTS__']; this.settings = { websocketsEnabled: windowSettings?.['websocketsEnabled'] ?? false, + websocketsReloadOnDisconnect: + windowSettings?.['websocketsReloadOnDisconnect'] ?? false, webComponentsCacheKey: windowSettings?.['webComponentsCacheKey'] ?? null, }; } @@ -22,6 +25,9 @@ export class ExperimentService { get websocketsEnabled(): boolean { return this.settings.websocketsEnabled; } + get websocketsReloadOnDisconnect(): boolean { + return this.settings.websocketsReloadOnDisconnect; + } get webComponentsCacheKey(): string | null { return this.settings.webComponentsCacheKey; } diff --git a/mesop/web/src/services/experiment_service_spec.ts b/mesop/web/src/services/experiment_service_spec.ts index 7d8b63fe6..0d38104cd 100644 --- a/mesop/web/src/services/experiment_service_spec.ts +++ b/mesop/web/src/services/experiment_service_spec.ts @@ -8,6 +8,7 @@ describe('ExperimentService', () => { it('uses default settings when __MESOP_EXPERIMENTS__ is not set', () => { const service = new ExperimentService(); expect(service.websocketsEnabled).toBe(false); + expect(service.websocketsReloadOnDisconnect).toBe(false); }); it('reads websocketsEnabled=true from window settings', () => { @@ -19,6 +20,16 @@ describe('ExperimentService', () => { expect(service.websocketsEnabled).toBe(true); }); + it('reads websocketsReloadOnDisconnect=true from window settings', () => { + (window as any).__MESOP_EXPERIMENTS__ = { + websocketsEnabled: true, + websocketsReloadOnDisconnect: true, + webComponentsCacheKey: null, + }; + const service = new ExperimentService(); + expect(service.websocketsReloadOnDisconnect).toBe(true); + }); + it('reads webComponentsCacheKey from window settings', () => { (window as any).__MESOP_EXPERIMENTS__ = { websocketsEnabled: false, From 238b3dab649a9d29ef5a595c8e99f5479151a6c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 17:35:54 +0000 Subject: [PATCH 2/2] Document state-loss caveat for WS disconnect reload Calls out that window.location.reload() drops in-flight client state (typed input, scroll position, ephemeral UI). Surfaces this both in the env var docstring (so opt-in is informed) and at the reload call site (so anyone reading the code sees the tradeoff). --- mesop/env/env.py | 5 +++++ mesop/web/src/services/channel.ts | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/mesop/env/env.py b/mesop/env/env.py index dd17250b8..0c6ee7bae 100644 --- a/mesop/env/env.py +++ b/mesop/env/env.py @@ -54,6 +54,11 @@ def get_base_url_path() -> str: # handshakes). If the probe indicates the underlying HTTP layer is unhealthy # (non-2xx, cross-origin redirect, or fetch error), the client reloads the # page so the proxy's SSO redirect can run as a normal HTTP navigation. +# +# Caveat: the reload drops any in-flight client state (typed-but-unsubmitted +# form input, scroll position, ephemeral UI state). Apps that need to preserve +# state across recovery should leave this off until a connection-state API is +# available. # See: https://github.com/mesop-dev/mesop/issues/1389 MESOP_WEBSOCKETS_RELOAD_ON_DISCONNECT = ( os.environ.get("MESOP_WEBSOCKETS_RELOAD_ON_DISCONNECT", "false").lower() diff --git a/mesop/web/src/services/channel.ts b/mesop/web/src/services/channel.ts index 77a3b42c1..a4ab5d106 100644 --- a/mesop/web/src/services/channel.ts +++ b/mesop/web/src/services/channel.ts @@ -314,6 +314,10 @@ export class Channel { } catch { // ignore } + // Caveat: a full reload drops any in-flight client state (typed-but- + // unsubmitted form input, scroll position, ephemeral UI state). The + // longer-term fix is a connection-state API that lets the app decide + // whether to auto-reload, prompt the user, or persist state first. console.warn( 'WebSocket disconnect recovery: reloading page to recover connection.', );