Skip to content

Commit f7b1fe2

Browse files
committed
Add the fault-injection form and the first fault/boundary cases to the reload harness.
Gate the build form in both directions so a fault that never took effect cannot pass, forward only FF_FAULT/FF_FAULT_DELAY_MS into the supervised stack, and add rt20/rt20b/rt21/rt22 (bounded aborts, signature-matched) plus rt23 (reload re-entry refused, production form only).
1 parent c4f683b commit f7b1fe2

4 files changed

Lines changed: 248 additions & 8 deletions

File tree

‎tests/integration/common/reload_checks.py‎

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
import sys
1414
import time
1515

16-
CASES = {"precheck", "baseline", "rt01", "rt02", "rv9", "gr0", "rt12", "rt13"}
16+
CASES = {"precheck", "baseline", "rt01", "rt02", "rv9", "gr0", "rt12", "rt13",
17+
"rt20", "rt20b", "rt21", "rt22", "rt23"}
18+
# Named faults are the ones implemented under FF_RELOAD_FAULT_INJECTION
19+
# (lib/ff_reload.c:1130/1224/1315/1657/1660). Empty means the production form.
20+
FAULTS = {"", "ready_never", "ready_delay", "park_never", "flip_fail", "mutex_timeout"}
1721
SAFE_PATH = re.compile(r"/[A-Za-z0-9_./-]+\Z")
1822
KILL_TOOL = "/data/workspace/kill_process.sh"
1923

@@ -30,12 +34,16 @@ def validate(values):
3034
address(values["KERNEL_NIC_IP"])
3135
if not re.fullmatch(r"(?:[A-Za-z_][A-Za-z0-9_.-]*@)?[A-Za-z0-9][A-Za-z0-9_.-]*", values["CLIENT"]):
3236
raise ValueError("invalid client host")
37+
fault = values.get("FAULT", "")
38+
if fault not in FAULTS:
39+
raise ValueError("unknown fault")
3340
names = values["CASES"].split(",")
3441
if len(names) != len(set(names)) or any(n not in CASES for n in names):
3542
raise ValueError("unknown or duplicate case")
3643
bounds = {"ROUNDS": (1, 10000), "INTERVAL": (1, 3600), "POLL": (1, 60),
3744
"WORKERS": (1, 30), "DRAIN_TIMEOUT": (1, 900), "STARTUP_WAIT": (1, 120),
3845
"STREAM_MB": (1, 1024), "RTE_FRESH_MIN": (1, 1440),
46+
"FAULT_DELAY_MS": (1, 59000),
3947
"SHUTDOWN_TIMEOUT": (0, 900)}
4048
for key, (low, high) in bounds.items():
4149
value = values[key]
@@ -199,13 +207,41 @@ def source_record(path):
199207
return {"path": str(path), "kind": "file", "sha256": digest(path)}
200208

201209

202-
def verify_build(path, nginx, expected_head):
210+
def verify_build(path, nginx, expected_head, fault=""):
211+
"""Bind a manifest to the artifacts on disk.
212+
213+
The form must match in BOTH directions: a production run (fault == "") needs
214+
a production manifest, and a fault run needs a fault-injection manifest that
215+
really carries the hooks -- otherwise a fault that never took effect could be
216+
reported as a passing fault case.
217+
"""
203218
with open(path) as f:
204219
data = json.load(f)
205220
if data.get("version") != 1 or data.get("source_head") != expected_head:
206221
raise ValueError("build source identity mismatch")
207-
if data.get("fault_injection") is not False or not data.get("build_commands"):
222+
if fault and fault not in FAULTS:
223+
raise ValueError("unknown fault")
224+
declared = data.get("fault_injection")
225+
if fault == "" and declared is not False:
208226
raise ValueError("production build provenance missing")
227+
if fault == "" and any("FF_RELOAD_FAULT_INJECTION=1" in c for c in data["build_commands"]):
228+
raise ValueError("fault build presented as production")
229+
if fault != "" and declared is not True:
230+
raise ValueError("fault-injection manifest required for a fault run")
231+
if not data.get("build_commands"):
232+
raise ValueError("build provenance missing")
233+
if fault != "":
234+
symbols = data.get("fault_symbols") or []
235+
if not symbols:
236+
raise ValueError("fault symbols missing")
237+
if not any("FF_RELOAD_FAULT_INJECTION=1" in c for c in data["build_commands"]):
238+
raise ValueError("fault build command missing")
239+
archive = Path(data["libfstack"]["path"])
240+
listing = subprocess.run(["nm", "--defined-only", str(archive)],
241+
capture_output=True, text=True, check=True).stdout
242+
for symbol in symbols:
243+
if symbol not in listing:
244+
raise ValueError("fault symbol absent from the archive: " + symbol)
209245
sources = data.get("source_files", [])
210246
if not sources or len({item["path"] for item in sources}) != len(sources):
211247
raise ValueError("source content inventory missing")

