diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a5f5092b..2e0f34140e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ ### πŸ› Bug Fixes +- **fix(live-ws):** the Live Dashboard WebSocket server now **rejects on bind failure** (e.g. `EADDRINUSE` when the API bridge already holds the port) instead of letting the error surface as an unhandled `error` event that crash-loops the process β€” the `error` listener is attached to `wss` (not `server`) and releases the EventBus subscription on a failed start ([#6324](https://github.com/diegosouzapw/OmniRoute/issues/6324)). Regression guard: `tests/unit/live-ws-eaddrinuse-6324.test.ts`. (thanks @vinayakkulkarni) - **fix(dashboard):** the Home provider-topology widget now trusts the live provider-metrics snapshot β€” it uses `topology.errorProvider` and live `activeRequests` directly instead of re-deriving state from a stale `lastErrorAt` or applying a frontend timeout filter, so the topology reflects real-time provider health. Regression guard: `tests/unit/home-provider-topology-live-state.test.ts`. (thanks @xz-dev) - **fix(sse):** strip zero-width markers from streamed **tool-call arguments** β€” a follow-up to [#5857](https://github.com/diegosouzapw/OmniRoute/pull/5857). That PR removed injected zero-width joiners (U+200D) from streamed assistant text/reasoning but deliberately left tool-call argument JSON byte-exact. The request-side obfuscation (`open-sse/services/claudeCodeObfuscation.ts`) injects ZWJ into agent words β€” including the temp path inside the Bash tool description β€” and Claude models copy that verbatim into generated commands, which are delivered as tool-call arguments rather than assistant text. As a result the ZWJ survived and corrupted code blocks (e.g. a temp path rendered with an invisible joiner). Now `open-sse/handlers/responseSanitizer.ts` strips zero-width code points from tool-call argument strings at every emit site (OpenAI non-stream/stream chat `tool_calls` + legacy `function_call`, native Responses `function_call` items, the OpenAIβ†’Responses conversion, and the native Responses streaming `response.function_call_arguments.delta/.done` events). Only zero-width code points are removed; JSON structure and all other bytes stay identical (no parse/restringify), so normal arguments remain byte-exact. Regression guard: 6 new cases in `tests/unit/response-sanitizer.test.ts` (suite 50/50). diff --git a/src/server/ws/liveServer.ts b/src/server/ws/liveServer.ts index 1275545572..c3bbccdc89 100644 --- a/src/server/ws/liveServer.ts +++ b/src/server/ws/liveServer.ts @@ -20,12 +20,7 @@ import { randomUUID } from "crypto"; // ── Types ───────────────────────────────────────────────────────────────── -import type { - WsClientMessage, - WsServerMessage, - WsEventMessage, - WsAuthResult, -} from "./types"; +import type { WsClientMessage, WsServerMessage, WsEventMessage, WsAuthResult } from "./types"; import { emit, on, onAny, getEventHistory, type HistoryEntry } from "@/lib/events/eventBus"; @@ -580,7 +575,19 @@ export async function startLiveDashboardServer( clients.clear(); }); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { + // Reject on bind failure (e.g. EADDRINUSE when the API bridge already holds + // 20129) instead of crashing the process β€” the crash-loop of issue #6324. + // The listener MUST be on `wss`, not `server`: ws re-emits the server's + // "error" via wss.emit(), which throws synchronously when wss has no + // listener β€” before any `server.on("error")` handler could run. The server + // never opened, so wss.on("close") won't fire; release the EventBus + // subscription here so a failed start leaks nothing. + wss.once("error", (err: NodeJS.ErrnoException) => { + unsubscribe(); + clients.clear(); + reject(err); + }); server.listen(port, host, () => { console.log("[LiveWS] Dashboard WebSocket server listening on %s:%d", host, port); resolve(server); diff --git a/tests/unit/live-ws-eaddrinuse-6324.test.ts b/tests/unit/live-ws-eaddrinuse-6324.test.ts new file mode 100644 index 0000000000..170af0b9f4 --- /dev/null +++ b/tests/unit/live-ws-eaddrinuse-6324.test.ts @@ -0,0 +1,49 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import net from "node:net"; + +// #6324 regression: startLiveDashboardServer must REJECT on a bind failure +// (e.g. EADDRINUSE when the API bridge already holds 20129) rather than let the +// error surface as an unhandled 'error' event that crashes the process. The +// 3.8.45 standalone bin auto-imports liveServer.ts, whose module-level start +// already has a `.catch`; before this fix the listen error never reached it. +// +// Importing liveServer.ts is safe here: its auto-start guard is short-circuited +// by isBuildOrTest() (the node:test runner passes "--test" in process.argv). +const { startLiveDashboardServer } = await import("../../src/server/ws/liveServer.ts"); + +function occupyPort(host: string): Promise<{ port: number; release: () => Promise }> { + return new Promise((resolve, reject) => { + const blocker = net.createServer(); + blocker.once("error", reject); + blocker.listen(0, host, () => { + const address = blocker.address(); + if (address && typeof address === "object") { + resolve({ + port: address.port, + release: () => new Promise((r) => blocker.close(() => r())), + }); + } else { + blocker.close(() => reject(new Error("Failed to allocate a port"))); + } + }); + }); +} + +describe("#6324 LiveWS start degrades gracefully on port conflict", () => { + test("startLiveDashboardServer rejects with EADDRINUSE when the port is taken", async () => { + const host = "127.0.0.1"; + const { port, release } = await occupyPort(host); + try { + await assert.rejects( + () => startLiveDashboardServer(port, host), + (err: NodeJS.ErrnoException) => { + assert.equal(err.code, "EADDRINUSE"); + return true; + } + ); + } finally { + await release(); + } + }); +});