fix(gateway): harden launchd reload handoff race recovery (#84641)

* fix(gateway): harden launchd reload handoff race recovery

* docs(changelog): mention launchd reload handoff race fix

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
NianJiu
2026-05-22 21:13:26 +08:00
committed by GitHub
parent ca2b9ad289
commit fc7a531f6c
3 changed files with 23 additions and 2 deletions
+1
View File
@@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/LaunchAgent: wait for launchd reload bootout to finish and fall back to kickstart when bootstrap races, so reload handoff does not leave the service deregistered. Fixes #84630. (#84641) Thanks @NianJiuZst.
- CLI/update: preserve managed Gateway service environment during package cutovers so macOS LaunchAgent repair/restart reads the pre-update service state instead of caller shell state. (#83026)
- Agents/providers: honor per-model `api` and `baseUrl` overrides in custom provider auth hooks and transport selection. Fixes #80487. (#80488) Thanks @huveewomg.
- Gateway/restart: eager-load the lifecycle runtime before in-place upgrade signal handling so package replacement does not deadlock restart imports. (#84890) Thanks @myps6415.
+6 -2
View File
@@ -94,7 +94,7 @@ describe("scheduleDetachedLaunchdRestartHandoff", () => {
expect(args[1]).not.toContain('basename "$service_target"');
});
it("bootouts and bootstraps for reload mode", () => {
it("polls after bootout and falls back to kickstart on bootstrap failure for reload mode", () => {
spawnMock.mockReturnValue({ pid: 4242, unref: unrefMock });
scheduleDetachedLaunchdRestartHandoff({
@@ -110,8 +110,12 @@ describe("scheduleDetachedLaunchdRestartHandoff", () => {
expect(args[1]).toContain("openclaw restart attempt source=launchd-handoff mode=reload");
expect(args[1]).toContain('launchctl enable "$service_target"');
expect(args[1]).toContain('launchctl bootout "$service_target"');
// polls until launchd finishes the async unload before re-bootstrapping
expect(args[1]).toContain("bootout_wait_count=");
expect(args[1]).toContain('if ! launchctl print "$service_target" >/dev/null 2>&1; then');
expect(args[1]).toContain('if launchctl bootstrap "$domain" "$plist_path"; then');
expect(args[1]).not.toContain('launchctl kickstart -k "$service_target"');
// fallback: kickstart -k on bootstrap failure so service isn't left deregistered
expect(args[1]).toContain('launchctl kickstart -k "$service_target"');
});
it("sanitizes restart helper environment overrides before spawning", () => {
+16
View File
@@ -153,6 +153,19 @@ exit "$status"
if (mode === "reload") {
// Reloading is required after plist content changes; kickstart alone keeps
// launchd's already-loaded stdout/stderr/stdin paths.
// After bootout we poll until launchd finishes the async unload before
// re-bootstrapping to avoid EIO (Bootstrap failed: 5) from the race.
// If bootstrap still fails, kickstart -k as a fallback to keep the service
// alive rather than leaving it deregistered.
const bootoutWaitLoop = `bootout_wait_count="${START_AFTER_EXIT_PRINT_RETRY_COUNT}"
while [ "$bootout_wait_count" -gt 0 ]; do
if ! launchctl print "$service_target" >/dev/null 2>&1; then
break
fi
bootout_wait_count=$((bootout_wait_count - 1))
sleep ${START_AFTER_EXIT_PRINT_RETRY_DELAY_SECONDS}
done
`;
return `service_target="$1"
domain="$2"
plist_path="$3"
@@ -160,10 +173,13 @@ ${waitForCallerPid}
status=0
launchctl enable "$service_target"
launchctl bootout "$service_target" >/dev/null 2>&1 || true
${bootoutWaitLoop}
if launchctl bootstrap "$domain" "$plist_path"; then
status=0
else
status=$?
launchctl kickstart -k "$service_target"
status=$?
fi
if [ "$status" -eq 0 ]; then
printf '[%s] openclaw restart done source=launchd-handoff mode=${mode}\\n' "$(date -u +%FT%TZ)" >&2