| import { spawnSync } from "node:child_process"; |
| import { randomUUID } from "node:crypto"; |
| import fs from "node:fs/promises"; |
| import os from "node:os"; |
| import path from "node:path"; |
| import { PassThrough } from "node:stream"; |
| import { afterAll, beforeAll, describe, expect, it } from "vitest"; |
| import { |
| installLaunchAgent, |
| readLaunchAgentRuntime, |
| restartLaunchAgent, |
| resolveLaunchAgentPlistPath, |
| uninstallLaunchAgent, |
| } from "./launchd.js"; |
| import type { GatewayServiceEnv } from "./service-types.js"; |
|
|
| const WAIT_INTERVAL_MS = 200; |
| const WAIT_TIMEOUT_MS = 30_000; |
| const STARTUP_TIMEOUT_MS = 45_000; |
|
|
| function canRunLaunchdIntegration(): boolean { |
| if (process.platform !== "darwin") { |
| return false; |
| } |
| if (typeof process.getuid !== "function") { |
| return false; |
| } |
| const domain = `gui/${process.getuid()}`; |
| const probe = spawnSync("launchctl", ["print", domain], { encoding: "utf8" }); |
| if (probe.error) { |
| return false; |
| } |
| return probe.status === 0; |
| } |
|
|
| const describeLaunchdIntegration = canRunLaunchdIntegration() ? describe : describe.skip; |
|
|
| async function withTimeout<T>(params: { |
| run: () => Promise<T>; |
| timeoutMs: number; |
| message: string; |
| }): Promise<T> { |
| let timer: NodeJS.Timeout | undefined; |
| try { |
| return await Promise.race([ |
| params.run(), |
| new Promise<T>((_, reject) => { |
| timer = setTimeout(() => reject(new Error(params.message)), params.timeoutMs); |
| }), |
| ]); |
| } finally { |
| if (timer) { |
| clearTimeout(timer); |
| } |
| } |
| } |
|
|
| async function waitForRunningRuntime(params: { |
| env: GatewayServiceEnv; |
| pidNot?: number; |
| timeoutMs?: number; |
| }): Promise<{ pid: number }> { |
| const timeoutMs = params.timeoutMs ?? WAIT_TIMEOUT_MS; |
| const deadline = Date.now() + timeoutMs; |
| let lastStatus = "unknown"; |
| let lastPid: number | undefined; |
| while (Date.now() < deadline) { |
| const runtime = await readLaunchAgentRuntime(params.env); |
| lastStatus = runtime.status ?? "unknown"; |
| lastPid = runtime.pid; |
| if ( |
| runtime.status === "running" && |
| typeof runtime.pid === "number" && |
| runtime.pid > 1 && |
| (params.pidNot === undefined || runtime.pid !== params.pidNot) |
| ) { |
| return { pid: runtime.pid }; |
| } |
| await new Promise((resolve) => { |
| setTimeout(resolve, WAIT_INTERVAL_MS); |
| }); |
| } |
| throw new Error( |
| `Timed out waiting for launchd runtime (status=${lastStatus}, pid=${lastPid ?? "none"})`, |
| ); |
| } |
|
|
| describeLaunchdIntegration("launchd integration", () => { |
| let env: GatewayServiceEnv | undefined; |
| let homeDir = ""; |
| const stdout = new PassThrough(); |
|
|
| beforeAll(async () => { |
| const testId = randomUUID().slice(0, 8); |
| homeDir = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-launchd-int-${testId}-`)); |
| env = { |
| HOME: homeDir, |
| OPENCLAW_LAUNCHD_LABEL: `ai.openclaw.launchd-int-${testId}`, |
| OPENCLAW_LOG_PREFIX: `gateway-launchd-int-${testId}`, |
| }; |
| }); |
|
|
| afterAll(async () => { |
| if (env) { |
| try { |
| await uninstallLaunchAgent({ env, stdout }); |
| } catch { |
| |
| } |
| } |
| if (homeDir) { |
| await fs.rm(homeDir, { recursive: true, force: true }); |
| } |
| }, 60_000); |
|
|
| it("restarts launchd service and keeps it running with a new pid", async () => { |
| if (!env) { |
| throw new Error("launchd integration env was not initialized"); |
| } |
| const launchEnv = env; |
| try { |
| await withTimeout({ |
| run: async () => { |
| await installLaunchAgent({ |
| env: launchEnv, |
| stdout, |
| programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"], |
| }); |
| await waitForRunningRuntime({ env: launchEnv }); |
| }, |
| timeoutMs: STARTUP_TIMEOUT_MS, |
| message: "Timed out initializing launchd integration runtime", |
| }); |
| } catch { |
| |
| return; |
| } |
| const before = await waitForRunningRuntime({ env: launchEnv }); |
| await restartLaunchAgent({ env: launchEnv, stdout }); |
| const after = await waitForRunningRuntime({ env: launchEnv, pidNot: before.pid }); |
| expect(after.pid).toBeGreaterThan(1); |
| expect(after.pid).not.toBe(before.pid); |
| await fs.access(resolveLaunchAgentPlistPath(launchEnv)); |
| }, 60_000); |
| }); |
|
|