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 () => { // A direct Startup fallback inherits the install preference, without a live WScript owner. 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(); }); });