perf: lighten gateway watch startup

This commit is contained in:
Peter Steinberger
2026-05-04 08:36:45 +01:00
parent e11a8a84ac
commit 7129db1960
4 changed files with 180 additions and 74 deletions

View File

@@ -0,0 +1,63 @@
import path from "node:path";
import {
BUNDLED_PLUGIN_PATH_PREFIX,
BUNDLED_PLUGIN_ROOT_DIR,
} from "./lib/bundled-plugin-paths.mjs";
export const runNodeSourceRoots = ["src", BUNDLED_PLUGIN_ROOT_DIR];
export const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"];
export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles];
export const extensionRestartMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]);
const ignoredRunNodeRepoPaths = new Set([
"src/canvas-host/a2ui/.bundle.hash",
"src/canvas-host/a2ui/a2ui.bundle.js",
]);
const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/;
export const normalizeRunNodePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/");
const isIgnoredSourcePath = (relativePath) => {
const normalizedPath = normalizeRunNodePath(relativePath);
return (
normalizedPath.endsWith(".test.ts") ||
normalizedPath.endsWith(".test.tsx") ||
normalizedPath.endsWith("test-helpers.ts")
);
};
const isBuildRelevantSourcePath = (relativePath) => {
const normalizedPath = normalizeRunNodePath(relativePath);
return extensionSourceFilePattern.test(normalizedPath) && !isIgnoredSourcePath(normalizedPath);
};
const isRestartRelevantExtensionPath = (relativePath) => {
const normalizedPath = normalizeRunNodePath(relativePath);
if (extensionRestartMetadataFiles.has(path.posix.basename(normalizedPath))) {
return true;
}
return isBuildRelevantSourcePath(normalizedPath);
};
const isRelevantRunNodePath = (repoPath, isRelevantBundledPluginPath) => {
const normalizedPath = normalizeRunNodePath(repoPath).replace(/^\.\/+/, "");
if (ignoredRunNodeRepoPaths.has(normalizedPath)) {
return false;
}
if (runNodeConfigFiles.includes(normalizedPath)) {
return true;
}
if (normalizedPath.startsWith("src/")) {
return !isIgnoredSourcePath(normalizedPath.slice("src/".length));
}
if (normalizedPath.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) {
return isRelevantBundledPluginPath(normalizedPath.slice(BUNDLED_PLUGIN_PATH_PREFIX.length));
}
return false;
};
export const isBuildRelevantRunNodePath = (repoPath) =>
isRelevantRunNodePath(repoPath, isBuildRelevantSourcePath);
export const isRestartRelevantRunNodePath = (repoPath) =>
isRelevantRunNodePath(repoPath, isRestartRelevantExtensionPath);

View File

