Skip to content
Open
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
9 changes: 9 additions & 0 deletions crates/config/src/schema/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand All @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions crates/config/src/schema/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"
Expand Down
1 change: 1 addition & 0 deletions crates/config/src/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions crates/config/src/validate/schema_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
17 changes: 17 additions & 0 deletions crates/config/src/validate/tests/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"
Expand Down
2 changes: 2 additions & 0 deletions crates/web/src/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ pub(crate) struct GonData {
tts_enabled: bool,
graphql_enabled: bool,
terminal_enabled: bool,
rpc_timeout_ms: u64,
git_branch: Option<String>,
mem: MemSnapshot,
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -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()
Expand Down
43 changes: 43 additions & 0 deletions crates/web/ui/e2e/specs/websocket.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
7 changes: 6 additions & 1 deletion crates/web/ui/src/helpers.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions crates/web/ui/src/types/gon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions docs/src/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down