Skip to main content

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}