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 <shakespeare@hessianinformatics.com>
This commit is contained in:
Peter Steinberger 2026-02-14 19:25:19 +01:00
parent 01b3226ecb
commit 0e046f61ab
3 changed files with 82 additions and 4 deletions

View File

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

View File

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

View File

@ -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<string>();
for (const root of resolveWatchPaths(workspaceDir, config)) {
// Some configs point directly at a skill folder.
targets.add(path.join(root, "SKILL.md"));
// Standard layout: <skillsRoot>/<skillName>/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,
});