fix(mac): scope restart app cleanup

This commit is contained in:
Vincent Koc
2026-06-07 06:58:46 +02:00
parent ab645aca31
commit 1d2bebbb41
2 changed files with 197 additions and 31 deletions

View File

@@ -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)."

View File

@@ -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("");
});