‎tests/integration/common/reload_runtime.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ wait_http() {
170170
verify_build_identity() {
171171
[ -n "$BUILD_MANIFEST" ] || return 1
172172
python3 -B "$CHECKS" verify-build "$BUILD_MANIFEST" "$NGINX_BIN" \
173-
"$(git -C "$REPO_ROOT" rev-parse HEAD)" > "$OUT/build-verified.json"
173+
"$(git -C "$REPO_ROOT" rev-parse HEAD)" "$FAULT" > "$OUT/build-verified.json"
174174
}
175175

176176
zc_archive() {

‎tests/integration/common/reload_supervisor.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818

1919
from reload_checks import digest, identity
2020

21+
# Environment variables a run may forward into the supervised stack.
22+
FAULT_ENV_KEYS = {"FF_FAULT", "FF_FAULT_DELAY_MS"}
23+
2124
KILL_TOOL = "/data/workspace/kill_process.sh"
2225
POLL = 0.05
2326
CLEANUP_SECONDS = 10
@@ -469,6 +472,14 @@ def dispatch(self, message, caller):
469472
self.tree.default_stack = stack
470473
env = {k: v for k, v in os.environ.items() if not k.startswith("GR_SUPERVISOR_")}
471474
env["FF_RELOAD_RUN_ID"] = self.run_id
475+
# The supervisor may predate the run that needs these, so its own
476+
# environment is not enough: fault variables are forwarded with the
477+
# request, restricted to a fixed allowlist (never arbitrary env).
478+
extra = args.get("env")
479+
if isinstance(extra, dict):
480+
for key, value in extra.items():
481+
if key in FAULT_ENV_KEYS and isinstance(value, str):
482+
env[key] = value
472483
self.launcher = self.tree.spawn(argv, "target", stack, env)
473484
self.phase = "starting"
474485
self.start_deadline = time.monotonic() + 10
@@ -740,7 +751,8 @@ def main(argv):
740751
value = request("hello", run_id=args[0])
741752
elif op == "start":
742753
stack, sha, *cmd = args
743-
value = request("start", stack, dict(argv=cmd, sha256=sha))
754+
env = {k: v for k, v in os.environ.items() if k in FAULT_ENV_KEYS}
755+
value = request("start", stack, dict(argv=cmd, sha256=sha, env=env))
744756
value = wait_phase(stack, "running", 10)
745757
elif op == "stop":
746758
value = request("stop", args[0])

‎tests/integration/test_graceful_reload.sh‎

Lines changed: 195 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ STREAM_MB=8
7777
# the 180 s summary wait and the 300 s remote probe budget.
7878
STREAM_DURATION=120
7979
KERNEL_NIC_IP=""
80+
# Runtime fault injection (FF_FAULT). Empty = production form; a non-empty name
81+
# requires a fault-injection build manifest (checked by reload_checks.verify-build).
82+
FAULT=""
83+
FAULT_DELAY_MS=15000
8084
ZC_BUILD=auto
8185
RTE_FRESH_MIN=10
8286
# KNI owner must be a *secondary* proc_id, never the resident primary (0):
@@ -139,7 +143,11 @@ Usage: test_graceful_reload.sh -t <TARGET_IP> [options]
139143
test (write <DPDK_NIC_IP> in any report).
140144
-c, --cases <list> Comma-separated case list, or 'all'.
141145
available: precheck,baseline,rt01,rt02,rv9,gr0,
142-
rt12,rt13
146+
rt12,rt13,rt20,rt20b,rt21,rt22,rt23
147+
--fault <name> runtime fault injection (FF_FAULT); requires a
148+
fault-injection build manifest. rt23 is the only
149+
fault case that runs on the production form
150+
--fault-delay-ms <n> FF_FAULT_DELAY_MS for ready_delay (1..59000)
143151
default : precheck,rt01,rv9
144152
-r, --rounds <n> reload-loop rounds for rv9 (default 100)
145153
-i, --interval <s> reload-loop cadence in seconds (default 15)
@@ -213,6 +221,8 @@ while [ $# -gt 0 ]; do
213221
--baseline-duration) BASELINE_DURATION="${2:-}"; shift 2 ;;
214222
--stream-mb) STREAM_MB="${2:-}"; shift 2 ;;
215223
--kernel-nic-ip) KERNEL_NIC_IP="${2:-}"; shift 2 ;;
224+
--fault) FAULT="${2:-}"; shift 2 ;;
225+
--fault-delay-ms) FAULT_DELAY_MS="${2:-}"; shift 2 ;;
216226
--zc-build) ZC_BUILD="${2:-}"; shift 2 ;;
217227
--rte-fresh-min) RTE_FRESH_MIN="${2:-}"; shift 2 ;;
218228
-h|--help) usage; exit 0 ;;
@@ -226,7 +236,8 @@ done
226236
local n
227237
local -a values=()
228238
for n in TARGET_IP CLIENT CASES ROUNDS INTERVAL POLL WORKERS DRAIN_TIMEOUT STARTUP_WAIT \
229-
STREAM_MB RTE_FRESH_MIN SHUTDOWN_TIMEOUT BASELINE_DURATION GRACEFUL ZC_BUILD \
239+
STREAM_MB RTE_FRESH_MIN SHUTDOWN_TIMEOUT BASELINE_DURATION GRACEFUL ZC_BUILD FAULT \
240+
FAULT_DELAY_MS \
230241
NGINX_BIN FSTACK_TPL PROBE_DIR OUT BUILD_MANIFEST KERNEL_NIC_IP LCORE_MASK LCORE_LIST; do
231242
values+=("$n=${!n}")
232243
done
@@ -809,6 +820,187 @@ do_hup_case() { # tag graceful shutdown_timeout probe-kind(none|stream|lc|cps)
809820
return 0
810821
}
811822

