AtlatestRepositoryfolio
1
#!/bin/sh2
# gate-f3a.sh -- the F3a gate: coordinator, cross-process lock, and3
# revision-checked scalar transactions.4
#5
# RUN: sh test/gate-f3a.sh (all arms)6
# SABOTAGE: sh test/sabotage-f3a.sh (proves these arms can go red)7
#8
# ARMS9
# 1 harness self-test: the probe's assertion primitive CAN fail10
# 2 the in-process battery (test/f3a-txn-probe.sgl, 19 arms: stand-down11
# control first, rebase preconditions, refusal vocabulary, reopen,12
# journalled transitions, duplicate text, multi-byte, manifest check)13
# 3 REAL two-process contention: two Folio-aware writer processes, same14
# note, same field, same expected revision -- exactly one OK per round,15
# the loser refuses with current revision + context + diff (5 rounds)16
# 4 crash release: a lock holder is SIGKILLed and a second process proves17
# the kernel released; a writer killed mid-transaction leaves the note18
# either fully old or fully new, never torn; a stale staging temp does19
# not wedge the next transaction20
# 5 the amendment-4 exclusion probe REFUSES a root whose flock grants two21
# "exclusive" locks (a PATH shim simulates the NFS-style filesystem);22
# the same driver arm passes against the real flock23
# 6 the sabotage driver's arms all green (they are the sabotage script's24
# instruments; green here is their stand-down control)25
#26
# SETUP-FAILED is a distinct outcome from RED and GREEN: where this gate27
# cannot establish its subject it says so and exits 3 without claiming28
# anything about the product.30
set -u32
repo_dir=$(cd "$(dirname "$0")/.." && pwd)33
cd "$repo_dir" || exit 135
work=$(mktemp -d /tmp/gate-f3a.XXXXXX) || { echo "GATE-F3A SETUP-FAILED: no temp dir"; exit 3; }36
trap 'chmod -R u+rwX "$work" 2>/dev/null; rm -rf "$work"' EXIT INT TERM38
sigil="${SIGIL:-sigil}"39
export SIGIL_REDIRECTS="${SIGIL_F3A_REDIRECTS:-$repo_dir/dev-redirects.sgl}"40
FIXTURES="$repo_dir/test/fixtures/f3a"42
fails=043
fail () { echo "GATE-F3A FAIL: $*" >&2; fails=$((fails + 1)); }44
setup_failed () { echo "GATE-F3A SETUP-FAILED: $*" >&2; exit 3; }46
arm_summary () {47
# $1 = arm number; refuses to print OK when failures were recorded.48
if [ "$fails" -gt "${prev_fails:-0}" ]; then49
echo "GATE-F3A arm $1: FAILED ($((fails - ${prev_fails:-0})) failure(s))"50
else51
echo "GATE-F3A arm $1: OK"52
fi53
prev_fails=$fails54
}55
prev_fails=057
only_arms="${*:-1 2 3 4 5 6}"58
want () { case " $only_arms " in *" $1 "*) return 0;; *) return 1;; esac; }60
rev_of () { printf 'sha256:%s' "$(sha256sum "$1" | cut -d' ' -f1)"; }62
fresh_root () {63
# $1 = destination dir (created)64
mkdir -p "$1" || setup_failed "cannot create fixture root $1"65
cp "$FIXTURES"/note.sgd "$FIXTURES"/broken.sgd "$1"/ || setup_failed "cannot populate fixture root"66
}68
# --- build: the gate tests the artifact it just built, never a stale tree ---69
echo "GATE-F3A: building (dev config)..."70
if ! "$sigil" build >"$work/build.log" 2>&1; then71
setup_failed "sigil build failed; see $work/build.log (kept only until exit -- rerun sigil build to reproduce)"72
fi73
[ -d build/dev/lib ] || setup_failed "build reported success but build/dev/lib is absent"75
run_sgl () { "$sigil" -L build/dev/lib -L src "$@"; }77
# Warm the auto-compile cache SEQUENTIALLY before any arm spawns concurrent78
# sigil processes: after a source change, simultaneous cold starts race each79
# other renaming freshly compiled modules into the shared cache80
# ("Auto-compile error ... rename-file"), which is toolchain noise this gate81
# must not let near a verdict. The self-test import closure covers every82
# module the writers load.83
warm_root="$work/warm-root"84
mkdir -p "$warm_root"; cp "$FIXTURES"/note.sgd "$FIXTURES"/broken.sgd "$warm_root"/ 2>/dev/null85
F3A_PROBE_SELFTEST=1 F3A_ROOT="$warm_root" run_sgl test/f3a-txn-probe.sgl >/dev/null 2>&1 || true86
# ANY process this gate will `kill` is spawned as a DIRECT command, never87
# through run_sgl: `run_sgl ... &` puts a SUBSHELL pid in $!, and `kill -9 $!`88
# then kills the subshell while sigil survives as an orphan STILL HOLDING ITS89
# LOCK -- measured on this gate's first run (arm 4 reported "lock NOT90
# released"; the lock itself was innocent).92
# ---------------------------------------------------------------------------93
# Arm 1: the probe harness can fail94
# ---------------------------------------------------------------------------95
if want 1; then96
root1="$work/selftest-root"; fresh_root "$root1"97
if F3A_PROBE_SELFTEST=1 F3A_ROOT="$root1" run_sgl test/f3a-txn-probe.sgl >"$work/selftest.log" 2>&1; then98
grep -q "F3A_ARM_selftest_RED" "$work/selftest.log" \99
|| fail "arm1: self-test did not record a red for a false assertion"100
else101
fail "arm1: self-test run itself errored; see log:"; cat "$work/selftest.log" >&2102
fi103
arm_summary 1104
fi106
# ---------------------------------------------------------------------------107
# Arm 2: the in-process battery108
# ---------------------------------------------------------------------------109
if want 2; then110
root2="$work/probe-root"; fresh_root "$root2"111
if F3A_ROOT="$root2" run_sgl test/f3a-txn-probe.sgl >"$work/probe.log" 2>&1; then112
:113
else114
fail "arm2: probe battery exited non-zero"115
fi116
grep -q "F3A_PROBE_DONE arms=22 green=22 red=0" "$work/probe.log" \117
|| { fail "arm2: expected 22/22 green; got: $(grep F3A_PROBE_DONE "$work/probe.log" || echo 'no done marker')"; \118
grep "_RED" "$work/probe.log" >&2 || true; }119
arm_summary 2120
fi122
# ---------------------------------------------------------------------------123
# Arm 3: real two-process contention, 5 rounds124
# ---------------------------------------------------------------------------125
if want 3; then126
rounds=5127
round=1128
while [ "$round" -le "$rounds" ]; do129
root3="$work/contention-$round"; fresh_root "$root3"130
note="$root3/note.sgd"131
start="$work/start-$round"132
rm -f "$start"133
rev=$(rev_of "$note")134
F3A_ROOT="$root3" F3A_NOTE="$note" F3A_EXPECTED_REV="$rev" \135
F3A_TITLE="writer-A-round-$round" F3A_START_FILE="$start" \136
run_sgl test/f3a-writer.sgl >"$work/wa-$round.log" 2>&1 &137
wa=$!138
F3A_ROOT="$root3" F3A_NOTE="$note" F3A_EXPECTED_REV="$rev" \139
F3A_TITLE="writer-B-round-$round" F3A_START_FILE="$start" \140
run_sgl test/f3a-writer.sgl >"$work/wb-$round.log" 2>&1 &141
wb=$!142
# Wait until both writers report READY (coordinator built), then fire.143
tries=0144
while ! { grep -q WRITER_READY "$work/wa-$round.log" 2>/dev/null \145
&& grep -q WRITER_READY "$work/wb-$round.log" 2>/dev/null; }; do146
tries=$((tries + 1))147
[ "$tries" -gt 600 ] && break148
sleep 0.1149
done150
: > "$start"151
wait "$wa"; wait "$wb"152
oks=$(cat "$work/wa-$round.log" "$work/wb-$round.log" | grep -c "WRITER_RESULT OK" || true)153
refusals=$(cat "$work/wa-$round.log" "$work/wb-$round.log" | grep -c "WRITER_RESULT REFUSED" || true)154
errors=$(cat "$work/wa-$round.log" "$work/wb-$round.log" | grep -c "WRITER_RESULT ERROR" || true)155
[ "$oks" -eq 1 ] || fail "arm3 round $round: expected exactly 1 OK, got $oks (refusals=$refusals errors=$errors)"156
[ "$refusals" -eq 1 ] || fail "arm3 round $round: expected exactly 1 refusal, got $refusals"157
[ "$errors" -eq 0 ] || { fail "arm3 round $round: writer error"; grep "WRITER_RESULT ERROR" "$work/wa-$round.log" "$work/wb-$round.log" >&2; }158
if [ "$oks" -ne 1 ] || [ "$refusals" -ne 1 ]; then159
echo "--- arm3 round $round writer A tail ---" >&2; tail -5 "$work/wa-$round.log" >&2160
echo "--- arm3 round $round writer B tail ---" >&2; tail -5 "$work/wb-$round.log" >&2161
fi162
# The loser's refusal must carry the fresh context.163
loser_line=$(cat "$work/wa-$round.log" "$work/wb-$round.log" | grep "WRITER_RESULT REFUSED" | head -1)164
echo "$loser_line" | grep -q "reason=revision-mismatch" \165
|| fail "arm3 round $round: loser refused with the wrong reason: $loser_line"166
echo "$loser_line" | grep -q "has-diff=yes" \167
|| fail "arm3 round $round: loser's refusal carries no diff"168
echo "$loser_line" | grep -q "current-rev=$(rev_of "$note")" \169
|| fail "arm3 round $round: loser's reported current revision is not the published file's"170
# The winner's value is the one on disk.171
winner=$(cat "$work/wa-$round.log" "$work/wb-$round.log" | grep "WRITER_RESULT OK" | sed 's/.*value=\([^ ]*\).*/\1/')172
grep -q "title: \"$winner\"" "$note" \173
|| fail "arm3 round $round: the file does not carry the winner's value ($winner)"174
round=$((round + 1))175
done176
arm_summary 3177
fi179
# ---------------------------------------------------------------------------180
# Arm 4: crash release + never-torn + staging-temp resilience181
# ---------------------------------------------------------------------------182
if want 4; then183
root4="$work/crash-root"; fresh_root "$root4"184
note4="$root4/note.sgd"186
# 4a. SIGKILL the holder; the kernel must release.187
F3A_ROOT="$root4" "$sigil" -L build/dev/lib -L src test/f3a-lock-holder.sgl >"$work/holder.log" 2>&1 &188
holder=$!189
tries=0190
while ! grep -q HOLDER_HOLDING "$work/holder.log" 2>/dev/null; do191
tries=$((tries + 1)); [ "$tries" -gt 300 ] && break; sleep 0.1192
done193
grep -q HOLDER_HOLDING "$work/holder.log" || setup_failed "arm4: holder never reported HOLDING"194
# While held, a second process must see the lock as HELD (flock exits 1).195
if flock -n "$root4/.folio-coordination" -c true 2>/dev/null; then196
fail "arm4: lock reads as free while the holder holds it"197
fi198
pre_hash=$(sha256sum "$note4" | cut -d' ' -f1)199
kill -9 "$holder" 2>/dev/null200
wait "$holder" 2>/dev/null201
# The helper child dies with its parent (pipe EOF); poll for release.202
released=no203
tries=0204
while [ "$tries" -lt 100 ]; do205
if flock -n "$root4/.folio-coordination" -c true 2>/dev/null; then released=yes; break; fi206
tries=$((tries + 1)); sleep 0.1207
done208
[ "$released" = yes ] || fail "arm4: lock NOT released within 10s of SIGKILLing the holder"209
post_hash=$(sha256sum "$note4" | cut -d' ' -f1)210
[ "$pre_hash" = "$post_hash" ] || fail "arm4: holding+killing the lock changed the note's bytes"212
# 4b. Kill a writer mid-transaction at staggered delays; the note must be213
# fully-old or fully-new each time, and must still parse (a follow-up214
# transaction is the parse witness).215
for delay in 0.05 0.15 0.30; do216
rootk="$work/kill-$delay"; fresh_root "$rootk"217
notek="$rootk/note.sgd"218
old_hash=$(sha256sum "$notek" | cut -d' ' -f1)219
startk="$work/kill-start-$delay"; rm -f "$startk"220
rev=$(rev_of "$notek")221
F3A_ROOT="$rootk" F3A_NOTE="$notek" F3A_EXPECTED_REV="$rev" \222
F3A_TITLE="killed-at-$delay" F3A_START_FILE="$startk" \223
"$sigil" -L build/dev/lib -L src test/f3a-writer.sgl >"$work/wk-$delay.log" 2>&1 &224
wk=$!225
tries=0226
while ! grep -q WRITER_READY "$work/wk-$delay.log" 2>/dev/null; do227
tries=$((tries + 1)); [ "$tries" -gt 300 ] && break; sleep 0.1228
done229
: > "$startk"230
sleep "$delay"231
kill -9 "$wk" 2>/dev/null232
wait "$wk" 2>/dev/null233
now_hash=$(sha256sum "$notek" | cut -d' ' -f1)234
if [ "$now_hash" != "$old_hash" ]; then235
grep -q "title: \"killed-at-$delay\"" "$notek" \236
|| fail "arm4b delay=$delay: note bytes changed but carry neither the old nor the new content -- TORN"237
fi238
# The killed writer's lock must release, and the note must still transact.239
root5b="$rootk"240
if ! F3A_ROOT="$root5b" F3A_SAB_ARMS=sab-uncontended F3A_FIXTURES="$FIXTURES" \241
run_sgl test/f3a-sab-driver.sgl >"$work/after-kill-$delay.log" 2>&1; then242
fail "arm4b delay=$delay: a transaction after the kill failed; see after-kill log"243
fi244
done246
# 4c. A stale staging temp from a crashed writer must not wedge publishing.247
rootc="$work/stale-temp"; fresh_root "$rootc"248
printf 'junk left by a crash' > "$rootc/.folio-staging-note.sgd.tmp"249
rev=$(rev_of "$rootc/note.sgd")250
startc="$work/stale-start"; : > "$startc"251
F3A_ROOT="$rootc" F3A_NOTE="$rootc/note.sgd" F3A_EXPECTED_REV="$rev" \252
F3A_TITLE="after-stale-temp" F3A_START_FILE="$startc" \253
run_sgl test/f3a-writer.sgl >"$work/stale.log" 2>&1254
grep -q "WRITER_RESULT OK" "$work/stale.log" \255
|| fail "arm4c: a stale staging temp blocked the next transaction"256
grep -q 'title: "after-stale-temp"' "$rootc/note.sgd" \257
|| fail "arm4c: the transaction after the stale temp did not publish"258
arm_summary 4259
fi261
# ---------------------------------------------------------------------------262
# Arm 5: the exclusion probe refuses a lock that cannot exclude263
# ---------------------------------------------------------------------------264
if want 5; then265
# 5a. Against the REAL flock the coordinator builds (probe passes).266
if ! F3A_FIXTURES="$FIXTURES" F3A_SAB_ARMS=sab-coord-init \267
run_sgl test/f3a-sab-driver.sgl >"$work/coord-real.log" 2>&1; then268
fail "arm5a: coordinator init failed against the real flock"269
fi270
grep -q "SAB_ARM_sab-coord-init_GREEN" "$work/coord-real.log" \271
|| fail "arm5a: no green marker from the real-flock init"273
# 5b. A shim flock that grants every "exclusive" lock -- the NFS-style274
# filesystem in one file. The coordinator must REFUSE the root.275
shim="$work/shim"; mkdir -p "$shim"276
cat > "$shim/flock" <<'SHIM'277
#!/bin/sh278
# A lock-shaped object: grants exclusivity to everyone who asks.279
# Understands the two call shapes the product uses:280
# flock -n <file> -c <cmd> / flock -w <secs> <file> -c <cmd>281
while [ "$#" -gt 0 ]; do282
case "$1" in283
-n) shift;;284
-w) shift 2;;285
-c) shift; exec sh -c "$1";;286
*) shift;;287
esac288
done289
exit 0290
SHIM291
chmod +x "$shim/flock"292
if PATH="$shim:$PATH" F3A_FIXTURES="$FIXTURES" F3A_SAB_ARMS=sab-coord-init \293
run_sgl test/f3a-sab-driver.sgl >"$work/coord-shim.log" 2>&1; then294
fail "arm5b: the coordinator ACCEPTED a root whose flock excludes nothing"295
fi296
grep -q "exclusion probe FAILED" "$work/coord-shim.log" \297
|| fail "arm5b: the refusal does not name the exclusion probe; see $work/coord-shim.log"298
grep -q "provider-service" "$work/coord-shim.log" \299
|| fail "arm5b: the refusal does not route to the provider-service path"300
arm_summary 5301
fi303
# ---------------------------------------------------------------------------304
# Arm 6: the sabotage instruments are green when nothing is planted305
# ---------------------------------------------------------------------------306
if want 6; then307
if ! F3A_FIXTURES="$FIXTURES" run_sgl test/f3a-sab-driver.sgl >"$work/sabdrv.log" 2>&1; then308
fail "arm6: sabotage driver reported reds on an unplanted tree"309
grep "_RED" "$work/sabdrv.log" >&2 || true310
fi311
grep -q "SAB_DRIVER_DONE arms=8 green=8 red=0" "$work/sabdrv.log" \312
|| fail "arm6: expected 8/8 green from the sabotage driver; got: $(grep SAB_DRIVER_DONE "$work/sabdrv.log" || echo none)"313
arm_summary 6314
fi316
# ---------------------------------------------------------------------------317
echo "---"318
if [ "$fails" -eq 0 ]; then319
echo "F3A_GATE_GREEN arms_run=[$only_arms] probe_arms=22 sab_arms=8 contention_rounds=5 sha=$(git -C "$repo_dir" rev-parse --short HEAD 2>/dev/null || echo unknown)"320
exit 0321
else322
echo "F3A_GATE_RED failures=$fails arms_run=[$only_arms]"323
exit 1324
fi