Skip to content
Merged
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
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
!LICENSE
!.git/
!.cargo/
!shadow/
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,8 @@ eyre = "0.6"
tikv-jemallocator = { version = "0.6", features = ["stats", "unprefixed_malloc_on_supported_platforms", "profiling"] }
jemalloc_pprof = { version = "0.8", features = ["flamegraph"] }

# NOTE: Shadow-simulator builds also replace quinn-udp with the fallback crate
# in `shadow/quinn-udp-patch` (no GSO/GRO batch syscalls). A Cargo `[patch]`
# cannot be feature-gated, so it is NOT committed here; `shadow/build.sh` injects
# it into this manifest at build time. See `make shadow-build`.

20 changes: 18 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,28 @@ ENV BUILD_PROFILE=$BUILD_PROFILE
ARG FEATURES=""
ENV FEATURES=$FEATURES

RUN cargo chef cook --profile $BUILD_PROFILE --features "$FEATURES" --recipe-path recipe.json
# Disable default features (e.g. for `shadow-integration`, which must drop the
# default jemalloc allocator). Set to "--no-default-features" to enable.
ARG NO_DEFAULT_FEATURES=""
ENV NO_DEFAULT_FEATURES=$NO_DEFAULT_FEATURES

# Cargo --locked by default. The Shadow build injects a [patch] (see below) that
# is absent from the committed lockfile, so it must build unlocked: set LOCKED= .
ARG LOCKED="--locked"
ENV LOCKED=$LOCKED

RUN cargo chef cook --profile $BUILD_PROFILE $NO_DEFAULT_FEATURES --features "$FEATURES" --recipe-path recipe.json

# Build application
# Include .git so vergen-git2 can extract version info (branch, commit SHA)
COPY --exclude=target . .
RUN cargo build --profile $BUILD_PROFILE --features "$FEATURES" --locked --bin ethlambda

# Shadow builds inject the quinn-udp [patch] into the workspace manifest, since a
# Cargo patch cannot be feature-gated and is therefore not committed. Set SHADOW=1.
ARG SHADOW=""
RUN if [ -n "$SHADOW" ]; then cat shadow/cargo-patch.toml >> Cargo.toml; fi

RUN cargo build --profile $BUILD_PROFILE $NO_DEFAULT_FEATURES --features "$FEATURES" $LOCKED --bin ethlambda

# ARG is not resolved in COPY so we have to hack around it by copying the
# binary to a temporary location
Expand Down
16 changes: 15 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help fmt lint docker-build run-devnet test docs docs-deps docs-serve
.PHONY: help fmt lint docker-build shadow-build shadow-docker-build run-devnet test docs docs-deps docs-serve

help: ## 📚 Show help for each of the Makefile recipes
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
Expand All @@ -24,6 +24,20 @@ docker-build: ## 🐳 Build the Docker image
-t ghcr.io/lambdaclass/ethlambda:$(DOCKER_TAG) .
@echo

shadow-build: ## 👻 Build a Shadow-simulator-compatible binary (single-threaded, no jemalloc)
./shadow/build.sh cargo build --release --no-default-features --features shadow-integration --bin ethlambda

shadow-docker-build: ## 👻🐳 Build a Shadow-compatible Docker image
docker build \
--build-arg GIT_COMMIT=$(GIT_COMMIT) \
--build-arg GIT_BRANCH=$(GIT_BRANCH) \
--build-arg SHADOW=1 \
--build-arg FEATURES=shadow-integration \
--build-arg NO_DEFAULT_FEATURES=--no-default-features \
--build-arg LOCKED= \
-t ghcr.io/lambdaclass/ethlambda:$(DOCKER_TAG)-shadow .
@echo

# 2026-05-17
LEAN_SPEC_COMMIT_HASH:=f12000bd68a9640cffdfbd9a07503c9112d32bee

Expand Down
15 changes: 14 additions & 1 deletion bin/ethlambda/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ edition.workspace = true
authors.workspace = true
license.workspace = true

[features]
default = ["jemalloc"]
# jemalloc allocator with heap profiling. Build the Shadow binary with
# `--no-default-features --features shadow-integration` to drop it: jemalloc's
# `unprefixed_malloc` overrides the system allocator symbols, conflicting with
# the Shadow simulator's preload shim. Cargo features are additive, so a feature
# cannot subtract a default-on dependency; hence the `--no-default-features`.
jemalloc = ["dep:tikv-jemallocator"]
# Shadow simulator compatibility: single-threaded tokio runtime and no jemalloc.
# The quinn-udp UDP fallback is a Cargo `[patch]` (which cannot be feature-gated),
# injected at build time by `shadow/build.sh` / `make shadow-build`.
shadow-integration = []

