mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
perf(server): do not watch bundled plugins for file changes (#16677)
Bundled sandbox-provider plugins are installed from local paths inside the image, so production started a chokidar dev watcher for each of them over files that never change. createPluginDevWatcher now takes unwatchedRoots (compared in resolved and real-path form), and the app passes the bundled plugin catalog root; plugins installed elsewhere are watched as before, and a plugin moving into an unwatched root closes its old watcher and cancels any pending restart. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d30648dee8
commit
bebeb389f1
3 files changed
+91
-2
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { EventEmitter } from "node:events";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -134,6 +134,75 @@ describe("createPluginDevWatcher", () => {
|
||||
devWatcher.close();
|
||||
});
|
||||
|
||||
it("never watches plugins installed from an unwatched root such as the bundled catalog", async () => {
|
||||
const catalogRoot = makeTempPluginDir();
|
||||
const bundledDir = path.join(catalogRoot, "sandbox-providers", "example");
|
||||
writePluginPackage(bundledDir);
|
||||
const localDir = makeTempPluginDir();
|
||||
writePluginPackage(localDir);
|
||||
installMockFsWatcher();
|
||||
const lifecycle = createLifecycle();
|
||||
|
||||
const devWatcher = createPluginDevWatcher(
|
||||
lifecycle as never,
|
||||
async (pluginId) => (pluginId === "bundled" ? bundledDir : localDir),
|
||||
undefined,
|
||||
{ unwatchedRoots: [catalogRoot] },
|
||||
);
|
||||
|
||||
lifecycle.emit("plugin.loaded", { pluginId: "bundled", pluginKey: "bundled" });
|
||||
devWatcher.watch("bundled-direct", bundledDir);
|
||||
lifecycle.emit("plugin.loaded", { pluginId: "local", pluginKey: "local" });
|
||||
|
||||
await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(1));
|
||||
const [watchedPaths] = chokidarMock.watch.mock.calls[0] ?? [];
|
||||
expect(watchedPaths).toContain(path.join(localDir, "dist", "worker.js"));
|
||||
const everyWatched = chokidarMock.watch.mock.calls.flatMap(([paths]) => paths as string[]);
|
||||
expect(everyWatched.some((watched) => watched.startsWith(catalogRoot))).toBe(false);
|
||||
|
||||
devWatcher.close();
|
||||
});
|
||||
|
||||
it("matches unwatched roots through symbolic links", async () => {
|
||||
const realRoot = makeTempPluginDir();
|
||||
const linkParent = makeTempPluginDir();
|
||||
const linkedRoot = path.join(linkParent, "catalog");
|
||||
symlinkSync(realRoot, linkedRoot, "dir");
|
||||
const bundledDir = path.join(realRoot, "sandbox-providers", "example");
|
||||
writePluginPackage(bundledDir);
|
||||
installMockFsWatcher();
|
||||
|
||||
const devWatcher = createPluginDevWatcher(createLifecycle() as never, undefined, undefined, { unwatchedRoots: [linkedRoot] });
|
||||
devWatcher.watch("bundled", bundledDir);
|
||||
devWatcher.watch("bundled-through-link", path.join(linkedRoot, "sandbox-providers", "example"));
|
||||
|
||||
expect(chokidarMock.watch).not.toHaveBeenCalled();
|
||||
devWatcher.close();
|
||||
});
|
||||
|
||||
it("closes the old watcher and cancels a pending restart when a plugin moves into an unwatched root", async () => {
|
||||
vi.useFakeTimers();
|
||||
const localDir = makeTempPluginDir();
|
||||
writePluginPackage(localDir);
|
||||
const catalogRoot = makeTempPluginDir();
|
||||
const bundledDir = path.join(catalogRoot, "sandbox-providers", "example");
|
||||
writePluginPackage(bundledDir);
|
||||
const { fakeWatcher, handlers } = installMockFsWatcher();
|
||||
const lifecycle = createLifecycle();
|
||||
|
||||
const devWatcher = createPluginDevWatcher(lifecycle as never, undefined, undefined, { unwatchedRoots: [catalogRoot] });
|
||||
devWatcher.watch("plugin-1", localDir);
|
||||
handlers.all?.("change", path.join(localDir, "dist", "worker.js"));
|
||||
devWatcher.watch("plugin-1", bundledDir);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(fakeWatcher.close).toHaveBeenCalled();
|
||||
expect(lifecycle.restartWorker).not.toHaveBeenCalled();
|
||||
expect(chokidarMock.watch).toHaveBeenCalledTimes(1);
|
||||
|
||||
devWatcher.close();
|
||||
});
|
||||
|
||||
it("debounces watched file changes and restarts the plugin worker", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pluginDir = makeTempPluginDir();
|
||||
|
||||
@@ -1369,6 +1369,8 @@ export async function createApp(
|
||||
lifecycle,
|
||||
async (pluginId) =>
|
||||
(await pluginRegistry.getById(pluginId))?.packagePath ?? null,
|
||||
undefined,
|
||||
{ unwatchedRoots: [bundledCatalogRoot] },
|
||||
);
|
||||
// Auto-provision bundled plugins so their providers are registered for
|
||||
// agent runs. Bundles are excluded from the pnpm
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* @see PLUGIN_SPEC.md §27.2 — Local Development Workflow
|
||||
*/
|
||||
import chokidar, { type FSWatcher } from "chokidar";
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import type { PluginLifecycleManager } from "./plugin-lifecycle.js";
|
||||
@@ -160,6 +160,7 @@ export function createPluginDevWatcher(
|
||||
lifecycle: PluginLifecycleManager,
|
||||
resolvePluginPackagePath?: ResolvePluginPackagePath,
|
||||
fsDeps?: PluginDevWatcherFsDeps,
|
||||
options: { unwatchedRoots?: readonly string[] } = {},
|
||||
): PluginDevWatcher {
|
||||
const watchers = new Map<string, { watcher: FSWatcher; packagePath: string }>();
|
||||
const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
@@ -167,6 +168,19 @@ export function createPluginDevWatcher(
|
||||
// Never reuse an identity after unwatching: an older lookup may still finish.
|
||||
let registrationGeneration = 0;
|
||||
const fileExists = fsDeps?.existsSync ?? existsSync;
|
||||
const pathForms = (target: string) => {
|
||||
const resolved = path.resolve(target);
|
||||
try {
|
||||
return [resolved, realpathSync(resolved)];
|
||||
} catch {
|
||||
return [resolved];
|
||||
}
|
||||
};
|
||||
const unwatchedRoots = (options.unwatchedRoots ?? []).flatMap(pathForms);
|
||||
const isUnwatched = (absPath: string) => pathForms(absPath).some((candidate) => unwatchedRoots.some((root) => {
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}));
|
||||
log.info(
|
||||
{ resolvesInstalledPlugins: Boolean(resolvePluginPackagePath) },
|
||||
"plugin-dev-watcher: initialized",
|
||||
@@ -179,6 +193,10 @@ export function createPluginDevWatcher(
|
||||
// watcher only while it still targets the installed package path.
|
||||
if (watchers.get(pluginId)?.packagePath === absPath) return;
|
||||
unwatchPlugin(pluginId);
|
||||
if (isUnwatched(absPath)) {
|
||||
log.debug({ pluginId, packagePath: absPath }, "plugin-dev-watcher: plugin path is not watched");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileExists(absPath)) {
|
||||
log.warn(
|
||||
|
||||
Reference in new issue
Block a user