| |
| import { spawn, type ChildProcess } from "node:child_process"; |
| import { once } from "node:events"; |
| import fs from "node:fs"; |
| import path from "node:path"; |
| import { afterEach, describe, expect, it, vi } from "vitest"; |
| import { withTestTimeout } from "../../../test/helpers/promise.js"; |
| import { createTempDirTracker } from "../../../test/helpers/temp-dir.js"; |
| import { resolveRuntimeWorkerUrl } from "../../infra/runtime-worker-url.js"; |
| import { gatewayDirectStopEntrypoints } from "../cli-entrypoint.test-support.js"; |
|
|
| const CHILD_READY_TIMEOUT_MS = 45_000; |
| const TEST_TIMEOUT_MS = 60_000; |
| const CHILD_CLOSE_TIMEOUT_MS = 5_000; |
| const RELEASE_DELAY_MS = 400; |
|
|
| const tempDirs = createTempDirTracker(); |
| const children = new Map<ChildProcess, Promise<unknown[]>>(); |
|
|
| function ownChild(child: ChildProcess): Promise<unknown[]> { |
| const closed = once(child, "close"); |
| children.set(child, closed); |
| void closed.catch(() => {}); |
| return closed; |
| } |
|
|
| async function cleanupFixtures() { |
| for (const child of children.keys()) { |
| child.kill("SIGKILL"); |
| } |
| const results = await withTestTimeout( |
| Promise.allSettled(children.values()), |
| CHILD_CLOSE_TIMEOUT_MS, |
| "direct-stop fixture children did not close; retaining their temporary directories", |
| ); |
| const errors = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); |
| if (errors.length) { |
| throw new AggregateError(errors, "direct-stop fixture cleanup failed; retaining state"); |
| } |
| children.clear(); |
| tempDirs.cleanup(); |
| } |
|
|
| afterEach(cleanupFixtures); |
|
|
| const moduleUrl = (entry: Parameters<typeof resolveRuntimeWorkerUrl>[0]) => |
| resolveRuntimeWorkerUrl(entry).href; |
|
|
| const childScript = ` |
| import fs from "node:fs"; |
| import path from "node:path"; |
| import { createChannelIngressDrain } from ${JSON.stringify(moduleUrl(gatewayDirectStopEntrypoints.ingressDrain))}; |
| import { createChannelIngressQueue } from ${JSON.stringify(moduleUrl(gatewayDirectStopEntrypoints.ingressQueue))}; |
| import { |
| clearActiveEmbeddedRun, |
| setActiveEmbeddedRun, |
| } from ${JSON.stringify(moduleUrl(gatewayDirectStopEntrypoints.runs))}; |
| import { getActiveEmbeddedRunCount } from ${JSON.stringify(moduleUrl(gatewayDirectStopEntrypoints.activeRunProjections))}; |
| import { runGatewayLoop } from ${JSON.stringify(moduleUrl(gatewayDirectStopEntrypoints.runLoop))}; |
| import { getActiveGatewayRootWorkCount } from ${JSON.stringify(moduleUrl(gatewayDirectStopEntrypoints.workAdmission))}; |
| |
| const tracePath = process.argv[1]; |
| const stateDir = process.argv[2]; |
| const trace = (line) => { |
| fs.appendFileSync(tracePath, line + "\\n"); |
| process.stdout.write("process proof: " + line + "\\n"); |
| }; |
| const keepAlive = setInterval(() => {}, 1_000); |
| const queue = createChannelIngressQueue({ |
| channelId: "process-proof", |
| accountId: "direct-stop", |
| stateDir, |
| }); |
| let releaseEmbedded; |
| const embeddedMaySettle = new Promise((resolve) => { |
| releaseEmbedded = resolve; |
| }); |
| let resolveAdopted; |
| const adopted = new Promise((resolve) => { |
| resolveAdopted = resolve; |
| }); |
| const sessionId = "direct-stop-active-work"; |
| const sessionKey = "agent:main:process-proof:direct-stop-active-work"; |
| const handle = { |
| runId: "run-direct-stop-active-work", |
| queueMessage: async () => {}, |
| isStreaming: () => true, |
| isCompacting: () => false, |
| abort: () => trace("embedded-aborted"), |
| }; |
| const drain = createChannelIngressDrain({ |
| queue, |
| dispatchClaimedEvent: async (_event, lifecycle) => { |
| setActiveEmbeddedRun(sessionId, handle, sessionKey); |
| await lifecycle.onAdopted(); |
| trace( |
| "adopted:roots=" + |
| getActiveGatewayRootWorkCount() + |
| ":embedded=" + |
| getActiveEmbeddedRunCount(), |
| ); |
| resolveAdopted(); |
| await embeddedMaySettle; |
| clearActiveEmbeddedRun(sessionId, handle, sessionKey); |
| trace("embedded-completed"); |
| }, |
| }); |
| |
| await queue.enqueue("event-direct-stop", { text: "hello" }, { laneKey: sessionKey }); |
| process.prependOnceListener("SIGTERM", () => { |
| trace("signal:SIGTERM"); |
| setTimeout(() => releaseEmbedded(), ${RELEASE_DELAY_MS}); |
| }); |
| |
| await runGatewayLoop({ |
| start: async () => { |
| await drain.drainOnce(); |
| await adopted; |
| setImmediate(() => trace("gateway-ready")); |
| return { |
| getTailscaleIngressEndpoint: () => undefined, |
| startupSettled: Promise.resolve(), |
| close: async () => { |
| trace("gateway-close"); |
| drain.dispose(); |
| }, |
| }; |
| }, |
| runtime: { |
| log: () => {}, |
| error: () => {}, |
| exit: (code) => { |
| clearInterval(keepAlive); |
| trace("process-exit:" + code); |
| process.exit(code); |
| }, |
| }, |
| lockPort: 19473, |
| }); |
| `; |
|
|
| function readTrace(tracePath: string): string[] { |
| try { |
| return fs.readFileSync(tracePath, "utf8").split("\n").filter(Boolean); |
| } catch (error) { |
| if ((error as NodeJS.ErrnoException).code === "ENOENT") { |
| return []; |
| } |
| throw error; |
| } |
| } |
|
|
| describe("runGatewayLoop direct-stop active work", () => { |
| const posixIt = process.platform === "win32" ? it.skip : it; |
|
|
| posixIt("joins forced child cleanup before deleting its fixture directory", async () => { |
| const fixtureDir = tempDirs.make("openclaw-direct-stop-failure-"); |
| const child = spawn( |
| process.execPath, |
| ["-e", 'process.stdout.write("ready"); setInterval(() => {}, 1_000)'], |
| { stdio: ["ignore", "pipe", "ignore"] }, |
| ); |
| const closed = ownChild(child); |
| let rootExistsAtClose = false; |
| child.once("close", () => { |
| rootExistsAtClose = fs.existsSync(fixtureDir); |
| }); |
| await withTestTimeout( |
| once(child.stdout!, "data"), |
| CHILD_READY_TIMEOUT_MS, |
| "forced cleanup fixture did not start", |
| ); |
|
|
| await cleanupFixtures(); |
|
|
| expect(await closed).toEqual([null, "SIGKILL"]); |
| expect(rootExistsAtClose).toBe(true); |
| expect(fs.existsSync(fixtureDir)).toBe(false); |
| }); |
|
|
| posixIt.each([false, true])( |
| "reports and drains a rootless adopted channel run after OS SIGTERM (trace=%s)", |
| async (traceEnabled) => { |
| const fixtureDir = tempDirs.make("openclaw-direct-stop-active-work-"); |
| const stateDir = path.join(fixtureDir, "state"); |
| const homeDir = path.join(fixtureDir, "home"); |
| const tracePath = path.join(fixtureDir, "trace.log"); |
| fs.mkdirSync(stateDir, { recursive: true }); |
| fs.mkdirSync(homeDir, { recursive: true }); |
| const child = spawn( |
| process.execPath, |
| ["--import", "tsx", "--input-type=module", "--eval", childScript, tracePath, stateDir], |
| { |
| cwd: path.resolve("."), |
| env: { |
| ...process.env, |
| HOME: homeDir, |
| NODE_ENV: undefined, |
| NODE_DISABLE_COMPILE_CACHE: "1", |
| NODE_OPTIONS: undefined, |
| OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"), |
| OPENCLAW_STATE_DIR: stateDir, |
| OPENCLAW_GATEWAY_RESTART_TRACE: traceEnabled ? "1" : undefined, |
| VITEST: undefined, |
| }, |
| stdio: ["ignore", "pipe", "pipe"], |
| }, |
| ); |
| |
| const exited = ownChild(child); |
| const stdout: Buffer[] = []; |
| const stderr: Buffer[] = []; |
| child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk)); |
| child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk)); |
|
|
| await vi.waitFor( |
| () => { |
| expect(readTrace(tracePath), Buffer.concat(stderr).toString("utf8")).toContain( |
| "gateway-ready", |
| ); |
| }, |
| { timeout: CHILD_READY_TIMEOUT_MS, interval: 25 }, |
| ); |
| |
| expect(child.kill("SIGTERM")).toBe(true); |
|
|
| const exit = await exited; |
| expect( |
| exit, |
| `${readTrace(tracePath).join(" -> ")}\n${Buffer.concat(stderr).toString("utf8")}`, |
| ).toEqual([0, null]); |
| children.delete(child); |
| const trace = readTrace(tracePath); |
| expect(trace).toContain("adopted:roots=0:embedded=1"); |
| expect(trace).not.toContain("embedded-aborted"); |
| expect(trace.indexOf("signal:SIGTERM")).toBeLessThan(trace.indexOf("embedded-completed")); |
| expect(trace.indexOf("embedded-completed")).toBeLessThan(trace.indexOf("gateway-close")); |
| expect(trace.indexOf("gateway-close")).toBeLessThan(trace.indexOf("process-exit:0")); |
| const output = Buffer.concat(stdout).toString("utf8"); |
| expect(output).toContain("embeddedRuns=1"); |
| expect(output.indexOf("embeddedRuns=1")).toBeLessThan( |
| output.indexOf("process proof: embedded-completed"), |
| ); |
| expect(output).toContain("active-work drain settled; beginning server close"); |
| expect(output.indexOf("active-work drain settled; beginning server close")).toBeLessThan( |
| output.indexOf("process proof: gateway-close"), |
| ); |
| if (traceEnabled) { |
| expect(output).toContain("restart trace: stop.signal.received "); |
| expect(output).toContain("restart trace: stop.drain.begin "); |
| expect(output).toContain("restart trace: stop.drain "); |
| } else { |
| expect(output).not.toContain("restart trace:"); |
| } |
| }, |
| TEST_TIMEOUT_MS, |
| ); |
| }); |
|
|