From 2bc35484098fe3db04d39f0c940d7ea8309e64f0 Mon Sep 17 00:00:00 2001 From: khimaros Date: Wed, 17 Jun 2026 09:48:12 -0700 Subject: [PATCH] feat: make webui rpc timeout configurable --- crates/config/src/schema/system.rs | 9 +++++ crates/config/src/schema/tests.rs | 12 ++++++ crates/config/src/template.rs | 1 + crates/config/src/validate/schema_map.rs | 1 + crates/config/src/validate/tests/common.rs | 17 +++++++++ crates/web/src/templates.rs | 2 + crates/web/ui/e2e/specs/websocket.spec.js | 43 ++++++++++++++++++++++ crates/web/ui/src/helpers.ts | 7 +++- crates/web/ui/src/types/gon.ts | 1 + docs/src/configuration-reference.md | 1 + 10 files changed, 93 insertions(+), 1 deletion(-) diff --git a/crates/config/src/schema/system.rs b/crates/config/src/schema/system.rs index 70d1737a69..bf21a65cfd 100644 --- a/crates/config/src/schema/system.rs +++ b/crates/config/src/schema/system.rs @@ -32,6 +32,10 @@ pub struct ServerConfig { /// usage for personal gateways. Defaults to 5. #[serde(default = "default_db_pool_max_connections")] pub db_pool_max_connections: u32, + /// Milliseconds the web UI waits for a WebSocket RPC reply before failing + /// with a `TIMEOUT` error. Defaults to 5000. + #[serde(default = "default_rpc_timeout_ms")] + pub rpc_timeout_ms: u64, /// Base URL for the Shiki syntax-highlighting library loaded by the web UI. /// /// Defaults to `https://esm.sh/shiki@3.2.1?bundle` when unset. @@ -62,6 +66,10 @@ fn default_db_pool_max_connections() -> u32 { 5 } +fn default_rpc_timeout_ms() -> u64 { + 5000 +} + fn default_terminal_enabled() -> bool { true } @@ -76,6 +84,7 @@ impl Default for ServerConfig { log_buffer_size: default_log_buffer_size(), update_releases_url: None, db_pool_max_connections: default_db_pool_max_connections(), + rpc_timeout_ms: default_rpc_timeout_ms(), shiki_cdn_url: None, terminal_enabled: default_terminal_enabled(), external_url: None, diff --git a/crates/config/src/schema/tests.rs b/crates/config/src/schema/tests.rs index 7a8501c663..00094c2506 100644 --- a/crates/config/src/schema/tests.rs +++ b/crates/config/src/schema/tests.rs @@ -1017,6 +1017,18 @@ fn terminal_disabled_via_config_reflects_in_helper() { assert!(!cfg.server.is_terminal_enabled()); } +#[test] +fn rpc_timeout_ms_defaults_to_5000() { + let cfg: MoltisConfig = toml::from_str("").unwrap(); + assert_eq!(cfg.server.rpc_timeout_ms, 5000); +} + +#[test] +fn rpc_timeout_ms_parsed_from_config() { + let cfg: MoltisConfig = toml::from_str("[server]\nrpc_timeout_ms = 8000\n").unwrap(); + assert_eq!(cfg.server.rpc_timeout_ms, 8000); +} + #[test] fn voice_openai_and_whisper_base_url_parse_from_toml() { let toml_str = r#" diff --git a/crates/config/src/template.rs b/crates/config/src/template.rs index 8320fd737f..dd5c713b2f 100644 --- a/crates/config/src/template.rs +++ b/crates/config/src/template.rs @@ -38,6 +38,7 @@ port = {port} # Port number (auto-generated for this i # bind = "127.0.0.1" # Address to bind to ("0.0.0.0" for all interfaces) # http_request_logs = false # Enable verbose Axum HTTP request/response logs (debugging) # ws_request_logs = false # Enable WebSocket RPC request/response logs (debugging) +# rpc_timeout_ms = 5000 # Web UI WebSocket RPC reply timeout (milliseconds) # terminal_enabled = true # Enable interactive host terminal in Settings > Terminal # Set to false to disable the unsandboxed shell in the web UI. # NOTE: this can be re-enabled via the web UI config editor. diff --git a/crates/config/src/validate/schema_map.rs b/crates/config/src/validate/schema_map.rs index b5db4d5f99..09f4b38ca8 100644 --- a/crates/config/src/validate/schema_map.rs +++ b/crates/config/src/validate/schema_map.rs @@ -374,6 +374,7 @@ pub(super) fn build_schema_map() -> KnownKeys { ("log_buffer_size", Leaf), ("update_releases_url", Leaf), ("db_pool_max_connections", Leaf), + ("rpc_timeout_ms", Leaf), ("shiki_cdn_url", Leaf), ("terminal_enabled", Leaf), ("external_url", Leaf), diff --git a/crates/config/src/validate/tests/common.rs b/crates/config/src/validate/tests/common.rs index a71805782c..f8ac18b2b0 100644 --- a/crates/config/src/validate/tests/common.rs +++ b/crates/config/src/validate/tests/common.rs @@ -249,6 +249,23 @@ terminal_enabled = false ); } +#[test] +fn server_rpc_timeout_ms_is_known_field() { + let toml = r#" +[server] +rpc_timeout_ms = 8000 +"#; + let result = validate_toml_str(toml); + let unknown = result + .diagnostics + .iter() + .find(|d| d.category == "unknown-field" && d.path.contains("rpc_timeout_ms")); + assert!( + unknown.is_none(), + "rpc_timeout_ms should be a known field, got: {unknown:?}" + ); +} + #[test] fn server_external_url_is_known_field() { let toml = r#" diff --git a/crates/web/src/templates.rs b/crates/web/src/templates.rs index e123dd34e2..481cc4860b 100644 --- a/crates/web/src/templates.rs +++ b/crates/web/src/templates.rs @@ -71,6 +71,7 @@ pub(crate) struct GonData { tts_enabled: bool, graphql_enabled: bool, terminal_enabled: bool, + rpc_timeout_ms: u64, git_branch: Option, mem: MemSnapshot, #[serde(skip_serializing_if = "Option::is_none")] @@ -546,6 +547,7 @@ pub(crate) async fn build_gon_data(gw: &GatewayState) -> GonData { tts_enabled: cfg!(feature = "voice") && gw.config.voice.tts.enabled, graphql_enabled: cfg!(feature = "graphql"), terminal_enabled: gw.config.server.is_terminal_enabled(), + rpc_timeout_ms: gw.config.server.rpc_timeout_ms, git_branch: tokio::task::spawn_blocking(detect_git_branch) .await .ok() diff --git a/crates/web/ui/e2e/specs/websocket.spec.js b/crates/web/ui/e2e/specs/websocket.spec.js index 5da1e58cda..31385da97f 100644 --- a/crates/web/ui/e2e/specs/websocket.spec.js +++ b/crates/web/ui/e2e/specs/websocket.spec.js @@ -235,6 +235,49 @@ test.describe("WebSocket connection lifecycle", () => { expect(pageErrors).toEqual([]); }); + test("RPC timeout honors server-configured gon rpc_timeout_ms", async ({ page }) => { + const pageErrors = watchPageErrors(page); + await page.goto("/"); + await waitForWsConnected(page); + + const { res, elapsedMs } = await page.evaluate(async () => { + const appScript = document.querySelector('script[type="module"][src*="js/app.js"]'); + if (!appScript) throw new Error("app module script not found"); + + const appUrl = new URL(appScript.src, window.location.origin); + const prefix = appUrl.href.slice(0, appUrl.href.length - "js/app.js".length); + const helpers = await import(`${prefix}js/helpers.js`); + const state = await import(`${prefix}js/state.js`); + const gon = await import(`${prefix}js/gon.js`); + const originalWs = state.ws; + const originalGon = gon.get("rpc_timeout_ms"); + // The test-only window override must be unset so the gon value is used. + window.__moltisTestRpcTimeoutMs = undefined; + + try { + gon.set("rpc_timeout_ms", 1_000); + state.setWs({ + readyState: WebSocket.OPEN, + send() { + // Never resolves; the gon-configured timeout should fire. + }, + }); + + const start = performance.now(); + const res = await helpers.sendRpc("test.slow_method", {}); + return { res, elapsedMs: performance.now() - start }; + } finally { + state.setWs(originalWs); + gon.set("rpc_timeout_ms", originalGon); + } + }); + + expect(res).toMatchObject({ ok: false, error: { code: "TIMEOUT" } }); + // Proves the 1000ms gon value was applied rather than the 5000ms default. + expect(elapsedMs).toBeLessThan(3_000); + expect(pageErrors).toEqual([]); + }); + test("final chat text is kept when it includes tool output plus analysis", async ({ page }) => { const pageErrors = watchPageErrors(page); await page.goto("/chats/main"); diff --git a/crates/web/ui/src/helpers.ts b/crates/web/ui/src/helpers.ts index 9195532848..ee2bde98d5 100644 --- a/crates/web/ui/src/helpers.ts +++ b/crates/web/ui/src/helpers.ts @@ -1,5 +1,7 @@ // ── Helpers ────────────────────────────────────────────────── + import { Marked, Renderer, type Token } from "marked"; +import * as gon from "./gon"; import { hasTranslation, t } from "./i18n"; import * as S from "./state"; import type { RpcResponse } from "./types"; @@ -272,7 +274,10 @@ const markedInstance = new Marked({ renderer: mdRenderer, breaks: true, gfm: tru const RPC_TIMEOUT_MS = 5_000; function rpcTimeoutMs(): number { - return window.__moltisTestRpcTimeoutMs ?? RPC_TIMEOUT_MS; + if (typeof window.__moltisTestRpcTimeoutMs === "number") return window.__moltisTestRpcTimeoutMs; + // server.rpc_timeout_ms via gon; ignore a misconfigured 0/negative. + const configured = gon.get("rpc_timeout_ms"); + return typeof configured === "number" && configured > 0 ? configured : RPC_TIMEOUT_MS; } export function renderMarkdown(raw: string): string { diff --git a/crates/web/ui/src/types/gon.ts b/crates/web/ui/src/types/gon.ts index c52697818a..0483d9d776 100644 --- a/crates/web/ui/src/types/gon.ts +++ b/crates/web/ui/src/types/gon.ts @@ -233,6 +233,7 @@ export interface GonData { tts_enabled: boolean; graphql_enabled: boolean; terminal_enabled: boolean; + rpc_timeout_ms: number; git_branch?: string; mem: MemSnapshot; deploy_platform?: string; diff --git a/docs/src/configuration-reference.md b/docs/src/configuration-reference.md index e3b4cb0146..eedf4e9d6c 100644 --- a/docs/src/configuration-reference.md +++ b/docs/src/configuration-reference.md @@ -132,6 +132,7 @@ Gateway server configuration. | `log_buffer_size` | integer | `1000` | Maximum number of log entries kept in the in-memory ring buffer. Older entries are persisted to disk. Increase for busy servers, decrease for memory-constrained devices. | | `update_releases_url` | optional string | — | URL of the releases manifest (`releases.json`) used by the update checker. Defaults to `https://www.moltis.org/releases.json` when unset. | | `db_pool_max_connections` | integer | `5` | Maximum number of SQLite pool connections. Lower values reduce memory usage for personal gateways. | +| `rpc_timeout_ms` | integer | `5000` | Milliseconds the web UI waits for a WebSocket RPC reply before failing with a `TIMEOUT` error. | | `shiki_cdn_url` | optional string | — | Base URL for the Shiki syntax-highlighting library loaded by the web UI. Defaults to `https://esm.sh/shiki@3.2.1?bundle` when unset. | | `terminal_enabled` | bool | `true` | Enable or disable the host terminal in the web UI. Set to `false` to prevent an unsandboxed shell. The `MOLTIS_TERMINAL_DISABLED` env var (`1` or `true`) takes precedence. |