637 lines
31 KiB
Bash
Executable File
637 lines
31 KiB
Bash
Executable File
#!/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
|
|
export DEMO_ANCHOR_RECHECK_ATTEMPTS=10
|
|
export DEMO_ANCHOR_RECHECK_INTERVAL=0.01
|
|
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=
|
|
|
|
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 exact-anvil raw-capture reused atomic matching group term-refusal anchor-race mutated partial local-reset local-noop base-canonical base-active base-upgrade uncertain-upgrade 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/upgrade-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/bin/leaderless" \
|
|
"$TEST_ROOT/vite-child.pid" "$TEST_ROOT/term-refusal-child.pid" "$TEST_ROOT/anchor-race-child.pid" \
|
|
"$TEST_ROOT/leaderless-child.pid" "$TEST_ROOT/vanished-signal.err" "$TEST_ROOT/unsignaled-signal.err" \
|
|
"$TEST_ROOT/term-refusal.err"
|
|
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" || "$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
|
|
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
|
|
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" "${ANVIL_EXACT_ARGS[@]}" >/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
|
|
}
|
|
|
|
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..29\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 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 one transient cmdline/exe read failure as a permanent identity mismatch.
|
|
setsid /bin/sleep 120 & recovered_anchor_pid=$!
|
|
track_process "$recovered_anchor_pid"
|
|
recovered_anchor_state='' recovered_anchor_start='' recovered_anchor_pgid='' recovered_anchor_sid=''
|
|
test_process_identity "$recovered_anchor_pid" recovered_anchor_state recovered_anchor_start \
|
|
recovered_anchor_pgid recovered_anchor_sid
|
|
[[ "$recovered_anchor_state" != Z && "$recovered_anchor_sid" == "$recovered_anchor_pid" ]] \
|
|
|| fail 'recovering anchor fixture was not a live session leader'
|
|
if (
|
|
anchor_command_attempt=0
|
|
demo_command_matches() {
|
|
anchor_command_attempt=$((anchor_command_attempt + 1))
|
|
((anchor_command_attempt > 1)) && return 0
|
|
return 2
|
|
}
|
|
demo_anchor_allows_signal "$ROOT" anvil "$recovered_anchor_pid" "$recovered_anchor_start" \
|
|
"$recovered_anchor_pgid" validated
|
|
); then
|
|
recovered_anchor_result=0
|
|
else
|
|
recovered_anchor_result=$?
|
|
fi
|
|
((recovered_anchor_result == 0)) || fail 'a transient anchor command-read failure was not retried'
|
|
assert_alive "$recovered_anchor_pid"
|
|
pass 'a transient anchor command-read failure recovers through exact revalidation'
|
|
|
|
# Catches failing shutdown when an unverifiable leader vanishes during bounded observation.
|
|
setsid /bin/sleep 120 & vanishing_anchor_pid=$!
|
|
track_process "$vanishing_anchor_pid"
|
|
vanishing_anchor_state='' vanishing_anchor_start='' vanishing_anchor_pgid='' vanishing_anchor_sid=''
|
|
test_process_identity "$vanishing_anchor_pid" vanishing_anchor_state vanishing_anchor_start \
|
|
vanishing_anchor_pgid vanishing_anchor_sid
|
|
[[ "$vanishing_anchor_state" != Z && "$vanishing_anchor_sid" == "$vanishing_anchor_pid" ]] \
|
|
|| fail 'vanishing anchor fixture was not a live session leader'
|
|
(
|
|
sleep 0.03
|
|
builtin kill -TERM -- "$vanishing_anchor_pid" 2>/dev/null || true
|
|
) & vanishing_anchor_killer=$!
|
|
if (
|
|
demo_command_matches() { return 2; }
|
|
demo_anchor_allows_signal "$ROOT" anvil "$vanishing_anchor_pid" "$vanishing_anchor_start" \
|
|
"$vanishing_anchor_pgid" validated
|
|
); then
|
|
vanishing_anchor_result=0
|
|
else
|
|
vanishing_anchor_result=$?
|
|
fi
|
|
wait "$vanishing_anchor_killer"
|
|
wait "$vanishing_anchor_pid" 2>/dev/null || true
|
|
((vanishing_anchor_result == 0)) || fail 'an exiting unverifiable anchor made shutdown fatal'
|
|
pass 'an unverifiable anchor is accepted only after it vanishes or becomes a zombie'
|
|
|
|
# Catches accepting a persistent live anchor merely because cmdline/exe cannot be read.
|
|
setsid /bin/sleep 120 & unreadable_anchor_pid=$!
|
|
track_process "$unreadable_anchor_pid"
|
|
unreadable_anchor_state='' unreadable_anchor_start='' unreadable_anchor_pgid='' unreadable_anchor_sid=''
|
|
test_process_identity "$unreadable_anchor_pid" unreadable_anchor_state unreadable_anchor_start \
|
|
unreadable_anchor_pgid unreadable_anchor_sid
|
|
[[ "$unreadable_anchor_state" != Z && "$unreadable_anchor_sid" == "$unreadable_anchor_pid" ]] \
|
|
|| fail 'unreadable anchor fixture was not a live session leader'
|
|
if (
|
|
demo_process_has_command_line() { return 1; }
|
|
demo_command_matches() { return 2; }
|
|
demo_anchor_allows_signal "$ROOT" anvil "$unreadable_anchor_pid" "$unreadable_anchor_start" \
|
|
"$unreadable_anchor_pgid" validated
|
|
); then
|
|
unreadable_anchor_result=0
|
|
else
|
|
unreadable_anchor_result=$?
|
|
fi
|
|
((unreadable_anchor_result != 0)) || fail 'a stable live anchor with unreadable command identity was accepted'
|
|
assert_alive "$unreadable_anchor_pid"
|
|
pass 'a stable live anchor remains refused when command identity is unverifiable'
|
|
|
|
# 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 \
|
|
2>"$TEST_ROOT/vanished-signal.err"; 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'
|
|
[[ ! -s "$TEST_ROOT/vanished-signal.err" ]] || fail 'a benign vanished-member signal race emitted raw stderr'
|
|
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 \
|
|
2>"$TEST_ROOT/unsignaled-signal.err"; 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'
|
|
[[ $(<"$TEST_ROOT/unsignaled-signal.err") == \
|
|
"Failed to signal unchanged live PID $unsignaled_pid with TERM" ]] \
|
|
|| fail 'an unchanged-live signal failure lacked its controlled diagnostic'
|
|
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
|
|
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 a silent TERM-loop refusal after exact command identity definitively changes.
|
|
root=$(make_root term-refusal)
|
|
term_refusal_helper="$TEST_ROOT/bin/anvil"
|
|
term_refusal_child_file="$TEST_ROOT/term-refusal-child.pid"
|
|
cat >"$term_refusal_helper" <<'HELPER'
|
|
#!/usr/bin/env bash
|
|
sleep 120 &
|
|
printf '%s\n' "$!" >"$DEMO_CHILD_PID_FILE"
|
|
wait
|
|
sleep 120
|
|
HELPER
|
|
chmod +x "$term_refusal_helper"
|
|
DEMO_CHILD_PID_FILE="$term_refusal_child_file" setsid "$term_refusal_helper" "${ANVIL_EXACT_ARGS[@]}" \
|
|
& term_refusal_pid=$!
|
|
track_process "$term_refusal_pid"
|
|
for _ in {1..50}; do [[ -s "$term_refusal_child_file" ]] && break; sleep 0.02; done
|
|
[[ -s "$term_refusal_child_file" ]] || fail 'TERM-refusal helper child did not start'
|
|
term_refusal_start=$(start_tick "$term_refusal_pid")
|
|
if (
|
|
anchor_command_attempt=0
|
|
demo_command_matches() {
|
|
anchor_command_attempt=$((anchor_command_attempt + 1))
|
|
((anchor_command_attempt <= 2)) && return 0
|
|
return 1
|
|
}
|
|
demo_stop_launch "$root" anvil "$term_refusal_pid" "$term_refusal_start" "$term_refusal_pid"
|
|
) 2>"$TEST_ROOT/term-refusal.err"; then
|
|
term_refusal_result=0
|
|
else
|
|
term_refusal_result=$?
|
|
fi
|
|
((term_refusal_result != 0)) || fail 'a definitive TERM-loop anchor mismatch was accepted'
|
|
[[ $(<"$TEST_ROOT/term-refusal.err") == \
|
|
"Refusing TERM: anvil leader identity changed or remained unverifiable" ]] \
|
|
|| fail 'a TERM-loop anchor refusal lacked its controlled diagnostic'
|
|
assert_alive "$term_refusal_pid"
|
|
pass 'a definitive TERM-loop anchor mismatch is refused with a controlled diagnostic'
|
|
|
|
# Catches the child-exit/leader-exit race becoming fatal during transient command-read loss.
|
|
root=$(make_root anchor-race)
|
|
anchor_race_helper="$TEST_ROOT/bin/anvil"
|
|
anchor_race_child_file="$TEST_ROOT/anchor-race-child.pid"
|
|
cat >"$anchor_race_helper" <<'HELPER'
|
|
#!/usr/bin/env bash
|
|
sleep 120 &
|
|
printf '%s\n' "$!" >"$DEMO_CHILD_PID_FILE"
|
|
wait
|
|
sleep 0.05
|
|
HELPER
|
|
chmod +x "$anchor_race_helper"
|
|
DEMO_CHILD_PID_FILE="$anchor_race_child_file" setsid "$anchor_race_helper" "${ANVIL_EXACT_ARGS[@]}" \
|
|
& anchor_race_pid=$!
|
|
track_process "$anchor_race_pid"
|
|
for _ in {1..50}; do [[ -s "$anchor_race_child_file" ]] && break; sleep 0.02; done
|
|
[[ -s "$anchor_race_child_file" ]] || fail 'anchor-race helper child did not start'
|
|
anchor_race_start=$(start_tick "$anchor_race_pid")
|
|
if (
|
|
anchor_command_attempt=0
|
|
demo_command_matches() {
|
|
anchor_command_attempt=$((anchor_command_attempt + 1))
|
|
((anchor_command_attempt <= 2)) && return 0
|
|
return 2
|
|
}
|
|
demo_stop_launch "$root" anvil "$anchor_race_pid" "$anchor_race_start" "$anchor_race_pid"
|
|
); then
|
|
anchor_race_result=0
|
|
else
|
|
anchor_race_result=$?
|
|
fi
|
|
wait "$anchor_race_pid" 2>/dev/null || true
|
|
((anchor_race_result == 0)) || fail 'child exit made the concurrently exiting leader fatal'
|
|
assert_dead "$anchor_race_pid"
|
|
pass 'child-exit cleanup tolerates transient identity loss while the leader exits'
|
|
|
|
# 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" "${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
|
|
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 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
|
|
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 '%s\n' '{"mode":"upgrade","network":"anvil","chainId":31337,"token":"0x1111111111111111111111111111111111111111","proxy":"0x2222222222222222222222222222222222222222","previousImplementation":"0x3333333333333333333333333333333333333333","implementation":"0x4444444444444444444444444444444444444444","owner":"0x5555555555555555555555555555555555555555","deploymentBlock":1,"snapshot":{"proxy":"0x2222222222222222222222222222222222222222","implementation":"0x3333333333333333333333333333333333333333","owner":"0x5555555555555555555555555555555555555555","asset":"0x1111111111111111111111111111111111111111","paused":false,"balances":[{"address":"0x5555555555555555555555555555555555555555","balance":0}],"liabilities":0,"reserves":0,"surplus":0,"deploymentBlock":1,"version":1}}' >"$root/deployments/upgrade-pending.json"
|
|
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/upgrade-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 reset leaving a strictly validated local no-op upgrade marker behind.
|
|
root=$(make_root local-noop)
|
|
printf '%s\n' '{"mode":"noop","chainId":31337,"observedBlock":9,"ownerNonce":3,"proxy":"0x2222222222222222222222222222222222222222","implementation":"0x4444444444444444444444444444444444444444"}' >"$root/deployments/upgrade-pending.json"
|
|
(cd "$root" && bash tools/reset-local.sh >/dev/null)
|
|
assert_absent "$root/deployments/upgrade-pending.json"
|
|
pass 'reset removes a strictly validated local no-op upgrade marker'
|
|
|
|
# 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 reset deleting a valid Base-shaped upgrade marker that may require public recovery.
|
|
root=$(make_root base-upgrade)
|
|
printf '%s\n' '{"mode":"upgrade","network":"baseSepolia","chainId":84532,"token":"0x1111111111111111111111111111111111111111","proxy":"0x2222222222222222222222222222222222222222","previousImplementation":"0x3333333333333333333333333333333333333333","implementation":"0x4444444444444444444444444444444444444444","owner":"0x5555555555555555555555555555555555555555","deploymentBlock":1,"snapshot":{"proxy":"0x2222222222222222222222222222222222222222","implementation":"0x3333333333333333333333333333333333333333","owner":"0x5555555555555555555555555555555555555555","asset":"0x1111111111111111111111111111111111111111","paused":false,"balances":[{"address":"0x5555555555555555555555555555555555555555","balance":0}],"liabilities":0,"reserves":0,"surplus":0,"deploymentBlock":1,"version":1}}' >"$root/deployments/upgrade-pending.json"
|
|
base_upgrade_before=$(sha256sum "$root/deployments/upgrade-pending.json")
|
|
(cd "$root" && bash tools/reset-local.sh >/dev/null)
|
|
[[ "$base_upgrade_before" == "$(sha256sum "$root/deployments/upgrade-pending.json")" ]] || fail 'Base upgrade marker changed'
|
|
pass 'a Base-shaped upgrade marker survives reset byte-for-byte'
|
|
|
|
# Catches reset guessing that malformed or incomplete upgrade staging is safe to delete.
|
|
root=$(make_root uncertain-upgrade)
|
|
printf '%s\n' '{"mode":"upgrade","chainId":31337}' >"$root/deployments/upgrade-pending.json"
|
|
uncertain_before=$(sha256sum "$root/deployments/upgrade-pending.json")
|
|
(cd "$root" && bash tools/reset-local.sh >/dev/null)
|
|
[[ "$uncertain_before" == "$(sha256sum "$root/deployments/upgrade-pending.json")" ]] || fail 'uncertain upgrade marker changed'
|
|
pass 'a malformed or uncertain upgrade marker survives 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"
|