fix: dedupe canvas host watcher

This commit is contained in:
Peter Steinberger
2026-01-04 15:15:46 +01:00
parent ec09b06636
commit 1e555e693a
4 changed files with 65 additions and 9 deletions

View File

@@ -59,6 +59,7 @@
- Build: drop stale ClawdisCLI product from macOS build-and-run script.
- Auto-reply: add run-level telemetry + typing TTL guardrails to diagnose stuck replies.
- WhatsApp: honor per-group mention gating overrides when group ids are stored as session keys.
- Canvas host: reuse shared handler to avoid double file watchers and close watchers on error (EMFILE resilience).
- Dependencies: bump pi-mono packages to 0.32.3.
### Docs

View File

@@ -3,7 +3,7 @@ import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws";
import { rawDataToString } from "../infra/ws.js";
import { defaultRuntime } from "../runtime.js";
@@ -100,6 +100,43 @@ describe("canvas host", () => {
}
});
it("reuses a handler without closing it twice", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-canvas-"));
await fs.writeFile(
path.join(dir, "index.html"),
"<html><body>v1</body></html>",
"utf8",
);
const handler = await createCanvasHostHandler({
runtime: defaultRuntime,
rootDir: dir,
basePath: CANVAS_HOST_PATH,
allowInTests: true,
});
const originalClose = handler.close;
const closeSpy = vi.fn(async () => originalClose());
handler.close = closeSpy;
const server = await startCanvasHost({
runtime: defaultRuntime,
handler,
ownsHandler: false,
port: 0,
listenHost: "127.0.0.1",
allowInTests: true,
});
try {
expect(server.port).toBeGreaterThan(0);
} finally {
await server.close();
expect(closeSpy).not.toHaveBeenCalled();
await originalClose();
await fs.rm(dir, { recursive: true, force: true });
}
});
it("serves HTML with injection and broadcasts reload on file changes", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-canvas-"));
const index = path.join(dir, "index.html");

View File

@@ -29,6 +29,11 @@ export type CanvasHostOpts = {
allowInTests?: boolean;
};
export type CanvasHostServerOpts = CanvasHostOpts & {
handler?: CanvasHostHandler;
ownsHandler?: boolean;
};
export type CanvasHostServer = {
port: number;
rootDir: string;
@@ -255,6 +260,7 @@ export async function createCanvasHostHandler(
debounce.unref?.();
};
let watcherClosed = false;
const watcher = chokidar.watch(rootReal, {
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 75, pollInterval: 10 },
@@ -264,6 +270,12 @@ export async function createCanvasHostHandler(
],
});
watcher.on("all", () => scheduleReload());
watcher.on("error", (err) => {
if (watcherClosed) return;
watcherClosed = true;
opts.runtime.error(`canvasHost watcher error: ${String(err)}`);
void watcher.close().catch(() => {});
});
const handleUpgrade = (
req: IncomingMessage,
@@ -361,6 +373,7 @@ export async function createCanvasHostHandler(
handleUpgrade,
close: async () => {
if (debounce) clearTimeout(debounce);
watcherClosed = true;
await watcher.close().catch(() => {});
await new Promise<void>((resolve) => wss.close(() => resolve()));
},
@@ -368,18 +381,21 @@ export async function createCanvasHostHandler(
}
export async function startCanvasHost(
opts: CanvasHostOpts,
opts: CanvasHostServerOpts,
): Promise<CanvasHostServer> {
if (isDisabledByEnv() && opts.allowInTests !== true) {
return { port: 0, rootDir: "", close: async () => {} };
}
const handler = await createCanvasHostHandler({
runtime: opts.runtime,
rootDir: opts.rootDir,
basePath: CANVAS_HOST_PATH,
allowInTests: opts.allowInTests,
});
const handler =
opts.handler ??
(await createCanvasHostHandler({
runtime: opts.runtime,
rootDir: opts.rootDir,
basePath: CANVAS_HOST_PATH,
allowInTests: opts.allowInTests,
}));
const ownsHandler = opts.ownsHandler ?? opts.handler === undefined;
const bindHost = opts.listenHost?.trim() || "0.0.0.0";
const server: Server = http.createServer((req, res) => {
@@ -430,7 +446,7 @@ export async function startCanvasHost(
port: boundPort,
rootDir: handler.rootDir,
close: async () => {
await handler.close();
if (ownsHandler) await handler.close();
await new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())),
);

View File

@@ -860,6 +860,8 @@ export async function startGatewayServer(
port: canvasHostPort,
listenHost: bridgeHost,
allowInTests: opts.allowCanvasHostInTests,
handler: canvasHost ?? undefined,
ownsHandler: canvasHost ? false : undefined,
});
if (started.port > 0) {
canvasHostServer = started;