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:
|
For fast iteration, run the gateway under the file watcher:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm gateway:watch --force
|
pnpm gateway:watch
|
||||||
```
|
```
|
||||||
|
|
||||||
This maps to:
|
This maps to:
|
||||||
|
|
||||||
```bash
|
```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
|
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:
|
Enable it via CLI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm gateway:watch --force --raw-stream
|
pnpm gateway:watch --raw-stream
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional path override:
|
Optional path override:
|
||||||
|
|
||||||
```bash
|
```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:
|
Equivalent env vars:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { pathToFileURL } from "node:url";
|
|||||||
const compiler = "tsdown";
|
const compiler = "tsdown";
|
||||||
const compilerArgs = ["exec", compiler, "--no-clean"];
|
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) => {
|
const statMtime = (filePath, fsImpl = fs) => {
|
||||||
try {
|
try {
|
||||||
@@ -91,7 +91,7 @@ const resolveGitHead = (deps) => {
|
|||||||
|
|
||||||
const hasDirtySourceTree = (deps) => {
|
const hasDirtySourceTree = (deps) => {
|
||||||
const output = runGit(
|
const output = runGit(
|
||||||
["status", "--porcelain", "--untracked-files=normal", "--", ...gitWatchedPaths],
|
["status", "--porcelain", "--untracked-files=normal", "--", ...runNodeWatchedPaths],
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
if (output === null) {
|
if (output === null) {
|
||||||
|
|||||||
@@ -1,65 +1,92 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { spawn, spawnSync } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { runNodeWatchedPaths } from "./run-node.mjs";
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const WATCH_NODE_RUNNER = "scripts/run-node.mjs";
|
||||||
const env = { ...process.env };
|
|
||||||
const cwd = process.cwd();
|
const buildWatchArgs = (args) => [
|
||||||
const compiler = "tsdown";
|
...runNodeWatchedPaths.flatMap((watchPath) => ["--watch-path", watchPath]),
|
||||||
const watchSession = `${Date.now()}-${process.pid}`;
|
"--watch-preserve-output",
|
||||||
env.OPENCLAW_WATCH_MODE = "1";
|
WATCH_NODE_RUNNER,
|
||||||
env.OPENCLAW_WATCH_SESSION = watchSession;
|
...args,
|
||||||
if (args.length > 0) {
|
];
|
||||||
env.OPENCLAW_WATCH_COMMAND = args.join(" ");
|
|
||||||
|
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], {
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||||
cwd,
|
void runWatchMain()
|
||||||
env,
|
.then((code) => process.exit(code))
|
||||||
stdio: "inherit",
|
.catch((err) => {
|
||||||
});
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
if (initialBuild.status !== 0) {
|
});
|
||||||
process.exit(initialBuild.status ?? 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