-
Notifications
You must be signed in to change notification settings - Fork 20
smite-scenarios: warm up and freeze Eclair's JIT before the snapshot #161
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,9 @@ | ||
| //! Eclair IR fuzzing scenario binary. | ||
|
|
||
| use smite::scenarios::smite_run; | ||
| use smite_scenarios::scenarios::{IrScenario, PostInitSetup}; | ||
| use smite_scenarios::scenarios::{EclairWarmupSetup, IrScenario}; | ||
| use smite_scenarios::targets::EclairTarget; | ||
|
|
||
| fn main() -> std::process::ExitCode { | ||
| smite_run::<IrScenario<EclairTarget, PostInitSetup>>() | ||
| smite_run::<IrScenario<EclairTarget, EclairWarmupSetup>>() | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,13 +2,15 @@ | |
|
|
||
| use std::time::Duration; | ||
|
|
||
| use smite::bolt::{Init, InitTlvs, Message}; | ||
| use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; | ||
| use smite::bolt::{ChannelId, Init, InitTlvs, Message, OpenChannel, OpenChannelTlvs}; | ||
| use smite::noise::NoiseConnection; | ||
| use smite::scenarios::ScenarioError; | ||
| use smite_ir::operation::ChannelTypeVariant; | ||
|
|
||
| use super::{handshake_with_target, ping_pong}; | ||
| use crate::executor::ProgramContext; | ||
| use crate::targets::{INITIAL_BLOCKS, Target}; | ||
| use crate::targets::{EclairTarget, INITIAL_BLOCKS, Target}; | ||
|
|
||
| /// Bitcoin regtest genesis hash (in BOLT 2 network byte order). | ||
| pub const REGTEST_CHAIN_HASH: [u8; 32] = [ | ||
|
|
@@ -77,22 +79,139 @@ fn init_for_single_funded(received: &Init) -> Init { | |
| } | ||
| } | ||
|
|
||
| /// Fixed feerate (sat/kW) for warmup `open_channel` messages. 2500 sat/kW | ||
| /// (~10 sat/vB) is comfortably inside every target's accepted range. | ||
| const WARMUP_FEERATE_PER_KW: u32 = 2500; | ||
|
|
||
| /// Fixed funding amount (sat) for warmup `open_channel` messages. | ||
| const WARMUP_FUNDING_SATOSHIS: u64 = 100_000; | ||
|
|
||
| /// Derives the six public keys an `open_channel` requires from fixed secrets. | ||
| fn warmup_channel_keys() -> [PublicKey; 6] { | ||
| let secp = Secp256k1::new(); | ||
| let secrets: [[u8; 32]; 6] = [ | ||
| [0x21; 32], [0x22; 32], [0x23; 32], [0x24; 32], [0x25; 32], [0x26; 32], | ||
| ]; | ||
| secrets.map(|s| { | ||
| let sk = SecretKey::from_slice(&s).expect("valid warmup secret"); | ||
| PublicKey::from_secret_key(&secp, &sk) | ||
| }) | ||
| } | ||
|
|
||
| /// Builds a spec-valid single-funded `open_channel` for warmup traffic. | ||
| /// | ||
| /// The parameters are fixed and known-good; only `temporary_channel_id` changes | ||
| /// per iteration. The `channel_type` is `Anchors`, the only type Eclair (the | ||
| /// sole JVM target, and thus the only one that runs warmup) accepts. | ||
| fn warmup_open_channel( | ||
| chain_hash: [u8; 32], | ||
| temporary_channel_id: ChannelId, | ||
| keys: &[PublicKey; 6], | ||
| ) -> OpenChannel { | ||
| OpenChannel { | ||
| chain_hash, | ||
| temporary_channel_id, | ||
| funding_satoshis: WARMUP_FUNDING_SATOSHIS, | ||
| push_msat: 0, | ||
| dust_limit_satoshis: 546, | ||
| max_htlc_value_in_flight_msat: WARMUP_FUNDING_SATOSHIS * 1000, | ||
| channel_reserve_satoshis: 1000, | ||
| htlc_minimum_msat: 1, | ||
| feerate_per_kw: WARMUP_FEERATE_PER_KW, | ||
| to_self_delay: 144, | ||
| max_accepted_htlcs: 483, | ||
| funding_pubkey: keys[0], | ||
| revocation_basepoint: keys[1], | ||
| payment_basepoint: keys[2], | ||
| delayed_payment_basepoint: keys[3], | ||
| htlc_basepoint: keys[4], | ||
| first_per_commitment_point: keys[5], | ||
| channel_flags: 0x00, | ||
| tlvs: OpenChannelTlvs { | ||
| // Always send the TLV: a zero-length value is the BOLT 2 opt-out | ||
| // signal when option_upfront_shutdown_script is negotiated (which | ||
| // Eclair advertises and we echo). Omitting it is a protocol | ||
| // violation that makes Eclair drop the connection. | ||
| upfront_shutdown_script: Some(Vec::new()), | ||
| channel_type: Some(ChannelTypeVariant::Anchors.encode()), | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| /// Derives a distinct, non-zero `temporary_channel_id` for warmup iteration | ||
| /// `seed`. | ||
| /// | ||
| /// Each open within a connection needs a distinct id or Eclair rejects the | ||
| /// duplicate; `seed` provides that. The `0x01` fill also keeps every id | ||
| /// non-zero, avoiding the all-zero channel id BOLT 1 reserves for "fail all | ||
| /// channels". | ||
| fn warmup_temp_channel_id(seed: u64) -> ChannelId { | ||
| let mut bytes = [0x01u8; 32]; | ||
| bytes[..8].copy_from_slice(&seed.to_be_bytes()); | ||
| ChannelId::new(bytes) | ||
| } | ||
|
|
||
| /// Number of `open_channel`s to send per throwaway warmup connection. | ||
| /// | ||
| /// Eclair rate-limits pending (half-open) channels per peer (default 99). Opens | ||
| /// stay pending until the connection drops, so batches stay well under the limit | ||
| /// to keep Eclair accepting (and JIT-compiling) each one. | ||
| const WARMUP_OPENS_PER_CONNECTION: usize = 40; | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: this const should go directly above |
||
|
|
||
| /// Establishes a Noise connection and completes the `init` exchange for the | ||
| /// single-funded `open_channel` flow, returning a connection ready to carry | ||
| /// channel messages along with the target's `Init`. | ||
| fn establish_connection<T: Target>(target: &T) -> Result<(NoiseConnection, Init), ScenarioError> { | ||
| let (mut conn, target_init) = handshake_with_target(target, TIMEOUT)?; | ||
|
|
||
| // Echo features but strip the bits that would take us off the single-funded | ||
| // `open_channel` path this setup is built for. | ||
| let our_init = init_for_single_funded(&target_init); | ||
| conn.send_message(&Message::Init(our_init).encode())?; | ||
|
|
||
| // Drain any post-init noise so the caller starts from a clean connection. | ||
| ping_pong(&mut conn)?; | ||
|
|
||
| Ok((conn, target_init)) | ||
| } | ||
|
|
||
| /// Drives `iterations` `open_channel` -> `accept_channel` exchanges to warm up a | ||
| /// JVM target before the snapshot, so `HotSpot` JIT-compiles the channel path. | ||
| /// | ||
| /// Opens run on throwaway connections, never the snapshot connection: Eclair | ||
| /// rate-limits pending channels per peer, so each connection sends a batch | ||
| /// (<= [`WARMUP_OPENS_PER_CONNECTION`]) then drops, releasing the slots. | ||
| fn warmup<T: Target>( | ||
| target: &T, | ||
| chain_hash: [u8; 32], | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: we can probably drop the |
||
| iterations: usize, | ||
| ) -> Result<(), ScenarioError> { | ||
| log::info!("Warming up target with {iterations} open_channel exchanges"); | ||
| let keys = warmup_channel_keys(); | ||
| for batch_start in (0..iterations).step_by(WARMUP_OPENS_PER_CONNECTION) { | ||
| let (mut conn, _) = establish_connection(target)?; | ||
| let batch_end = (batch_start + WARMUP_OPENS_PER_CONNECTION).min(iterations); | ||
| for seed in batch_start..batch_end { | ||
| let temp_id = warmup_temp_channel_id(seed as u64); | ||
| let open = warmup_open_channel(chain_hash, temp_id, &keys); | ||
| conn.send_message(&Message::OpenChannel(open).encode())?; | ||
| } | ||
| // Sync so Eclair processes (and JIT-compiles) the whole batch before conn | ||
| // drops at the end of this iteration, releasing its pending-channel slots. | ||
| ping_pong(&mut conn)?; | ||
|
Comment on lines
+199
to
+201
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Eclair actually processes
|
||
| } | ||
| log::info!("Warmup complete"); | ||
| Ok(()) | ||
| } | ||
|
|
||
| /// Setup that snapshots just after the Noise handshake and init exchange are | ||
| /// complete. | ||
| pub struct PostInitSetup; | ||
|
|
||
| impl<T: Target> SnapshotSetup<T> for PostInitSetup { | ||
| fn setup(target: &T) -> Result<(NoiseConnection, ProgramContext), ScenarioError> { | ||
| let (mut conn, target_init) = handshake_with_target(target, TIMEOUT)?; | ||
|
|
||
| // Echo features but strip the bits that would take us off the | ||
| // single-funded `open_channel` path this setup is built for. | ||
| let our_init = init_for_single_funded(&target_init); | ||
| conn.send_message(&Message::Init(our_init).encode())?; | ||
|
|
||
| // Drain any remaining post-init noise so the snapshot starts with a | ||
| // clean connection. | ||
| ping_pong(&mut conn)?; | ||
| // Establish the pristine connection the fuzzer reuses across runs. | ||
| let (conn, target_init) = establish_connection(target)?; | ||
|
|
||
| let context = ProgramContext { | ||
| target_pubkey: *target.pubkey(), | ||
|
|
@@ -107,3 +226,33 @@ impl<T: Target> SnapshotSetup<T> for PostInitSetup { | |
| Ok((conn, context)) | ||
| } | ||
| } | ||
|
|
||
| /// Default number of warmup `open_channel` exchanges for [`EclairWarmupSetup`]. | ||
| /// Worth ~25x on the open/accept path (cold ~73 ms `accept_channel` -> ~3 ms); | ||
| /// diminishing returns past ~2000, so 4000 sits on the plateau. Overridable at | ||
| /// runtime via `SMITE_WARMUP_ITERATIONS`. | ||
| const ECLAIR_WARMUP_ITERATIONS: usize = 4000; | ||
|
|
||
| /// [`PostInitSetup`] preceded by a JVM warmup pass; used for Eclair. | ||
| /// | ||
| /// Before the snapshot it drives thousands of `open_channel` exchanges so | ||
| /// `HotSpot` JIT-compiles Eclair's channel path, then freezes the JIT, so every | ||
| /// restored VM starts hot with no compiler threads running during fuzzing. | ||
| pub struct EclairWarmupSetup; | ||
|
|
||
| impl SnapshotSetup<EclairTarget> for EclairWarmupSetup { | ||
| fn setup(target: &EclairTarget) -> Result<(NoiseConnection, ProgramContext), ScenarioError> { | ||
| // `SMITE_WARMUP_ITERATIONS` overrides the default without recompiling. | ||
| let iterations = std::env::var("SMITE_WARMUP_ITERATIONS") | ||
| .ok() | ||
| .and_then(|s| s.parse::<usize>().ok()) | ||
| .unwrap_or(ECLAIR_WARMUP_ITERATIONS); | ||
| if iterations > 0 { | ||
| warmup(target, REGTEST_CHAIN_HASH, iterations)?; | ||
| target.freeze_jit()?; | ||
| } | ||
|
|
||
| // Reuse the generic post-init setup for the snapshot connection + context. | ||
| PostInitSetup::setup(target) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,7 +8,7 @@ use std::fs; | |
| use std::net::SocketAddr; | ||
| use std::path::Path; | ||
| use std::process::{Command, Stdio}; | ||
| use std::time::Duration; | ||
| use std::time::{Duration, Instant}; | ||
|
|
||
| use bitcoin::secp256k1; | ||
| use serde::Deserialize; | ||
|
|
@@ -21,6 +21,18 @@ use super::{Target, TargetError, check_crash_log}; | |
| /// API password for Eclair's REST API. | ||
| const API_PASSWORD: &str = "fuzzpass"; | ||
|
|
||
| /// Upper bound on how long to wait for the JVM's JIT compile queue to drain | ||
| /// after warmup before giving up and freezing anyway. | ||
| const JIT_DRAIN_TIMEOUT: Duration = Duration::from_secs(15); | ||
|
|
||
| /// How often to poll `jcmd Compiler.queue` while waiting for it to drain. | ||
| const JIT_DRAIN_POLL: Duration = Duration::from_millis(150); | ||
|
|
||
| /// Number of consecutive idle polls required before declaring the compile queue | ||
| /// drained, so we don't stop during a transient lull while Eclair is still | ||
| /// feeding the compiler. | ||
| const JIT_DRAIN_CONFIRMATIONS: u32 = 3; | ||
|
|
||
| /// Configuration for the Eclair target. | ||
| pub struct EclairConfig { | ||
| /// Bitcoin RPC port (default: 18443 for regtest). | ||
|
|
@@ -125,9 +137,22 @@ impl EclairTarget { | |
| cmd.env("LD_PRELOAD", handler); | ||
| } | ||
|
|
||
| cmd.arg(format!("-Declair.datadir={}", eclair_dir.display())) | ||
| .stdout(Stdio::null()) | ||
| .stderr(Stdio::null()); | ||
| // Forward JVM tuning options (e.g. compiler thresholds, +PrintCompilation) | ||
| // to eclair-node.sh, which passes JAVA_OPTS through to the JVM. Used to | ||
| // experiment with JIT warmup behavior before the snapshot. | ||
| if let Ok(opts) = std::env::var("SMITE_ECLAIR_JAVA_OPTS") { | ||
| cmd.env("JAVA_OPTS", opts); | ||
| } | ||
|
Comment on lines
+143
to
+145
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This ends up overriding the |
||
|
|
||
| cmd.arg(format!("-Declair.datadir={}", eclair_dir.display())); | ||
|
|
||
| // Silence Eclair by default; inherit its stdio when we need to see JVM | ||
| // diagnostics such as -XX:+PrintCompilation output. | ||
| if std::env::var("SMITE_ECLAIR_LOG").is_ok() { | ||
| cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit()); | ||
| } else { | ||
| cmd.stdout(Stdio::null()).stderr(Stdio::null()); | ||
| } | ||
|
|
||
| let eclair = ManagedProcess::spawn(&mut cmd, "eclair")?; | ||
|
|
||
|
|
@@ -193,6 +218,87 @@ impl EclairTarget { | |
|
|
||
| Ok((pubkey, info.block_height)) | ||
| } | ||
|
|
||
| /// Runs `jcmd <eclair-jvm-pid> <args...>` and returns its stdout on success. | ||
| fn run_jcmd(&self, args: &[&str]) -> Result<String, String> { | ||
| let java_home = std::env::var("JAVA_HOME").unwrap_or_else(|_| "/opt/java/openjdk".into()); | ||
| let out = Command::new(format!("{java_home}/bin/jcmd")) | ||
| .arg(self.eclair.pid().to_string()) | ||
| .args(args) | ||
| .output() | ||
| .map_err(|e| format!("failed to run jcmd: {e}"))?; | ||
| if out.status.success() { | ||
| Ok(String::from_utf8_lossy(&out.stdout).into_owned()) | ||
| } else { | ||
| Err(format!( | ||
| "jcmd {} failed (status {}): {}", | ||
| args.join(" "), | ||
| out.status, | ||
| String::from_utf8_lossy(&out.stderr).trim() | ||
| )) | ||
| } | ||
| } | ||
|
|
||
| /// Polls `jcmd Compiler.queue` until the compiler is idle for | ||
| /// [`JIT_DRAIN_CONFIRMATIONS`] consecutive polls, or [`JIT_DRAIN_TIMEOUT`] | ||
| /// elapses. Idle = the output lists no in-flight `CompilerThread` and no | ||
| /// queued `Class::method` (`::`). | ||
| fn wait_for_compiler_idle(&self) { | ||
| let deadline = Instant::now() + JIT_DRAIN_TIMEOUT; | ||
| let mut consecutive_idle = 0u32; | ||
| loop { | ||
| let idle = match self.run_jcmd(&["Compiler.queue"]) { | ||
| Ok(out) => !out.contains("CompilerThread") && !out.contains("::"), | ||
| // If we can't query the queue we can't confirm idle; keep trying | ||
| // until the timeout rather than freezing a possibly-busy compiler. | ||
| Err(e) => { | ||
| log::debug!("Compiler.queue poll failed: {e}"); | ||
| false | ||
| } | ||
| }; | ||
|
|
||
| if idle { | ||
| consecutive_idle += 1; | ||
| if consecutive_idle >= JIT_DRAIN_CONFIRMATIONS { | ||
| log::info!("JIT compile queue drained"); | ||
| return; | ||
| } | ||
| } else { | ||
| consecutive_idle = 0; | ||
| } | ||
|
|
||
| if Instant::now() >= deadline { | ||
| log::warn!("timed out waiting for JIT compile queue to drain; freezing anyway"); | ||
| return; | ||
| } | ||
| std::thread::sleep(JIT_DRAIN_POLL); | ||
| } | ||
| } | ||
|
|
||
| /// Waits for the compile queue to drain, then freezes the JIT via a catch-all | ||
| /// `Exclude` compiler directive (`jcmd`). This blocks further compilation | ||
| /// while keeping warmed code installed (verified: no deopt), so no compiler | ||
| /// threads run during fuzzing. Draining first is essential — freezing with | ||
| /// methods still queued would leave them interpreted. | ||
| /// | ||
| /// `eclair-node.sh` `exec`s the JVM, so `self.eclair.pid()` is the JVM pid. | ||
| /// Best-effort: jcmd failures are logged, not propagated. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns an error only if writing the directive file fails. | ||
| pub fn freeze_jit(&self) -> Result<(), TargetError> { | ||
| self.wait_for_compiler_idle(); | ||
|
|
||
| // Catch-all exclude: block future JIT compilation of every method. | ||
| let directive = std::env::temp_dir().join("smite-jit-exclude-all.json"); | ||
| fs::write(&directive, "[ { match: [\"*.*\"], Exclude: true } ]\n")?; | ||
| match self.run_jcmd(&["Compiler.directives_add", &directive.to_string_lossy()]) { | ||
|
Comment on lines
+294
to
+296
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd be curious how much difference the JIT freeze makes on its own -- is this the load-bearing change or is it the warmup? |
||
| Ok(out) => log::info!("Froze JIT via jcmd: {}", out.trim()), | ||
| Err(e) => log::warn!("could not freeze JIT: {e}"), | ||
| } | ||
| Ok(()) | ||
| } | ||
| } | ||
|
|
||
| impl Target for EclairTarget { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Eclair accepts other channel types if they're negotiated (e.g., zero-fee-commits, scid-alias).