From 71875bfb9e71cab429f4f129e24f0336897efe38 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 23 Sep 2026 13:38:00 -0700 Subject: [PATCH] fix(macos): treat a vanished DMG mount as detached and force-detach by device node (#156702) --- scripts/create-dmg.sh | 27 ++++++++++--- test/scripts/create-dmg.test.ts | 69 +++++++++++++++++++++++++++++++-- 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/scripts/create-dmg.sh b/scripts/create-dmg.sh index 585a249c3d81..3d8d4b5ae997 100755 --- a/scripts/create-dmg.sh +++ b/scripts/create-dmg.sh @@ -124,10 +124,16 @@ DMG_RW_PATH="$DMG_TEMP/image-rw.dmg" DMG_OUTPUT_TEMP="" DMG_FINAL_PATH="" MOUNTED=0 +DMG_DEVICE="" + +dmg_attached() { + # mount(8) prints resolved paths; the run-private mount suffix still matches. + mount | grep -F -e "$MOUNT_POINT" -e "${DMG_DEVICE:-$MOUNT_POINT} on " >/dev/null +} cleanup_dmg() { if [[ "$MOUNTED" == "1" ]]; then - if hdiutil detach "$MOUNT_POINT" -force 2>/dev/null; then + if hdiutil detach "${DMG_DEVICE:-$MOUNT_POINT}" -force 2>/dev/null || ! dmg_attached; then MOUNTED=0 else echo "WARN: Preserving DMG temp root because mount is still attached: $DMG_TEMP" >&2 @@ -145,17 +151,25 @@ detach_dmg() { local delay # Mount writers have exited and the shell never enters MOUNT_POINT. # Flush writes, then give Finder/Spotlight up to 54s to release the volume. + # A volume that vanished on its own (diskarbitration ejected it) is detached. sync for delay in 2 3 4 5 6 7 8 9 10; do sleep "$delay" - if hdiutil detach "$MOUNT_POINT" -quiet 2>/dev/null; then + if hdiutil detach "$MOUNT_POINT" -quiet 2>/dev/null || ! dmg_attached; then MOUNTED=0 return fi done echo "WARN: DMG mount still busy; forcing detach: $MOUNT_POINT" >&2 - hdiutil detach "$MOUNT_POINT" -force || return 1 - MOUNTED=0 + for delay in 2 4 8; do + hdiutil detach "${DMG_DEVICE:-$MOUNT_POINT}" -force || hdiutil detach "$MOUNT_POINT" -force || true + if ! dmg_attached; then + MOUNTED=0 + return + fi + sleep "$delay" + done + return 1 } mkdir -p "$DMG_SOURCE" "$MOUNT_POINT" @@ -171,8 +185,11 @@ hdiutil create \ -format UDRW \ "$DMG_RW_PATH" -hdiutil attach "$DMG_RW_PATH" -mountpoint "$MOUNT_POINT" -nobrowse +DMG_ATTACH_OUTPUT="$(hdiutil attach "$DMG_RW_PATH" -mountpoint "$MOUNT_POINT" -nobrowse)" MOUNTED=1 +echo "$DMG_ATTACH_OUTPUT" +# Detach by device node survives the mount path disappearing under hdiutil. +DMG_DEVICE="$(awk -v mount="$MOUNT_POINT" 'index($0, mount) && $1 ~ /^\/dev\/disk/ { print $1; exit }' <<<"$DMG_ATTACH_OUTPUT")" if [[ "${SKIP_DMG_STYLE:-0}" != "1" ]]; then mkdir -p "$MOUNT_POINT/.background" diff --git a/test/scripts/create-dmg.test.ts b/test/scripts/create-dmg.test.ts index 760bcd3d8ffb..32598edb801f 100644 --- a/test/scripts/create-dmg.test.ts +++ b/test/scripts/create-dmg.test.ts @@ -83,6 +83,8 @@ case "$command_name" in mkdir -p "$mountpoint" printf mounted > "$mountpoint/live-volume-file" fi + printf '%s' "$mountpoint" > "\${HDIUTIL_LOG}.attached" + printf '/dev/disk99\tGUID_partition_scheme\t\n/dev/disk99s1\tApple_HFS\t%s\n' "$mountpoint" ;; detach) if [[ "\${HDIUTIL_DETACH_FAIL:-0}" == "1" ]]; then @@ -95,9 +97,16 @@ case "$command_name" in fi detach_attempts=$((detach_attempts + 1)) printf '%s' "$detach_attempts" > "$detach_attempts_file" + vanish_at="\${HDIUTIL_DETACH_VANISH_AT:-0}" + if (( vanish_at > 0 && detach_attempts >= vanish_at )); then + rm -f "\${HDIUTIL_LOG}.attached" + echo "hdiutil: detach failed - No such file or directory" >&2 + exit 1 + fi if (( detach_attempts <= \${HDIUTIL_DETACH_FAIL_COUNT:-0} )); then exit 9 fi + rm -f "\${HDIUTIL_LOG}.attached" ;; resize) if [[ "\${1:-}" == "-limits" ]]; then @@ -132,6 +141,17 @@ esac writeFileSync(tool, `#!/bin/bash\nprintf '${command} %s\\n' "$*" >> "$HDIUTIL_LOG"\n`, "utf8"); chmodSync(tool, 0o755); } + const mount = path.join(bin, "mount"); + writeFileSync( + mount, + `#!/bin/bash +echo '/dev/disk1s1 on / (apfs, sealed, local, read-only, journaled)' +[[ -f "$HDIUTIL_LOG.attached" ]] && echo "/dev/disk99s1 on $(cat "$HDIUTIL_LOG.attached") (hfs, local, nobrowse)" +exit 0 +`, + "utf8", + ); + chmodSync(mount, 0o755); return { env: { HDIUTIL_LOG: hdiutilLog, @@ -172,7 +192,10 @@ function expectPrivateDmgMount(log: string): string { log.matchAll(/^detach (.+?)(?: -(?:quiet|force))?$/gm), ([, target]) => target, ); - expect(new Set(detachTargets)).toEqual(new Set([mountPoint])); + expect(detachTargets).toContain(mountPoint); + expect(detachTargets.filter((target) => target !== "/dev/disk99s1")).toEqual( + detachTargets.filter((target) => target === mountPoint), + ); return mountPoint; } @@ -396,15 +419,53 @@ describe.runIf(process.platform === "darwin")("create-dmg ownership boundaries", expect(result.stderr).toContain("Failed to detach DMG mount"); expect(result.stderr).toContain("Preserving DMG temp root"); const log = readFileSync(tools.hdiutilLog, "utf8"); + const mountPoint = expectPrivateDmgMount(log); expect(log).not.toContain("resize"); expect(log).not.toContain("convert"); - // Ten packaging attempts plus the EXIT trap's last cleanup attempt. - expect(log.match(/^detach /gm)).toHaveLength(11); - const mountPoint = expectPrivateDmgMount(log); + // Nine polite attempts, three forced rounds by device then path, and the + // EXIT trap's last cleanup attempt. + expect(log.match(/^detach /gm)).toHaveLength(16); + expect(log.match(/^detach \/dev\/disk99s1 -force$/gm)).toHaveLength(4); + expect(log).toContain(`detach ${mountPoint} -force`); expect(readFileSync(path.join(mountPoint, "live-volume-file"), "utf8")).toBe("mounted"); rmSync(path.dirname(mountPoint), { recursive: true, force: true }); }); + it.each([ + { vanishAt: 5, detaches: 5, forced: false }, + { vanishAt: 10, detaches: 11, forced: true }, + ])( + "treats a busy volume that vanished at detach attempt $vanishAt as detached", + ({ vanishAt, detaches, forced }) => { + const app = makeValidApp(); + const outputDir = mkdtempSync(path.join(tmpdir(), "openclaw-create-dmg-output-")); + tempDirs.push(outputDir); + const output = path.join(outputDir, "OpenClaw.dmg"); + const tools = makeFakeDmgTools(); + + const result = runScript([app, output], { + ...tools.env, + HDIUTIL_DETACH_FAIL_COUNT: "9", + HDIUTIL_DETACH_VANISH_AT: String(vanishAt), + }); + + expect(result.status).toBe(0); + expect(readFileSync(output, "utf8")).toBe("converted"); + expect(result.stderr).not.toContain("Preserving DMG temp root"); + const log = readFileSync(tools.hdiutilLog, "utf8"); + const mountPoint = expectPrivateDmgMount(log); + expect(existsSync(path.dirname(mountPoint))).toBe(false); + const detachLines = log.split("\n").filter((line) => line.startsWith("detach ")); + expect(detachLines).toHaveLength(detaches); + // The forced round tries the device node first, then the mount path. + expect(detachLines.filter((line) => line.endsWith("-force"))).toEqual( + forced ? ["detach /dev/disk99s1 -force", `detach ${mountPoint} -force`] : [], + ); + expect(log).toContain("resize"); + expect(log).toContain("convert "); + }, + ); + it.each([6, 9])("retries %i failed DMG detaches before finalizing the artifact", (failures) => { const app = makeValidApp(); const outputDir = mkdtempSync(path.join(tmpdir(), "openclaw-create-dmg-output-"));