fix(gateway): remove watch-mode build/start race (#18782)

This commit is contained in:
Josh Avant
2026-02-16 18:24:08 -08:00
committed by GitHub
parent 4b8f53979e
commit 81741c37fd
4 changed files with 169 additions and 65 deletions

View File

@@ -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:

View File

@@ -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) {

View File

@@ -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);
});

View 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);
});
});