fix(preflight): asset mode retries a move that fails, names the cause and rolls back (#197)
A part of the stack that will not move from the unpack folder into place is retried for about 30 seconds; each retry removes what reached the target and unpacks the entry afresh from the archive, so the entry ends up there once and whole. If it still fails, both scripts take back everything they moved and exit 1 with the system's message and "nothing was installed". New test hook CHEMENU_PREFLIGHT_MOVE_FAIL=<entry>:<n>[:partial]. Files changed: - CHANGES.md - INSTALL.md - VERSION - instructions/preflight.md - tools/CONTRACT.md - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_preflight.py - tools/chemenu/tests/test_preflight_pwsh.py - tools/preflight.ps1 - tools/preflight.sh Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
1 parent
5915b07383
commit
5c9be38aa8
10 files changed
+379
-47
No files matched your search
+99
-19
@@ -23,16 +23,21 @@
|
||||
# it and what happens next; show it verbatim and wait (AGENTS.md § Tool
|
||||
# error contract). Never install anything on the user's behalf.
|
||||
# Exit 1: this script was called wrongly, a download or unpack failed (nothing is
|
||||
# unpacked then), or the tree next to it is incomplete.
|
||||
# unpacked then - or, should even that fail, the message names what is
|
||||
# left), or the tree next to it is incomplete.
|
||||
#
|
||||
# POSIX sh on purpose: it has to run before Python is known to exist, under dash,
|
||||
# bash and the Git Bash that Claude Code uses on Windows. The PowerShell
|
||||
# counterpart is tools/preflight.ps1; what both check is tools/prerequisites.txt.
|
||||
# The procedure around this script: instructions/preflight.md.
|
||||
#
|
||||
# Two variables exist for the test suite and are read nowhere else:
|
||||
# Three variables exist for the test suite and are read nowhere else:
|
||||
# CHEMENU_PREFLIGHT_PLATFORM (windows|macos|linux) and
|
||||
# CHEMENU_PREFLIGHT_LONGPATHS (0|1) stand in for `uname` and the registry.
|
||||
# CHEMENU_PREFLIGHT_LONGPATHS (0|1) stand in for `uname` and the registry;
|
||||
# CHEMENU_PREFLIGHT_MOVE_FAIL (<entry>:<n>[:partial]) stands in for a file some other
|
||||
# program holds open: moving the unpacked <entry> up fails the first <n> times (`always`:
|
||||
# every time), with :partial after moving a part of it, and the pauses between attempts
|
||||
# are skipped.
|
||||
|
||||
set -u
|
||||
|
||||
@@ -209,21 +214,87 @@ sha256_of() { # <file>
|
||||
fi
|
||||
}
|
||||
|
||||
# Whether the install folder holds anything besides this script and a .git - the
|
||||
# two things an empty folder may already carry: the script was downloaded into it, and
|
||||
# the user may have cloned the instance's own, still empty repository there.
|
||||
target_is_occupied() {
|
||||
[ -d "$TARGET" ] || return 1
|
||||
self_name=${0##*/}
|
||||
target_real=$(CDPATH='' cd -- "$TARGET" && pwd -P)
|
||||
for entry in "$TARGET"/* "$TARGET"/.[!.]* "$TARGET"/..?*; do
|
||||
[ -e "$entry" ] || [ -L "$entry" ] || continue
|
||||
name=${entry##*/}
|
||||
[ "$name" = .git ] && continue
|
||||
[ "$target_real" = "$DIR" ] && [ "$name" = "$self_name" ] && continue
|
||||
return 0
|
||||
# This script and a .git are the two things an empty install folder may already carry:
|
||||
# the script was downloaded into it, and the user may have cloned the instance's own,
|
||||
# still empty repository there. Neither counts as occupying the folder, and neither is
|
||||
# ever removed from it.
|
||||
is_protected() { # <name>
|
||||
[ "$1" = .git ] && return 0
|
||||
[ "$1" = "${0##*/}" ] && [ "$(CDPATH='' cd -- "$TARGET" && pwd -P)" = "$DIR" ]
|
||||
}
|
||||
|
||||
# The names in the install folder besides those two, one per line.
|
||||
occupants() {
|
||||
[ -d "$TARGET" ] || return 0
|
||||
for occupant in "$TARGET"/* "$TARGET"/.[!.]* "$TARGET"/..?*; do
|
||||
[ -e "$occupant" ] || [ -L "$occupant" ] || continue
|
||||
is_protected "${occupant##*/}" || printf '%s\n' "${occupant##*/}"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# How long an entry that will not move up is retried: the pauses between attempts, in
|
||||
# seconds - 30 in all. On Windows a folder cannot be renamed while any file in it is
|
||||
# held open, and a virus scanner or indexer looking at a freshly unpacked tree holds one
|
||||
# for a moment; waiting is what gets past it.
|
||||
MOVE_PAUSES='1 2 3 4 5 6 9'
|
||||
|
||||
# One attempt at moving the unpacked entry <name> up into the target; on failure the
|
||||
# system's message is in MOVE_ERROR. A failed attempt can leave part of the entry on
|
||||
# either side - a move that falls back to copying and deleting stops wherever the lock
|
||||
# is - so every attempt after the first starts over: what reached the target is removed,
|
||||
# and the entry is unpacked afresh from the archive. Only then is the entry in the
|
||||
# target, once and whole, after the attempt that works.
|
||||
move_entry() { # <name> <attempt>
|
||||
from="$UNPACK/$top/$1"
|
||||
landed="$TARGET/$1"
|
||||
if [ "$2" -gt 1 ]; then
|
||||
if { [ -e "$landed" ] || [ -L "$landed" ]; } && ! is_protected "$1"; then
|
||||
MOVE_ERROR=$(rm -rf -- "$landed" 2>&1) || return 1
|
||||
fi
|
||||
MOVE_ERROR=$(tar -xzf "$archive" -C "$UNPACK" "$top/$1" 2>&1) \
|
||||
|| { MOVE_ERROR="unpacking it again failed: $MOVE_ERROR"; return 1; }
|
||||
fi
|
||||
hook=${CHEMENU_PREFLIGHT_MOVE_FAIL:-}
|
||||
times=${hook#*:}
|
||||
case "$hook" in
|
||||
"$1":always*|"$1":[0-9]*)
|
||||
if [ "${times%%:*}" = always ] || [ "$2" -le "${times%%:*}" ]; then
|
||||
case "$hook" in
|
||||
*:partial)
|
||||
if [ -d "$from" ]; then
|
||||
mkdir -p "$landed"
|
||||
for part in "$from"/*; do
|
||||
[ -e "$part" ] && mv -- "$part" "$landed/"
|
||||
break
|
||||
done
|
||||
else
|
||||
: > "$landed"
|
||||
fi ;;
|
||||
esac
|
||||
MOVE_ERROR="mv: cannot move '$from': Permission denied (CHEMENU_PREFLIGHT_MOVE_FAIL, attempt $2)"
|
||||
return 1
|
||||
fi ;;
|
||||
esac
|
||||
MOVE_ERROR=$(mv -- "$from" "$TARGET/" 2>&1)
|
||||
}
|
||||
|
||||
# An entry would not move even after waiting: take back everything this run moved into
|
||||
# the target, so that it holds what it held before and running this again is all it
|
||||
# takes. Only names that came out of the archive are removed, never a protected one.
|
||||
undo_move() { # <name of the entry that would not move>
|
||||
while IFS= read -r undone; do
|
||||
[ -n "$undone" ] || continue
|
||||
is_protected "$undone" || rm -rf -- "${TARGET:?}/$undone" 2>/dev/null
|
||||
done <<EOT
|
||||
$MOVED
|
||||
$1
|
||||
EOT
|
||||
rm -rf -- "$UNPACK" 2>/dev/null && UNPACK=""
|
||||
left=$(occupants | tr '\n' ' ')
|
||||
if [ -z "$left" ]; then
|
||||
asset_fail "could not move $1 into $(native_path "$TARGET") ($MOVE_ERROR) - nothing was installed, the folder is as it was before. Run this again."
|
||||
fi
|
||||
asset_fail "could not move $1 into $(native_path "$TARGET") ($MOVE_ERROR), and could not take back what was already there: ${left% } - empty the folder (keep .git), then run this again."
|
||||
}
|
||||
|
||||
asset_mode() {
|
||||
@@ -241,7 +312,7 @@ asset_mode() {
|
||||
if { [ -e "$TARGET" ] || [ -L "$TARGET" ]; } && [ ! -d "$TARGET" ]; then
|
||||
asset_fail "$(native_path "$TARGET") exists and is not a folder - nothing was unpacked."
|
||||
fi
|
||||
if target_is_occupied; then
|
||||
if [ -n "$(occupants)" ]; then
|
||||
asset_fail "$(native_path "$TARGET") is not empty - nothing was unpacked. The wiki installs into an empty folder (an empty git clone is fine): put this script into one and run it there, or pass --into <path>."
|
||||
fi
|
||||
|
||||
@@ -323,9 +394,18 @@ Alternatively, someone with administrator rights can turn on long paths in Windo
|
||||
mkdir "$UNPACK" || asset_fail "could not create a temporary folder in the target."
|
||||
tar -xzf "$archive" -C "$UNPACK" || asset_fail "unpacking failed - nothing was installed."
|
||||
[ -d "$UNPACK/$top" ] || asset_fail "unpacking produced no $top folder - nothing was installed."
|
||||
MOVED=""
|
||||
for entry in "$UNPACK/$top"/* "$UNPACK/$top"/.[!.]* "$UNPACK/$top"/..?*; do
|
||||
[ -e "$entry" ] || [ -L "$entry" ] || continue
|
||||
mv "$entry" "$TARGET/" || asset_fail "could not move ${entry##*/} into $(native_path "$TARGET") - the folder holds part of the stack now; empty it (keep .git) and run this again."
|
||||
attempt=1
|
||||
until move_entry "${entry##*/}" "$attempt"; do
|
||||
pause=$(printf '%s\n' $MOVE_PAUSES | sed -n "${attempt}p")
|
||||
[ -n "$pause" ] || undo_move "${entry##*/}"
|
||||
[ -n "${CHEMENU_PREFLIGHT_MOVE_FAIL:-}" ] || sleep "$pause"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
MOVED="$MOVED
|
||||
${entry##*/}"
|
||||
done
|
||||
rm -rf "$UNPACK"
|
||||
UNPACK=""
|
||||
|
||||
Reference in new issue
Block a user