diff --git a/CHANGELOG.md b/CHANGELOG.md index 31153806f..9c4616ac4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index f1f8c0652..ec4f6ec9c 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -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"), + "v1", + "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"); diff --git a/src/canvas-host/server.ts b/src/canvas-host/server.ts index f8f43b85a..00dc1f83e 100644 --- a/src/canvas-host/server.ts +++ b/src/canvas-host/server.ts @@ -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((resolve) => wss.close(() => resolve())); }, @@ -368,18 +381,21 @@ export async function createCanvasHostHandler( } export async function startCanvasHost( - opts: CanvasHostOpts, + opts: CanvasHostServerOpts, ): Promise { 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((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())), ); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 0d50a055b..4c9fb42d5 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -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;