Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions mesop/env/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,23 @@ 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.
#
# 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()
== "true"
)

MESOP_HTTP_CACHE_JS_BUNDLE = (
os.environ.get("MESOP_HTTP_CACHE_JS_BUNDLE", "false").lower() == "true"
)
Expand Down
11 changes: 11 additions & 0 deletions mesop/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions mesop/server/static_file_serving.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"""
Expand Down
64 changes: 64 additions & 0 deletions mesop/web/src/services/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,11 +256,75 @@ 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
}
// 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.',
);
window.location.reload();
}
}

private async handleUiResponse(
request: UiRequest,
uiResponse: UiResponse,
Expand Down
6 changes: 6 additions & 0 deletions mesop/web/src/services/experiment_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {Injectable} from '@angular/core';

interface ExperimentSettings {
readonly websocketsEnabled: boolean;
readonly websocketsReloadOnDisconnect: boolean;
readonly webComponentsCacheKey: string | null;
}

Expand All @@ -15,13 +16,18 @@ export class ExperimentService {
const windowSettings = (window as any)['__MESOP_EXPERIMENTS__'];
this.settings = {
websocketsEnabled: windowSettings?.['websocketsEnabled'] ?? false,
websocketsReloadOnDisconnect:
windowSettings?.['websocketsReloadOnDisconnect'] ?? false,
webComponentsCacheKey: windowSettings?.['webComponentsCacheKey'] ?? null,
};
}

get websocketsEnabled(): boolean {
return this.settings.websocketsEnabled;
}
get websocketsReloadOnDisconnect(): boolean {
return this.settings.websocketsReloadOnDisconnect;
}
get webComponentsCacheKey(): string | null {
return this.settings.webComponentsCacheKey;
}
Expand Down
11 changes: 11 additions & 0 deletions mesop/web/src/services/experiment_service_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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,
Expand Down
Loading