823+
# ---- fault-injection cases (F1) ------------------------------------------
824+
# These need a fault-injection build (FF_RELOAD_FAULT_INJECTION=1) and a
825+
# manifest that declares it; reload_checks.verify-build enforces the pairing in
826+
# both directions. They never contribute to functional acceptance: rt23 is the
827+
# only one that runs on the production form.
828+
fault_case() { # tag fault expect(ok|abort) criterion [abort-signature]
829+
local tag="$1" fault="$2" expect="$3" crit="$4" sig="${5:-}"
830+
local rc=0 conf out before
831+
# The --fault option drives the build-form gate; it must name the same
832+
# fault the case injects, otherwise a verdict could be labelled wrongly.
833+
if [ "$FAULT" != "$fault" ]; then
834+
say "$tag: --fault=$FAULT does not match the case fault $fault"
835+
record "$tag" "FAIL" "$crit" "fault option/case mismatch ($FAULT vs $fault)"
836+
return 1
837+
fi
838+
export FF_FAULT="$fault"
839+
if [ "$fault" = "ready_delay" ]; then
840+
export FF_FAULT_DELAY_MS="$FAULT_DELAY_MS"
841+
else
842+
unset FF_FAULT_DELAY_MS
843+
fi
844+
conf=$(gen_nginx_conf "$tag" 0)
845+
push_probes || { unset FF_FAULT; unset FF_FAULT_DELAY_MS; return 1; }
846+
if ! start_stack "$tag" 1 0; then
847+
stop_stack "$tag" "$conf" || rc=1
848+
unset FF_FAULT
849+
record "$tag" "FAIL" "$crit" "start failed (fault=$fault)"
850+
return 1
851+
fi
852+
before=$(worker_count)
853+
if have_probe m4_lc.py; then
854+
launch_probe "$tag" 120 m4_lc.py --server "$TARGET_IP" --conns 12 \
855+
--interval 0.1 --duration 45 --fresh 0.5 --timeout 2 \
856+
|| { unset FF_FAULT; unset FF_FAULT_DELAY_MS; stop_stack "$tag" "$conf"; record "$tag" "FAIL" "$crit" "probe launch failed"; return 1; }
857+
sleep 3
858+
fi
859+
probe_running || rc=1
860+
local hrc=0
861+
if [ "$expect" = abort ]; then
862+
# An aborted reload never prints the completion line, so waiting for it
863+
# would only burn the drain timeout. Wait for the abort signature
864+
# instead, bounded by the READY/park budget plus a margin.
865+
nginx_signal "$conf" reload || hrc=1
866+
local deadline=$((SECONDS + 90)) found=0
867+
while [ "$SECONDS" -lt "$deadline" ]; do
868+
if [ -n "$sig" ]; then
869+
if grep -q "graceful reload aborted: $sig" "$ERRLOG"; then found=1; break; fi
870+
elif grep -q "graceful reload aborted" "$ERRLOG"; then
871+
found=1; break
872+
fi
873+
sleep 1
874+
done
875+
if [ "$found" != "1" ]; then
876+
say "$tag: no bounded abort within 90 s (errlog: $ERRLOG)"
877+
hrc=1
878+
fi
879+
else
880+
hup_once "$conf" || hrc=1
881+
fi
882+
# hrc == 0 means the expected event happened: the bounded abort signature
883+
# for expect=abort, the completion line for expect=ok.
884+
[ "$hrc" = "0" ] || { say "$tag: expect=$expect not satisfied (rc=$hrc)"; rc=1; }
885+
# NB: no probe_running check here -- by the time the bounded abort/completion
886+
# is observed the probe (45 s) has normally finished; the probe's own
887+
# summary below is the evidence that G_old kept serving, not a liveness bit.
888+
# G_old must have kept serving: the probe's own verdict must be clean.
889+
local summary="no probe" fetch=0
890+
if ! have_probe m4_lc.py; then
891+
# No probe means no evidence that G_old kept serving: never a silent
892+
# pass (same rule as the other cases in this harness).
893+
unset FF_FAULT
894+
unset FF_FAULT_DELAY_MS
895+
stop_stack "$tag" "$conf" || rc=1
896+
record "$tag" "SKIP" "$crit" "NO_DATA (m4_lc.py absent from $PROBE_DIR)"
897+
return 0
898+
fi
899+
if have_probe m4_lc.py; then
900+
summary=$(wait_client_summary /tmp/gr_${tag}_lc_out.log 'LC_SUMMARY' 120) || fetch=$?
901+
if [ "$fetch" = "1" ]; then
902+
summary="NO_DATA (m4_lc.py did not report within 120 s)"; rc=1
903+
elif [ "$fetch" = "2" ]; then
904+
say "$tag: probe reported a summary but failed its own criterion"; rc=1
905+
fi
906+
check_summary lc "$summary" || { say "$tag: lc verdict below target: $summary"; rc=1; }
907+
fi
908+
# no double master and no lost generation: the count must be back to
909+
# exactly the pre-reload set.
910+
if [ "$(worker_count)" -ne "$before" ]; then
911+
say "$tag: worker count changed ($before -> $(worker_count))"
912+
rc=1
913+
fi
914+
unset FF_FAULT
915+
unset FF_FAULT_DELAY_MS
916+
stop_stack "$tag" "$conf" || rc=1
917+
if [ "$rc" = "0" ]; then
918+
record "$tag" "PASS" "$crit" \
919+
"fault=$fault expect=$expect hrc=$hrc workers_before=$before traffic=$summary (fault-injection build)"
920+
else
921+
record "$tag" "FAIL" "$crit" \
922+
"fault=$fault expect=$expect hrc=$hrc workers_before=$before traffic=$summary (fault-injection build)"
923+
fi
924+
return $rc
925+
}
926+
927+
case_rt20() {
928+
say "=== case rt20 (READY never arrives -> bounded abort) ==="
929+
fault_case "rt20" ready_never abort \
930+
"READY wait times out within NGX_FF_RELOAD_READY_WAIT_SEC (60s); reload aborts to T0_IDLE; G_old keeps serving; no second master" \
931+
"READY wait timed out"
932+
}
933+
case_rt20b() {
934+
say "=== case rt20b (READY late but reachable -> completes) ==="
935+
fault_case "rt20b" ready_delay ok \
936+
"READY delayed by FF_FAULT_DELAY_MS (<60s) still completes: 6/6 FSM, workers back to N, service restored"
937+
}
938+
case_rt21() {
939+
say "=== case rt21 (handover flip fails -> T2/T_ERROR/T0) ==="
940+
fault_case "rt21" flip_fail abort \
941+
"T2 -> T_ERROR -> T0 with 'rx ownership flip failed'; G_old keeps serving; no half-handover" \
942+
"rx ownership flip failed"
943+
}
944+
case_rt22() {
945+
say "=== case rt22 (park never confirmed -> bounded abort) ==="
946+
fault_case "rt22" park_never abort \
947+
"park budget (FF_RELOAD_HANDOVER_TIMEOUT_MS_DEFAULT 100U) expires: T_ERROR -> T0 with 'G_old park confirmation timed out'" \
948+
"G_old park confirmation timed out"
949+
}
950+
case_rt23() {
951+
say "=== case rt23 (reload re-entry during drain is refused) ==="
952+
local rc=0 conf out
953+
# rt23 is the only fault-matrix case that runs on the PRODUCTION form:
954+
# --fault/BUILD_MANIFEST are per-run globals, so refuse the mixed form and
955+
# drop any inherited fault variables.
956+
if [ -n "$FAULT" ]; then
957+
say "rt23: requires the production form (--fault=$FAULT)"
958+
record "rt23" "FAIL" "second HUP during T3 is refused; first reload still completes" \
959+
"fault form not allowed for rt23"
960+
return 1
961+
fi
962+
unset FF_FAULT
963+
unset FF_FAULT_DELAY_MS
964+
conf=$(gen_nginx_conf "rt23" 0)
965+
prep_stream_payload || return 1
966+
push_probes || return 1
967+
if ! start_stack "rt23" 1 0; then
968+
stop_stack "rt23" "$conf" || rc=1
969+
record "rt23" "FAIL" "second HUP during T3 is refused; first reload still completes" "start failed"
970+
return 1
971+
fi
972+
launch_probe rt23 300 m4_stream.py --server "$TARGET_IP" \
973+
--path /dl/big.bin --streams 12 --chunk 16384 --gap 0.1 \
974+
--timeout 5 --stall 3.0 --duration "$STREAM_DURATION" \
975+
--expect-md5 "$STREAM_MD5" || return 1
976+
sleep 3
977+
probe_running || rc=1
978+
# The second HUP must land WHILE the first one is draining (T3), not after
979+
# it finished, otherwise it simply starts a second reload.
980+
nginx_signal "$conf" reload || rc=1
981+
sleep 5
982+
nginx_signal "$conf" reload || rc=1
983+
local deadline=$((SECONDS + 150)) done=0
984+
while [ "$SECONDS" -lt "$deadline" ]; do
985+
if grep -q "graceful reload complete" "$ERRLOG"; then done=1; break; fi
986+
sleep 1
987+
done
988+
[ "$done" = "1" ] || { say "rt23: first reload did not complete within 150 s"; rc=1; }
989+
grep -q "graceful reload rejected: previous reload still in progress" "$ERRLOG" \
990+
|| { say "rt23: re-entry refusal not found in $ERRLOG"; rc=1; }
991+
HUP_SUMMARY=$(wait_client_summary /tmp/gr_rt23_stream.log 'STREAM_SUMMARY' 120) \
992+
|| HUP_SUMMARY="NO_DATA (m4_stream.py did not report within 120 s)"
993+
stop_stack "rt23" "$conf" || rc=1
994+
if [ "$rc" = "0" ]; then
995+
record "rt23" "PASS" "second HUP during T3 is refused; first reload still completes" \
996+
"fsm=$HUP_FSM/6 drain=${HUP_DRAIN}ms traffic=$HUP_SUMMARY"
997+
else
998+
record "rt23" "FAIL" "second HUP during T3 is refused; first reload still completes" \
999+
"fsm=$HUP_FSM/6 drain=${HUP_DRAIN}ms traffic=$HUP_SUMMARY"
1000+
fi
1001+
return $rc
1002+
}
1003+
8121004
case_rt01() {
8131005
say "=== case rt01 (unloaded HUP) ==="
8141006
local rc=0
@@ -1250,7 +1442,7 @@ main() {
12501442
[ "$("$KILLTOOL" --capabilities)" = pidfd-identity-v1 ] || return 4
12511443
zc_probe_selftest || return 4
12521444
run_case precheck || { print_summary; return 3; }
1253-
for t in baseline rt01 rt02 rv9 gr0 rt12 rt13; do
1445+
for t in baseline rt01 rt02 rv9 gr0 rt12 rt13 rt20 rt20b rt21 rt22 rt23; do
12541446
need_case "$t" || continue
12551447
run_case "$t"
12561448
[ "$CLEANUP_FAILED" = 0 ] || break

0 commit comments

Comments
 (0)