Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions smite-scenarios/src/bin/eclair_ir.rs
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>>()
}
2 changes: 1 addition & 1 deletion smite-scenarios/src/scenarios.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
173 changes: 161 additions & 12 deletions smite-scenarios/src/scenarios/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
Expand Down Expand Up @@ -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.
Comment on lines +104 to +105

Copy link
Copy Markdown
Owner

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).

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;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this const should go directly above warmup


/// 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],

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: we can probably drop the chain_hash parameter and just hard-code regtest

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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eclair actually processes open_channel messages async, so the only way to be sure it has processed the whole batch is to wait for all the accept_channel messages. Eclair also rejects concurrent open_channels, so we actually need to send them one at a time and wait for each response before sending the next one. A few other benefits of doing this:

  • More thorough warmup, potentially requiring much fewer iterations.
  • We can detect if our open_channel messages ever start getting rejected (e.g., due to Eclair update) and raise the alarm.

}
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(),
Expand All @@ -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)
}
}
114 changes: 110 additions & 4 deletions smite-scenarios/src/targets/eclair.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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).
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ends up overriding the JAVA_OPTS set in init.sh, which includes our coverage agent and the C1 compiler flag. I think we probably want to append to JAVA_OPTS here instead.


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")?;

Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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 {
Expand Down
6 changes: 5 additions & 1 deletion workloads/eclair/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pid> 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().
Expand Down
8 changes: 5 additions & 3 deletions workloads/eclair/init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down