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:
parent
01b3226ecb
commit
0e046f61ab
@ -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.
|
||||
|
||||
64
src/agents/skills/refresh.test.ts
Normal file
64
src/agents/skills/refresh.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user