AtlatestRepositoryfolio

folio / tree / testgate-f3a.sh

1#!/bin/sh
2# gate-f3a.sh -- the F3a gate: coordinator, cross-process lock, and
3# 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# ARMS
9# 1 harness self-test: the probe's assertion primitive CAN fail
10# 2 the in-process battery (test/f3a-txn-probe.sgl, 19 arms: stand-down
11# 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, same
14# 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 proves
17# the kernel released; a writer killed mid-transaction leaves the note
18# either fully old or fully new, never torn; a stale staging temp does
19# not wedge the next transaction
20# 5 the amendment-4 exclusion probe REFUSES a root whose flock grants two
21# "exclusive" locks (a PATH shim simulates the NFS-style filesystem);
22# the same driver arm passes against the real flock
23# 6 the sabotage driver's arms all green (they are the sabotage script's
24# instruments; green here is their stand-down control)
26# SETUP-FAILED is a distinct outcome from RED and GREEN: where this gate
27# cannot establish its subject it says so and exits 3 without claiming
28# anything about the product.
30set -u
32repo_dir=$(cd "$(dirname "$0")/.." && pwd)
33cd "$repo_dir" || exit 1
35work=$(mktemp -d /tmp/gate-f3a.XXXXXX) || { echo "GATE-F3A SETUP-FAILED: no temp dir"; exit 3; }
36trap 'chmod -R u+rwX "$work" 2>/dev/null; rm -rf "$work"' EXIT INT TERM
38sigil="${SIGIL:-sigil}"
39export SIGIL_REDIRECTS="${SIGIL_F3A_REDIRECTS:-$repo_dir/dev-redirects.sgl}"
40FIXTURES="$repo_dir/test/fixtures/f3a"
42fails=0
43fail () { echo "GATE-F3A FAIL: $*" >&2; fails=$((fails + 1)); }
44setup_failed () { echo "GATE-F3A SETUP-FAILED: $*" >&2; exit 3; }
46arm_summary () {
47 # $1 = arm number; refuses to print OK when failures were recorded.
48 if [ "$fails" -gt "${prev_fails:-0}" ]; then
49 echo "GATE-F3A arm $1: FAILED ($((fails - ${prev_fails:-0})) failure(s))"
50 else
51 echo "GATE-F3A arm $1: OK"
52 fi
53 prev_fails=$fails
55prev_fails=0
57only_arms="${*:-1 2 3 4 5 6}"
58want () { case " $only_arms " in *" $1 "*) return 0;; *) return 1;; esac; }
60rev_of () { printf 'sha256:%s' "$(sha256sum "$1" | cut -d' ' -f1)"; }
62fresh_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"
68# --- build: the gate tests the artifact it just built, never a stale tree ---
69echo "GATE-F3A: building (dev config)..."
70if ! "$sigil" build >"$work/build.log" 2>&1; then
71 setup_failed "sigil build failed; see $work/build.log (kept only until exit -- rerun sigil build to reproduce)"
72fi
73[ -d build/dev/lib ] || setup_failed "build reported success but build/dev/lib is absent"
75run_sgl () { "$sigil" -L build/dev/lib -L src "$@"; }
77# Warm the auto-compile cache SEQUENTIALLY before any arm spawns concurrent
78# sigil processes: after a source change, simultaneous cold starts race each
79# other renaming freshly compiled modules into the shared cache
80# ("Auto-compile error ... rename-file"), which is toolchain noise this gate
81# must not let near a verdict. The self-test import closure covers every
82# module the writers load.
83warm_root="$work/warm-root"
84mkdir -p "$warm_root"; cp "$FIXTURES"/note.sgd "$FIXTURES"/broken.sgd "$warm_root"/ 2>/dev/null
85F3A_PROBE_SELFTEST=1 F3A_ROOT="$warm_root" run_sgl test/f3a-txn-probe.sgl >/dev/null 2>&1 || true
86# ANY process this gate will `kill` is spawned as a DIRECT command, never
87# 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 ITS
89# LOCK -- measured on this gate's first run (arm 4 reported "lock NOT
90# released"; the lock itself was innocent).
92# ---------------------------------------------------------------------------
93# Arm 1: the probe harness can fail
94# ---------------------------------------------------------------------------
95if want 1; then
96 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; then
98 grep -q "F3A_ARM_selftest_RED" "$work/selftest.log" \
99 || fail "arm1: self-test did not record a red for a false assertion"
100 else
101 fail "arm1: self-test run itself errored; see log:"; cat "$work/selftest.log" >&2
102 fi
103 arm_summary 1
104fi
106# ---------------------------------------------------------------------------
107# Arm 2: the in-process battery
108# ---------------------------------------------------------------------------
109if want 2; then
110 root2="$work/probe-root"; fresh_root "$root2"
111 if F3A_ROOT="$root2" run_sgl test/f3a-txn-probe.sgl >"$work/probe.log" 2>&1; then
112 :
113 else
114 fail "arm2: probe battery exited non-zero"
115 fi
116 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 2
120fi
122# ---------------------------------------------------------------------------
123# Arm 3: real two-process contention, 5 rounds
124# ---------------------------------------------------------------------------
125if want 3; then
126 rounds=5
127 round=1
128 while [ "$round" -le "$rounds" ]; do
129 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=0
144 while ! { grep -q WRITER_READY "$work/wa-$round.log" 2>/dev/null \
145 && grep -q WRITER_READY "$work/wb-$round.log" 2>/dev/null; }; do
146 tries=$((tries + 1))
147 [ "$tries" -gt 600 ] && break
148 sleep 0.1
149 done
150 : > "$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 ]; then
159 echo "--- arm3 round $round writer A tail ---" >&2; tail -5 "$work/wa-$round.log" >&2
160 echo "--- arm3 round $round writer B tail ---" >&2; tail -5 "$work/wb-$round.log" >&2
161 fi
162 # 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 done
176 arm_summary 3
177fi
179# ---------------------------------------------------------------------------
180# Arm 4: crash release + never-torn + staging-temp resilience
181# ---------------------------------------------------------------------------
182if want 4; then
183 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=0
190 while ! grep -q HOLDER_HOLDING "$work/holder.log" 2>/dev/null; do
191 tries=$((tries + 1)); [ "$tries" -gt 300 ] && break; sleep 0.1
192 done
193 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; then
196 fail "arm4: lock reads as free while the holder holds it"
197 fi
198 pre_hash=$(sha256sum "$note4" | cut -d' ' -f1)
199 kill -9 "$holder" 2>/dev/null
200 wait "$holder" 2>/dev/null
201 # The helper child dies with its parent (pipe EOF); poll for release.
202 released=no
203 tries=0
204 while [ "$tries" -lt 100 ]; do
205 if flock -n "$root4/.folio-coordination" -c true 2>/dev/null; then released=yes; break; fi
206 tries=$((tries + 1)); sleep 0.1
207 done
208 [ "$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 be
213 # fully-old or fully-new each time, and must still parse (a follow-up
214 # transaction is the parse witness).
215 for delay in 0.05 0.15 0.30; do
216 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=0
226 while ! grep -q WRITER_READY "$work/wk-$delay.log" 2>/dev/null; do
227 tries=$((tries + 1)); [ "$tries" -gt 300 ] && break; sleep 0.1
228 done
229 : > "$startk"
230 sleep "$delay"
231 kill -9 "$wk" 2>/dev/null
232 wait "$wk" 2>/dev/null
233 now_hash=$(sha256sum "$notek" | cut -d' ' -f1)
234 if [ "$now_hash" != "$old_hash" ]; then
235 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 fi
238 # 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; then
242 fail "arm4b delay=$delay: a transaction after the kill failed; see after-kill log"
243 fi
244 done
246 # 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>&1
254 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 4
259fi
261# ---------------------------------------------------------------------------
262# Arm 5: the exclusion probe refuses a lock that cannot exclude
263# ---------------------------------------------------------------------------
264if want 5; then
265 # 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; then
268 fail "arm5a: coordinator init failed against the real flock"
269 fi
270 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-style
274 # filesystem in one file. The coordinator must REFUSE the root.
275 shim="$work/shim"; mkdir -p "$shim"
276 cat > "$shim/flock" <<'SHIM'
277#!/bin/sh
278# 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>
281while [ "$#" -gt 0 ]; do
282 case "$1" in
283 -n) shift;;
284 -w) shift 2;;
285 -c) shift; exec sh -c "$1";;
286 *) shift;;
287 esac
288done
289exit 0
290SHIM
291 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; then
294 fail "arm5b: the coordinator ACCEPTED a root whose flock excludes nothing"
295 fi
296 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 5
301fi
303# ---------------------------------------------------------------------------
304# Arm 6: the sabotage instruments are green when nothing is planted
305# ---------------------------------------------------------------------------
306if want 6; then
307 if ! F3A_FIXTURES="$FIXTURES" run_sgl test/f3a-sab-driver.sgl >"$work/sabdrv.log" 2>&1; then
308 fail "arm6: sabotage driver reported reds on an unplanted tree"
309 grep "_RED" "$work/sabdrv.log" >&2 || true
310 fi
311 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 6
314fi
316# ---------------------------------------------------------------------------
317echo "---"
318if [ "$fails" -eq 0 ]; then
319 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 0
321else
322 echo "F3A_GATE_RED failures=$fails arms_run=[$only_arms]"
323 exit 1
324fi