From 0e046f61abcf598db1c25550983e3edde2dc4cc8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Feb 2026 19:25:19 +0100 Subject: [PATCH] fix(skills): avoid skills watcher FD exhaustion Watch SKILL.md only (and one-level SKILL.md in skill roots) to prevent chokidar from tracking huge unrelated trees. Co-authored-by: household-bard --- CHANGELOG.md | 1 + src/agents/skills/refresh.test.ts | 64 +++++++++++++++++++++++++++++++ src/agents/skills/refresh.ts | 21 ++++++++-- 3 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 src/agents/skills/refresh.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ab71058a04c..a2877640b38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Security/Tlon: harden Urbit URL fetching against SSRF by blocking private/internal hosts by default (opt-in: `channels.tlon.allowPrivateNetwork`). Thanks @p80n-sec. - Security/Voice Call (Telnyx): require webhook signature verification when receiving inbound events; configs without `telnyx.publicKey` are now rejected unless `skipSignatureVerification` is enabled. Thanks @p80n-sec. - Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek. +- Skills: watch `SKILL.md` only when refreshing skills snapshot to avoid file-descriptor exhaustion in large data trees. (#11325) Thanks @household-bard. - macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins. - Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238. - TUI: use available terminal width for session name display in searchable select lists. (#16238) Thanks @robbyczgw-cla. diff --git a/src/agents/skills/refresh.test.ts b/src/agents/skills/refresh.test.ts new file mode 100644 index 00000000000..7d81e09ada4 --- /dev/null +++ b/src/agents/skills/refresh.test.ts @@ -0,0 +1,64 @@ +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +const watchMock = vi.fn(() => ({ + on: vi.fn(), + close: vi.fn(async () => undefined), +})); + +vi.mock("chokidar", () => { + return { + default: { watch: watchMock }, + }; +}); + +describe("ensureSkillsWatcher", () => { + it("ignores node_modules, dist, .git, and Python venvs by default", async () => { + const mod = await import("./refresh.js"); + mod.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace" }); + + expect(watchMock).toHaveBeenCalledTimes(1); + const targets = watchMock.mock.calls[0]?.[0] as string[]; + const opts = watchMock.mock.calls[0]?.[1] as { ignored?: unknown }; + + expect(opts.ignored).toBe(mod.DEFAULT_SKILLS_WATCH_IGNORED); + expect(targets).toEqual( + expect.arrayContaining([ + path.join("/tmp/workspace", "skills", "SKILL.md"), + path.join("/tmp/workspace", "skills", "*", "SKILL.md"), + ]), + ); + expect(targets.every((target) => target.includes("SKILL.md"))).toBe(true); + const ignored = mod.DEFAULT_SKILLS_WATCH_IGNORED; + + // Node/JS paths + expect(ignored.some((re) => re.test("/tmp/workspace/skills/node_modules/pkg/index.js"))).toBe( + true, + ); + expect(ignored.some((re) => re.test("/tmp/workspace/skills/dist/index.js"))).toBe(true); + expect(ignored.some((re) => re.test("/tmp/workspace/skills/.git/config"))).toBe(true); + + // Python virtual environments and caches + expect(ignored.some((re) => re.test("/tmp/workspace/skills/scripts/.venv/bin/python"))).toBe( + true, + ); + expect(ignored.some((re) => re.test("/tmp/workspace/skills/venv/lib/python3.10/site.py"))).toBe( + true, + ); + expect(ignored.some((re) => re.test("/tmp/workspace/skills/__pycache__/module.pyc"))).toBe( + true, + ); + expect(ignored.some((re) => re.test("/tmp/workspace/skills/.mypy_cache/3.10/foo.json"))).toBe( + true, + ); + expect(ignored.some((re) => re.test("/tmp/workspace/skills/.pytest_cache/v/cache"))).toBe(true); + + // Build artifacts and caches + expect(ignored.some((re) => re.test("/tmp/workspace/skills/build/output.js"))).toBe(true); + expect(ignored.some((re) => re.test("/tmp/workspace/skills/.cache/data.json"))).toBe(true); + + // Should NOT ignore normal skill files + expect(ignored.some((re) => re.test("/tmp/.hidden/skills/index.md"))).toBe(false); + expect(ignored.some((re) => re.test("/tmp/workspace/skills/my-skill/SKILL.md"))).toBe(false); + }); +}); diff --git a/src/agents/skills/refresh.ts b/src/agents/skills/refresh.ts index 8c407066345..85402024ab5 100644 --- a/src/agents/skills/refresh.ts +++ b/src/agents/skills/refresh.ts @@ -72,6 +72,19 @@ function resolveWatchPaths(workspaceDir: string, config?: OpenClawConfig): strin return paths; } +function resolveWatchTargets(workspaceDir: string, config?: OpenClawConfig): string[] { + // Skills are defined by SKILL.md; watch only those files to avoid traversing + // or watching unrelated large trees (e.g. datasets) that can exhaust FDs. + const targets = new Set(); + for (const root of resolveWatchPaths(workspaceDir, config)) { + // Some configs point directly at a skill folder. + targets.add(path.join(root, "SKILL.md")); + // Standard layout: //SKILL.md + targets.add(path.join(root, "*", "SKILL.md")); + } + return Array.from(targets); +} + export function registerSkillsChangeListener(listener: (event: SkillsChangeEvent) => void) { listeners.add(listener); return () => { @@ -130,8 +143,8 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope return; } - const watchPaths = resolveWatchPaths(workspaceDir, params.config); - const pathsKey = watchPaths.join("|"); + const watchTargets = resolveWatchTargets(workspaceDir, params.config); + const pathsKey = watchTargets.join("|"); if (existing && existing.pathsKey === pathsKey && existing.debounceMs === debounceMs) { return; } @@ -143,14 +156,14 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope void existing.watcher.close().catch(() => {}); } - const watcher = chokidar.watch(watchPaths, { + const watcher = chokidar.watch(watchTargets, { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: debounceMs, pollInterval: 100, }, // Avoid FD exhaustion on macOS when a workspace contains huge trees. - // This watcher only needs to react to skill changes. + // This watcher only needs to react to SKILL.md changes. ignored: DEFAULT_SKILLS_WATCH_IGNORED, });