fix(macos): treat a vanished DMG mount as detached and force-detach by device node (#156702)

This commit is contained in:
Peter Steinberger
2026-09-23 13:38:00 -07:00
committed by GitHub
parent a08af31b6f
commit 71875bfb9e
2 changed files with 87 additions and 9 deletions
+22 -5
View File
@@ -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"
+65 -4
View File
@@ -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-"));