From 43c40a3b3e52213f1b41ce06ab42728394d87ef1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 11:18:39 -0700 Subject: [PATCH] fix: recover delayed live Mac bundle restores (#104613) --- .../scripts/update-main.mjs | 73 +++++++++++++++--- test/scripts/openclaw-live-updater.test.ts | 74 +++++++++++++++++++ 2 files changed, 137 insertions(+), 10 deletions(-) diff --git a/.agents/skills/openclaw-live-updater/scripts/update-main.mjs b/.agents/skills/openclaw-live-updater/scripts/update-main.mjs index a3db978eaa04..47285237cd21 100644 --- a/.agents/skills/openclaw-live-updater/scripts/update-main.mjs +++ b/.agents/skills/openclaw-live-updater/scripts/update-main.mjs @@ -524,7 +524,21 @@ function assertExactBuild(checkout, expectedSha) { return state; } -function runBuildWithPreservedMacApp(runCommand, checkout) { +function isOriginalMacBundle(bundlePath, originalStat) { + try { + const currentStat = lstatSync(bundlePath); + return ( + currentStat.isDirectory() && + !currentStat.isSymbolicLink() && + currentStat.dev === originalStat.dev && + currentStat.ino === originalStat.ino + ); + } catch { + return false; + } +} + +function runBuildWithPreservedMacApp(runCommand, checkout, sleep = defaultSleep) { const appBundle = path.join(checkout, "dist/OpenClaw.app"); if (!existsSync(appBundle)) { runCommand("pnpm", ["build"], checkout); @@ -546,20 +560,59 @@ function runBuildWithPreservedMacApp(runCommand, checkout) { try { runCommand("pnpm", ["build"], checkout); } finally { - if (!existsSync(preservedBundle)) { - throw new UpdateInvariantError( - "missing_preserved_mac_bundle", - `preserved Mac app bundle disappeared: ${preservedBundle}`, - ); + // A running app or external file coordinator can temporarily relocate and + // restore the exact bundle while the JS build runs. Allow that move to settle, but + // require the original inode so an unrelated replacement still fails closed. + for (let attempt = 0; attempt < 20; attempt += 1) { + if (existsSync(preservedBundle) || existsSync(appBundle)) { + break; + } + sleep(100); } - if (existsSync(appBundle)) { + const alreadyRestored = isOriginalMacBundle(appBundle, appStat); + if (!alreadyRestored && existsSync(appBundle)) { throw new UpdateInvariantError( "mac_bundle_restore_conflict", `build unexpectedly created ${appBundle}; preserved bundle remains at ${preservedBundle}`, ); } - mkdirSync(path.dirname(appBundle), { recursive: true }); - renameSync(preservedBundle, appBundle); + if (!alreadyRestored) { + mkdirSync(path.dirname(appBundle), { recursive: true }); + try { + renameSync(preservedBundle, appBundle); + } catch (error) { + if (!isOriginalMacBundle(appBundle, appStat)) { + if (existsSync(appBundle)) { + throw new UpdateInvariantError( + "mac_bundle_restore_conflict", + `build unexpectedly created ${appBundle}; preserved bundle remains at ${preservedBundle}`, + ); + } + if (existsSync(preservedBundle)) { + throw new UpdateInvariantError( + "mac_bundle_restore_failed", + `failed to restore Mac app bundle: ${String(error)}`, + ); + } + throw new UpdateInvariantError( + "missing_preserved_mac_bundle", + `preserved Mac app bundle disappeared: ${preservedBundle}`, + ); + } + } + } + if (!isOriginalMacBundle(appBundle, appStat)) { + throw new UpdateInvariantError( + "missing_preserved_mac_bundle", + `original Mac app bundle was not restored to ${appBundle}`, + ); + } + if (existsSync(preservedBundle)) { + throw new UpdateInvariantError( + "mac_bundle_restore_conflict", + `original Mac app bundle exists at both ${appBundle} and ${preservedBundle}`, + ); + } } } @@ -819,7 +872,7 @@ export function maintainMain(options, dependencies = {}) { // Use the existing built CLI directly. Source launchers may auto-build a // stale dist before dispatching `gateway stop`, recreating the live-import race. runCommand(process.execPath, ["dist/index.js", "gateway", "stop"], update.checkout); - runBuildWithPreservedMacApp(runCommand, update.checkout); + runBuildWithPreservedMacApp(runCommand, update.checkout, sleep); assertExactBuild(update.checkout, update.afterSha); const restartStartedAt = restartGateway(runCommand, update.checkout, update.afterSha); gatewayLogAudit = verifyAndAuditGateway({ diff --git a/test/scripts/openclaw-live-updater.test.ts b/test/scripts/openclaw-live-updater.test.ts index 87312d17d962..85cbec63cde4 100644 --- a/test/scripts/openclaw-live-updater.test.ts +++ b/test/scripts/openclaw-live-updater.test.ts @@ -508,6 +508,80 @@ describe("openclaw live updater", () => { expect(readFileSync(appMarker, "utf8")).toBe("signed\n"); }); + test("accepts a delayed external restore of the exact preserved Mac bundle", () => { + const { root, mirror } = makeFixture(); + mkdirSync(path.join(mirror, "node_modules")); + const appBundle = path.join(mirror, "dist/OpenClaw.app"); + const appMarker = path.join(appBundle, "Contents/signature-marker"); + mkdirSync(path.dirname(appMarker), { recursive: true }); + writeFileSync(appMarker, "signed\n"); + const commands = fakeCommands(mirror); + const delayedBundle = path.join(root, "delayed-openclaw.app"); + let restored = false; + + maintainFixture( + { checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") }, + { + runCommand(command: string, args: string[]) { + if (command === "pnpm" && args[0] === "build") { + expect(existsSync(appBundle)).toBe(false); + } + commands.runCommand(command, args); + if (command === "pnpm" && args[0] === "build") { + const preserved = readdirSync(path.join(mirror, ".git")).find((entry) => + entry.startsWith(".openclaw-live-mac-"), + ); + expect(preserved).toBeDefined(); + renameSync(path.join(mirror, ".git", preserved!), delayedBundle); + } + }, + sleep() { + if (restored) { + return; + } + renameSync(delayedBundle, appBundle); + restored = true; + }, + }, + ); + + expect(restored).toBe(true); + expect(readFileSync(appMarker, "utf8")).toBe("signed\n"); + expect( + readdirSync(path.join(mirror, ".git")).filter((entry) => + entry.startsWith(".openclaw-live-mac-"), + ), + ).toEqual([]); + }); + + test("preserves a build failure after an external Mac bundle restore", () => { + const { root, mirror } = makeFixture(); + mkdirSync(path.join(mirror, "node_modules")); + const appBundle = path.join(mirror, "dist/OpenClaw.app"); + const appMarker = path.join(appBundle, "Contents/signature-marker"); + mkdirSync(path.dirname(appMarker), { recursive: true }); + writeFileSync(appMarker, "signed\n"); + + expect(() => + maintainFixture( + { checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") }, + { + runCommand(command: string, args: string[]) { + if (command === "pnpm" && args[0] === "build") { + const preserved = readdirSync(path.join(mirror, ".git")).find((entry) => + entry.startsWith(".openclaw-live-mac-"), + ); + expect(preserved).toBeDefined(); + renameSync(path.join(mirror, ".git", preserved!), appBundle); + throw new Error("build failed after external restore"); + } + }, + }, + ), + ).toThrow("build failed after external restore"); + expect(readFileSync(appMarker, "utf8")).toBe("signed\n"); + }); + test("proves a current exact-SHA Gateway on a no-op heartbeat", () => { const { root, mirror } = makeFixture(); mkdirSync(path.join(mirror, "node_modules"));