mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
fix(skills): bound execution watcher generations
This commit is contained in:
@@ -34,6 +34,7 @@ function createMockWatcher() {
|
||||
}
|
||||
|
||||
const createdWatchers: Array<ReturnType<typeof createMockWatcher>> = [];
|
||||
const SKILLS_WORKSPACE_WATCH_MAX_ENTRIES = 128;
|
||||
const watchMock = vi.fn(() => {
|
||||
const watcher = createMockWatcher();
|
||||
createdWatchers.push(watcher);
|
||||
@@ -1040,4 +1041,73 @@ describe("ensureSkillsWatcher", () => {
|
||||
|
||||
expect(createdWatchers[activeSkillsIndex]?.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds execution-root watcher generations by least-recently ensured ownership", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const workspaceDir = "/tmp/workspace-execution-churn";
|
||||
const executionDir = (index: number) => `/tmp/execution-cap-${index}/skills`;
|
||||
|
||||
for (let index = 0; index < SKILLS_WORKSPACE_WATCH_MAX_ENTRIES; index += 1) {
|
||||
refreshModule.ensureSkillsWatcher({ workspaceDir, executionSkillsDir: executionDir(index) });
|
||||
vi.advanceTimersByTime(1);
|
||||
}
|
||||
refreshModule.ensureSkillsWatcher({ workspaceDir, executionSkillsDir: executionDir(0) });
|
||||
vi.advanceTimersByTime(1);
|
||||
|
||||
const watcherIndexFor = (index: number) =>
|
||||
(watchMock.mock.calls as unknown as Array<[string]>).findIndex(
|
||||
([target]) => target === executionDir(index),
|
||||
);
|
||||
const oldestWatcherIndex = watcherIndexFor(1);
|
||||
const refreshedWatcherIndex = watcherIndexFor(0);
|
||||
const versionBeforeEviction = getSkillsSnapshotVersion(workspaceDir);
|
||||
|
||||
refreshModule.ensureSkillsWatcher({
|
||||
workspaceDir,
|
||||
executionSkillsDir: executionDir(SKILLS_WORKSPACE_WATCH_MAX_ENTRIES),
|
||||
});
|
||||
|
||||
expect(createdWatchers[oldestWatcherIndex]?.close).toHaveBeenCalledTimes(1);
|
||||
expect(createdWatchers[refreshedWatcherIndex]?.close).not.toHaveBeenCalled();
|
||||
expect(getSkillsSnapshotVersion(workspaceDir)).toBeGreaterThan(versionBeforeEviction);
|
||||
|
||||
for (
|
||||
let index = SKILLS_WORKSPACE_WATCH_MAX_ENTRIES + 1;
|
||||
index < SKILLS_WORKSPACE_WATCH_MAX_ENTRIES * 10;
|
||||
index += 1
|
||||
) {
|
||||
vi.advanceTimersByTime(1);
|
||||
refreshModule.ensureSkillsWatcher({ workspaceDir, executionSkillsDir: executionDir(index) });
|
||||
}
|
||||
const liveExecutionWatchers = () =>
|
||||
(watchMock.mock.calls as unknown as Array<[string]>).filter(
|
||||
([target], index) =>
|
||||
/^\/tmp\/execution-cap-\d+\/skills$/u.test(target) &&
|
||||
createdWatchers[index]?.close.mock.calls.length === 0,
|
||||
);
|
||||
expect(liveExecutionWatchers()).toHaveLength(SKILLS_WORKSPACE_WATCH_MAX_ENTRIES);
|
||||
const sharedWorkspaceWatcherIndexes = (
|
||||
watchMock.mock.calls as unknown as Array<[string]>
|
||||
).flatMap(([target], index) => (target === path.join(workspaceDir, "skills") ? [index] : []));
|
||||
expect(sharedWorkspaceWatcherIndexes).toHaveLength(1);
|
||||
const sharedWorkspaceWatcherIndex = sharedWorkspaceWatcherIndexes[0];
|
||||
if (sharedWorkspaceWatcherIndex === undefined) {
|
||||
throw new Error("expected a shared workspace watcher");
|
||||
}
|
||||
expect(createdWatchers[sharedWorkspaceWatcherIndex]?.close).not.toHaveBeenCalled();
|
||||
|
||||
const callsBeforeRebuild = watchMock.mock.calls.length;
|
||||
const versionBeforeRebuild = getSkillsSnapshotVersion(workspaceDir);
|
||||
refreshModule.ensureSkillsWatcher({ workspaceDir, executionSkillsDir: executionDir(1) });
|
||||
const rebuildCalls = watchMock.mock.calls.slice(callsBeforeRebuild) as unknown as Array<
|
||||
[string]
|
||||
>;
|
||||
expect(rebuildCalls.some(([target]) => target === executionDir(1))).toBe(true);
|
||||
expect(getSkillsSnapshotVersion(workspaceDir)).toBeGreaterThan(versionBeforeRebuild);
|
||||
expect(liveExecutionWatchers()).toHaveLength(SKILLS_WORKSPACE_WATCH_MAX_ENTRIES);
|
||||
|
||||
await refreshModule.closeSkillsWatchers();
|
||||
expect(createdWatchers.every((watcher) => watcher.close.mock.calls.length > 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { expectDefined, normalizeOptionalString } from "@openclaw/normalization-core";
|
||||
import chokidar, { type FSWatcher } from "chokidar";
|
||||
import { isDefaultStateDir } from "../../config/paths.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
@@ -75,8 +75,8 @@ const workspaceWatchOwnerDirs = new Map<string, string>();
|
||||
// filesystem changes require a fresh root scan.
|
||||
const workspaceWatchTargetCache = new Map<string, WatchTargetCacheEntry>();
|
||||
const workspaceWatchLastEnsuredAt = new Map<string, number>();
|
||||
// Session turns re-ensure their workspace; entries older than this are treated
|
||||
// as abandoned subscriptions and evicted by the next ensure call.
|
||||
// Session turns re-ensure their workspace; idle and cardinality eviction share
|
||||
// disposal so every unwatched interval invalidates the stable workspace version.
|
||||
const SKILLS_WORKSPACE_WATCH_IDLE_TTL_MS = 60 * 60_000;
|
||||
|
||||
setSkillsChangeListenerErrorHandler((err) => {
|
||||
@@ -700,6 +700,10 @@ export function ensureSkillsWatcher(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workspaceWatchLastEnsuredAt.delete(watcherKey) && workspaceWatchLastEnsuredAt.size >= 128) {
|
||||
const oldestKey = expectDefined(workspaceWatchLastEnsuredAt.keys().next().value, "watcher");
|
||||
disposeWorkspaceWatchState(oldestKey);
|
||||
}
|
||||
workspaceWatchLastEnsuredAt.set(watcherKey, now);
|
||||
const watchTargets = resolveWatchTargets(
|
||||
workspaceDir,
|
||||
|
||||
Reference in New Issue
Block a user