cuprate_test_utils/
monerod.rs

1//! Monerod Module
2//!
3//! This module contains a function [`monerod`] to start `monerod` - the core Monero node. Cuprate can then use
4//! this to test compatibility with monerod.
5//!
6use std::{
7    env::current_dir,
8    ffi::OsStr,
9    fs::read_dir,
10    io::Read,
11    net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener},
12    path::PathBuf,
13    process::{Child, Command, Stdio},
14    str::from_utf8,
15    thread::panicking,
16    time::Duration,
17};
18
19use tokio::{task::yield_now, time::timeout};
20
21/// IPv4 local host.
22const LOCALHOST: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
23
24/// The log line `monerod` emits indicated it has successfully started up.
25const MONEROD_STARTUP_TEXT: &str =
26    "The daemon will start synchronizing with the network. This may take a long time to complete.";
27
28/// The log line `monerod` emits indicated it has stopped.
29const MONEROD_SHUTDOWN_TEXT: &str = "Stopping cryptonote protocol";
30
31/// Spawns monerod and returns [`SpawnedMoneroD`].
32///
33/// This function will set `regtest` and the P2P/ RPC ports so these can't be included in the flags.
34pub async fn monerod<T: AsRef<OsStr>>(flags: impl IntoIterator<Item = T>) -> SpawnedMoneroD {
35    let path_to_monerod = find_root().join("monerod");
36
37    let rpc_port = get_available_port(&[]);
38    let p2p_port = get_available_port(&[rpc_port]);
39    let zmq_port = get_available_port(&[rpc_port, p2p_port]);
40
41    let data_dir = tempfile::tempdir().unwrap();
42
43    let mut monerod = Command::new(path_to_monerod)
44        .stdout(Stdio::piped())
45        .stderr(Stdio::piped())
46        .args(flags)
47        .arg("--regtest")
48        .arg("--log-level=2")
49        .arg(format!("--p2p-bind-port={p2p_port}"))
50        .arg(format!("--rpc-bind-port={rpc_port}"))
51        .arg(format!("--zmq-rpc-bind-port={zmq_port}"))
52        .arg(format!("--data-dir={}", data_dir.path().display()))
53        .arg("--non-interactive")
54        .spawn()
55        .expect(
56            "Failed to start monerod, you need to have the monerod binary in the root of the repo",
57        );
58
59    let mut logs = String::new();
60
61    timeout(Duration::from_secs(30), async {
62        loop {
63            let mut next_str = [0];
64            let _ = monerod
65                .stdout
66                .as_mut()
67                .unwrap()
68                .read(&mut next_str)
69                .unwrap();
70
71            logs.push_str(from_utf8(&next_str).unwrap());
72
73            if logs.contains(MONEROD_SHUTDOWN_TEXT) {
74                panic!("Failed to start monerod, logs: \n {logs}");
75            } else if logs.contains(MONEROD_STARTUP_TEXT) {
76                break;
77            }
78            // this is blocking code but as this is for tests performance isn't a priority. However we should still yield so
79            // the timeout works.
80            yield_now().await;
81        }
82    })
83    .await
84    .unwrap_or_else(|_| panic!("Failed to start monerod in time, logs: {logs}"));
85
86    SpawnedMoneroD {
87        process: monerod,
88        rpc_port,
89        p2p_port,
90        _data_dir: data_dir,
91        start_up_logs: logs,
92    }
93}
94
95/// Finds the root of the repo by finding the `target` directory, this will work up from the current
96/// directory until it finds a `target` directory, then returns the directory that the target is contained
97/// in.
98fn find_root() -> PathBuf {
99    let mut current_dir = current_dir().unwrap();
100    loop {
101        if read_dir(current_dir.join("target")).is_ok() {
102            return current_dir;
103        } else if !current_dir.pop() {
104            panic!("Could not find ./target");
105        }
106    }
107}
108
109/// Fetch an available TCP port on the machine for `monerod` to bind to.
110fn get_available_port(already_taken: &[u16]) -> u16 {
111    loop {
112        // Using `0` makes the OS return a random available port.
113        let port = TcpListener::bind("127.0.0.1:0")
114            .unwrap()
115            .local_addr()
116            .unwrap()
117            .port();
118
119        if !already_taken.contains(&port) {
120            return port;
121        }
122    }
123}
124
125/// A struct representing a spawned monerod.
126pub struct SpawnedMoneroD {
127    /// A handle to the monerod process, monerod will be stopped when this is dropped.
128    process: Child,
129    /// The RPC port of the monerod instance.
130    rpc_port: u16,
131    /// The P2P port of the monerod instance.
132    p2p_port: u16,
133    /// The data dir for monerod - when this is dropped the dir will be deleted.
134    _data_dir: tempfile::TempDir,
135    /// The logs upto [`MONEROD_STARTUP_TEXT`].
136    start_up_logs: String,
137}
138
139impl SpawnedMoneroD {
140    /// Returns the p2p port of the spawned monerod
141    pub const fn p2p_addr(&self) -> SocketAddr {
142        SocketAddr::new(LOCALHOST, self.p2p_port)
143    }
144
145    /// Returns the RPC port of the spawned monerod
146    pub const fn rpc_port(&self) -> SocketAddr {
147        SocketAddr::new(LOCALHOST, self.rpc_port)
148    }
149}
150
151impl Drop for SpawnedMoneroD {
152    fn drop(&mut self) {
153        let mut error = false;
154
155        if self.process.kill().is_err() {
156            error = true;
157            println!("Failed to kill monerod, process id: {}", self.process.id());
158        }
159
160        if panicking() {
161            // If we are panicking then a test failed so print monerod's logs.
162
163            let mut out = String::new();
164
165            if self
166                .process
167                .stdout
168                .as_mut()
169                .unwrap()
170                .read_to_string(&mut out)
171                .is_err()
172            {
173                println!("Failed to get monerod's logs.");
174            }
175
176            println!("-----START-MONEROD-LOGS-----");
177            println!("{}{out}", self.start_up_logs);
178            println!("------END-MONEROD-LOGS------");
179        }
180
181        #[expect(clippy::manual_assert, reason = "`if` is more clear")]
182        if error && !panicking() {
183            // `println` only outputs in a test when panicking so if there is an error while
184            // dropping monerod but not an error in the test then we need to panic to make sure
185            // the println!s are output.
186            panic!("Error while dropping monerod");
187        }
188    }
189}