sui_protocol_config/reachability.rs
1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Reachability assertions whose "must be hit" expectation is decided at runtime from the
5//! protocol config, rather than at compile time.
6//!
7//! `mysten_common::assert_reachable!` registers with Antithesis at compile time: every call
8//! site linked into the binary is unconditionally expected to execute at least once during a
9//! run. That is the wrong expectation for code behind a protocol feature flag. One sui-node
10//! binary serves every chain configuration - Antithesis picks one per run by setting
11//! `SUI_PROTOCOL_CONFIG_CHAIN_OVERRIDE` - so a flag that is on for one chain is off for
12//! another, and a site that is correctly dark for the run's configuration is still reported as
13//! "never reached". The same thing happens in reverse while a flag is rolling out: a path
14//! guarded by `!flag` is dark on whichever chains already have the flag on.
15//!
16//! `assert_reachable_gated!` takes a predicate over `ProtocolConfig` and registers a reachability
17//! expectation when the node adopts a config that satisfies it:
18//!
19//! ```ignore
20//! assert_reachable_gated!(
21//! "retry object withdraw later",
22//! |pc| !pc.check_object_funds_withdraw_in_execution()
23//! );
24//! ```
25//!
26//! `register_reachability_for_config` performs that registration and is called at every epoch
27//! start. Registration is cumulative across epochs, so an upgrade test that starts below a
28//! flag's enabling version and upgrades through it requires both the old and the new path,
29//! since both configurations were adopted. A point reached before registration is also
30//! catalogued as required: the observed hit already satisfies that expectation.
31
32use crate::ProtocolConfig;
33use antithesis_sdk::assert::{AssertType, assert_raw};
34use antithesis_sdk::linkme::distributed_slice;
35use serde_json::json;
36use std::sync::{
37 Once,
38 atomic::{AtomicBool, Ordering},
39};
40
41/// A reachability assertion that is registered with Antithesis at runtime.
42///
43/// Constructed by [`crate::assert_reachable_gated!`]; there is no reason to name this type directly.
44pub struct GatedReachabilityPoint {
45 pub message: &'static str,
46 pub class: &'static str,
47 /// Resolves the enclosing function's path. A fn pointer rather than a `&'static str`
48 /// because the `type_name` trick it uses has to be expanded at the call site.
49 pub function: fn() -> &'static str,
50 pub file: &'static str,
51 pub line: u32,
52 pub column: u32,
53 /// Whether the point is expected to be reachable under a given protocol config.
54 pub expected_reachable: fn(&ProtocolConfig) -> bool,
55 pub catalogued: Once,
56 pub hit: AtomicBool,
57}
58
59/// Every [`crate::assert_reachable_gated!`] site linked into the binary.
60#[distributed_slice]
61#[linkme(crate = antithesis_sdk::linkme)]
62pub static GATED_REACHABILITY_CATALOG: [GatedReachabilityPoint];
63
64/// Forces a non-capturing closure at a call site to the predicate signature, so that
65/// `|pc| ...` can be written without naming `ProtocolConfig`.
66pub const fn as_predicate(f: fn(&ProtocolConfig) -> bool) -> fn(&ProtocolConfig) -> bool {
67 f
68}
69
70impl GatedReachabilityPoint {
71 fn ensure_catalogued(&self) {
72 // A concurrent first hit must wait until the declaration has been emitted.
73 self.catalogued.call_once(|| self.emit(false));
74 }
75
76 /// Records that control flow reached this point. Called by [`crate::assert_reachable_gated!`].
77 pub fn reached(&self) {
78 // Only the first hit is reported, so keep the steady state to a single relaxed load:
79 // some of these sit on per-transaction paths.
80 if self.hit.load(Ordering::Relaxed) {
81 return;
82 }
83 // A binary without an epoch store can reach a point before registration. Requiring
84 // an already-observed point is safe and keeps its SDK declaration stable.
85 self.ensure_catalogued();
86 if !self.hit.swap(true, Ordering::Relaxed) {
87 self.emit(true);
88 }
89 }
90
91 fn emit(&self, hit: bool) {
92 // Mirror what the sdk's own macros put on the wire: catalog entries carry
93 // `condition: false` and an empty payload, hits carry `condition: true`.
94 let details = if hit { json!({}) } else { json!(null) };
95 assert_raw(
96 hit, // condition
97 self.message.to_owned(), // message
98 &details, // details
99 self.class.to_owned(), // class
100 (self.function)().to_owned(), // function
101 self.file.to_owned(), // file
102 self.line, // begin_line
103 self.column, // begin_column
104 hit, // hit
105 true, // must_hit
106 AssertType::Reachability, // assert_type
107 "Reachable".to_owned(), // display_type
108 self.message.to_owned(), // id
109 );
110 }
111}
112
113/// Catalogs every gated point that `config` makes live.
114///
115/// Call this each time the node adopts a protocol config, including at startup. It is
116/// idempotent, so repeated calls with the same config emit nothing after the first.
117pub fn register_reachability_for_config(config: &ProtocolConfig) {
118 // Calling in to the antithesis sdk breaks determinism in simtests (on linux only).
119 if cfg!(msim) {
120 return;
121 }
122 for point in GATED_REACHABILITY_CATALOG.iter() {
123 if (point.expected_reachable)(config) {
124 point.ensure_catalogued();
125 }
126 }
127}
128
129/// Like `mysten_common::assert_reachable!`, but only expected to be reached under protocol
130/// configs satisfying `$expected_reachable`. See the module docs.
131///
132/// The predicate must be a non-capturing closure over `&ProtocolConfig` that does not panic
133/// for any supported configuration; it runs during epoch-store construction.
134#[macro_export]
135macro_rules! assert_reachable_gated {
136 ($message:literal, $expected_reachable:expr) => {{
137 // `_f`'s `type_name` is the enclosing function's path with `::_f` appended, which is
138 // how the antithesis sdk recovers a function name. It has to be defined here, in the
139 // caller's body, rather than inside a helper.
140 fn _f() {}
141 fn __function_name() -> &'static str {
142 fn type_name_of<T>(_: T) -> &'static str {
143 ::std::any::type_name::<T>()
144 }
145 let name = type_name_of(_f);
146 &name[..name.len() - "::_f".len()]
147 }
148
149 #[$crate::linkme::distributed_slice($crate::reachability::GATED_REACHABILITY_CATALOG)]
150 #[linkme(crate = $crate::linkme)]
151 static POINT: $crate::reachability::GatedReachabilityPoint =
152 $crate::reachability::GatedReachabilityPoint {
153 message: $message,
154 class: ::std::module_path!(),
155 function: __function_name,
156 file: ::std::file!(),
157 line: ::std::line!(),
158 column: ::std::column!(),
159 expected_reachable: $crate::reachability::as_predicate($expected_reachable),
160 catalogued: ::std::sync::Once::new(),
161 hit: ::std::sync::atomic::AtomicBool::new(false),
162 };
163
164 // calling in to antithesis sdk breaks determinism in simtests (on linux only)
165 if !cfg!(msim) {
166 POINT.reached();
167 } else {
168 $crate::assert_reachable_simtest!($message);
169 }
170 }};
171}
172
173// Registration is skipped under msim, so these cannot run there.
174#[cfg(all(test, not(msim)))]
175mod tests {
176 use super::*;
177 use std::{path::Path, process::Command};
178
179 /// A config with the gating flag forced on or off.
180 ///
181 /// Pinning real protocol versions would couple this test to one flag's rollout schedule,
182 /// and would break once MIN_PROTOCOL_VERSION advances past them. The code under test only
183 /// ever sees a `ProtocolConfig`.
184 fn config_with_flag(enabled: bool) -> ProtocolConfig {
185 let mut config = ProtocolConfig::get_for_max_version_UNSAFE();
186 config.set_check_object_funds_withdraw_in_execution_for_testing(enabled);
187 config
188 }
189
190 fn early_hit() {
191 assert_reachable_gated!("gated reachability test: early", |pc| pc
192 .check_object_funds_withdraw_in_execution());
193 }
194
195 fn legacy() {
196 assert_reachable_gated!("gated reachability test: legacy", |pc| !pc
197 .check_object_funds_withdraw_in_execution());
198 }
199
200 fn upgraded() {
201 assert_reachable_gated!("gated reachability test: upgraded", |pc| pc
202 .check_object_funds_withdraw_in_execution());
203 }
204
205 fn disabled() {
206 assert_reachable_gated!("gated reachability test: disabled", |_| false);
207 }
208
209 fn assert_output(path: &Path, expected: &[(&str, bool)]) {
210 use mysten_common::ZipDebugEqIteratorExt as _;
211
212 let output = std::fs::read_to_string(path).unwrap();
213 let assertions: Vec<_> = output
214 .lines()
215 .map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
216 .filter_map(|mut event| {
217 let assertion = event.get_mut("antithesis_assert")?;
218 assertion["id"]
219 .as_str()?
220 .starts_with("gated reachability test:")
221 .then(|| assertion.take())
222 })
223 .collect();
224 assert_eq!(assertions.len(), expected.len(), "{assertions:#?}");
225 for (assertion, (id, hit)) in assertions.iter().zip_debug_eq(expected) {
226 assert_eq!(assertion["id"], *id);
227 assert_eq!(assertion["hit"], *hit);
228 assert_eq!(assertion["condition"], *hit);
229 assert_eq!(assertion["must_hit"], true);
230 assert_eq!(assertion["assert_type"], "reachability");
231 assert_eq!(assertion["display_type"], "Reachable");
232 }
233 }
234
235 #[test]
236 fn emits_stable_reachability_across_registration_and_hits() {
237 let expected = [
238 ("gated reachability test: early", false),
239 ("gated reachability test: early", true),
240 ("gated reachability test: legacy", false),
241 ("gated reachability test: legacy", true),
242 ("gated reachability test: upgraded", false),
243 ("gated reachability test: upgraded", true),
244 ];
245 const CHILD: &str = "SUI_REACHABILITY_TEST_CHILD";
246 if std::env::var_os(CHILD).is_none() {
247 // The SDK caches both its output destination and its assertion tracker globally.
248 // A subprocess isolates them without changing the test runner's environment.
249 let dir = tempfile::tempdir().unwrap();
250 let path = dir.path().join("sdk.jsonl");
251 let output = Command::new(std::env::current_exe().unwrap())
252 .args([
253 "--exact",
254 "reachability::tests::emits_stable_reachability_across_registration_and_hits",
255 "--nocapture",
256 ])
257 .env(CHILD, "1")
258 .env("ANTITHESIS_SDK_LOCAL_OUTPUT", &path)
259 .output()
260 .unwrap();
261 assert!(
262 output.status.success(),
263 "child failed:\nstdout: {}\nstderr: {}",
264 String::from_utf8_lossy(&output.stdout),
265 String::from_utf8_lossy(&output.stderr),
266 );
267 assert_output(&path, &expected);
268 return;
269 }
270
271 std::hint::black_box(disabled as fn());
272 let path = std::env::var_os("ANTITHESIS_SDK_LOCAL_OUTPUT").unwrap();
273 let path = Path::new(&path);
274
275 early_hit();
276 assert_output(path, &expected[..2]);
277
278 let old = config_with_flag(false);
279 register_reachability_for_config(&old);
280 register_reachability_for_config(&old);
281 assert_output(path, &expected[..3]);
282
283 legacy();
284 legacy();
285 assert_output(path, &expected[..4]);
286
287 let new = config_with_flag(true);
288 register_reachability_for_config(&new);
289 register_reachability_for_config(&new);
290 assert_output(path, &expected[..5]);
291
292 upgraded();
293 upgraded();
294 early_hit();
295 register_reachability_for_config(&old);
296 assert_output(path, &expected);
297 }
298}