Files
fips/testing/check-chaos-schedule.py
Johnathan Corgan e644f5d81f Make the chaos draws a function of the scenario seed, not Python's hash seed
The chaos simulator walked its edge sets unsorted before drawing from its
seeded streams. A set of strings iterates in an order that follows Python's
per-process string hash seed, which is random unless PYTHONHASHSEED is set,
so "seed 42" handed the draws to the edges in a different order on every
run: which links got which bandwidth and ingress tier, which were mutated,
which flapped, which peer churn dropped, and which node dialled which. The
scenario floors were calibrated on that moving schedule, and a red run
could not be replayed from its seed.

Walk the edges in sorted order at each site: the bandwidth and ingress
tiers and the mutation population in netem.py, the flap candidates in
links.py, the disconnect candidates in peer_churn.py, and the spanning-tree
adjacency in directed_outbound(). The non-tree loop there is sorted too;
the generated config already sorts each peer list, so that one changes
nothing a node sees.

Add testing/check-chaos-schedule.py, which runs the simulator's own
managers with their side effects replaced by recorders under several
PYTHONHASHSEED values, one of them repeated, and fails when any draw
differs. It exits 2 rather than passing when it cannot show that it would
see a difference: when the repeated seed does not reproduce its hash value
or its draws (as under python -E or -I), when the seeds do not give
distinct hash values, when an unsorted control draw does not vary, or when
a manager it stubs has been renamed. It runs in local CI's guards and in
the GitHub ci-parity job.

Correct the churn-mixed comment and the README's seed line to say what the
seed fixes and what it does not. The runtime schedule still varies: events
are drawn as the run goes, and where managers share one random stream, as
the runner does on this branch, host timing decides how their draws
interleave.
2026-10-25 09:15:02 +00:00