[dependencies]
ethlambda-blockchain.workspace = true
ethlambda-network-api.workspace = true
Expand All @@ -31,7 +44,7 @@ reqwest.workspace = true
thiserror.workspace = true
eyre.workspace = true

tikv-jemallocator.workspace = true
tikv-jemallocator = { workspace = true, optional = true }

[build-dependencies]
vergen-git2.workspace = true
27 changes: 21 additions & 6 deletions bin/ethlambda/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
mod checkpoint_sync;
mod version;

#[cfg(not(target_env = "msvc"))]
// Jemalloc causes programs to deadlock during process startup under Shadow.
// See https://github.com/shadow/shadow/issues/3763. Build the Shadow binary
// with `--no-default-features --features shadow-integration` to drop the
// (default) `jemalloc` feature and thus the `tikv-jemallocator` dependency.
#[cfg(all(feature = "jemalloc", feature = "shadow-integration"))]
compile_error!(
"the `jemalloc` feature is incompatible with `shadow-integration`; \
build the Shadow binary with `--no-default-features --features shadow-integration`"
);

#[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))]
#[global_allocator]
static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;

#[cfg(not(target_env = "msvc"))]
#[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))]
#[allow(non_upper_case_globals)]
#[unsafe(export_name = "malloc_conf")]
static malloc_conf: &[u8] = b"prof:true,prof_active:true,lg_prof_sample:19\0";
Expand Down Expand Up @@ -116,7 +126,12 @@ struct CliOptions {
data_dir: PathBuf,
}

#[tokio::main]
// Shadow single-steps execution in a discrete-event simulation, so the default
// multi-threaded runtime's worker threads add only scheduling noise, never
// parallelism. Use a single-threaded runtime under Shadow. This is an
// optimization, not a correctness requirement.
#[cfg_attr(not(feature = "shadow-integration"), tokio::main)]
#[cfg_attr(feature = "shadow-integration", tokio::main(flavor = "current_thread"))]
async fn main() -> eyre::Result<()> {
let filter = EnvFilter::builder()
.with_default_directive(tracing::Level::INFO.into())
Expand Down Expand Up @@ -162,10 +177,10 @@ async fn main() -> eyre::Result<()> {
})?;
let p2p_socket = SocketAddr::new(IpAddr::from([0, 0, 0, 0]), options.gossipsub_port);

#[cfg(not(target_env = "msvc"))]
#[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))]
info!("Using jemalloc allocator with heap profiling enabled");
#[cfg(target_env = "msvc")]
info!("Using system allocator (MSVC target)");
#[cfg(any(target_env = "msvc", not(feature = "jemalloc")))]
info!("Using system allocator");

info!(node_key=?options.node_key, "got node key");

Expand Down
82 changes: 82 additions & 0 deletions shadow/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Shadow simulator integration

Build support for running ethlambda under the [Shadow] network simulator,
used by the [lean-shadow-fuzzer]. Everything here is **opt-in**: a normal
`cargo build` / `make docker-build` is completely unaffected, and the committed
`Cargo.toml` / `Cargo.lock` are identical to a Shadow-free checkout.

## Why Shadow needs special handling

Shadow runs the real binary but emulates time, threads, and the network. Three
things in a stock ethlambda build need to change for Shadow:

