From 1d2bebbb41bf1b0c965544a89e59be93bb6491db Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 7 Jun 2026 06:58:46 +0200 Subject: [PATCH] fix(mac): scope restart app cleanup --- scripts/restart-mac.sh | 74 +++++++++++---- test/scripts/restart-mac.test.ts | 154 ++++++++++++++++++++++++++++--- 2 files changed, 197 insertions(+), 31 deletions(-) diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh index 23ec363dc81..0fc9ad63189 100755 --- a/scripts/restart-mac.sh +++ b/scripts/restart-mac.sh @@ -6,7 +6,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source "${ROOT_DIR}/scripts/lib/restart-mac-gateway.sh" APP_BUNDLE="${OPENCLAW_APP_BUNDLE:-}" -APP_PROCESS_PATTERN="OpenClaw.app/Contents/MacOS/OpenClaw" +APP_EXECUTABLE_RELATIVE_PATH="Contents/MacOS/OpenClaw" DEBUG_PROCESS_PATTERN="${ROOT_DIR}/apps/macos/.build/debug/OpenClaw" LOCAL_PROCESS_PATTERN="${ROOT_DIR}/apps/macos/.build-local/debug/OpenClaw" RELEASE_PROCESS_PATTERN="${ROOT_DIR}/apps/macos/.build/release/OpenClaw" @@ -76,6 +76,16 @@ check_signing_keys() { | grep -Eq '(Developer ID Application|Apple Distribution|Apple Development)' } +canonicalize_app_bundle() { + if [[ -z "${APP_BUNDLE}" ]]; then + return 0 + fi + if [[ ! -d "${APP_BUNDLE}" ]]; then + fail "OPENCLAW_APP_BUNDLE does not exist: ${APP_BUNDLE}" + fi + APP_BUNDLE="$(cd "${APP_BUNDLE}" && pwd -P)" +} + trap cleanup EXIT INT TERM for arg in "$@"; do @@ -113,6 +123,7 @@ done if [[ "$NO_SIGN" -eq 1 && "$SIGN" -eq 1 ]]; then fail "Cannot use --sign and --no-sign together" fi +canonicalize_app_bundle mkdir -p "$(dirname "$LOG_PATH")" rm -f "$LOG_PATH" @@ -129,19 +140,48 @@ acquire_lock kill_all_openclaw() { for _ in {1..10}; do - pkill -f "${APP_PROCESS_PATTERN}" 2>/dev/null || true - pkill -f "${DEBUG_PROCESS_PATTERN}" 2>/dev/null || true - pkill -f "${LOCAL_PROCESS_PATTERN}" 2>/dev/null || true - pkill -f "${RELEASE_PROCESS_PATTERN}" 2>/dev/null || true - if ! pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1 \ - && ! pgrep -f "${DEBUG_PROCESS_PATTERN}" >/dev/null 2>&1 \ - && ! pgrep -f "${LOCAL_PROCESS_PATTERN}" >/dev/null 2>&1 \ - && ! pgrep -f "${RELEASE_PROCESS_PATTERN}" >/dev/null 2>&1; then + local pids="" + pids="$(openclaw_process_pids)" + if [[ -z "${pids}" ]]; then return 0 fi + while IFS= read -r pid; do + kill "${pid}" 2>/dev/null || true + done <<< "${pids}" sleep 0.3 done - return 1 + [[ -z "$(openclaw_process_pids)" ]] +} + +known_openclaw_executables() { + if [[ -n "${APP_BUNDLE}" ]]; then + printf '%s\n' "${APP_BUNDLE}/${APP_EXECUTABLE_RELATIVE_PATH}" + fi + printf '%s\n' \ + "${ROOT_DIR}/dist/OpenClaw.app/${APP_EXECUTABLE_RELATIVE_PATH}" \ + "/Applications/OpenClaw.app/${APP_EXECUTABLE_RELATIVE_PATH}" \ + "${DEBUG_PROCESS_PATTERN}" \ + "${LOCAL_PROCESS_PATTERN}" \ + "${RELEASE_PROCESS_PATTERN}" +} + +openclaw_process_pids() { + local pattern="" + while IFS= read -r pattern; do + [[ -n "${pattern}" ]] || continue + process_pids_matching "${pattern}" + done < <(known_openclaw_executables) | sort -u +} + +process_pids_matching() { + local pattern="$1" + ps axww -o pid=,command= 2>/dev/null \ + | while read -r pid command_line; do + [[ "${pid}" =~ ^[0-9]+$ ]] || continue + [[ "${pid}" != "$$" ]] || continue + [[ "${command_line}" == *"${pattern}"* ]] || continue + printf '%s\n' "${pid}" + done } stop_launch_agent() { @@ -190,14 +230,12 @@ run_step "package app" bash -lc "cd '${ROOT_DIR}' && SKIP_TSC=${SKIP_TSC:-1} '${ choose_app_bundle() { if [[ -n "${APP_BUNDLE}" ]]; then - if [[ -d "${APP_BUNDLE}" ]]; then - return 0 - fi - fail "OPENCLAW_APP_BUNDLE does not exist: ${APP_BUNDLE}" + canonicalize_app_bundle + return 0 fi if [[ -d "${ROOT_DIR}/dist/OpenClaw.app" ]]; then - APP_BUNDLE="${ROOT_DIR}/dist/OpenClaw.app" + APP_BUNDLE="$(cd "${ROOT_DIR}/dist/OpenClaw.app" && pwd -P)" if [[ ! -d "${APP_BUNDLE}/Contents/Frameworks/Sparkle.framework" ]]; then fail "dist/OpenClaw.app missing Sparkle after packaging" fi @@ -205,7 +243,7 @@ choose_app_bundle() { fi if [[ -d "/Applications/OpenClaw.app" ]]; then - APP_BUNDLE="/Applications/OpenClaw.app" + APP_BUNDLE="$(cd "/Applications/OpenClaw.app" && pwd -P)" return 0 fi @@ -259,11 +297,11 @@ run_step "launch app" env -i \ TMPDIR="${TMPDIR:-/tmp}" \ PATH="/usr/bin:/bin:/usr/sbin:/sbin" \ LANG="${LANG:-en_US.UTF-8}" \ - /usr/bin/open "${APP_BUNDLE}" ${ATTACH_ONLY_ARGS[@]:+"${ATTACH_ONLY_ARGS[@]}"} + /usr/bin/open -n "${APP_BUNDLE}" ${ATTACH_ONLY_ARGS[@]:+"${ATTACH_ONLY_ARGS[@]}"} # 5) Verify the app is alive. sleep 1.5 -if pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1; then +if [[ -n "$(process_pids_matching "${APP_BUNDLE}/${APP_EXECUTABLE_RELATIVE_PATH}")" ]]; then log "OK: OpenClaw is running." else fail "App exited immediately. Check ${LOG_PATH} or Console.app (User Reports)." diff --git a/test/scripts/restart-mac.test.ts b/test/scripts/restart-mac.test.ts index 2e4b653294f..152e07ea3db 100644 --- a/test/scripts/restart-mac.test.ts +++ b/test/scripts/restart-mac.test.ts @@ -1,6 +1,15 @@ // Restart Mac tests cover restart mac script behavior. import { spawnSync } from "node:child_process"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -36,15 +45,15 @@ function runGatewayPortCheck(fakeLsof: string) { ); } -function runCleanupFunction(fakePgrep: string) { +function runCleanupFunction(fakePs: string) { const root = mkdtempSync(join(tmpdir(), "openclaw-restart-mac-test-")); tempRoots.push(root); const binDir = join(root, "bin"); + const killCallsPath = join(root, "kill-calls.txt"); mkdirSync(binDir); for (const [name, body] of [ - ["pgrep", fakePgrep], - ["pkill", "#!/usr/bin/env bash\nexit 0\n"], + ["ps", fakePs], ["sleep", "#!/usr/bin/env bash\nexit 0\n"], ] as const) { const toolPath = join(binDir, name); @@ -63,22 +72,64 @@ function runCleanupFunction(fakePgrep: string) { [ "#!/usr/bin/env bash", cleanupFunction, - 'APP_PROCESS_PATTERN="OpenClaw.app/Contents/MacOS/OpenClaw"', + 'ROOT_DIR="/worktree"', + 'APP_BUNDLE=""', + 'APP_EXECUTABLE_RELATIVE_PATH="Contents/MacOS/OpenClaw"', 'DEBUG_PROCESS_PATTERN="/worktree/apps/macos/.build/debug/OpenClaw"', 'LOCAL_PROCESS_PATTERN="/worktree/apps/macos/.build-local/debug/OpenClaw"', 'RELEASE_PROCESS_PATTERN="/worktree/apps/macos/.build/release/OpenClaw"', + "kill() {", + ' printf "%s\\n" "$*" >> "$OPENCLAW_TEST_KILL_CALLS"', + " return 0", + "}", "kill_all_openclaw", ].join("\n"), ); chmodSync(harnessPath, 0o755); - return spawnSync("bash", [harnessPath], { + const result = spawnSync("bash", [harnessPath], { encoding: "utf8", env: { ...process.env, + OPENCLAW_TEST_KILL_CALLS: killCallsPath, PATH: `${binDir}:${process.env.PATH ?? ""}`, }, }); + const killCalls = existsSync(killCallsPath) ? readFileSync(killCallsPath, "utf8") : ""; + return { killCalls, result }; +} + +function runCanonicalizeAppBundle(appBundle: string) { + const root = mkdtempSync(join(tmpdir(), "openclaw-restart-mac-test-")); + tempRoots.push(root); + + const script = readFileSync(restartScriptPath, "utf8"); + const canonicalizeFunction = script.slice( + script.indexOf("canonicalize_app_bundle()"), + script.indexOf("trap cleanup"), + ); + const harnessPath = join(root, "canonicalize-harness.sh"); + writeFileSync( + harnessPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + canonicalizeFunction, + 'APP_BUNDLE="$1"', + "fail() {", + " printf 'ERROR: %s\\n' \"$*\" >&2", + " exit 1", + "}", + "canonicalize_app_bundle", + 'printf "%s\\n" "$APP_BUNDLE"', + ].join("\n"), + ); + chmodSync(harnessPath, 0o755); + + return { + result: spawnSync("bash", [harnessPath, appBundle], { cwd: root, encoding: "utf8" }), + root, + }; } afterEach(() => { @@ -136,7 +187,8 @@ describe("scripts/restart-mac.sh", () => { script.indexOf("choose_app_bundle", script.indexOf("choose_app_bundle()") + 1), ); - expect(chooseBlock).toContain('fail "OPENCLAW_APP_BUNDLE does not exist: ${APP_BUNDLE}"'); + expect(script).toContain('fail "OPENCLAW_APP_BUNDLE does not exist: ${APP_BUNDLE}"'); + expect(chooseBlock).toContain("canonicalize_app_bundle"); expect(chooseBlock.indexOf("${ROOT_DIR}/dist/OpenClaw.app")).toBeGreaterThan(-1); expect(chooseBlock.indexOf("/Applications/OpenClaw.app")).toBeGreaterThan(-1); expect(chooseBlock.indexOf("${ROOT_DIR}/dist/OpenClaw.app")).toBeLessThan( @@ -151,11 +203,18 @@ describe("scripts/restart-mac.sh", () => { script.indexOf("stop_launch_agent()"), ); - expect(cleanupBlock).toContain('pkill -f "${APP_PROCESS_PATTERN}"'); - expect(cleanupBlock).toContain('pkill -f "${DEBUG_PROCESS_PATTERN}"'); - expect(cleanupBlock).toContain('pkill -f "${LOCAL_PROCESS_PATTERN}"'); - expect(cleanupBlock).toContain('pkill -f "${RELEASE_PROCESS_PATTERN}"'); + expect(cleanupBlock).toContain("ps axww -o pid=,command="); + expect(cleanupBlock).toContain( + '"${ROOT_DIR}/dist/OpenClaw.app/${APP_EXECUTABLE_RELATIVE_PATH}"', + ); + expect(cleanupBlock).toContain('"/Applications/OpenClaw.app/${APP_EXECUTABLE_RELATIVE_PATH}"'); + expect(cleanupBlock).toContain('"${DEBUG_PROCESS_PATTERN}"'); + expect(cleanupBlock).toContain('"${LOCAL_PROCESS_PATTERN}"'); + expect(cleanupBlock).toContain('"${RELEASE_PROCESS_PATTERN}"'); + expect(cleanupBlock).not.toContain("APP_PROCESS_PATTERN"); + expect(cleanupBlock).not.toContain("pkill"); expect(cleanupBlock).not.toContain('pkill -x "OpenClaw"'); + expect(cleanupBlock).not.toContain("pgrep"); expect(cleanupBlock).not.toContain('pgrep -x "OpenClaw"'); }); @@ -169,18 +228,87 @@ describe("scripts/restart-mac.sh", () => { expect(stopIndex).toBeLessThan(killIndex); }); + it("verifies the launched app through the chosen bundle executable", () => { + const script = readFileSync(restartScriptPath, "utf8"); + const verifyBlock = script.slice(script.indexOf("# 5) Verify the app is alive.")); + + expect(verifyBlock).toContain( + 'process_pids_matching "${APP_BUNDLE}/${APP_EXECUTABLE_RELATIVE_PATH}"', + ); + expect(verifyBlock).not.toContain("APP_PROCESS_PATTERN"); + expect(verifyBlock).not.toContain("pgrep"); + }); + + it("forces LaunchServices to start the selected app bundle", () => { + const script = readFileSync(restartScriptPath, "utf8"); + + expect(script).toContain('/usr/bin/open -n "${APP_BUNDLE}"'); + expect(script).not.toContain('/usr/bin/open "${APP_BUNDLE}"'); + }); + + it("normalizes custom app bundle paths before process matching", () => { + const root = mkdtempSync(join(tmpdir(), "openclaw-restart-mac-test-")); + tempRoots.push(root); + const appBundle = join(root, "dist", "OpenClaw.app"); + mkdirSync(appBundle, { recursive: true }); + + const { result } = runCanonicalizeAppBundle(`${appBundle}/../OpenClaw.app/`); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(realpathSync(appBundle)); + expect(result.stderr).toBe(""); + }); + it("fails restart cleanup when scoped processes survive every kill attempt", () => { - const result = runCleanupFunction("#!/usr/bin/env bash\nexit 0\n"); + const { killCalls, result } = runCleanupFunction( + [ + "#!/usr/bin/env bash", + "printf '%s\\n' ' 321 /worktree/dist/OpenClaw.app/Contents/MacOS/OpenClaw --attach-only'", + ].join("\n"), + ); expect(result.status).toBe(1); + expect(killCalls).toContain("321\n"); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe(""); + }); + + it("passes restart cleanup when the final kill attempt clears the process", () => { + const { killCalls, result } = runCleanupFunction( + [ + "#!/usr/bin/env bash", + 'kill_count="$(wc -l < "$OPENCLAW_TEST_KILL_CALLS" 2>/dev/null || echo 0)"', + 'if [[ "$kill_count" -lt 10 ]]; then', + " printf '%s\\n' ' 321 /worktree/dist/OpenClaw.app/Contents/MacOS/OpenClaw --attach-only'", + "fi", + ].join("\n"), + ); + + expect(result.status).toBe(0); + expect(killCalls.trim().split(/\r?\n/u)).toHaveLength(10); expect(result.stdout).toBe(""); expect(result.stderr).toBe(""); }); it("passes restart cleanup when scoped processes are gone", () => { - const result = runCleanupFunction("#!/usr/bin/env bash\nexit 1\n"); + const { killCalls, result } = runCleanupFunction("#!/usr/bin/env bash\nexit 0\n"); expect(result.status).toBe(0); + expect(killCalls).toBe(""); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe(""); + }); + + it("does not kill unrelated OpenClaw app bundles", () => { + const { killCalls, result } = runCleanupFunction( + [ + "#!/usr/bin/env bash", + "printf '%s\\n' ' 654 /tmp/Other/OpenClaw.app/Contents/MacOS/OpenClaw'", + ].join("\n"), + ); + + expect(result.status).toBe(0); + expect(killCalls).toBe(""); expect(result.stdout).toBe(""); expect(result.stderr).toBe(""); });