366 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
"""Fail when a chaos scenario's seeded draws depend on Python's string hash seed.
The chaos simulator draws its schedule from streams seeded by the scenario's
`seed`. Iterating a set of strings gives an order that follows the
interpreter's per-process string hash seed, which is random unless
PYTHONHASHSEED is set. Wherever that order decides which edge a draw lands
on, "seed 42" draws a different schedule on every run, a floor is calibrated
on a moving target, and a red run cannot be replayed. Nothing in a single run
shows it, so this check runs the draws under several hash seeds and compares.
How: the parent runs this file again as a child (`--emit <case>`) once per
PYTHONHASHSEED in HASH_SEEDS, with one seed repeated. Each child loads
testing/chaos/scenarios/churn-mixed.yaml, builds the topology and the netem,
link-flap, node-churn and peer-churn managers from the simulator's own
classes, replaces their side-effecting methods with recorders, drives a few
rounds of each, and prints every draw. Each manager gets its own stream, so
one site's dependence can neither hide nor be blamed on another's. Cases:
ci churn-mixed at 10 nodes, the invocation local and GitHub CI gate on;
all churn-mixed at its own 20 nodes, with ingress policing (four tiers)
and peer churn turned on, so every known site is exercised. 20 nodes
is what makes the dial-direction site reliably visible.
A child's printed outbound lists are sorted per node, because the generated
config sorts them; only which node dials which reaches a run.
Before it compares anything, the parent requires proof that the comparison
can see a difference:
- the repeated hash seed printed the same `hash()` value both times, and
the distinct seeds printed distinct ones. Under `python -E` or `-I` the
interpreter ignores PYTHONHASHSEED and randomises every process, which
the repeat catches and distinctness alone does not;
- the repeated seed printed the same draws both times, so a difference
between the other seeds can be put down to the hash seed and not to some
other source of variation;
- a control draw, made on purpose over the unsorted edge set, differed
between the distinct seeds, so a site that walks the set unsorted would
show here.
The recorders replace private methods by name. Each name is checked to exist
on its class first, because assigning to a renamed method succeeds silently
and the real one would then run `docker`; subprocess is also disabled in the
child as a backstop. A rename in testing/chaos/sim/ therefore exits 2 here
and needs the matching one-line change in STUBS.
Runs the simulator from disk beside this file, needs pyyaml and nothing
built, and takes a few seconds.
Exit codes:
0 - every draw identical across the hash seeds
1 - a draw differs between two hash seeds; the key and seeds are named
2 - the check could not run or could not show it can see a difference
(a child failed, a key was missing or empty, or a proof above
failed); never a pass
"""
from __future__ import annotations
import os
import random
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
HERE = Path(__file__).resolve().parent
CHAOS_DIR = HERE / "chaos"
SCENARIO = CHAOS_DIR / "scenarios" / "churn-mixed.yaml"
DISTINCT_SEEDS = ("0", "1", "2", "3")
REPEAT_SEED = "1"
HASH_SEEDS = DISTINCT_SEEDS + (REPEAT_SEED,)
HASH_PROBE = "chaos-schedule-probe"
ROUNDS = 3
CHILD_TIMEOUT_SECS = 120
# Draw keys each case must print, all non-empty, in report order. "control"
# is the deliberately unsorted draw and is not compared as a draw.
CASES: dict[str, tuple[str, ...]] = {
"ci": ("outbound", "tiers", "mutate", "flaps", "nodechurn"),
"all": ("outbound", "tiers", "ingress", "mutate", "flaps", "nodechurn", "peerchurn"),
}
# (module, class, method) replaced by a recorder in the child.
STUBS = (
("netem", "NetemManager", "_update_link"),
("links", "LinkManager", "_link_down"),
("nodes", "NodeManager", "_stop_node"),
("peer_churn", "PeerChurnManager", "_disconnect_edge"),
("peer_churn", "PeerChurnManager", "_connect_edge"),
)
class GuardError(Exception):
"""The child cannot set up a faithful run of the draws."""
def _forbidden(*args, **kwargs):
"""Stand in for subprocess.Popen, so no stray call can reach docker."""
raise GuardError(f"a simulator call tried to run a command: {args[:1]!r}")
def _check_stubs(modules: dict) -> None:
"""Fail unless every method STUBS names still exists on its class."""
for module, cls, method in STUBS:
klass = getattr(modules[module], cls, None)
if klass is None or not callable(getattr(klass, method, None)):
raise GuardError(
f"sim.{module}.{cls}.{method} not found; it was renamed or "
"removed, so update STUBS in this file"
)
def _stub(obj, method: str, fn) -> None:
"""Replace obj.method with fn; only methods checked through STUBS."""
if not any(type(obj).__name__ == c and method == m for _, c, m in STUBS):
raise GuardError(f"no STUBS entry for {type(obj).__name__}.{method}")
setattr(obj, method, fn)
def emit(case: str) -> int:
"""Child mode: print every draw for one case under this process's hash seed."""
if case not in CASES:
print(f"check-chaos-schedule: unknown case {case!r}", file=sys.stderr)
return 2
sys.dont_write_bytecode = True
sys.path.insert(0, str(CHAOS_DIR))
subprocess.Popen = _forbidden # type: ignore[misc,assignment]
import sim.links
import sim.netem
import sim.nodes
import sim.peer_churn
from sim.scenario import load_scenario
from sim.topology import generate_topology
modules = {
"netem": sim.netem,
"links": sim.links,
"nodes": sim.nodes,
"peer_churn": sim.peer_churn,
}
_check_stubs(modules)
s = load_scenario(str(SCENARIO))
if case == "ci":
s.topology.num_nodes = 10
else:
s.ingress.enabled = True
s.ingress.tiers_kbps = [250, 500, 1000, 2000]
s.peer_churn.enabled = True
def stream(name: str) -> random.Random:
"""One stream per consumer, named as master's runner names them."""
return random.Random(f"{s.seed}:{name}")
out: dict[str, str] = {}
topo = generate_topology(s.topology, random.Random(s.seed), "chaos-schedule")
outbound = topo.directed_outbound()
out["outbound"] = ";".join(
f"{n}>{','.join(sorted(peers))}" for n, peers in sorted(outbound.items())
)
if s.netem.enabled:
nm = sim.netem.NetemManager(
topo,
s.netem,
stream("netem"),
bandwidth=s.bandwidth if s.bandwidth.enabled else None,
ingress=s.ingress if s.ingress.enabled else None,
)
out["tiers"] = " ".join(
f"{a}-{b}={r}" for (a, b), r in sorted(nm._edge_rates.items()) if a < b
)
out["ingress"] = " ".join(
f"{a}-{b}={r}" for (a, b), r in sorted(nm._ingress_rates.items()) if a < b
)
mutated: list[str] = []
_stub(nm, "_update_link", lambda a, b, p: mutated.append(f"{a}-{b}"))
for _ in range(ROUNDS):
nm.mutate()
mutated.append("|")
out["mutate"] = ",".join(mutated)
if s.link_flaps.enabled:
lm = sim.links.LinkManager(topo, s.link_flaps, stream("flaps"), netem_mgr=None)
flapped: list[str] = []
def link_down(edge, duration):
"""Record the flap and mark the link down, as the real one does."""
flapped.append(f"{edge[0]}-{edge[1]}@{duration:.3f}")
lm.link_states[edge].is_down = True
_stub(lm, "_link_down", link_down)
for _ in range(ROUNDS):
lm.maybe_flap()
out["flaps"] = ",".join(flapped)
if s.node_churn.enabled:
down: set[str] = set()
ndm = sim.nodes.NodeManager(topo, s.node_churn, stream("churn"), down_nodes=down)
stopped: list[str] = []
def stop_node(node_id, duration):
"""Record the stop and mark the node down, as the real one does."""
stopped.append(f"{node_id}@{duration:.3f}")
ndm.node_states[node_id].is_down = True
down.add(node_id)
_stub(ndm, "_stop_node", stop_node)
for _ in range(ROUNDS):
ndm.maybe_kill()
out["nodechurn"] = ",".join(stopped)
if s.peer_churn.enabled:
pc = sim.peer_churn.PeerChurnManager(
topo, s.peer_churn, stream("peer-churn"), down_nodes=set()
)
churned: list[str] = []
_stub(pc, "_disconnect_edge", lambda a, b: churned.append(f"-{a}-{b}") or True)
_stub(pc, "_connect_edge", lambda a, b: churned.append(f"+{a}-{b}") or True)
for _ in range(ROUNDS):
pc.maybe_churn()
out["peerchurn"] = ",".join(churned)
# Deliberately unsorted: a draw over the set itself, to show these hash
# seeds would expose a site that walks it.
control = random.Random(f"{s.seed}:control").sample(list(topo.edges), 5)
out["control"] = ",".join(f"{a}-{b}" for a, b in control)
for key, value in out.items():
print(f"{key}: {value}")
print(f"hash: {hash(HASH_PROBE)}")
return 0
def run_child(case: str, hash_seed: str) -> tuple[dict[str, str] | None, str]:
"""Run one child; return its parsed output, or None and the reason."""
env = dict(os.environ, PYTHONHASHSEED=hash_seed, PYTHONDONTWRITEBYTECODE="1")
try:
proc = subprocess.run(
[sys.executable, str(Path(__file__).resolve()), "--emit", case],
env=env,
capture_output=True,
text=True,
timeout=CHILD_TIMEOUT_SECS,
)
except (OSError, subprocess.TimeoutExpired) as e:
return None, f"could not run: {e}"
if proc.returncode != 0:
return None, f"exited {proc.returncode}:\n{proc.stderr.rstrip()}"
parsed: dict[str, str] = {}
for line in proc.stdout.splitlines():
key, sep, value = line.partition(": ")
if sep:
parsed[key] = value
return parsed, ""
def check_case(case: str, results: dict) -> int:
"""Compare one case's children across the hash seeds; return its exit code."""
keys = CASES[case]
runs: list[tuple[str, dict[str, str]]] = []
for i, hs in enumerate(HASH_SEEDS):
parsed, why = results[(case, i)]
if parsed is None:
print(f"[{case}] child PYTHONHASHSEED={hs} {why}")
return 2
for key in keys + ("control", "hash"):
if not parsed.get(key):
print(f"[{case}] child PYTHONHASHSEED={hs} printed no {key!r} "
"(missing or empty): a site this case must exercise drew "
"nothing, so agreement there would prove nothing")
return 2
runs.append((hs, parsed))
distinct = runs[: len(DISTINCT_SEEDS)]
first = next(p for hs, p in distinct if hs == REPEAT_SEED)
repeat = runs[-1][1]
if first["hash"] != repeat["hash"]:
print(f"[{case}] PYTHONHASHSEED={REPEAT_SEED} gave two different hash() "
"values: the children ignore PYTHONHASHSEED (run under -E or -I?), "
"so their agreement would prove nothing")
return 2
hashes = [p["hash"] for _, p in distinct]
if len(set(hashes)) != len(hashes):
print(f"[{case}] the hash seeds {', '.join(DISTINCT_SEEDS)} did not all "
"give distinct hash() values, so they did not take effect")
return 2
for key in keys + ("control",):
if first[key] != repeat[key]:
print(f"[{case}] {key!r} differs between two runs under the same "
f"PYTHONHASHSEED={REPEAT_SEED}: something other than the hash "
"seed varies, so a difference cannot be put down to it")
print(f" first: {first[key]}")
print(f" repeat: {repeat[key]}")
return 2
if len({p["control"] for _, p in distinct}) < 2:
print(f"[{case}] the unsorted control draw was the same under every hash "
"seed, so these seeds would not expose a set-order dependence")
return 2
base_hs, base = distinct[0]
differing = []
for key in keys:
for hs, p in distinct[1:]:
if p[key] != base[key]:
differing.append((key, hs, p[key]))
break
if differing:
key, hs, value = differing[0]
print(f"[{case}] {key!r} differs between PYTHONHASHSEED={base_hs} and "
f"PYTHONHASHSEED={hs}: a draw depends on set iteration order")
print(f" {base_hs}: {base[key]}")
print(f" {hs}: {value}")
if len(differing) > 1:
print(f" also differing: {', '.join(k for k, _, _ in differing[1:])}")
return 1
print(f"[{case}] {len(keys)} draw keys identical under PYTHONHASHSEED "
f"{', '.join(DISTINCT_SEEDS)} (and {REPEAT_SEED} repeated)")
return 0
def main(argv: list[str]) -> int:
"""Parent mode, or child mode with --emit <case>."""
if len(argv) == 2 and argv[0] == "--emit":
try:
return emit(argv[1])
except GuardError as e:
print(f"check-chaos-schedule: {e}", file=sys.stderr)
return 2
if argv:
print("usage: check-chaos-schedule.py [--emit <case>]", file=sys.stderr)
return 2
if not SCENARIO.is_file():
print(f"check-chaos-schedule: missing {SCENARIO}", file=sys.stderr)
return 2
start = time.monotonic()
# Children are independent processes, so they run side by side. Results
# are keyed by position, because the repeated seed appears twice.
jobs = [(case, i, hs) for case in CASES for i, hs in enumerate(HASH_SEEDS)]
with ThreadPoolExecutor(max_workers=len(jobs)) as pool:
done = list(pool.map(lambda j: run_child(j[0], j[2]), jobs))
worst = 0
for case in CASES:
results = {}
for (c, i, hs), result in zip(jobs, done):
if c == case:
results[(c, i)] = result
worst = max(worst, check_case(case, results))
elapsed = time.monotonic() - start
verdict = {0: "ok", 1: "FAILED", 2: "could not check"}[worst]
print(f"check-chaos-schedule: {verdict} ({len(CASES)} cases, "
f"{len(CASES) * len(HASH_SEEDS)} children, {elapsed:.1f} s)")
return worst
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))