fix: harden demo lifecycle and scanner

This commit is contained in:
golem
2026-08-21 04:31:37 -06:00
parent 2446f0b2ef
commit a710106932
8 changed files with 509 additions and 128 deletions
+153 -20
View File
@@ -5,19 +5,32 @@ ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
PROCESS_LIB="$ROOT/tools/process-lib.sh"
RESET_SCRIPT="$ROOT/tools/reset-local.sh"
TEST_ROOT=$(mktemp -d /tmp/uups-bank-process-test.XXXXXX)
declare -a TEST_PIDS=()
PATH="$TEST_ROOT/bin:$PATH"
export DEMO_TERM_WAIT_ATTEMPTS=5
export DEMO_KILL_WAIT_ATTEMPTS=10
declare -a TEST_IDENTITIES=()
PASSED=0
STARTED_PID=
cleanup() {
local pid
for pid in "${TEST_PIDS[@]}"; do
if [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then
kill -TERM -- "-$pid" 2>/dev/null || kill -TERM -- "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fi
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 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 \
.demo/sentinel .demo/adjacent.keep tools/process-lib.sh tools/reset-local.sh \
deployments/pending.json deployments/anvil.json deployments/active.json deployments/base-sepolia.json \
web/public/deployment.json web/src/generated/contracts.ts web/node_modules/.bin/vite; do
rm -f -- "$TEST_ROOT/$root/$relative"
done
rmdir -- "$TEST_ROOT/$root/web/node_modules/.bin" "$TEST_ROOT/$root/web/node_modules" \
"$TEST_ROOT/$root/web/src/generated" "$TEST_ROOT/$root/web/src" "$TEST_ROOT/$root/web/public" \
"$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 -rf -- "$TEST_ROOT"
rm -f -- "$TEST_ROOT/bin/anvil" "$TEST_ROOT/bin/vite" "$TEST_ROOT/vite-child.pid"
rmdir -- "$TEST_ROOT/bin" "$TEST_ROOT" 2>/dev/null || true
}
trap cleanup EXIT
@@ -25,8 +38,65 @@ fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
pass() { PASSED=$((PASSED + 1)); printf 'ok %d - %s\n' "$PASSED" "$1"; }
assert_exists() { [[ -e "$1" ]] || fail "expected $1 to exist"; }
assert_absent() { [[ ! -e "$1" ]] || fail "expected $1 to be absent"; }
assert_dead() { ! kill -0 "$1" 2>/dev/null || fail "expected PID $1 to be stopped"; }
assert_alive() { kill -0 "$1" 2>/dev/null || fail "expected PID $1 to remain alive"; }
assert_dead() {
local state start pgid sid
! test_process_identity "$1" state start pgid sid || [[ "$state" == Z ]] || fail "expected PID $1 to be stopped"
}
assert_alive() {
local state start pgid sid
test_process_identity "$1" state start pgid sid && [[ "$state" != Z ]] || fail "expected PID $1 to remain alive"
}
test_process_identity() {
local pid=$1 state_name=$2 start_name=$3 pgid_name=$4 sid_name=$5 stat rest
local -a fields
{ IFS= read -r stat <"/proc/$pid/stat"; } 2>/dev/null || return 1
rest=${stat##*) }
read -r -a fields <<<"$rest"
printf -v "$state_name" '%s' "${fields[0]}"
printf -v "$pgid_name" '%s' "${fields[2]}"
printf -v "$sid_name" '%s' "${fields[3]}"
printf -v "$start_name" '%s' "${fields[19]}"
}
track_process() {
local pid=$1 state start pgid sid
for _ in {1..50}; do
if test_process_identity "$pid" state start pgid sid && [[ "$pid" == "$pgid" && "$pid" == "$sid" ]]; then
TEST_IDENTITIES+=("$pid:$start:$pgid:$sid")
return 0
fi
sleep 0.02
done
fail "could not capture test process identity for PID $pid"
}
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
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
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
kill -"$signal" -- "$member" 2>/dev/null || true
done < <(ps -eo pid=,pgid=,sid=,stat=)
for _ in {1..20}; do
line=$(ps -eo pgid=,sid=,stat= | awk -v p="$expected_pgid" -v s="$expected_sid" '$1 == p && $2 == s && $3 !~ /^Z/ { print; exit }')
[[ -z "$line" ]] && break
sleep 0.02
done
done
wait "$leader" 2>/dev/null || true
}
# RED gate: these are the missing production interfaces this suite specifies.
[[ -f "$PROCESS_LIB" ]] || fail "missing process library: $PROCESS_LIB"
@@ -73,12 +143,16 @@ HELPER
setsid "$helper" web --host 127.0.0.1 --port 5173 >/dev/null 2>&1 &
fi
local pid=$!
TEST_PIDS+=("$pid")
write_record "$root" "$kind" "$pid" "$(start_tick "$pid")" "$pid"
track_process "$pid"
for _ in {1..50}; do
if demo_record_process "$root" "$kind" "$pid"; then break; fi
sleep 0.02
done
assert_exists "$root/.demo/$kind.pid"
STARTED_PID=$pid
}
printf '1..11\n'
printf '1..16\n'
# Catches cleanup treating a missing record as an error or signaling an inferred PID.
root=$(make_root absent)
@@ -104,12 +178,33 @@ pass 'a nonnumeric PID is rejected'
# Catches PID collision signaling an unrelated live process with the wrong command.
root=$(make_root wrong-command)
setsid /bin/sleep 120 & wrong_pid=$!
TEST_PIDS+=("$wrong_pid")
track_process "$wrong_pid"
write_record "$root" anvil "$wrong_pid" "$(start_tick "$wrong_pid")" "$wrong_pid"
if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'wrong command signature was accepted'; fi
assert_alive "$wrong_pid"
pass 'a live PID with the wrong command signature is never signaled'
# Catches accepting anvil merely because it appears as a non-executable argument.
root=$(make_root false-anvil)
setsid /bin/bash -c 'sleep 120 & wait' anvil --host 127.0.0.1 --port 8545 --chain-id 31337 & false_anvil_pid=$!
track_process "$false_anvil_pid"
write_record "$root" anvil "$false_anvil_pid" "$(start_tick "$false_anvil_pid")" "$false_anvil_pid"
if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'argument-only anvil signature was accepted'; fi
assert_alive "$false_anvil_pid"
pass 'Anvil identity requires its actual executable and argv zero'
# Catches accepting a project-local Vite-looking argument under an unrelated executable.
root=$(make_root false-vite)
mkdir -p "$root/web/node_modules/.bin"
printf '#!/usr/bin/env bash\n' >"$root/web/node_modules/.bin/vite"
chmod +x "$root/web/node_modules/.bin/vite"
setsid /bin/bash -c 'sleep 120 & wait' "$root/web/node_modules/.bin/vite" web --host 127.0.0.1 --port 5173 & false_vite_pid=$!
track_process "$false_vite_pid"
write_record "$root" vite "$false_vite_pid" "$(start_tick "$false_vite_pid")" "$false_vite_pid"
if demo_stop_recorded "$root" vite 2>/dev/null; then fail 'argument-only Vite signature was accepted'; fi
assert_alive "$false_vite_pid"
pass 'Vite identity requires the exact project-local entry and interpreter'
# Catches recycled PID ownership being inferred from PID and command alone.
root=$(make_root reused)
start_owned "$root" anvil
@@ -119,6 +214,13 @@ if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'mismatched start tic
assert_alive "$reused_pid"
pass 'PID reuse is rejected by recorded process start tick'
# Catches publishing the PID before the complete identity has an atomic commit marker.
root=$(make_root atomic)
start_owned "$root" anvil
[[ $(stat -c '%i' "$root/.demo/anvil.pid") == $(stat -c '%i' "$root/.demo/anvil.pgid") ]] || fail 'PID is not an atomic hard-link commit marker'
demo_stop_recorded "$root" anvil
pass 'the PID commit marker is atomically linked only after start and group metadata'
# Catches a validated project child not being terminated and reaped.
root=$(make_root matching)
start_owned "$root" anvil
@@ -129,31 +231,62 @@ assert_dead "$matching_pid"
assert_absent "$root/.demo/anvil.pid"
pass 'a matching project-started child is terminated and reaped'
# Catches stopping only the Vite leader and orphaning a child, or broad group signaling.
# Catches treating leader exit as group exit and orphaning a TERM-resistant Vite child.
root=$(make_root group)
group_helper="$TEST_ROOT/bin/vite"
mkdir -p "$root/web/node_modules/.bin"
group_helper="$root/web/node_modules/.bin/vite"
child_file="$TEST_ROOT/vite-child.pid"
cat >"$group_helper" <<'HELPER'
#!/usr/bin/env bash
sleep 120 &
bash -c 'trap "" TERM; exec sleep 120' &
printf '%s\n' "$!" >"$DEMO_CHILD_PID_FILE"
wait
HELPER
chmod +x "$group_helper"
DEMO_CHILD_PID_FILE="$child_file" setsid "$group_helper" web --host 127.0.0.1 --port 5173 & group_pid=$!
TEST_PIDS+=("$group_pid")
track_process "$group_pid"
for _ in {1..50}; do [[ -s "$child_file" ]] && break; sleep 0.02; done
[[ -s "$child_file" ]] || fail 'Vite helper child did not start'
child_pid=$(<"$child_file")
setsid /bin/sleep 120 & unrelated_pid=$!
TEST_PIDS+=("$unrelated_pid")
track_process "$unrelated_pid"
write_record "$root" vite "$group_pid" "$(start_tick "$group_pid")" "$group_pid"
demo_stop_recorded "$root" vite
wait "$group_pid" 2>/dev/null || true
for _ in {1..50}; do ! kill -0 "$child_pid" 2>/dev/null && break; sleep 0.02; done
assert_dead "$child_pid"
assert_alive "$unrelated_pid"
pass 'an owned process group is stopped completely while an unrelated group survives'
pass 'bounded KILL removes a TERM-resistant owned child while an unrelated group survives'
# Catches escalating after the recorded leader changes to a different command identity.
root=$(make_root mutated)
mutating_helper="$TEST_ROOT/bin/anvil"
cat >"$mutating_helper" <<'HELPER'
#!/usr/bin/env bash
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=$!
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
assert_alive "$mutated_pid"
pass 'identity is revalidated and a changed leader is never escalated'
# Catches launch-record publication failure leaving an independently known child alive.
root=$(make_root partial)
start_owned "$root" anvil
partial_pid=$STARTED_PID
partial_start=$(start_tick "$partial_pid")
rm -f -- "$root/.demo/anvil.pid" "$root/.demo/anvil.start" "$root/.demo/anvil.pgid"
printf '%s\n' "$partial_start" >"$root/.demo/anvil.start"
printf '%s\n' "$partial_pid" >"$root/.demo/anvil.pgid"
demo_stop_launch "$root" anvil "$partial_pid" "$partial_start" "$partial_pid"
assert_dead "$partial_pid"
assert_absent "$root/.demo/anvil.start"
assert_absent "$root/.demo/anvil.pgid"
pass 'in-memory launch identity safely cleans a partial unpublished record'
# Catches reset deleting arbitrary neighbors or leaving known reproducible local artifacts.
root=$(make_root local-reset)