From 337789d44171b8544fc95dd3bb2ea0fff2a4ba73 Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Wed, 22 Jul 2026 16:27:48 -0300 Subject: [PATCH] smite-scenarios: warm up and freeze Eclair's JIT before the snapshot Snapshot-restore fuzzing of Eclair started every iteration on a cold JVM, so the channel path ran interpreted. Add an Eclair-specific SnapshotSetup that, before the snapshot is taken: - drives thousands of open_channel exchanges on throwaway connections so HotSpot JIT-compiles the channel path (batched under Eclair's pending- channel rate limit), - waits for the compile queue to drain (polling jcmd Compiler.queue), - freezes the JIT with a catch-all Exclude directive so no compiler threads run during fuzzing. Keeps C1 only (-XX:TieredStopAtLevel=1): C2 measured ~28% slower here as its speculation deopts on varied fuzzing inputs. The runtime image switches to the full JDK for jcmd. --- smite-scenarios/src/bin/eclair_ir.rs | 4 +- smite-scenarios/src/scenarios.rs | 2 +- smite-scenarios/src/scenarios/setup.rs | 173 +++++++++++++++++++++++-- smite-scenarios/src/targets/eclair.rs | 114 +++++++++++++++- workloads/eclair/Dockerfile | 6 +- workloads/eclair/init.sh | 8 +- 6 files changed, 284 insertions(+), 23 deletions(-) diff --git a/smite-scenarios/src/bin/eclair_ir.rs b/smite-scenarios/src/bin/eclair_ir.rs index 93736881..9c994904 100644 --- a/smite-scenarios/src/bin/eclair_ir.rs +++ b/smite-scenarios/src/bin/eclair_ir.rs @@ -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::>() + smite_run::>() } diff --git a/smite-scenarios/src/scenarios.rs b/smite-scenarios/src/scenarios.rs index f95923ed..94f8f65d 100644 --- a/smite-scenarios/src/scenarios.rs +++ b/smite-scenarios/src/scenarios.rs @@ -10,7 +10,7 @@ pub use encrypted_bytes::EncryptedBytesScenario; pub use init::InitScenario; pub use ir::IrScenario; pub use noise::NoiseScenario; -pub use setup::{PostInitSetup, REGTEST_CHAIN_HASH, SnapshotSetup}; +pub use setup::{EclairWarmupSetup, PostInitSetup, REGTEST_CHAIN_HASH, SnapshotSetup}; use smite::scenarios::ScenarioError; use std::time::Duration; diff --git a/smite-scenarios/src/scenarios/setup.rs b/smite-scenarios/src/scenarios/setup.rs index 08422d86..a651db93 100644 --- a/smite-scenarios/src/scenarios/setup.rs +++ b/smite-scenarios/src/scenarios/setup.rs @@ -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; + +/// 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(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( + target: &T, + chain_hash: [u8; 32], + 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)?; + } + log::info!("Warmup complete"); + Ok(()) +} + /// Setup that snapshots just after the Noise handshake and init exchange are /// complete. pub struct PostInitSetup; impl SnapshotSetup 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 SnapshotSetup 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 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::().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) + } +} diff --git a/smite-scenarios/src/targets/eclair.rs b/smite-scenarios/src/targets/eclair.rs index 8a0a561f..ab47721c 100644 --- a/smite-scenarios/src/targets/eclair.rs +++ b/smite-scenarios/src/targets/eclair.rs @@ -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); + } + + 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 ` and returns its stdout on success. + fn run_jcmd(&self, args: &[&str]) -> Result { + 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()]) { + Ok(out) => log::info!("Froze JIT via jcmd: {}", out.trim()), + Err(e) => log::warn!("could not freeze JIT: {e}"), + } + Ok(()) + } } impl Target for EclairTarget { diff --git a/workloads/eclair/Dockerfile b/workloads/eclair/Dockerfile index e4636882..0f02bb68 100644 --- a/workloads/eclair/Dockerfile +++ b/workloads/eclair/Dockerfile @@ -91,7 +91,11 @@ RUN cc -shared -fPIC -DENABLE_NYX -DNO_PT_NYX \ smite-nyx-sys/src/jvm-crash-handler.c -o /jvm-crash-handler.so # Runtime image. -FROM eclipse-temurin:21-jre +# +# Uses the full JDK (not -jre) so `jcmd` is available: after warmup the harness +# runs `jcmd Compiler.directives_add` to freeze the JIT (block new +# compilation while keeping warmed code) before the snapshot. +FROM eclipse-temurin:21 ARG SCENARIO # Install curl for Eclair readiness polling in EclairTarget::query_info(). diff --git a/workloads/eclair/init.sh b/workloads/eclair/init.sh index 71d0caa2..c36dc0ad 100644 --- a/workloads/eclair/init.sh +++ b/workloads/eclair/init.sh @@ -16,9 +16,11 @@ export SMITE_CRASH_HANDLER=/nyx-jvm-crash-handler.so # JVM tuning for Nyx fuzzing performance. JAVA_OPTS is picked up by # eclair-node.sh and passed to the JVM. # -# -XX:TieredStopAtLevel=1: Use only the C1 JIT compiler, skipping C2 compilation -# entirely. C2 runs expensive optimizations in background threads which get -# repeated every time we restore the VM snapshot, reducing fuzzing speed. +# -XX:TieredStopAtLevel=1: Use only the C1 JIT compiler, skipping C2. C2 measured +# ~28% fewer execs/sec here: its speculative optimizations are tuned to the +# fixed warmup path, so varied fuzzing inputs trip uncommon traps and deopt to +# the interpreter each restore. C1 doesn't speculate, staying robust across +# inputs with a more compact code cache. # # -javaagent: Coverage agent that instruments bytecode and writes edge counters # to AFL shared memory via JNI.