@@ -16,14 +16,22 @@ import {
writeRuntimePostBuildStamp as writeDistRuntimePostBuildStamp,
} from "./lib/local-build-metadata.mjs";
import { listStaticExtensionAssetSources } from "./lib/static-extension-assets.mjs";
import {
extensionRestartMetadataFiles,
isBuildRelevantRunNodePath,
isRestartRelevantRunNodePath,
normalizeRunNodePath as normalizePath,
runNodeConfigFiles,
runNodeSourceRoots,
runNodeWatchedPaths,
} from "./run-node-watch-paths.mjs";
import { runRuntimePostBuild } from "./runtime-postbuild.mjs";
export { isBuildRelevantRunNodePath, isRestartRelevantRunNodePath, runNodeWatchedPaths };
const buildScript = "scripts/tsdown-build.mjs";
const compilerArgs = [buildScript, "--no-clean"];
const runNodeSourceRoots = ["src", BUNDLED_PLUGIN_ROOT_DIR];
const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"];
export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles];
const runtimePostBuildWatchedPaths = [
"scripts/copy-bundled-plugin-metadata.mjs",
"scripts/copy-plugin-sdk-root-alias.mjs",
@@ -40,63 +48,10 @@ const runtimePostBuildWatchedPaths = [
"src/plugin-sdk/root-alias.cjs",
BUNDLED_PLUGIN_ROOT_DIR,
];
const ignoredRunNodeRepoPaths = new Set([
"src/canvas-host/a2ui/.bundle.hash",
"src/canvas-host/a2ui/a2ui.bundle.js",
]);
const runtimePostBuildScriptPaths = new Set(
runtimePostBuildWatchedPaths.filter((entry) => entry.startsWith("scripts/")),
);
const runtimePostBuildStaticAssetPaths = new Set(listStaticExtensionAssetSources());
const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/;
const extensionRestartMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]);
const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/");
const isIgnoredSourcePath = (relativePath) => {
const normalizedPath = normalizePath(relativePath);
return (
normalizedPath.endsWith(".test.ts") ||
normalizedPath.endsWith(".test.tsx") ||
normalizedPath.endsWith("test-helpers.ts")
);
};
const isBuildRelevantSourcePath = (relativePath) => {
const normalizedPath = normalizePath(relativePath);
return extensionSourceFilePattern.test(normalizedPath) && !isIgnoredSourcePath(normalizedPath);
};
const isRestartRelevantExtensionPath = (relativePath) => {
const normalizedPath = normalizePath(relativePath);
if (extensionRestartMetadataFiles.has(path.posix.basename(normalizedPath))) {
return true;
}
return isBuildRelevantSourcePath(normalizedPath);
};
const isRelevantRunNodePath = (repoPath, isRelevantBundledPluginPath) => {
const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, "");
if (ignoredRunNodeRepoPaths.has(normalizedPath)) {
return false;
}
if (runNodeConfigFiles.includes(normalizedPath)) {
return true;
}
if (normalizedPath.startsWith("src/")) {
return !isIgnoredSourcePath(normalizedPath.slice("src/".length));
}
if (normalizedPath.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) {
return isRelevantBundledPluginPath(normalizedPath.slice(BUNDLED_PLUGIN_PATH_PREFIX.length));
}
return false;
};
export const isBuildRelevantRunNodePath = (repoPath) =>
isRelevantRunNodePath(repoPath, isBuildRelevantSourcePath);
export const isRestartRelevantRunNodePath = (repoPath) =>
isRelevantRunNodePath(repoPath, isRestartRelevantExtensionPath);
const statMtime = (filePath, fsImpl = fs) => {
try {

View File

@@ -5,7 +5,7 @@ import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import { isRestartRelevantRunNodePath, runNodeWatchedPaths } from "./run-node.mjs";
import { isRestartRelevantRunNodePath, runNodeWatchedPaths } from "./run-node-watch-paths.mjs";
const WATCH_NODE_RUNNER = "scripts/run-node.mjs";
const WATCH_RESTART_SIGNAL = "SIGTERM";
@@ -255,19 +255,6 @@ const releaseWatchLock = (lockHandle) => {
* }} [params]
*/
export async function runWatchMain(params = {}) {
let createWatcher = params.createWatcher;
if (!createWatcher) {
try {
const chokidarModule = await (params.loadChokidar ?? loadChokidar)();
createWatcher = (watchPaths, options) => chokidarModule.watch(watchPaths, options);
} catch (err) {
if (isInvalidPackageConfigError(err)) {
printFriendlyWatchStartupError(err);
}
throw err;
}
}
const deps = {
spawn: params.spawn ?? spawn,
process: params.process ?? process,
@@ -278,7 +265,8 @@ export async function runWatchMain(params = {}) {
sleep: params.sleep ?? sleep,
signalProcess: params.signalProcess ?? ((pid, signal) => process.kill(pid, signal)),
lockDisabled: params.lockDisabled === true,
createWatcher,
createWatcher: params.createWatcher,
loadChokidar: params.loadChokidar ?? loadChokidar,
watchPaths: params.watchPaths ?? runNodeWatchedPaths,
};
@@ -293,7 +281,7 @@ export async function runWatchMain(params = {}) {
childEnv.OPENCLAW_WATCH_COMMAND = deps.args.join(" ");
}
return await new Promise((resolve) => {
return await new Promise((resolve, reject) => {
let settled = false;
let shuttingDown = false;
let restartRequested = false;
@@ -357,6 +345,38 @@ export async function runWatchMain(params = {}) {
settle(1);
};
const rejectWatcherStartupError = (err) => {
if (settled) {
return;
}
settled = true;
shuttingDown = true;
if (watchProcess && typeof watchProcess.kill === "function") {
watchProcess.kill(WATCH_RESTART_SIGNAL);
}
releaseWatchLock(lockHandle);
watcher?.close?.().catch?.(() => {});
if (onSigInt) {
deps.process.off("SIGINT", onSigInt);
}
if (onSigTerm) {
deps.process.off("SIGTERM", onSigTerm);
}
reject(err);
};
const resolveCreateWatcher = async () => {
try {
const chokidarModule = await deps.loadChokidar();
return (watchPaths, options) => chokidarModule.watch(watchPaths, options);
} catch (err) {
if (isInvalidPackageConfigError(err)) {
printFriendlyWatchStartupError(err);
}
throw err;
}
};
const runAutoDoctorAndRestart = () => {
autoDoctorAttempted = true;
logWatcher(
@@ -405,8 +425,11 @@ export async function runWatchMain(params = {}) {
}
};
const startWatcher = () => {
watcher = deps.createWatcher(deps.watchPaths, {
const attachWatcher = (createWatcher) => {
if (settled) {
return;
}
watcher = createWatcher(deps.watchPaths, {
ignoreInitial: true,
ignored: (watchPath, stats) =>
isIgnoredWatchPath(watchPath, deps.cwd, deps.watchPaths, stats),
@@ -417,6 +440,14 @@ export async function runWatchMain(params = {}) {
watcher.on("error", handleWatcherError);
};
const startWatcher = () => {
if (deps.createWatcher) {
attachWatcher(deps.createWatcher);
return;
}
void resolveCreateWatcher().then(attachWatcher).catch(rejectWatcherStartupError);
};
onSigInt = () => {
shuttingDown = true;
if (watchProcess && typeof watchProcess.kill === "function") {

View File

@@ -155,6 +155,49 @@ describe("watch-node script", () => {
});
});
it("starts the runner before loading chokidar", async () => {
const child = Object.assign(new EventEmitter(), {
kill: vi.fn(() => {}),
});
const spawn = vi.fn(() => child);
const watcher = Object.assign(new EventEmitter(), {
close: vi.fn(async () => {}),
});
const watch = vi.fn(() => watcher);
let resolveLoadChokidar: (value: { watch: typeof watch }) => void = () => {};
const loadChokidar = vi.fn(
() =>
new Promise<{ watch: typeof watch }>((resolve) => {
resolveLoadChokidar = resolve;
}),
);
const fakeProcess = createFakeProcess();
const runPromise = runWatch({
args: ["gateway", "--force"],
loadChokidar,
lockDisabled: true,
process: fakeProcess,
spawn,
});
expect(spawn).toHaveBeenCalledTimes(1);
expect(loadChokidar).toHaveBeenCalledTimes(1);
expect(spawn.mock.invocationCallOrder[0]).toBeLessThan(
loadChokidar.mock.invocationCallOrder[0],
);
resolveLoadChokidar({ watch });
await new Promise((resolve) => setImmediate(resolve));
expect(watch).toHaveBeenCalledTimes(1);
fakeProcess.emit("SIGINT");
const exitCode = await runPromise;
expect(exitCode).toBe(130);
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
expect(watcher.close).toHaveBeenCalledTimes(1);
});
it("terminates child on SIGINT and returns shell interrupt code", async () => {
const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness();
@@ -412,6 +455,10 @@ describe("watch-node script", () => {
),
{ code: "ERR_INVALID_PACKAGE_CONFIG" },
);
const child = Object.assign(new EventEmitter(), {
kill: vi.fn(() => {}),
});
const spawn = vi.fn(() => child);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
@@ -423,9 +470,12 @@ describe("watch-node script", () => {
throw error;
}),
process: createFakeProcess(),
spawn,
}),
).rejects.toBe(error);
expect(spawn).toHaveBeenCalledTimes(1);
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
expect(errorSpy.mock.calls).toEqual([
[""],
[
@@ -450,6 +500,10 @@ describe("watch-node script", () => {
const error = Object.assign(new Error("Cannot find package 'chokidar'"), {
code: "ERR_MODULE_NOT_FOUND",
});
const child = Object.assign(new EventEmitter(), {
kill: vi.fn(() => {}),
});
const spawn = vi.fn(() => child);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
@@ -459,9 +513,12 @@ describe("watch-node script", () => {
throw error;
}),
process: createFakeProcess(),
spawn,
}),
).rejects.toBe(error);
expect(spawn).toHaveBeenCalledTimes(1);
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
expect(errorSpy).not.toHaveBeenCalled();
} finally {
errorSpy.mockRestore();