fix: Canvas WebSocket creation in browser context silently swallows... (#82505)

* fix(canvas): report live reload websocket errors

Surface WebSocket initialization failures from the injected canvas live reload client through console diagnostics, DOM attributes, and a browser event instead of silently swallowing them.

* refactor(canvas): keep reload diagnostics console-only

* fix(canvas): distinguish page teardown from socket failure

* style(canvas): brace pagehide listener

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Coy Geek
2026-07-12 02:13:04 -07:00
committed by GitHub
parent 4983594867
commit 3ac83ddfca
2 changed files with 117 additions and 1 deletions
+21 -1
View File
@@ -27,6 +27,16 @@ export function injectCanvasLiveReload(html: string): string {
// - iOS: window.webkit.messageHandlers.openclawCanvasA2UIAction.postMessage(...)
// - Android: window.openclawCanvasA2UIAction.postMessage(...)
const handlerNames = ["openclawCanvasA2UIAction"];
let liveReloadErrorReported = false;
let pageUnloading = false;
globalThis.addEventListener?.("pagehide", () => { pageUnloading = true; }, { once: true });
function reportCanvasLiveReloadError(err) {
if (liveReloadErrorReported) return;
liveReloadErrorReported = true;
try {
console.error("OpenClaw canvas live reload unavailable:", err);
} catch {}
}
function postToNode(payload) {
try {
const raw = typeof payload === "string" ? payload : JSON.stringify(payload);
@@ -67,7 +77,17 @@ export function injectCanvasLiveReload(html: string): string {
ws.onmessage = (ev) => {
if (String(ev.data || "") === "reload") location.reload();
};
} catch {}
ws.onerror = (ev) => {
reportCanvasLiveReloadError(ev);
};
ws.onclose = (ev) => {
if (ev.code !== 1000 && !(ev.code === 1001 && pageUnloading)) {
reportCanvasLiveReloadError(ev);
}
};
} catch (err) {
reportCanvasLiveReloadError(err);
}
})();
</script>
`.trim();
+96
View File
@@ -4,6 +4,7 @@ import type { IncomingMessage } from "node:http";
import os from "node:os";
import path from "node:path";
import type { Duplex } from "node:stream";
import vm from "node:vm";
import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
@@ -117,6 +118,51 @@ async function captureA2uiFixtureResponse(
return await captureHttpResponse(createA2uiHttpRequestHandler({ rootDir }), url, method);
}
function extractInjectedScript(html: string): string {
const match = html.match(/<script>([\s\S]+)<\/script>/);
if (!match?.[1]) {
throw new Error("expected injected canvas live reload script");
}
return match[1];
}
type MockLiveReloadSocket = {
onclose?: (event: { code: number; reason: string }) => void;
onerror?: (event: Error) => void;
onmessage?: (event: { data?: string }) => void;
};
function runInjectedScript(WebSocket: (this: MockLiveReloadSocket) => void) {
const consoleError = vi.fn();
let pagehide: (() => void) | undefined;
const runtime: Record<string, unknown> = {
URLSearchParams,
WebSocket,
addEventListener: (event: string, listener: () => void) => {
if (event === "pagehide") {
pagehide = listener;
}
},
console: { error: consoleError },
encodeURIComponent,
location: {
host: "control.example",
protocol: "https:",
reload: vi.fn(),
search: "",
},
};
runtime.window = runtime;
runtime.self = runtime;
runtime.globalThis = runtime;
vm.createContext(runtime);
vm.runInContext(
extractInjectedScript(injectCanvasLiveReload("<html><body>Hello</body></html>")),
runtime,
);
return { consoleError, pagehide: () => pagehide?.() };
}
describe("canvas host", () => {
const quietRuntime = {
...defaultRuntime,
@@ -194,6 +240,56 @@ describe("canvas host", () => {
expect(out).toContain("openclawSendUserAction");
});
it("reports websocket initialization errors instead of swallowing them silently", () => {
function ThrowingWebSocket(): never {
throw new TypeError("constructor failed");
}
const { consoleError } = runInjectedScript(ThrowingWebSocket);
expect(consoleError).toHaveBeenCalledTimes(1);
expect(consoleError).toHaveBeenCalledWith(
"OpenClaw canvas live reload unavailable:",
expect.objectContaining({ message: "constructor failed" }),
);
});
it("reports asynchronous websocket connection errors once", () => {
const sockets: MockLiveReloadSocket[] = [];
function CapturingWebSocket(this: MockLiveReloadSocket): void {
sockets.push(this);
}
const { consoleError } = runInjectedScript(CapturingWebSocket);
expect(sockets).toHaveLength(1);
sockets[0]?.onerror?.(new Error("connect failed"));
sockets[0]?.onclose?.({ code: 1006, reason: "abnormal closure" });
expect(consoleError).toHaveBeenCalledTimes(1);
});
it("does not report a normal close after connecting", () => {
const sockets: MockLiveReloadSocket[] = [];
const { consoleError, pagehide } = runInjectedScript(function (this: MockLiveReloadSocket) {
sockets.push(this);
});
pagehide();
sockets[0]?.onclose?.({ code: 1001, reason: "page closed" });
expect(consoleError).not.toHaveBeenCalled();
});
it("reports an abnormal close without a preceding error event", () => {
const sockets: MockLiveReloadSocket[] = [];
const { consoleError } = runInjectedScript(function (this: MockLiveReloadSocket) {
sockets.push(this);
});
sockets[0]?.onclose?.({ code: 1011, reason: "server error" });
expect(consoleError).toHaveBeenCalledTimes(1);
});
it("creates a default index.html when missing", async () => {
const dir = await createCaseDir();
const handler = await createTestCanvasHostHandler(dir);