Download src/daemon/launchd.integration.e2e.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 17.1 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/daemon/launchd.integration.e2e.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/daemon/launchd.integration.e2e.test.ts
-
curl -L -o launchd.integration.e2e.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/daemon/launchd.integration.e2e.test.ts
17.1 kB
| // Launchd integration tests cover daemon CLI behavior in macOS-like scenarios. | |
| 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 { LOOPBACK_PORT_PROBE_HOSTS, probePortUsage } from "../infra/ports-probe.js"; | |
| import { withEnvAsync } from "../test-utils/env.js"; | |
| import { withTimeout } from "../utils/with-timeout.js"; | |
| import { | |
| installLaunchAgent, | |
| readLaunchAgentRuntime, | |
| repairLaunchAgentBootstrap, | |
| restartLaunchAgent, | |
| resolveLaunchAgentPlistPath, | |
| startLaunchAgent, | |
| stopLaunchAgent, | |
| uninstallLaunchAgent, | |
| } from "./launchd.js"; | |
| import type { GatewayServiceEnv } from "./service-types.js"; | |
| import { resolveGatewayService, startGatewayService } from "./service.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; | |
| function resolveGuiDomain(): string { | |
| return `gui/${process.getuid?.() ?? 501}`; | |
| } | |
| 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"})`, | |
| ); | |
| } | |
| async function waitForNotRunningRuntime(params: { | |
| env: GatewayServiceEnv; | |
| timeoutMs?: number; | |
| }): Promise<void> { | |
| 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" && runtime.pid === undefined) { | |
| return; | |
| } | |
| await new Promise((resolve) => { | |
| setTimeout(resolve, WAIT_INTERVAL_MS); | |
| }); | |
| } | |
| throw new Error( | |
| `Timed out waiting for launchd runtime to stop (status=${lastStatus}, pid=${lastPid ?? "none"})`, | |
| ); | |
| } | |
| function launchEnvOrThrow(env: GatewayServiceEnv | undefined): GatewayServiceEnv { | |
| if (!env) { | |
| throw new Error("launchd integration env was not initialized"); | |
| } | |
| return env; | |
| } | |
| async function initializeLaunchdRuntime(launchEnv: GatewayServiceEnv, stdout: PassThrough) { | |
| await withTimeout( | |
| (async () => { | |
| await installLaunchAgent({ | |
| env: launchEnv, | |
| stdout, | |
| programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"], | |
| }); | |
| await waitForRunningRuntime({ env: launchEnv }); | |
| })(), | |
| STARTUP_TIMEOUT_MS, | |
| { message: "Timed out initializing launchd integration runtime" }, | |
| ); | |
| } | |
| async function writeLaunchAgentProbeScript(params: { | |
| eventsPath: string; | |
| scriptPath: string; | |
| }): Promise<void> { | |
| await fs.writeFile( | |
| params.scriptPath, | |
| [ | |
| 'const fs = require("node:fs");', | |
| `const eventsPath = ${JSON.stringify(params.eventsPath)};`, | |
| "fs.appendFileSync(eventsPath, `start ${process.pid}\\n`);", | |
| 'for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {', | |
| " process.on(signal, () => {", | |
| " fs.appendFileSync(eventsPath, `${signal} ${process.pid}\\n`);", | |
| " process.exit(0);", | |
| " });", | |
| "}", | |
| "setInterval(() => {}, 1000);", | |
| "", | |
| ].join("\n"), | |
| "utf8", | |
| ); | |
| } | |
| async function expectRuntimePidReplaced(params: { | |
| env: GatewayServiceEnv; | |
| previousPid: number; | |
| }): Promise<void> { | |
| const after = await waitForRunningRuntime({ | |
| env: params.env, | |
| pidNot: params.previousPid, | |
| }); | |
| expect(after.pid).toBeGreaterThan(1); | |
| expect(after.pid).not.toBe(params.previousPid); | |
| await fs.access(resolveLaunchAgentPlistPath(params.env)); | |
| } | |
| describeLaunchdIntegration("launchd integration", () => { | |
| let env: GatewayServiceEnv | undefined; | |
| let homeDir = ""; | |
| const stdout = new PassThrough(); | |
| it("real launchctl: node-host LaunchAgent stop/restart survives a co-located busy Gateway port (#124296)", async () => { | |
| // Real-world proof for https://github.com/openclaw/openclaw/issues/124296: | |
| // this drives actual `launchctl` LaunchAgents (no mocked port-inspection | |
| // or launchctl calls) to reproduce the reported false-positive | |
| // "gateway port is still busy" failure and confirm the fix resolves it. | |
| const testId = randomUUID().slice(0, 8); | |
| const gatewayPort = 19_500 + (Number.parseInt(testId.slice(0, 4), 16) % 400); | |
| // Real "gateway" LaunchAgent that genuinely binds the scratch port, so | |
| // the port really is busy for the whole test — no port mocking at all. | |
| const gatewayHomeDir = await fs.mkdtemp( | |
| path.join(os.tmpdir(), `openclaw-launchd-int-gw-${testId}-`), | |
| ); | |
| const gatewayEnv: GatewayServiceEnv = { | |
| HOME: gatewayHomeDir, | |
| OPENCLAW_LAUNCHD_LABEL: `ai.openclaw.launchd-int-gw-${testId}`, | |
| OPENCLAW_LOG_PREFIX: `gateway-launchd-int-gw-${testId}`, | |
| OPENCLAW_GATEWAY_PORT: String(gatewayPort), | |
| }; | |
| // Real "node-host" LaunchAgent, co-located on the same machine, tagged | |
| // with the node service kind. It never binds the gateway port itself. | |
| const nodeHomeDir = await fs.mkdtemp( | |
| path.join(os.tmpdir(), `openclaw-launchd-int-node-${testId}-`), | |
| ); | |
| const nodeEnv: GatewayServiceEnv = { | |
| HOME: nodeHomeDir, | |
| OPENCLAW_LAUNCHD_LABEL: `ai.openclaw.launchd-int-node-${testId}`, | |
| OPENCLAW_LOG_PREFIX: `gateway-launchd-int-node-${testId}`, | |
| OPENCLAW_SERVICE_KIND: "node", | |
| OPENCLAW_GATEWAY_PORT: String(gatewayPort), | |
| }; | |
| try { | |
| await installLaunchAgent({ | |
| env: gatewayEnv, | |
| stdout, | |
| programArguments: [ | |
| process.execPath, | |
| "-e", | |
| `require("node:http").createServer((_req,res)=>res.end("ok")).listen(${gatewayPort}, "127.0.0.1", () => {}); setInterval(() => {}, 1000);`, | |
| ], | |
| }); | |
| await waitForRunningRuntime({ env: gatewayEnv }); | |
| // Prove the gateway port is genuinely bound right now via a real TCP | |
| // probe (no launchd-status inference) before exercising the node-host | |
| // lifecycle against it. This closes the gap where a LaunchAgent could | |
| // report "running" before its listener has actually bound the port. | |
| await expect | |
| .poll(() => probePortUsage(gatewayPort, LOOPBACK_PORT_PROBE_HOSTS), { | |
| timeout: 10_000, | |
| interval: 100, | |
| }) | |
| .toBe("busy"); | |
| await installLaunchAgent({ | |
| env: nodeEnv, | |
| stdout, | |
| programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"], | |
| }); | |
| const nodeBefore = await waitForRunningRuntime({ env: nodeEnv }); | |
| // Re-confirm the port is still genuinely busy immediately before the | |
| // stop call, so the assertion below is tied to a real, current probe. | |
| await expect(probePortUsage(gatewayPort, LOOPBACK_PORT_PROBE_HOSTS)).resolves.toBe("busy"); | |
| // The gateway port is genuinely still bound by the co-located gateway | |
| // LaunchAgent right now. Stopping the node-host LaunchAgent must not | |
| // fail with the false-positive "gateway port is still busy" error. | |
| await expect(stopLaunchAgent({ env: nodeEnv, stdout })).resolves.not.toThrow(); | |
| await waitForNotRunningRuntime({ env: nodeEnv }); | |
| // Confirm the co-located gateway is genuinely untouched throughout. | |
| const gatewayStillRunning = await readLaunchAgentRuntime(gatewayEnv); | |
| expect(gatewayStillRunning.status).toBe("running"); | |
| // Re-install (stop leaves it uninstalled-from-runtime-state in some | |
| // paths depending on service semantics) and exercise restart too. | |
| await installLaunchAgent({ | |
| env: nodeEnv, | |
| stdout, | |
| programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"], | |
| }); | |
| await waitForRunningRuntime({ env: nodeEnv, pidNot: nodeBefore.pid }); | |
| const nodeRunningBeforeRestart = await readLaunchAgentRuntime(nodeEnv); | |
| // Re-probe immediately before restart too: the port must still be | |
| // genuinely busy (real TCP probe, not inferred from launchd status) | |
| // for this to be a valid proof of the restart-guard fix. | |
| await expect(probePortUsage(gatewayPort, LOOPBACK_PORT_PROBE_HOSTS)).resolves.toBe("busy"); | |
| await expect(restartLaunchAgent({ env: nodeEnv, stdout })).resolves.not.toThrow(); | |
| await expectRuntimePidReplaced({ | |
| env: nodeEnv, | |
| previousPid: nodeRunningBeforeRestart.pid ?? nodeBefore.pid, | |
| }); | |
| const gatewayStillRunningAfterRestart = await readLaunchAgentRuntime(gatewayEnv); | |
| expect(gatewayStillRunningAfterRestart.status).toBe("running"); | |
| } finally { | |
| await uninstallLaunchAgent({ env: nodeEnv, stdout }).catch(() => {}); | |
| await uninstallLaunchAgent({ env: gatewayEnv, stdout }).catch(() => {}); | |
| await fs.rm(nodeHomeDir, { recursive: true, force: true }); | |
| await fs.rm(gatewayHomeDir, { recursive: true, force: true }); | |
| } | |
| }, 90_000); | |
| 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 { | |
| // Best-effort cleanup in case launchctl state already changed. | |
| } | |
| } | |
| 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 () => { | |
| const launchEnv = launchEnvOrThrow(env); | |
| await initializeLaunchdRuntime(launchEnv, stdout); | |
| const before = await waitForRunningRuntime({ env: launchEnv }); | |
| await restartLaunchAgent({ env: launchEnv, stdout }); | |
| await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid }); | |
| }, 60_000); | |
| it("manages a named profile through the guarded host-service lifecycle", async () => { | |
| const testId = randomUUID().slice(0, 8); | |
| const profile = `launchd-int-${testId}`; | |
| const accountHome = os.userInfo().homedir; | |
| const stateDir = path.join(accountHome, `.openclaw-${profile}`); | |
| const profileEnv: GatewayServiceEnv = { | |
| HOME: accountHome, | |
| OPENCLAW_HOME: undefined, | |
| OPENCLAW_PROFILE: profile, | |
| OPENCLAW_STATE_DIR: stateDir, | |
| OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"), | |
| OPENCLAW_LAUNCHD_LABEL: undefined, | |
| OPENCLAW_SUPERVISOR_MODE: undefined, | |
| }; | |
| await withEnvAsync(profileEnv, async () => { | |
| const service = resolveGatewayService(); | |
| try { | |
| await service.install({ | |
| env: profileEnv, | |
| stdout, | |
| programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"], | |
| }); | |
| const installed = await waitForRunningRuntime({ env: profileEnv }); | |
| await service.stop({ env: profileEnv, stdout }); | |
| await waitForNotRunningRuntime({ env: profileEnv }); | |
| const startResult = await startGatewayService(service, { env: profileEnv, stdout }); | |
| expect(startResult.outcome).toBe("started"); | |
| const started = await waitForRunningRuntime({ | |
| env: profileEnv, | |
| pidNot: installed.pid, | |
| }); | |
| await service.restart({ env: profileEnv, stdout }); | |
| await expectRuntimePidReplaced({ env: profileEnv, previousPid: started.pid }); | |
| } finally { | |
| try { | |
| await service.uninstall({ env: profileEnv, stdout }); | |
| } finally { | |
| await fs.rm(stateDir, { recursive: true, force: true }); | |
| } | |
| } | |
| }); | |
| }, 60_000); | |
| it("refuses a relocated OPENCLAW_HOME before launchd mutation", async () => { | |
| const testId = randomUUID().slice(0, 8); | |
| const relocatedHome = await fs.mkdtemp( | |
| path.join(os.tmpdir(), `openclaw-relocated-home-${testId}-`), | |
| ); | |
| const relocatedEnv: GatewayServiceEnv = { | |
| HOME: os.userInfo().homedir, | |
| OPENCLAW_HOME: relocatedHome, | |
| OPENCLAW_PROFILE: `launchd-int-${testId}`, | |
| }; | |
| try { | |
| await withEnvAsync(relocatedEnv, async () => { | |
| const service = resolveGatewayService(); | |
| await expect( | |
| service.install({ | |
| env: relocatedEnv, | |
| stdout, | |
| programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"], | |
| }), | |
| ).rejects.toThrow("service management skipped: non-default state dir or config path"); | |
| await expect(fs.access(resolveLaunchAgentPlistPath(relocatedEnv))).rejects.toThrow(); | |
| }); | |
| } finally { | |
| await fs.rm(relocatedHome, { recursive: true, force: true }); | |
| } | |
| }); | |
| it("keeps LaunchAgent supervision after a raw SIGTERM", async () => { | |
| const launchEnv = launchEnvOrThrow(env); | |
| await initializeLaunchdRuntime(launchEnv, stdout); | |
| const before = await waitForRunningRuntime({ env: launchEnv }); | |
| process.kill(before.pid, "SIGTERM"); | |
| await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid }); | |
| }, 60_000); | |
| it("stops persistently without reinstall and starts later", async () => { | |
| const launchEnv = launchEnvOrThrow(env); | |
| await initializeLaunchdRuntime(launchEnv, stdout); | |
| const before = await waitForRunningRuntime({ env: launchEnv }); | |
| await stopLaunchAgent({ env: launchEnv, stdout }); | |
| await waitForNotRunningRuntime({ env: launchEnv }); | |
| await startLaunchAgent({ env: launchEnv, stdout }); | |
| await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid }); | |
| }, 60_000); | |
| it("stops persistently without reinstall and restarts later", async () => { | |
| const launchEnv = launchEnvOrThrow(env); | |
| await initializeLaunchdRuntime(launchEnv, stdout); | |
| const before = await waitForRunningRuntime({ env: launchEnv }); | |
| await stopLaunchAgent({ env: launchEnv, stdout }); | |
| await waitForNotRunningRuntime({ env: launchEnv }); | |
| await restartLaunchAgent({ env: launchEnv, stdout }); | |
| await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid }); | |
| }, 60_000); | |
| it("repairs a missing bootstrap without kickstarting the fresh LaunchAgent", async () => { | |
| const launchEnv = launchEnvOrThrow(env); | |
| const eventsPath = path.join(homeDir, "repair-probe.events.log"); | |
| const scriptPath = path.join(homeDir, "repair-probe.cjs"); | |
| await writeLaunchAgentProbeScript({ eventsPath, scriptPath }); | |
| await installLaunchAgent({ | |
| env: launchEnv, | |
| stdout, | |
| programArguments: [process.execPath, scriptPath], | |
| }); | |
| await waitForRunningRuntime({ env: launchEnv }); | |
| const bootout = spawnSync( | |
| "launchctl", | |
| ["bootout", resolveGuiDomain(), resolveLaunchAgentPlistPath(launchEnv)], | |
| { encoding: "utf8" }, | |
| ); | |
| expect(bootout.status).toBe(0); | |
| await waitForNotRunningRuntime({ env: launchEnv }); | |
| await fs.access(resolveLaunchAgentPlistPath(launchEnv)); | |
| await fs.writeFile(eventsPath, "", "utf8"); | |
| const repair = await withTimeout( | |
| repairLaunchAgentBootstrap({ env: launchEnv }), | |
| STARTUP_TIMEOUT_MS, | |
| { message: "Timed out repairing launchd integration runtime" }, | |
| ); | |
| expect(repair).toEqual({ ok: true, status: "repaired" }); | |
| await waitForRunningRuntime({ env: launchEnv }); | |
| await new Promise((resolve) => { | |
| setTimeout(resolve, 1_500); | |
| }); | |
| const events = await fs.readFile(eventsPath, "utf8"); | |
| const trimmedEvents = events.trim(); | |
| const lines = trimmedEvents.length > 0 ? trimmedEvents.split(/\r?\n/) : []; | |
| expect(lines.reduce((count, line) => count + (line.startsWith("start ") ? 1 : 0), 0)).toBe(1); | |
| const signalLines = lines.filter((line) => /^(SIGHUP|SIGINT|SIGTERM) /.test(line)); | |
| expect(signalLines).toStrictEqual([]); | |
| }, 60_000); | |
| }); | |