You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
I reviewed the codex/gravity-chaos-simple chaos toolkit (cluster/chaos/*) together with its design/status doc. The foundation is genuinely good — stake-weighted quorum math, real docker-backend network partitions, separate block-hash and state-root comparison, a panic/fatal log scan, receipt re-fetch, scoped heal, and an honest gap list.
The concern below is narrow but matters for a consensus-safety test tool: several defects let the harness report PASS on exactly the fork/halt conditions it exists to detect. A false PASS in a safety test is worse than no test — it manufactures confidence. Practical takeaway today: trust the FAIL signals (docker partition scenarios), don't yet trust a PASS until the items below are addressed.
Findings come from a multi-lens adversarial read; the #1 oracle bug was independently reproduced by two reviewers.
✅ Keep (working well)
Stake-weighted BFT quorum (stake*3 > total*2) used consistently — not node count.
Docker-backend splits are genuine partitions (fresh bridge nets, disconnect members from all original nets, reject host-network); correctly die on non-docker backends.
State root read from block.stateRoot and compared separately from block hash.
receipt_checker.verify_one re-fetches receipt and canonical block per node and compares blockHash/blockNumber.
Heal is scoped (only the GRAVITY_CHAOS chain / the specific qdisc), no global iptables -F.
🔴 Correctness bugs — can report a false PASS (fix before trusting a PASS)
Oracle no-fork / state-root guard is a dead tautology — cluster/chaos/lib/cluster.py:812-825. pass = len(hashes)==1 and len(common_block_samples)==len(nodes); the second clause is always true (one sample is appended per node regardless of RPC success, :800-801), and hashes/state_roots are sets built only from samples where rpc_ok and a truthy hash (:802-811). So a node on a divergent chain whose common-height requery times out / returns null is silently dropped, and the agreeing remainder passes. Worst case: a single responder ⇒ len(hashes)==1 ⇒ PASS. Fix:responders = [s for s in common_block_samples if s.get('rpc_ok') and s.get('hash')]; pass = len(responders)==len(nodes) and len({s['hash'] for s in responders})==1 (same for state_root); parallelize + lengthen the requery timeout so a slow-but-honest node isn't dropped.
Oracle only compares one deep block (min_height - common_depth), never the tip — cluster/chaos/lib/cluster.py:797-825. Divergence first appears at the tip; the single post-heal oracle run every scenario fires evaluates a height below the fork point ⇒ identical pre-fork hash ⇒ PASS while forked at the tip. height_spread <= HEIGHT_DIFF_MAX(10) compares only heights, never hashes, so it doesn't compensate. Fix: also diff hash + state_root at the highest common height (min tip) and across a band of recent heights, in addition to the deep finality sample. (Note the --common-depth default is 2 at :1117, even shallower.)
wait_oracle returns on the FIRST passing run — cluster/chaos/scenarios.sh:190-215 (used by every scenario via timed_wait_oracle). Combined with the tautology above, retry-until-pass actively selects the transient false-pass and records "recovered" for a fork that never reconciled. Fix: require N consecutive all-present passes; never count a run where a node was unreachable at common-height.
receipt_checker passes on any 2 nodes, and auto-downgrades to 1 during partitions — cluster/chaos/lib/receipt_checker.py:197, 226. ok = len(ok_nodes) >= min_nodes with min_nodes defaulting to 2 and min_nodes = min(args.min_nodes, len(nodes)), so a 3rd forked/halted node is ignored exactly when the check should be strictest. Fix: require all selected nodes agree (len(failures)==0 and len(ok_nodes)==len(nodes)); if fewer than intended nodes are reachable ⇒ inconclusive/fail, never pass; record how many were actually compared.
Local delay/loss/throttle apply netem to the default-route interface, not loopback — cluster/chaos/lib/net.sh:16-22,121-148 (detect_iface returns the default-route dev). On a 127.0.0.1 cluster all inter-node traffic goes over lo, so the qdisc lands on an interface carrying zero inter-node traffic — the fault injects nothing, yet the scenario passes while claiming to validate latency/loss. Fix: target lo via iptables -t mangle MARK on the listener ports + tc filter fw, or die on the local backend like partition-split already does.
Local symmetric partition doesn't block the victim's outbound dials — cluster/chaos/chaos.sh:148-167. At 127.0.0.1 peer_hosts is always empty, so only the victim's own listener ports are dropped; its outbound TCP to peer listener ports survives, keeping a persistent bidirectional consensus connection alive → not a real cut (weaker than partition-asym out). This surfaces as misleading "victim advanced while partitioned" reports. Fix: make symmetric partition the union of asym-in + asym-out, or gate it to the docker backend.
In-flight tx at workload shutdown → downgraded to inconclusive/pass, never checked on-chain — cluster/chaos/lib/tx_workload.py:200, 303-313; receipt_checker.py. On STOP, still-pending txs are written as tx_interrupted (a warning ⇒ inconclusive ⇒ overall pass by default) and are never verified against the chain. A tx a fault actually dropped is reported PASS. Fix: on STOP, grace re-poll, then verify each remaining tx_hash on all nodes (mined-but-forked vs truly absent).
panic_log_scan gates on file mtime — cluster/chaos/lib/cluster.py:678; oracle.sh:10. A node that panicked and went silent has a frozen mtime (< oracle start) ⇒ its log is skipped ⇒ the crash signature the scan exists to catch is discarded for the dead node. Fix: don't gate crash detection on mtime; scan candidate log tails unconditionally; treat a stopped-growing log as suspect, not clean.
(secondary)validator_stake_advancing increments on a node advancing its own (possibly forked/minority) chain, so it isn't a fork detector; and --skip-advancing disables the only halt detector — cluster/chaos/lib/cluster.py:768-795.
🟡 Coverage gaps (fault classes the oracle currently can't observe)
No clock-skew / time-manipulation fault. Timestamp-gated hardfork-boundary behavior is out of the test envelope. Highest-value single addition: a clock-skew primitive (e.g. libfaketime preload in the node image) + a scenario that skews one node across a configured activation timestamp while the cluster produces blocks, then runs the state-root oracle.
No hardfork-activation-under-chaos scenario (crossing a fork-activation timestamp while partitioned / under load / with a laggard). The strict "no init / no clean data dir" design principle also structurally prevents starting one node with a differing chain-spec.
Transfer-only sentinel workload ⇒ the state-root oracle can't observe execution nondeterminism. Add contract creation / reverts / storage-contention under parallel workers, and EIP-7702 (type-4 / SetCode) transactions, so the oracle actually has divergence to catch.
No laggard / slow-but-connected node (execution lag via CPU/IO throttle, not partition) with a bounded catch-up + post-catch-up state-root-equality assertion.
Suggested order (please don't reorder)
Fix the oracle (tautology guard + tip-band compare) and make receipt_checker require all-agree with no silent min_nodes downgrade. Acceptance test: a fixture that deliberately forks ONE node in a 4-node cluster and asserts oracle and receipt_checker report FAIL — this fixture is the gate for the whole fix.
Make faults verifiable — a post-injection effectiveness probe (partition ⇒ victim can't TCP-connect a peer listener; netem ⇒ measured RTT/loss delta); fix or die on the local no-op faults. Any round where the fault didn't measurably bite ⇒ inconclusive, never pass.
Context
I reviewed the
codex/gravity-chaos-simplechaos toolkit (cluster/chaos/*) together with its design/status doc. The foundation is genuinely good — stake-weighted quorum math, real docker-backend network partitions, separate block-hash and state-root comparison, a panic/fatal log scan, receipt re-fetch, scoped heal, and an honest gap list.The concern below is narrow but matters for a consensus-safety test tool: several defects let the harness report PASS on exactly the fork/halt conditions it exists to detect. A false PASS in a safety test is worse than no test — it manufactures confidence. Practical takeaway today: trust the FAIL signals (docker partition scenarios), don't yet trust a PASS until the items below are addressed.
Findings come from a multi-lens adversarial read; the #1 oracle bug was independently reproduced by two reviewers.
✅ Keep (working well)
stake*3 > total*2) used consistently — not node count.dieon non-docker backends.block.stateRootand compared separately from block hash.receipt_checker.verify_onere-fetches receipt and canonical block per node and comparesblockHash/blockNumber.GRAVITY_CHAOSchain / the specific qdisc), no globaliptables -F.🔴 Correctness bugs — can report a false PASS (fix before trusting a PASS)
Oracle no-fork / state-root guard is a dead tautology —
cluster/chaos/lib/cluster.py:812-825.pass = len(hashes)==1 and len(common_block_samples)==len(nodes); the second clause is always true (one sample is appended per node regardless of RPC success,:800-801), andhashes/state_rootsare sets built only from samples whererpc_okand a truthy hash (:802-811). So a node on a divergent chain whose common-height requery times out / returns null is silently dropped, and the agreeing remainder passes. Worst case: a single responder ⇒len(hashes)==1⇒ PASS. Fix:responders = [s for s in common_block_samples if s.get('rpc_ok') and s.get('hash')]; pass = len(responders)==len(nodes) and len({s['hash'] for s in responders})==1(same forstate_root); parallelize + lengthen the requery timeout so a slow-but-honest node isn't dropped.Oracle only compares one deep block (
min_height - common_depth), never the tip —cluster/chaos/lib/cluster.py:797-825. Divergence first appears at the tip; the single post-healoraclerun every scenario fires evaluates a height below the fork point ⇒ identical pre-fork hash ⇒ PASS while forked at the tip.height_spread <= HEIGHT_DIFF_MAX(10)compares only heights, never hashes, so it doesn't compensate. Fix: also diff hash + state_root at the highest common height (min tip) and across a band of recent heights, in addition to the deep finality sample. (Note the--common-depthdefault is2at:1117, even shallower.)wait_oraclereturns on the FIRST passing run —cluster/chaos/scenarios.sh:190-215(used by every scenario viatimed_wait_oracle). Combined with the tautology above, retry-until-pass actively selects the transient false-pass and records "recovered" for a fork that never reconciled. Fix: require N consecutive all-present passes; never count a run where a node was unreachable at common-height.receipt_checkerpasses on any 2 nodes, and auto-downgrades to 1 during partitions —cluster/chaos/lib/receipt_checker.py:197, 226.ok = len(ok_nodes) >= min_nodeswithmin_nodesdefaulting to 2 andmin_nodes = min(args.min_nodes, len(nodes)), so a 3rd forked/halted node is ignored exactly when the check should be strictest. Fix: require all selected nodes agree (len(failures)==0 and len(ok_nodes)==len(nodes)); if fewer than intended nodes are reachable ⇒ inconclusive/fail, never pass; record how many were actually compared.Local
delay/loss/throttleapplynetemto the default-route interface, not loopback —cluster/chaos/lib/net.sh:16-22,121-148(detect_ifacereturns the default-route dev). On a127.0.0.1cluster all inter-node traffic goes overlo, so the qdisc lands on an interface carrying zero inter-node traffic — the fault injects nothing, yet the scenario passes while claiming to validate latency/loss. Fix: targetloviaiptables -t mangleMARK on the listener ports +tc filter fw, ordieon the local backend like partition-split already does.Local symmetric partition doesn't block the victim's outbound dials —
cluster/chaos/chaos.sh:148-167. At 127.0.0.1peer_hostsis always empty, so only the victim's own listener ports are dropped; its outbound TCP to peer listener ports survives, keeping a persistent bidirectional consensus connection alive → not a real cut (weaker thanpartition-asym out). This surfaces as misleading "victim advanced while partitioned" reports. Fix: make symmetric partition the union of asym-in + asym-out, or gate it to the docker backend.In-flight tx at workload shutdown → downgraded to
inconclusive/pass, never checked on-chain —cluster/chaos/lib/tx_workload.py:200, 303-313;receipt_checker.py. On STOP, still-pending txs are written astx_interrupted(a warning ⇒inconclusive⇒ overall pass by default) and are never verified against the chain. A tx a fault actually dropped is reported PASS. Fix: on STOP, grace re-poll, then verify each remainingtx_hashon all nodes (mined-but-forked vs truly absent).panic_log_scangates on file mtime —cluster/chaos/lib/cluster.py:678;oracle.sh:10. A node that panicked and went silent has a frozen mtime (< oracle start) ⇒ its log is skipped ⇒ the crash signature the scan exists to catch is discarded for the dead node. Fix: don't gate crash detection on mtime; scan candidate log tails unconditionally; treat a stopped-growing log as suspect, not clean.(secondary)
validator_stake_advancingincrements on a node advancing its own (possibly forked/minority) chain, so it isn't a fork detector; and--skip-advancingdisables the only halt detector —cluster/chaos/lib/cluster.py:768-795.🟡 Coverage gaps (fault classes the oracle currently can't observe)
libfaketimepreload in the node image) + a scenario that skews one node across a configured activation timestamp while the cluster produces blocks, then runs the state-root oracle.Suggested order (please don't reorder)
receipt_checkerrequire all-agree with no silentmin_nodesdowngrade. Acceptance test: a fixture that deliberately forks ONE node in a 4-node cluster and asserts oracle and receipt_checker report FAIL — this fixture is the gate for the whole fix.dieon the local no-op faults. Any round where the fault didn't measurably bite ⇒ inconclusive, never pass.Adding scenarios (4–6) before fixing the oracle (1) and fault verification (2) just produces more confident false PASSes.
cc @Lchangliang — thanks for building this; the skeleton is solid and the direction is right. These are about making a PASS trustworthy.