fix(live-ws): reject on bind failure instead of crashing the process (closes #6324) (#6332)

LiveWS server rejects on bind failure instead of crash-looping the process (closes #6324). Integrated into release/v3.8.46.
This commit is contained in:
Vinayak Kulkarni
2026-07-07 01:12:20 +05:30
committed by GitHub
parent 8853b257aa
commit fb3da7ce98
3 changed files with 64 additions and 7 deletions

View File

@@ -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).

View File

@@ -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);

View File

@@ -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<void> }> {
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<void>((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();
}
});
});