| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| import { parseCmdScriptCommandLine } from "../../daemon/cmd-argv.js"; |
| import { |
| readWindowsTaskSupervisorRestartExitCode, |
| WINDOWS_TASK_SUPERVISOR_CHILD_FLAG, |
| WINDOWS_TASK_SUPERVISOR_RESTART_EXIT_CODE_MAX, |
| WINDOWS_TASK_SUPERVISOR_RESTART_EXIT_CODE_MIN, |
| } from "../../daemon/windows-task-supervisor-contract.js"; |
| import type { SpawnInput } from "../../process/supervisor/types.js"; |
|
|
| const { spawn, log, flushLogger, bindWindowsTaskLauncher } = vi.hoisted(() => ({ |
| spawn: vi.fn(), |
| log: { info: vi.fn(), error: vi.fn() }, |
| flushLogger: vi.fn(async () => {}), |
| bindWindowsTaskLauncher: vi.fn(), |
| })); |
|
|
| vi.mock("koffi", () => ({ default: {} })); |
| vi.mock("../../process/supervisor/service-child-windows-task-launcher.js", () => ({ |
| bindWindowsTaskLauncher, |
| })); |
|
|
| vi.mock("../../logging/subsystem.js", () => ({ |
| createSubsystemLogger: () => log, |
| })); |
|
|
| vi.mock("../../logging/logger.js", () => ({ flushLogger })); |
|
|
| vi.mock("../../process/supervisor/index.js", () => ({ |
| getProcessSupervisor: () => ({ spawn }), |
| })); |
|
|
| function readSpawnRestartExitCode(input: SpawnInput): number { |
| if (input.mode !== "anchored-shell") { |
| throw new Error("Expected anchored shell input"); |
| } |
| const exitCode = readWindowsTaskSupervisorRestartExitCode( |
| parseCmdScriptCommandLine(input.command), |
| ); |
| if (exitCode === undefined) { |
| throw new Error("Expected a correlated task-supervisor restart code"); |
| } |
| expect(exitCode).toBeGreaterThanOrEqual(WINDOWS_TASK_SUPERVISOR_RESTART_EXIT_CODE_MIN); |
| expect(exitCode).toBeLessThanOrEqual(WINDOWS_TASK_SUPERVISOR_RESTART_EXIT_CODE_MAX); |
| return exitCode; |
| } |
|
|
| describe("Windows Gateway task supervisor", () => { |
| const argv = [...process.argv]; |
| const execArgv = [...process.execArgv]; |
| const exitCode = process.exitCode; |
| const launcherMarker = process.env.OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER; |
|
|
| beforeEach(() => { |
| process.argv = [ |
| process.execPath, |
| "C:\\OpenClaw\\dist\\entry.js", |
| "gateway", |
| "--task-supervisor", |
| ]; |
| process.execArgv = ["--import", "tsx"]; |
| process.exitCode = undefined; |
| delete process.env.OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER; |
| vi.spyOn(process, "platform", "get").mockReturnValue("win32"); |
| }); |
|
|
| afterEach(() => { |
| process.argv = [...argv]; |
| process.execArgv = [...execArgv]; |
| process.exitCode = exitCode; |
| if (launcherMarker === undefined) { |
| delete process.env.OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER; |
| } else { |
| process.env.OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER = launcherMarker; |
| } |
| vi.restoreAllMocks(); |
| vi.clearAllMocks(); |
| spawn.mockReset(); |
| bindWindowsTaskLauncher.mockReset(); |
| }); |
|
|
| it("binds launcher ownership before admitting a child and consumes the launcher marker", async () => { |
| process.env.OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER = "wscript"; |
| bindWindowsTaskLauncher.mockImplementation(() => { |
| expect(spawn).not.toHaveBeenCalled(); |
| expect(process.env.OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER).toBeUndefined(); |
| }); |
| spawn.mockImplementation(async () => { |
| expect(bindWindowsTaskLauncher).toHaveBeenCalledOnce(); |
| expect(process.env.OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER).toBeUndefined(); |
| return { cancel: vi.fn(), wait: async () => ({ exitCode: 0, exitSignal: null }) }; |
| }); |
| const { runWindowsGatewayTaskSupervisor } = await import("./task-supervisor.js"); |
| await runWindowsGatewayTaskSupervisor(); |
| expect(spawn).toHaveBeenCalledOnce(); |
| expect(bindWindowsTaskLauncher).toHaveBeenCalledOnce(); |
| }); |
|
|
| it("does not admit a Gateway after its task launcher has exited", async () => { |
| process.env.OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER = "wscript"; |
| spawn.mockResolvedValue({ |
| cancel: vi.fn(), |
| wait: async () => ({ exitCode: 0, exitSignal: null }), |
| }); |
| bindWindowsTaskLauncher.mockImplementation(() => { |
| throw new Error("Windows task WScript launcher is no longer live"); |
| }); |
| const { runWindowsGatewayTaskSupervisor } = await import("./task-supervisor.js"); |
| await runWindowsGatewayTaskSupervisor(); |
| expect(spawn).not.toHaveBeenCalled(); |
| expect(process.exitCode).toBe(1); |
| expect(JSON.stringify(log.error.mock.calls)).toContain("WScript launcher is no longer live"); |
| expect(process.env.OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER).toBeUndefined(); |
| }); |
|
|
| it("runs the Gateway child through the anchored Job Object and waits for its tree", async () => { |
| |
| process.env.OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER = "1"; |
| const waitForExtinction = vi.fn(async () => {}); |
| spawn.mockResolvedValue({ |
| cancel: vi.fn(), |
| wait: async () => ({ exitCode: 0, exitSignal: null }), |
| waitForExtinction, |
| }); |
| const { runWindowsGatewayTaskSupervisor } = await import("./task-supervisor.js"); |
| await runWindowsGatewayTaskSupervisor(); |
|
|
| expect(bindWindowsTaskLauncher).not.toHaveBeenCalled(); |
| expect(spawn).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| mode: "anchored-shell", |
| command: expect.stringContaining("gateway"), |
| scopeKey: `gateway-task-supervisor:${process.pid}`, |
| captureOutput: false, |
| }), |
| ); |
| expect(spawn.mock.calls[0]?.[0].command).not.toMatch(/"--task-supervisor"(?:\s|$)/u); |
| expect(spawn.mock.calls[0]?.[0].command).toContain(`${WINDOWS_TASK_SUPERVISOR_CHILD_FLAG}=`); |
| expect(spawn.mock.calls[0]?.[0].command).toContain("--import"); |
| expect(spawn.mock.calls[0]?.[0].command).toContain("tsx"); |
| readSpawnRestartExitCode(spawn.mock.calls[0]?.[0]); |
| expect(waitForExtinction).toHaveBeenCalledOnce(); |
| }); |
|
|
| it.each([ |
| { exitCode: 23, exitSignal: null, reason: "exit", expectedCode: 23 }, |
| { exitCode: null, exitSignal: "SIGTERM", reason: "signal", expectedCode: 1 }, |
| { exitCode: 0, exitSignal: null, reason: "exit", expectedCode: 0 }, |
| ])("records child result $exitCode/$exitSignal and preserves its task result", async (result) => { |
| const stderr = "Gateway failed to bind its configured port\n"; |
| spawn.mockImplementation(async (input: SpawnInput) => { |
| input.onStderr?.(stderr); |
| return { |
| cancel: vi.fn(), |
| wait: async () => result, |
| waitForExtinction: async () => {}, |
| }; |
| }); |
|
|
| const { runWindowsGatewayTaskSupervisor } = await import("./task-supervisor.js"); |
| await runWindowsGatewayTaskSupervisor(); |
|
|
| expect(process.exitCode ?? 0).toBe(result.expectedCode); |
| const diagnostic = result.exitCode === 0 ? log.info : log.error; |
| expect(diagnostic).toHaveBeenCalledWith( |
| expect.any(String), |
| expect.objectContaining({ |
| exitCode: result.exitCode, |
| exitSignal: result.exitSignal, |
| reason: result.reason, |
| stderr, |
| }), |
| ); |
| expect(flushLogger).toHaveBeenCalledOnce(); |
| }); |
|
|
| it("retains only a bounded stderr tail and discards stdout", async () => { |
| const lastReason = "final startup failure"; |
| spawn.mockImplementation(async (input: SpawnInput) => { |
| expect(input.captureOutput).toBe(false); |
| input.onStdout?.("unretained stdout"); |
| input.onStderr?.("old stderr diagnostic\n"); |
| input.onStderr?.("x".repeat(8192)); |
| input.onStderr?.(lastReason); |
| return { |
| cancel: vi.fn(), |
| wait: async () => ({ exitCode: 1, exitSignal: null, reason: "exit" }), |
| waitForExtinction: async () => {}, |
| }; |
| }); |
|
|
| const { runWindowsGatewayTaskSupervisor } = await import("./task-supervisor.js"); |
| await runWindowsGatewayTaskSupervisor(); |
|
|
| expect(log.error).toHaveBeenCalledWith( |
| expect.any(String), |
| expect.objectContaining({ stderr: expect.stringContaining(lastReason) }), |
| ); |
| const stderr: string = log.error.mock.calls[0]?.[1].stderr; |
| expect(stderr.length).toBeLessThanOrEqual(8192); |
| expect(stderr).not.toContain("old stderr diagnostic"); |
| expect(JSON.stringify(log.error.mock.calls)).not.toContain("unretained stdout"); |
| }); |
|
|
| it("records a spawn failure and fails the task", async () => { |
| spawn.mockRejectedValue(new Error("synthetic Job Object spawn failure")); |
|
|
| const { runWindowsGatewayTaskSupervisor } = await import("./task-supervisor.js"); |
| await runWindowsGatewayTaskSupervisor(); |
|
|
| expect(process.exitCode).toBe(1); |
| expect(JSON.stringify(log.error.mock.calls)).toContain("synthetic Job Object spawn failure"); |
| expect(flushLogger).toHaveBeenCalledOnce(); |
| }); |
|
|
| it("preserves the child diagnostic when its tree cleanup fails", async () => { |
| const stderr = "synthetic child startup failure"; |
| spawn.mockImplementation(async (input: SpawnInput) => { |
| input.onStderr?.(stderr); |
| return { |
| cancel: vi.fn(), |
| wait: async () => ({ exitCode: 23, exitSignal: null, reason: "exit" }), |
| waitForExtinction: async () => { |
| expect(log.error).toHaveBeenCalledWith( |
| expect.any(String), |
| expect.objectContaining({ exitCode: 23, stderr }), |
| ); |
| throw new Error("synthetic Job Object cleanup failure"); |
| }, |
| }; |
| }); |
|
|
| const { runWindowsGatewayTaskSupervisor } = await import("./task-supervisor.js"); |
| await runWindowsGatewayTaskSupervisor(); |
|
|
| expect(process.exitCode).toBe(1); |
| expect(JSON.stringify(log.error.mock.calls)).toContain("synthetic Job Object cleanup failure"); |
| expect(flushLogger).toHaveBeenCalledOnce(); |
| }); |
|
|
| it("replaces only a child that requests an ordinary Gateway restart", async () => { |
| const firstExtinction = vi.fn(async () => {}); |
| const secondExtinction = vi.fn(async () => {}); |
| spawn |
| .mockImplementationOnce(async (input: SpawnInput) => ({ |
| cancel: vi.fn(), |
| wait: async () => ({ |
| exitCode: readSpawnRestartExitCode(input), |
| exitSignal: null, |
| }), |
| waitForExtinction: firstExtinction, |
| })) |
| .mockResolvedValueOnce({ |
| cancel: vi.fn(), |
| wait: async () => ({ exitCode: 0, exitSignal: null }), |
| waitForExtinction: secondExtinction, |
| }); |
| const { runWindowsGatewayTaskSupervisor } = await import("./task-supervisor.js"); |
| await runWindowsGatewayTaskSupervisor(); |
|
|
| expect(spawn).toHaveBeenCalledTimes(2); |
| expect(firstExtinction).toHaveBeenCalledOnce(); |
| expect(secondExtinction).toHaveBeenCalledOnce(); |
| expect(process.exitCode).toBeUndefined(); |
| }); |
|
|
| it("does not mistake a conventional temporary-failure exit for a restart request", async () => { |
| spawn.mockResolvedValue({ |
| cancel: vi.fn(), |
| wait: async () => ({ exitCode: 75, exitSignal: null, reason: "exit" }), |
| waitForExtinction: vi.fn(async () => {}), |
| }); |
|
|
| const { runWindowsGatewayTaskSupervisor } = await import("./task-supervisor.js"); |
| await runWindowsGatewayTaskSupervisor(); |
|
|
| expect(spawn).toHaveBeenCalledOnce(); |
| expect(process.exitCode).toBe(75); |
| }); |
|
|
| it("does not replace a restarting child when shutdown arrives during extinction", async () => { |
| let shutdown: (() => void) | undefined; |
| vi.spyOn(process, "once").mockImplementation(((event: string, listener: () => void) => { |
| if (event === "SIGTERM") { |
| shutdown = listener; |
| } |
| return process; |
| }) as typeof process.once); |
| spawn.mockImplementationOnce(async (input: SpawnInput) => ({ |
| cancel: vi.fn(), |
| wait: async () => ({ |
| exitCode: readSpawnRestartExitCode(input), |
| exitSignal: null, |
| }), |
| waitForExtinction: async () => shutdown?.(), |
| })); |
|
|
| const { runWindowsGatewayTaskSupervisor } = await import("./task-supervisor.js"); |
| await runWindowsGatewayTaskSupervisor(); |
|
|
| expect(spawn).toHaveBeenCalledOnce(); |
| }); |
|
|
| it("exits cleanly when shutdown races a child restart result", async () => { |
| let shutdown: (() => void) | undefined; |
| vi.spyOn(process, "once").mockImplementation(((event: string, listener: () => void) => { |
| if (event === "SIGTERM") { |
| shutdown = listener; |
| } |
| return process; |
| }) as typeof process.once); |
| spawn.mockImplementationOnce(async (input: SpawnInput) => ({ |
| cancel: vi.fn(), |
| wait: async () => { |
| shutdown?.(); |
| return { |
| exitCode: readSpawnRestartExitCode(input), |
| exitSignal: null, |
| }; |
| }, |
| waitForExtinction: vi.fn(async () => {}), |
| })); |
|
|
| const { runWindowsGatewayTaskSupervisor } = await import("./task-supervisor.js"); |
| await runWindowsGatewayTaskSupervisor(); |
|
|
| expect(spawn).toHaveBeenCalledOnce(); |
| expect(process.exitCode).toBeUndefined(); |
| expect(log.error).not.toHaveBeenCalled(); |
| }); |
|
|
| it("cancels a child when shutdown arrives while spawn is pending", async () => { |
| let resolveSpawn: ((value: unknown) => void) | undefined; |
| const pendingSpawn = new Promise((resolve) => { |
| resolveSpawn = resolve; |
| }); |
| const cancel = vi.fn(); |
| let shutdown: (() => void) | undefined; |
| vi.spyOn(process, "once").mockImplementation(((event: string, listener: () => void) => { |
| if (event === "SIGTERM") { |
| shutdown = listener; |
| } |
| return process; |
| }) as typeof process.once); |
| spawn.mockReturnValue(pendingSpawn); |
| const { runWindowsGatewayTaskSupervisor } = await import("./task-supervisor.js"); |
| const running = runWindowsGatewayTaskSupervisor(); |
| await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()); |
| shutdown?.(); |
| resolveSpawn?.({ |
| cancel, |
| wait: async () => ({ exitCode: 0, exitSignal: null }), |
| waitForExtinction: vi.fn(async () => {}), |
| }); |
| await running; |
|
|
| expect(cancel).toHaveBeenCalledOnce(); |
| }); |
| }); |
|
|