Add a spike workflow to diagnose the macOS DMG detach failure

Three Intel appbundle runs have now failed identically, always exactly 120
seconds after create-dmg starts unmounting, with the bundle otherwise complete
and verified; arm64 has passed four times through the same code, and disabling
Spotlight changed nothing. Reproducing it through `make appbundle` costs forty
minutes a go, nearly all of it compiling cryptography on Intel, which is a
miserable way to test a hypothesis.

So this reproduces the failing step directly, in a couple of minutes, and
answers three questions at once: whether plain hdiutil fails or only create-dmg
does, whether it depends on image size, and whether it is specific to the
macos-15-intel image. arm64 is included as the control that is known to work.

When something fails it captures evidence rather than leaving us guessing:
hdiutil's own view of what is attached, lsof against the volume, the processes
that typically hold one, the diskarbitrationd and hdiutil unified log for the
preceding few minutes, and whether a forced detach succeeds where the polite
one timed out. That last one should be the most telling, since it distinguishes
something holding a reference from DiskArbitration itself not answering.

The mdutil step added to check-macos-build.yml stays for now so the two runs
remain comparable; it should come out once we know the real cause.
This commit is contained in:
Dave Page committed 2026-09-16 12:03:20 +01:00
1 parent 68a5f7781f
commit d5c71d7063
1 file changed
+200
+200
View File
@@ -0,0 +1,200 @@
# Throwaway diagnostic spike. NOT part of CI.
#
# `hdiutil detach` fails deterministically on macos-15-intel at the end of
# create-dmg, always exactly 120 seconds after the unmount starts:
#
# Unmounting disk image...
# hdiutil: detach: timeout for DiskArbitration expired
# hdiutil: detach: drive not detached
#
# Three appbundle runs, three identical failures, while arm64 passed four times
# through the same code. Disabling Spotlight made no difference.
#
# Reproducing that through `make appbundle` costs 40 minutes a go, most of it
# compiling cryptography, so this reproduces it directly instead and takes a
# couple of minutes. It answers three questions at once:
#
# 1. Is it create-dmg at all, or plain hdiutil? (test A vs test B)
# 2. Does it depend on image size? (test A runs small and large)
# 3. Is it the runner image? (the matrix includes macos-26-intel, with
# arm64 as the control that is known to work)
#
# and when something does fail, it captures who is holding the volume rather
# than leaving us to guess: hdiutil's own view, lsof, the diskarbitrationd
# log, and whether a forced detach succeeds where the polite one did not.
#
# Delete once the cause is understood.
name: Spike - macOS DMG detach diagnostics
on:
workflow_dispatch:
permissions:
contents: read
jobs:
dmg:
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
runner:
- 'macos-15-intel'
- 'macos-26-intel'
- 'macos-15'
steps:
- uses: actions/checkout@v7
- name: Record the runner specification
run: |
sw_vers
echo "Architecture: $(uname -m)"
echo "CPUs: $(sysctl -n hw.logicalcpu)"
echo "RAM: $(( $(sysctl -n hw.memsize) / 1024 / 1024 )) MB"
df -h /
# Written once and reused by every test below, so a failure anywhere
# produces the same evidence.
- name: Install the diagnostic helper
run: |
cat > "${RUNNER_TEMP}/diagnose.sh" <<'EOF'
#!/bin/bash
# $1 = mount point (may be empty), $2 = device node (may be empty)
MOUNT="$1"
DEV="$2"
echo "======== DIAGNOSTICS ========"
echo "---- hdiutil info ----"
hdiutil info || true
echo "---- mounted volumes ----"
ls -l /Volumes/ || true
mount || true
echo "---- lsof against the volume ----"
if [ -n "${MOUNT}" ]; then
lsof "${MOUNT}" 2>/dev/null || true
lsof +D "${MOUNT}" 2>/dev/null || true
fi
lsof 2>/dev/null | grep -F '/Volumes/' || echo "(no lsof rows mentioning /Volumes/)"
echo "---- processes that tend to hold volumes ----"
ps aux | grep -E 'mds|mds_stores|fseventsd|diskarbitrationd|Spotlight|antivirus' \
| grep -v grep || echo "(none)"
echo "---- diskarbitrationd log, last 3 minutes ----"
sudo log show --last 3m --predicate \
'process == "diskarbitrationd" OR process == "hdiutil" OR process == "diskimages-helper"' \
--style compact 2>/dev/null | tail -60 || echo "(log show unavailable)"
echo "---- does a forced detach work where the polite one did not? ----"
if [ -n "${DEV}" ]; then
time hdiutil detach "${DEV}" -force && echo "FORCED DETACH: SUCCEEDED" \
|| echo "FORCED DETACH: ALSO FAILED"
fi
echo "======== END DIAGNOSTICS ========"
EOF
chmod +x "${RUNNER_TEMP}/diagnose.sh"
# Test A: no create-dmg, no pgAdmin, just hdiutil. If this fails, the
# problem is the runner's disk image handling and create-dmg is a
# bystander. Two sizes, because a 120-second timeout smells like it
# could scale with how much there is to flush.
- name: 'Test A: bare hdiutil attach/detach'
id: test_a
continue-on-error: true
run: |
set -o pipefail
for SIZE in 100m 1500m; do
echo "############ bare hdiutil, ${SIZE} ############"
IMG="${RUNNER_TEMP}/bare-${SIZE}.dmg"
rm -f "${IMG}"
hdiutil create -size "${SIZE}" -fs HFS+ -volname "BareTest" "${IMG}"
DEV=$(hdiutil attach "${IMG}" -nobrowse -noverify -noautoopen \
| grep -E '^/dev/' | head -1 | awk '{print $1}')
MOUNT=$(mount | grep -F "${DEV}" | sed -E 's/.* on (.*) \(.*/\1/')
echo "device=${DEV} mount=${MOUNT}"
# Put something in it, so the detach has buffers to flush.
dd if=/dev/urandom of="${MOUNT}/payload" bs=1m count=50 2>/dev/null
sync
echo "---- detaching ----"
START=$(date +%s)
if hdiutil detach "${DEV}"; then
echo "DETACH OK after $(( $(date +%s) - START ))s (${SIZE})"
else
echo "DETACH FAILED after $(( $(date +%s) - START ))s (${SIZE})"
"${RUNNER_TEMP}/diagnose.sh" "${MOUNT}" "${DEV}"
exit 1
fi
done
# Test B: create-dmg with pgAdmin's real flags and real assets, over a
# payload of roughly the size of the actual bundle. This is the failing
# path, minus the 40 minutes of build in front of it.
- name: 'Test B: create-dmg with pgAdmin flags'
id: test_b
continue-on-error: true
run: |
set -o pipefail
git clone --depth 1 https://github.com/create-dmg/create-dmg.git "${RUNNER_TEMP}/create-dmg"
echo "create-dmg at $(git -C "${RUNNER_TEMP}/create-dmg" rev-parse --short HEAD)"
# A stand-in for the .app: a few hundred MB of mixed compressible and
# incompressible data, since the real DMG is UDBZ and the bundle is
# mostly a Python framework plus Electron.
APP="${RUNNER_TEMP}/pgAdmin 4.app"
mkdir -p "${APP}/Contents/MacOS"
dd if=/dev/urandom of="${APP}/Contents/MacOS/blob" bs=1m count=400 2>/dev/null
for i in $(seq 1 200); do
cp "${GITHUB_WORKSPACE}/README.md" "${APP}/Contents/text-${i}.md"
done
du -sh "${APP}"
mkdir -p "${RUNNER_TEMP}/out"
START=$(date +%s)
set +e
"${RUNNER_TEMP}/create-dmg/create-dmg" \
--volname "pgAdmin 4" \
--volicon "${GITHUB_WORKSPACE}/pkg/mac/dmg-icon.icns" \
--eula "${GITHUB_WORKSPACE}/pkg/mac/licence.rtf" \
--background "${GITHUB_WORKSPACE}/pkg/mac/dmg-background.png" \
--app-drop-link 600 220 \
--icon "pgAdmin 4.app" 200 220 \
--window-pos 200 120 \
--window-size 800 400 \
--hide-extension "pgAdmin 4.app" \
--add-file .DS_Store "${GITHUB_WORKSPACE}/pkg/mac/dmg.DS_Store" 5 5 \
--format UDBZ \
--skip-jenkins \
--no-internet-enable \
"${RUNNER_TEMP}/out/test.dmg" \
"${APP}"
RC=$?
set -e
echo "create-dmg exited ${RC} after $(( $(date +%s) - START ))s"
if [ ${RC} -ne 0 ]; then
# create-dmg leaves the volume mounted when the detach fails, so
# find it rather than assuming a name.
MOUNT=$(ls -d /Volumes/dmg.* 2>/dev/null | head -1)
DEV=$(mount | grep -F "${MOUNT}" | awk '{print $1}')
echo "leftover mount=${MOUNT} device=${DEV}"
"${RUNNER_TEMP}/diagnose.sh" "${MOUNT}" "${DEV}"
exit 1
fi
ls -lh "${RUNNER_TEMP}/out/"
- name: Summarise
if: always()
run: |
echo "runner: ${{ matrix.runner }}"
echo "test A (bare hdiutil): ${{ steps.test_a.outcome }}"
echo "test B (create-dmg): ${{ steps.test_b.outcome }}"