Wait on the profile being repaired, not on every browser (#6837)

The migration asked a user to close every running browser before repairing
the Copy URL shortcut, but a browser only ever rewrites its own Preferences
on exit. Waiting on all browsers deadlocks `omarchy update` for anyone whose
main browser is effectively never closed: the pending ghosts commonly sit in
a stale profile nobody has open, yet the migration blocks on the always-open
browser until the prompt is declined, failing the whole update.

A running Chromium-family browser holds a SingletonLock (and socket) inside
its user-data-dir, so whether the profile being repaired is open is
mechanical. Gate on that instead of on the sheer presence of a browser
process — the repair proceeds where the affected profile is closed, and
browsers attached to other profiles no longer hold the update hostage.

The gate stays conservative while an affected profile actually is open, and
the existing post-repair verification still catches a browser that starts
mid-repair and restores stale Preferences on exit.

The migration test now simulates an open profile with its SingletonLock
instead of a pgrep stub; every prior scenario still passes.
This commit is contained in:
Shrijit Srivastav
2026-08-14 08:59:14 +02:00
committed by GitHub
parent dc698e5df0
commit d35f4b6d59
2 changed files with 128 additions and 69 deletions
+42 -16
View File
@@ -122,10 +122,36 @@ unverified_repairs_exist() {
return 1 return 1
} }
# Only this user's browsers matter — another user's cannot rewrite these # A browser only rewrites its own Preferences on exit, so a repair can only be
# Preferences. # reverted by a browser attached to a profile this migration has to touch.
browsers_running() { # Whether a profile is open is mechanical: a running Chromium-family browser
pgrep -x -u "$UID" 'chromium|chrome|brave|msedge|vivaldi-bin|vivaldi|opera|helium' >/dev/null 2>&1 # holds a SingletonLock (and socket) inside its user-data-dir.
profile_open() {
# -L catches a SingletonLock left as a dangling symlink; -e covers a plain
# file, and -S the socket — any of them means a browser is attached.
[[ -L $1/SingletonLock || -e $1/SingletonLock || -S $1/SingletonSocket ]]
}
# Gate on a pending — or to-be-verified — profile actually being open, not on
# browsers in general. Waiting on every browser process deadlocks the update on
# machines whose main browser is effectively never closed: the pending ghosts
# commonly sit in a stale profile nobody has open, yet the update blocks on the
# always-open one.
affected_profile_open() {
local preferences backup profile_root
for preferences in "${pending[@]}"; do
profile_open "$(dirname "$(dirname "$preferences")")" && return 0
done
for profile_root in "${profile_roots[@]}"; do
for backup in "$profile_root"/*/Preferences.omarchy-copy-url-repair.bak; do
[[ -f $backup ]] || continue
profile_open "$(dirname "$(dirname "$backup")")" && return 0
done
done
return 1
} }
find_pending find_pending
@@ -134,16 +160,16 @@ if (( ! ${#pending[@]} )) && ! unverified_repairs_exist; then
fi fi
# A running browser holds Preferences in memory and rewrites the file on exit, # A running browser holds Preferences in memory and rewrites the file on exit,
# reverting the repair, so ask for the windows to be closed first. gum draws # reverting the repair, so ask for the affected windows to be closed first. gum
# the prompt on stderr, so it must stay attached: suppressing it leaves gum # draws the prompt on stderr, so it must stay attached: suppressing it leaves
# waiting for a keypress behind an unpainted screen. Without a terminal to ask # gum waiting for a keypress behind an unpainted screen. Without a terminal to
# in, gum fails; then — as on decline — fail so the migration stays pending, # ask in, gum fails; then — as on decline — fail so the migration stays
# and the login notifier keeps prompting until a rerun goes through with # pending, and the login notifier keeps prompting until a rerun goes through
# browsers closed. # with the affected profiles closed.
while browsers_running; do while affected_profile_open; do
if ! gum confirm "Close all browser windows to repair the Copy URL shortcut, then continue"; then if ! gum confirm "Close the browser windows to repair the Copy URL shortcut, then continue"; then
echo "A running browser would undo the Copy URL shortcut repair." >&2 echo "A running browser would undo the Copy URL shortcut repair." >&2
echo "Close all browser windows, then run: omarchy-migrate" >&2 echo "Close the browser windows, then run: omarchy-migrate" >&2
exit 1 exit 1
fi fi
done done
@@ -159,9 +185,9 @@ done
# A browser that started mid-repair read the stale Preferences and will write # A browser that started mid-repair read the stale Preferences and will write
# them back on exit; stay pending so the next browser-free run can verify the # them back on exit; stay pending so the next browser-free run can verify the
# repair stuck. # repair stuck.
if browsers_running; then if affected_profile_open; then
echo "A browser started during the Copy URL shortcut repair and may undo it on exit." >&2 echo "A browser started during the Copy URL shortcut repair and may undo it on exit." >&2
echo "Close all browser windows, then run: omarchy-migrate" >&2 echo "Close the browser windows, then run: omarchy-migrate" >&2
exit 1 exit 1
fi fi
@@ -172,7 +198,7 @@ fi
for preferences in "${pending[@]}"; do for preferences in "${pending[@]}"; do
if python3 -c "$repair_py" "$preferences" "$pinned_id" check; then if python3 -c "$repair_py" "$preferences" "$pinned_id" check; then
echo "A browser undid the Copy URL shortcut repair on exit." >&2 echo "A browser undid the Copy URL shortcut repair on exit." >&2
echo "Close all browser windows, then run: omarchy-migrate" >&2 echo "Close the browser windows, then run: omarchy-migrate" >&2
exit 1 exit 1
fi fi
done done
@@ -12,7 +12,8 @@ test_dir=$(mktemp -d)
trap 'rm -rf "$test_dir"' EXIT trap 'rm -rf "$test_dir"' EXIT
home="$test_dir/home" home="$test_dir/home"
preferences="$home/.config/chromium/Default/Preferences" profile_root="$home/.config/chromium"
preferences="$profile_root/Default/Preferences"
mkdir -p "$(dirname "$preferences")" mkdir -p "$(dirname "$preferences")"
# Any id Chromium once derived from the extension's keyless load path; the # Any id Chromium once derived from the extension's keyless load path; the
@@ -27,23 +28,38 @@ write_stale_preferences() {
stub_bin="$test_dir/bin" stub_bin="$test_dir/bin"
mkdir -p "$stub_bin" mkdir -p "$stub_bin"
REAL_PYTHON=$(command -v python3)
export REAL_PYTHON
run_migration() { run_migration() {
HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1 HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1
} }
# A running browser prompts for the windows to be closed; declining (or having # A running Chromium-family browser marks its profile root with a SingletonLock
# no terminal to ask in) defers the repair so a rewrite-on-exit cannot revert # symlink to <hostname>-<pid>, a target that never exists on disk. That lock —
# it. # not the mere presence of a browser process — is what the migration waits on.
printf '#!/bin/bash\nexit 0\n' >"$stub_bin/pgrep" open_browser() {
mkdir -p "$profile_root"
ln -sfn "test-host-1234" "$profile_root/SingletonLock"
}
close_browser() {
rm -f "$profile_root/SingletonLock"
}
# The affected profile being open prompts for the windows to be closed;
# declining (or having no terminal to ask in) defers the repair so a
# rewrite-on-exit cannot revert it.
printf '#!/bin/bash\nexit 1\n' >"$stub_bin/gum" printf '#!/bin/bash\nexit 1\n' >"$stub_bin/gum"
chmod +x "$stub_bin/pgrep" "$stub_bin/gum" chmod +x "$stub_bin/gum"
write_stale_preferences write_stale_preferences
open_browser
before_hash=$(sha256sum "$preferences" | cut -d' ' -f1) before_hash=$(sha256sum "$preferences" | cut -d' ' -f1)
run_migration && fail "migration defers while a browser is running" run_migration && fail "migration defers while the affected profile is open"
[[ $(sha256sum "$preferences" | cut -d' ' -f1) == "$before_hash" ]] || [[ $(sha256sum "$preferences" | cut -d' ' -f1) == "$before_hash" ]] ||
fail "migration leaves preferences alone while a browser is running" fail "migration leaves preferences alone while the affected profile is open"
pass "migration defers the repair while a browser is running" pass "migration defers the repair while the affected profile is open"
# gum paints its prompt on stderr, so that stream has to stay attached: # gum paints its prompt on stderr, so that stream has to stay attached:
# suppressing it leaves gum reading keys behind an unpainted screen, which # suppressing it leaves gum reading keys behind an unpainted screen, which
@@ -59,34 +75,47 @@ HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2
grep -q "gum-prompt-painted" "$prompt_stderr" || fail "migration keeps the browser prompt visible" grep -q "gum-prompt-painted" "$prompt_stderr" || fail "migration keeps the browser prompt visible"
pass "migration keeps the browser prompt visible" pass "migration keeps the browser prompt visible"
# Confirming the prompt after closing the browser lets the repair proceed. # A browser holding a different profile root cannot revert this repair, so it
# must not hold the update: the repair goes through without ever reaching the
# prompt, which the still-declining gum stub would otherwise fail.
close_browser
mkdir -p "$home/.config/google-chrome"
ln -sfn "test-host-1234" "$home/.config/google-chrome/SingletonLock"
write_stale_preferences
run_migration || fail "migration repairs while a different profile root is open"
jq -e --arg pinned "$pinned_id" '.extensions.commands["linux:Alt+Shift+L"].extension == $pinned' "$preferences" >/dev/null ||
fail "migration repairs the shortcut while a different profile root is open"
pass "migration ignores a browser on a different profile root"
rm -f "$home/.config/google-chrome/SingletonLock" "$preferences.omarchy-copy-url-repair.bak"
# Closing the affected profile and confirming the prompt lets the repair
# proceed.
write_stale_preferences
open_browser
cat >"$stub_bin/gum" <<'STUB' cat >"$stub_bin/gum" <<'STUB'
#!/bin/bash #!/bin/bash
"$CLOSE_BROWSER"
touch "${GUM_CALLED:?}" touch "${GUM_CALLED:?}"
exit 0 exit 0
STUB STUB
cat >"$stub_bin/pgrep" <<'STUB' cat >"$stub_bin/close-browser" <<'STUB'
#!/bin/bash #!/bin/bash
count_file="${PGREP_COUNT_FILE:?}" rm -f "$HOME/.config/chromium/SingletonLock"
count=$(( $(cat "$count_file" 2>/dev/null || echo 0) + 1 ))
printf '%s\n' "$count" >"$count_file"
(( count == 1 )) && exit 0 || exit 1
STUB STUB
rm -f "$test_dir/pgrep-count" chmod +x "$stub_bin/gum" "$stub_bin/close-browser"
GUM_CALLED="$test_dir/gum-called" PGREP_COUNT_FILE="$test_dir/pgrep-count" \ GUM_CALLED="$test_dir/gum-called" CLOSE_BROWSER="$stub_bin/close-browser" \
HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1 || HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1 ||
fail "migration proceeds once the browser prompt is confirmed" fail "migration proceeds once the profile is closed and the prompt confirmed"
[[ -e $test_dir/gum-called ]] || fail "migration asks before repairing under a running browser" [[ -e $test_dir/gum-called ]] || fail "migration asks before repairing under a running browser"
jq -e --arg pinned "$pinned_id" '.extensions.commands["linux:Alt+Shift+L"].extension == $pinned' "$preferences" >/dev/null || jq -e --arg pinned "$pinned_id" '.extensions.commands["linux:Alt+Shift+L"].extension == $pinned' "$preferences" >/dev/null ||
fail "migration repairs after the browser prompt is confirmed" fail "migration repairs after the browser prompt is confirmed"
pass "migration asks to close the browser and repairs on confirmation" pass "migration asks to close the browser and repairs on confirmation"
rm -f "$preferences.omarchy-copy-url-repair.bak" rm -f "$preferences.omarchy-copy-url-repair.bak"
printf '#!/bin/bash\nexit 1\n' >"$stub_bin/gum"
printf '#!/bin/bash\nexit 1\n' >"$stub_bin/pgrep"
write_stale_preferences
# With browsers closed the ghost registration moves to the pinned id. # With the affected profile closed the ghost registration moves to the pinned id.
printf '#!/bin/bash\nexit 1\n' >"$stub_bin/pgrep" printf '#!/bin/bash\nexit 1\n' >"$stub_bin/gum"
close_browser
write_stale_preferences
run_migration || fail "migration repairs the shortcut when no browser is running" run_migration || fail "migration repairs the shortcut when no browser is running"
jq -e --arg ghost "$ghost_id" --arg pinned "$pinned_id" ' jq -e --arg ghost "$ghost_id" --arg pinned "$pinned_id" '
@@ -99,15 +128,15 @@ jq -e --arg ghost "$ghost_id" --arg pinned "$pinned_id" '
pass "migration rebinds the Copy URL shortcut to the pinned extension id" pass "migration rebinds the Copy URL shortcut to the pinned extension id"
# A repaired profile has no ghost registration left, so nothing is pending — # A repaired profile has no ghost registration left, so nothing is pending —
# even while a browser is running. # even while that same profile is open.
rm "$preferences.omarchy-copy-url-repair.bak" rm "$preferences.omarchy-copy-url-repair.bak"
repaired_hash=$(sha256sum "$preferences" | cut -d' ' -f1) repaired_hash=$(sha256sum "$preferences" | cut -d' ' -f1)
printf '#!/bin/bash\nexit 0\n' >"$stub_bin/pgrep" open_browser
run_migration || fail "migration reruns cleanly after the repair" run_migration || fail "migration reruns cleanly after the repair"
[[ $(sha256sum "$preferences" | cut -d' ' -f1) == "$repaired_hash" && ! -e $preferences.omarchy-copy-url-repair.bak ]] || [[ $(sha256sum "$preferences" | cut -d' ' -f1) == "$repaired_hash" && ! -e $preferences.omarchy-copy-url-repair.bak ]] ||
fail "migration is idempotent after the repair" fail "migration is idempotent after the repair"
pass "migration is idempotent after the repair" pass "migration is idempotent after the repair"
printf '#!/bin/bash\nexit 1\n' >"$stub_bin/pgrep" close_browser
# A remapped shortcut keeps the user's chosen key while moving to the pinned id. # A remapped shortcut keeps the user's chosen key while moving to the pinned id.
jq -n --arg ghost "$ghost_id" '{extensions: {commands: {"linux:Ctrl+Alt+P": {command_name: "copy-url", extension: $ghost, global: false}}, settings: {}}}' >"$preferences" jq -n --arg ghost "$ghost_id" '{extensions: {commands: {"linux:Ctrl+Alt+P": {command_name: "copy-url", extension: $ghost, global: false}}, settings: {}}}' >"$preferences"
@@ -127,60 +156,64 @@ jq -e --arg pinned "$pinned_id" '
pass "migration never double-binds the pinned extension" pass "migration never double-binds the pinned extension"
# A browser starting mid-repair may write stale Preferences back on exit, so # A browser starting mid-repair may write stale Preferences back on exit, so
# the migration must stay pending for a later browser-free run to verify. # the migration must stay pending for a later browser-free run to verify. A
# stub hands the repair call through and opens the profile right after it.
write_stale_preferences write_stale_preferences
cat >"$stub_bin/pgrep" <<'STUB' close_browser
rm -f "$preferences.omarchy-copy-url-repair.bak"
cat >"$stub_bin/python3" <<'STUB'
#!/bin/bash #!/bin/bash
count_file="${PGREP_COUNT_FILE:?}" # Called as `python3 -c <script> <preferences> <pinned_id> <check|repair>`, and
count=$(( $(cat "$count_file" 2>/dev/null || echo 0) + 1 )) # the check calls report a surviving ghost through their exit status.
printf '%s\n' "$count" >"$count_file" "${REAL_PYTHON}" "$@"
(( count >= 2 )) && exit 0 || exit 1 status=$?
[[ ${5:-} == "repair" ]] && ln -sfn "test-host-1234" "$HOME/.config/chromium/SingletonLock"
exit $status
STUB STUB
rm -f "$test_dir/pgrep-count" chmod +x "$stub_bin/python3"
if PGREP_COUNT_FILE="$test_dir/pgrep-count" HOME="$home" PATH="$stub_bin:$PATH" \ if HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1; then
bash -euo pipefail "$migration" >/dev/null 2>&1; then
fail "migration stays pending when a browser starts mid-repair" fail "migration stays pending when a browser starts mid-repair"
fi fi
jq -e --arg pinned "$pinned_id" '.extensions.commands["linux:Alt+Shift+L"].extension == $pinned' "$preferences" >/dev/null || jq -e --arg pinned "$pinned_id" '.extensions.commands["linux:Alt+Shift+L"].extension == $pinned' "$preferences" >/dev/null ||
fail "migration still repairs preferences before deferring on a late browser" fail "migration still repairs preferences before deferring on a late browser"
pass "migration stays pending when a browser starts mid-repair" pass "migration stays pending when a browser starts mid-repair"
rm -f "$preferences.omarchy-copy-url-repair.bak" rm -f "$stub_bin/python3" "$preferences.omarchy-copy-url-repair.bak"
close_browser
# A browser that started and exited mid-repair restores stale Preferences # A browser that started and exited mid-repair restores stale Preferences
# before the final process check; the post-repair file verification catches it. # before the final profile check; the post-repair file verification catches it.
write_stale_preferences write_stale_preferences
cp "$preferences" "$test_dir/stale-preferences" cp "$preferences" "$test_dir/stale-preferences"
cat >"$stub_bin/pgrep" <<'STUB' cat >"$stub_bin/python3" <<'STUB'
#!/bin/bash #!/bin/bash
count_file="${PGREP_COUNT_FILE:?}" "${REAL_PYTHON}" "$@"
count=$(( $(cat "$count_file" 2>/dev/null || echo 0) + 1 )) status=$?
printf '%s\n' "$count" >"$count_file" [[ ${5:-} == "repair" ]] && cp "${STALE_PREFERENCES:?}" "${REPAIRED_PREFERENCES:?}"
(( count == 2 )) && cp "$STALE_PREFERENCES" "$REPAIRED_PREFERENCES" exit $status
exit 1
STUB STUB
rm -f "$test_dir/pgrep-count" chmod +x "$stub_bin/python3"
if PGREP_COUNT_FILE="$test_dir/pgrep-count" STALE_PREFERENCES="$test_dir/stale-preferences" \ if HOME="$home" PATH="$stub_bin:$PATH" STALE_PREFERENCES="$test_dir/stale-preferences" \
REPAIRED_PREFERENCES="$preferences" HOME="$home" PATH="$stub_bin:$PATH" \ REPAIRED_PREFERENCES="$preferences" bash -euo pipefail "$migration" >/dev/null 2>&1; then
bash -euo pipefail "$migration" >/dev/null 2>&1; then
fail "migration stays pending when a briefly-lived browser undoes the repair" fail "migration stays pending when a briefly-lived browser undoes the repair"
fi fi
pass "migration stays pending when a briefly-lived browser undoes the repair" pass "migration stays pending when a briefly-lived browser undoes the repair"
printf '#!/bin/bash\nexit 1\n' >"$stub_bin/pgrep" rm -f "$stub_bin/python3"
close_browser
write_stale_preferences write_stale_preferences
run_migration || fail "migration recovers after a reverted repair" run_migration || fail "migration recovers after a reverted repair"
rm -f "$preferences.omarchy-copy-url-repair.bak" rm -f "$preferences.omarchy-copy-url-repair.bak"
# A repair attempted while a browser was open leaves its backup behind. A # A repair attempted while the affected profile was open leaves its backup
# rerun that sees a clean disk while that browser still runs must stay # behind. A rerun that sees a clean disk while that profile still runs must
# pending — the browser can restore the ghost on exit — and only a # stay pending — the browser can restore the ghost on exit — and only a
# browser-free rerun verifies the repair and completes. # browser-free rerun verifies the repair and completes.
write_stale_preferences write_stale_preferences
run_migration || fail "repair run before the verification scenario" run_migration || fail "repair run before the verification scenario"
[[ -f $preferences.omarchy-copy-url-repair.bak ]] || fail "verification scenario has a repair backup" [[ -f $preferences.omarchy-copy-url-repair.bak ]] || fail "verification scenario has a repair backup"
printf '#!/bin/bash\nexit 0\n' >"$stub_bin/pgrep" open_browser
run_migration && fail "migration must not complete an unverified repair while a browser runs" run_migration && fail "migration must not complete an unverified repair while a browser runs"
pass "migration keeps an unverified repair pending while a browser runs" pass "migration keeps an unverified repair pending while a browser runs"
printf '#!/bin/bash\nexit 1\n' >"$stub_bin/pgrep" close_browser
run_migration || fail "migration completes once the repair is verified with browsers closed" run_migration || fail "migration completes once the repair is verified with browsers closed"
pass "migration verifies an attempted repair on a browser-free rerun" pass "migration verifies an attempted repair on a browser-free rerun"
rm -f "$preferences.omarchy-copy-url-repair.bak" rm -f "$preferences.omarchy-copy-url-repair.bak"