diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index 4a195513..12d889c4 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -20,6 +20,8 @@ use smite::noise::{ConnectionError, NoiseConnection}; use smite::oracles::{AcceptChannelContext, AcceptChannelOracle, Oracle}; use smite::pending_channel::PendingChannel; use smite::violation::Violation; + +use super::targets::TargetRpc; use smite_ir::operation::AcceptChannelField; use smite_ir::{Operation, Program, Variable}; use std::collections::{HashMap, HashSet}; @@ -228,11 +230,13 @@ pub enum ExecuteError { } /// Executes IR programs against a target over an established connection. -pub struct Executor { +pub struct Executor { /// Connection used to send and receive Lightning messages. conn: C, /// Interface to bitcoind for wallet and chain operations. bitcoin_cli: B, + /// Interface for interacting with the target node through RPC. + rpc: R, /// Immutable state captured during snapshot setup. context: ProgramContext, /// Channel states maintained implicitly across program execution, keyed by @@ -258,13 +262,15 @@ pub struct Executor { mined_txids: HashSet, } -impl Executor { - /// Creates an executor with the given connection, bitcoin-cli handle, and - /// program context. Channel state and negotiations start empty. - pub fn new(conn: C, bitcoin_cli: B, context: ProgramContext) -> Self { +impl Executor { + /// Creates an executor with the given connection, bitcoin-cli handle, + /// program context, and target RPC handle. Channel state and negotiations + /// start empty. + pub fn new(conn: C, bitcoin_cli: B, rpc: R, context: ProgramContext) -> Self { Self { conn, bitcoin_cli, + rpc, context, channel_states: HashMap::new(), negotiations: HashMap::new(), @@ -520,6 +526,7 @@ impl Executor { .map(|(_, hex)| hex) .collect(); self.bitcoin_cli.mine_blocks(*v, &private_mempool); + self.rpc.chain_sync(); self.mined_txids.extend(self.unmined_txids.drain()); log::debug!("[{:?}] MineBlocks: mined {} block(s)", start.elapsed(), v); None @@ -1556,6 +1563,19 @@ mod tests { } } + // Mocking TargetRpc via MockTargetRpc + + #[derive(Default)] + struct MockTargetRpc { + chain_syncs: usize, + } + + impl TargetRpc for MockTargetRpc { + fn chain_sync(&mut self) { + self.chain_syncs += 1; + } + } + // -- Helpers -- fn sample_pubkey(byte: u8) -> PublicKey { @@ -1860,6 +1880,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor @@ -1944,6 +1965,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor @@ -2012,6 +2034,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor @@ -2104,6 +2127,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor @@ -2215,6 +2239,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor @@ -2308,6 +2333,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor @@ -2357,6 +2383,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor @@ -2419,6 +2446,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor.conn.queue_recv(ac_bytes); @@ -2444,6 +2472,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor.conn.queue_recv(init_bytes); @@ -2477,6 +2506,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor.conn.queue_recv(error_bytes); @@ -2509,6 +2539,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor.conn.queue_recv(ping_bytes); @@ -2549,6 +2580,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor.conn.queue_recv(gossip_bytes); @@ -2585,6 +2617,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor.conn.queue_recv(ac_bytes); @@ -2625,6 +2658,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor.conn.queue_recv(ac_bytes); @@ -2667,6 +2701,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor.conn.queue_recv(ac_bytes); @@ -2713,6 +2748,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor.conn.queue_recv(ac_bytes.clone()); @@ -2765,6 +2801,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor @@ -2814,7 +2851,12 @@ mod tests { instrs.push(instr); } - let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + let mut executor = Executor::new( + MockConnection::new(), + mock_cli, + MockTargetRpc::default(), + sample_context(), + ); executor .negotiations .insert(temporary_channel_id, sample_funding_negotiation()); @@ -2847,6 +2889,7 @@ mod tests { let _ = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ) .execute(&program, std::time::Instant::now()); @@ -2870,6 +2913,7 @@ mod tests { let _ = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ) .execute(&program, std::time::Instant::now()); @@ -2887,6 +2931,7 @@ mod tests { let _ = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ) .execute(&program, std::time::Instant::now()); @@ -2910,6 +2955,7 @@ mod tests { let _ = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ) .execute(&program, std::time::Instant::now()); @@ -2934,6 +2980,7 @@ mod tests { let _ = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ) .execute(&program, std::time::Instant::now()); @@ -2957,6 +3004,7 @@ mod tests { let _ = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ) .execute(&program, std::time::Instant::now()); @@ -2983,6 +3031,7 @@ mod tests { let _ = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ) .execute(&program, std::time::Instant::now()); @@ -3010,6 +3059,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor.conn.queue_recv(ac_bytes); @@ -3029,6 +3079,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor @@ -3038,6 +3089,7 @@ mod tests { // Verify that mine_blocks was called with the correct number assert_eq!(executor.bitcoin_cli.mine_blocks_calls, vec![6]); assert!(executor.bitcoin_cli.mined_private_mempool.is_empty()); + assert_eq!(executor.rpc.chain_syncs, 1); } #[test] @@ -3059,6 +3111,7 @@ mod tests { let _ = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ) .execute(&program, std::time::Instant::now()); @@ -3071,7 +3124,12 @@ mod tests { change_spk: sample_change_spk(), ..Default::default() }; - let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + let mut executor = Executor::new( + MockConnection::new(), + mock_cli, + MockTargetRpc::default(), + sample_context(), + ); executor .execute( &Program { @@ -3087,6 +3145,7 @@ mod tests { broadcast_tx.compute_txid().to_string(), "09b0549b35f14ee862f63bd75811c6c27963c4dea6766ec6836952ec78df1e7e" ); + assert_eq!(executor.rpc.chain_syncs, 0); } // LookupShortChannelId should combine the confirmed block position with @@ -3114,7 +3173,12 @@ mod tests { // Build and send a channel_announcement carrying the looked-up SCID. instrs.extend(channel_announcement_from_scid_instructions(instrs.len(), 9)); - let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + let mut executor = Executor::new( + MockConnection::new(), + mock_cli, + MockTargetRpc::default(), + sample_context(), + ); executor .execute( &Program { @@ -3190,7 +3254,12 @@ mod tests { ]; instrs.extend(channel_announcement_from_scid_instructions(instrs.len(), 7)); - let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + let mut executor = Executor::new( + MockConnection::new(), + mock_cli, + MockTargetRpc::default(), + sample_context(), + ); executor .execute( &Program { @@ -3233,7 +3302,12 @@ mod tests { inputs: vec![], }); - let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + let mut executor = Executor::new( + MockConnection::new(), + mock_cli, + MockTargetRpc::default(), + sample_context(), + ); executor .execute( &Program { @@ -3270,7 +3344,13 @@ mod tests { change_spk: sample_change_spk(), ..Default::default() }; - let err = Executor::new(MockConnection::new(), mock_cli, sample_context()) + let mut executor = Executor::new( + MockConnection::new(), + mock_cli, + MockTargetRpc::default(), + sample_context(), + ); + let err = executor .execute( &Program { instructions: create_and_broadcast_tx_instructions(), @@ -3384,7 +3464,12 @@ mod tests { }) .encode(); - let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + let mut executor = Executor::new( + MockConnection::new(), + mock_cli, + MockTargetRpc::default(), + sample_context(), + ); executor.conn.queue_recv(fs_bytes); executor .negotiations @@ -3432,6 +3517,7 @@ mod tests { .get(&ChannelId::new([0xbb; 32])) .unwrap(); assert!(pending.funding_built); + assert_eq!(executor.rpc.chain_syncs, 0); } #[test] @@ -3464,7 +3550,12 @@ mod tests { let mut instrs = send_funding_created_and_recv_funding_signed_instructions(); instrs[9].inputs[1] = 2; - let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + let mut executor = Executor::new( + MockConnection::new(), + mock_cli, + MockTargetRpc::default(), + sample_context(), + ); executor.conn.queue_recv(fs_bytes); executor .negotiations @@ -3542,7 +3633,12 @@ mod tests { }, ]); - let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + let mut executor = Executor::new( + MockConnection::new(), + mock_cli, + MockTargetRpc::default(), + sample_context(), + ); executor .negotiations .insert(ChannelId::new([0xbb; 32]), sample_funding_negotiation()); @@ -3572,7 +3668,12 @@ mod tests { change_spk: sample_change_spk(), ..Default::default() }; - let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + let mut executor = Executor::new( + MockConnection::new(), + mock_cli, + MockTargetRpc::default(), + sample_context(), + ); executor .negotiations .insert(ChannelId::new([0xbb; 32]), negotiation); @@ -3601,7 +3702,12 @@ mod tests { change_spk: sample_change_spk(), ..Default::default() }; - let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + let mut executor = Executor::new( + MockConnection::new(), + mock_cli, + MockTargetRpc::default(), + sample_context(), + ); executor .negotiations .insert(ChannelId::new([0xbb; 32]), negotiation); @@ -3632,7 +3738,12 @@ mod tests { let mut instrs = send_funding_created_and_recv_funding_signed_instructions(); instrs.pop(); // Drop the trailing `RecvFundingSigned` instruction. - let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + let mut executor = Executor::new( + MockConnection::new(), + mock_cli, + MockTargetRpc::default(), + sample_context(), + ); executor .execute( &Program { @@ -3671,7 +3782,12 @@ mod tests { let mut instrs = send_funding_created_and_recv_funding_signed_instructions(); instrs.pop(); // Drop the trailing `RecvFundingSigned` instruction. - let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + let mut executor = Executor::new( + MockConnection::new(), + mock_cli, + MockTargetRpc::default(), + sample_context(), + ); executor .negotiations .insert(ChannelId::new([0xbb; 32]), negotiation); @@ -3716,7 +3832,12 @@ mod tests { }) .encode(); - let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + let mut executor = Executor::new( + MockConnection::new(), + mock_cli, + MockTargetRpc::default(), + sample_context(), + ); executor.conn.queue_recv(fs_bytes); executor .negotiations @@ -3756,7 +3877,12 @@ mod tests { }) .encode(); - let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + let mut executor = Executor::new( + MockConnection::new(), + mock_cli, + MockTargetRpc::default(), + sample_context(), + ); executor.conn.queue_recv(fs_bytes); executor .negotiations @@ -3823,7 +3949,12 @@ mod tests { signature: "304402203dbf3dbf337b042a72576488c1fb019086089d8d790a47f652346cff2511b6e70220395fdf700cb82b0abfcfe8e0b7c822181f2ee72409c82c3ff8e04e36593662c7".parse().unwrap(), }) .encode(); - let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + let mut executor = Executor::new( + MockConnection::new(), + mock_cli, + MockTargetRpc::default(), + sample_context(), + ); executor.conn.queue_recv(fs_bytes); executor .negotiations @@ -3896,6 +4027,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor @@ -3936,6 +4068,7 @@ mod tests { let mut executor = Executor::new( MockConnection::new(), MockBitcoinCli::default(), + MockTargetRpc::default(), sample_context(), ); executor @@ -3952,7 +4085,7 @@ mod tests { } fn recv_channel_ready_executor() -> ( - Executor, + Executor, ChannelId, PublicKey, ) { @@ -3986,7 +4119,12 @@ mod tests { }) .encode(); - let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + let mut executor = Executor::new( + MockConnection::new(), + mock_cli, + MockTargetRpc::default(), + sample_context(), + ); executor.conn.queue_recv(fs_bytes); executor.conn.queue_recv(cr_bytes); executor diff --git a/smite-scenarios/src/scenarios/ir.rs b/smite-scenarios/src/scenarios/ir.rs index 9c56a383..265b165b 100644 --- a/smite-scenarios/src/scenarios/ir.rs +++ b/smite-scenarios/src/scenarios/ir.rs @@ -21,10 +21,10 @@ use crate::targets::Target; /// (out-of-bounds variable refs, type mismatches, `MineBlocks(0)`, etc.). pub struct IrScenario> { target: T, - /// Executes IR programs and owns the connection, bitcoin-cli handle, and - /// program context. Created once before the snapshot and reused across - /// fuzzing runs. - executor: Executor, + /// Executes IR programs and owns the connection, bitcoin-cli handle, + /// program context, and the target's RPC handle. Created once before the + /// snapshot and reused across fuzzing runs. + executor: Executor, // S is only used for static dispatch on S::setup(), not stored. _phantom: PhantomData, } @@ -34,7 +34,7 @@ impl> Scenario for IrScenario { let target = T::start(T::Config::default())?; let (conn, context) = S::setup(&target)?; let bitcoin_cli = target.bitcoin_cli().clone(); - let executor = Executor::new(conn, bitcoin_cli, context); + let executor = Executor::new(conn, bitcoin_cli, target.rpc(), context); Ok(Self { target, executor, diff --git a/smite-scenarios/src/targets.rs b/smite-scenarios/src/targets.rs index 6ee35c9c..e7e3119b 100644 --- a/smite-scenarios/src/targets.rs +++ b/smite-scenarios/src/targets.rs @@ -7,10 +7,10 @@ mod ldk; mod lnd; pub use bitcoind::INITIAL_BLOCKS; -pub use cln::{ClnConfig, ClnTarget}; -pub use eclair::{EclairConfig, EclairTarget}; -pub use ldk::{LdkConfig, LdkTarget}; -pub use lnd::{LndConfig, LndTarget}; +pub use cln::{ClnConfig, ClnRpc, ClnTarget}; +pub use eclair::{EclairConfig, EclairRpc, EclairTarget}; +pub use ldk::{LdkConfig, LdkRpc, LdkTarget}; +pub use lnd::{LndConfig, LndRpc, LndTarget}; use smite::bitcoin::BitcoinCli; use smite::scenarios::TargetError; @@ -42,6 +42,13 @@ pub fn check_crash_log() -> Result<(), TargetError> { Ok(()) } +/// Abstraction over target RPC operations for executing commands on a running +/// target, allowing target-specific implementations. +pub trait TargetRpc { + /// Notifies the target of newly mined blocks so it updates its chain view. + fn chain_sync(&mut self); +} + /// A Lightning implementation that can be fuzzed. /// /// This trait abstracts over different Lightning implementations (LND, CLN, LDK, etc.), @@ -50,6 +57,9 @@ pub trait Target: Sized { /// Configuration for this target. type Config: Default; + /// RPC handle for this target. + type Rpc: TargetRpc; + /// Start the target and any dependencies (e.g., bitcoind). /// /// # Errors @@ -63,6 +73,9 @@ pub trait Target: Sized { /// Target's P2P listen address. fn addr(&self) -> SocketAddr; + /// Target's RPC handle for executing commands. + fn rpc(&self) -> Self::Rpc; + /// `bitcoin-cli` wrapper for the regtest `bitcoind` instance. fn bitcoin_cli(&self) -> &BitcoinCli; diff --git a/smite-scenarios/src/targets/cln.rs b/smite-scenarios/src/targets/cln.rs index 0d37ee04..d7ac5c61 100644 --- a/smite-scenarios/src/targets/cln.rs +++ b/smite-scenarios/src/targets/cln.rs @@ -9,7 +9,9 @@ //! This means checking lightningd's liveness is sufficient for crash detection. use std::fs; +use std::io; use std::net::SocketAddr; +use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::Duration; @@ -20,7 +22,7 @@ use smite::bitcoin::BitcoinCli; use smite::process::ManagedProcess; use super::bitcoind; -use super::{Target, TargetError, check_crash_log}; +use super::{Target, TargetError, TargetRpc, check_crash_log}; /// Configuration for the CLN target. pub struct ClnConfig { @@ -43,19 +45,87 @@ impl Default for ClnConfig { } impl ClnConfig { - fn bitcoind_config(&self, data_dir: &Path) -> bitcoind::BitcoindConfig { + fn bitcoind_config(&self) -> bitcoind::BitcoindConfig { bitcoind::BitcoindConfig { rpc_port: self.bitcoind_rpc_port, p2p_port: self.bitcoind_p2p_port, - extra_args: vec![format!( - "-blocknotify=lightning-cli --lightning-dir='{}' --network=regtest syncblocks", - data_dir.join("cln").display() - )], ..bitcoind::BitcoindConfig::default() } } } +/// RPC handle for interacting with CLN node target. +#[derive(Debug, Clone)] +pub struct ClnRpc { + /// Path to the CLN node's unix RPC socket. + pub rpc_socket: PathBuf, +} + +impl ClnRpc { + // Bound RPC socket I/O so a stalled lightningd cannot block indefinitely. + const RPC_IO_TIMEOUT: Duration = Duration::from_secs(1); + + /// Sends a JSON-RPC request to CLN over its Unix RPC socket and returns the + /// `result` of the response. + /// + /// # Errors + /// + /// Returns an [`io::Error`] only if the RPC socket cannot be connected to, + /// which means CLN already crashed. That is a symptom of an earlier crash + /// rather than a fault in the call. + /// + /// # Panics + /// + /// If the request cannot be written, the response cannot be read or parsed, + /// or CLN answers with a JSON-RPC `error` object. + fn run(&self, method: &str, params: impl serde::Serialize) -> io::Result { + let mut sock = UnixStream::connect(&self.rpc_socket)?; + sock.set_read_timeout(Some(Self::RPC_IO_TIMEOUT)) + .expect("valid timeout"); + sock.set_write_timeout(Some(Self::RPC_IO_TIMEOUT)) + .expect("valid timeout"); + + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "smite", + "method": method, + "params": params, + }); + serde_json::to_writer(&mut sock, &request) + .unwrap_or_else(|e| panic!("failed to send {method} to lightningd: {e}")); + + let mut response: serde_json::Value = serde_json::Deserializer::from_reader(&mut sock) + .into_iter() + .next() + .unwrap_or_else(|| panic!("lightningd closed the socket without answering {method}")) + .unwrap_or_else(|e| panic!("failed to read {method} response from lightningd: {e}")); + assert!( + response.get("error").is_none(), + "lightningd rejected {method}: {}", + response["error"] + ); + + Ok(response["result"].take()) + } +} + +impl TargetRpc for ClnRpc { + /// RPC to make CLN poll for new blocks immediately instead of waiting for + /// its regular poll interval, allowing it to sync faster. + /// + /// # Panics + /// + /// - If lightningd answers with an error, which means the call itself is at + /// fault rather than the target having crashed. + fn chain_sync(&mut self) { + if let Err(e) = self.run("syncblocks", serde_json::json!({})) { + // lightningd is unreachable, indicating that CLN has already + // crashed, check_alive will report the crash at the end. + log::warn!("syncblocks could not reach lightningd: {e}"); + } + } +} + /// CLN (Core Lightning) node target. /// /// Field order matters: `cln` is declared before `bitcoind` so it drops first, @@ -207,12 +277,12 @@ impl Drop for ClnTarget { impl Target for ClnTarget { type Config = ClnConfig; + type Rpc = ClnRpc; fn start(config: Self::Config) -> Result { let (data_path, temp_dir) = bitcoind::resolve_data_dir()?; - let bitcoind_config = config.bitcoind_config(&data_path); - let (bitcoind, bitcoin_cli) = bitcoind::start(&bitcoind_config, &data_path)?; + let (bitcoind, bitcoin_cli) = bitcoind::start(&config.bitcoind_config(), &data_path)?; let (cln, pubkey, cln_dir) = Self::start_cln(&config, &data_path)?; let addr = SocketAddr::from(([127, 0, 0, 1], config.cln_p2p_port)); @@ -237,6 +307,12 @@ impl Target for ClnTarget { self.addr } + fn rpc(&self) -> Self::Rpc { + ClnRpc { + rpc_socket: self.cln_dir.join("regtest").join("lightning-rpc"), + } + } + fn bitcoin_cli(&self) -> &BitcoinCli { &self.bitcoin_cli } diff --git a/smite-scenarios/src/targets/eclair.rs b/smite-scenarios/src/targets/eclair.rs index c017105a..40ee3f38 100644 --- a/smite-scenarios/src/targets/eclair.rs +++ b/smite-scenarios/src/targets/eclair.rs @@ -16,7 +16,7 @@ use smite::bitcoin::BitcoinCli; use smite::process::ManagedProcess; use super::bitcoind; -use super::{Target, TargetError, check_crash_log}; +use super::{Target, TargetError, TargetRpc, check_crash_log}; /// API password for Eclair's REST API. const API_PASSWORD: &str = "fuzzpass"; @@ -64,6 +64,16 @@ impl EclairConfig { } } +/// RPC handle for interacting with eclair node target. +#[derive(Debug, Clone)] +pub struct EclairRpc; + +impl TargetRpc for EclairRpc { + /// Eclair receives new blocks directly from bitcoind over ZMQ, so no manual + /// chain synchronization is required. + fn chain_sync(&mut self) {} +} + /// Eclair Lightning node target. /// /// Field order matters: `eclair` is declared before `bitcoind` so it drops first, @@ -197,6 +207,7 @@ impl EclairTarget { impl Target for EclairTarget { type Config = EclairConfig; + type Rpc = EclairRpc; fn start(config: Self::Config) -> Result { let (data_path, temp_dir) = bitcoind::resolve_data_dir()?; @@ -225,6 +236,10 @@ impl Target for EclairTarget { self.addr } + fn rpc(&self) -> Self::Rpc { + EclairRpc + } + fn bitcoin_cli(&self) -> &BitcoinCli { &self.bitcoin_cli } diff --git a/smite-scenarios/src/targets/ldk.rs b/smite-scenarios/src/targets/ldk.rs index 6d168740..b2e4bb6d 100644 --- a/smite-scenarios/src/targets/ldk.rs +++ b/smite-scenarios/src/targets/ldk.rs @@ -11,10 +11,10 @@ use std::process::{Command, Stdio}; use bitcoin::secp256k1; use smite::bitcoin::BitcoinCli; -use smite::process::ManagedProcess; +use smite::process::{ManagedProcess, send_sigusr1}; use super::bitcoind; -use super::{Target, TargetError, check_crash_log}; +use super::{Target, TargetError, TargetRpc, check_crash_log}; /// Configuration for the LDK target. pub struct LdkConfig { @@ -41,14 +41,41 @@ impl LdkConfig { bitcoind::BitcoindConfig { rpc_port: self.bitcoind_rpc_port, p2p_port: self.bitcoind_p2p_port, - // signals the wrapper (SIGUSR1) to sync on each new block instead - // of waiting for the next poll. - extra_args: vec!["-blocknotify=pkill -USR1 -f ^ldk-node-wrapper".to_string()], ..bitcoind::BitcoindConfig::default() } } } +/// RPC handle for interacting with LDK node target. +/// +/// LDK currently has no RPC socket, so commands are delivered through signals +/// that invoke the corresponding APIs directly. +#[derive(Debug, Clone)] +pub struct LdkRpc { + /// PID of the LDK node's wrapper process, which receives the signals. + pid: u32, +} + +impl TargetRpc for LdkRpc { + /// Signals the wrapper (SIGUSR1) to sync on new blocks immediately instead + /// of waiting for its regular poll interval, allowing it to sync faster. + /// + /// # Panics + /// + /// Panics if the signal cannot be sent, which means the call itself is at + /// fault rather than the target having crashed. + fn chain_sync(&mut self) { + // A crashed wrapper remains an unreaped zombie, so the signal is sent + // but discarded. check_alive will report the crash at the end. + if let Err(e) = send_sigusr1(self.pid) { + panic!( + "failed to send SIGUSR1 to ldk-node-wrapper (pid {}): {e}", + self.pid + ); + } + } +} + /// LDK Lightning node target. /// /// Field order matters: `ldk` is declared before `bitcoind` so it drops first, @@ -90,28 +117,6 @@ impl LdkTarget { cmd.env("LD_PRELOAD", handler); } - // Ignore SIGUSR1 for the window between exec and the wrapper blocking - // it. Initial-block generation triggers a burst of asynchronous - // `-blocknotify` (`pkill -USR1`), and a stray one landing in that window - // would kill the wrapper, since SIGUSR1 is fatal by default. SIG_IGN - // survives exec; a caught handler would not. - // - // Signals arriving while SIG_IGN is in effect are dropped, but that ends - // once the wrapper calls `pthread_sigmask(SIG_BLOCK)`: blocking wins over - // the disposition, so SIGUSR1 then stays pending for `sigwait()` instead - // of being discarded. SIG_IGN therefore stays in effect for the whole run - // and the wrapper never needs to replace it. - // - // SAFETY: runs in the child after fork, before exec; calls only the - // async-signal-safe `signal`. - unsafe { - use std::os::unix::process::CommandExt; - cmd.pre_exec(|| { - libc::signal(libc::SIGUSR1, libc::SIG_IGN); - Ok(()) - }); - } - let mut ldk = ManagedProcess::spawn(&mut cmd, "ldk-node-wrapper")?; // Parse pubkey from stdout. The wrapper prints: @@ -150,6 +155,7 @@ impl LdkTarget { impl Target for LdkTarget { type Config = LdkConfig; + type Rpc = LdkRpc; fn start(config: Self::Config) -> Result { let (data_path, temp_dir) = bitcoind::resolve_data_dir()?; @@ -178,6 +184,12 @@ impl Target for LdkTarget { self.addr } + fn rpc(&self) -> Self::Rpc { + LdkRpc { + pid: self.ldk.pid(), + } + } + fn bitcoin_cli(&self) -> &BitcoinCli { &self.bitcoin_cli } diff --git a/smite-scenarios/src/targets/lnd.rs b/smite-scenarios/src/targets/lnd.rs index f42f2a0c..bf257f0a 100644 --- a/smite-scenarios/src/targets/lnd.rs +++ b/smite-scenarios/src/targets/lnd.rs @@ -14,7 +14,7 @@ use smite::bitcoin::BitcoinCli; use smite::process::ManagedProcess; use super::bitcoind; -use super::{Target, TargetError}; +use super::{Target, TargetError, TargetRpc}; /// Configuration for the LND target. pub struct LndConfig { @@ -81,6 +81,16 @@ impl CoveragePipes { } } +/// RPC handle for interacting with LND node target. +#[derive(Debug, Clone)] +pub struct LndRpc; + +impl TargetRpc for LndRpc { + /// LND receives new blocks directly from bitcoind over ZMQ, so no manual + /// chain synchronization is required. + fn chain_sync(&mut self) {} +} + /// LND Lightning node target. /// /// Field order matters: `lnd` is declared before `bitcoind` so it drops first, @@ -271,6 +281,7 @@ impl LndTarget { impl Target for LndTarget { type Config = LndConfig; + type Rpc = LndRpc; fn start(config: Self::Config) -> Result { let (data_path, temp_dir) = bitcoind::resolve_data_dir()?; @@ -300,6 +311,10 @@ impl Target for LndTarget { self.addr } + fn rpc(&self) -> Self::Rpc { + LndRpc + } + fn bitcoin_cli(&self) -> &BitcoinCli { &self.bitcoin_cli } diff --git a/smite/src/process.rs b/smite/src/process.rs index 52e30617..276176b3 100644 --- a/smite/src/process.rs +++ b/smite/src/process.rs @@ -8,7 +8,7 @@ use std::os::unix::process::CommandExt; use std::process::{Child, Command, ExitStatus}; use std::time::{Duration, Instant}; -use nix::sys::signal::{Signal, killpg}; +use nix::sys::signal::{Signal, kill, killpg}; use nix::unistd::Pid; /// A managed subprocess with graceful shutdown support. @@ -153,6 +153,19 @@ impl Drop for ManagedProcess { } } +/// Sends SIGUSR1 to the process with the given `pid`. +/// +/// # Errors +/// +/// Returns an error if `pid` exceeds `i32::MAX` or if sending the signal fails. +pub fn send_sigusr1(pid: u32) -> io::Result<()> { + let pid = i32::try_from(pid) + .map(Pid::from_raw) + .map_err(|_| io::Error::other("pid exceeds i32::MAX"))?; + + kill(pid, Signal::SIGUSR1).map_err(Into::into) +} + #[cfg(test)] mod tests { use super::*; diff --git a/workloads/ldk/src/main.rs b/workloads/ldk/src/main.rs index e179e840..b82cbd9e 100644 --- a/workloads/ldk/src/main.rs +++ b/workloads/ldk/src/main.rs @@ -32,12 +32,12 @@ fn install_panic_hook() { /// with `sigwait()`. /// /// Blocking supersedes the disposition inherited across exec, which for SIGUSR1 -/// is `SIG_IGN` (set by the scenario's pre-exec hook, see `LdkTarget::start`). -/// That distinction is the whole point: an *ignored* signal is discarded the -/// moment it is delivered, while a *blocked* one stays pending until `sigwait()` -/// consumes it, regardless of its disposition. So this call is what makes -/// bitcoind's `-blocknotify` SIGUSR1 observable, and why nothing here calls -/// `sigaction`: the wait loop below is the only consumer these signals need. +/// defaults to terminating the process. That distinction is the whole point: an +/// *ignored* signal is discarded the moment it is delivered, while a *blocked* +/// one stays pending until `sigwait()` consumes it, regardless of its +/// disposition. So this call is what makes target's SIGUSR1 observable, and +/// why nothing here calls `sigaction`: the wait loop below is the only consumer +/// these signals need. /// /// Standard signals do not queue, so a burst of SIGUSR1 collapses into one /// pending instance and thus one wakeup. That is fine here: each wakeup syncs @@ -64,7 +64,7 @@ fn setup_signal_set() -> libc::sigset_t { fn main() { install_panic_hook(); - // bitcoind's -blocknotify sends SIGUSR1 for each new block. + // The target sends SIGUSR1 to request an immediate chain sync. // SIGTERM/SIGINT are used for graceful shutdown. let signal_set = setup_signal_set(); @@ -114,7 +114,7 @@ fn main() { println!("READY"); // Wait for signals. sigwait() blocks here without polling and returns - // immediately when bitcoind sends SIGUSR1 or the process receives + // immediately when the target sends SIGUSR1 or the process receives // SIGTERM/SIGINT. loop { let mut signal = 0; @@ -126,13 +126,13 @@ fn main() { match signal { libc::SIGUSR1 => { - // Sync the wallet whenever bitcoind signals a new block. + // Sync the wallet whenever the target asks for it. // ldk-node's own 2s background poll keeps running, so this is // technically redundant and may race it, but that's safe: // ldk-node coalesces concurrent syncs, so whichever loses the // race just waits on the in-flight sync's result rather than // applying the block twice. We accept the redundancy to sync on - // the block instead of up to 2s later. + // demand instead of up to 2s later. if let Err(e) = node.sync_wallets() { eprintln!("sync_wallets failed: {e}"); }