| Area | Stock build | Under Shadow | Why |
|------|-------------|--------------|-----|
| Allocator | jemalloc with `unprefixed_malloc` (interposes the global C `malloc`) | system allocator (drop jemalloc) | Self-deadlocks at startup ([shadow#3763]) |
| QUIC UDP I/O | `quinn-udp` uses GSO/GRO batch syscalls (`sendmmsg`, segmentation offload) | fall back to `send_to`/`recv_from` | Shadow's UDP emulation doesn't support those batch syscalls. |
| Tokio runtime | multi-threaded | `current_thread` | Shadow single-steps execution, so worker threads add only scheduling noise. |

The allocator change (dropping jemalloc) and the runtime flavor are gated behind
the `shadow-integration` Cargo feature (jemalloc is dropped via
`--no-default-features`). The quinn-udp change is a Cargo `[patch]`, which
**cannot** be feature-gated, so it is injected into the manifest only at build
time (see below). Of the three, only the allocator and quinn-udp changes are
correctness requirements; the single-threaded runtime is purely a performance
choice.

## Contents

| Path | Purpose |
|------|---------|
| `quinn-udp-patch/` | Drop-in `quinn-udp` replacement (package name stays `quinn-udp`) routing every send/receive through plain `send_to`/`recv_from`, batch size 1 |
| `cargo-patch.toml` | The `[patch.crates-io]` snippet appended to the workspace `Cargo.toml` for Shadow builds |
| `build.sh` | Runs a cargo command with the patch temporarily injected, then restores `Cargo.toml` + `Cargo.lock` |

## Building

```bash
# Local binary (release, single-threaded, no jemalloc, quinn-udp fallback)
make shadow-build

# Docker image, tagged ...:<DOCKER_TAG>-shadow
make shadow-docker-build
```

`make shadow-build` is a thin wrapper around:

```bash
./shadow/build.sh cargo build --release \
--no-default-features --features shadow-integration --bin ethlambda
```

`shadow/build.sh` can run any cargo command against the patched workspace, e.g.:

```bash
./shadow/build.sh cargo check --no-default-features --features shadow-integration
./shadow/build.sh cargo clippy --no-default-features --features shadow-integration
```

It backs up `Cargo.toml` and `Cargo.lock`, appends `cargo-patch.toml`, runs the
command, and restores both files on exit (even on failure), so the working tree
is left pristine.

> [!NOTE]
> `--no-default-features` is required, not optional: Cargo features are additive,
> so the `shadow-integration` feature cannot *remove* the default `jemalloc`
> dependency on its own. A `compile_error!` in `bin/ethlambda/src/main.rs` fires
> if `shadow-integration` is built with jemalloc still enabled.

## Docker

`make shadow-docker-build` passes these build args to the standard `Dockerfile`:

| Arg | Value | Effect |
|-----|-------|--------|
| `SHADOW` | `1` | Appends `shadow/cargo-patch.toml` to `Cargo.toml` before the build |
| `FEATURES` | `shadow-integration` | Enables the feature |
| `NO_DEFAULT_FEATURES` | `--no-default-features` | Drops jemalloc |
| `LOCKED` | (empty) | Builds unlocked, since the injected patch is absent from the committed lockfile |

[Shadow]: https://shadow.github.io/
[lean-shadow-fuzzer]: https://github.com/kamilsa/lean-shadow-fuzzer
[shadow#3763]: https://github.com/shadow/shadow/issues/3763
36 changes: 36 additions & 0 deletions shadow/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
#
# Run a cargo command against the Shadow-patched workspace.
#
# A Cargo `[patch.crates-io]` table cannot be gated behind a feature flag, so the
# quinn-udp fallback patch is kept out of the committed Cargo.toml. This script
# temporarily appends it (from shadow/cargo-patch.toml), runs the given command,
# then restores Cargo.toml and Cargo.lock to their pristine state.
#
# Usage:
# shadow/build.sh cargo build --release \
# --no-default-features --features shadow-integration --bin ethlambda
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"

if [ "$#" -eq 0 ]; then
echo "usage: $0 <cargo command...>" >&2
exit 64
fi

# Back up the manifest + lockfile so we can restore them verbatim, regardless of
# whether they were committed or had local edits.
cp Cargo.toml Cargo.toml.shadow-bak
cp Cargo.lock Cargo.lock.shadow-bak

restore() {
mv -f Cargo.toml.shadow-bak Cargo.toml
mv -f Cargo.lock.shadow-bak Cargo.lock
}
trap restore EXIT

cat shadow/cargo-patch.toml >> Cargo.toml

"$@"
7 changes: 7 additions & 0 deletions shadow/cargo-patch.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@

# Shadow simulator compatibility: replace quinn-udp with a fallback that uses
# plain send_to/recv_from instead of GSO/GRO batch syscalls, which Shadow's UDP
# emulation does not support. Appended to the workspace Cargo.toml at build time
# by shadow/build.sh (and the Dockerfile's SHADOW path); never committed.
[patch.crates-io]
quinn-udp = { path = "shadow/quinn-udp-patch" }
24 changes: 24 additions & 0 deletions shadow/quinn-udp-patch/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[package]
name = "quinn-udp"
version = "0.5.14"
edition = "2021"

[features]
default = ["tracing", "log"]
log = ["tracing/log"]
direct-log = ["dep:log"]
fast-apple-datapath = []

[dependencies]
libc = { version = "0.2" }
log = { version = "0.4", optional = true }
tracing = { version = "0.1", optional = true }

[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies]
socket2 = { version = "0.5" }

[build-dependencies]
cfg_aliases = { version = "0.2" }

[lib]
bench = false
Loading
Loading