#!/usr/bin/env bash set -euo pipefail 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) 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 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 -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 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() { 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" [[ -x "$RESET_SCRIPT" ]] || fail "missing executable reset script: $RESET_SCRIPT" # shellcheck source=process-lib.sh source "$PROCESS_LIB" make_root() { local root="$TEST_ROOT/$1" mkdir -p "$root/.demo" "$root/tools" "$root/deployments" "$root/web/public" "$root/web/src/generated" cp "$PROCESS_LIB" "$RESET_SCRIPT" "$root/tools/" chmod +x "$root/tools/reset-local.sh" printf '%s\n' "$root" } write_record() { local root=$1 kind=$2 pid=$3 start=$4 pgid=$5 printf '%s\n' "$pid" >"$root/.demo/$kind.pid" printf '%s\n' "$start" >"$root/.demo/$kind.start" printf '%s\n' "$pgid" >"$root/.demo/$kind.pgid" } start_tick() { local stat rest local -a fields stat=$(<"/proc/$1/stat") rest=${stat##*) } read -r -a fields <<<"$rest" printf '%s\n' "${fields[19]}" } start_owned() { local root=$1 kind=$2 helper="$TEST_ROOT/bin/$2" mkdir -p "$TEST_ROOT/bin" cat >"$helper" <<'HELPER' #!/usr/bin/env bash sleep 120 & 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 & else setsid "$helper" web --host 127.0.0.1 --port 5173 >/dev/null 2>&1 & fi local 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..16\n' # Catches cleanup treating a missing record as an error or signaling an inferred PID. root=$(make_root absent) demo_stop_recorded "$root" anvil pass 'an absent PID file is a no-op' # Catches stale PID metadata accumulating or being treated as a live target. root=$(make_root stale) write_record "$root" anvil 999999999 1 999999999 demo_stop_recorded "$root" anvil assert_absent "$root/.demo/anvil.pid" assert_absent "$root/.demo/anvil.start" assert_absent "$root/.demo/anvil.pgid" pass 'a stale numeric PID record is removed' # Catches unvalidated PID text reaching kill or shell option parsing. root=$(make_root nonnumeric) write_record "$root" anvil 'not-a-pid' 1 1 if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'nonnumeric PID was accepted'; fi assert_exists "$root/.demo/anvil.pid" 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=$! 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 reused_pid=$STARTED_PID printf '%s\n' "$(( $(start_tick "$reused_pid") + 1 ))" >"$root/.demo/anvil.start" if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'mismatched start tick was accepted'; fi 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 matching_pid=$STARTED_PID demo_stop_recorded "$root" anvil wait "$matching_pid" 2>/dev/null || true assert_dead "$matching_pid" assert_absent "$root/.demo/anvil.pid" pass 'a matching project-started child is terminated and reaped' # Catches treating leader exit as group exit and orphaning a TERM-resistant Vite child. root=$(make_root group) 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 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=$! 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=$! 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 '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) 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 printf 'sentinel\n' >"$root/.demo/sentinel" for path in deployments/pending.json deployments/anvil.json deployments/active.json web/public/deployment.json; do printf '{"network":"anvil","chainId":31337}\n' >"$root/$path" done printf 'generated ABI\n' >"$root/web/src/generated/contracts.ts" (cd "$root" && bash tools/reset-local.sh >/dev/null) for path 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 deployments/pending.json deployments/anvil.json deployments/active.json web/public/deployment.json web/src/generated/contracts.ts; do assert_absent "$root/$path" done assert_exists "$root/.demo/sentinel" pass 'reset removes only explicitly known local runtime and generated files' # Catches a local reset corrupting a canonical Base Sepolia deployment. root=$(make_root base-canonical) printf '{"network":"baseSepolia","chainId":84532,"marker":"canonical"}\n' >"$root/deployments/base-sepolia.json" before=$(sha256sum "$root/deployments/base-sepolia.json") (cd "$root" && bash tools/reset-local.sh >/dev/null) after=$(sha256sum "$root/deployments/base-sepolia.json") [[ "$before" == "$after" ]] || fail 'Base canonical manifest changed' pass 'a Base canonical manifest survives reset byte-for-byte' # Catches a local reset deleting or rewriting selected and browser-copied Base state. root=$(make_root base-active) base='{"network":"baseSepolia","chainId":84532,"marker":"active"}' printf '%s\n' "$base" >"$root/deployments/active.json" printf '%s\n' "$base" >"$root/web/public/deployment.json" active_before=$(sha256sum "$root/deployments/active.json") browser_before=$(sha256sum "$root/web/public/deployment.json") (cd "$root" && bash tools/reset-local.sh >/dev/null) [[ "$active_before" == "$(sha256sum "$root/deployments/active.json")" ]] || fail 'Base active manifest changed' [[ "$browser_before" == "$(sha256sum "$root/web/public/deployment.json")" ]] || fail 'Base browser manifest changed' pass 'Base active and browser manifests survive reset byte-for-byte' # Catches cleanup broadening from exact files to recursive .demo deletion. root=$(make_root sentinel) printf 'keep me\n' >"$root/.demo/adjacent.keep" (cd "$root" && bash tools/reset-local.sh >/dev/null) assert_exists "$root/.demo/adjacent.keep" pass 'a sentinel adjacent to runtime records survives' printf 'PASS: %d process-safety cases\n' "$PASSED"