fix: close demo lifecycle races

This commit is contained in:
golem
2026-08-21 04:57:14 -06:00
parent a710106932
commit a28ad76868
5 changed files with 295 additions and 47 deletions
+129 -9
View File
@@ -8,6 +8,8 @@ TEST_ROOT=$(mktemp -d /tmp/uups-bank-process-test.XXXXXX)
PATH="$TEST_ROOT/bin:$PATH"
export DEMO_TERM_WAIT_ATTEMPTS=5
export DEMO_KILL_WAIT_ATTEMPTS=10
ANVIL_DEV_PHRASE='test test test test test test test test test test test junk'
ANVIL_EXACT_ARGS=(--host 127.0.0.1 --port 8545 --chain-id 31337 --mnemonic "$ANVIL_DEV_PHRASE")
declare -a TEST_IDENTITIES=()
PASSED=0
STARTED_PID=
@@ -15,7 +17,7 @@ STARTED_PID=
cleanup() {
local identity root relative
for identity in "${TEST_IDENTITIES[@]}"; do test_safe_stop "$identity"; done
for root in absent stale nonnumeric wrong-command false-anvil false-vite reused atomic matching group mutated partial local-reset base-canonical base-active sentinel; do
for root in absent stale nonnumeric wrong-command false-anvil false-vite exact-anvil raw-capture reused atomic matching group mutated partial local-reset base-canonical base-active sentinel; do
for relative in \
.demo/anvil.pid .demo/anvil.start .demo/anvil.pgid .demo/anvil.log \
.demo/vite.pid .demo/vite.start .demo/vite.pgid .demo/vite.log \
@@ -29,7 +31,8 @@ cleanup() {
"$TEST_ROOT/$root/web" "$TEST_ROOT/$root/deployments" "$TEST_ROOT/$root/tools" "$TEST_ROOT/$root/.demo" \
"$TEST_ROOT/$root" 2>/dev/null || true
done
rm -f -- "$TEST_ROOT/bin/anvil" "$TEST_ROOT/bin/vite" "$TEST_ROOT/vite-child.pid"
rm -f -- "$TEST_ROOT/bin/anvil" "$TEST_ROOT/bin/vite" "$TEST_ROOT/bin/leaderless" \
"$TEST_ROOT/vite-child.pid" "$TEST_ROOT/leaderless-child.pid"
rmdir -- "$TEST_ROOT/bin" "$TEST_ROOT" 2>/dev/null || true
}
trap cleanup EXIT
@@ -75,15 +78,17 @@ test_safe_stop() {
local identity=$1 leader expected_start expected_pgid expected_sid state start pgid sid signal line member member_start
local current_state current_start current_pgid current_sid
IFS=: read -r leader expected_start expected_pgid expected_sid <<<"$identity"
if test_process_identity "$leader" state start pgid sid && [[ "$start" != "$expected_start" ]]; then return 0; fi
if test_process_identity "$leader" state start pgid sid \
&& [[ "$start" != "$expected_start" || "$pgid" != "$expected_pgid" || "$sid" != "$expected_sid" ]]; then return 0; fi
for signal in TERM KILL; do
while read -r member pgid sid state; do
[[ "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" && "$state" != Z ]] || continue
test_process_identity "$member" state member_start pgid sid || continue
[[ "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" ]] || continue
test_process_identity "$leader" current_state current_start current_pgid current_sid || continue
[[ "$current_start" == "$expected_start" && "$current_pgid" == "$expected_pgid" \
&& "$current_sid" == "$expected_sid" ]] || continue
if test_process_identity "$leader" current_state current_start current_pgid current_sid; then
[[ "$current_start" == "$expected_start" && "$current_pgid" == "$expected_pgid" \
&& "$current_sid" == "$expected_sid" ]] || continue
fi
test_process_identity "$member" current_state current_start current_pgid current_sid || continue
[[ "$current_state" != Z && "$current_start" == "$member_start" \
&& "$current_pgid" == "$expected_pgid" && "$current_sid" == "$expected_sid" ]] || continue
@@ -138,7 +143,7 @@ wait
HELPER
chmod +x "$helper"
if [[ "$kind" == anvil ]]; then
setsid "$helper" --host 127.0.0.1 --port 8545 --chain-id 31337 >/dev/null 2>&1 &
setsid "$helper" "${ANVIL_EXACT_ARGS[@]}" >/dev/null 2>&1 &
else
setsid "$helper" web --host 127.0.0.1 --port 5173 >/dev/null 2>&1 &
fi
@@ -152,7 +157,17 @@ HELPER
STARTED_PID=$pid
}
printf '1..16\n'
assert_anvil_args_rejected() {
local root=$1 label=$2 state start pgid sid pid
shift 2
setsid "$TEST_ROOT/bin/anvil" "$@" & pid=$!
track_process "$pid"
test_process_identity "$pid" state start pgid sid
if demo_command_matches "$root" "$pid" anvil; then fail "$label Anvil arguments were accepted"; fi
demo_stop_raw_launch "$root" anvil "$pid" "$start" "$pgid"
}
printf '1..21\n'
# Catches cleanup treating a missing record as an error or signaling an inferred PID.
root=$(make_root absent)
@@ -205,6 +220,81 @@ if demo_stop_recorded "$root" vite 2>/dev/null; then fail 'argument-only Vite si
assert_alive "$false_vite_pid"
pass 'Vite identity requires the exact project-local entry and interpreter'
# Catches accepting extra or overriding Anvil arguments after a valid-looking prefix.
root=$(make_root exact-anvil)
mkdir -p "$TEST_ROOT/bin"
cat >"$TEST_ROOT/bin/anvil" <<'HELPER'
#!/usr/bin/env bash
sleep 120 &
wait
HELPER
chmod +x "$TEST_ROOT/bin/anvil"
assert_anvil_args_rejected "$root" extra "${ANVIL_EXACT_ARGS[@]}" --silent
assert_anvil_args_rejected "$root" duplicate "${ANVIL_EXACT_ARGS[@]}" --port 9999
assert_anvil_args_rejected "$root" reordered \
--port 8545 --host 127.0.0.1 --chain-id 31337 --mnemonic "$ANVIL_DEV_PHRASE"
pass 'Anvil identity requires the exact complete launch argument tail'
# Catches treating an ESRCH-like signal race as fatal after the captured member has exited.
setsid /bin/bash -c 'trap "exit 0" TERM; while :; do sleep 0.01; done' & vanished_pid=$!
track_process "$vanished_pid"
vanished_state='' vanished_start='' vanished_pgid='' vanished_sid=''
test_process_identity "$vanished_pid" vanished_state vanished_start vanished_pgid vanished_sid
[[ "$vanished_state" != Z ]] || fail 'vanishing race fixture exited before injection'
kill() {
builtin kill -TERM -- "$vanished_pid" 2>/dev/null || true
for _ in {1..50}; do
vanished_state_after=''
if ! test_process_identity "$vanished_pid" vanished_state_after vanished_start_after vanished_pgid_after vanished_sid_after \
|| [[ "$vanished_state_after" == Z ]]; then break; fi
sleep 0.01
done
return 1
}
if demo_signal_member "$vanished_pid" "$vanished_start" "$vanished_pgid" "$vanished_sid" TERM; then
vanished_result=0
else
vanished_result=$?
fi
unset -f kill
wait "$vanished_pid" 2>/dev/null || true
((vanished_result == 0)) || fail 'a vanished member made its raced signal fatal'
pass 'a failed signal is idempotent after the captured member vanishes'
# Catches swallowing a real signal failure while the exact captured member remains live.
setsid /bin/sleep 120 & unsignaled_pid=$!
track_process "$unsignaled_pid"
unsignaled_state='' unsignaled_start='' unsignaled_pgid='' unsignaled_sid=''
test_process_identity "$unsignaled_pid" unsignaled_state unsignaled_start unsignaled_pgid unsignaled_sid
[[ "$unsignaled_state" != Z ]] || fail 'unchanged signal-failure fixture exited early'
kill() { return 1; }
if demo_signal_member "$unsignaled_pid" "$unsignaled_start" "$unsignaled_pgid" "$unsignaled_sid" TERM; then
unsignaled_result=0
else
unsignaled_result=$?
fi
unset -f kill
((unsignaled_result != 0)) || fail 'a failed signal to the unchanged live member was accepted'
assert_alive "$unsignaled_pid"
pass 'a failed signal remains fatal for the same live member tuple'
# Catches command validation failure occurring before launch ownership is retained for cleanup.
root=$(make_root raw-capture)
setsid /bin/bash -c 'sleep 120 & wait' unexpected-entry --not-the-demo & raw_pid=$!
track_process "$raw_pid"
raw_start='' raw_pgid=''
declare -F demo_capture_raw_launch >/dev/null || fail 'missing raw launch identity capture'
declare -F demo_stop_raw_launch >/dev/null || fail 'missing command-independent raw launch cleanup'
for _ in {1..50}; do
if demo_capture_raw_launch "$raw_pid" raw_start raw_pgid; then break; fi
sleep 0.02
done
[[ -n ${raw_start:-} && "$raw_pgid" == "$raw_pid" ]] || fail 'raw launch identity was not captured'
if demo_command_matches "$root" "$raw_pid" anvil; then fail 'raw test command unexpectedly passed Anvil validation'; fi
demo_stop_raw_launch "$root" anvil "$raw_pid" "$raw_start" "$raw_pgid"
assert_dead "$raw_pid"
pass 'raw launch ownership safely cleans a command-validation failure'
# Catches recycled PID ownership being inferred from PID and command alone.
root=$(make_root reused)
start_owned "$root" anvil
@@ -267,7 +357,7 @@ trap 'exec -a changed-after-term /bin/sleep 120' TERM
while :; do sleep 0.05; done
HELPER
chmod +x "$mutating_helper"
setsid "$mutating_helper" --host 127.0.0.1 --port 8545 --chain-id 31337 & mutated_pid=$!
setsid "$mutating_helper" "${ANVIL_EXACT_ARGS[@]}" & mutated_pid=$!
track_process "$mutated_pid"
write_record "$root" anvil "$mutated_pid" "$(start_tick "$mutated_pid")" "$mutated_pid"
if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'changed identity was escalated instead of rejected'; fi
@@ -288,6 +378,36 @@ assert_absent "$root/.demo/anvil.start"
assert_absent "$root/.demo/anvil.pgid"
pass 'in-memory launch identity safely cleans a partial unpublished record'
# Catches test cleanup leaking a TERM-resistant session member after its original leader is reaped.
leaderless_helper="$TEST_ROOT/bin/leaderless"
leaderless_child_file="$TEST_ROOT/leaderless-child.pid"
cat >"$leaderless_helper" <<'HELPER'
#!/usr/bin/env bash
bash -c 'trap "" TERM; exec sleep 120' &
printf '%s\n' "$!" >"$DEMO_CHILD_PID_FILE"
sleep 1
HELPER
chmod +x "$leaderless_helper"
DEMO_CHILD_PID_FILE="$leaderless_child_file" setsid "$leaderless_helper" & leaderless_pid=$!
track_process "$leaderless_pid"
leaderless_identity=${TEST_IDENTITIES[${#TEST_IDENTITIES[@]}-1]}
for _ in {1..50}; do [[ -s "$leaderless_child_file" ]] && break; sleep 0.02; done
[[ -s "$leaderless_child_file" ]] || fail 'leaderless cleanup child did not start'
leaderless_child=$(<"$leaderless_child_file")
child_state='' child_start='' child_pgid='' child_sid=''
test_process_identity "$leaderless_child" child_state child_start child_pgid child_sid
[[ "$child_state" != Z ]] || fail 'leaderless cleanup child exited early'
wait "$leaderless_pid"
test_safe_stop "$leaderless_identity"
if test_process_identity "$leaderless_child" current_state current_start current_pgid current_sid \
&& [[ "$current_state" != Z ]]; then
if [[ "$current_start" == "$child_start" && "$current_pgid" == "$child_pgid" && "$current_sid" == "$child_sid" ]]; then
builtin kill -KILL -- "$leaderless_child" 2>/dev/null || true
fi
fail 'test cleanup leaked a session member after the leader was reaped'
fi
pass 'test cleanup safely stops surviving members after leader reaping'
# Catches reset deleting arbitrary neighbors or leaving known reproducible local artifacts.
root=$(make_root local-reset)
for kind in anvil vite; do printf 'log\n' >"$root/.demo/$kind.log"; printf '1\n' >"$root/.demo/$kind.start"; printf '1\n' >"$root/.demo/$kind.pgid"; done