mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix(gateway): remove watch-mode build/start race (#18782)
This commit is contained in:
@@ -34,13 +34,13 @@ Examples:
|
||||
For fast iteration, run the gateway under the file watcher:
|
||||
|
||||
```bash
|
||||
pnpm gateway:watch --force
|
||||
pnpm gateway:watch
|
||||
```
|
||||
|
||||
This maps to:
|
||||
|
||||
```bash
|
||||
tsx watch src/entry.ts gateway --force
|
||||
node --watch-path src --watch-path tsconfig.json --watch-path package.json --watch-preserve-output scripts/run-node.mjs gateway --force
|
||||
```
|
||||
|
||||
Add any gateway CLI flags after `gateway:watch` and they will be passed through
|
||||
@@ -113,13 +113,13 @@ This is the best way to see whether reasoning is arriving as plain text deltas
|
||||
Enable it via CLI:
|
||||
|
||||
```bash
|
||||
pnpm gateway:watch --force --raw-stream
|
||||
pnpm gateway:watch --raw-stream
|
||||
```
|
||||
|
||||
Optional path override:
|
||||
|
||||
```bash
|
||||
pnpm gateway:watch --force --raw-stream --raw-stream-path ~/.openclaw/logs/raw-stream.jsonl
|
||||
pnpm gateway:watch --raw-stream --raw-stream-path ~/.openclaw/logs/raw-stream.jsonl
|
||||
```
|
||||
|
||||
Equivalent env vars:
|
||||
|
||||
@@ -8,7 +8,7 @@ import { pathToFileURL } from "node:url";
|
||||
const compiler = "tsdown";
|
||||
const compilerArgs = ["exec", compiler, "--no-clean"];
|
||||
|
||||
const gitWatchedPaths = ["src", "tsconfig.json", "package.json"];
|
||||
export const runNodeWatchedPaths = ["src", "tsconfig.json", "package.json"];
|
||||
|
||||
const statMtime = (filePath, fsImpl = fs) => {
|
||||
try {
|
||||
@@ -91,7 +91,7 @@ const resolveGitHead = (deps) => {
|
||||
|
||||
const hasDirtySourceTree = (deps) => {
|
||||
const output = runGit(
|
||||
["status", "--porcelain", "--untracked-files=normal", "--", ...gitWatchedPaths],
|
||||
["status", "--porcelain", "--untracked-files=normal", "--", ...runNodeWatchedPaths],
|
||||
deps,
|
||||
);
|
||||
if (output === null) {
|
||||
|
||||
@@ -1,65 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { spawn } from "node:child_process";
|
||||
import process from "node:process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { runNodeWatchedPaths } from "./run-node.mjs";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const env = { ...process.env };
|
||||
const cwd = process.cwd();
|
||||
const compiler = "tsdown";
|
||||
const watchSession = `${Date.now()}-${process.pid}`;
|
||||
env.OPENCLAW_WATCH_MODE = "1";
|
||||
env.OPENCLAW_WATCH_SESSION = watchSession;
|
||||
if (args.length > 0) {
|
||||
env.OPENCLAW_WATCH_COMMAND = args.join(" ");
|
||||
const WATCH_NODE_RUNNER = "scripts/run-node.mjs";
|
||||
|
||||
const buildWatchArgs = (args) => [
|
||||
...runNodeWatchedPaths.flatMap((watchPath) => ["--watch-path", watchPath]),
|
||||
"--watch-preserve-output",
|
||||
WATCH_NODE_RUNNER,
|
||||
...args,
|
||||
];
|
||||
|
||||
export async function runWatchMain(params = {}) {
|
||||
const deps = {
|
||||
spawn: params.spawn ?? spawn,
|
||||
process: params.process ?? process,
|
||||
cwd: params.cwd ?? process.cwd(),
|
||||
args: params.args ?? process.argv.slice(2),
|
||||
env: params.env ? { ...params.env } : { ...process.env },
|
||||
now: params.now ?? Date.now,
|
||||
};
|
||||
|
||||
const childEnv = { ...deps.env };
|
||||
const watchSession = `${deps.now()}-${deps.process.pid}`;
|
||||
childEnv.OPENCLAW_WATCH_MODE = "1";
|
||||
childEnv.OPENCLAW_WATCH_SESSION = watchSession;
|
||||
if (deps.args.length > 0) {
|
||||
childEnv.OPENCLAW_WATCH_COMMAND = deps.args.join(" ");
|
||||
}
|
||||
|
||||
const watchProcess = deps.spawn(deps.process.execPath, buildWatchArgs(deps.args), {
|
||||
cwd: deps.cwd,
|
||||
env: childEnv,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
let onSigInt;
|
||||
let onSigTerm;
|
||||
|
||||
const settle = (resolve, code) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (onSigInt) {
|
||||
deps.process.off("SIGINT", onSigInt);
|
||||
}
|
||||
if (onSigTerm) {
|
||||
deps.process.off("SIGTERM", onSigTerm);
|
||||
}
|
||||
resolve(code);
|
||||
};
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
onSigInt = () => {
|
||||
if (typeof watchProcess.kill === "function") {
|
||||
watchProcess.kill("SIGTERM");
|
||||
}
|
||||
settle(resolve, 130);
|
||||
};
|
||||
onSigTerm = () => {
|
||||
if (typeof watchProcess.kill === "function") {
|
||||
watchProcess.kill("SIGTERM");
|
||||
}
|
||||
settle(resolve, 143);
|
||||
};
|
||||
|
||||
deps.process.on("SIGINT", onSigInt);
|
||||
deps.process.on("SIGTERM", onSigTerm);
|
||||
|
||||
watchProcess.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
settle(resolve, 1);
|
||||
return;
|
||||
}
|
||||
settle(resolve, code ?? 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const initialBuild = spawnSync("pnpm", ["exec", compiler], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (initialBuild.status !== 0) {
|
||||
process.exit(initialBuild.status ?? 1);
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
void runWatchMain()
|
||||
.then((code) => process.exit(code))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
const compilerProcess = spawn("pnpm", ["exec", compiler, "--watch"], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
const nodeProcess = spawn(process.execPath, ["--watch", "openclaw.mjs", ...args], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
let exiting = false;
|
||||
|
||||
function cleanup(code = 0) {
|
||||
if (exiting) {
|
||||
return;
|
||||
}
|
||||
exiting = true;
|
||||
nodeProcess.kill("SIGTERM");
|
||||
compilerProcess.kill("SIGTERM");
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => cleanup(130));
|
||||
process.on("SIGTERM", () => cleanup(143));
|
||||
|
||||
compilerProcess.on("exit", (code) => {
|
||||
if (exiting) {
|
||||
return;
|
||||
}
|
||||
cleanup(code ?? 1);
|
||||
});
|
||||
|
||||
nodeProcess.on("exit", (code, signal) => {
|
||||
if (signal || exiting) {
|
||||
return;
|
||||
}
|
||||
cleanup(code ?? 1);
|
||||
});
|
||||
|
||||
77
src/infra/watch-node.test.ts
Normal file
77
src/infra/watch-node.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runNodeWatchedPaths } from "../../scripts/run-node.mjs";
|
||||
import { runWatchMain } from "../../scripts/watch-node.mjs";
|
||||
|
||||
const createFakeProcess = () =>
|
||||
Object.assign(new EventEmitter(), {
|
||||
pid: 4242,
|
||||
execPath: "/usr/local/bin/node",
|
||||
}) as unknown as NodeJS.Process;
|
||||
|
||||
describe("watch-node script", () => {
|
||||
it("wires node watch to run-node with watched source/config paths", async () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
kill: vi.fn(),
|
||||
});
|
||||
const spawn = vi.fn(() => child);
|
||||
const fakeProcess = createFakeProcess();
|
||||
|
||||
const runPromise = runWatchMain({
|
||||
args: ["gateway", "--force"],
|
||||
cwd: "/tmp/openclaw",
|
||||
env: { PATH: "/usr/bin" },
|
||||
now: () => 1700000000000,
|
||||
process: fakeProcess,
|
||||
spawn,
|
||||
});
|
||||
|
||||
queueMicrotask(() => child.emit("exit", 0, null));
|
||||
const exitCode = await runPromise;
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
"/usr/local/bin/node",
|
||||
[
|
||||
...runNodeWatchedPaths.flatMap((watchPath) => ["--watch-path", watchPath]),
|
||||
"--watch-preserve-output",
|
||||
"scripts/run-node.mjs",
|
||||
"gateway",
|
||||
"--force",
|
||||
],
|
||||
expect.objectContaining({
|
||||
cwd: "/tmp/openclaw",
|
||||
stdio: "inherit",
|
||||
env: expect.objectContaining({
|
||||
PATH: "/usr/bin",
|
||||
OPENCLAW_WATCH_MODE: "1",
|
||||
OPENCLAW_WATCH_SESSION: "1700000000000-4242",
|
||||
OPENCLAW_WATCH_COMMAND: "gateway --force",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("terminates child on SIGINT and returns shell interrupt code", async () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
kill: vi.fn(),
|
||||
});
|
||||
const spawn = vi.fn(() => child);
|
||||
const fakeProcess = createFakeProcess();
|
||||
|
||||
const runPromise = runWatchMain({
|
||||
args: ["gateway", "--force"],
|
||||
process: fakeProcess,
|
||||
spawn,
|
||||
});
|
||||
|
||||
fakeProcess.emit("SIGINT");
|
||||
const exitCode = await runPromise;
|
||||
|
||||
expect(exitCode).toBe(130);
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
expect(fakeProcess.listenerCount("SIGINT")).toBe(0);
|
||||
expect(fakeProcess.listenerCount("SIGTERM")).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user