mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
perf: lighten gateway watch startup
This commit is contained in:
63
scripts/run-node-watch-paths.mjs
Normal file
63
scripts/run-node-watch-paths.mjs
Normal 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);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user