317 lines
12 KiB
Bash
317 lines
12 KiB
Bash
#!/bin/bash
|
|
|
|
set -euo pipefail
|
|
|
|
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
|
|
|
require_command flock
|
|
require_command setsid
|
|
|
|
tmp=$(mktemp -d)
|
|
lazy_groups=()
|
|
cleanup() {
|
|
local status=$? gate group running attempt
|
|
trap - EXIT
|
|
# Release every fixture gate, including on an assertion failure. Each lazy
|
|
# invocation owns a private process group, so its detached pool is tracked.
|
|
for gate in "$tmp"/lazy-state*/gate; do
|
|
[[ ! -d ${gate%/*} ]] || touch "$gate"
|
|
done
|
|
for attempt in {1..500}; do
|
|
running=false
|
|
for group in "${lazy_groups[@]}"; do
|
|
if kill -0 -- "-$group" 2>/dev/null; then running=true; fi
|
|
done
|
|
[[ $running == "true" ]] || break
|
|
sleep 0.02
|
|
done
|
|
for group in "${lazy_groups[@]}"; do
|
|
kill -TERM -- "-$group" 2>/dev/null || true
|
|
wait "$group" 2>/dev/null || true
|
|
done
|
|
rm -rf "$tmp"
|
|
exit "$status"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
cache_home="$tmp/cache"
|
|
images="$tmp/images"
|
|
stub_bin="$tmp/bin"
|
|
mkdir -p "$images" "$stub_bin"
|
|
|
|
cat >"$stub_bin/vipsthumbnail" <<'EOF'
|
|
#!/bin/bash
|
|
|
|
image="$1"
|
|
shift
|
|
|
|
while (( $# > 0 )); do
|
|
if [[ $1 == "--path" ]]; then
|
|
output=${2%%\[*}
|
|
break
|
|
fi
|
|
shift
|
|
done
|
|
|
|
if [[ -f ${VIPSTHUMBNAIL_FAIL_FILE:-} ]] && grep -Fxq "$image" "$VIPSTHUMBNAIL_FAIL_FILE"; then
|
|
exit 1
|
|
fi
|
|
|
|
[[ -z ${VIPSTHUMBNAIL_CALLS_FILE:-} ]] || printf '%s\n' "$image" >>"$VIPSTHUMBNAIL_CALLS_FILE"
|
|
[[ -z ${VIPSTHUMBNAIL_DELAY:-} ]] || sleep "$VIPSTHUMBNAIL_DELAY"
|
|
printf 'thumbnail' >"$output"
|
|
EOF
|
|
chmod +x "$stub_bin/vipsthumbnail"
|
|
|
|
for name in one two three; do
|
|
printf 'image-%s' "$name" >"$images/$name.png"
|
|
done
|
|
|
|
cache_dir="$cache_home/omarchy/image-selector"
|
|
mkdir -p "$cache_dir"
|
|
|
|
stale_tmp=""
|
|
live_lock=""
|
|
for image in "$images"/*; do
|
|
signature=$(stat -Lc '%s:%Y' "$image")
|
|
hash=$(printf '%s\t%s' "$image" "$signature" | md5sum | cut -d ' ' -f 1)
|
|
mkdir "$cache_dir/$hash.jpg.lock"
|
|
touch -m -d '10 minutes ago' "$cache_dir/$hash.jpg.lock"
|
|
stale_tmp="$cache_dir/$hash.jpg.4242.jpg"
|
|
live_lock="$cache_dir/$hash.jpg.lock"
|
|
done
|
|
printf 'partial' >"$stale_tmp"
|
|
|
|
cache_key=$(printf '%s' "$images" | md5sum | cut -d ' ' -f 1)
|
|
printf '%s\t%s' "$images/one.png" "$cache_dir/missing.jpg" >"$cache_dir/$cache_key.rows"
|
|
printf 'v2\n%s:%s\n' "$images" "$(stat -Lc '%Y' "$images")" >"$cache_dir/$cache_key.signature"
|
|
printf 'v1\n%s:%s\n' "$images" "$(stat -Lc '%Y' "$images")" >"$cache_dir/$cache_key.fast-signature"
|
|
|
|
PATH="$stub_bin:$PATH" XDG_CACHE_HOME="$cache_home" \
|
|
"$ROOT/bin/omarchy-menu-images" --cache-only "$images"
|
|
|
|
(( $(find "$cache_dir" -maxdepth 1 -name '*.jpg' -type f | wc -l) == 3 )) ||
|
|
fail "image menu recovers thumbnails from stranded locks"
|
|
(( $(awk 'END { print NR }' "$cache_dir/$cache_key.rows") == 3 )) ||
|
|
fail "image menu rebuilds every row after cache invalidation"
|
|
[[ $(head -n 1 "$cache_dir/$cache_key.signature") == "v4" ]] ||
|
|
fail "image menu invalidates stale row caches"
|
|
[[ ! -e $stale_tmp ]] ||
|
|
fail "image menu clears partial thumbnails left by killed generators"
|
|
pass "image menu recovers stranded locks and stale rows"
|
|
|
|
rm -rf "$cache_home"
|
|
mkdir -p "$cache_dir"
|
|
mkdir "$live_lock"
|
|
|
|
PATH="$stub_bin:$PATH" XDG_CACHE_HOME="$cache_home" \
|
|
"$ROOT/bin/omarchy-menu-images" --cache-only "$images"
|
|
|
|
(( $(find "$cache_dir" -maxdepth 1 -name '*.jpg' -type f | wc -l) == 2 )) ||
|
|
fail "image menu skips a thumbnail whose fresh legacy lock may still be owned"
|
|
[[ -d $live_lock ]] ||
|
|
fail "image menu leaves a fresh legacy lock directory alone"
|
|
[[ ! -e $cache_dir/$cache_key.rows ]] ||
|
|
fail "image menu does not cache rows while a legacy generator holds a lock"
|
|
pass "image menu respects a live legacy generator's lock"
|
|
|
|
rm -rf "$cache_home"
|
|
mkdir -p "$cache_home"
|
|
printf '%s\n' "$images/two.png" >"$tmp/failures"
|
|
|
|
PATH="$stub_bin:$PATH" XDG_CACHE_HOME="$cache_home" VIPSTHUMBNAIL_FAIL_FILE="$tmp/failures" \
|
|
"$ROOT/bin/omarchy-menu-images" --cache-only "$images"
|
|
|
|
cache_dir="$cache_home/omarchy/image-selector"
|
|
[[ ! -e $cache_dir/$cache_key.rows ]] || fail "image menu does not cache incomplete rows"
|
|
[[ ! -e $cache_dir/$cache_key.signature ]] || fail "image menu does not sign incomplete rows"
|
|
[[ ! -e $cache_dir/$cache_key.fast-signature ]] || fail "image menu does not fast-cache incomplete rows"
|
|
pass "image menu leaves failed thumbnail batches uncached"
|
|
|
|
rm "$tmp/failures"
|
|
PATH="$stub_bin:$PATH" XDG_CACHE_HOME="$cache_home" \
|
|
"$ROOT/bin/omarchy-menu-images" --cache-only "$images"
|
|
|
|
(( $(find "$cache_dir" -maxdepth 1 -name '*.jpg' -type f | wc -l) == 3 )) ||
|
|
fail "image menu retries a previously failed thumbnail"
|
|
(( $(awk 'END { print NR }' "$cache_dir/$cache_key.rows") == 3 )) ||
|
|
fail "image menu caches every row after retry"
|
|
pass "image menu completes and caches a later retry"
|
|
|
|
rm -rf "$cache_home"
|
|
mkdir -p "$cache_home"
|
|
: >"$tmp/calls"
|
|
|
|
# The delay keeps both runs inside the generation window so the locks are
|
|
# actually contended rather than the second run arriving after the first.
|
|
pids=()
|
|
for run in 1 2; do
|
|
PATH="$stub_bin:$PATH" XDG_CACHE_HOME="$cache_home" \
|
|
VIPSTHUMBNAIL_CALLS_FILE="$tmp/calls" VIPSTHUMBNAIL_DELAY=0.25 \
|
|
"$ROOT/bin/omarchy-menu-images" --cache-only "$images" &
|
|
pids+=($!)
|
|
done
|
|
for pid in "${pids[@]}"; do
|
|
wait "$pid" || fail "concurrent image menu runs exit cleanly"
|
|
done
|
|
|
|
(( $(wc -l <"$tmp/calls") == 3 )) || fail "image menu serializes concurrent thumbnail generators"
|
|
|
|
rm -f "$cache_dir"/*.jpg
|
|
rm -f "$cache_dir/$cache_key.rows" "$cache_dir/$cache_key.signature" "$cache_dir/$cache_key.fast-signature"
|
|
PATH="$stub_bin:$PATH" XDG_CACHE_HOME="$cache_home" VIPSTHUMBNAIL_CALLS_FILE="$tmp/calls" \
|
|
"$ROOT/bin/omarchy-menu-images" --cache-only "$images"
|
|
|
|
(( $(wc -l <"$tmp/calls") == 6 )) || fail "image menu releases thumbnail locks after generation"
|
|
pass "image menu owns locks for exactly one generator lifetime"
|
|
|
|
rm -rf "$cache_home"
|
|
mkdir -p "$cache_home"
|
|
rows=$(PATH="$stub_bin:$PATH" XDG_CACHE_HOME="$cache_home" \
|
|
"$ROOT/bin/omarchy-menu-images" --print-rows "$images")
|
|
|
|
(( $(wc -l <<<"$rows") == 3 )) || fail "image menu prints one row per image"
|
|
while IFS=$'\t' read -r row_image row_thumbnail; do
|
|
[[ $row_image == "$images"/* && -f $row_thumbnail ]] ||
|
|
fail "image menu prints each image with its generated thumbnail"
|
|
done <<<"$rows"
|
|
pass "image menu prints its rows for the shell to hold"
|
|
|
|
# Block converters behind a gate: printing lazy rows must neither await them
|
|
# nor start one process per image. Repeated refreshes share one worker pool.
|
|
lazy_images="$tmp/lazy-images"
|
|
lazy_state="$tmp/lazy-state"
|
|
mkdir -p "$lazy_images" "$lazy_state"
|
|
for (( i = 0; i < 40; i++ )); do
|
|
printf 'image' >"$lazy_images/$i.png"
|
|
done
|
|
printf '0\n' >"$lazy_state/active"
|
|
printf '0\n' >"$lazy_state/peak"
|
|
cat >"$stub_bin/nproc" <<'EOF'
|
|
#!/bin/bash
|
|
echo "${FAKE_CORES:-2}"
|
|
EOF
|
|
cat >"$stub_bin/vipsthumbnail" <<'EOF'
|
|
#!/bin/bash
|
|
while (( $# > 0 )); do
|
|
if [[ $1 == "--path" ]]; then output=${2%%\[*}; break; fi
|
|
shift
|
|
done
|
|
exec 9>"$LAZY_STATE/lock"
|
|
priority=$(ps -o ni= -p "$$")
|
|
(( priority >= 10 )) || : >"$LAZY_STATE/priority-failed"
|
|
[[ $(ionice -p "$$") == "idle" ]] || : >"$LAZY_STATE/priority-failed"
|
|
flock 9
|
|
active=$(<"$LAZY_STATE/active")
|
|
active=$((active + 1))
|
|
printf '%s\n' "$active" >"$LAZY_STATE/active"
|
|
(( active <= $(<"$LAZY_STATE/peak") )) || printf '%s\n' "$active" >"$LAZY_STATE/peak"
|
|
flock -u 9
|
|
while [[ ! -f $LAZY_STATE/gate ]]; do sleep 0.02; done
|
|
printf 'thumbnail' >"$output"
|
|
flock 9
|
|
active=$(<"$LAZY_STATE/active")
|
|
printf '%s\n' "$((active - 1))" >"$LAZY_STATE/active"
|
|
echo done >>"$LAZY_STATE/completed"
|
|
EOF
|
|
chmod +x "$stub_bin/nproc" "$stub_bin/vipsthumbnail"
|
|
|
|
lazy_rows() {
|
|
setsid env PATH="$stub_bin:$PATH" XDG_CACHE_HOME="$tmp/lazy-cache-$cores" LAZY_STATE="$lazy_state" FAKE_CORES="$cores" \
|
|
timeout --foreground 10 "$ROOT/bin/omarchy-menu-images" --lazy-thumbnails --print-rows "$lazy_images" >"$lazy_state/rows" &
|
|
local group=$!
|
|
lazy_groups+=("$group")
|
|
wait "$group" || fail "lazy image menu returns rows without waiting for its pool"
|
|
rows=$(<"$lazy_state/rows")
|
|
}
|
|
|
|
for cores in 1 2 8; do
|
|
rm -f "$lazy_images/new.png"
|
|
printf 'image' >"$lazy_images/0.png"
|
|
lazy_state="$tmp/lazy-state-$cores"
|
|
mkdir -p "$lazy_state"
|
|
printf '0\n' >"$lazy_state/active"
|
|
printf '0\n' >"$lazy_state/peak"
|
|
expected_workers=1
|
|
(( cores < 4 )) || expected_workers=2
|
|
for run in 1 2; do
|
|
lazy_rows
|
|
(( $(wc -l <<<"$rows") == 40 )) || fail "lazy image menu returns all rows before conversion"
|
|
done
|
|
for attempt in {1..100}; do
|
|
(( $(<"$lazy_state/active") == expected_workers )) && break
|
|
sleep 0.02
|
|
done
|
|
(( $(<"$lazy_state/active") == expected_workers && $(<"$lazy_state/peak") == expected_workers )) ||
|
|
fail "lazy image menu bounds repeated refreshes to $expected_workers workers on $cores cores"
|
|
[[ ! -e $lazy_state/completed ]] || fail "lazy image menu does not wait for conversion"
|
|
[[ ! -e $lazy_state/priority-failed ]] || fail "lazy image menu reserves CPU and I/O priority for the UI"
|
|
pass "lazy image menu opens with at most $expected_workers workers on $cores cores"
|
|
|
|
# This job did not exist in the pool's first batch. A contended refresh must
|
|
# retain it, along with a replacement for an image changed during conversion.
|
|
printf 'new-image' >"$lazy_images/new.png"
|
|
printf 'changed-image-with-new-size' >"$lazy_images/0.png"
|
|
lazy_rows
|
|
(( $(wc -l <<<"$rows") == 41 )) || fail "contended refresh returns the added image"
|
|
for changed in new 0; do
|
|
signature=$(stat -Lc '%s:%Y' "$lazy_images/$changed.png")
|
|
hash=$(printf '%s\t%s' "$lazy_images/$changed.png" "$signature" | md5sum | cut -d ' ' -f 1)
|
|
[[ ! -e $tmp/lazy-cache-$cores/omarchy/image-selector/$hash.jpg ]] ||
|
|
fail "contended refresh does not start another converter pool"
|
|
done
|
|
|
|
touch "$lazy_state/gate"
|
|
for attempt in {1..500}; do
|
|
if [[ -f $lazy_state/completed ]] && (( $(wc -l <"$lazy_state/completed") == 42 )); then break; fi
|
|
sleep 0.02
|
|
done
|
|
(( $(wc -l <"$lazy_state/completed") == 42 && $(<"$lazy_state/peak") == expected_workers )) ||
|
|
fail "lazy image menu completes the queue after its parent and queue path are gone"
|
|
for changed in new 0; do
|
|
signature=$(stat -Lc '%s:%Y' "$lazy_images/$changed.png")
|
|
hash=$(printf '%s\t%s' "$lazy_images/$changed.png" "$signature" | md5sum | cut -d ' ' -f 1)
|
|
[[ -f $tmp/lazy-cache-$cores/omarchy/image-selector/$hash.jpg ]] ||
|
|
fail "lazy image menu retains new and changed jobs while its pool is busy"
|
|
done
|
|
pass "lazy image menu workers finish every queued thumbnail after the caller exits"
|
|
done
|
|
|
|
# Exercise the same cleanup handler on both successful and failing exits,
|
|
# with a detached, gated fixture still running when the EXIT trap fires.
|
|
run_node_test <<'JS'
|
|
const fs = require('fs')
|
|
const { spawnSync } = require('child_process')
|
|
const script = fs.readFileSync(path.join(root, 'test/shell.d/menu-images-test.sh'), 'utf8')
|
|
const cleanupHandler = script.match(/cleanup\(\) \{[\s\S]*?\n\}/)[0]
|
|
for (const status of [0, 31]) {
|
|
const result = spawnSync('bash', ['-c', `
|
|
set -euo pipefail
|
|
tmp=$(mktemp -d)
|
|
lazy_groups=()
|
|
${cleanupHandler}
|
|
trap cleanup EXIT
|
|
mkdir -p "$tmp/lazy-state-probe"
|
|
setsid bash -c ': >"$1/ready"; while [[ ! -e $1/gate ]]; do sleep 0.02; done; sleep 0.05' _ "$tmp/lazy-state-probe" &
|
|
lazy_groups+=("$!")
|
|
while [[ ! -e $tmp/lazy-state-probe/ready ]]; do sleep 0.02; done
|
|
printf '%s\\n%s\\n' "$tmp" "$!"
|
|
exit ${status}
|
|
`], { encoding: 'utf8', timeout: 15000 })
|
|
const [directory, group] = result.stdout.trim().split('\n')
|
|
const removed = !fs.existsSync(directory)
|
|
let alive = false
|
|
try {
|
|
try { process.kill(-Number(group), 0); alive = true } catch (error) {
|
|
if (error.code !== 'ESRCH') throw error
|
|
}
|
|
} finally {
|
|
if (group) { try { process.kill(-Number(group), 'SIGKILL') } catch (_) {} }
|
|
if (directory) fs.rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
assertEqual(result.status, status, `fixture cleanup preserves exit status ${status}`)
|
|
assert(removed, `fixture cleanup removes its directory on exit ${status}`)
|
|
assert(!alive, `fixture cleanup finishes its detached workers on exit ${status}`)
|
|
}
|
|
JS
|