From 578f8a9ed8badad70404f1ae53061c0e094ca1fc Mon Sep 17 00:00:00 2001 From: owen Date: Tue, 26 May 2026 11:55:44 +0100 Subject: [PATCH 01/16] adds beacon api --- Cargo.lock | 15 + Cargo.toml | 2 + crates/beacon_api/Cargo.toml | 19 + crates/beacon_api/examples/srv.rs | 20 ++ crates/beacon_api/src/lib.rs | 2 + crates/beacon_api/src/tile.rs | 577 ++++++++++++++++++++++++++++++ crates/bin/Cargo.toml | 1 + crates/bin/src/main.rs | 6 +- 8 files changed, 641 insertions(+), 1 deletion(-) create mode 100644 crates/beacon_api/Cargo.toml create mode 100644 crates/beacon_api/examples/srv.rs create mode 100644 crates/beacon_api/src/lib.rs create mode 100644 crates/beacon_api/src/tile.rs diff --git a/Cargo.lock b/Cargo.lock index 150b6377..53cc5f77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4323,6 +4323,7 @@ dependencies = [ "mimalloc", "quinn-proto", "rand 0.8.6", + "silver_beacon_api", "silver_beacon_state", "silver_beacon_state_data", "silver_common", @@ -4337,6 +4338,20 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "silver_beacon_api" +version = "0.0.1" +dependencies = [ + "flux", + "hex", + "httparse", + "mio", + "serde", + "serde_json", + "silver_common", + "tracing", +] + [[package]] name = "silver_beacon_state" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 22977a35..3483b726 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/beacon_api", "crates/beacon_state/data", "crates/beacon_state/tile", "crates/bin", @@ -60,6 +61,7 @@ inherits = "dev" opt-level = 3 [workspace.dependencies] +silver_beacon_api = { path = "crates/beacon_api" } silver_beacon_state = { path = "crates/beacon_state/tile" } silver_beacon_state_data = { path = "crates/beacon_state/data" } silver_chain_spec = { path = "crates/config/chain_spec" } diff --git a/crates/beacon_api/Cargo.toml b/crates/beacon_api/Cargo.toml new file mode 100644 index 00000000..d655e1f0 --- /dev/null +++ b/crates/beacon_api/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "silver_beacon_api" +edition.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +flux.workspace = true +hex.workspace = true +mio.workspace = true +silver_common.workspace = true +serde.workspace = true +tracing.workspace = true +serde_json = "1.0.149" +httparse = "1.10.1" + +[lints] +workspace = true diff --git a/crates/beacon_api/examples/srv.rs b/crates/beacon_api/examples/srv.rs new file mode 100644 index 00000000..5cdaaa74 --- /dev/null +++ b/crates/beacon_api/examples/srv.rs @@ -0,0 +1,20 @@ +use flux::{ + tile::{TileConfig, attach_tile}, + utils::ThreadPriority, +}; +use silver_beacon_api::BeaconApiTile; +use silver_common::{Enr, Identify, Keypair, SilverSpine}; + +fn main() { + let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); + let local_enr = Enr::empty(keypair.secret_key()).unwrap(); + let identify = Identify::default(); + let spine = SilverSpine::new(None); + spine.start(None, None, |scoped_spine| { + attach_tile( + BeaconApiTile::new(&keypair, local_enr, &identify), + scoped_spine, + TileConfig::new(1, ThreadPriority::OSDefault), + ); + }); +} diff --git a/crates/beacon_api/src/lib.rs b/crates/beacon_api/src/lib.rs new file mode 100644 index 00000000..500cb6dc --- /dev/null +++ b/crates/beacon_api/src/lib.rs @@ -0,0 +1,2 @@ +mod tile; +pub use tile::BeaconApiTile; diff --git a/crates/beacon_api/src/tile.rs b/crates/beacon_api/src/tile.rs new file mode 100644 index 00000000..c6923e68 --- /dev/null +++ b/crates/beacon_api/src/tile.rs @@ -0,0 +1,577 @@ +use std::{ + collections::HashMap, + io::{self, Read, Write}, + time::Duration, +}; + +use flux::{spine::SpineAdapter, tile::Tile}; +use mio::{ + Events, Interest, Poll, Token, + net::{TcpListener, TcpStream}, +}; +use serde::{Deserialize, Serialize}; +use silver_common::{Enr, Eth2Addr, Identify, Keypair, SilverSpine}; + +const LISTENER: Token = Token(0); +const IDENTITY_PATH: &str = "/eth/v1/node/identity"; +const METRICS_PATH: &str = "/metrics"; +const NOT_FOUND: &[u8] = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"; +const VERSION_NOT_SUPPORTED: &[u8] = + b"HTTP/1.1 505 HTTP Version Not Supported\r\nContent-Length: 0\r\n\r\n"; +const METRICS_EMPTY: &[u8] = + b"HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4; charset=utf-8\r\nContent-Length: 0\r\n\r\n"; +// Hard cap on the read buffer. Raw SSZ, uncompressed. 16 MiB matches observed +// production maximums (21 blobs × 128 KiB plus block fields). +const READ_BUF_MAX: usize = 16 << 20; +const WRITE_BUF_INIT: usize = 4096; + +#[allow(dead_code)] +struct ParsedRequest<'a> { + method: &'a str, + path: &'a str, + query: &'a str, + body: &'a [u8], + version: u8, + keep_alive: bool, +} + +#[derive(Debug, Serialize)] +struct IdentityResponse<'a> { + data: &'a Identity, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Identity { + peer_id: String, + enr: String, + p2p_addresses: Vec, + discovery_addresses: Vec, + metadata: Metadata, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Metadata { + seq_number: String, + attnets: String, + syncnets: String, + custody_group_count: String, +} + +struct HttpConnection { + stream: TcpStream, + read_buf: Box<[u8; READ_BUF_MAX]>, + read_pos: usize, + read_end: usize, + write_buf: Vec, + write_pos: usize, + keep_alive: bool, +} + +impl HttpConnection { + fn new(stream: TcpStream) -> Self { + Self { + stream, + read_buf: Box::new([0u8; READ_BUF_MAX]), + read_pos: 0, + read_end: 0, + write_buf: Vec::with_capacity(WRITE_BUF_INIT), + write_pos: 0, + keep_alive: true, + } + } + + fn reset(&mut self) { + self.write_buf.clear(); + self.write_pos = 0; + } +} + +pub struct BeaconApiTile { + poll: Poll, + events: Events, + listener: TcpListener, + current_token: Token, + connections: HashMap, + identity_response: Vec, +} + +impl BeaconApiTile { + pub fn new(keypair: &Keypair, local_enr: Enr, identify: &Identify) -> Self { + let poll = Poll::new().unwrap(); + let addr = "0.0.0.0:5051".parse().unwrap(); + let mut listener = TcpListener::bind(addr).unwrap(); + poll.registry().register(&mut listener, LISTENER, Interest::READABLE).unwrap(); + + let identity_response = build_identity_response(keypair, &local_enr, identify); + + Self { + poll, + events: Events::with_capacity(1024), + listener, + current_token: Token(LISTENER.0 + 1), + connections: HashMap::new(), + identity_response, + } + } +} + +impl Tile for BeaconApiTile { + fn loop_body(&mut self, _adapter: &mut SpineAdapter) { + self.poll.poll(&mut self.events, Some(Duration::from_millis(100))).unwrap(); + + for event in &self.events { + match event.token() { + LISTENER => { + let (mut stream, address) = match self.listener.accept() { + Ok(conn) => conn, + Err(e) => { + tracing::warn!("accept failed: {e}"); + continue; + } + }; + + tracing::info!("accepted connection from {address}"); + let token = next(&mut self.current_token); + self.poll.registry().register(&mut stream, token, Interest::READABLE).unwrap(); + self.connections.insert(token, HttpConnection::new(stream)); + } + token => { + if let Some(conn) = self.connections.get_mut(&token) { + match handle_event(self.poll.registry(), conn, event, &|req, out| match req + .path + { + IDENTITY_PATH => handle_identity(&self.identity_response, out), + METRICS_PATH => handle_metrics(out), + _ => handle_unknown(req.path, out), + }) { + Ok(true) => { + let _ = self.poll.registry().deregister(&mut conn.stream); + self.connections.remove(&token); + } + Ok(false) => {} + Err(e) => { + tracing::warn!("connection error: {e}"); + let _ = self.poll.registry().deregister(&mut conn.stream); + self.connections.remove(&token); + } + }; + } + } + } + } + } +} + +fn handle_identity(response: &[u8], out: &mut Vec) { + out.extend_from_slice(response); +} + +fn handle_metrics(out: &mut Vec) { + out.extend_from_slice(METRICS_EMPTY); +} + +fn handle_unknown(path: &str, out: &mut Vec) { + tracing::warn!("unknown path: {path}"); + out.extend_from_slice(NOT_FOUND); +} + +// TODO: write_buf materialises the full response in heap memory. For large +// payloads (beacon states >200 MiB, blocks, blobs) replace with scatter-gather +// streaming: hold a tcache snapshot reference and write headers + body via +// write_vectored without copying. The write loop already drains by position so +// the structure supports a multi-part write state without changes to the outer +// logic. +// +// TODO: path routing here is exact-match only. Most beacon API paths are +// parameterised (/eth/v1/beacon/states/{state_id}/...). Add prefix/pattern +// matching before implementing any parameterised routes. +fn handle_event, &mut Vec)>( + registry: &mio::Registry, + conn: &mut HttpConnection, + event: &mio::event::Event, + request_handler: &F, +) -> io::Result { + if event.is_readable() { + loop { + if conn.read_end == READ_BUF_MAX { + return Err(io::Error::new(io::ErrorKind::InvalidData, "request too large")); + } + match conn.stream.read(&mut conn.read_buf[conn.read_end..]) { + Ok(0) => return Err(io::Error::from(io::ErrorKind::UnexpectedEof)), + Ok(n) => conn.read_end += n, + Err(e) if would_block(&e) => break, + Err(e) if interrupted(&e) => continue, + Err(e) => return Err(e), + } + } + + dispatch(registry, conn, event.token(), request_handler)?; + return Ok(false); + } + + if event.is_writable() { + if conn.write_pos < conn.write_buf.len() { + loop { + match conn.stream.write(&conn.write_buf[conn.write_pos..]) { + Ok(0) => { + return Err(io::Error::new(io::ErrorKind::WriteZero, "write returned 0")) + } + Ok(n) => { + conn.write_pos += n; + if conn.write_pos == conn.write_buf.len() { + break; + } + } + Err(e) if would_block(&e) => return Ok(false), + Err(e) if interrupted(&e) => continue, + Err(e) => return Err(e), + } + } + if conn.keep_alive { + conn.reset(); + // Serve any pipelined request buffered while we were writing. + // Without this, edge-triggered epoll won't re-fire for data + // that's already in read_buf. + if !dispatch(registry, conn, event.token(), request_handler)? { + registry.reregister(&mut conn.stream, event.token(), Interest::READABLE)?; + } + } else { + return Ok(true); + } + } + return Ok(false); + } + + Ok(false) +} + +fn dispatch, &mut Vec)>( + registry: &mio::Registry, + conn: &mut HttpConnection, + token: Token, + handler: &F, +) -> io::Result { + let Some((consumed, req)) = try_parse_request(&conn.read_buf[conn.read_pos..conn.read_end]) + else { + return Ok(false); + }; + if req.version != 1 { + tracing::warn!("rejecting HTTP/1.0 request"); + conn.keep_alive = false; + conn.write_buf.extend_from_slice(VERSION_NOT_SUPPORTED); + } else { + conn.keep_alive = req.keep_alive; + handler(&req, &mut conn.write_buf); + } + conn.read_pos += consumed; + //TODO: check if we actually need to support pipeling. If not, we can simplify + // this. + if conn.read_pos == conn.read_end { + conn.read_pos = 0; + conn.read_end = 0; + } + registry.reregister(&mut conn.stream, token, Interest::WRITABLE)?; + Ok(true) +} + +fn try_parse_request(buf: &[u8]) -> Option<(usize, ParsedRequest<'_>)> { + let mut headers = [httparse::EMPTY_HEADER; 64]; + let mut req = httparse::Request::new(&mut headers); + let headers_end = match req.parse(buf) { + Ok(httparse::Status::Complete(n)) => n, + _ => return None, + }; + let method = req.method?; + let raw_path = req.path?; + let (path, query) = raw_path.split_once('?').unwrap_or((raw_path, "")); + let version = req.version?; + let keep_alive = version == 1 && + !headers.iter().any(|h| { + h.name.eq_ignore_ascii_case("connection") && h.value.eq_ignore_ascii_case(b"close") + }); + let content_length: usize = + match headers.iter().find(|h| h.name.eq_ignore_ascii_case("content-length")) { + None => 0, + Some(h) => std::str::from_utf8(h.value).ok().and_then(|v| v.trim().parse().ok())?, + }; + let total = headers_end + content_length; + if buf.len() < total { + return None; + } + Some((total, ParsedRequest { + method, + path, + query, + body: &buf[headers_end..total], + version, + keep_alive, + })) +} + +fn build_identity_response(keypair: &Keypair, local_enr: &Enr, identify: &Identify) -> Vec { + let pid_multiaddr = Eth2Addr::PeerId(keypair.peer_id()).to_string(); + let peer_id_str = pid_multiaddr.strip_prefix("/p2p/").unwrap_or(&pid_multiaddr); + + let mut p2p_addresses = Vec::new(); + if let Some(addr) = identify.tcp_ipv4 { + p2p_addresses.push(format!("/ip4/{}/tcp/{}/p2p/{}", addr.ip(), addr.port(), peer_id_str)); + } + if let Some(addr) = identify.tcp_ipv6 { + p2p_addresses.push(format!("/ip6/{}/tcp/{}/p2p/{}", addr.ip(), addr.port(), peer_id_str)); + } + if let Some(addr) = identify.udp_ipv4 { + p2p_addresses.push(format!( + "/ip4/{}/udp/{}/quic-v1/p2p/{}", + addr.ip(), + addr.port(), + peer_id_str + )); + } + if let Some(addr) = identify.udp_ipv6 { + p2p_addresses.push(format!( + "/ip6/{}/udp/{}/quic-v1/p2p/{}", + addr.ip(), + addr.port(), + peer_id_str + )); + } + + let mut discovery_addresses = Vec::new(); + if let (Some(ip), Some(udp)) = (local_enr.ip4(), local_enr.udp4()) { + discovery_addresses.push(format!("/ip4/{}/udp/{}/p2p/{}", ip, udp, peer_id_str)); + } + if let (Some(ip), Some(udp)) = (local_enr.ip6(), local_enr.udp6()) { + discovery_addresses.push(format!("/ip6/{}/udp/{}/p2p/{}", ip, udp, peer_id_str)); + } + + let identity = Identity { + peer_id: peer_id_str.to_string(), + enr: local_enr.to_base64(), + p2p_addresses, + discovery_addresses, + metadata: Metadata { + seq_number: local_enr.seq().to_string(), + attnets: format!("0x{}", hex::encode(local_enr.attnets().unwrap_or([0u8; 8]))), + syncnets: format!("0x{:02x}", local_enr.syncnets().unwrap_or(0)), + custody_group_count: local_enr.cgc().unwrap_or(4).to_string(), + }, + }; + + let body = serde_json::to_string(&IdentityResponse { data: &identity }).unwrap(); + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ) + .into_bytes() +} + +fn next(current: &mut Token) -> Token { + let tok = Token(current.0); + let n = current.0.wrapping_add(1); + // Skip Token(0) == LISTENER on wrap to avoid aliasing the accept socket. + current.0 = if n == LISTENER.0 { LISTENER.0 + 1 } else { n }; + tok +} + +fn would_block(err: &io::Error) -> bool { + err.kind() == io::ErrorKind::WouldBlock +} + +fn interrupted(err: &io::Error) -> bool { + err.kind() == io::ErrorKind::Interrupted +} + +#[cfg(test)] +mod tests { + use silver_common::{Enr, Identify, Keypair}; + + use super::*; + + fn get_req(path: &str, version: &str) -> Vec { + format!("GET {path} {version}\r\nHost: localhost\r\n\r\n").into_bytes() + } + + #[test] + fn parse_http11_defaults_keep_alive() { + let req = get_req("/eth/v1/node/identity", "HTTP/1.1"); + let (_, r) = try_parse_request(&req).unwrap(); + assert_eq!(r.path, "/eth/v1/node/identity"); + assert!(r.keep_alive); + } + + #[test] + fn parse_http11_connection_close() { + let req = b"GET /metrics HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + let (_, r) = try_parse_request(req).unwrap(); + assert_eq!(r.path, "/metrics"); + assert!(!r.keep_alive); + } + + #[test] + fn parse_http10_defaults_close() { + let req = get_req("/", "HTTP/1.0"); + let (_, r) = try_parse_request(&req).unwrap(); + assert!(!r.keep_alive); + } + + #[test] + fn parse_partial_returns_none() { + assert!(try_parse_request(b"GET /eth/v1/node/identity HTTP/1.1\r\n").is_none()); + } + + #[test] + fn parse_query_string_split() { + let req = get_req("/eth/v1/beacon/states/head/validators?status=active", "HTTP/1.1"); + let (_, r) = try_parse_request(&req).unwrap(); + assert_eq!(r.path, "/eth/v1/beacon/states/head/validators"); + assert_eq!(r.query, "status=active"); + } + + #[test] + fn parse_post_body_buffered() { + let body = b"{\"slot\":\"1\"}"; + let req = format!( + "POST /eth/v1/beacon/blocks HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\n\r\n", + body.len() + ); + let mut buf = req.into_bytes(); + // incomplete — body not yet arrived + assert!(try_parse_request(&buf).is_none()); + buf.extend_from_slice(body); + let (consumed, r) = try_parse_request(&buf).unwrap(); + assert_eq!(r.method, "POST"); + assert_eq!(r.body, body.as_ref()); + assert_eq!(consumed, buf.len()); + } + + #[test] + fn parse_returns_consumed_byte_count() { + let req1 = b"GET /metrics HTTP/1.1\r\nHost: localhost\r\n\r\n"; + let req2 = b"GET /eth/v1/node/identity HTTP/1.1\r\nHost: localhost\r\n\r\n"; + let mut buf = req1.to_vec(); + buf.extend_from_slice(req2); + let (consumed, r) = try_parse_request(&buf).unwrap(); + assert_eq!(r.path, "/metrics"); + assert_eq!(consumed, req1.len()); + let (_, r2) = try_parse_request(&buf[consumed..]).unwrap(); + assert_eq!(r2.path, "/eth/v1/node/identity"); + } + + #[test] + fn metrics_response_valid_prometheus_format() { + let mut out = Vec::new(); + handle_metrics(&mut out); + let s = std::str::from_utf8(&out).unwrap(); + assert!(s.starts_with("HTTP/1.1 200 OK\r\n")); + assert!(s.contains("text/plain; version=0.0.4; charset=utf-8")); + let body_start = s.find("\r\n\r\n").unwrap() + 4; + assert_eq!(&s[body_start..], ""); + } + + #[test] + fn unknown_path_returns_404() { + let mut out = Vec::new(); + handle_unknown("/not/real", &mut out); + assert!(out.starts_with(b"HTTP/1.1 404")); + } + + #[test] + fn identity_response_content_length_matches_body() { + let kp = Keypair::from_secret(&[1u8; 32]).unwrap(); + let enr = Enr::builder().build(kp.secret_key()).unwrap(); + let resp = build_identity_response(&kp, &enr, &Identify::default()); + let s = std::str::from_utf8(&resp).unwrap(); + let header_end = s.find("\r\n\r\n").unwrap(); + let body = &s[header_end + 4..]; + let cl: usize = s[..header_end] + .lines() + .find(|l| l.to_ascii_lowercase().starts_with("content-length:")) + .unwrap() + .split(':') + .nth(1) + .unwrap() + .trim() + .parse() + .unwrap(); + assert_eq!(cl, body.len()); + } + + #[test] + fn identity_response_json_fields_present() { + let kp = Keypair::from_secret(&[1u8; 32]).unwrap(); + let enr = Enr::builder().build(kp.secret_key()).unwrap(); + let resp = build_identity_response(&kp, &enr, &Identify::default()); + let s = std::str::from_utf8(&resp).unwrap(); + let body = &s[s.find("\r\n\r\n").unwrap() + 4..]; + let v: serde_json::Value = serde_json::from_str(body).unwrap(); + let data = &v["data"]; + assert!(data["peer_id"].as_str().is_some_and(|s| !s.is_empty())); + assert!(data["enr"].as_str().is_some_and(|s| s.starts_with("enr:"))); + assert!(data["metadata"]["seq_number"].as_str().is_some()); + assert!(data["metadata"]["attnets"].as_str().is_some_and(|s| s.starts_with("0x"))); + assert!(data["metadata"]["syncnets"].as_str().is_some_and(|s| s.starts_with("0x"))); + } + + #[test] + fn identity_response_p2p_address_format() { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + let kp = Keypair::from_secret(&[1u8; 32]).unwrap(); + let enr = Enr::builder().build(kp.secret_key()).unwrap(); + let mut identify = Identify::default(); + identify.tcp_ipv4 = Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 9000)); + let resp = build_identity_response(&kp, &enr, &identify); + let s = std::str::from_utf8(&resp).unwrap(); + let body = &s[s.find("\r\n\r\n").unwrap() + 4..]; + let v: serde_json::Value = serde_json::from_str(body).unwrap(); + let addrs = v["data"]["p2p_addresses"].as_array().unwrap(); + assert_eq!(addrs.len(), 1); + let addr = addrs[0].as_str().unwrap(); + assert!(addr.starts_with("/ip4/1.2.3.4/tcp/9000/p2p/"), "bad format: {addr}"); + } + + #[test] + fn parse_invalid_content_length_returns_none() { + let req = b"POST /foo HTTP/1.1\r\nHost: localhost\r\nContent-Length: abc\r\n\r\n"; + assert!(try_parse_request(req).is_none()); + } + + #[test] + fn dispatch_http10_writes_version_not_supported() { + let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = std_listener.local_addr().unwrap(); + let _client = std::net::TcpStream::connect(addr).unwrap(); + let (server, _) = std_listener.accept().unwrap(); + server.set_nonblocking(true).unwrap(); + + let poll = Poll::new().unwrap(); + let token = Token(1); + let mut stream = mio::net::TcpStream::from_std(server); + poll.registry().register(&mut stream, token, Interest::READABLE).unwrap(); + + let mut conn = HttpConnection::new(stream); + let req = b"GET /metrics HTTP/1.0\r\nHost: localhost\r\n\r\n"; + conn.read_buf[..req.len()].copy_from_slice(req); + conn.read_end = req.len(); + + dispatch(poll.registry(), &mut conn, token, &|_, out| { + out.extend_from_slice(b"should not appear"); + }) + .unwrap(); + + assert!( + conn.write_buf.starts_with(b"HTTP/1.1 505"), + "expected 505, got: {:?}", + String::from_utf8_lossy(&conn.write_buf) + ); + } + + #[test] + fn token_wrap_skips_listener() { + let mut cur = Token(usize::MAX); + let assigned = next(&mut cur); + assert_ne!(assigned, LISTENER, "returned token must not alias LISTENER"); + assert_ne!(cur, LISTENER, "next token must not alias LISTENER after wrap"); + assert_eq!(cur.0, LISTENER.0 + 1); + } +} diff --git a/crates/bin/Cargo.toml b/crates/bin/Cargo.toml index 65e93075..85442782 100644 --- a/crates/bin/Cargo.toml +++ b/crates/bin/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true version.workspace = true [dependencies] +silver_beacon_api.workspace = true silver_beacon_state.workspace = true silver_beacon_state_data.workspace = true silver_common.workspace = true diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index 98b412b3..dac5703f 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -6,6 +6,7 @@ use flux::{ }; use quinn_proto::{Endpoint, EndpointConfig}; use rand::RngCore; +use silver_beacon_api::BeaconApiTile; use silver_beacon_state::{BeaconStateTile, SlotTicker}; use silver_beacon_state_data::{BeaconState, BeaconStateOwner}; use silver_common::{Enr, ProtoIdentify, SilverSpine, TCache, TCacheProducer}; @@ -114,6 +115,7 @@ fn main() -> Result<(), Box> { None, ), ); + let identify = config.identify()?; let p2p_context = Context { gossip_producer: incoming_gossip_producer, gossip_consumer: outgoing_gossip_producer @@ -121,7 +123,7 @@ fn main() -> Result<(), Box> { .random_access("p2p_outgoing_gossip", true)?, rpc_producer: incoming_rpc_producer, rpc_consumer: outgoing_rpc_producer.cache_ref().random_access("p2p_outgoing_rpc", true)?, - identify: Some(ProtoIdentify::from((&config.identify()?, &keypair))), + identify: Some(ProtoIdentify::from((&identify, &keypair))), }; let now = Instant::now(); @@ -146,6 +148,7 @@ fn main() -> Result<(), Box> { discv5.add_enr(enr, now); } + let beacon_api_tile = BeaconApiTile::new(&keypair, local_enr, &identify); let network_tile = NetworkTile::new(discv5_addr, discv5, p2p_addr, p2p_endpoint, p2p_context)?; let gossip_tile = GossipHandler::new( incoming_gossip_consumer, @@ -215,6 +218,7 @@ fn main() -> Result<(), Box> { attach_tile(network_tile, scoped_spine, TileConfig::new(3, ThreadPriority::OSDefault)); attach_tile(beacon_state_tile, scoped_spine, TileConfig::new(4, ThreadPriority::OSDefault)); attach_tile(storage_tile, scoped_spine, TileConfig::new(5, ThreadPriority::OSDefault)); + attach_tile(beacon_api_tile, scoped_spine, TileConfig::new(6, ThreadPriority::OSDefault)); }); Ok(()) From d096b51473fb1c469ac9bc0dc9f44c7d694e9575 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Mon, 17 Aug 2026 09:25:38 +0100 Subject: [PATCH 02/16] Documentation of planned Beacon API work --- CONTEXT.md | 38 ++++++++++++++++++++++++++ docs/adr/0001-single-api-tile.md | 26 ++++++++++++++++++ docs/adr/0002-hand-rolled-http.md | 21 ++++++++++++++ docs/adr/0003-dispatch-asymmetry.md | 21 ++++++++++++++ docs/adr/0004-sync-materialized-api.md | 23 ++++++++++++++++ 5 files changed, 129 insertions(+) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-single-api-tile.md create mode 100644 docs/adr/0002-hand-rolled-http.md create mode 100644 docs/adr/0003-dispatch-asymmetry.md create mode 100644 docs/adr/0004-sync-materialized-api.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..7da5e644 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,38 @@ +# Silver + +A from-scratch Ethereum beacon node, organised as tiles — independent +pinned-thread components communicating over a typed message spine. + +## Language + +**Tile**: +A component with its own OS thread pinned to a dedicated CPU core, +implementing `loop_body` and attached to the spine. +_Avoid_: service, actor, worker. + +**Spine**: +The process-wide typed message fabric connecting tiles. +_Avoid_: bus, broker. + +**Spine queue**: +A fixed-size lock-free ring on the spine carrying `Copy` messages, broadcast +to consumers. +_Avoid_: channel. + +**TCache**: +The shared-memory bulk store; spine messages carry handles into it instead of +payloads. + +**Hosted crate**: +A transport-free library living inside a tile that owns the loop. Hosted +crates are hardcoded into their tile, not plugins. +_Avoid_: plugin, sub-tile, service. + +**Beacon API**: +The standard Ethereum REST API a beacon node serves; validator clients are +the primary consumers. Served by the `beacon_api` hosted crate. + +**Engine API**: +The standard JSON-RPC protocol between a beacon node and its execution +client. Called by the `engine_api` hosted crate. +_Avoid_: bare "engine" (ambiguous with the execution client itself). diff --git a/docs/adr/0001-single-api-tile.md b/docs/adr/0001-single-api-tile.md new file mode 100644 index 00000000..425964a6 --- /dev/null +++ b/docs/adr/0001-single-api-tile.md @@ -0,0 +1,26 @@ +--- +status: proposed +--- + +# One tile hosts all API access + +Every tile is an OS thread pinned to a dedicated CPU core, and API traffic — +serving the beacon API, calling the engine API — is latency-tolerant work +dominated by network round-trips that cannot justify two pinned cores. All API +access is consolidated into a single `client_server` tile hosting two +transport-free crates: `beacon_api` (HTTP server) and `engine_api` (HTTP +client, renamed from `engine`). Hosted crates are hardcoded and composed by +plain function calls in the tile's `loop_body` — no plugin registry, no +hosting trait; adding a future hosted crate (e.g. a builder-API client or a +`health`/`log_tail` endpoint family) edits the tile, which is a deliberate, +cheap cost. The spine contract is unchanged: producers and consumers of +`engine_reqs`/`engine_resps`/`engine_health` see no difference. + +## Considered options + +Separate tiles per API surface (status quo — wastes a core per surface); a +`Hosted` trait + registry (speculative generality for exactly two crates); +per-crate transport ownership behind a port trait (generics leak into every +hosted crate's signatures). Four independent designs were produced and +compared; see `.local/client-server-design.md` (untracked design notes) for +the full comparison. diff --git a/docs/adr/0002-hand-rolled-http.md b/docs/adr/0002-hand-rolled-http.md new file mode 100644 index 00000000..a3593756 --- /dev/null +++ b/docs/adr/0002-hand-rolled-http.md @@ -0,0 +1,21 @@ +--- +status: proposed +--- + +# Hand-rolled HTTP over mio; no async runtime, no TLS + +API I/O uses the same idiom as the rest of the node: non-blocking mio polled +from a busy-poll loop with `httparse` framing — one shared connection state +machine (crate `httpcore`) serving both roles, server and client — rather +than hyper/axum/reqwest and the tokio runtime they drag in. The node has no +async runtime and will not grow one for its coldest path; the machine already +existed twice (engine `http.rs` and the beacon_api prototype, plus a dead +474-line UDS copy) and, once shared, is small and testable at the byte level. + +Transports are a closed set we control, so they are an enum +(`Tcp | Uds`), not a trait. Unix sockets are supported on both sides: the +beacon_api server bind and the execution endpoint. TLS is a non-goal — all +API connections run over trusted local LAN or VPN. Auth is protocol-layer, +not transport-layer: `engine_api` owns the JWT Authorization header; UDS +relies on socket path permissions, and JWT-over-UDS can be added later as an +`engine_api` config flag without touching the transport layer. diff --git a/docs/adr/0003-dispatch-asymmetry.md b/docs/adr/0003-dispatch-asymmetry.md new file mode 100644 index 00000000..39b87de5 --- /dev/null +++ b/docs/adr/0003-dispatch-asymmetry.md @@ -0,0 +1,21 @@ +--- +status: proposed +--- + +# Dispatch: table for server routes, enum match for client methods + +Beacon-api request routing is a const data table — (method, parameterised +path pattern) → handler function, compiled to segments at init and linearly +scanned. Engine-api call dispatch stays a Rust `match` on closed enums +(`EngineReq` inbound, `ReqKind` on completion). The asymmetry is deliberate: +the server-side endpoint set is open and keyed by runtime wire strings, so a +table earns its keep; the client-side protocol set is closed and minted by +us, where a match is already a compile-time-exhaustive jump table, and a +runtime table would force type erasure over encoders with genuinely +different shapes (TCache handles, the hand-written newPayload envelope), +trading compile errors for runtime failures. + +Do not "fix" this inconsistency by making the client side table-driven: four +independently-produced designs each converged on exactly this split. The +governing principle, which also chose the transport enum in ADR-0002: +**closed set we control → enum; open set from the wire → table.** diff --git a/docs/adr/0004-sync-materialized-api.md b/docs/adr/0004-sync-materialized-api.md new file mode 100644 index 00000000..63e1f0c1 --- /dev/null +++ b/docs/adr/0004-sync-materialized-api.md @@ -0,0 +1,23 @@ +--- +status: proposed +--- + +# Synchronous handlers, materialized responses, no streaming + +Beacon-api handlers are synchronous compute — no I/O, no blocking — invoked +only once a request has fully arrived; responses are materialized in the +connection's write buffer and drained incrementally. All transport pumps are +non-blocking (`poll(Duration::ZERO)`), so serving and engine traffic +interleave per readiness event: a slow API consumer never stalls engine +calls, and vice versa. + +This holds for the whole surface v1 targets: verified against the +beacon-APIs spec and five validator clients (see +`.local/beacon-api-vc-surface.md`, untracked), nothing a validator client +requires streams or long-polls except the optional `/eth/v1/events` SSE +stream, which every surveyed client can replace with polling. v1 answers it +with a clean 404 and tolerates client reconnect retries. If subscriptions +are ever wanted, they may be served out-of-process (e.g. a circular-buffer +export read by a separate serving process) rather than by adding streaming +here. Endpoints whose response cannot be materialized in a bounded buffer +are out of scope by construction; revisit this ADR before accepting one. From b977f93b371521afb46683f1545793083fa5a998 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Mon, 17 Aug 2026 10:46:14 +0100 Subject: [PATCH 03/16] Extract HTTP server byte machine into silver_httpcore First step of the client_server tile consolidation (docs/adr/0001): the HTTP/1.1 connection state machine (parse, keep-alive, pipelining, response framing) moves out of beacon_api into a new transport-free crate with bytes-only interfaces, so it can be tested without sockets and shared with the client role next. beacon_api keeps its tile, poll, and endpoints unchanged; behavior is byte-identical. Framing tests move with the machine and gain deterministic chunking coverage (single-byte feeds, pipelined requests split across feeds, oversize rejection, dispatch-after-drain). Assisted-by: Claude:claude-fable-5 --- Cargo.lock | 10 +- Cargo.toml | 2 + crates/beacon_api/Cargo.toml | 2 +- crates/beacon_api/src/tile.rs | 289 ++++--------------------- crates/httpcore/Cargo.toml | 13 ++ crates/httpcore/src/lib.rs | 3 + crates/httpcore/src/server.rs | 383 ++++++++++++++++++++++++++++++++++ 7 files changed, 446 insertions(+), 256 deletions(-) create mode 100644 crates/httpcore/Cargo.toml create mode 100644 crates/httpcore/src/lib.rs create mode 100644 crates/httpcore/src/server.rs diff --git a/Cargo.lock b/Cargo.lock index 36ccc6e9..4ab273dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4449,11 +4449,11 @@ version = "0.0.1" dependencies = [ "flux", "hex", - "httparse", "mio", "serde", "serde_json", "silver_common", + "silver_httpcore", "tracing", ] @@ -4686,6 +4686,14 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "silver_httpcore" +version = "0.0.1" +dependencies = [ + "httparse", + "tracing", +] + [[package]] name = "silver_metrics" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index b0d71d02..43387908 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/discovery", "crates/e2e", "crates/gossip", + "crates/httpcore", "crates/engine", "crates/metrics", "crates/network", @@ -73,6 +74,7 @@ silver_ssz = { path = "crates/ssz" } silver_control = { path = "crates/control" } silver_discovery = {path = "crates/discovery" } silver_gossip = {path = "crates/gossip" } +silver_httpcore = { path = "crates/httpcore" } silver_network = {path = "crates/network" } silver_peer = {path = "crates/peer" } silver_storage = { path = "crates/storage" } diff --git a/crates/beacon_api/Cargo.toml b/crates/beacon_api/Cargo.toml index d655e1f0..ad6cda2c 100644 --- a/crates/beacon_api/Cargo.toml +++ b/crates/beacon_api/Cargo.toml @@ -10,10 +10,10 @@ flux.workspace = true hex.workspace = true mio.workspace = true silver_common.workspace = true +silver_httpcore.workspace = true serde.workspace = true tracing.workspace = true serde_json = "1.0.149" -httparse = "1.10.1" [lints] workspace = true diff --git a/crates/beacon_api/src/tile.rs b/crates/beacon_api/src/tile.rs index c6923e68..19ad2ebe 100644 --- a/crates/beacon_api/src/tile.rs +++ b/crates/beacon_api/src/tile.rs @@ -11,29 +11,12 @@ use mio::{ }; use serde::{Deserialize, Serialize}; use silver_common::{Enr, Eth2Addr, Identify, Keypair, SilverSpine}; +use silver_httpcore::{AfterResponse, ParsedRequest, ServerConnection, frame_response}; const LISTENER: Token = Token(0); const IDENTITY_PATH: &str = "/eth/v1/node/identity"; const METRICS_PATH: &str = "/metrics"; -const NOT_FOUND: &[u8] = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"; -const VERSION_NOT_SUPPORTED: &[u8] = - b"HTTP/1.1 505 HTTP Version Not Supported\r\nContent-Length: 0\r\n\r\n"; -const METRICS_EMPTY: &[u8] = - b"HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4; charset=utf-8\r\nContent-Length: 0\r\n\r\n"; -// Hard cap on the read buffer. Raw SSZ, uncompressed. 16 MiB matches observed -// production maximums (21 blobs × 128 KiB plus block fields). -const READ_BUF_MAX: usize = 16 << 20; -const WRITE_BUF_INIT: usize = 4096; - -#[allow(dead_code)] -struct ParsedRequest<'a> { - method: &'a str, - path: &'a str, - query: &'a str, - body: &'a [u8], - version: u8, - keep_alive: bool, -} +const METRICS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8"; #[derive(Debug, Serialize)] struct IdentityResponse<'a> { @@ -57,33 +40,9 @@ struct Metadata { custody_group_count: String, } -struct HttpConnection { +struct Connection { stream: TcpStream, - read_buf: Box<[u8; READ_BUF_MAX]>, - read_pos: usize, - read_end: usize, - write_buf: Vec, - write_pos: usize, - keep_alive: bool, -} - -impl HttpConnection { - fn new(stream: TcpStream) -> Self { - Self { - stream, - read_buf: Box::new([0u8; READ_BUF_MAX]), - read_pos: 0, - read_end: 0, - write_buf: Vec::with_capacity(WRITE_BUF_INIT), - write_pos: 0, - keep_alive: true, - } - } - - fn reset(&mut self) { - self.write_buf.clear(); - self.write_pos = 0; - } + http: ServerConnection, } pub struct BeaconApiTile { @@ -91,7 +50,7 @@ pub struct BeaconApiTile { events: Events, listener: TcpListener, current_token: Token, - connections: HashMap, + connections: HashMap, identity_response: Vec, } @@ -133,9 +92,15 @@ impl Tile for BeaconApiTile { tracing::info!("accepted connection from {address}"); let token = next(&mut self.current_token); self.poll.registry().register(&mut stream, token, Interest::READABLE).unwrap(); - self.connections.insert(token, HttpConnection::new(stream)); + self.connections + .insert(token, Connection { stream, http: ServerConnection::new() }); } token => { + // TODO: path routing here is exact-match only. Most beacon + // API paths are parameterised + // (/eth/v1/beacon/states/{state_id}/...). Add + // prefix/pattern matching before implementing any + // parameterised routes. if let Some(conn) = self.connections.get_mut(&token) { match handle_event(self.poll.registry(), conn, event, &|req, out| match req .path @@ -167,58 +132,48 @@ fn handle_identity(response: &[u8], out: &mut Vec) { } fn handle_metrics(out: &mut Vec) { - out.extend_from_slice(METRICS_EMPTY); + frame_response(out, "200 OK", Some(METRICS_CONTENT_TYPE), b""); } fn handle_unknown(path: &str, out: &mut Vec) { tracing::warn!("unknown path: {path}"); - out.extend_from_slice(NOT_FOUND); + frame_response(out, "404 Not Found", None, b""); } -// TODO: write_buf materialises the full response in heap memory. For large -// payloads (beacon states >200 MiB, blocks, blobs) replace with scatter-gather -// streaming: hold a tcache snapshot reference and write headers + body via -// write_vectored without copying. The write loop already drains by position so -// the structure supports a multi-part write state without changes to the outer -// logic. -// -// TODO: path routing here is exact-match only. Most beacon API paths are -// parameterised (/eth/v1/beacon/states/{state_id}/...). Add prefix/pattern -// matching before implementing any parameterised routes. fn handle_event, &mut Vec)>( registry: &mio::Registry, - conn: &mut HttpConnection, + conn: &mut Connection, event: &mio::event::Event, request_handler: &F, ) -> io::Result { if event.is_readable() { loop { - if conn.read_end == READ_BUF_MAX { - return Err(io::Error::new(io::ErrorKind::InvalidData, "request too large")); - } - match conn.stream.read(&mut conn.read_buf[conn.read_end..]) { + let space = conn.http.read_space()?; + match conn.stream.read(space) { Ok(0) => return Err(io::Error::from(io::ErrorKind::UnexpectedEof)), - Ok(n) => conn.read_end += n, + Ok(n) => conn.http.commit_read(n), Err(e) if would_block(&e) => break, Err(e) if interrupted(&e) => continue, Err(e) => return Err(e), } } - dispatch(registry, conn, event.token(), request_handler)?; + if conn.http.dispatch(request_handler) { + registry.reregister(&mut conn.stream, event.token(), Interest::WRITABLE)?; + } return Ok(false); } if event.is_writable() { - if conn.write_pos < conn.write_buf.len() { + if !conn.http.pending_write().is_empty() { loop { - match conn.stream.write(&conn.write_buf[conn.write_pos..]) { + match conn.stream.write(conn.http.pending_write()) { Ok(0) => { return Err(io::Error::new(io::ErrorKind::WriteZero, "write returned 0")) } Ok(n) => { - conn.write_pos += n; - if conn.write_pos == conn.write_buf.len() { + conn.http.commit_write(n); + if conn.http.pending_write().is_empty() { break; } } @@ -227,16 +182,14 @@ fn handle_event, &mut Vec)>( Err(e) => return Err(e), } } - if conn.keep_alive { - conn.reset(); - // Serve any pipelined request buffered while we were writing. - // Without this, edge-triggered epoll won't re-fire for data - // that's already in read_buf. - if !dispatch(registry, conn, event.token(), request_handler)? { - registry.reregister(&mut conn.stream, event.token(), Interest::READABLE)?; + match conn.http.after_response(request_handler) { + AfterResponse::Close => return Ok(true), + AfterResponse::ResponsePending => { + registry.reregister(&mut conn.stream, event.token(), Interest::WRITABLE)? + } + AfterResponse::AwaitRequest => { + registry.reregister(&mut conn.stream, event.token(), Interest::READABLE)? } - } else { - return Ok(true); } } return Ok(false); @@ -245,69 +198,6 @@ fn handle_event, &mut Vec)>( Ok(false) } -fn dispatch, &mut Vec)>( - registry: &mio::Registry, - conn: &mut HttpConnection, - token: Token, - handler: &F, -) -> io::Result { - let Some((consumed, req)) = try_parse_request(&conn.read_buf[conn.read_pos..conn.read_end]) - else { - return Ok(false); - }; - if req.version != 1 { - tracing::warn!("rejecting HTTP/1.0 request"); - conn.keep_alive = false; - conn.write_buf.extend_from_slice(VERSION_NOT_SUPPORTED); - } else { - conn.keep_alive = req.keep_alive; - handler(&req, &mut conn.write_buf); - } - conn.read_pos += consumed; - //TODO: check if we actually need to support pipeling. If not, we can simplify - // this. - if conn.read_pos == conn.read_end { - conn.read_pos = 0; - conn.read_end = 0; - } - registry.reregister(&mut conn.stream, token, Interest::WRITABLE)?; - Ok(true) -} - -fn try_parse_request(buf: &[u8]) -> Option<(usize, ParsedRequest<'_>)> { - let mut headers = [httparse::EMPTY_HEADER; 64]; - let mut req = httparse::Request::new(&mut headers); - let headers_end = match req.parse(buf) { - Ok(httparse::Status::Complete(n)) => n, - _ => return None, - }; - let method = req.method?; - let raw_path = req.path?; - let (path, query) = raw_path.split_once('?').unwrap_or((raw_path, "")); - let version = req.version?; - let keep_alive = version == 1 && - !headers.iter().any(|h| { - h.name.eq_ignore_ascii_case("connection") && h.value.eq_ignore_ascii_case(b"close") - }); - let content_length: usize = - match headers.iter().find(|h| h.name.eq_ignore_ascii_case("content-length")) { - None => 0, - Some(h) => std::str::from_utf8(h.value).ok().and_then(|v| v.trim().parse().ok())?, - }; - let total = headers_end + content_length; - if buf.len() < total { - return None; - } - Some((total, ParsedRequest { - method, - path, - query, - body: &buf[headers_end..total], - version, - keep_alive, - })) -} - fn build_identity_response(keypair: &Keypair, local_enr: &Enr, identify: &Identify) -> Vec { let pid_multiaddr = Eth2Addr::PeerId(keypair.peer_id()).to_string(); let peer_id_str = pid_multiaddr.strip_prefix("/p2p/").unwrap_or(&pid_multiaddr); @@ -358,12 +248,9 @@ fn build_identity_response(keypair: &Keypair, local_enr: &Enr, identify: &Identi }; let body = serde_json::to_string(&IdentityResponse { data: &identity }).unwrap(); - format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", - body.len(), - body - ) - .into_bytes() + let mut response = Vec::new(); + frame_response(&mut response, "200 OK", Some("application/json"), body.as_bytes()); + response } fn next(current: &mut Token) -> Token { @@ -388,76 +275,6 @@ mod tests { use super::*; - fn get_req(path: &str, version: &str) -> Vec { - format!("GET {path} {version}\r\nHost: localhost\r\n\r\n").into_bytes() - } - - #[test] - fn parse_http11_defaults_keep_alive() { - let req = get_req("/eth/v1/node/identity", "HTTP/1.1"); - let (_, r) = try_parse_request(&req).unwrap(); - assert_eq!(r.path, "/eth/v1/node/identity"); - assert!(r.keep_alive); - } - - #[test] - fn parse_http11_connection_close() { - let req = b"GET /metrics HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; - let (_, r) = try_parse_request(req).unwrap(); - assert_eq!(r.path, "/metrics"); - assert!(!r.keep_alive); - } - - #[test] - fn parse_http10_defaults_close() { - let req = get_req("/", "HTTP/1.0"); - let (_, r) = try_parse_request(&req).unwrap(); - assert!(!r.keep_alive); - } - - #[test] - fn parse_partial_returns_none() { - assert!(try_parse_request(b"GET /eth/v1/node/identity HTTP/1.1\r\n").is_none()); - } - - #[test] - fn parse_query_string_split() { - let req = get_req("/eth/v1/beacon/states/head/validators?status=active", "HTTP/1.1"); - let (_, r) = try_parse_request(&req).unwrap(); - assert_eq!(r.path, "/eth/v1/beacon/states/head/validators"); - assert_eq!(r.query, "status=active"); - } - - #[test] - fn parse_post_body_buffered() { - let body = b"{\"slot\":\"1\"}"; - let req = format!( - "POST /eth/v1/beacon/blocks HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\n\r\n", - body.len() - ); - let mut buf = req.into_bytes(); - // incomplete — body not yet arrived - assert!(try_parse_request(&buf).is_none()); - buf.extend_from_slice(body); - let (consumed, r) = try_parse_request(&buf).unwrap(); - assert_eq!(r.method, "POST"); - assert_eq!(r.body, body.as_ref()); - assert_eq!(consumed, buf.len()); - } - - #[test] - fn parse_returns_consumed_byte_count() { - let req1 = b"GET /metrics HTTP/1.1\r\nHost: localhost\r\n\r\n"; - let req2 = b"GET /eth/v1/node/identity HTTP/1.1\r\nHost: localhost\r\n\r\n"; - let mut buf = req1.to_vec(); - buf.extend_from_slice(req2); - let (consumed, r) = try_parse_request(&buf).unwrap(); - assert_eq!(r.path, "/metrics"); - assert_eq!(consumed, req1.len()); - let (_, r2) = try_parse_request(&buf[consumed..]).unwrap(); - assert_eq!(r2.path, "/eth/v1/node/identity"); - } - #[test] fn metrics_response_valid_prometheus_format() { let mut out = Vec::new(); @@ -530,42 +347,6 @@ mod tests { assert!(addr.starts_with("/ip4/1.2.3.4/tcp/9000/p2p/"), "bad format: {addr}"); } - #[test] - fn parse_invalid_content_length_returns_none() { - let req = b"POST /foo HTTP/1.1\r\nHost: localhost\r\nContent-Length: abc\r\n\r\n"; - assert!(try_parse_request(req).is_none()); - } - - #[test] - fn dispatch_http10_writes_version_not_supported() { - let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = std_listener.local_addr().unwrap(); - let _client = std::net::TcpStream::connect(addr).unwrap(); - let (server, _) = std_listener.accept().unwrap(); - server.set_nonblocking(true).unwrap(); - - let poll = Poll::new().unwrap(); - let token = Token(1); - let mut stream = mio::net::TcpStream::from_std(server); - poll.registry().register(&mut stream, token, Interest::READABLE).unwrap(); - - let mut conn = HttpConnection::new(stream); - let req = b"GET /metrics HTTP/1.0\r\nHost: localhost\r\n\r\n"; - conn.read_buf[..req.len()].copy_from_slice(req); - conn.read_end = req.len(); - - dispatch(poll.registry(), &mut conn, token, &|_, out| { - out.extend_from_slice(b"should not appear"); - }) - .unwrap(); - - assert!( - conn.write_buf.starts_with(b"HTTP/1.1 505"), - "expected 505, got: {:?}", - String::from_utf8_lossy(&conn.write_buf) - ); - } - #[test] fn token_wrap_skips_listener() { let mut cur = Token(usize::MAX); diff --git a/crates/httpcore/Cargo.toml b/crates/httpcore/Cargo.toml new file mode 100644 index 00000000..6a8e247b --- /dev/null +++ b/crates/httpcore/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "silver_httpcore" +edition.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +httparse.workspace = true +tracing.workspace = true + +[lints] +workspace = true diff --git a/crates/httpcore/src/lib.rs b/crates/httpcore/src/lib.rs new file mode 100644 index 00000000..c15ea426 --- /dev/null +++ b/crates/httpcore/src/lib.rs @@ -0,0 +1,3 @@ +mod server; + +pub use server::{AfterResponse, ParsedRequest, ServerConnection, frame_response}; diff --git a/crates/httpcore/src/server.rs b/crates/httpcore/src/server.rs new file mode 100644 index 00000000..4371a46c --- /dev/null +++ b/crates/httpcore/src/server.rs @@ -0,0 +1,383 @@ +use std::io::{self, Write}; + +// Hard cap on the read buffer. Raw SSZ, uncompressed. 16 MiB matches observed +// production maximums (21 blobs × 128 KiB plus block fields). +const READ_BUF_MAX: usize = 16 << 20; +const WRITE_BUF_INIT: usize = 4096; + +pub struct ParsedRequest<'a> { + pub method: &'a str, + pub path: &'a str, + pub query: &'a str, + pub body: &'a [u8], + pub version: u8, + pub keep_alive: bool, +} + +impl<'a> ParsedRequest<'a> { + fn parse(buf: &'a [u8]) -> Option<(usize, Self)> { + let mut headers = [httparse::EMPTY_HEADER; 64]; + let mut req = httparse::Request::new(&mut headers); + let headers_end = match req.parse(buf) { + Ok(httparse::Status::Complete(n)) => n, + _ => return None, + }; + let method = req.method?; + let raw_path = req.path?; + let (path, query) = raw_path.split_once('?').unwrap_or((raw_path, "")); + let version = req.version?; + let keep_alive = version == 1 && + !headers.iter().any(|h| { + h.name.eq_ignore_ascii_case("connection") && h.value.eq_ignore_ascii_case(b"close") + }); + let content_length: usize = + match headers.iter().find(|h| h.name.eq_ignore_ascii_case("content-length")) { + None => 0, + Some(h) => std::str::from_utf8(h.value).ok().and_then(|v| v.trim().parse().ok())?, + }; + let total = headers_end + content_length; + if buf.len() < total { + return None; + } + Some((total, Self { + method, + path, + query, + body: &buf[headers_end..total], + version, + keep_alive, + })) + } +} + +#[derive(Debug, PartialEq)] +#[must_use] +pub enum AfterResponse { + Close, + ResponsePending, + AwaitRequest, +} + +pub struct ServerConnection { + read_buf: Box<[u8; READ_BUF_MAX]>, + read_pos: usize, + read_end: usize, + write_buf: Vec, + write_pos: usize, + keep_alive: bool, +} + +impl ServerConnection { + pub fn new() -> Self { + Self { + read_buf: Box::new([0u8; READ_BUF_MAX]), + read_pos: 0, + read_end: 0, + write_buf: Vec::with_capacity(WRITE_BUF_INIT), + write_pos: 0, + keep_alive: true, + } + } + + pub fn read_space(&mut self) -> io::Result<&mut [u8]> { + if self.read_end == READ_BUF_MAX { + return Err(io::Error::new(io::ErrorKind::InvalidData, "request too large")); + } + Ok(&mut self.read_buf[self.read_end..]) + } + + pub fn commit_read(&mut self, n: usize) { + debug_assert!(self.read_end + n <= READ_BUF_MAX); + self.read_end += n; + } + + pub fn dispatch, &mut Vec)>(&mut self, handler: &F) -> bool { + let Some((consumed, req)) = + ParsedRequest::parse(&self.read_buf[self.read_pos..self.read_end]) + else { + return false; + }; + if req.version != 1 { + tracing::warn!("rejecting HTTP/1.0 request"); + self.keep_alive = false; + frame_response(&mut self.write_buf, "505 HTTP Version Not Supported", None, b""); + } else { + self.keep_alive = req.keep_alive; + handler(&req, &mut self.write_buf); + } + self.read_pos += consumed; + if self.read_pos == self.read_end { + self.read_pos = 0; + self.read_end = 0; + } + true + } + + pub fn pending_write(&self) -> &[u8] { + &self.write_buf[self.write_pos..] + } + + pub fn commit_write(&mut self, n: usize) { + debug_assert!(self.write_pos + n <= self.write_buf.len()); + self.write_pos += n; + } + + pub fn after_response, &mut Vec)>( + &mut self, + handler: &F, + ) -> AfterResponse { + debug_assert!(self.write_pos == self.write_buf.len()); + if !self.keep_alive { + return AfterResponse::Close; + } + self.write_buf.clear(); + self.write_pos = 0; + // A request pipelined behind the one just answered is already in + // read_buf — the transport will never feed those bytes again, so it + // must be dispatched here or it never will be. + if self.dispatch(handler) { + AfterResponse::ResponsePending + } else { + AfterResponse::AwaitRequest + } + } +} + +impl Default for ServerConnection { + fn default() -> Self { + Self::new() + } +} + +pub fn frame_response(out: &mut Vec, status: &str, content_type: Option<&str>, body: &[u8]) { + match content_type { + Some(ct) => write!( + out, + "HTTP/1.1 {status}\r\nContent-Type: {ct}\r\nContent-Length: {}\r\n\r\n", + body.len() + ), + None => write!(out, "HTTP/1.1 {status}\r\nContent-Length: {}\r\n\r\n", body.len()), + } + .unwrap(); + out.extend_from_slice(body); +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + + use super::*; + + fn get_req(path: &str, version: &str) -> Vec { + format!("GET {path} {version}\r\nHost: localhost\r\n\r\n").into_bytes() + } + + fn feed(conn: &mut ServerConnection, bytes: &[u8]) { + let space = conn.read_space().unwrap(); + space[..bytes.len()].copy_from_slice(bytes); + conn.commit_read(bytes.len()); + } + + fn drain(conn: &mut ServerConnection) -> Vec { + let out = conn.pending_write().to_vec(); + conn.commit_write(out.len()); + out + } + + fn echo_path(req: &ParsedRequest<'_>, out: &mut Vec) { + frame_response(out, "200 OK", None, req.path.as_bytes()); + } + + #[test] + fn parse_http11_defaults_keep_alive() { + let req = get_req("/eth/v1/node/identity", "HTTP/1.1"); + let (_, r) = ParsedRequest::parse(&req).unwrap(); + assert_eq!(r.path, "/eth/v1/node/identity"); + assert!(r.keep_alive); + } + + #[test] + fn parse_http11_connection_close() { + let req = b"GET /metrics HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + let (_, r) = ParsedRequest::parse(req).unwrap(); + assert_eq!(r.path, "/metrics"); + assert!(!r.keep_alive); + } + + #[test] + fn parse_http10_defaults_close() { + let req = get_req("/", "HTTP/1.0"); + let (_, r) = ParsedRequest::parse(&req).unwrap(); + assert!(!r.keep_alive); + } + + #[test] + fn parse_partial_returns_none() { + assert!(ParsedRequest::parse(b"GET /eth/v1/node/identity HTTP/1.1\r\n").is_none()); + } + + #[test] + fn parse_query_string_split() { + let req = get_req("/eth/v1/beacon/states/head/validators?status=active", "HTTP/1.1"); + let (_, r) = ParsedRequest::parse(&req).unwrap(); + assert_eq!(r.path, "/eth/v1/beacon/states/head/validators"); + assert_eq!(r.query, "status=active"); + } + + #[test] + fn parse_post_body_buffered() { + let body = b"{\"slot\":\"1\"}"; + let req = format!( + "POST /eth/v1/beacon/blocks HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\n\r\n", + body.len() + ); + let mut buf = req.into_bytes(); + // incomplete — body not yet arrived + assert!(ParsedRequest::parse(&buf).is_none()); + buf.extend_from_slice(body); + let (consumed, r) = ParsedRequest::parse(&buf).unwrap(); + assert_eq!(r.method, "POST"); + assert_eq!(r.body, body.as_ref()); + assert_eq!(consumed, buf.len()); + } + + #[test] + fn parse_returns_consumed_byte_count() { + let req1 = b"GET /metrics HTTP/1.1\r\nHost: localhost\r\n\r\n"; + let req2 = b"GET /eth/v1/node/identity HTTP/1.1\r\nHost: localhost\r\n\r\n"; + let mut buf = req1.to_vec(); + buf.extend_from_slice(req2); + let (consumed, r) = ParsedRequest::parse(&buf).unwrap(); + assert_eq!(r.path, "/metrics"); + assert_eq!(consumed, req1.len()); + let (_, r2) = ParsedRequest::parse(&buf[consumed..]).unwrap(); + assert_eq!(r2.path, "/eth/v1/node/identity"); + } + + #[test] + fn parse_invalid_content_length_returns_none() { + let req = b"POST /foo HTTP/1.1\r\nHost: localhost\r\nContent-Length: abc\r\n\r\n"; + assert!(ParsedRequest::parse(req).is_none()); + } + + #[test] + fn frame_response_without_content_type_omits_header() { + let mut out = Vec::new(); + frame_response(&mut out, "404 Not Found", None, b""); + assert_eq!(out, b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"); + } + + #[test] + fn frame_response_content_length_matches_body() { + let mut out = Vec::new(); + frame_response(&mut out, "200 OK", Some("application/json"), b"{\"data\":1}"); + assert_eq!( + out, + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 10\r\n\r\n{\"data\":1}" + ); + } + + #[test] + fn dispatch_http10_writes_version_not_supported_then_closes() { + let mut conn = ServerConnection::new(); + feed(&mut conn, b"GET /metrics HTTP/1.0\r\nHost: localhost\r\n\r\n"); + + assert!(conn.dispatch(&|_, out: &mut Vec| { + out.extend_from_slice(b"should not appear"); + })); + assert_eq!( + conn.pending_write(), + b"HTTP/1.1 505 HTTP Version Not Supported\r\nContent-Length: 0\r\n\r\n" + ); + + drain(&mut conn); + assert_eq!(conn.after_response(&echo_path), AfterResponse::Close); + } + + #[test] + fn connection_close_request_closes_after_response() { + let mut conn = ServerConnection::new(); + feed(&mut conn, b"GET /metrics HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + + assert!(conn.dispatch(&echo_path)); + drain(&mut conn); + assert_eq!(conn.after_response(&echo_path), AfterResponse::Close); + } + + #[test] + fn request_fed_one_byte_at_a_time() { + let mut conn = ServerConnection::new(); + let req = get_req("/metrics", "HTTP/1.1"); + + for (i, byte) in req.iter().enumerate() { + feed(&mut conn, &[*byte]); + assert_eq!(conn.dispatch(&echo_path), i == req.len() - 1, "byte {i}"); + } + assert_eq!(conn.pending_write(), b"HTTP/1.1 200 OK\r\nContent-Length: 8\r\n\r\n/metrics"); + + drain(&mut conn); + assert_eq!(conn.after_response(&echo_path), AfterResponse::AwaitRequest); + } + + #[test] + fn pipelined_requests_split_across_feeds_respond_in_order() { + let mut conn = ServerConnection::new(); + + feed(&mut conn, b"GET /first HTTP/1.1\r\nHost: x\r\n\r\nGET /sec"); + assert!(conn.dispatch(&echo_path)); + assert_eq!(drain(&mut conn), b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\n/first"); + assert_eq!(conn.after_response(&echo_path), AfterResponse::AwaitRequest); + + feed(&mut conn, b"ond HTTP/1.1\r\nHost: x\r\n\r\n"); + assert!(conn.dispatch(&echo_path)); + assert_eq!(drain(&mut conn), b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\n\r\n/second"); + assert_eq!(conn.after_response(&echo_path), AfterResponse::AwaitRequest); + } + + #[test] + fn buffered_pipelined_request_dispatched_after_drain() { + let mut conn = ServerConnection::new(); + let calls = RefCell::new(Vec::new()); + let handler = |req: &ParsedRequest<'_>, out: &mut Vec| { + calls.borrow_mut().push(req.path.to_string()); + echo_path(req, out); + }; + + feed( + &mut conn, + b"GET /first HTTP/1.1\r\nHost: x\r\n\r\nGET /second HTTP/1.1\r\nHost: x\r\n\r\n", + ); + assert!(conn.dispatch(&handler)); + assert_eq!(*calls.borrow(), ["/first"]); + + let mut written = Vec::new(); + while !conn.pending_write().is_empty() { + let chunk_len = conn.pending_write().len().min(3); + written.extend_from_slice(&conn.pending_write()[..chunk_len]); + conn.commit_write(chunk_len); + assert_eq!(*calls.borrow(), ["/first"], "no dispatch mid-drain"); + } + assert_eq!(written, b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\n/first"); + + assert_eq!(conn.after_response(&handler), AfterResponse::ResponsePending); + assert_eq!(*calls.borrow(), ["/first", "/second"]); + assert_eq!(conn.pending_write(), b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\n\r\n/second"); + } + + #[test] + fn read_space_exhausted_rejects_request_too_large() { + let mut conn = ServerConnection::new(); + let space = conn.read_space().unwrap(); + let header = b"POST /big HTTP/1.1\r\nHost: localhost\r\nContent-Length: 33554432\r\n\r\n"; + space[..header.len()].copy_from_slice(header); + let n = space.len(); + conn.commit_read(n); + + assert!( + !conn.dispatch(&|_, _: &mut Vec| panic!("incomplete request must not dispatch")) + ); + let err = conn.read_space().unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert_eq!(err.to_string(), "request too large"); + } +} From 42d86da0cb41e965cf5ca804eca5c7f3a831be37 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Mon, 17 Aug 2026 11:33:59 +0100 Subject: [PATCH 04/16] Move HTTP client machine into silver_httpcore; delete dead IPC transport Second step of the client_server consolidation (docs/adr/0001, 0002): the engine's connection byte machine (request framing, Content-Length response parsing, partial-I/O resumption) moves to silver_httpcore as the client-role sibling of the server machine, and the transport becomes the closed-set Stream enum (Tcp | Uds). The newline-framed ipc.rs (dead code) is deleted; Unix-socket support is now the same HTTP pool over Stream::Uds, proven by a real UDS round-trip test asserting the JWT bearer header on the wire. Engine keeps all protocol: pool policy, JSON-RPC, JWT, correlation, ReqKind dispatch. The newPayload transcode path is untouched (verified: one body copy before and after). Request framing is pinned by golden-byte tests captured from the previous implementation. New: EngineConfig::max_connections (default 32) bounds the previously unbounded pool; spine intake gates on pool capacity via consume_one, so excess requests wait on the queue. Healthcheck issuance gates on capacity too. A connect that cannot start (resolve/connect/register error) now fails the rpc through the normal error path instead of stranding it forever -- previously masked by unbounded pool growth, fatal under a cap. Behavior notes: an empty Content-Length value is now rejected instead of read as zero; the Connecting-state error checks for UDS follow the TCP shape (the old distrusting variant was unreachable dead code). Known limitation (follow-up tracked in Linear): no per-request deadline, so an EL that accepts requests but never responds can gate intake while the engine_reqs ring (1024 slots) overwrites oldest entries. Assisted-by: Claude:claude-fable-5 --- Cargo.lock | 3 + crates/config/src/engine_config.rs | 7 + crates/engine/Cargo.toml | 4 +- crates/engine/src/client.rs | 68 ++--- crates/engine/src/error.rs | 2 - crates/engine/src/http.rs | 474 ----------------------------- crates/engine/src/ipc.rs | 267 ---------------- crates/engine/src/lib.rs | 5 +- crates/engine/src/pool.rs | 451 +++++++++++++++++++++++++++ crates/engine/src/test_el.rs | 186 +++++++++++ crates/engine/src/tile.rs | 152 ++++++++- crates/httpcore/Cargo.toml | 1 + crates/httpcore/src/client.rs | 343 +++++++++++++++++++++ crates/httpcore/src/lib.rs | 4 + crates/httpcore/src/stream.rs | 170 +++++++++++ 15 files changed, 1336 insertions(+), 801 deletions(-) delete mode 100644 crates/engine/src/http.rs delete mode 100644 crates/engine/src/ipc.rs create mode 100644 crates/engine/src/pool.rs create mode 100644 crates/engine/src/test_el.rs create mode 100644 crates/httpcore/src/client.rs create mode 100644 crates/httpcore/src/stream.rs diff --git a/Cargo.lock b/Cargo.lock index 4ab273dc..6198339b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4660,7 +4660,9 @@ dependencies = [ "sha2", "silver_common", "silver_config", + "silver_httpcore", "simd-json", + "tempfile", "thiserror 1.0.69", "tracing", "tracing-subscriber", @@ -4691,6 +4693,7 @@ name = "silver_httpcore" version = "0.0.1" dependencies = [ "httparse", + "mio", "tracing", ] diff --git a/crates/config/src/engine_config.rs b/crates/config/src/engine_config.rs index 718cf84a..f48d9405 100644 --- a/crates/config/src/engine_config.rs +++ b/crates/config/src/engine_config.rs @@ -4,6 +4,10 @@ fn default_tcache_size() -> usize { 2 << 24 } +fn default_max_connections() -> usize { + 32 +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct EngineConfig { pub execution_endpoint: String, @@ -11,6 +15,8 @@ pub struct EngineConfig { pub jwt_secret: String, #[serde(default = "default_tcache_size")] pub incoming_engine_resp_tcache_size: usize, + #[serde(default = "default_max_connections")] + pub max_connections: usize, /// Unsafe testing mode: do not connect to the EL. The engine tile answers /// every spine request with a synthetic VALID response. Lets the CL run /// without an execution client. Never enable in production. @@ -24,6 +30,7 @@ impl Default for EngineConfig { execution_endpoint: "http://localhost:8551".into(), jwt_secret: "0".into(), incoming_engine_resp_tcache_size: 2 << 24, + max_connections: 32, unsafe_no_el: false, } } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index c733a4e1..2f6d0b68 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -11,17 +11,19 @@ base64.workspace = true flux.workspace = true hex.workspace = true hmac.workspace = true -httparse.workspace = true mio.workspace = true rustc-hash.workspace = true serde.workspace = true simd-json.workspace = true sha2.workspace = true silver_common.workspace = true +silver_httpcore.workspace = true thiserror.workspace = true tracing.workspace = true [dev-dependencies] +httparse.workspace = true +tempfile = "3" tracing-subscriber.workspace = true [lints] diff --git a/crates/engine/src/client.rs b/crates/engine/src/client.rs index 737e2849..2cadb460 100644 --- a/crates/engine/src/client.rs +++ b/crates/engine/src/client.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{path::PathBuf, time::Duration}; use mio::{Events, Poll}; use rustc_hash::FxHashMap; @@ -6,8 +6,7 @@ use silver_common::merkle::B256; use crate::{ EngineError, JwtSecret, - http::{HttpPool, http_pool_enqueue, poll_http_pool}, - ipc::{IpcPool, ipc_pool_enqueue, poll_ipc_pool}, + pool::{Endpoint, HttpPool}, types::{ ForkchoiceState, PayloadAttributesV3, write_new_payload_params_fulu, write_new_payload_params_gloas, @@ -45,13 +44,8 @@ pub enum ReqKind { GetPayloadBodiesByRange(u64), } -enum Transport { - Http(HttpPool), - Ipc(IpcPool), -} - pub struct EngineClient { - transport: Transport, + pool: HttpPool, poll: Poll, events: Events, id: u64, @@ -61,10 +55,18 @@ pub struct EngineClient { } impl EngineClient { - pub fn new(endpoint: impl Into, jwt: &str) -> Self { + pub fn new(endpoint: impl Into, jwt: &str, max_connections: usize) -> Self { + Self::with_endpoint(Endpoint::Http(endpoint.into()), jwt, max_connections) + } + + pub fn new_uds(path: impl Into, jwt: &str, max_connections: usize) -> Self { + Self::with_endpoint(Endpoint::Uds(path.into()), jwt, max_connections) + } + + fn with_endpoint(endpoint: Endpoint, jwt: &str, max_connections: usize) -> Self { let jwt = JwtSecret::from_file(jwt).unwrap_or_else(|e| panic!("invalid JWT secret: {e}")); Self { - transport: Transport::Http(HttpPool::new(endpoint.into(), jwt)), + pool: HttpPool::new(endpoint, jwt, max_connections), poll: Poll::new().expect("mio Poll::new failed"), events: Events::with_capacity(EVENTS_CAPACITY), id: 1, @@ -74,16 +76,8 @@ impl EngineClient { } } - pub fn new_ipc(path: impl Into) -> Self { - Self { - transport: Transport::Ipc(IpcPool::new(path.into())), - poll: Poll::new().expect("mio Poll::new failed"), - events: Events::with_capacity(EVENTS_CAPACITY), - id: 1, - pending_requests: FxHashMap::default(), - get_payload_method: "engine_getPayloadV3", - scratch: Vec::with_capacity(SCRATCH_CAPACITY), - } + pub fn has_capacity(&self) -> bool { + self.pool.has_capacity() } } @@ -114,10 +108,7 @@ fn enqueue(c: &mut EngineClient, rpc_id: u64, body: &simd_json::OwnedValue) { tracing::warn!("failed to serialize RPC body: {e}"); return; } - match &mut c.transport { - Transport::Http(p) => http_pool_enqueue(p, rpc_id, &c.scratch, &mut c.poll), - Transport::Ipc(p) => ipc_pool_enqueue(p, rpc_id, &c.scratch, &mut c.poll), - } + c.pool.enqueue(rpc_id, &c.scratch, &mut c.poll); } pub fn send_fcu( @@ -168,10 +159,7 @@ fn send_new_payload_request_impl( c.scratch.extend_from_slice(b",\"id\":"); append_decimal_u64(rpc_id, &mut c.scratch); c.scratch.push(b'}'); - match &mut c.transport { - Transport::Http(p) => http_pool_enqueue(p, rpc_id, &c.scratch, &mut c.poll), - Transport::Ipc(p) => ipc_pool_enqueue(p, rpc_id, &c.scratch, &mut c.poll), - } + c.pool.enqueue(rpc_id, &c.scratch, &mut c.poll); c.pending_requests.insert(rpc_id, ReqKind::NewPayload(block_root)); Ok(()) } @@ -256,26 +244,18 @@ pub fn get_client_version(c: &mut EngineClient) { } /// Drive I/O, calling `on_complete(req_kind, raw_body)` for each finished RPC. -/// Raw bytes are the full HTTP/IPC response body; handlers parse them as -/// needed. +/// Raw bytes are the full HTTP response body; handlers parse them as needed. pub fn poll(c: &mut EngineClient, mut on_complete: F) where F: FnMut(ReqKind, Result<&mut [u8], EngineError>), { c.poll.poll(&mut c.events, Some(Duration::ZERO)).ok(); - let EngineClient { transport, events, poll, pending_requests, .. } = c; - match transport { - Transport::Http(p) => poll_http_pool(p, events, poll, &mut |rpc_id, res| { - if let Some(req_kind) = pending_requests.remove(&rpc_id) { - on_complete(req_kind, res); - } - }), - Transport::Ipc(p) => poll_ipc_pool(p, events, poll, &mut |rpc_id, res| { - if let Some(req_kind) = pending_requests.remove(&rpc_id) { - on_complete(req_kind, res); - } - }), - } + let EngineClient { pool, events, poll, pending_requests, .. } = c; + pool.poll_events(events, poll, &mut |rpc_id, res| { + if let Some(req_kind) = pending_requests.remove(&rpc_id) { + on_complete(req_kind, res); + } + }); } #[cfg(test)] diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs index bc6fae82..237ab410 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine/src/error.rs @@ -10,8 +10,6 @@ pub enum EngineError { Json(#[from] simd_json::Error), #[error("jwt: {0}")] Jwt(String), - #[error("ipc: {0}")] - Ipc(String), #[error("ssz: {0}")] Ssz(String), } diff --git a/crates/engine/src/http.rs b/crates/engine/src/http.rs deleted file mode 100644 index fee7deef..00000000 --- a/crates/engine/src/http.rs +++ /dev/null @@ -1,474 +0,0 @@ -use std::{ - io::{self, Read, Write}, - net::{SocketAddr, ToSocketAddrs}, -}; - -use mio::{Events, Interest, Poll, Token, net::TcpStream}; - -use crate::{EngineError, JwtSecret}; - -// Sized for the largest expected EL response: getPayload with a full -// blobsBundle (~21 blobs × 256 KB hex-encoded + execution payload -// transactions). -const READ_BUF_CAPACITY: usize = 10 * 1024 * 1024; - -// Sized for the largest expected outgoing request: newPayload with a full -// block (~30M gas of transactions, hex-encoded in JSON) plus HTTP headers. -const WRITE_BUF_CAPACITY: usize = 10 * 1024 * 1024; - -enum Conn { - Disconnected, - Connecting(TcpStream), - Connected(TcpStream), -} - -struct HttpConnection { - endpoint: String, - host: String, - jwt: JwtSecret, - token: Token, - conn: Conn, - addr: Option, - in_flight: Option, - pending_id: Option, - write_buf: Vec, - write_pos: usize, - read_buf: Vec, - read_offset: usize, - // Cached from the first read of the current response; zero = not yet parsed. - response_header_end: usize, - response_total: usize, // header_end + content_length -} - -impl HttpConnection { - fn new(endpoint: String, jwt: JwtSecret, token: Token) -> Self { - let host = endpoint - .trim_start_matches("http://") - .split('/') - .next() - .unwrap_or("localhost") - .to_string(); - Self { - endpoint, - host, - jwt, - token, - conn: Conn::Disconnected, - addr: None, - pending_id: None, - write_buf: Vec::with_capacity(WRITE_BUF_CAPACITY), - write_pos: 0, - in_flight: None, - read_buf: Vec::with_capacity(READ_BUF_CAPACITY), - read_offset: 0, - response_header_end: 0, - response_total: 0, - } - } -} - -fn http_is_free(t: &HttpConnection) -> bool { - t.in_flight.is_none() && t.pending_id.is_none() -} - -fn http_enqueue(t: &mut HttpConnection, rpc_id: u64, body: &[u8], poll: &mut Poll) { - debug_assert!(t.in_flight.is_none() && t.pending_id.is_none(), "enqueue on busy connection"); - let bearer = t.jwt.bearer_token(); - build_request_into(&mut t.write_buf, &t.host, body, bearer, true); - t.pending_id = Some(rpc_id); - t.write_pos = 0; - - // matches! borrows t.conn transiently, freeing it before the function call - // below. - if matches!(t.conn, Conn::Disconnected) { - http_connect(t, poll); - } else if matches!(t.conn, Conn::Connected(_)) { - http_set_interest(&mut t.conn, t.token, poll, Interest::READABLE | Interest::WRITABLE); - } -} - -fn http_poll(t: &mut HttpConnection, events: &Events, poll: &mut Poll, on_complete: &mut F) -where - F: FnMut(u64, Result<&mut [u8], EngineError>), -{ - for event in events.iter() { - if event.token() != t.token { - continue; - } - if matches!(t.conn, Conn::Connecting(_)) { - if event.is_error() || event.is_read_closed() || event.is_write_closed() { - http_on_error(t, poll, on_complete, "connect failed"); - break; - } - if event.is_writable() { - // Take ownership to inspect peer_addr and transition state atomically. - let Conn::Connecting(stream) = std::mem::replace(&mut t.conn, Conn::Disconnected) - else { - unreachable!() - }; - if stream.peer_addr().is_ok() { - t.conn = Conn::Connected(stream); - let interest = if t.pending_id.is_none() { - Interest::READABLE - } else { - Interest::READABLE | Interest::WRITABLE - }; - http_set_interest(&mut t.conn, t.token, poll, interest); - } else { - t.conn = Conn::Connecting(stream); - http_on_error(t, poll, on_complete, "connect failed"); - break; - } - } - } else if matches!(t.conn, Conn::Connected(_)) { - if event.is_error() { - http_on_error(t, poll, on_complete, "connection error"); - break; - } - if event.is_writable() { - let result = { - let Conn::Connected(stream) = &mut t.conn else { unreachable!() }; - http_do_write( - stream, - &mut t.pending_id, - &t.write_buf, - &mut t.write_pos, - &mut t.in_flight, - ) - }; - if let Err(e) = result { - let msg = e.to_string(); - http_on_error(t, poll, on_complete, &msg); - break; - } - let interest = if t.pending_id.is_none() { - Interest::READABLE - } else { - Interest::READABLE | Interest::WRITABLE - }; - http_set_interest(&mut t.conn, t.token, poll, interest); - } - if event.is_readable() { - // Drain data before checking is_read_closed: when the remote - // sends a response + FIN in one exchange (EPOLLIN|EPOLLRDHUP), - // we must read the response first. http_do_read returns Err on - // EOF, so the break below covers that close path too. - let result = { - let Conn::Connected(stream) = &mut t.conn else { unreachable!() }; - http_do_read( - stream, - &mut t.in_flight, - &mut t.read_buf, - &mut t.read_offset, - &mut t.response_header_end, - &mut t.response_total, - on_complete, - ) - }; - if let Err(e) = result { - let msg = e.to_string(); - http_on_error(t, poll, on_complete, &msg); - break; - } - } - if event.is_read_closed() { - // Remote closed with no (more) data — in_flight will never get - // a response. - http_on_error(t, poll, on_complete, "connection closed"); - break; - } - } - } -} - -fn http_connect(t: &mut HttpConnection, poll: &mut Poll) { - let addr = if let Some(a) = t.addr { - a - } else { - match parse_addr(&t.endpoint) { - Ok(a) => { - t.addr = Some(a); - a - } - Err(e) => { - tracing::warn!("resolve failed for {}: {e}", t.endpoint); - return; - } - } - }; - match TcpStream::connect(addr) { - Ok(mut stream) => { - if poll.registry().register(&mut stream, t.token, Interest::WRITABLE).is_ok() { - t.conn = Conn::Connecting(stream); - } - } - Err(e) => tracing::warn!("connect error: {e}"), - } -} - -fn http_do_write( - stream: &mut TcpStream, - pending_id: &mut Option, - write_buf: &[u8], - write_pos: &mut usize, - in_flight: &mut Option, -) -> io::Result<()> { - if pending_id.is_some() { - loop { - match stream.write(&write_buf[*write_pos..]) { - Ok(0) => break, - Ok(n) => { - *write_pos += n; - if *write_pos == write_buf.len() { - *in_flight = pending_id.take(); - *write_pos = 0; - break; - } - } - Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, - Err(e) => return Err(e), - } - } - } - Ok(()) -} - -fn http_do_read( - stream: &mut TcpStream, - in_flight: &mut Option, - read_buf: &mut Vec, - read_offset: &mut usize, - response_header_end: &mut usize, - response_total: &mut usize, - on_complete: &mut F, -) -> io::Result<()> -where - F: FnMut(u64, Result<&mut [u8], EngineError>), -{ - loop { - // Deliver if a complete response is already buffered. - if *response_total > 0 && read_buf.len() - *read_offset >= *response_total { - if let Some(rpc_id) = in_flight.take() { - let start = *read_offset + *response_header_end; - let end = *read_offset + *response_total; - on_complete(rpc_id, Ok(&mut read_buf[start..end])); - } - *read_offset += *response_total; - *response_header_end = 0; - *response_total = 0; - if *read_offset == read_buf.len() { - read_buf.clear(); - *read_offset = 0; - } - continue; - } - - let want = if *response_total > 0 { - // Know total size; read exactly the remaining bytes. - *response_total - (read_buf.len() - *read_offset) - } else { - // Headers not yet parsed; 4096 covers any realistic HTTP response header. - 4096 - }; - - let base = read_buf.len(); - read_buf.resize(base + want, 0); - match stream.read(&mut read_buf[base..]) { - Ok(0) => { - return Err(io::Error::new(io::ErrorKind::ConnectionReset, "eof")); - } - Ok(n) => { - read_buf.truncate(base + n); - if *response_total == 0 { - match try_parse_headers(&read_buf[*read_offset..]) { - Ok(Some((hend, cl))) => { - *response_header_end = hend; - *response_total = hend + cl; - } - Ok(None) => {} // headers still incomplete - Err(e) => { - return Err(io::Error::new(io::ErrorKind::InvalidData, e.to_string())); - } - } - } - } - Err(e) if e.kind() == io::ErrorKind::WouldBlock => { - read_buf.truncate(base); - break; - } - Err(e) => { - return Err(e); - } - } - } - Ok(()) -} - -fn http_on_error(t: &mut HttpConnection, poll: &mut Poll, on_complete: &mut F, msg: &str) -where - F: FnMut(u64, Result<&mut [u8], EngineError>), -{ - tracing::warn!("{msg}"); - let err = msg.to_string(); - if let Some(rpc_id) = t.in_flight.take() { - on_complete(rpc_id, Err(EngineError::Http(err.clone()))); - } - if let Some(rpc_id) = t.pending_id.take() { - on_complete(rpc_id, Err(EngineError::Http(err.clone()))); - } - t.write_pos = 0; - t.read_buf.clear(); - t.read_offset = 0; - t.response_header_end = 0; - t.response_total = 0; - let old = std::mem::replace(&mut t.conn, Conn::Disconnected); - if let Conn::Connecting(mut stream) | Conn::Connected(mut stream) = old { - let _ = poll.registry().deregister(&mut stream); - } -} - -fn http_set_interest(conn: &mut Conn, token: Token, poll: &mut Poll, interest: Interest) { - let stream = match conn { - Conn::Connecting(s) | Conn::Connected(s) => s, - Conn::Disconnected => return, - }; - let _ = poll.registry().reregister(stream, token, interest); -} - -// Connection helper functions -fn build_request_into(buf: &mut Vec, host: &str, json: &[u8], bearer: &str, keep_alive: bool) { - use std::io::Write as _; - let connection = if keep_alive { "keep-alive" } else { "close" }; - buf.clear(); - // SAFETY: Vec's io::Write impl is infallible. - write!( - buf, - "POST / HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\n\ - Content-Length: {len}\r\nAuthorization: {bearer}\r\nConnection: {connection}\r\n\r\n", - len = json.len(), - ) - .unwrap(); - buf.extend_from_slice(json); -} - -fn parse_addr(endpoint: &str) -> io::Result { - let hostport = endpoint.trim_start_matches("http://").split('/').next().unwrap_or(endpoint); - hostport - .to_socket_addrs()? - .next() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "no address resolved")) -} - -// Returns (header_end, content_length) when headers are complete, None if -// partial. -fn try_parse_headers(buf: &[u8]) -> Result, EngineError> { - let mut headers = [httparse::EMPTY_HEADER; 32]; - let mut resp = httparse::Response::new(&mut headers); - let header_end = match resp.parse(buf) { - Ok(httparse::Status::Complete(n)) => n, - Ok(httparse::Status::Partial) => return Ok(None), - Err(e) => return Err(EngineError::Http(format!("httparse: {e}"))), - }; - match headers.iter().find(|h| h.name.eq_ignore_ascii_case("content-length")) { - Some(h) if h.value.iter().all(|b| b.is_ascii_digit()) => { - let cl = h.value.iter().copied().fold(0usize, |acc, b| acc * 10 + (b - b'0') as usize); - Ok(Some((header_end, cl))) - } - Some(_) => Err(EngineError::Http("invalid Content-Length".into())), - None => Err(EngineError::Http("missing Content-Length".into())), - } -} - -pub(crate) struct HttpPool { - connections: Vec, - endpoint: String, - jwt: JwtSecret, -} - -impl HttpPool { - pub(crate) fn new(endpoint: String, jwt: JwtSecret) -> Self { - let connections = vec![HttpConnection::new(endpoint.clone(), jwt.clone(), Token(0))]; - Self { connections, endpoint, jwt } - } -} - -pub(crate) fn http_pool_enqueue(pool: &mut HttpPool, rpc_id: u64, body: &[u8], poll: &mut Poll) { - if let Some(conn) = pool.connections.iter_mut().find(|c| http_is_free(c)) { - http_enqueue(conn, rpc_id, body, poll); - } else { - let mut new_conn = HttpConnection::new( - pool.endpoint.clone(), - pool.jwt.clone(), - Token(pool.connections.len()), - ); - http_enqueue(&mut new_conn, rpc_id, body, poll); - pool.connections.push(new_conn); - } -} - -pub(crate) fn poll_http_pool( - pool: &mut HttpPool, - events: &Events, - poll: &mut Poll, - on_complete: &mut F, -) where - F: FnMut(u64, Result<&mut [u8], EngineError>), -{ - for conn in &mut pool.connections { - http_poll(conn, events, poll, on_complete); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_response(body: &[u8]) -> Vec { - let header = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", - body.len() - ); - let mut buf = header.into_bytes(); - buf.extend_from_slice(body); - buf - } - - #[test] - fn headers_complete_returns_offsets() { - let body = br#"{"jsonrpc":"2.0","id":1,"result":true}"#; - let buf = make_response(body); - let (hend, cl) = try_parse_headers(&buf).unwrap().unwrap(); - assert_eq!(cl, body.len()); - assert_eq!(hend + cl, buf.len()); - } - - #[test] - fn headers_partial_returns_none() { - let partial = b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n"; - assert!(try_parse_headers(partial).unwrap().is_none()); - } - - #[test] - fn headers_complete_body_incomplete_still_returns_offsets() { - // try_parse_headers only cares about headers; body completeness is the caller's - // job. - let body = br#"{"result":1}"#; - let mut buf = make_response(body); - buf.truncate(buf.len() - 3); - let (hend, cl) = try_parse_headers(&buf).unwrap().unwrap(); - assert_eq!(cl, body.len()); - assert!(buf.len() < hend + cl); - } - - #[test] - fn missing_content_length_is_error() { - let buf = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{}"; - assert!(try_parse_headers(buf).is_err()); - } - - #[test] - fn invalid_content_length_is_error() { - let buf = b"HTTP/1.1 200 OK\r\nContent-Length: abc\r\n\r\n{}"; - assert!(try_parse_headers(buf).is_err()); - } -} diff --git a/crates/engine/src/ipc.rs b/crates/engine/src/ipc.rs deleted file mode 100644 index 2945fc58..00000000 --- a/crates/engine/src/ipc.rs +++ /dev/null @@ -1,267 +0,0 @@ -use std::{ - io::{self, Read, Write}, - path::PathBuf, -}; - -use mio::{Events, Interest, Poll, Token, net::UnixStream}; - -use crate::EngineError; - -// Sized for the largest expected EL response: getPayload with a full -// blobsBundle (~21 blobs × 256 KB hex-encoded + execution payload -// transactions). -const READ_BUF_CAPACITY: usize = 10 * 1024 * 1024; - -// Sized for the largest expected outgoing request: newPayload with a full -// block (~30M gas of transactions, hex-encoded in JSON). -const WRITE_BUF_CAPACITY: usize = 10 * 1024 * 1024; - -#[derive(PartialEq)] -enum State { - Disconnected, - Connecting, - Connected, -} - -struct IpcTransport { - path: PathBuf, - token: Token, - stream: Option, - state: State, - pending_id: Option, - write_buf: Vec, - write_pos: usize, - in_flight: Option, - read_buf: Vec, - read_offset: usize, -} - -impl IpcTransport { - pub(crate) fn new(path: String, token: Token) -> Self { - Self { - path: PathBuf::from(path), - token, - stream: None, - state: State::Disconnected, - pending_id: None, - write_buf: Vec::with_capacity(WRITE_BUF_CAPACITY), - write_pos: 0, - in_flight: None, - read_buf: Vec::with_capacity(READ_BUF_CAPACITY), - read_offset: 0, - } - } -} - -fn ipc_is_free(t: &IpcTransport) -> bool { - t.in_flight.is_none() && t.pending_id.is_none() -} - -fn ipc_enqueue(t: &mut IpcTransport, rpc_id: u64, body: &[u8], poll: &mut Poll) { - t.write_buf.clear(); - t.write_buf.extend_from_slice(body); - t.write_buf.push(b'\n'); - t.pending_id = Some(rpc_id); - t.write_pos = 0; - - match t.state { - State::Disconnected => ipc_connect(t, poll), - State::Connected => ipc_set_interest(t, poll, Interest::READABLE | Interest::WRITABLE), - State::Connecting => {} - } -} - -fn ipc_poll(t: &mut IpcTransport, events: &Events, poll: &mut Poll, on_complete: &mut F) -where - F: FnMut(u64, Result<&mut [u8], EngineError>), -{ - for event in events.iter() { - if event.token() != t.token { - continue; - } - match t.state { - State::Disconnected => {} - State::Connecting => { - if event.is_writable() { - // is_error/is_write_closed flags are not reliable; use - // take_error() (getsockopt SO_ERROR) as the authoritative check. - let err = t.stream.as_ref().and_then(|s| s.take_error().ok()).flatten(); - if let Some(e) = err { - ipc_on_error(t, poll, on_complete, &e.to_string()); - break; - } - t.state = State::Connected; - let interest = if t.pending_id.is_none() { - Interest::READABLE - } else { - Interest::READABLE | Interest::WRITABLE - }; - ipc_set_interest(t, poll, interest); - } - } - State::Connected => { - if event.is_error() || event.is_read_closed() { - ipc_on_error(t, poll, on_complete, "ipc connection lost"); - break; - } - if event.is_writable() { - if let Err(e) = ipc_do_write(t) { - let msg = e.to_string(); - ipc_on_error(t, poll, on_complete, &msg); - break; - } - let interest = if t.pending_id.is_none() { - Interest::READABLE - } else { - Interest::READABLE | Interest::WRITABLE - }; - ipc_set_interest(t, poll, interest); - } - if event.is_readable() { - if let Err(e) = ipc_do_read(t, on_complete) { - let msg = e.to_string(); - ipc_on_error(t, poll, on_complete, &msg); - break; - } - } - } - } - } -} - -fn ipc_connect(t: &mut IpcTransport, poll: &mut Poll) { - match UnixStream::connect(&t.path) { - Ok(mut stream) => { - if poll.registry().register(&mut stream, t.token, Interest::WRITABLE).is_ok() { - t.stream = Some(stream); - t.state = State::Connecting; - } - } - Err(e) => tracing::warn!("connect error: {e}"), - } -} - -fn ipc_do_write(t: &mut IpcTransport) -> io::Result<()> { - if t.pending_id.is_some() { - let stream = t.stream.as_mut().unwrap(); - loop { - match stream.write(&t.write_buf[t.write_pos..]) { - Ok(0) => return Err(io::Error::new(io::ErrorKind::WriteZero, "write returned 0")), - Ok(n) => { - t.write_pos += n; - if t.write_pos == t.write_buf.len() { - t.in_flight = t.pending_id.take(); - t.write_pos = 0; - break; - } - } - Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, - Err(e) => return Err(e), - } - } - } - Ok(()) -} - -fn ipc_do_read(t: &mut IpcTransport, on_complete: &mut F) -> io::Result<()> -where - F: FnMut(u64, Result<&mut [u8], EngineError>), -{ - let stream = t.stream.as_mut().unwrap(); - loop { - let base = t.read_buf.len(); - t.read_buf.resize(base + READ_BUF_CAPACITY, 0); - match stream.read(&mut t.read_buf[base..]) { - Ok(0) => { - t.read_buf.truncate(base); - return Err(io::Error::new(io::ErrorKind::ConnectionReset, "eof")); - } - Ok(n) => { - t.read_buf.truncate(base + n); - while let Some(rel) = t.read_buf[t.read_offset..].iter().position(|&b| b == b'\n') { - let offset = t.read_offset; - let end = offset + rel; - if let Some(rpc_id) = t.in_flight { - on_complete(rpc_id, Ok(&mut t.read_buf[offset..end])); - } - t.read_offset = end + 1; - } - if t.read_offset == t.read_buf.len() { - t.read_buf.clear(); - t.read_offset = 0; - } - } - Err(e) if e.kind() == io::ErrorKind::WouldBlock => { - t.read_buf.truncate(base); - break; - } - Err(e) => { - t.read_buf.truncate(base); - return Err(e); - } - } - } - Ok(()) -} - -fn ipc_on_error(t: &mut IpcTransport, poll: &mut Poll, on_complete: &mut F, msg: &str) -where - F: FnMut(u64, Result<&mut [u8], EngineError>), -{ - tracing::warn!("{msg}"); - let err = msg.to_string(); - if let Some(rpc_id) = t.in_flight.take() { - on_complete(rpc_id, Err(EngineError::Ipc(err.clone()))); - } - if let Some(rpc_id) = t.pending_id.take() { - on_complete(rpc_id, Err(EngineError::Ipc(err.clone()))); - } - t.write_pos = 0; - t.read_buf.clear(); - t.read_offset = 0; - if let Some(mut stream) = t.stream.take() { - let _ = poll.registry().deregister(&mut stream); - } - t.state = State::Disconnected; -} - -fn ipc_set_interest(t: &mut IpcTransport, poll: &mut Poll, interest: Interest) { - if let Some(stream) = t.stream.as_mut() { - let _ = poll.registry().reregister(stream, t.token, interest); - } -} - -pub(crate) struct IpcPool { - connections: Vec, - path: String, -} - -impl IpcPool { - pub(crate) fn new(path: String) -> Self { - let connections = vec![IpcTransport::new(path.clone(), Token(0))]; - Self { connections, path } - } -} - -pub(crate) fn ipc_pool_enqueue(pool: &mut IpcPool, rpc_id: u64, body: &[u8], poll: &mut Poll) { - if let Some(conn) = pool.connections.iter_mut().find(|c| ipc_is_free(c)) { - ipc_enqueue(conn, rpc_id, body, poll); - } else { - let mut new_conn = IpcTransport::new(pool.path.clone(), Token(pool.connections.len())); - ipc_enqueue(&mut new_conn, rpc_id, body, poll); - pool.connections.push(new_conn); - } -} - -pub(crate) fn poll_ipc_pool( - pool: &mut IpcPool, - events: &Events, - poll: &mut Poll, - on_complete: &mut F, -) where - F: FnMut(u64, Result<&mut [u8], EngineError>), -{ - for conn in &mut pool.connections { - ipc_poll(conn, events, poll, on_complete); - } -} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b0e36fea..0d982f7f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1,10 +1,11 @@ mod client; mod error; -mod http; -mod ipc; mod jwt; +mod pool; mod req_handlers; mod resp_handlers; +#[cfg(test)] +mod test_el; pub mod tile; mod types; diff --git a/crates/engine/src/pool.rs b/crates/engine/src/pool.rs new file mode 100644 index 00000000..2d06ba03 --- /dev/null +++ b/crates/engine/src/pool.rs @@ -0,0 +1,451 @@ +use std::{ + io::{self, Read, Write}, + net::{SocketAddr, ToSocketAddrs}, + path::PathBuf, +}; + +use mio::{Events, Interest, Poll, Token}; +use silver_httpcore::{ClientConnection, Stream, frame_request}; + +use crate::{EngineError, JwtSecret}; + +// Sized for the largest expected EL response: getPayload with a full +// blobsBundle (~21 blobs × 256 KB hex-encoded + execution payload +// transactions). +const READ_BUF_CAPACITY: usize = 10 * 1024 * 1024; + +// Sized for the largest expected outgoing request: newPayload with a full +// block (~30M gas of transactions, hex-encoded in JSON) plus HTTP headers. +const WRITE_BUF_CAPACITY: usize = 10 * 1024 * 1024; + +#[derive(Clone)] +pub(crate) enum Endpoint { + Http(String), + Uds(PathBuf), +} + +impl Endpoint { + fn host(&self) -> String { + match self { + Self::Http(endpoint) => endpoint + .trim_start_matches("http://") + .split('/') + .next() + .unwrap_or("localhost") + .to_string(), + Self::Uds(_) => "localhost".to_string(), + } + } +} + +enum Conn { + Disconnected, + Connecting(Stream), + Connected(Stream), +} + +struct PooledConnection { + endpoint: Endpoint, + host: String, + jwt: JwtSecret, + token: Token, + conn: Conn, + addr: Option, + machine: ClientConnection, + in_flight: Option, + pending_id: Option, +} + +impl PooledConnection { + fn new(endpoint: Endpoint, jwt: JwtSecret, token: Token) -> Self { + let host = endpoint.host(); + Self { + endpoint, + host, + jwt, + token, + conn: Conn::Disconnected, + addr: None, + machine: ClientConnection::with_capacity(READ_BUF_CAPACITY, WRITE_BUF_CAPACITY), + in_flight: None, + pending_id: None, + } + } + + fn is_free(&self) -> bool { + self.in_flight.is_none() && self.pending_id.is_none() + } + + fn enqueue(&mut self, rpc_id: u64, body: &[u8], poll: &mut Poll) { + debug_assert!(self.is_free(), "enqueue on busy connection"); + let out = self.machine.begin_request(); + frame_request(out, &self.host, body, Some(self.jwt.bearer_token()), true); + self.pending_id = Some(rpc_id); + + match self.conn { + Conn::Disconnected => self.connect(poll), + Conn::Connected(_) => self.update_interest(poll), + Conn::Connecting(_) => {} + } + } + + fn handle_events(&mut self, events: &Events, poll: &mut Poll, on_complete: &mut F) + where + F: FnMut(u64, Result<&mut [u8], EngineError>), + { + for event in events.iter() { + if event.token() != self.token { + continue; + } + match &self.conn { + Conn::Disconnected => {} + Conn::Connecting(stream) => { + if event.is_error() || event.is_read_closed() || event.is_write_closed() { + self.fail(poll, on_complete, "connect failed"); + break; + } + if event.is_writable() { + if stream.connect_complete().is_ok() { + let Conn::Connecting(stream) = + std::mem::replace(&mut self.conn, Conn::Disconnected) + else { + unreachable!() + }; + self.conn = Conn::Connected(stream); + self.update_interest(poll); + } else { + self.fail(poll, on_complete, "connect failed"); + break; + } + } + } + Conn::Connected(_) => { + if event.is_error() { + self.fail(poll, on_complete, "connection error"); + break; + } + if event.is_writable() { + if let Err(e) = self.do_write() { + let msg = e.to_string(); + self.fail(poll, on_complete, &msg); + break; + } + self.update_interest(poll); + } + if event.is_readable() { + // Drain data before checking is_read_closed: when the + // remote sends a response + FIN in one exchange + // (EPOLLIN|EPOLLRDHUP), we must read the response + // first. do_read returns Err on EOF, so the break + // below covers that close path too. + if let Err(e) = self.do_read(on_complete) { + let msg = e.to_string(); + self.fail(poll, on_complete, &msg); + break; + } + } + if event.is_read_closed() { + // Remote closed with no (more) data — in_flight will + // never get a response. + self.fail(poll, on_complete, "connection closed"); + break; + } + } + } + } + } + + fn connect(&mut self, poll: &mut Poll) { + let stream = match &self.endpoint { + Endpoint::Http(endpoint) => { + let addr = if let Some(a) = self.addr { + a + } else { + match parse_addr(endpoint) { + Ok(a) => { + self.addr = Some(a); + a + } + Err(e) => { + tracing::warn!("resolve failed for {endpoint}: {e}"); + return; + } + } + }; + Stream::connect_tcp(addr) + } + Endpoint::Uds(path) => Stream::connect_uds(path), + }; + match stream { + Ok(mut stream) => { + if poll.registry().register(&mut stream, self.token, Interest::WRITABLE).is_ok() { + self.conn = Conn::Connecting(stream); + } + } + Err(e) => tracing::warn!("connect error: {e}"), + } + } + + fn do_write(&mut self) -> io::Result<()> { + if self.pending_id.is_none() { + return Ok(()); + } + let Self { conn, machine, pending_id, in_flight, .. } = self; + let Conn::Connected(stream) = conn else { return Ok(()) }; + loop { + match stream.write(machine.pending_write()) { + Ok(0) => break, + Ok(n) => { + machine.commit_write(n); + if machine.pending_write().is_empty() { + *in_flight = pending_id.take(); + break; + } + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, + Err(e) => return Err(e), + } + } + Ok(()) + } + + fn do_read(&mut self, on_complete: &mut F) -> io::Result<()> + where + F: FnMut(u64, Result<&mut [u8], EngineError>), + { + let Self { conn, machine, in_flight, .. } = self; + let Conn::Connected(stream) = conn else { return Ok(()) }; + loop { + while let Some(body) = machine.take_response() { + if let Some(rpc_id) = in_flight.take() { + on_complete(rpc_id, Ok(body)); + } + } + match stream.read(machine.read_space()) { + Ok(0) => return Err(io::Error::new(io::ErrorKind::ConnectionReset, "eof")), + Ok(n) => machine.commit_read(n)?, + Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, + Err(e) => return Err(e), + } + } + Ok(()) + } + + fn fail(&mut self, poll: &mut Poll, on_complete: &mut F, msg: &str) + where + F: FnMut(u64, Result<&mut [u8], EngineError>), + { + tracing::warn!("{msg}"); + let err = msg.to_string(); + if let Some(rpc_id) = self.in_flight.take() { + on_complete(rpc_id, Err(EngineError::Http(err.clone()))); + } + if let Some(rpc_id) = self.pending_id.take() { + on_complete(rpc_id, Err(EngineError::Http(err.clone()))); + } + self.machine.reset(); + let old = std::mem::replace(&mut self.conn, Conn::Disconnected); + if let Conn::Connecting(mut stream) | Conn::Connected(mut stream) = old { + let _ = poll.registry().deregister(&mut stream); + } + } + + fn update_interest(&mut self, poll: &mut Poll) { + let interest = if self.pending_id.is_none() { + Interest::READABLE + } else { + Interest::READABLE | Interest::WRITABLE + }; + let stream = match &mut self.conn { + Conn::Connecting(s) | Conn::Connected(s) => s, + Conn::Disconnected => return, + }; + let _ = poll.registry().reregister(stream, self.token, interest); + } +} + +fn parse_addr(endpoint: &str) -> io::Result { + let hostport = endpoint.trim_start_matches("http://").split('/').next().unwrap_or(endpoint); + hostport + .to_socket_addrs()? + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "no address resolved")) +} + +pub(crate) struct HttpPool { + connections: Vec, + endpoint: Endpoint, + jwt: JwtSecret, + max_connections: usize, +} + +impl HttpPool { + pub(crate) fn new(endpoint: Endpoint, jwt: JwtSecret, max_connections: usize) -> Self { + let connections = vec![PooledConnection::new(endpoint.clone(), jwt.clone(), Token(0))]; + Self { connections, endpoint, jwt, max_connections } + } + + /// `enqueue` never refuses work; every caller gates on this before + /// submitting. The first-run healthcheck trio issues three requests + /// against one gate check, so the pool can overshoot `max_connections` + /// by at most two connections, once. + pub(crate) fn has_capacity(&self) -> bool { + self.connections.iter().any(PooledConnection::is_free) || + self.connections.len() < self.max_connections + } + + pub(crate) fn enqueue(&mut self, rpc_id: u64, body: &[u8], poll: &mut Poll) { + if let Some(conn) = self.connections.iter_mut().find(|c| c.is_free()) { + conn.enqueue(rpc_id, body, poll); + } else { + let mut new_conn = PooledConnection::new( + self.endpoint.clone(), + self.jwt.clone(), + Token(self.connections.len()), + ); + new_conn.enqueue(rpc_id, body, poll); + self.connections.push(new_conn); + } + } + + pub(crate) fn poll_events(&mut self, events: &Events, poll: &mut Poll, on_complete: &mut F) + where + F: FnMut(u64, Result<&mut [u8], EngineError>), + { + for conn in &mut self.connections { + // Disconnected with a request pending means connect() could not + // even start (resolve/connect/register error): no event will ever + // arrive for it, so fail the rpc here or it is stranded forever. + if matches!(conn.conn, Conn::Disconnected) && conn.pending_id.is_some() { + conn.fail(poll, on_complete, "connect failed to start"); + } + conn.handle_events(events, poll, on_complete); + } + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use tempfile::TempDir; + + use crate::{ + EngineClient, + client::{ReqKind, poll, send_fcu}, + test_el::{FCU_VALID_RESULT, FakeEl, write_jwt}, + types::ForkchoiceState, + }; + + fn fcu_state(byte: u8) -> ForkchoiceState { + ForkchoiceState { + head_block_hash: [byte; 32], + safe_block_hash: [byte; 32], + finalized_block_hash: [byte; 32], + } + } + + fn spin_until(deadline_msg: &str, mut done: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(10); + while !done() { + assert!(Instant::now() < deadline, "timeout: {deadline_msg}"); + std::thread::sleep(Duration::from_millis(1)); + } + } + + #[test] + fn uds_round_trip_resolves_correlation_with_jwt() { + let dir = TempDir::new().unwrap(); + let jwt_path = write_jwt(dir.path()); + let socket = dir.path().join("engine.sock"); + let mut el = FakeEl::uds(&socket); + + let mut client = EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 32); + let block_root = [7u8; 32]; + send_fcu(&mut client, block_root, fcu_state(1), None); + + let mut responded = false; + let mut completed: Option<([u8; 32], Vec)> = None; + spin_until("fcu round trip over uds", || { + poll(&mut client, |kind, response| { + let ReqKind::Fcu(root) = kind else { panic!("unexpected completion") }; + completed = Some((root, response.expect("fcu response").to_vec())); + }); + el.pump(); + if !responded && !el.requests.is_empty() { + let request = &el.requests[0]; + assert_eq!(request.method, "engine_forkchoiceUpdatedV3"); + let auth = request.authorization.as_deref().expect("JWT header sent over UDS"); + let token = auth.strip_prefix("Bearer ").expect("bearer scheme"); + assert_eq!(token.split('.').count(), 3, "three-part JWT"); + assert!( + request.body.contains(&format!("\"headBlockHash\":\"0x{}\"", "01".repeat(32))) + ); + el.respond(0, FCU_VALID_RESULT); + responded = true; + } + completed.is_some() + }); + + let (root, body) = completed.unwrap(); + assert_eq!(root, block_root, "completion correlated to the issued request"); + assert!(String::from_utf8(body).unwrap().contains("VALID")); + } + + #[test] + fn connect_failure_fails_rpc_and_frees_connection() { + let dir = TempDir::new().unwrap(); + let jwt_path = write_jwt(dir.path()); + let missing_socket = dir.path().join("missing.sock"); + + // max_connections = 1: after the failure, has_capacity() can only be + // true again if the zombie connection was actually freed. + let mut client = EngineClient::new_uds(&missing_socket, jwt_path.to_str().unwrap(), 1); + let block_root = [3u8; 32]; + send_fcu(&mut client, block_root, fcu_state(3), None); + assert!(!client.has_capacity(), "request occupies the only connection"); + + let mut failed: Option<[u8; 32]> = None; + spin_until("connect failure surfaces as rpc error", || { + poll(&mut client, |kind, response| { + let ReqKind::Fcu(root) = kind else { panic!("unexpected completion") }; + assert!(response.is_err(), "unstartable connect must fail the rpc"); + failed = Some(root); + }); + failed.is_some() + }); + + assert_eq!(failed.unwrap(), block_root); + assert!(client.has_capacity(), "failed connection must be reusable"); + } + + #[test] + fn transport_error_fails_in_flight_request() { + let dir = TempDir::new().unwrap(); + let jwt_path = write_jwt(dir.path()); + let socket = dir.path().join("engine.sock"); + let mut el = FakeEl::uds(&socket); + + let mut client = EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 32); + let block_root = [9u8; 32]; + send_fcu(&mut client, block_root, fcu_state(2), None); + + let mut request_seen = false; + let mut failure: Option<[u8; 32]> = None; + spin_until("in-flight request failed on connection close", || { + poll(&mut client, |kind, response| { + let ReqKind::Fcu(root) = kind else { panic!("unexpected completion") }; + assert!(response.is_err(), "closed connection must fail the rpc"); + failure = Some(root); + }); + el.pump(); + if !request_seen && !el.requests.is_empty() { + el.close_connection_of(0); + request_seen = true; + } + failure.is_some() + }); + + assert_eq!(failure.unwrap(), block_root); + } +} diff --git a/crates/engine/src/test_el.rs b/crates/engine/src/test_el.rs new file mode 100644 index 00000000..f7bdfee1 --- /dev/null +++ b/crates/engine/src/test_el.rs @@ -0,0 +1,186 @@ +use std::{ + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + os::unix::net::{UnixListener, UnixStream}, + path::{Path, PathBuf}, +}; + +use simd_json::prelude::{ValueAsScalar, ValueObjectAccess}; + +pub(crate) const FCU_VALID_RESULT: &str = r#"{"payloadStatus":{"status":"VALID","latestValidHash":null,"validationError":null},"payloadId":null}"#; + +pub(crate) fn write_jwt(dir: &Path) -> PathBuf { + let path = dir.join("jwt.hex"); + std::fs::write(&path, "0000000000000000000000000000000000000000000000000000000000000000") + .unwrap(); + path +} + +enum ElListener { + Tcp(TcpListener), + Uds(UnixListener), +} + +enum ElStream { + Tcp(TcpStream), + Uds(UnixStream), +} + +impl Read for ElStream { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + match self { + Self::Tcp(s) => s.read(buf), + Self::Uds(s) => s.read(buf), + } + } +} + +impl Write for ElStream { + fn write(&mut self, buf: &[u8]) -> io::Result { + match self { + Self::Tcp(s) => s.write(buf), + Self::Uds(s) => s.write(buf), + } + } + + fn flush(&mut self) -> io::Result<()> { + match self { + Self::Tcp(s) => s.flush(), + Self::Uds(s) => s.flush(), + } + } +} + +pub(crate) struct ElRequest { + conn: usize, + pub(crate) id: u64, + pub(crate) method: String, + pub(crate) authorization: Option, + pub(crate) body: String, +} + +/// Deterministic single-threaded fake execution client: accepts connections +/// and buffers requests on `pump`, answers only when the test says so. +pub(crate) struct FakeEl { + listener: ElListener, + conns: Vec>, + read_bufs: Vec>, + pub(crate) requests: Vec, +} + +impl FakeEl { + pub(crate) fn tcp() -> (Self, String) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + (Self::new(ElListener::Tcp(listener)), endpoint) + } + + pub(crate) fn uds(path: &Path) -> Self { + let listener = UnixListener::bind(path).unwrap(); + listener.set_nonblocking(true).unwrap(); + Self::new(ElListener::Uds(listener)) + } + + fn new(listener: ElListener) -> Self { + Self { listener, conns: Vec::new(), read_bufs: Vec::new(), requests: Vec::new() } + } + + pub(crate) fn pump(&mut self) { + loop { + let accepted = match &self.listener { + ElListener::Tcp(l) => l.accept().map(|(s, _)| { + s.set_nonblocking(true).unwrap(); + ElStream::Tcp(s) + }), + ElListener::Uds(l) => l.accept().map(|(s, _)| { + s.set_nonblocking(true).unwrap(); + ElStream::Uds(s) + }), + }; + match accepted { + Ok(stream) => { + self.conns.push(Some(stream)); + self.read_bufs.push(Vec::new()); + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, + Err(e) => panic!("accept: {e}"), + } + } + + for i in 0..self.conns.len() { + let Some(stream) = self.conns[i].as_mut() else { continue }; + let mut chunk = [0u8; 65536]; + let mut closed = false; + loop { + match stream.read(&mut chunk) { + Ok(0) => { + closed = true; + break; + } + Ok(n) => self.read_bufs[i].extend_from_slice(&chunk[..n]), + Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, + Err(e) => panic!("read: {e}"), + } + } + if closed { + self.conns[i] = None; + } + while let Some((consumed, request)) = parse_request(i, &self.read_bufs[i]) { + self.requests.push(request); + self.read_bufs[i].drain(..consumed); + } + } + } + + pub(crate) fn respond(&mut self, request_index: usize, result_json: &str) { + let request = &self.requests[request_index]; + let body = format!(r#"{{"jsonrpc":"2.0","id":{},"result":{result_json}}}"#, request.id); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ); + let stream = self.conns[request.conn].as_mut().expect("respond on closed connection"); + let mut bytes = response.as_bytes(); + while !bytes.is_empty() { + match stream.write(bytes) { + Ok(n) => bytes = &bytes[n..], + Err(e) if e.kind() == io::ErrorKind::WouldBlock => continue, + Err(e) => panic!("write: {e}"), + } + } + } + + pub(crate) fn close_connection_of(&mut self, request_index: usize) { + self.conns[self.requests[request_index].conn] = None; + } +} + +fn parse_request(conn: usize, buf: &[u8]) -> Option<(usize, ElRequest)> { + let mut headers = [httparse::EMPTY_HEADER; 32]; + let mut request = httparse::Request::new(&mut headers); + let header_end = match request.parse(buf) { + Ok(httparse::Status::Complete(n)) => n, + _ => return None, + }; + let content_length: usize = headers + .iter() + .find(|h| h.name.eq_ignore_ascii_case("content-length")) + .and_then(|h| std::str::from_utf8(h.value).ok()?.trim().parse().ok()) + .expect("request without Content-Length"); + if buf.len() < header_end + content_length { + return None; + } + let authorization = headers + .iter() + .find(|h| h.name.eq_ignore_ascii_case("authorization")) + .map(|h| String::from_utf8(h.value.to_vec()).unwrap()); + + let body = String::from_utf8(buf[header_end..header_end + content_length].to_vec()).unwrap(); + let mut json = body.clone().into_bytes(); + let json = simd_json::to_borrowed_value(&mut json).expect("request body is JSON"); + let id = json.get("id").and_then(|v| v.as_u64()).expect("rpc id"); + let method = json.get("method").and_then(|v| v.as_str()).expect("rpc method").to_string(); + + Some((header_end + content_length, ElRequest { conn, id, method, authorization, body })) +} diff --git a/crates/engine/src/tile.rs b/crates/engine/src/tile.rs index 4d4e6470..55cb423d 100644 --- a/crates/engine/src/tile.rs +++ b/crates/engine/src/tile.rs @@ -50,15 +50,23 @@ impl Tile for EngineTile { }); return; } - adapter.consume(|req: EngineReq, producers| { - handle_request( - self.client.as_mut().unwrap(), - &mut self.gossip_consumer, - &mut self.rpc_consumer, - &req, - producers, - ); - }); + // Requests stay queued on the spine while every connection is busy and + // the pool is at max_connections; intake resumes as completions free + // connections. + while self.client.as_ref().unwrap().has_capacity() { + let consumed = adapter.consume_one(|req: EngineReq, producers| { + handle_request( + self.client.as_mut().unwrap(), + &mut self.gossip_consumer, + &mut self.rpc_consumer, + &req, + producers, + ); + }); + if !consumed { + break; + } + } self.spin(adapter); } } @@ -76,7 +84,11 @@ impl EngineTile { ); None } else { - Some(EngineClient::new(&config.execution_endpoint, &config.jwt_secret)) + Some(EngineClient::new( + &config.execution_endpoint, + &config.jwt_secret, + config.max_connections, + )) }; Self { client, @@ -109,7 +121,10 @@ impl EngineTile { // Only reached in EL mode; loop_body returns early otherwise. let client = client.as_mut().expect("spin without EL client"); - if !*healthcheck_pending && Instant::now() >= *healthcheck_deadline { + if !*healthcheck_pending && + Instant::now() >= *healthcheck_deadline && + client.has_capacity() + { run_healthcheck(client, first_run, healthcheck_pending, healthcheck_deadline); } @@ -168,3 +183,118 @@ fn run_healthcheck( *healthcheck_deadline = Instant::now() + HEALTHCHECK_INTERVAL; *healthcheck_pending = true; } + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use flux::{spine::SpineAdapter, tile::Tile}; + use silver_common::{EngineFcuReq, EngineReq, EngineResp, SilverSpine, TCache, TCacheProducer}; + use silver_config::EngineConfig; + use tempfile::TempDir; + + use super::EngineTile; + use crate::test_el::{FCU_VALID_RESULT, FakeEl, write_jwt}; + + struct Injector; + impl Tile for Injector { + fn loop_body(&mut self, _: &mut SpineAdapter) {} + } + + fn fcu_req(byte: u8) -> EngineReq { + EngineReq::Fcu(EngineFcuReq { + block_root: [byte; 32], + head_block_hash: [byte; 32], + safe_block_hash: [0u8; 32], + finalized_block_hash: [0u8; 32], + }) + } + + fn head_block_hash_json(byte: u8) -> String { + format!("\"headBlockHash\":\"0x{}\"", hex::encode([byte; 32])) + } + + /// (cap+1) concurrent spine requests with `max_connections = cap`: the + /// last one must stay queued on the spine until a completion frees a + /// connection, and completions must correlate out of order. + #[test] + fn pool_cap_gates_spine_intake() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let (mut el, endpoint) = FakeEl::tcp(); + let jwt_path = write_jwt(base.path()); + + let gossip_p = TCache::producer("engine_cap_test_gossip", 1 << 12); + let rpc_p = TCache::producer("engine_cap_test_rpc", 1 << 12); + let resp_p = TCache::producer("engine_cap_test_resp", 1 << 12); + let config = EngineConfig { + execution_endpoint: endpoint, + jwt_secret: jwt_path.to_str().unwrap().to_string(), + max_connections: 3, + ..EngineConfig::default() + }; + let mut tile = EngineTile::new( + config, + gossip_p.cache_ref().random_access("t", true).unwrap(), + rpc_p.cache_ref().random_access("t", true).unwrap(), + resp_p, + ); + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); + inj.consume(|_: EngineResp, _| {}); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut crank = |tile: &mut EngineTile, el: &mut FakeEl, msg: &str| { + assert!(Instant::now() < deadline, "timeout: {msg}"); + tile.loop_body(&mut adapter); + el.pump(); + std::thread::sleep(Duration::from_millis(1)); + }; + + // First loop_body fires the startup healthcheck trio; answer it so all + // three pooled connections are free before the capped scenario. + while el.requests.len() < 3 { + crank(&mut tile, &mut el, "startup healthcheck trio"); + } + for i in 0..3 { + el.respond(i, "false"); + } + + for byte in [11u8, 12, 13, 14] { + inj.produce(fcu_req(byte)); + } + + let fcu_count = |el: &FakeEl| { + el.requests.iter().filter(|r| r.method == "engine_forkchoiceUpdatedV3").count() + }; + while fcu_count(&el) < 3 { + crank(&mut tile, &mut el, "first three FCUs sent"); + } + for _ in 0..50 { + crank(&mut tile, &mut el, "cap holds"); + assert_eq!(fcu_count(&el), 3, "4th request must wait while pool is at cap"); + } + + // Free one connection by answering the SECOND fcu; the gated request + // must then be sent, and the completion must carry the responded + // request's block root. + let second = el + .requests + .iter() + .position(|r| r.body.contains(&head_block_hash_json(12))) + .expect("fcu for root 12 on the wire"); + el.respond(second, FCU_VALID_RESULT); + + while fcu_count(&el) < 4 { + crank(&mut tile, &mut el, "gated FCU sent after a connection freed"); + } + + let mut completed = Vec::new(); + inj.consume(|resp: EngineResp, _| { + if let EngineResp::Fcu(r) = resp { + completed.push(r.block_root); + } + }); + assert_eq!(completed, vec![[12u8; 32]], "out-of-order completion correlated"); + } +} diff --git a/crates/httpcore/Cargo.toml b/crates/httpcore/Cargo.toml index 6a8e247b..6f13244e 100644 --- a/crates/httpcore/Cargo.toml +++ b/crates/httpcore/Cargo.toml @@ -7,6 +7,7 @@ version.workspace = true [dependencies] httparse.workspace = true +mio.workspace = true tracing.workspace = true [lints] diff --git a/crates/httpcore/src/client.rs b/crates/httpcore/src/client.rs new file mode 100644 index 00000000..a07a7a88 --- /dev/null +++ b/crates/httpcore/src/client.rs @@ -0,0 +1,343 @@ +use std::io::{self, Write}; + +// 4096 covers any realistic HTTP response header block; once headers are +// parsed, reads are sized to exactly the remaining Content-Length. +const HEADER_READ_LEN: usize = 4096; + +pub struct ClientConnection { + write_buf: Vec, + write_pos: usize, + read_buf: Vec, + read_end: usize, + read_offset: usize, + response_header_end: usize, + response_total: usize, +} + +impl ClientConnection { + pub fn with_capacity(read_capacity: usize, write_capacity: usize) -> Self { + Self { + write_buf: Vec::with_capacity(write_capacity), + write_pos: 0, + read_buf: Vec::with_capacity(read_capacity), + read_end: 0, + read_offset: 0, + response_header_end: 0, + response_total: 0, + } + } + + pub fn begin_request(&mut self) -> &mut Vec { + debug_assert!( + self.pending_write().is_empty(), + "one request in flight per connection: previous request not fully written" + ); + self.write_buf.clear(); + self.write_pos = 0; + &mut self.write_buf + } + + pub fn pending_write(&self) -> &[u8] { + &self.write_buf[self.write_pos..] + } + + pub fn commit_write(&mut self, n: usize) { + debug_assert!(self.write_pos + n <= self.write_buf.len()); + self.write_pos += n; + } + + pub fn read_space(&mut self) -> &mut [u8] { + if self.read_offset != 0 && self.read_offset == self.read_end { + self.read_end = 0; + self.read_offset = 0; + } + let want = if self.response_total > 0 { + self.response_total - (self.read_end - self.read_offset) + } else { + HEADER_READ_LEN + }; + debug_assert!(want > 0, "complete response pending: take_response before reading more"); + if self.read_buf.len() < self.read_end + want { + self.read_buf.resize(self.read_end + want, 0); + } + &mut self.read_buf[self.read_end..self.read_end + want] + } + + pub fn commit_read(&mut self, n: usize) -> io::Result<()> { + debug_assert!(self.read_end + n <= self.read_buf.len()); + self.read_end += n; + if self.response_total == 0 { + if let Some((header_end, content_length)) = + parse_response_head(&self.read_buf[self.read_offset..self.read_end])? + { + self.response_header_end = header_end; + self.response_total = header_end + content_length; + } + } + Ok(()) + } + + pub fn take_response(&mut self) -> Option<&mut [u8]> { + if self.response_total == 0 || self.read_end - self.read_offset < self.response_total { + return None; + } + let start = self.read_offset + self.response_header_end; + let end = self.read_offset + self.response_total; + self.read_offset = end; + self.response_header_end = 0; + self.response_total = 0; + Some(&mut self.read_buf[start..end]) + } + + pub fn reset(&mut self) { + self.write_buf.clear(); + self.write_pos = 0; + self.read_end = 0; + self.read_offset = 0; + self.response_header_end = 0; + self.response_total = 0; + } +} + +// Returns (header_end, content_length) when headers are complete, None if +// partial. Content-Length framing only: a response without it is an error, +// chunked transfer encoding is unsupported. +fn parse_response_head(buf: &[u8]) -> io::Result> { + let mut headers = [httparse::EMPTY_HEADER; 32]; + let mut resp = httparse::Response::new(&mut headers); + let header_end = match resp.parse(buf) { + Ok(httparse::Status::Complete(n)) => n, + Ok(httparse::Status::Partial) => return Ok(None), + Err(e) => return Err(io::Error::new(io::ErrorKind::InvalidData, format!("httparse: {e}"))), + }; + match headers.iter().find(|h| h.name.eq_ignore_ascii_case("content-length")) { + Some(h) if !h.value.is_empty() && h.value.iter().all(|b| b.is_ascii_digit()) => { + let cl = h.value.iter().copied().fold(0usize, |acc, b| acc * 10 + (b - b'0') as usize); + Ok(Some((header_end, cl))) + } + Some(_) => Err(io::Error::new(io::ErrorKind::InvalidData, "invalid Content-Length")), + None => Err(io::Error::new(io::ErrorKind::InvalidData, "missing Content-Length")), + } +} + +pub fn frame_request( + out: &mut Vec, + host: &str, + body: &[u8], + authorization: Option<&str>, + keep_alive: bool, +) { + let connection = if keep_alive { "keep-alive" } else { "close" }; + match authorization { + Some(bearer) => write!( + out, + "POST / HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\n\ + Content-Length: {len}\r\nAuthorization: {bearer}\r\nConnection: {connection}\r\n\r\n", + len = body.len(), + ), + None => write!( + out, + "POST / HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\n\ + Content-Length: {len}\r\nConnection: {connection}\r\n\r\n", + len = body.len(), + ), + } + .unwrap(); + out.extend_from_slice(body); +} + +#[cfg(test)] +mod tests { + use super::*; + + const BODY: &[u8] = br#"{"jsonrpc":"2.0","method":"eth_syncing","params":[],"id":1}"#; + const BEARER: &str = "Bearer aGVhZGVy.cGF5bG9hZA.c2ln"; + + fn machine() -> ClientConnection { + ClientConnection::with_capacity(4096, 4096) + } + + fn make_response(body: &[u8]) -> Vec { + let mut buf = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", + body.len() + ) + .into_bytes(); + buf.extend_from_slice(body); + buf + } + + fn feed(conn: &mut ClientConnection, bytes: &[u8]) -> io::Result<()> { + let space = conn.read_space(); + let n = bytes.len().min(space.len()); + assert_eq!(n, bytes.len(), "test chunk exceeds offered read space"); + space[..n].copy_from_slice(bytes); + conn.commit_read(n) + } + + // Captured verbatim from silver_engine's `build_request_into` before the + // extraction (2026-08-17); the framed request must stay byte-identical. + #[test] + fn golden_request_bytes_keep_alive() { + let mut conn = machine(); + frame_request(conn.begin_request(), "localhost:8551", BODY, Some(BEARER), true); + let expected: Vec = [ + b"POST / HTTP/1.1\r\nHost: localhost:8551\r\nContent-Type: application/json\r\n\ + Content-Length: 59\r\nAuthorization: Bearer aGVhZGVy.cGF5bG9hZA.c2ln\r\n\ + Connection: keep-alive\r\n\r\n" + .as_ref(), + BODY, + ] + .concat(); + assert_eq!(conn.pending_write(), expected); + } + + #[test] + fn golden_request_bytes_connection_close() { + let mut conn = machine(); + frame_request(conn.begin_request(), "localhost:8551", BODY, Some(BEARER), false); + let expected: Vec = [ + b"POST / HTTP/1.1\r\nHost: localhost:8551\r\nContent-Type: application/json\r\n\ + Content-Length: 59\r\nAuthorization: Bearer aGVhZGVy.cGF5bG9hZA.c2ln\r\n\ + Connection: close\r\n\r\n" + .as_ref(), + BODY, + ] + .concat(); + assert_eq!(conn.pending_write(), expected); + } + + #[test] + fn frame_request_without_authorization_omits_header() { + let mut out = Vec::new(); + frame_request(&mut out, "localhost:8551", b"{}", None, true); + let text = String::from_utf8(out).unwrap(); + assert!(!text.contains("Authorization")); + assert!(text.contains("Content-Length: 2\r\n")); + } + + #[test] + fn request_drained_in_small_chunks() { + let mut conn = machine(); + frame_request(conn.begin_request(), "localhost:8551", BODY, Some(BEARER), true); + let expected = conn.pending_write().to_vec(); + + let mut wire = Vec::new(); + while !conn.pending_write().is_empty() { + let chunk_len = conn.pending_write().len().min(3); + wire.extend_from_slice(&conn.pending_write()[..chunk_len]); + conn.commit_write(chunk_len); + } + assert_eq!(wire, expected); + } + + #[test] + fn response_fed_one_byte_at_a_time() { + let mut conn = machine(); + let body = br#"{"jsonrpc":"2.0","id":1,"result":false}"#; + let response = make_response(body); + + for (i, byte) in response.iter().enumerate() { + assert!(conn.take_response().is_none(), "byte {i}"); + feed(&mut conn, &[*byte]).unwrap(); + } + assert_eq!(conn.take_response().unwrap(), body); + assert!(conn.take_response().is_none()); + } + + #[test] + fn headers_complete_body_incomplete_returns_none() { + let mut conn = machine(); + let mut response = make_response(br#"{"result":1}"#); + response.truncate(response.len() - 3); + feed(&mut conn, &response).unwrap(); + assert!(conn.take_response().is_none()); + feed(&mut conn, br#":1}"#).unwrap(); + assert_eq!(conn.take_response().unwrap(), br#"{"result":1}"#.as_ref()); + } + + #[test] + fn partial_headers_return_none_without_error() { + let mut conn = machine(); + feed(&mut conn, b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n").unwrap(); + assert!(conn.take_response().is_none()); + } + + #[test] + fn missing_content_length_is_error() { + let mut conn = machine(); + let err = feed(&mut conn, b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{}") + .unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert_eq!(err.to_string(), "missing Content-Length"); + } + + #[test] + fn invalid_content_length_is_error() { + let mut conn = machine(); + let err = feed(&mut conn, b"HTTP/1.1 200 OK\r\nContent-Length: abc\r\n\r\n{}").unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert_eq!(err.to_string(), "invalid Content-Length"); + } + + #[test] + fn body_larger_than_header_read_arrives_in_exact_sized_reads() { + let mut conn = machine(); + let body = vec![b'x'; 3 * HEADER_READ_LEN]; + let response = make_response(&body); + + let mut sent = 0; + while sent < response.len() { + assert!(conn.take_response().is_none()); + let space = conn.read_space(); + let n = space.len().min(response.len() - sent); + space[..n].copy_from_slice(&response[sent..sent + n]); + conn.commit_read(n).unwrap(); + sent += n; + } + assert_eq!(conn.take_response().unwrap(), body); + } + + #[test] + fn keep_alive_connection_serves_second_request() { + let mut conn = machine(); + for body in [br#"{"id":1}"#.as_ref(), br#"{"id":2}"#.as_ref()] { + frame_request(conn.begin_request(), "h", body, None, true); + while !conn.pending_write().is_empty() { + let n = conn.pending_write().len(); + conn.commit_write(n); + } + feed(&mut conn, &make_response(body)).unwrap(); + assert_eq!(conn.take_response().unwrap(), body); + } + } + + #[test] + fn two_connections_complete_out_of_order() { + let mut first = machine(); + let mut second = machine(); + frame_request(first.begin_request(), "h", br#"{"id":1}"#, None, true); + frame_request(second.begin_request(), "h", br#"{"id":2}"#, None, true); + + feed(&mut second, &make_response(br#"{"id":2,"result":"b"}"#)).unwrap(); + assert!(first.take_response().is_none()); + assert_eq!(second.take_response().unwrap(), br#"{"id":2,"result":"b"}"#.as_ref()); + + feed(&mut first, &make_response(br#"{"id":1,"result":"a"}"#)).unwrap(); + assert_eq!(first.take_response().unwrap(), br#"{"id":1,"result":"a"}"#.as_ref()); + } + + #[test] + fn reset_clears_partial_state_but_keeps_capacity() { + let mut conn = machine(); + frame_request(conn.begin_request(), "h", b"{}", None, true); + feed(&mut conn, b"HTTP/1.1 200 OK\r\nContent-Le").unwrap(); + + conn.reset(); + assert!(conn.pending_write().is_empty()); + assert!(conn.take_response().is_none()); + + feed(&mut conn, &make_response(b"{}")).unwrap(); + assert_eq!(conn.take_response().unwrap(), b"{}"); + } +} diff --git a/crates/httpcore/src/lib.rs b/crates/httpcore/src/lib.rs index c15ea426..7198254f 100644 --- a/crates/httpcore/src/lib.rs +++ b/crates/httpcore/src/lib.rs @@ -1,3 +1,7 @@ +mod client; mod server; +mod stream; +pub use client::{ClientConnection, frame_request}; pub use server::{AfterResponse, ParsedRequest, ServerConnection, frame_response}; +pub use stream::Stream; diff --git a/crates/httpcore/src/stream.rs b/crates/httpcore/src/stream.rs new file mode 100644 index 00000000..aa0e6766 --- /dev/null +++ b/crates/httpcore/src/stream.rs @@ -0,0 +1,170 @@ +use std::{ + io::{self, Read, Write}, + net::SocketAddr, + path::Path, +}; + +use mio::{ + Interest, Registry, Token, + event::Source, + net::{TcpStream, UnixStream}, +}; + +pub enum Stream { + Tcp(TcpStream), + Uds(UnixStream), +} + +impl Stream { + pub fn connect_tcp(addr: SocketAddr) -> io::Result { + Ok(Self::Tcp(TcpStream::connect(addr)?)) + } + + pub fn connect_uds(path: &Path) -> io::Result { + Ok(Self::Uds(UnixStream::connect(path)?)) + } + + /// After the writable event that ends a non-blocking connect, distinguishes + /// success from failure: TCP has a peer address only once connected; a Unix + /// socket reports connect failure through SO_ERROR (mio's readiness flags + /// are not reliable for it). + pub fn connect_complete(&self) -> io::Result<()> { + match self { + Self::Tcp(s) => s.peer_addr().map(|_| ()), + Self::Uds(s) => match s.take_error()? { + Some(e) => Err(e), + None => Ok(()), + }, + } + } +} + +impl Read for Stream { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + match self { + Self::Tcp(s) => s.read(buf), + Self::Uds(s) => s.read(buf), + } + } +} + +impl Write for Stream { + fn write(&mut self, buf: &[u8]) -> io::Result { + match self { + Self::Tcp(s) => s.write(buf), + Self::Uds(s) => s.write(buf), + } + } + + fn flush(&mut self) -> io::Result<()> { + match self { + Self::Tcp(s) => s.flush(), + Self::Uds(s) => s.flush(), + } + } +} + +impl Source for Stream { + fn register( + &mut self, + registry: &Registry, + token: Token, + interests: Interest, + ) -> io::Result<()> { + match self { + Self::Tcp(s) => s.register(registry, token, interests), + Self::Uds(s) => s.register(registry, token, interests), + } + } + + fn reregister( + &mut self, + registry: &Registry, + token: Token, + interests: Interest, + ) -> io::Result<()> { + match self { + Self::Tcp(s) => s.reregister(registry, token, interests), + Self::Uds(s) => s.reregister(registry, token, interests), + } + } + + fn deregister(&mut self, registry: &Registry) -> io::Result<()> { + match self { + Self::Tcp(s) => s.deregister(registry), + Self::Uds(s) => s.deregister(registry), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::{ClientConnection, frame_request}; + + #[test] + fn uds_pair_round_trip_through_client_connection() { + let (client_half, mut server_half) = UnixStream::pair().unwrap(); + let mut stream = Stream::Uds(client_half); + let mut conn = ClientConnection::with_capacity(4096, 4096); + + let body = br#"{"jsonrpc":"2.0","method":"eth_syncing","params":[],"id":7}"#; + frame_request(conn.begin_request(), "localhost", body, Some("Bearer t.t.t"), true); + while !conn.pending_write().is_empty() { + match stream.write(conn.pending_write()) { + Ok(n) => conn.commit_write(n), + Err(e) if e.kind() == io::ErrorKind::WouldBlock => continue, + Err(e) => panic!("write: {e}"), + } + } + + let mut request = vec![0u8; 4096]; + let n = blocking_read(&mut server_half, &mut request); + let request = String::from_utf8(request[..n].to_vec()).unwrap(); + assert!(request.starts_with("POST / HTTP/1.1\r\n")); + assert!(request.contains("Authorization: Bearer t.t.t\r\n")); + assert!(request.ends_with(std::str::from_utf8(body).unwrap())); + + let response_body = br#"{"jsonrpc":"2.0","id":7,"result":false}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}", + response_body.len(), + std::str::from_utf8(response_body).unwrap() + ); + blocking_write(&mut server_half, response.as_bytes()); + + loop { + if let Some(got) = conn.take_response() { + assert_eq!(got, response_body); + break; + } + match stream.read(conn.read_space()) { + Ok(n) => conn.commit_read(n).unwrap(), + Err(e) if e.kind() == io::ErrorKind::WouldBlock => continue, + Err(e) => panic!("read: {e}"), + } + } + } + + fn blocking_read(stream: &mut UnixStream, buf: &mut [u8]) -> usize { + use std::io::Read as _; + loop { + match stream.read(buf) { + Ok(n) => return n, + Err(e) if e.kind() == io::ErrorKind::WouldBlock => continue, + Err(e) => panic!("read: {e}"), + } + } + } + + fn blocking_write(stream: &mut UnixStream, mut bytes: &[u8]) { + use std::io::Write as _; + while !bytes.is_empty() { + match stream.write(bytes) { + Ok(n) => bytes = &bytes[n..], + Err(e) if e.kind() == io::ErrorKind::WouldBlock => continue, + Err(e) => panic!("write: {e}"), + } + } + } +} From e7cbd99b3f56173d2316d13cf59746f426e3901a Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Mon, 17 Aug 2026 12:34:39 +0100 Subject: [PATCH 05/16] Table-based dispatch for beacon_api routes Third step of the client_server consolidation (docs/adr/0003): the inline exact-match path closure becomes a const route table -- (method, pattern, handler fn) compiled once at init into literal/param segments, linearly scanned, with zero-alloc borrowed params (inline capacity 4). The router owns 404 (byte-identical to before) and the new 405 for known-path/wrong- method -- previously the HTTP method was ignored entirely. Handlers own 400, and ApiCtx::read_state_or_503 pins the pre-bootstrap contract: a BeaconStateReader (now threaded from the beacon-state tile) answering None yields 503 with the beacon-api error JSON shape. Identity moves to body-bytes-plus-per-request framing; wire bytes are byte-identical, pinned by a golden test captured from the previous implementation. Duplicate patterns (modulo param names) and >4 params panic at init. Adding an endpoint is now one table row + one handler + one socket-free test through the table. Assisted-by: Claude:claude-fable-5 --- Cargo.lock | 1 + crates/beacon_api/Cargo.toml | 1 + crates/beacon_api/examples/srv.rs | 5 +- crates/beacon_api/src/identity.rs | 115 +++++++++++ crates/beacon_api/src/lib.rs | 5 + crates/beacon_api/src/response.rs | 65 ++++++ crates/beacon_api/src/router.rs | 328 ++++++++++++++++++++++++++++++ crates/beacon_api/src/routes.rs | 173 ++++++++++++++++ crates/beacon_api/src/tile.rs | 206 ++----------------- crates/bin/src/main.rs | 4 +- 10 files changed, 715 insertions(+), 188 deletions(-) create mode 100644 crates/beacon_api/src/identity.rs create mode 100644 crates/beacon_api/src/response.rs create mode 100644 crates/beacon_api/src/router.rs create mode 100644 crates/beacon_api/src/routes.rs diff --git a/Cargo.lock b/Cargo.lock index 6198339b..37d87515 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4452,6 +4452,7 @@ dependencies = [ "mio", "serde", "serde_json", + "silver_beacon_state_data", "silver_common", "silver_httpcore", "tracing", diff --git a/crates/beacon_api/Cargo.toml b/crates/beacon_api/Cargo.toml index ad6cda2c..51d08ff2 100644 --- a/crates/beacon_api/Cargo.toml +++ b/crates/beacon_api/Cargo.toml @@ -9,6 +9,7 @@ version.workspace = true flux.workspace = true hex.workspace = true mio.workspace = true +silver_beacon_state_data.workspace = true silver_common.workspace = true silver_httpcore.workspace = true serde.workspace = true diff --git a/crates/beacon_api/examples/srv.rs b/crates/beacon_api/examples/srv.rs index e72e00ef..4f6d8627 100644 --- a/crates/beacon_api/examples/srv.rs +++ b/crates/beacon_api/examples/srv.rs @@ -1,15 +1,18 @@ use flux::tile::{TileConfig, attach_tile}; use silver_beacon_api::BeaconApiTile; +use silver_beacon_state_data::BeaconStateOwner; use silver_common::{Enr, Identify, Keypair, SilverSpine}; fn main() { let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); let local_enr = Enr::empty(keypair.secret_key()).unwrap(); let identify = Identify::default(); + // Never-published reader: state endpoints answer 503, as pre-bootstrap. + let state = BeaconStateOwner::empty_test(0).reader(); let spine = SilverSpine::new(None); spine.start(None, None, |scoped_spine| { attach_tile( - BeaconApiTile::new(&keypair, local_enr, &identify), + BeaconApiTile::new(&keypair, local_enr, &identify, state), scoped_spine, TileConfig::new(1, None), ); diff --git a/crates/beacon_api/src/identity.rs b/crates/beacon_api/src/identity.rs new file mode 100644 index 00000000..10f4da35 --- /dev/null +++ b/crates/beacon_api/src/identity.rs @@ -0,0 +1,115 @@ +use serde::{Deserialize, Serialize}; +use silver_common::{Enr, Eth2Addr, Identify, Keypair}; + +#[derive(Debug, Serialize)] +struct IdentityResponse<'a> { + data: &'a Identity, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Identity { + peer_id: String, + enr: String, + p2p_addresses: Vec, + discovery_addresses: Vec, + metadata: Metadata, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Metadata { + seq_number: String, + attnets: String, + syncnets: String, + custody_group_count: String, +} + +pub(crate) fn build_identity_json( + keypair: &Keypair, + local_enr: &Enr, + identify: &Identify, +) -> Vec { + let pid_multiaddr = Eth2Addr::PeerId(keypair.peer_id()).to_string(); + let peer_id_str = pid_multiaddr.strip_prefix("/p2p/").unwrap_or(&pid_multiaddr); + + let mut p2p_addresses = Vec::new(); + if let Some(addr) = identify.tcp_ipv4 { + p2p_addresses.push(format!("/ip4/{}/tcp/{}/p2p/{}", addr.ip(), addr.port(), peer_id_str)); + } + if let Some(addr) = identify.tcp_ipv6 { + p2p_addresses.push(format!("/ip6/{}/tcp/{}/p2p/{}", addr.ip(), addr.port(), peer_id_str)); + } + if let Some(addr) = identify.udp_ipv4 { + p2p_addresses.push(format!( + "/ip4/{}/udp/{}/quic-v1/p2p/{}", + addr.ip(), + addr.port(), + peer_id_str + )); + } + if let Some(addr) = identify.udp_ipv6 { + p2p_addresses.push(format!( + "/ip6/{}/udp/{}/quic-v1/p2p/{}", + addr.ip(), + addr.port(), + peer_id_str + )); + } + + let mut discovery_addresses = Vec::new(); + if let (Some(ip), Some(udp)) = (local_enr.ip4(), local_enr.udp4()) { + discovery_addresses.push(format!("/ip4/{}/udp/{}/p2p/{}", ip, udp, peer_id_str)); + } + if let (Some(ip), Some(udp)) = (local_enr.ip6(), local_enr.udp6()) { + discovery_addresses.push(format!("/ip6/{}/udp/{}/p2p/{}", ip, udp, peer_id_str)); + } + + let identity = Identity { + peer_id: peer_id_str.to_string(), + enr: local_enr.to_base64(), + p2p_addresses, + discovery_addresses, + metadata: Metadata { + seq_number: local_enr.seq().to_string(), + attnets: format!("0x{}", hex::encode(local_enr.attnets().unwrap_or([0u8; 8]))), + syncnets: format!("0x{:02x}", local_enr.syncnets().unwrap_or(0)), + custody_group_count: local_enr.cgc().unwrap_or(4).to_string(), + }, + }; + + serde_json::to_vec(&IdentityResponse { data: &identity }).unwrap() +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use super::*; + + #[test] + fn identity_json_fields_present() { + let kp = Keypair::from_secret(&[1u8; 32]).unwrap(); + let enr = Enr::builder().build(kp.secret_key()).unwrap(); + let body = build_identity_json(&kp, &enr, &Identify::default()); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let data = &v["data"]; + assert!(data["peer_id"].as_str().is_some_and(|s| !s.is_empty())); + assert!(data["enr"].as_str().is_some_and(|s| s.starts_with("enr:"))); + assert!(data["metadata"]["seq_number"].as_str().is_some()); + assert!(data["metadata"]["attnets"].as_str().is_some_and(|s| s.starts_with("0x"))); + assert!(data["metadata"]["syncnets"].as_str().is_some_and(|s| s.starts_with("0x"))); + } + + #[test] + fn identity_p2p_address_format() { + let kp = Keypair::from_secret(&[1u8; 32]).unwrap(); + let enr = Enr::builder().build(kp.secret_key()).unwrap(); + let mut identify = Identify::default(); + identify.tcp_ipv4 = Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 9000)); + let body = build_identity_json(&kp, &enr, &identify); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let addrs = v["data"]["p2p_addresses"].as_array().unwrap(); + assert_eq!(addrs.len(), 1); + let addr = addrs[0].as_str().unwrap(); + assert!(addr.starts_with("/ip4/1.2.3.4/tcp/9000/p2p/"), "bad format: {addr}"); + } +} diff --git a/crates/beacon_api/src/lib.rs b/crates/beacon_api/src/lib.rs index 500cb6dc..e7bb308d 100644 --- a/crates/beacon_api/src/lib.rs +++ b/crates/beacon_api/src/lib.rs @@ -1,2 +1,7 @@ +mod identity; +mod response; +mod router; +mod routes; mod tile; + pub use tile::BeaconApiTile; diff --git a/crates/beacon_api/src/response.rs b/crates/beacon_api/src/response.rs new file mode 100644 index 00000000..732aa23c --- /dev/null +++ b/crates/beacon_api/src/response.rs @@ -0,0 +1,65 @@ +use silver_httpcore::frame_response; + +pub(crate) struct Response<'a> { + out: &'a mut Vec, +} + +impl<'a> Response<'a> { + pub(crate) fn new(out: &'a mut Vec) -> Self { + Self { out } + } + + pub(crate) fn json(&mut self, body: &[u8]) { + frame_response(self.out, "200 OK", Some("application/json"), body); + } + + pub(crate) fn empty(&mut self, content_type: &str) { + frame_response(self.out, "200 OK", Some(content_type), b""); + } + + /// Beacon-API error shape: `{"code":,"message":"..."}`. + pub(crate) fn error(&mut self, code: u16, message: &str) { + debug_assert!(!message.contains(['"', '\\']), "message goes into JSON unescaped"); + let status = match code { + 400 => "400 Bad Request", + 405 => "405 Method Not Allowed", + 503 => "503 Service Unavailable", + _ => unreachable!("unmapped error code {code}"), + }; + let body = format!("{{\"code\":{code},\"message\":\"{message}\"}}"); + frame_response(self.out, status, Some("application/json"), body.as_bytes()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_writes_status_line_and_json_body() { + let mut out = Vec::new(); + Response::new(&mut out).error(400, "invalid state_id"); + let expected: &[u8] = b"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: 41\r\n\r\n{\"code\":400,\"message\":\"invalid state_id\"}"; + assert_eq!(out, expected); + } + + #[test] + fn json_frames_ok_with_content_type() { + let mut out = Vec::new(); + Response::new(&mut out).json(b"{\"data\":1}"); + assert_eq!( + out, + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 10\r\n\r\n{\"data\":1}" + ); + } + + #[test] + fn empty_frames_ok_with_zero_length_body() { + let mut out = Vec::new(); + Response::new(&mut out).empty("text/plain"); + assert_eq!( + out, + b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 0\r\n\r\n" + ); + } +} diff --git a/crates/beacon_api/src/router.rs b/crates/beacon_api/src/router.rs new file mode 100644 index 00000000..f0d258df --- /dev/null +++ b/crates/beacon_api/src/router.rs @@ -0,0 +1,328 @@ +use silver_httpcore::{ParsedRequest, frame_response}; + +use crate::{response::Response, routes::ApiCtx}; + +const MAX_PARAMS: usize = 4; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Method { + Get, + Post, +} + +impl Method { + fn parse(name: &str) -> Option { + match name { + "GET" => Some(Self::Get), + "POST" => Some(Self::Post), + _ => None, + } + } +} + +pub(crate) type Handler = fn(&Request<'_>, &ApiCtx, &mut Response<'_>); + +// Fields become live with the first parameterised endpoints; until then only +// tests read them. +#[allow(dead_code)] +pub(crate) struct Request<'a> { + pub(crate) method: Method, + pub(crate) path: &'a str, + pub(crate) params: Params<'a>, + pub(crate) query: &'a str, + pub(crate) body: &'a [u8], +} + +pub(crate) struct Params<'a> { + entries: [(&'static str, &'a str); MAX_PARAMS], + len: usize, +} + +impl<'a> Params<'a> { + #[allow(dead_code)] + pub(crate) fn get(&self, name: &str) -> Option<&'a str> { + self.entries[..self.len].iter().find(|(n, _)| *n == name).map(|&(_, value)| value) + } + + fn push(&mut self, name: &'static str, value: &'a str) { + self.entries[self.len] = (name, value); + self.len += 1; + } +} + +impl Default for Params<'_> { + fn default() -> Self { + Self { entries: [("", ""); MAX_PARAMS], len: 0 } + } +} + +enum Seg { + Lit(&'static str), + Param(&'static str), +} + +struct Route { + method: Method, + segs: Vec, + handler: Handler, +} + +impl Route { + fn capture<'p>(&self, path: &'p str) -> Option> { + let mut parts = path.strip_prefix('/')?.split('/'); + let mut params = Params::default(); + for seg in &self.segs { + let part = parts.next()?; + match seg { + Seg::Lit(lit) if *lit == part => {} + Seg::Param(name) => params.push(name, part), + Seg::Lit(_) => return None, + } + } + parts.next().is_none().then_some(params) + } +} + +pub(crate) struct Router { + routes: Vec, +} + +impl Router { + pub(crate) fn new(table: &[(Method, &'static str, Handler)]) -> Self { + let mut routes: Vec = Vec::with_capacity(table.len()); + for &(method, pattern, handler) in table { + let segs = compile(pattern); + assert!( + !routes.iter().any(|r| r.method == method && same_match_set(&r.segs, &segs)), + "duplicate route pattern: {pattern}" + ); + routes.push(Route { method, segs, handler }); + } + Self { routes } + } + + pub(crate) fn dispatch(&self, req: &ParsedRequest<'_>, ctx: &ApiCtx, out: &mut Vec) { + let method = Method::parse(req.method); + let mut path_known = false; + for route in &self.routes { + let Some(params) = route.capture(req.path) else { continue }; + if method != Some(route.method) { + path_known = true; + continue; + } + let request = Request { + method: route.method, + path: req.path, + params, + query: req.query, + body: req.body, + }; + (route.handler)(&request, ctx, &mut Response::new(out)); + return; + } + if path_known { + Response::new(out).error(405, "method not allowed"); + } else { + tracing::warn!("unknown path: {}", req.path); + frame_response(out, "404 Not Found", None, b""); + } + } +} + +fn compile(pattern: &'static str) -> Vec { + let stripped = pattern + .strip_prefix('/') + .unwrap_or_else(|| panic!("route pattern must start with '/': {pattern}")); + let segs: Vec<_> = stripped + .split('/') + .map(|seg| match seg.strip_prefix('{') { + Some(name) => Seg::Param( + name.strip_suffix('}') + .unwrap_or_else(|| panic!("unterminated param in route pattern: {pattern}")), + ), + None => Seg::Lit(seg), + }) + .collect(); + let params = segs.iter().filter(|s| matches!(s, Seg::Param(_))).count(); + assert!(params <= MAX_PARAMS, "route pattern exceeds {MAX_PARAMS} params: {pattern}"); + segs +} + +/// Whether two compiled patterns match exactly the same set of paths — +/// param names don't affect matching, so they are ignored. +fn same_match_set(a: &[Seg], b: &[Seg]) -> bool { + a.len() == b.len() && + a.iter().zip(b).all(|pair| match pair { + (Seg::Lit(x), Seg::Lit(y)) => x == y, + (Seg::Param(_), Seg::Param(_)) => true, + _ => false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::routes::preboot_ctx; + + fn request<'a>(method: &'a str, path: &'a str) -> ParsedRequest<'a> { + ParsedRequest { method, path, query: "", body: b"", version: 1, keep_alive: true } + } + + fn dispatch(router: &Router, method: &str, path: &str) -> Vec { + let mut out = Vec::new(); + router.dispatch(&request(method, path), &preboot_ctx(), &mut out); + out + } + + fn body(response: &[u8]) -> &[u8] { + let s = std::str::from_utf8(response).unwrap(); + &response[s.find("\r\n\r\n").unwrap() + 4..] + } + + fn first(_req: &Request<'_>, _ctx: &ApiCtx, resp: &mut Response<'_>) { + resp.json(b"first"); + } + + fn second(_req: &Request<'_>, _ctx: &ApiCtx, resp: &mut Response<'_>) { + resp.json(b"second"); + } + + fn echo_params(req: &Request<'_>, _ctx: &ApiCtx, resp: &mut Response<'_>) { + let mut joined = String::new(); + for name in ["state_id", "epoch", "a", "b", "c", "d"] { + if let Some(value) = req.params.get(name) { + joined.push_str(name); + joined.push('='); + joined.push_str(value); + joined.push(';'); + } + } + resp.json(joined.as_bytes()); + } + + fn echo_query_body(req: &Request<'_>, _ctx: &ApiCtx, resp: &mut Response<'_>) { + let mut joined = req.query.as_bytes().to_vec(); + joined.push(b'|'); + joined.extend_from_slice(req.body); + resp.json(&joined); + } + + #[test] + fn literal_route_dispatches_matching_handler() { + let router = Router::new(&[ + (Method::Get, "/eth/v1/node/identity", first), + (Method::Get, "/metrics", second), + ]); + assert_eq!(body(&dispatch(&router, "GET", "/eth/v1/node/identity")), b"first"); + assert_eq!(body(&dispatch(&router, "GET", "/metrics")), b"second"); + } + + #[test] + fn single_param_extracted_by_name() { + let router = Router::new(&[( + Method::Get, + "/eth/v1/beacon/states/{state_id}/finality_checkpoints", + echo_params, + )]); + let resp = dispatch(&router, "GET", "/eth/v1/beacon/states/head/finality_checkpoints"); + assert_eq!(body(&resp), b"state_id=head;"); + } + + #[test] + fn two_params_extracted_by_name() { + let router = + Router::new(&[(Method::Get, "/eth/v1/states/{state_id}/epochs/{epoch}", echo_params)]); + let resp = dispatch(&router, "GET", "/eth/v1/states/0xdead/epochs/42"); + assert_eq!(body(&resp), b"state_id=0xdead;epoch=42;"); + } + + #[test] + fn url_encoded_param_value_passed_through_verbatim() { + let router = Router::new(&[(Method::Get, "/states/{state_id}", echo_params)]); + let resp = dispatch(&router, "GET", "/states/0x1234%2Fabc%20d"); + assert_eq!(body(&resp), b"state_id=0x1234%2Fabc%20d;"); + } + + #[test] + fn query_and_body_reach_handler() { + let router = Router::new(&[(Method::Post, "/submit", echo_query_body)]); + let mut out = Vec::new(); + let req = ParsedRequest { + method: "POST", + path: "/submit", + query: "k=v", + body: b"payload", + version: 1, + keep_alive: true, + }; + router.dispatch(&req, &preboot_ctx(), &mut out); + assert_eq!(body(&out), b"k=v|payload"); + } + + #[test] + fn unmatched_path_gets_bare_404() { + let router = Router::new(&[( + Method::Get, + "/eth/v1/beacon/states/{state_id}/finality_checkpoints", + echo_params, + )]); + let expected: &[u8] = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"; + assert_eq!(dispatch(&router, "GET", "/not/real"), expected); + assert_eq!(dispatch(&router, "GET", "/eth/v1/beacon/states/head"), expected, "prefix"); + assert_eq!( + dispatch(&router, "GET", "/eth/v1/beacon/states/head/finality_checkpoints/x"), + expected, + "longer than pattern" + ); + } + + #[test] + fn matched_path_wrong_method_gets_405() { + let router = Router::new(&[(Method::Get, "/metrics", first)]); + let resp = dispatch(&router, "POST", "/metrics"); + assert!(resp.starts_with(b"HTTP/1.1 405 Method Not Allowed\r\n")); + assert_eq!(body(&resp), br#"{"code":405,"message":"method not allowed"}"#); + } + + #[test] + fn unknown_method_gets_405_on_known_path_else_404() { + let router = Router::new(&[(Method::Get, "/metrics", first)]); + assert!(dispatch(&router, "PUT", "/metrics").starts_with(b"HTTP/1.1 405")); + assert!(dispatch(&router, "PUT", "/nope").starts_with(b"HTTP/1.1 404")); + } + + #[test] + fn same_pattern_distinct_methods_dispatch_by_method() { + let router = Router::new(&[ + (Method::Get, "/eth/v1/thing", first), + (Method::Post, "/eth/v1/thing", second), + ]); + assert_eq!(body(&dispatch(&router, "GET", "/eth/v1/thing")), b"first"); + assert_eq!(body(&dispatch(&router, "POST", "/eth/v1/thing")), b"second"); + } + + #[test] + #[should_panic(expected = "duplicate route pattern")] + fn duplicate_pattern_panics_at_init() { + Router::new(&[(Method::Get, "/a/b", first), (Method::Get, "/a/b", second)]); + } + + #[test] + #[should_panic(expected = "duplicate route pattern")] + fn duplicate_modulo_param_names_panics_at_init() { + Router::new(&[(Method::Get, "/a/{x}/c", first), (Method::Get, "/a/{y}/c", second)]); + } + + #[test] + fn four_param_pattern_matches() { + let router = Router::new(&[(Method::Get, "/{a}/{b}/{c}/{d}", echo_params)]); + let resp = dispatch(&router, "GET", "/1/2/3/4"); + assert_eq!(body(&resp), b"a=1;b=2;c=3;d=4;"); + } + + #[test] + #[should_panic(expected = "exceeds 4 params")] + fn fifth_param_panics_at_init() { + Router::new(&[(Method::Get, "/{a}/{b}/{c}/{d}/{e}", echo_params)]); + } +} diff --git a/crates/beacon_api/src/routes.rs b/crates/beacon_api/src/routes.rs new file mode 100644 index 00000000..d8da5ccf --- /dev/null +++ b/crates/beacon_api/src/routes.rs @@ -0,0 +1,173 @@ +#[cfg(test)] +use silver_beacon_state_data::BeaconStateOwner; +use silver_beacon_state_data::{BeaconStateReader, StateReadView}; +use silver_common::{Enr, Identify, Keypair}; + +use crate::{ + identity::build_identity_json, + response::Response, + router::{Handler, Method, Request}, +}; + +const METRICS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8"; + +pub(crate) const ROUTES: &[(Method, &str, Handler)] = + &[(Method::Get, "/eth/v1/node/identity", identity), (Method::Get, "/metrics", metrics)]; + +pub(crate) struct ApiCtx { + pub(crate) identity_json: Vec, + pub(crate) state: BeaconStateReader, +} + +impl ApiCtx { + pub(crate) fn new( + keypair: &Keypair, + local_enr: &Enr, + identify: &Identify, + state: BeaconStateReader, + ) -> Self { + Self { identity_json: build_identity_json(keypair, local_enr, identify), state } + } + + #[allow(dead_code)] + pub(crate) fn read_state_or_503( + &self, + resp: &mut Response<'_>, + read: impl Fn(StateReadView<'_>) -> R, + ) -> Option { + let result = self.state.read(&read); + if result.is_none() { + resp.error(503, "beacon node not initialized"); + } + result + } +} + +fn identity(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + resp.json(&ctx.identity_json); +} + +fn metrics(_req: &Request<'_>, _ctx: &ApiCtx, resp: &mut Response<'_>) { + resp.empty(METRICS_CONTENT_TYPE); +} + +/// Never-published reader: `read` yields `None`, as on a node before +/// bootstrap. +#[cfg(test)] +pub(crate) fn preboot_ctx() -> ApiCtx { + ApiCtx { identity_json: Vec::new(), state: BeaconStateOwner::empty_test(0).reader() } +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use silver_beacon_state_data::BeaconState; + use silver_httpcore::ParsedRequest; + + use super::*; + use crate::router::Router; + + /// Wire bytes the pre-table implementation produced for these exact + /// inputs (captured before the table dispatch landed). + const GOLDEN_IDENTITY: &str = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 478\r\n\r\n{\"data\":{\"peer_id\":\"16Uiu2HAmEWQnHq2jLKJypwVnVoQeFCULuyop6atvq2eWjYSUjzNi\",\"enr\":\"enr:-HW4QFVim6voTojjE-JbeUF0GPFRcqmWxgqgJ8-tXE5hh9PFTQSCwUJPHY_61U3Wvzi6OGrvJfb6KNjNpw4Q18sNL_sBgmlkgnY0iXNlY3AyNTZrMaEDG4TFVnsSZECZXT7VqroFZdceGDRgSBn_nBf16dXdB48\",\"p2p_addresses\":[\"/ip4/1.2.3.4/tcp/9000/p2p/16Uiu2HAmEWQnHq2jLKJypwVnVoQeFCULuyop6atvq2eWjYSUjzNi\"],\"discovery_addresses\":[],\"metadata\":{\"seq_number\":\"1\",\"attnets\":\"0x0000000000000000\",\"syncnets\":\"0x00\",\"custody_group_count\":\"4\"}}}"; + + fn fixture_ctx() -> ApiCtx { + let kp = Keypair::from_secret(&[1u8; 32]).unwrap(); + let enr = Enr::builder().build(kp.secret_key()).unwrap(); + let mut identify = Identify::default(); + identify.tcp_ipv4 = Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 9000)); + ApiCtx::new(&kp, &enr, &identify, BeaconStateOwner::empty_test(0).reader()) + } + + fn get(router: &Router, ctx: &ApiCtx, path: &str) -> Vec { + let mut out = Vec::new(); + let req = ParsedRequest { + method: "GET", + path, + query: "", + body: b"", + version: 1, + keep_alive: true, + }; + router.dispatch(&req, ctx, &mut out); + out + } + + fn body(response: &[u8]) -> &[u8] { + let s = std::str::from_utf8(response).unwrap(); + &response[s.find("\r\n\r\n").unwrap() + 4..] + } + + #[test] + fn identity_wire_bytes_match_pre_table_implementation() { + let router = Router::new(ROUTES); + let resp = get(&router, &fixture_ctx(), "/eth/v1/node/identity"); + assert_eq!(std::str::from_utf8(&resp).unwrap(), GOLDEN_IDENTITY); + } + + #[test] + fn identity_content_length_matches_body() { + let router = Router::new(ROUTES); + let resp = get(&router, &fixture_ctx(), "/eth/v1/node/identity"); + let s = std::str::from_utf8(&resp).unwrap(); + let header_end = s.find("\r\n\r\n").unwrap(); + let cl: usize = s[..header_end] + .lines() + .find(|l| l.to_ascii_lowercase().starts_with("content-length:")) + .unwrap() + .split(':') + .nth(1) + .unwrap() + .trim() + .parse() + .unwrap(); + assert_eq!(cl, s[header_end + 4..].len()); + } + + #[test] + fn metrics_response_valid_prometheus_format() { + let router = Router::new(ROUTES); + let resp = get(&router, &preboot_ctx(), "/metrics"); + let s = std::str::from_utf8(&resp).unwrap(); + assert!(s.starts_with("HTTP/1.1 200 OK\r\n")); + assert!(s.contains("text/plain; version=0.0.4; charset=utf-8")); + assert_eq!(body(&resp), b""); + } + + #[test] + fn unknown_path_returns_404() { + let router = Router::new(ROUTES); + let resp = get(&router, &preboot_ctx(), "/not/real"); + assert_eq!(resp, b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"); + } + + fn genesis_root(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + let Some(root) = ctx.read_state_or_503(resp, |view| view.imm.genesis_validators_root) + else { + return; + }; + resp.json(hex::encode(root).as_bytes()); + } + + #[test] + fn state_route_503_before_bootstrap() { + let router = Router::new(&[(Method::Get, "/test/genesis_root", genesis_root)]); + let resp = get(&router, &preboot_ctx(), "/test/genesis_root"); + assert!(resp.starts_with(b"HTTP/1.1 503 Service Unavailable\r\n")); + assert_eq!(body(&resp), br#"{"code":503,"message":"beacon node not initialized"}"#); + } + + #[test] + fn state_route_reads_published_state() { + let mut owner = BeaconStateOwner::new(BeaconState::empty_test(0)); + let anchor = owner.roll_fresh(); + owner.publish_state_id(anchor); + let ctx = ApiCtx { identity_json: Vec::new(), state: owner.reader() }; + + let router = Router::new(&[(Method::Get, "/test/genesis_root", genesis_root)]); + let resp = get(&router, &ctx, "/test/genesis_root"); + assert!(resp.starts_with(b"HTTP/1.1 200 OK\r\n")); + assert_eq!(body(&resp), hex::encode([0u8; 32]).as_bytes()); + } +} diff --git a/crates/beacon_api/src/tile.rs b/crates/beacon_api/src/tile.rs index 19ad2ebe..09b99076 100644 --- a/crates/beacon_api/src/tile.rs +++ b/crates/beacon_api/src/tile.rs @@ -9,36 +9,16 @@ use mio::{ Events, Interest, Poll, Token, net::{TcpListener, TcpStream}, }; -use serde::{Deserialize, Serialize}; -use silver_common::{Enr, Eth2Addr, Identify, Keypair, SilverSpine}; -use silver_httpcore::{AfterResponse, ParsedRequest, ServerConnection, frame_response}; +use silver_beacon_state_data::BeaconStateReader; +use silver_common::{Enr, Identify, Keypair, SilverSpine}; +use silver_httpcore::{AfterResponse, ParsedRequest, ServerConnection}; -const LISTENER: Token = Token(0); -const IDENTITY_PATH: &str = "/eth/v1/node/identity"; -const METRICS_PATH: &str = "/metrics"; -const METRICS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8"; - -#[derive(Debug, Serialize)] -struct IdentityResponse<'a> { - data: &'a Identity, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct Identity { - peer_id: String, - enr: String, - p2p_addresses: Vec, - discovery_addresses: Vec, - metadata: Metadata, -} +use crate::{ + router::Router, + routes::{ApiCtx, ROUTES}, +}; -#[derive(Debug, Clone, Serialize, Deserialize)] -struct Metadata { - seq_number: String, - attnets: String, - syncnets: String, - custody_group_count: String, -} +const LISTENER: Token = Token(0); struct Connection { stream: TcpStream, @@ -51,25 +31,30 @@ pub struct BeaconApiTile { listener: TcpListener, current_token: Token, connections: HashMap, - identity_response: Vec, + router: Router, + ctx: ApiCtx, } impl BeaconApiTile { - pub fn new(keypair: &Keypair, local_enr: Enr, identify: &Identify) -> Self { + pub fn new( + keypair: &Keypair, + local_enr: Enr, + identify: &Identify, + state: BeaconStateReader, + ) -> Self { let poll = Poll::new().unwrap(); let addr = "0.0.0.0:5051".parse().unwrap(); let mut listener = TcpListener::bind(addr).unwrap(); poll.registry().register(&mut listener, LISTENER, Interest::READABLE).unwrap(); - let identity_response = build_identity_response(keypair, &local_enr, identify); - Self { poll, events: Events::with_capacity(1024), listener, current_token: Token(LISTENER.0 + 1), connections: HashMap::new(), - identity_response, + router: Router::new(ROUTES), + ctx: ApiCtx::new(keypair, &local_enr, identify, state), } } } @@ -96,18 +81,9 @@ impl Tile for BeaconApiTile { .insert(token, Connection { stream, http: ServerConnection::new() }); } token => { - // TODO: path routing here is exact-match only. Most beacon - // API paths are parameterised - // (/eth/v1/beacon/states/{state_id}/...). Add - // prefix/pattern matching before implementing any - // parameterised routes. if let Some(conn) = self.connections.get_mut(&token) { - match handle_event(self.poll.registry(), conn, event, &|req, out| match req - .path - { - IDENTITY_PATH => handle_identity(&self.identity_response, out), - METRICS_PATH => handle_metrics(out), - _ => handle_unknown(req.path, out), + match handle_event(self.poll.registry(), conn, event, &|req, out| { + self.router.dispatch(req, &self.ctx, out) }) { Ok(true) => { let _ = self.poll.registry().deregister(&mut conn.stream); @@ -127,19 +103,6 @@ impl Tile for BeaconApiTile { } } -fn handle_identity(response: &[u8], out: &mut Vec) { - out.extend_from_slice(response); -} - -fn handle_metrics(out: &mut Vec) { - frame_response(out, "200 OK", Some(METRICS_CONTENT_TYPE), b""); -} - -fn handle_unknown(path: &str, out: &mut Vec) { - tracing::warn!("unknown path: {path}"); - frame_response(out, "404 Not Found", None, b""); -} - fn handle_event, &mut Vec)>( registry: &mio::Registry, conn: &mut Connection, @@ -198,61 +161,6 @@ fn handle_event, &mut Vec)>( Ok(false) } -fn build_identity_response(keypair: &Keypair, local_enr: &Enr, identify: &Identify) -> Vec { - let pid_multiaddr = Eth2Addr::PeerId(keypair.peer_id()).to_string(); - let peer_id_str = pid_multiaddr.strip_prefix("/p2p/").unwrap_or(&pid_multiaddr); - - let mut p2p_addresses = Vec::new(); - if let Some(addr) = identify.tcp_ipv4 { - p2p_addresses.push(format!("/ip4/{}/tcp/{}/p2p/{}", addr.ip(), addr.port(), peer_id_str)); - } - if let Some(addr) = identify.tcp_ipv6 { - p2p_addresses.push(format!("/ip6/{}/tcp/{}/p2p/{}", addr.ip(), addr.port(), peer_id_str)); - } - if let Some(addr) = identify.udp_ipv4 { - p2p_addresses.push(format!( - "/ip4/{}/udp/{}/quic-v1/p2p/{}", - addr.ip(), - addr.port(), - peer_id_str - )); - } - if let Some(addr) = identify.udp_ipv6 { - p2p_addresses.push(format!( - "/ip6/{}/udp/{}/quic-v1/p2p/{}", - addr.ip(), - addr.port(), - peer_id_str - )); - } - - let mut discovery_addresses = Vec::new(); - if let (Some(ip), Some(udp)) = (local_enr.ip4(), local_enr.udp4()) { - discovery_addresses.push(format!("/ip4/{}/udp/{}/p2p/{}", ip, udp, peer_id_str)); - } - if let (Some(ip), Some(udp)) = (local_enr.ip6(), local_enr.udp6()) { - discovery_addresses.push(format!("/ip6/{}/udp/{}/p2p/{}", ip, udp, peer_id_str)); - } - - let identity = Identity { - peer_id: peer_id_str.to_string(), - enr: local_enr.to_base64(), - p2p_addresses, - discovery_addresses, - metadata: Metadata { - seq_number: local_enr.seq().to_string(), - attnets: format!("0x{}", hex::encode(local_enr.attnets().unwrap_or([0u8; 8]))), - syncnets: format!("0x{:02x}", local_enr.syncnets().unwrap_or(0)), - custody_group_count: local_enr.cgc().unwrap_or(4).to_string(), - }, - }; - - let body = serde_json::to_string(&IdentityResponse { data: &identity }).unwrap(); - let mut response = Vec::new(); - frame_response(&mut response, "200 OK", Some("application/json"), body.as_bytes()); - response -} - fn next(current: &mut Token) -> Token { let tok = Token(current.0); let n = current.0.wrapping_add(1); @@ -271,82 +179,8 @@ fn interrupted(err: &io::Error) -> bool { #[cfg(test)] mod tests { - use silver_common::{Enr, Identify, Keypair}; - use super::*; - #[test] - fn metrics_response_valid_prometheus_format() { - let mut out = Vec::new(); - handle_metrics(&mut out); - let s = std::str::from_utf8(&out).unwrap(); - assert!(s.starts_with("HTTP/1.1 200 OK\r\n")); - assert!(s.contains("text/plain; version=0.0.4; charset=utf-8")); - let body_start = s.find("\r\n\r\n").unwrap() + 4; - assert_eq!(&s[body_start..], ""); - } - - #[test] - fn unknown_path_returns_404() { - let mut out = Vec::new(); - handle_unknown("/not/real", &mut out); - assert!(out.starts_with(b"HTTP/1.1 404")); - } - - #[test] - fn identity_response_content_length_matches_body() { - let kp = Keypair::from_secret(&[1u8; 32]).unwrap(); - let enr = Enr::builder().build(kp.secret_key()).unwrap(); - let resp = build_identity_response(&kp, &enr, &Identify::default()); - let s = std::str::from_utf8(&resp).unwrap(); - let header_end = s.find("\r\n\r\n").unwrap(); - let body = &s[header_end + 4..]; - let cl: usize = s[..header_end] - .lines() - .find(|l| l.to_ascii_lowercase().starts_with("content-length:")) - .unwrap() - .split(':') - .nth(1) - .unwrap() - .trim() - .parse() - .unwrap(); - assert_eq!(cl, body.len()); - } - - #[test] - fn identity_response_json_fields_present() { - let kp = Keypair::from_secret(&[1u8; 32]).unwrap(); - let enr = Enr::builder().build(kp.secret_key()).unwrap(); - let resp = build_identity_response(&kp, &enr, &Identify::default()); - let s = std::str::from_utf8(&resp).unwrap(); - let body = &s[s.find("\r\n\r\n").unwrap() + 4..]; - let v: serde_json::Value = serde_json::from_str(body).unwrap(); - let data = &v["data"]; - assert!(data["peer_id"].as_str().is_some_and(|s| !s.is_empty())); - assert!(data["enr"].as_str().is_some_and(|s| s.starts_with("enr:"))); - assert!(data["metadata"]["seq_number"].as_str().is_some()); - assert!(data["metadata"]["attnets"].as_str().is_some_and(|s| s.starts_with("0x"))); - assert!(data["metadata"]["syncnets"].as_str().is_some_and(|s| s.starts_with("0x"))); - } - - #[test] - fn identity_response_p2p_address_format() { - use std::net::{IpAddr, Ipv4Addr, SocketAddr}; - let kp = Keypair::from_secret(&[1u8; 32]).unwrap(); - let enr = Enr::builder().build(kp.secret_key()).unwrap(); - let mut identify = Identify::default(); - identify.tcp_ipv4 = Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 9000)); - let resp = build_identity_response(&kp, &enr, &identify); - let s = std::str::from_utf8(&resp).unwrap(); - let body = &s[s.find("\r\n\r\n").unwrap() + 4..]; - let v: serde_json::Value = serde_json::from_str(body).unwrap(); - let addrs = v["data"]["p2p_addresses"].as_array().unwrap(); - assert_eq!(addrs.len(), 1); - let addr = addrs[0].as_str().unwrap(); - assert!(addr.starts_with("/ip4/1.2.3.4/tcp/9000/p2p/"), "bad format: {addr}"); - } - #[test] fn token_wrap_skips_listener() { let mut cur = Token(usize::MAX); diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index b733a756..35283a39 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -179,7 +179,6 @@ fn main() -> Result<(), Box> { } } - let beacon_api_tile = BeaconApiTile::new(&keypair, local_enr, &identify); let network_tile = NetworkTile::new(discv5_addr, discv5, p2p_addr, p2p_endpoint, p2p_context)?; let (checkpoint, checkpoint_pubkeys) = load_checkpoint(&config)?; @@ -231,6 +230,9 @@ fn main() -> Result<(), Box> { !config.disable_weak_subjectivity_check(), state, ); + let beacon_api_tile = + BeaconApiTile::new(&keypair, local_enr, &identify, beacon_state_tile.reader()); + let state_reader = beacon_state_tile.reader(); let storage_tile = StorageTile::new( From 96a900cedb21d21216a1f25974fc43a426f339b8 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Mon, 17 Aug 2026 13:12:19 +0100 Subject: [PATCH 06/16] Rename crates/engine to crates/engine_api Names track current reality: since C2 the crate is a pure engine-API protocol client (JSON-RPC, JWT, correlation, ReqKind dispatch) over the shared silver_httpcore transport, and "Engine API" is the established name for the EL protocol it speaks. Package silver_engine becomes silver_engine_api. Purely mechanical; no logic changes. The EngineTile type and the spine-flow doc's "Engine" tile naming are untouched -- the tile itself dissolves in the upcoming consolidation commit, which owns that doc update. Assisted-by: Claude:claude-fable-5 --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- crates/bin/Cargo.toml | 2 +- crates/bin/src/main.rs | 2 +- crates/{engine => engine_api}/Cargo.toml | 2 +- crates/{engine => engine_api}/src/client.rs | 0 crates/{engine => engine_api}/src/error.rs | 0 crates/{engine => engine_api}/src/jwt.rs | 0 crates/{engine => engine_api}/src/lib.rs | 0 crates/{engine => engine_api}/src/pool.rs | 0 crates/{engine => engine_api}/src/req_handlers.rs | 0 crates/{engine => engine_api}/src/resp_handlers.rs | 0 crates/{engine => engine_api}/src/test_el.rs | 0 crates/{engine => engine_api}/src/tile.rs | 0 crates/{engine => engine_api}/src/types.rs | 0 .../testdata/empty_var_payload.ssz | Bin .../testdata/get_payload_tcache.bin | Bin .../testdata/large_extra_payload.ssz | Bin .../testdata/many_tx_payload.ssz | Bin .../testdata/sample_payload.ssz | Bin .../testdata/signed_block.ssz | Bin .../testdata/signed_block_params.json | 0 crates/{engine => engine_api}/testdata/tx_multi.bin | Bin .../{engine => engine_api}/testdata/tx_single.bin | Bin .../{engine => engine_api}/testdata/withdrawals.bin | Bin crates/httpcore/src/client.rs | 2 +- 26 files changed, 8 insertions(+), 8 deletions(-) rename crates/{engine => engine_api}/Cargo.toml (95%) rename crates/{engine => engine_api}/src/client.rs (100%) rename crates/{engine => engine_api}/src/error.rs (100%) rename crates/{engine => engine_api}/src/jwt.rs (100%) rename crates/{engine => engine_api}/src/lib.rs (100%) rename crates/{engine => engine_api}/src/pool.rs (100%) rename crates/{engine => engine_api}/src/req_handlers.rs (100%) rename crates/{engine => engine_api}/src/resp_handlers.rs (100%) rename crates/{engine => engine_api}/src/test_el.rs (100%) rename crates/{engine => engine_api}/src/tile.rs (100%) rename crates/{engine => engine_api}/src/types.rs (100%) rename crates/{engine => engine_api}/testdata/empty_var_payload.ssz (100%) rename crates/{engine => engine_api}/testdata/get_payload_tcache.bin (100%) rename crates/{engine => engine_api}/testdata/large_extra_payload.ssz (100%) rename crates/{engine => engine_api}/testdata/many_tx_payload.ssz (100%) rename crates/{engine => engine_api}/testdata/sample_payload.ssz (100%) rename crates/{engine => engine_api}/testdata/signed_block.ssz (100%) rename crates/{engine => engine_api}/testdata/signed_block_params.json (100%) rename crates/{engine => engine_api}/testdata/tx_multi.bin (100%) rename crates/{engine => engine_api}/testdata/tx_single.bin (100%) rename crates/{engine => engine_api}/testdata/withdrawals.bin (100%) diff --git a/Cargo.lock b/Cargo.lock index 37d87515..b61065e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4435,7 +4435,7 @@ dependencies = [ "silver_config", "silver_control", "silver_discovery", - "silver_engine", + "silver_engine_api", "silver_gossip", "silver_network", "silver_peer", @@ -4647,7 +4647,7 @@ dependencies = [ ] [[package]] -name = "silver_engine" +name = "silver_engine_api" version = "0.0.1" dependencies = [ "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 43387908..2d0fa574 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ "crates/e2e", "crates/gossip", "crates/httpcore", - "crates/engine", + "crates/engine_api", "crates/metrics", "crates/network", "crates/peer", @@ -78,7 +78,7 @@ silver_httpcore = { path = "crates/httpcore" } silver_network = {path = "crates/network" } silver_peer = {path = "crates/peer" } silver_storage = { path = "crates/storage" } -silver_engine = { path = "crates/engine"} +silver_engine_api = { path = "crates/engine_api" } flux = { git = "https://github.com/gattaca-com/flux", rev = "d6785f1af35336002476c3d97721fcc67fe76dfd"} flux-utils = { git = "https://github.com/gattaca-com/flux", rev = "d6785f1af35336002476c3d97721fcc67fe76dfd", features = ["bytes"]} flux-profiler = { git = "https://github.com/gattaca-com/flux", rev = "d6785f1af35336002476c3d97721fcc67fe76dfd"} diff --git a/crates/bin/Cargo.toml b/crates/bin/Cargo.toml index 8fb3e1ac..d666283e 100644 --- a/crates/bin/Cargo.toml +++ b/crates/bin/Cargo.toml @@ -18,7 +18,7 @@ silver_gossip.workspace = true silver_network.workspace = true silver_peer.workspace = true silver_storage.workspace = true -silver_engine.workspace = true +silver_engine_api.workspace = true clap.workspace = true flux.workspace = true diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index 35283a39..e2de9a5c 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -16,7 +16,7 @@ use silver_common::{ use silver_config::Config; use silver_control::Controller; use silver_discovery::{DiscV5, Discovery}; -use silver_engine::EngineTile; +use silver_engine_api::EngineTile; use silver_gossip::GossipHandler; use silver_network::{Context, NetworkTile, P2p}; use silver_peer::PeerManager; diff --git a/crates/engine/Cargo.toml b/crates/engine_api/Cargo.toml similarity index 95% rename from crates/engine/Cargo.toml rename to crates/engine_api/Cargo.toml index 2f6d0b68..0ada4f80 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine_api/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "silver_engine" +name = "silver_engine_api" edition.workspace = true repository.workspace = true rust-version.workspace = true diff --git a/crates/engine/src/client.rs b/crates/engine_api/src/client.rs similarity index 100% rename from crates/engine/src/client.rs rename to crates/engine_api/src/client.rs diff --git a/crates/engine/src/error.rs b/crates/engine_api/src/error.rs similarity index 100% rename from crates/engine/src/error.rs rename to crates/engine_api/src/error.rs diff --git a/crates/engine/src/jwt.rs b/crates/engine_api/src/jwt.rs similarity index 100% rename from crates/engine/src/jwt.rs rename to crates/engine_api/src/jwt.rs diff --git a/crates/engine/src/lib.rs b/crates/engine_api/src/lib.rs similarity index 100% rename from crates/engine/src/lib.rs rename to crates/engine_api/src/lib.rs diff --git a/crates/engine/src/pool.rs b/crates/engine_api/src/pool.rs similarity index 100% rename from crates/engine/src/pool.rs rename to crates/engine_api/src/pool.rs diff --git a/crates/engine/src/req_handlers.rs b/crates/engine_api/src/req_handlers.rs similarity index 100% rename from crates/engine/src/req_handlers.rs rename to crates/engine_api/src/req_handlers.rs diff --git a/crates/engine/src/resp_handlers.rs b/crates/engine_api/src/resp_handlers.rs similarity index 100% rename from crates/engine/src/resp_handlers.rs rename to crates/engine_api/src/resp_handlers.rs diff --git a/crates/engine/src/test_el.rs b/crates/engine_api/src/test_el.rs similarity index 100% rename from crates/engine/src/test_el.rs rename to crates/engine_api/src/test_el.rs diff --git a/crates/engine/src/tile.rs b/crates/engine_api/src/tile.rs similarity index 100% rename from crates/engine/src/tile.rs rename to crates/engine_api/src/tile.rs diff --git a/crates/engine/src/types.rs b/crates/engine_api/src/types.rs similarity index 100% rename from crates/engine/src/types.rs rename to crates/engine_api/src/types.rs diff --git a/crates/engine/testdata/empty_var_payload.ssz b/crates/engine_api/testdata/empty_var_payload.ssz similarity index 100% rename from crates/engine/testdata/empty_var_payload.ssz rename to crates/engine_api/testdata/empty_var_payload.ssz diff --git a/crates/engine/testdata/get_payload_tcache.bin b/crates/engine_api/testdata/get_payload_tcache.bin similarity index 100% rename from crates/engine/testdata/get_payload_tcache.bin rename to crates/engine_api/testdata/get_payload_tcache.bin diff --git a/crates/engine/testdata/large_extra_payload.ssz b/crates/engine_api/testdata/large_extra_payload.ssz similarity index 100% rename from crates/engine/testdata/large_extra_payload.ssz rename to crates/engine_api/testdata/large_extra_payload.ssz diff --git a/crates/engine/testdata/many_tx_payload.ssz b/crates/engine_api/testdata/many_tx_payload.ssz similarity index 100% rename from crates/engine/testdata/many_tx_payload.ssz rename to crates/engine_api/testdata/many_tx_payload.ssz diff --git a/crates/engine/testdata/sample_payload.ssz b/crates/engine_api/testdata/sample_payload.ssz similarity index 100% rename from crates/engine/testdata/sample_payload.ssz rename to crates/engine_api/testdata/sample_payload.ssz diff --git a/crates/engine/testdata/signed_block.ssz b/crates/engine_api/testdata/signed_block.ssz similarity index 100% rename from crates/engine/testdata/signed_block.ssz rename to crates/engine_api/testdata/signed_block.ssz diff --git a/crates/engine/testdata/signed_block_params.json b/crates/engine_api/testdata/signed_block_params.json similarity index 100% rename from crates/engine/testdata/signed_block_params.json rename to crates/engine_api/testdata/signed_block_params.json diff --git a/crates/engine/testdata/tx_multi.bin b/crates/engine_api/testdata/tx_multi.bin similarity index 100% rename from crates/engine/testdata/tx_multi.bin rename to crates/engine_api/testdata/tx_multi.bin diff --git a/crates/engine/testdata/tx_single.bin b/crates/engine_api/testdata/tx_single.bin similarity index 100% rename from crates/engine/testdata/tx_single.bin rename to crates/engine_api/testdata/tx_single.bin diff --git a/crates/engine/testdata/withdrawals.bin b/crates/engine_api/testdata/withdrawals.bin similarity index 100% rename from crates/engine/testdata/withdrawals.bin rename to crates/engine_api/testdata/withdrawals.bin diff --git a/crates/httpcore/src/client.rs b/crates/httpcore/src/client.rs index a07a7a88..293c6bf3 100644 --- a/crates/httpcore/src/client.rs +++ b/crates/httpcore/src/client.rs @@ -175,7 +175,7 @@ mod tests { conn.commit_read(n) } - // Captured verbatim from silver_engine's `build_request_into` before the + // Captured verbatim from the engine crate's `build_request_into` before the // extraction (2026-08-17); the framed request must stay byte-identical. #[test] fn golden_request_bytes_keep_alive() { From 36f291ec66f4d5d924203127a3cec2d51ac776b3 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Mon, 17 Aug 2026 13:47:05 +0100 Subject: [PATCH 07/16] Consolidate API access into the client_server tile Realizes docs/adr/0001: one spine-attached tile now hosts all API access. BeaconApiTile and EngineTile dissolve into transport-free-of-flux components -- BeaconApi (own mio Poll, now polled with Duration::ZERO: the 100 ms blocking poll is gone) and EngineApi (EngineTile's intake/spin logic verbatim; C2's pool and event paths untouched) -- composed by plain function calls in ClientServerTile::loop_body. The tile attaches at core 5; core 7 is freed. Server activity now feeds flux work-tracking (the old beacon tile ignored its adapter). Config: beacon_api_bind (default 0.0.0.0:5051, preserving today's behavior) via config file, builder, and --beacon-api-bind; binds parse as TCP addr or unix socket path (httpcore Bind/Listener, UDS serving included); execution_endpoint accepts http:// or a socket path, panicking on any other scheme. BeaconApi::local_addr exposes the resolved bind so tests bind port 0 and discover the ephemeral port. New integration tests drive the real tile over a real spine (SpineAdapter::connect_tile) with hand-cranked loop_body: identity served over real TCP and UDS sockets, and the merged-loop invariant from ADR 0004 gets its first test -- a beacon-api request served while four engine calls sit unanswered on a fake EL, with the FCU completion still correlating afterwards. The pool-cap test migrates to the merged tile intact. Accept now drains until WouldBlock (single-accept could strand a simultaneous second connection under edge-triggered registration), and EngineApi::spin no-ops without an EL client instead of panicking, since the merged loop calls it unconditionally in unsafe_no_el mode. Assisted-by: Claude:claude-fable-5 --- Cargo.lock | 20 +- Cargo.toml | 2 + crates/beacon_api/Cargo.toml | 1 - crates/beacon_api/examples/srv.rs | 25 +- crates/beacon_api/src/lib.rs | 4 +- crates/beacon_api/src/{tile.rs => server.rs} | 50 ++-- crates/bin/Cargo.toml | 2 + crates/bin/src/main.rs | 26 +- crates/client_server/Cargo.toml | 24 ++ crates/client_server/src/lib.rs | 19 ++ crates/client_server/tests/tile.rs | 299 +++++++++++++++++++ crates/config/src/lib.rs | 26 ++ crates/engine_api/Cargo.toml | 5 + crates/engine_api/src/{tile.rs => api.rs} | 193 +++--------- crates/engine_api/src/client.rs | 39 ++- crates/engine_api/src/lib.rs | 8 +- crates/engine_api/src/test_el.rs | 28 +- crates/httpcore/Cargo.toml | 3 + crates/httpcore/src/lib.rs | 2 +- crates/httpcore/src/stream.rs | 127 +++++++- docs/spine-message-flow.md | 21 +- 21 files changed, 688 insertions(+), 236 deletions(-) rename crates/beacon_api/src/{tile.rs => server.rs} (84%) create mode 100644 crates/client_server/Cargo.toml create mode 100644 crates/client_server/src/lib.rs create mode 100644 crates/client_server/tests/tile.rs rename crates/engine_api/src/{tile.rs => api.rs} (54%) diff --git a/Cargo.lock b/Cargo.lock index b61065e6..58b6806f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4430,6 +4430,7 @@ dependencies = [ "silver_beacon_api", "silver_beacon_state", "silver_beacon_state_data", + "silver_client_server", "silver_columns", "silver_common", "silver_config", @@ -4437,6 +4438,7 @@ dependencies = [ "silver_discovery", "silver_engine_api", "silver_gossip", + "silver_httpcore", "silver_network", "silver_peer", "silver_storage", @@ -4447,7 +4449,6 @@ dependencies = [ name = "silver_beacon_api" version = "0.0.1" dependencies = [ - "flux", "hex", "mio", "serde", @@ -4509,6 +4510,22 @@ dependencies = [ "serde", ] +[[package]] +name = "silver_client_server" +version = "0.0.1" +dependencies = [ + "flux", + "hex", + "serde_json", + "silver_beacon_api", + "silver_beacon_state_data", + "silver_common", + "silver_config", + "silver_engine_api", + "silver_httpcore", + "tempfile", +] + [[package]] name = "silver_columns" version = "0.0.1" @@ -4695,6 +4712,7 @@ version = "0.0.1" dependencies = [ "httparse", "mio", + "tempfile", "tracing", ] diff --git a/Cargo.toml b/Cargo.toml index 2d0fa574..1e1bbbe4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/beacon_state/data", "crates/beacon_state/tile", "crates/bin", + "crates/client_server", "crates/common", "crates/config", "crates/config/chain_spec", @@ -66,6 +67,7 @@ silver_beacon_api = { path = "crates/beacon_api" } silver_beacon_state = { path = "crates/beacon_state/tile" } silver_beacon_state_data = { path = "crates/beacon_state/data" } silver_chain_spec = { path = "crates/config/chain_spec" } +silver_client_server = { path = "crates/client_server" } silver_columns = { path = "crates/columns" } silver_common = { path = "crates/common" } silver_config = { path = "crates/config" } diff --git a/crates/beacon_api/Cargo.toml b/crates/beacon_api/Cargo.toml index 51d08ff2..e62d76bf 100644 --- a/crates/beacon_api/Cargo.toml +++ b/crates/beacon_api/Cargo.toml @@ -6,7 +6,6 @@ rust-version.workspace = true version.workspace = true [dependencies] -flux.workspace = true hex.workspace = true mio.workspace = true silver_beacon_state_data.workspace = true diff --git a/crates/beacon_api/examples/srv.rs b/crates/beacon_api/examples/srv.rs index 4f6d8627..87316c0b 100644 --- a/crates/beacon_api/examples/srv.rs +++ b/crates/beacon_api/examples/srv.rs @@ -1,20 +1,21 @@ -use flux::tile::{TileConfig, attach_tile}; -use silver_beacon_api::BeaconApiTile; +use std::time::Duration; + +use silver_beacon_api::BeaconApi; use silver_beacon_state_data::BeaconStateOwner; -use silver_common::{Enr, Identify, Keypair, SilverSpine}; +use silver_common::{Enr, Identify, Keypair}; +use silver_httpcore::Bind; fn main() { + let bind = Bind::parse(&std::env::args().nth(1).unwrap_or_else(|| "0.0.0.0:5051".into())); let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); let local_enr = Enr::empty(keypair.secret_key()).unwrap(); - let identify = Identify::default(); // Never-published reader: state endpoints answer 503, as pre-bootstrap. let state = BeaconStateOwner::empty_test(0).reader(); - let spine = SilverSpine::new(None); - spine.start(None, None, |scoped_spine| { - attach_tile( - BeaconApiTile::new(&keypair, local_enr, &identify, state), - scoped_spine, - TileConfig::new(1, None), - ); - }); + + let mut api = BeaconApi::new(&bind, &keypair, local_enr, &Identify::default(), state); + println!("serving on {:?}", api.local_addr()); + loop { + api.pump(); + std::thread::sleep(Duration::from_millis(1)); + } } diff --git a/crates/beacon_api/src/lib.rs b/crates/beacon_api/src/lib.rs index e7bb308d..56e01769 100644 --- a/crates/beacon_api/src/lib.rs +++ b/crates/beacon_api/src/lib.rs @@ -2,6 +2,6 @@ mod identity; mod response; mod router; mod routes; -mod tile; +mod server; -pub use tile::BeaconApiTile; +pub use server::BeaconApi; diff --git a/crates/beacon_api/src/tile.rs b/crates/beacon_api/src/server.rs similarity index 84% rename from crates/beacon_api/src/tile.rs rename to crates/beacon_api/src/server.rs index 09b99076..82049a6e 100644 --- a/crates/beacon_api/src/tile.rs +++ b/crates/beacon_api/src/server.rs @@ -4,14 +4,10 @@ use std::{ time::Duration, }; -use flux::{spine::SpineAdapter, tile::Tile}; -use mio::{ - Events, Interest, Poll, Token, - net::{TcpListener, TcpStream}, -}; +use mio::{Events, Interest, Poll, Token}; use silver_beacon_state_data::BeaconStateReader; -use silver_common::{Enr, Identify, Keypair, SilverSpine}; -use silver_httpcore::{AfterResponse, ParsedRequest, ServerConnection}; +use silver_common::{Enr, Identify, Keypair}; +use silver_httpcore::{AfterResponse, Bind, Listener, ParsedRequest, ServerConnection, Stream}; use crate::{ router::Router, @@ -21,30 +17,31 @@ use crate::{ const LISTENER: Token = Token(0); struct Connection { - stream: TcpStream, + stream: Stream, http: ServerConnection, } -pub struct BeaconApiTile { +pub struct BeaconApi { poll: Poll, events: Events, - listener: TcpListener, + listener: Listener, current_token: Token, connections: HashMap, router: Router, ctx: ApiCtx, } -impl BeaconApiTile { +impl BeaconApi { pub fn new( + bind: &Bind, keypair: &Keypair, local_enr: Enr, identify: &Identify, state: BeaconStateReader, ) -> Self { let poll = Poll::new().unwrap(); - let addr = "0.0.0.0:5051".parse().unwrap(); - let mut listener = TcpListener::bind(addr).unwrap(); + let mut listener = + Listener::bind(bind).unwrap_or_else(|e| panic!("beacon api bind {bind:?}: {e}")); poll.registry().register(&mut listener, LISTENER, Interest::READABLE).unwrap(); Self { @@ -57,31 +54,36 @@ impl BeaconApiTile { ctx: ApiCtx::new(keypair, &local_enr, identify, state), } } -} -impl Tile for BeaconApiTile { - fn loop_body(&mut self, _adapter: &mut SpineAdapter) { - self.poll.poll(&mut self.events, Some(Duration::from_millis(100))).unwrap(); + pub fn local_addr(&self) -> Bind { + self.listener.local_addr() + } + + pub fn pump(&mut self) -> bool { + self.poll.poll(&mut self.events, Some(Duration::ZERO)).unwrap(); + let mut did_work = false; for event in &self.events { match event.token() { - LISTENER => { - let (mut stream, address) = match self.listener.accept() { - Ok(conn) => conn, + LISTENER => loop { + let mut stream = match self.listener.accept() { + Ok(stream) => stream, + Err(e) if would_block(&e) => break, Err(e) => { tracing::warn!("accept failed: {e}"); - continue; + break; } }; - tracing::info!("accepted connection from {address}"); + did_work = true; let token = next(&mut self.current_token); self.poll.registry().register(&mut stream, token, Interest::READABLE).unwrap(); self.connections .insert(token, Connection { stream, http: ServerConnection::new() }); - } + }, token => { if let Some(conn) = self.connections.get_mut(&token) { + did_work = true; match handle_event(self.poll.registry(), conn, event, &|req, out| { self.router.dispatch(req, &self.ctx, out) }) { @@ -100,6 +102,8 @@ impl Tile for BeaconApiTile { } } } + + did_work } } diff --git a/crates/bin/Cargo.toml b/crates/bin/Cargo.toml index d666283e..11a264d0 100644 --- a/crates/bin/Cargo.toml +++ b/crates/bin/Cargo.toml @@ -9,6 +9,7 @@ version.workspace = true silver_beacon_api.workspace = true silver_beacon_state.workspace = true silver_beacon_state_data.workspace = true +silver_client_server.workspace = true silver_columns.workspace = true silver_common.workspace = true silver_config.workspace = true @@ -19,6 +20,7 @@ silver_network.workspace = true silver_peer.workspace = true silver_storage.workspace = true silver_engine_api.workspace = true +silver_httpcore.workspace = true clap.workspace = true flux.workspace = true diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index e2de9a5c..00fd7290 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -4,9 +4,10 @@ use flux::tile::{TileConfig, attach_tile}; use mimalloc::MiMalloc; use quinn_proto::{Endpoint, EndpointConfig}; use rand::RngCore; -use silver_beacon_api::BeaconApiTile; +use silver_beacon_api::BeaconApi; use silver_beacon_state::{BeaconStateTile, SlotTicker}; use silver_beacon_state_data::{BeaconState, SLOTS_PER_EPOCH}; +use silver_client_server::ClientServerTile; use silver_columns::tile::DataColumnsTile; #[cfg(feature = "alloc-profile")] use silver_common::metrics::CountingAllocator; @@ -16,8 +17,9 @@ use silver_common::{ use silver_config::Config; use silver_control::Controller; use silver_discovery::{DiscV5, Discovery}; -use silver_engine_api::EngineTile; +use silver_engine_api::EngineApi; use silver_gossip::GossipHandler; +use silver_httpcore::Bind; use silver_network::{Context, NetworkTile, P2p}; use silver_peer::PeerManager; use silver_storage::{latest_local_checkpoint, tile::StorageTile}; @@ -230,8 +232,13 @@ fn main() -> Result<(), Box> { !config.disable_weak_subjectivity_check(), state, ); - let beacon_api_tile = - BeaconApiTile::new(&keypair, local_enr, &identify, beacon_state_tile.reader()); + let beacon_api = BeaconApi::new( + &Bind::parse(config.beacon_api_bind()), + &keypair, + local_enr, + &identify, + beacon_state_tile.reader(), + ); let state_reader = beacon_state_tile.reader(); @@ -262,12 +269,13 @@ fn main() -> Result<(), Box> { el_producer, ); - let engine_tile = EngineTile::new( + let engine_api = EngineApi::new( config.engine_config(), ssz_gossip_consumer_eng, incoming_rpc_consumer_eng, incoming_engine_resp_producer, ); + let client_server_tile = ClientServerTile { beacon: beacon_api, engine: engine_api }; // Spine let spine = SilverSpine::new(None); @@ -278,9 +286,8 @@ fn main() -> Result<(), Box> { attach_tile(network_tile, scoped_spine, TileConfig::new(2, None)); attach_tile(beacon_state_tile, scoped_spine, TileConfig::new(3, None)); attach_tile(storage_tile, scoped_spine, TileConfig::new(4, None)); - attach_tile(engine_tile, scoped_spine, TileConfig::new(5, None)); + attach_tile(client_server_tile, scoped_spine, TileConfig::new(5, None)); attach_tile(data_columns_tile, scoped_spine, TileConfig::new(6, None)); - attach_tile(beacon_api_tile, scoped_spine, TileConfig::new(7, None)); }); Ok(()) @@ -322,6 +329,11 @@ fn load_config() -> Result { if args.iter().any(|a| a == "--unsafe-no-el") { config = config.with_unsafe_no_el(true); } + if let Some(bind) = + args.iter().position(|a| a == "--beacon-api-bind").and_then(|i| args.get(i + 1)) + { + config = config.with_beacon_api_bind(bind.clone()); + } tracing::info!("loaded config: {config:#?}"); diff --git a/crates/client_server/Cargo.toml b/crates/client_server/Cargo.toml new file mode 100644 index 00000000..29660382 --- /dev/null +++ b/crates/client_server/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "silver_client_server" +edition.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +flux.workspace = true +silver_beacon_api.workspace = true +silver_common.workspace = true +silver_engine_api.workspace = true + +[dev-dependencies] +hex.workspace = true +serde_json.workspace = true +silver_beacon_state_data.workspace = true +silver_config.workspace = true +silver_engine_api = { workspace = true, features = ["test-el"] } +silver_httpcore.workspace = true +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/client_server/src/lib.rs b/crates/client_server/src/lib.rs new file mode 100644 index 00000000..c07638e9 --- /dev/null +++ b/crates/client_server/src/lib.rs @@ -0,0 +1,19 @@ +use flux::{spine::SpineAdapter, tile::Tile}; +use silver_beacon_api::BeaconApi; +use silver_common::SilverSpine; +use silver_engine_api::EngineApi; + +pub struct ClientServerTile { + pub beacon: BeaconApi, + pub engine: EngineApi, +} + +impl Tile for ClientServerTile { + fn loop_body(&mut self, adapter: &mut SpineAdapter) { + self.engine.intake(adapter); + self.engine.spin(adapter); + if self.beacon.pump() { + adapter.mark_work(); + } + } +} diff --git a/crates/client_server/tests/tile.rs b/crates/client_server/tests/tile.rs new file mode 100644 index 00000000..0e9110d4 --- /dev/null +++ b/crates/client_server/tests/tile.rs @@ -0,0 +1,299 @@ +use std::{ + io::{Read, Write}, + net::TcpStream, + os::unix::net::UnixStream, + time::{Duration, Instant}, +}; + +use flux::{spine::SpineAdapter, tile::Tile}; +use silver_beacon_api::BeaconApi; +use silver_beacon_state_data::BeaconStateOwner; +use silver_client_server::ClientServerTile; +use silver_common::{ + EngineFcuReq, EngineReq, EngineResp, Enr, Identify, Keypair, SilverSpine, TCache, + TCacheProducer, +}; +use silver_config::EngineConfig; +use silver_engine_api::{ + EngineApi, + test_el::{FCU_VALID_RESULT, FakeEl, write_jwt}, +}; +use silver_httpcore::Bind; +use tempfile::TempDir; + +struct Injector; +impl Tile for Injector { + fn loop_body(&mut self, _: &mut SpineAdapter) {} +} + +fn beacon(bind: &Bind) -> BeaconApi { + let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); + let local_enr = Enr::empty(keypair.secret_key()).unwrap(); + BeaconApi::new( + bind, + &keypair, + local_enr, + &Identify::default(), + BeaconStateOwner::empty_test(0).reader(), + ) +} + +fn engine(config: EngineConfig, tcache_names: [&'static str; 3]) -> EngineApi { + let gossip_p = TCache::producer(tcache_names[0], 1 << 12); + let rpc_p = TCache::producer(tcache_names[1], 1 << 12); + let resp_p = TCache::producer(tcache_names[2], 1 << 12); + EngineApi::new( + config, + gossip_p.cache_ref().random_access("t", true).unwrap(), + rpc_p.cache_ref().random_access("t", true).unwrap(), + resp_p, + ) +} + +fn no_el() -> EngineConfig { + EngineConfig { unsafe_no_el: true, ..EngineConfig::default() } +} + +fn http_get(mut stream: impl Read + Write, path: &str) -> String { + write!(stream, "GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n").unwrap(); + stream.flush().unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).unwrap(); + String::from_utf8(response).unwrap() +} + +fn assert_identity_ok(response: &str) { + assert!(response.starts_with("HTTP/1.1 200 OK\r\n"), "unexpected response: {response}"); + let body = &response[response.find("\r\n\r\n").unwrap() + 4..]; + let json: serde_json::Value = serde_json::from_str(body).unwrap(); + assert!(json["data"]["peer_id"].as_str().is_some_and(|id| !id.is_empty())); + assert!(json["data"]["enr"].as_str().unwrap().starts_with("enr:")); + assert!(json["data"]["metadata"]["seq_number"].is_string()); +} + +fn fcu_req(byte: u8) -> EngineReq { + EngineReq::Fcu(EngineFcuReq { + block_root: [byte; 32], + head_block_hash: [byte; 32], + safe_block_hash: [0u8; 32], + finalized_block_hash: [0u8; 32], + }) +} + +fn head_block_hash_json(byte: u8) -> String { + format!("\"headBlockHash\":\"0x{}\"", hex::encode([byte; 32])) +} + +#[test] +fn serves_identity_over_tcp() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let mut tile = ClientServerTile { + beacon: beacon(&Bind::parse("127.0.0.1:0")), + engine: engine(no_el(), ["cs_tcp_gossip", "cs_tcp_rpc", "cs_tcp_resp"]), + }; + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + + let Bind::Tcp(addr) = tile.beacon.local_addr() else { panic!("expected tcp bind") }; + assert_ne!(addr.port(), 0, "port-0 bind must resolve to an ephemeral port"); + + let client = std::thread::spawn(move || { + let stream = TcpStream::connect(addr).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + http_get(stream, "/eth/v1/node/identity") + }); + + let deadline = Instant::now() + Duration::from_secs(10); + while !client.is_finished() { + assert!(Instant::now() < deadline, "timeout: identity over tcp"); + tile.loop_body(&mut adapter); + std::thread::sleep(Duration::from_millis(1)); + } + assert_identity_ok(&client.join().unwrap()); +} + +#[test] +fn serves_identity_over_uds() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let socket = base.path().join("beacon_api.sock"); + let mut tile = ClientServerTile { + beacon: beacon(&Bind::Unix(socket.clone())), + engine: engine(no_el(), ["cs_uds_gossip", "cs_uds_rpc", "cs_uds_resp"]), + }; + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + + assert_eq!(tile.beacon.local_addr(), Bind::Unix(socket.clone())); + + let client = std::thread::spawn(move || { + let stream = UnixStream::connect(&socket).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + http_get(stream, "/eth/v1/node/identity") + }); + + let deadline = Instant::now() + Duration::from_secs(10); + while !client.is_finished() { + assert!(Instant::now() < deadline, "timeout: identity over uds"); + tile.loop_body(&mut adapter); + std::thread::sleep(Duration::from_millis(1)); + } + assert_identity_ok(&client.join().unwrap()); +} + +/// ADR 0004's core claim: all pumps are non-blocking, so an unanswered EL +/// call never stalls beacon-api serving, and the EL completion still lands +/// once the response arrives. +#[test] +fn serves_beacon_api_while_engine_call_in_flight() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let (mut el, endpoint) = FakeEl::tcp(); + let jwt_path = write_jwt(base.path()); + + let config = EngineConfig { + execution_endpoint: endpoint, + jwt_secret: jwt_path.to_str().unwrap().to_string(), + ..EngineConfig::default() + }; + let mut tile = ClientServerTile { + beacon: beacon(&Bind::parse("127.0.0.1:0")), + engine: engine(config, ["cs_flight_gossip", "cs_flight_rpc", "cs_flight_resp"]), + }; + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); + inj.consume(|_: EngineResp, _| {}); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut crank = |tile: &mut ClientServerTile, el: &mut FakeEl, msg: &str| { + assert!(Instant::now() < deadline, "timeout: {msg}"); + tile.loop_body(&mut adapter); + el.pump(); + std::thread::sleep(Duration::from_millis(1)); + }; + + // Crank until the startup healthcheck trio is on the wire: the tile's + // EngineReq cursor initializes on its first consume, so injecting before + // the first loop_body would be skipped. The trio stays unanswered — three + // more in-flight EL calls. + while el.requests.len() < 3 { + crank(&mut tile, &mut el, "startup healthcheck trio"); + } + + inj.produce(fcu_req(42)); + let fcu_on_wire = + |el: &FakeEl| el.requests.iter().position(|r| r.method == "engine_forkchoiceUpdatedV3"); + while fcu_on_wire(&el).is_none() { + crank(&mut tile, &mut el, "fcu on the wire"); + } + + // The FCU (and the startup healthcheck trio) sit unanswered on the EL; + // the API request must be served anyway. + let Bind::Tcp(addr) = tile.beacon.local_addr() else { panic!("expected tcp bind") }; + let client = std::thread::spawn(move || { + let stream = TcpStream::connect(addr).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + http_get(stream, "/eth/v1/node/identity") + }); + while !client.is_finished() { + crank(&mut tile, &mut el, "identity served while fcu in flight"); + } + assert_identity_ok(&client.join().unwrap()); + + let mut completed = Vec::new(); + inj.consume(|resp: EngineResp, _| { + if let EngineResp::Fcu(r) = resp { + completed.push(r.block_root); + } + }); + assert!(completed.is_empty(), "engine call must still be in flight after the API response"); + + el.respond(fcu_on_wire(&el).unwrap(), FCU_VALID_RESULT); + while completed.is_empty() { + crank(&mut tile, &mut el, "fcu completion on the spine"); + inj.consume(|resp: EngineResp, _| { + if let EngineResp::Fcu(r) = resp { + completed.push(r.block_root); + } + }); + } + assert_eq!(completed, vec![[42u8; 32]]); +} + +/// (cap+1) concurrent spine requests with `max_connections = cap`: the +/// last one must stay queued on the spine until a completion frees a +/// connection, and completions must correlate out of order. +#[test] +fn pool_cap_gates_spine_intake() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let (mut el, endpoint) = FakeEl::tcp(); + let jwt_path = write_jwt(base.path()); + + let config = EngineConfig { + execution_endpoint: endpoint, + jwt_secret: jwt_path.to_str().unwrap().to_string(), + max_connections: 3, + ..EngineConfig::default() + }; + let mut tile = ClientServerTile { + beacon: beacon(&Bind::parse("127.0.0.1:0")), + engine: engine(config, ["cs_cap_gossip", "cs_cap_rpc", "cs_cap_resp"]), + }; + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); + inj.consume(|_: EngineResp, _| {}); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut crank = |tile: &mut ClientServerTile, el: &mut FakeEl, msg: &str| { + assert!(Instant::now() < deadline, "timeout: {msg}"); + tile.loop_body(&mut adapter); + el.pump(); + std::thread::sleep(Duration::from_millis(1)); + }; + + // First loop_body fires the startup healthcheck trio; answer it so all + // three pooled connections are free before the capped scenario. + while el.requests.len() < 3 { + crank(&mut tile, &mut el, "startup healthcheck trio"); + } + for i in 0..3 { + el.respond(i, "false"); + } + + for byte in [11u8, 12, 13, 14] { + inj.produce(fcu_req(byte)); + } + + let fcu_count = |el: &FakeEl| { + el.requests.iter().filter(|r| r.method == "engine_forkchoiceUpdatedV3").count() + }; + while fcu_count(&el) < 3 { + crank(&mut tile, &mut el, "first three FCUs sent"); + } + for _ in 0..50 { + crank(&mut tile, &mut el, "cap holds"); + assert_eq!(fcu_count(&el), 3, "4th request must wait while pool is at cap"); + } + + // Free one connection by answering the SECOND fcu; the gated request + // must then be sent, and the completion must carry the responded + // request's block root. + let second = el + .requests + .iter() + .position(|r| r.body.contains(&head_block_hash_json(12))) + .expect("fcu for root 12 on the wire"); + el.respond(second, FCU_VALID_RESULT); + + while fcu_count(&el) < 4 { + crank(&mut tile, &mut el, "gated FCU sent after a connection freed"); + } + + let mut completed = Vec::new(); + inj.consume(|resp: EngineResp, _| { + if let EngineResp::Fcu(r) = resp { + completed.push(r.block_root); + } + }); + assert_eq!(completed, vec![[12u8; 32]], "out-of-order completion correlated"); +} diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 4e965e5a..734b4209 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -33,6 +33,10 @@ const fn default_u64() -> u64 { V } +fn default_beacon_api_bind() -> String { + "0.0.0.0:5051".into() +} + fn default_data_dir() -> String { std::env::home_dir() .and_then(|mut b| { @@ -122,6 +126,9 @@ pub struct Config { data_storage_dir: String, #[serde(default)] engine_config: EngineConfig, + /// TCP `addr:port` or a unix socket path. + #[serde(default = "default_beacon_api_bind")] + beacon_api_bind: String, #[serde(default)] disable_weak_subjectivity_check: bool, } @@ -156,6 +163,7 @@ impl Config { outgoing_rpc_tcache_size: 2 << 24, // ssz data_storage_dir: default_data_dir(), engine_config: Default::default(), + beacon_api_bind: default_beacon_api_bind(), disable_weak_subjectivity_check: false, } } @@ -208,6 +216,11 @@ impl Config { self } + pub fn with_beacon_api_bind(mut self, bind: String) -> Self { + self.beacon_api_bind = bind; + self + } + pub fn keypair(&self) -> Result { Keypair::from_secret(&self.secret_key) } @@ -336,6 +349,10 @@ impl Config { self.engine_config.clone() } + pub fn beacon_api_bind(&self) -> &str { + &self.beacon_api_bind + } + pub fn disable_weak_subjectivity_check(&self) -> bool { self.disable_weak_subjectivity_check } @@ -366,6 +383,15 @@ mod tests { assert_eq!(cfg.next_fork_epoch, u64::MAX); assert_eq!(cfg.supported_protocols().unwrap().len(), 11); assert_eq!(cfg.gossip_topics().unwrap().len(), 8); + assert_eq!(cfg.beacon_api_bind(), "0.0.0.0:5051"); + } + + #[test] + fn builder_sets_beacon_api_bind() { + let cfg = Config::new([1u8; 32], [0u8; 4], [0u8; 4], 0); + assert_eq!(cfg.beacon_api_bind(), "0.0.0.0:5051"); + let cfg = cfg.with_beacon_api_bind("/run/beacon.sock".into()); + assert_eq!(cfg.beacon_api_bind(), "/run/beacon.sock"); } #[test] diff --git a/crates/engine_api/Cargo.toml b/crates/engine_api/Cargo.toml index 0ada4f80..2ec66152 100644 --- a/crates/engine_api/Cargo.toml +++ b/crates/engine_api/Cargo.toml @@ -11,6 +11,7 @@ base64.workspace = true flux.workspace = true hex.workspace = true hmac.workspace = true +httparse = { workspace = true, optional = true } mio.workspace = true rustc-hash.workspace = true serde.workspace = true @@ -21,6 +22,10 @@ silver_httpcore.workspace = true thiserror.workspace = true tracing.workspace = true +[features] +# Exposes the `test_el` fake execution client to dependents' tests. +test-el = ["dep:httparse"] + [dev-dependencies] httparse.workspace = true tempfile = "3" diff --git a/crates/engine_api/src/tile.rs b/crates/engine_api/src/api.rs similarity index 54% rename from crates/engine_api/src/tile.rs rename to crates/engine_api/src/api.rs index 55cb423d..42bed05f 100644 --- a/crates/engine_api/src/tile.rs +++ b/crates/engine_api/src/api.rs @@ -1,6 +1,6 @@ use std::time::{Duration, Instant}; -use flux::{spine::SpineAdapter, tile::Tile}; +use flux::spine::SpineAdapter; use silver_common::{ ELSyncStatus, EngineHealthEvent, EngineReq, SilverSpine, TProducer, TRandomAccess, }; @@ -15,7 +15,7 @@ use crate::{ const HEALTHCHECK_INTERVAL: Duration = Duration::from_secs(10); -pub struct EngineTile { +pub struct EngineApi { /// `None` in unsafe no-EL testing mode — see /// [`EngineConfig::unsafe_no_el`]. pub client: Option, @@ -32,8 +32,38 @@ pub struct EngineTile { scratch: Vec, } -impl Tile for EngineTile { - fn loop_body(&mut self, adapter: &mut SpineAdapter) { +impl EngineApi { + pub fn new( + config: EngineConfig, + gossip_consumer: TRandomAccess, + rpc_consumer: TRandomAccess, + resp_producer: TProducer, + ) -> Self { + let client = if config.unsafe_no_el { + tracing::warn!("engine api in UNSAFE no-EL testing mode: answering all requests VALID"); + None + } else { + Some(EngineClient::new( + &config.execution_endpoint, + &config.jwt_secret, + config.max_connections, + )) + }; + Self { + client, + gossip_consumer, + rpc_consumer, + resp_producer, + + first_run: true, + healthcheck_pending: false, + healthcheck_deadline: Instant::now(), + sync_status: ELSyncStatus::Unknown, + scratch: Vec::new(), + } + } + + pub fn intake(&mut self, adapter: &mut SpineAdapter) { self.rpc_consumer.free(); self.gossip_consumer.free(); @@ -67,44 +97,9 @@ impl Tile for EngineTile { break; } } - self.spin(adapter); } -} -impl EngineTile { - pub fn new( - config: EngineConfig, - gossip_consumer: TRandomAccess, - rpc_consumer: TRandomAccess, - resp_producer: TProducer, - ) -> Self { - let client = if config.unsafe_no_el { - tracing::warn!( - "engine tile in UNSAFE no-EL testing mode: answering all requests VALID" - ); - None - } else { - Some(EngineClient::new( - &config.execution_endpoint, - &config.jwt_secret, - config.max_connections, - )) - }; - Self { - client, - gossip_consumer, - rpc_consumer, - resp_producer, - - first_run: true, - healthcheck_pending: false, - healthcheck_deadline: Instant::now(), - sync_status: ELSyncStatus::Unknown, - scratch: Vec::new(), - } - } - - fn spin(&mut self, adapter: &mut SpineAdapter) { + pub fn spin(&mut self, adapter: &mut SpineAdapter) { let mut negotiated_get_payload_method: Option<&'static str> = None; { @@ -118,8 +113,7 @@ impl EngineTile { sync_status, .. } = self; - // Only reached in EL mode; loop_body returns early otherwise. - let client = client.as_mut().expect("spin without EL client"); + let Some(client) = client.as_mut() else { return }; if !*healthcheck_pending && Instant::now() >= *healthcheck_deadline && @@ -183,118 +177,3 @@ fn run_healthcheck( *healthcheck_deadline = Instant::now() + HEALTHCHECK_INTERVAL; *healthcheck_pending = true; } - -#[cfg(test)] -mod tests { - use std::time::{Duration, Instant}; - - use flux::{spine::SpineAdapter, tile::Tile}; - use silver_common::{EngineFcuReq, EngineReq, EngineResp, SilverSpine, TCache, TCacheProducer}; - use silver_config::EngineConfig; - use tempfile::TempDir; - - use super::EngineTile; - use crate::test_el::{FCU_VALID_RESULT, FakeEl, write_jwt}; - - struct Injector; - impl Tile for Injector { - fn loop_body(&mut self, _: &mut SpineAdapter) {} - } - - fn fcu_req(byte: u8) -> EngineReq { - EngineReq::Fcu(EngineFcuReq { - block_root: [byte; 32], - head_block_hash: [byte; 32], - safe_block_hash: [0u8; 32], - finalized_block_hash: [0u8; 32], - }) - } - - fn head_block_hash_json(byte: u8) -> String { - format!("\"headBlockHash\":\"0x{}\"", hex::encode([byte; 32])) - } - - /// (cap+1) concurrent spine requests with `max_connections = cap`: the - /// last one must stay queued on the spine until a completion frees a - /// connection, and completions must correlate out of order. - #[test] - fn pool_cap_gates_spine_intake() { - let base = TempDir::new().unwrap(); - let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); - let (mut el, endpoint) = FakeEl::tcp(); - let jwt_path = write_jwt(base.path()); - - let gossip_p = TCache::producer("engine_cap_test_gossip", 1 << 12); - let rpc_p = TCache::producer("engine_cap_test_rpc", 1 << 12); - let resp_p = TCache::producer("engine_cap_test_resp", 1 << 12); - let config = EngineConfig { - execution_endpoint: endpoint, - jwt_secret: jwt_path.to_str().unwrap().to_string(), - max_connections: 3, - ..EngineConfig::default() - }; - let mut tile = EngineTile::new( - config, - gossip_p.cache_ref().random_access("t", true).unwrap(), - rpc_p.cache_ref().random_access("t", true).unwrap(), - resp_p, - ); - let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); - let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); - inj.consume(|_: EngineResp, _| {}); - - let deadline = Instant::now() + Duration::from_secs(10); - let mut crank = |tile: &mut EngineTile, el: &mut FakeEl, msg: &str| { - assert!(Instant::now() < deadline, "timeout: {msg}"); - tile.loop_body(&mut adapter); - el.pump(); - std::thread::sleep(Duration::from_millis(1)); - }; - - // First loop_body fires the startup healthcheck trio; answer it so all - // three pooled connections are free before the capped scenario. - while el.requests.len() < 3 { - crank(&mut tile, &mut el, "startup healthcheck trio"); - } - for i in 0..3 { - el.respond(i, "false"); - } - - for byte in [11u8, 12, 13, 14] { - inj.produce(fcu_req(byte)); - } - - let fcu_count = |el: &FakeEl| { - el.requests.iter().filter(|r| r.method == "engine_forkchoiceUpdatedV3").count() - }; - while fcu_count(&el) < 3 { - crank(&mut tile, &mut el, "first three FCUs sent"); - } - for _ in 0..50 { - crank(&mut tile, &mut el, "cap holds"); - assert_eq!(fcu_count(&el), 3, "4th request must wait while pool is at cap"); - } - - // Free one connection by answering the SECOND fcu; the gated request - // must then be sent, and the completion must carry the responded - // request's block root. - let second = el - .requests - .iter() - .position(|r| r.body.contains(&head_block_hash_json(12))) - .expect("fcu for root 12 on the wire"); - el.respond(second, FCU_VALID_RESULT); - - while fcu_count(&el) < 4 { - crank(&mut tile, &mut el, "gated FCU sent after a connection freed"); - } - - let mut completed = Vec::new(); - inj.consume(|resp: EngineResp, _| { - if let EngineResp::Fcu(r) = resp { - completed.push(r.block_root); - } - }); - assert_eq!(completed, vec![[12u8; 32]], "out-of-order completion correlated"); - } -} diff --git a/crates/engine_api/src/client.rs b/crates/engine_api/src/client.rs index 2cadb460..48b6ae34 100644 --- a/crates/engine_api/src/client.rs +++ b/crates/engine_api/src/client.rs @@ -55,8 +55,8 @@ pub struct EngineClient { } impl EngineClient { - pub fn new(endpoint: impl Into, jwt: &str, max_connections: usize) -> Self { - Self::with_endpoint(Endpoint::Http(endpoint.into()), jwt, max_connections) + pub fn new(endpoint: &str, jwt: &str, max_connections: usize) -> Self { + Self::with_endpoint(parse_endpoint(endpoint), jwt, max_connections) } pub fn new_uds(path: impl Into, jwt: &str, max_connections: usize) -> Self { @@ -81,6 +81,19 @@ impl EngineClient { } } +fn parse_endpoint(endpoint: &str) -> Endpoint { + if endpoint.starts_with("http://") { + Endpoint::Http(endpoint.to_string()) + } else if endpoint.contains("://") { + panic!( + "unsupported execution_endpoint scheme (only http:// or a unix socket path): \ + {endpoint}" + ) + } else { + Endpoint::Uds(PathBuf::from(endpoint)) + } +} + fn next_id(id: &mut u64) -> u64 { let v = *id; *id += 1; @@ -264,6 +277,28 @@ mod tests { use super::*; + #[test] + fn endpoint_http_scheme_parses_to_http() { + assert!(matches!( + parse_endpoint("http://localhost:8551"), + Endpoint::Http(e) if e == "http://localhost:8551" + )); + } + + #[test] + fn endpoint_bare_path_parses_to_uds() { + assert!(matches!( + parse_endpoint("/run/reth/engine.sock"), + Endpoint::Uds(p) if p == std::path::Path::new("/run/reth/engine.sock") + )); + } + + #[test] + #[should_panic(expected = "unsupported execution_endpoint scheme")] + fn endpoint_unknown_scheme_panics() { + parse_endpoint("https://localhost:8551"); + } + #[test] fn next_id_returns_current_then_increments() { let mut id = 1u64; diff --git a/crates/engine_api/src/lib.rs b/crates/engine_api/src/lib.rs index 0d982f7f..fb9ea03f 100644 --- a/crates/engine_api/src/lib.rs +++ b/crates/engine_api/src/lib.rs @@ -1,15 +1,15 @@ +mod api; mod client; mod error; mod jwt; mod pool; mod req_handlers; mod resp_handlers; -#[cfg(test)] -mod test_el; -pub mod tile; +#[cfg(any(test, feature = "test-el"))] +pub mod test_el; mod types; +pub use api::EngineApi; pub use client::EngineClient; pub use error::EngineError; pub use jwt::JwtSecret; -pub use tile::EngineTile; diff --git a/crates/engine_api/src/test_el.rs b/crates/engine_api/src/test_el.rs index f7bdfee1..8fbbb9d2 100644 --- a/crates/engine_api/src/test_el.rs +++ b/crates/engine_api/src/test_el.rs @@ -7,9 +7,9 @@ use std::{ use simd_json::prelude::{ValueAsScalar, ValueObjectAccess}; -pub(crate) const FCU_VALID_RESULT: &str = r#"{"payloadStatus":{"status":"VALID","latestValidHash":null,"validationError":null},"payloadId":null}"#; +pub const FCU_VALID_RESULT: &str = r#"{"payloadStatus":{"status":"VALID","latestValidHash":null,"validationError":null},"payloadId":null}"#; -pub(crate) fn write_jwt(dir: &Path) -> PathBuf { +pub fn write_jwt(dir: &Path) -> PathBuf { let path = dir.join("jwt.hex"); std::fs::write(&path, "0000000000000000000000000000000000000000000000000000000000000000") .unwrap(); @@ -51,32 +51,32 @@ impl Write for ElStream { } } -pub(crate) struct ElRequest { +pub struct ElRequest { conn: usize, - pub(crate) id: u64, - pub(crate) method: String, - pub(crate) authorization: Option, - pub(crate) body: String, + pub id: u64, + pub method: String, + pub authorization: Option, + pub body: String, } /// Deterministic single-threaded fake execution client: accepts connections /// and buffers requests on `pump`, answers only when the test says so. -pub(crate) struct FakeEl { +pub struct FakeEl { listener: ElListener, conns: Vec>, read_bufs: Vec>, - pub(crate) requests: Vec, + pub requests: Vec, } impl FakeEl { - pub(crate) fn tcp() -> (Self, String) { + pub fn tcp() -> (Self, String) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); let endpoint = format!("http://{}", listener.local_addr().unwrap()); (Self::new(ElListener::Tcp(listener)), endpoint) } - pub(crate) fn uds(path: &Path) -> Self { + pub fn uds(path: &Path) -> Self { let listener = UnixListener::bind(path).unwrap(); listener.set_nonblocking(true).unwrap(); Self::new(ElListener::Uds(listener)) @@ -86,7 +86,7 @@ impl FakeEl { Self { listener, conns: Vec::new(), read_bufs: Vec::new(), requests: Vec::new() } } - pub(crate) fn pump(&mut self) { + pub fn pump(&mut self) { loop { let accepted = match &self.listener { ElListener::Tcp(l) => l.accept().map(|(s, _)| { @@ -133,7 +133,7 @@ impl FakeEl { } } - pub(crate) fn respond(&mut self, request_index: usize, result_json: &str) { + pub fn respond(&mut self, request_index: usize, result_json: &str) { let request = &self.requests[request_index]; let body = format!(r#"{{"jsonrpc":"2.0","id":{},"result":{result_json}}}"#, request.id); let response = format!( @@ -151,7 +151,7 @@ impl FakeEl { } } - pub(crate) fn close_connection_of(&mut self, request_index: usize) { + pub fn close_connection_of(&mut self, request_index: usize) { self.conns[self.requests[request_index].conn] = None; } } diff --git a/crates/httpcore/Cargo.toml b/crates/httpcore/Cargo.toml index 6f13244e..237e1c7a 100644 --- a/crates/httpcore/Cargo.toml +++ b/crates/httpcore/Cargo.toml @@ -10,5 +10,8 @@ httparse.workspace = true mio.workspace = true tracing.workspace = true +[dev-dependencies] +tempfile = "3" + [lints] workspace = true diff --git a/crates/httpcore/src/lib.rs b/crates/httpcore/src/lib.rs index 7198254f..7efa53d4 100644 --- a/crates/httpcore/src/lib.rs +++ b/crates/httpcore/src/lib.rs @@ -4,4 +4,4 @@ mod stream; pub use client::{ClientConnection, frame_request}; pub use server::{AfterResponse, ParsedRequest, ServerConnection, frame_response}; -pub use stream::Stream; +pub use stream::{Bind, Listener, Stream}; diff --git a/crates/httpcore/src/stream.rs b/crates/httpcore/src/stream.rs index aa0e6766..e63eda53 100644 --- a/crates/httpcore/src/stream.rs +++ b/crates/httpcore/src/stream.rs @@ -1,15 +1,107 @@ use std::{ io::{self, Read, Write}, net::SocketAddr, - path::Path, + path::{Path, PathBuf}, }; use mio::{ Interest, Registry, Token, event::Source, - net::{TcpStream, UnixStream}, + net::{TcpListener, TcpStream, UnixListener, UnixStream}, }; +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Bind { + Tcp(SocketAddr), + Unix(PathBuf), +} + +impl Bind { + pub fn parse(text: &str) -> Self { + match text.parse() { + Ok(addr) => Self::Tcp(addr), + Err(_) => Self::Unix(PathBuf::from(text)), + } + } +} + +pub enum Listener { + Tcp(TcpListener), + Unix(UnixListener), +} + +impl Listener { + pub fn bind(bind: &Bind) -> io::Result { + match bind { + Bind::Tcp(addr) => TcpListener::bind(*addr).map(Self::Tcp), + Bind::Unix(path) => UnixListener::bind(path).map(Self::Unix), + } + } + + pub fn accept(&self) -> io::Result { + match self { + Self::Tcp(listener) => { + let (stream, peer) = listener.accept()?; + tracing::info!("accepted connection from {peer}"); + Ok(Stream::Tcp(stream)) + } + Self::Unix(listener) => { + let (stream, _) = listener.accept()?; + tracing::info!("accepted connection on unix socket"); + Ok(Stream::Uds(stream)) + } + } + } + + /// The resolved bind: for TCP the actual listening address (a port-0 bind + /// reports the ephemeral port the OS assigned), for Unix the socket path. + pub fn local_addr(&self) -> Bind { + match self { + Self::Tcp(listener) => Bind::Tcp(listener.local_addr().expect("tcp local_addr")), + Self::Unix(listener) => Bind::Unix( + listener + .local_addr() + .ok() + .and_then(|addr| addr.as_pathname().map(Path::to_path_buf)) + .expect("unix listener bound to a path"), + ), + } + } +} + +impl Source for Listener { + fn register( + &mut self, + registry: &Registry, + token: Token, + interests: Interest, + ) -> io::Result<()> { + match self { + Self::Tcp(l) => l.register(registry, token, interests), + Self::Unix(l) => l.register(registry, token, interests), + } + } + + fn reregister( + &mut self, + registry: &Registry, + token: Token, + interests: Interest, + ) -> io::Result<()> { + match self { + Self::Tcp(l) => l.reregister(registry, token, interests), + Self::Unix(l) => l.reregister(registry, token, interests), + } + } + + fn deregister(&mut self, registry: &Registry) -> io::Result<()> { + match self { + Self::Tcp(l) => l.deregister(registry), + Self::Unix(l) => l.deregister(registry), + } + } +} + pub enum Stream { Tcp(TcpStream), Uds(UnixStream), @@ -102,6 +194,37 @@ mod tests { use super::*; use crate::client::{ClientConnection, frame_request}; + #[test] + fn parse_socket_addr_is_tcp() { + assert_eq!(Bind::parse("0.0.0.0:5051"), Bind::Tcp("0.0.0.0:5051".parse().unwrap())); + assert_eq!(Bind::parse("127.0.0.1:0"), Bind::Tcp("127.0.0.1:0".parse().unwrap())); + assert_eq!(Bind::parse("[::1]:5051"), Bind::Tcp("[::1]:5051".parse().unwrap())); + } + + #[test] + fn parse_non_addr_is_unix_path() { + assert_eq!(Bind::parse("/run/beacon.sock"), Bind::Unix("/run/beacon.sock".into())); + assert_eq!(Bind::parse("beacon.sock"), Bind::Unix("beacon.sock".into())); + // Hostnames don't parse as SocketAddr (no resolution here), so they + // fall through to a path. + assert_eq!(Bind::parse("localhost:5051"), Bind::Unix("localhost:5051".into())); + } + + #[test] + fn tcp_listener_reports_ephemeral_port() { + let listener = Listener::bind(&Bind::parse("127.0.0.1:0")).unwrap(); + let Bind::Tcp(addr) = listener.local_addr() else { panic!("tcp bind") }; + assert_ne!(addr.port(), 0); + } + + #[test] + fn unix_listener_reports_bound_path() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("api.sock"); + let listener = Listener::bind(&Bind::Unix(path.clone())).unwrap(); + assert_eq!(listener.local_addr(), Bind::Unix(path)); + } + #[test] fn uds_pair_round_trip_through_client_connection() { let (client_half, mut server_half) = UnixStream::pair().unwrap(); diff --git a/docs/spine-message-flow.md b/docs/spine-message-flow.md index 25b9eb4d..a86ba26b 100644 --- a/docs/spine-message-flow.md +++ b/docs/spine-message-flow.md @@ -8,7 +8,8 @@ them (see [tcaches](#tcaches)). The tiles: **Network** (QUIC + discv5), **Control** (`PeerManager` + `SyncEngine` + `GossipHandler` — gossipsub decode/encode runs in-tile, not as its own tile), **BeaconState** (state transition + fork choice), **Storage** (disk + backfill), -**Engine** (EL / engine API). +**ClientServer** (hosting the `engine_api` client and the `beacon_api` server; the +server talks HTTP only, so it has no spine edges of its own). ```mermaid flowchart LR @@ -16,7 +17,7 @@ flowchart LR CTL["Control
PeerManager + SyncEngine + GossipHandler"] BS["BeaconState
state · fork choice"] ST["Storage
disk · backfill"] - EN["Engine
EL / engine API"] + EN["ClientServer
engine_api client · beacon_api server"] %% ---- inbound ---- NET -.->|"incoming_gossip (tcache)"| CTL @@ -69,8 +70,8 @@ output is `new_gossip`). The gossip handler's other traffic is in-tile, not on t spine: its `PeerEvent`s (gossipsub scoring/misbehaviour) go straight to the `PeerManager`, `PeerControl` is forwarded to the handler directly, and its fork digest is set from the `Status` Control already consumes. `engine_health` is omitted -from the diagram: Engine produces it but no tile currently consumes it. Both -Storage↔Engine edges carry only the `GetBlobs` variants (EL-mempool blob fetch); the +from the diagram: ClientServer produces it but no tile currently consumes it. Both +Storage↔ClientServer edges carry only the `GetBlobs` variants (EL-mempool blob fetch); the queues are broadcast, so Storage sees every `EngineResp` and ignores the rest. ## Spine queues @@ -87,9 +88,9 @@ queues are broadcast, so Storage sees every `EngineResp` and ignores the rest. | `sync_target` | `SyncUpdate` | Control | BeaconState, Storage | inline | | `replay_blocks` | `ReplayBlock` | Storage | BeaconState | ref → `replay_blocks` tcache | | `syncing_strategy` | `SyncingStrategy` | Control | Storage | inline | -| `engine_reqs` | `EngineReq` | BeaconState, Storage _(GetBlobs)_ | Engine | refs → `ssz_gossip` / `incoming_rpc`; GetBlobs inline | -| `engine_resps` | `EngineResp` | Engine | BeaconState, Storage _(GetBlobs)_ | ref → `incoming_engine_resp` | -| `engine_health` | `EngineHealthEvent` | Engine | _none (currently unconsumed)_ | inline | +| `engine_reqs` | `EngineReq` | BeaconState, Storage _(GetBlobs)_ | ClientServer | refs → `ssz_gossip` / `incoming_rpc`; GetBlobs inline | +| `engine_resps` | `EngineResp` | ClientServer | BeaconState, Storage _(GetBlobs)_ | ref → `incoming_engine_resp` | +| `engine_health` | `EngineHealthEvent` | ClientServer | _none (currently unconsumed)_ | inline | ## TCaches @@ -98,12 +99,12 @@ Bulk-byte rings that the queue messages reference, so payloads cross tiles witho | TCache | Producer | Consumer(s) | Payload | |--------|----------|-------------|---------| | `incoming_gossip` | Network | Control _(gossip)_ | raw gossipsub protobuf from the wire | -| `ssz_gossip` | Control _(gossip)_ | BeaconState, Storage (live + persist), Engine | decompressed gossip SSZ | +| `ssz_gossip` | Control _(gossip)_ | BeaconState, Storage (live + persist), ClientServer | decompressed gossip SSZ | | `outgoing_gossip` | Control _(gossip)_ | Network | gossip protobuf: mcache copies of incoming messages, local publishes, IDONTWANT/IWANT control frames | -| `incoming_rpc` | Network | BeaconState, Storage (live + persist), Engine, Control (column republish) | RPC response bodies (BeaconBlock / DataColumnSidecar) | +| `incoming_rpc` | Network | BeaconState, Storage (live + persist), ClientServer, Control (column republish) | RPC response bodies (BeaconBlock / DataColumnSidecar) | | `outgoing_rpc` _(multi-producer)_ | Control, Storage | Network | RPC request bodies (we ask) + served response bodies (we answer) | | `replay_blocks` | Storage | BeaconState | persisted block SSZ replayed at startup | -| `incoming_engine_resp` | Engine | BeaconState, Storage (GetBlobs) | EL responses (payloads, blobs, bodies) | +| `incoming_engine_resp` | ClientServer | BeaconState, Storage (GetBlobs) | EL responses (payloads, blobs, bodies) | --- From bd42255cd476b2ac3db93a9be0b2341be3b1f3ce Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Mon, 17 Aug 2026 14:50:15 +0100 Subject: [PATCH 08/16] Harden API transport: zero-alloc hot-path test, conn cap, lazy buffers Closes out the consolidation plan (C6). The newPayload transcode's zero-allocation invariant finally gets a failing-capable test: a dedicated integration binary installs a counting global allocator, warms every buffer (scratch, connection write buffer, JWT second-cache, pending map) through real UDS round trips against the fake EL, then asserts the next send performs exactly zero heap allocations -- with the JWT cache's wall-clock second handled by retry rather than a weakened assertion. Server hardening: beacon_api_max_connections (default 64) accepts-and- drops beyond the cap (leaving the backlog unaccepted would go silent under edge-triggered registration until the next SYN). ServerConnection's 16 MiB eagerly-boxed read buffer becomes a 4 KiB lazily-doubling Vec with the same hard cap and byte-identical rejection, and read_space now compacts the partial tail to the buffer front -- previously a long-lived pipelined keep-alive connection crept its offsets toward the cap and would spuriously reject small requests (the new creep test feeds 2x the cap in small requests and fails against the old code, which also could not construct on a default test-thread stack). GET /eth/v1/events is pinned as 404: v1 defers SSE, all surveyed validator clients poll (.local/beacon-api-vc-surface.md). Real-socket smoke coverage audited across {server,client} x {TCP,UDS}: all four combinations already exercised; none added. Assisted-by: Claude:claude-fable-5 --- Cargo.lock | 1 + crates/beacon_api/examples/srv.rs | 2 +- crates/beacon_api/src/routes.rs | 7 + crates/beacon_api/src/server.rs | 107 +++++++++++++++ crates/bin/src/main.rs | 1 + crates/client_server/tests/tile.rs | 1 + crates/config/src/lib.rs | 21 +++ crates/engine_api/Cargo.toml | 1 + crates/engine_api/src/lib.rs | 2 + crates/engine_api/tests/newpayload_alloc.rs | 113 ++++++++++++++++ crates/httpcore/src/server.rs | 139 ++++++++++++++++++-- 11 files changed, 383 insertions(+), 12 deletions(-) create mode 100644 crates/engine_api/tests/newpayload_alloc.rs diff --git a/Cargo.lock b/Cargo.lock index 58b6806f..2f2f6074 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4678,6 +4678,7 @@ dependencies = [ "sha2", "silver_common", "silver_config", + "silver_engine_api", "silver_httpcore", "simd-json", "tempfile", diff --git a/crates/beacon_api/examples/srv.rs b/crates/beacon_api/examples/srv.rs index 87316c0b..e83466be 100644 --- a/crates/beacon_api/examples/srv.rs +++ b/crates/beacon_api/examples/srv.rs @@ -12,7 +12,7 @@ fn main() { // Never-published reader: state endpoints answer 503, as pre-bootstrap. let state = BeaconStateOwner::empty_test(0).reader(); - let mut api = BeaconApi::new(&bind, &keypair, local_enr, &Identify::default(), state); + let mut api = BeaconApi::new(&bind, 64, &keypair, local_enr, &Identify::default(), state); println!("serving on {:?}", api.local_addr()); loop { api.pump(); diff --git a/crates/beacon_api/src/routes.rs b/crates/beacon_api/src/routes.rs index d8da5ccf..66aad9ee 100644 --- a/crates/beacon_api/src/routes.rs +++ b/crates/beacon_api/src/routes.rs @@ -142,6 +142,13 @@ mod tests { assert_eq!(resp, b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"); } + #[test] + fn events_returns_404_v1_defers_sse_clients_poll() { + let router = Router::new(ROUTES); + let resp = get(&router, &preboot_ctx(), "/eth/v1/events"); + assert_eq!(resp, b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"); + } + fn genesis_root(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { let Some(root) = ctx.read_state_or_503(resp, |view| view.imm.genesis_validators_root) else { diff --git a/crates/beacon_api/src/server.rs b/crates/beacon_api/src/server.rs index 82049a6e..b8fab32b 100644 --- a/crates/beacon_api/src/server.rs +++ b/crates/beacon_api/src/server.rs @@ -25,6 +25,7 @@ pub struct BeaconApi { poll: Poll, events: Events, listener: Listener, + max_connections: usize, current_token: Token, connections: HashMap, router: Router, @@ -34,6 +35,7 @@ pub struct BeaconApi { impl BeaconApi { pub fn new( bind: &Bind, + max_connections: usize, keypair: &Keypair, local_enr: Enr, identify: &Identify, @@ -48,6 +50,7 @@ impl BeaconApi { poll, events: Events::with_capacity(1024), listener, + max_connections, current_token: Token(LISTENER.0 + 1), connections: HashMap::new(), router: Router::new(ROUTES), @@ -76,6 +79,16 @@ impl BeaconApi { }; did_work = true; + // Accept-and-close at the cap: with edge-triggered + // registration, leaving the stream in the backlog would go + // silent until the next SYN retriggers the listener. + if self.connections.len() >= self.max_connections { + tracing::warn!( + "beacon api connection cap {} reached, dropping new connection", + self.max_connections + ); + continue; + } let token = next(&mut self.current_token); self.poll.registry().register(&mut stream, token, Interest::READABLE).unwrap(); self.connections @@ -183,6 +196,14 @@ fn interrupted(err: &io::Error) -> bool { #[cfg(test)] mod tests { + use std::{ + net::{SocketAddr, TcpStream}, + thread::JoinHandle, + time::Instant, + }; + + use silver_beacon_state_data::BeaconStateOwner; + use super::*; #[test] @@ -193,4 +214,90 @@ mod tests { assert_ne!(cur, LISTENER, "next token must not alias LISTENER after wrap"); assert_eq!(cur.0, LISTENER.0 + 1); } + + fn pump_until(api: &mut BeaconApi, client: JoinHandle, msg: &str) -> T { + let deadline = Instant::now() + Duration::from_secs(10); + while !client.is_finished() { + assert!(Instant::now() < deadline, "timeout: {msg}"); + api.pump(); + std::thread::sleep(Duration::from_millis(1)); + } + client.join().unwrap() + } + + fn connect(addr: SocketAddr) -> TcpStream { + let stream = TcpStream::connect(addr).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + stream + } + + #[test] + fn connection_cap_drops_excess_then_recovers() { + let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); + let local_enr = Enr::empty(keypair.secret_key()).unwrap(); + let mut api = BeaconApi::new( + &Bind::parse("127.0.0.1:0"), + 1, + &keypair, + local_enr, + &Identify::default(), + BeaconStateOwner::empty_test(0).reader(), + ); + let Bind::Tcp(addr) = api.local_addr() else { panic!("expected tcp bind") }; + + let held_open = pump_until( + &mut api, + std::thread::spawn(move || { + let mut stream = connect(addr); + write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\n\r\n").unwrap(); + let mut response = Vec::new(); + let mut chunk = [0u8; 1024]; + while !response.windows(4).any(|w| w == b"\r\n\r\n") { + let n = stream.read(&mut chunk).unwrap(); + assert!(n > 0, "server closed the first connection"); + response.extend_from_slice(&chunk[..n]); + } + assert!(response.starts_with(b"HTTP/1.1 200 OK\r\n")); + stream + }), + "first client served", + ); + + let denied = pump_until( + &mut api, + std::thread::spawn(move || { + let mut stream = connect(addr); + let _ = write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\n\r\n"); + let mut chunk = [0u8; 1024]; + stream.read(&mut chunk) + }), + "second client dropped at cap", + ); + assert!( + !matches!(denied, Ok(n) if n > 0), + "connection over the cap must not be served: {denied:?}" + ); + + drop(held_open); + let deadline = Instant::now() + Duration::from_secs(10); + while !api.connections.is_empty() { + assert!(Instant::now() < deadline, "timeout: closed connection reaped"); + api.pump(); + std::thread::sleep(Duration::from_millis(1)); + } + + let response = pump_until( + &mut api, + std::thread::spawn(move || { + let mut stream = connect(addr); + write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") + .unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).unwrap(); + response + }), + "third client served after the slot freed", + ); + assert!(response.starts_with(b"HTTP/1.1 200 OK\r\n")); + } } diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index 00fd7290..bfa5479b 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -234,6 +234,7 @@ fn main() -> Result<(), Box> { ); let beacon_api = BeaconApi::new( &Bind::parse(config.beacon_api_bind()), + config.beacon_api_max_connections(), &keypair, local_enr, &identify, diff --git a/crates/client_server/tests/tile.rs b/crates/client_server/tests/tile.rs index 0e9110d4..7f07bc33 100644 --- a/crates/client_server/tests/tile.rs +++ b/crates/client_server/tests/tile.rs @@ -31,6 +31,7 @@ fn beacon(bind: &Bind) -> BeaconApi { let local_enr = Enr::empty(keypair.secret_key()).unwrap(); BeaconApi::new( bind, + 64, &keypair, local_enr, &Identify::default(), diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 734b4209..55674e07 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -129,6 +129,8 @@ pub struct Config { /// TCP `addr:port` or a unix socket path. #[serde(default = "default_beacon_api_bind")] beacon_api_bind: String, + #[serde(default = "default_usize::<64>")] + beacon_api_max_connections: usize, #[serde(default)] disable_weak_subjectivity_check: bool, } @@ -164,6 +166,7 @@ impl Config { data_storage_dir: default_data_dir(), engine_config: Default::default(), beacon_api_bind: default_beacon_api_bind(), + beacon_api_max_connections: 64, disable_weak_subjectivity_check: false, } } @@ -221,6 +224,11 @@ impl Config { self } + pub fn with_beacon_api_max_connections(mut self, max: usize) -> Self { + self.beacon_api_max_connections = max; + self + } + pub fn keypair(&self) -> Result { Keypair::from_secret(&self.secret_key) } @@ -353,6 +361,10 @@ impl Config { &self.beacon_api_bind } + pub fn beacon_api_max_connections(&self) -> usize { + self.beacon_api_max_connections + } + pub fn disable_weak_subjectivity_check(&self) -> bool { self.disable_weak_subjectivity_check } @@ -384,6 +396,7 @@ mod tests { assert_eq!(cfg.supported_protocols().unwrap().len(), 11); assert_eq!(cfg.gossip_topics().unwrap().len(), 8); assert_eq!(cfg.beacon_api_bind(), "0.0.0.0:5051"); + assert_eq!(cfg.beacon_api_max_connections(), 64); } #[test] @@ -394,6 +407,14 @@ mod tests { assert_eq!(cfg.beacon_api_bind(), "/run/beacon.sock"); } + #[test] + fn builder_sets_beacon_api_max_connections() { + let cfg = Config::new([1u8; 32], [0u8; 4], [0u8; 4], 0); + assert_eq!(cfg.beacon_api_max_connections(), 64); + let cfg = cfg.with_beacon_api_max_connections(2); + assert_eq!(cfg.beacon_api_max_connections(), 2); + } + #[test] fn builders_set_external_ip_and_genesis() { let cfg = Config::new([1u8; 32], [0u8; 4], [0u8; 4], 0) diff --git a/crates/engine_api/Cargo.toml b/crates/engine_api/Cargo.toml index 2ec66152..b0f521f8 100644 --- a/crates/engine_api/Cargo.toml +++ b/crates/engine_api/Cargo.toml @@ -28,6 +28,7 @@ test-el = ["dep:httparse"] [dev-dependencies] httparse.workspace = true +silver_engine_api = { workspace = true, features = ["test-el"] } tempfile = "3" tracing-subscriber.workspace = true diff --git a/crates/engine_api/src/lib.rs b/crates/engine_api/src/lib.rs index fb9ea03f..c72f0708 100644 --- a/crates/engine_api/src/lib.rs +++ b/crates/engine_api/src/lib.rs @@ -11,5 +11,7 @@ mod types; pub use api::EngineApi; pub use client::EngineClient; +#[cfg(feature = "test-el")] +pub use client::{ReqKind, poll, send_new_payload}; pub use error::EngineError; pub use jwt::JwtSecret; diff --git a/crates/engine_api/tests/newpayload_alloc.rs b/crates/engine_api/tests/newpayload_alloc.rs new file mode 100644 index 00000000..61e696a2 --- /dev/null +++ b/crates/engine_api/tests/newpayload_alloc.rs @@ -0,0 +1,113 @@ +//! Pins the newPayload hot-path invariant: once every buffer is warm (scratch, +//! connection write buffer, JWT token cache, pending-request map), the SSZ→JSON +//! transcode + frame + enqueue path performs zero heap allocations. + +use std::{ + alloc::{GlobalAlloc, Layout, System}, + cell::Cell, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use silver_engine_api::{ + EngineClient, ReqKind, poll, send_new_payload, + test_el::{FakeEl, write_jwt}, +}; + +thread_local! { + static ALLOCATION_EVENTS: Cell = const { Cell::new(0) }; +} + +fn allocation_events() -> u64 { + ALLOCATION_EVENTS.with(Cell::get) +} + +struct CountingAllocator; + +unsafe impl GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + ALLOCATION_EVENTS.with(|c| c.set(c.get() + 1)); + unsafe { System.alloc(layout) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + ALLOCATION_EVENTS.with(|c| c.set(c.get() + 1)); + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + ALLOCATION_EVENTS.with(|c| c.set(c.get() + 1)); + unsafe { System.realloc(ptr, layout, new_size) } + } +} + +#[global_allocator] +static GLOBAL: CountingAllocator = CountingAllocator; + +const SIGNED_BLOCK_SSZ: &[u8] = include_bytes!("../testdata/signed_block.ssz"); +const NEW_PAYLOAD_VALID: &str = + r#"{"status":"VALID","latestValidHash":null,"validationError":null}"#; + +fn unix_secs() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() +} + +fn complete_round_trip(client: &mut EngineClient, el: &mut FakeEl, request_index: usize) { + let deadline = Instant::now() + Duration::from_secs(10); + let mut responded = false; + let mut done = false; + while !done { + assert!(Instant::now() < deadline, "timeout: newPayload round trip {request_index}"); + el.pump(); + if !responded && el.requests.len() > request_index { + assert_eq!(el.requests[request_index].method, "engine_newPayloadV4"); + el.respond(request_index, NEW_PAYLOAD_VALID); + responded = true; + } + poll(client, |kind, response| { + assert!(matches!(kind, ReqKind::NewPayload(_))); + response.expect("newPayload response"); + done = true; + }); + std::thread::sleep(Duration::from_millis(1)); + } +} + +#[test] +fn warm_new_payload_send_allocates_nothing() { + let dir = tempfile::tempdir().unwrap(); + let jwt_path = write_jwt(dir.path()); + let socket = dir.path().join("engine.sock"); + let mut el = FakeEl::uds(&socket); + let mut client = EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 4); + + send_new_payload(&mut client, SIGNED_BLOCK_SSZ, [0u8; 32]).unwrap(); + complete_round_trip(&mut client, &mut el, 0); + let mut request_index = 1; + assert!(allocation_events() > 0, "counting allocator must observe the cold path"); + + // The JWT bearer token is cached per wall-clock second, so a warm send and + // the measured send must land in the same second for the token recompute + // to stay out of the measured window; retry on the rare rollover. + for _ in 0..5 { + let second = unix_secs(); + send_new_payload(&mut client, SIGNED_BLOCK_SSZ, [1u8; 32]).unwrap(); + complete_round_trip(&mut client, &mut el, request_index); + request_index += 1; + + let before = allocation_events(); + send_new_payload(&mut client, SIGNED_BLOCK_SSZ, [2u8; 32]).unwrap(); + let events = allocation_events() - before; + + complete_round_trip(&mut client, &mut el, request_index); + request_index += 1; + if unix_secs() == second { + assert_eq!(events, 0, "warm newPayload send performed {events} heap allocations"); + return; + } + } + panic!("wall clock crossed a second boundary on every attempt"); +} diff --git a/crates/httpcore/src/server.rs b/crates/httpcore/src/server.rs index 4371a46c..540b0a8c 100644 --- a/crates/httpcore/src/server.rs +++ b/crates/httpcore/src/server.rs @@ -3,6 +3,7 @@ use std::io::{self, Write}; // Hard cap on the read buffer. Raw SSZ, uncompressed. 16 MiB matches observed // production maximums (21 blobs × 128 KiB plus block fields). const READ_BUF_MAX: usize = 16 << 20; +const READ_BUF_INIT: usize = 4096; const WRITE_BUF_INIT: usize = 4096; pub struct ParsedRequest<'a> { @@ -59,7 +60,7 @@ pub enum AfterResponse { } pub struct ServerConnection { - read_buf: Box<[u8; READ_BUF_MAX]>, + read_buf: Vec, read_pos: usize, read_end: usize, write_buf: Vec, @@ -70,7 +71,7 @@ pub struct ServerConnection { impl ServerConnection { pub fn new() -> Self { Self { - read_buf: Box::new([0u8; READ_BUF_MAX]), + read_buf: vec![0u8; READ_BUF_INIT], read_pos: 0, read_end: 0, write_buf: Vec::with_capacity(WRITE_BUF_INIT), @@ -80,14 +81,25 @@ impl ServerConnection { } pub fn read_space(&mut self) -> io::Result<&mut [u8]> { + // Compact the partial tail to the front: without this, a long-lived + // pipelined keep-alive connection whose buffer never fully drains + // creeps read_end toward the cap and spuriously rejects small requests. + if self.read_pos > 0 { + self.read_buf.copy_within(self.read_pos..self.read_end, 0); + self.read_end -= self.read_pos; + self.read_pos = 0; + } if self.read_end == READ_BUF_MAX { return Err(io::Error::new(io::ErrorKind::InvalidData, "request too large")); } + if self.read_end == self.read_buf.len() { + self.read_buf.resize((self.read_buf.len() * 2).min(READ_BUF_MAX), 0); + } Ok(&mut self.read_buf[self.read_end..]) } pub fn commit_read(&mut self, n: usize) { - debug_assert!(self.read_end + n <= READ_BUF_MAX); + debug_assert!(self.read_end + n <= self.read_buf.len()); self.read_end += n; } @@ -178,6 +190,32 @@ mod tests { conn.commit_read(bytes.len()); } + fn feed_all(conn: &mut ServerConnection, mut bytes: &[u8]) { + while !bytes.is_empty() { + let space = conn.read_space().unwrap(); + let n = space.len().min(bytes.len()); + space[..n].copy_from_slice(&bytes[..n]); + conn.commit_read(n); + bytes = &bytes[n..]; + } + } + + fn fill_with_junk_until_reject(conn: &mut ServerConnection) -> io::Error { + loop { + match conn.read_space() { + Ok(space) => { + let n = space.len(); + space.fill(b'j'); + conn.commit_read(n); + } + Err(e) => return e, + } + assert!(!conn.dispatch(&|_, _: &mut Vec| { + panic!("incomplete request must not dispatch") + })); + } + } + fn drain(conn: &mut ServerConnection) -> Vec { let out = conn.pending_write().to_vec(); conn.commit_write(out.len()); @@ -367,17 +405,96 @@ mod tests { #[test] fn read_space_exhausted_rejects_request_too_large() { let mut conn = ServerConnection::new(); - let space = conn.read_space().unwrap(); - let header = b"POST /big HTTP/1.1\r\nHost: localhost\r\nContent-Length: 33554432\r\n\r\n"; - space[..header.len()].copy_from_slice(header); - let n = space.len(); - conn.commit_read(n); + feed( + &mut conn, + b"POST /big HTTP/1.1\r\nHost: localhost\r\nContent-Length: 33554432\r\n\r\n", + ); - assert!( - !conn.dispatch(&|_, _: &mut Vec| panic!("incomplete request must not dispatch")) + let err = fill_with_junk_until_reject(&mut conn); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert_eq!(err.to_string(), "request too large"); + } + + #[test] + fn body_just_over_cap_rejects_with_identical_error() { + let mut conn = ServerConnection::new(); + let header = format!( + "POST /big HTTP/1.1\r\nHost: localhost\r\nContent-Length: {READ_BUF_MAX}\r\n\r\n" ); - let err = conn.read_space().unwrap_err(); + feed(&mut conn, header.as_bytes()); + + let err = fill_with_junk_until_reject(&mut conn); assert_eq!(err.kind(), io::ErrorKind::InvalidData); assert_eq!(err.to_string(), "request too large"); } + + #[test] + fn body_near_cap_dispatches() { + let mut conn = ServerConnection::new(); + let body_len = READ_BUF_MAX - 128; + let header = + format!("POST /big HTTP/1.1\r\nHost: localhost\r\nContent-Length: {body_len}\r\n\r\n"); + feed_all(&mut conn, header.as_bytes()); + let chunk = vec![b'b'; 1 << 16]; + let mut remaining = body_len; + while remaining > 0 { + let n = remaining.min(chunk.len()); + feed_all(&mut conn, &chunk[..n]); + remaining -= n; + } + + let seen = RefCell::new(0usize); + assert!(conn.dispatch(&|req: &ParsedRequest<'_>, out: &mut Vec| { + *seen.borrow_mut() = req.body.len(); + assert!(req.body.iter().all(|&b| b == b'b')); + frame_response(out, "200 OK", None, b""); + })); + assert_eq!(*seen.borrow(), body_len); + } + + #[test] + fn pipelined_keep_alive_partial_tails_never_creep_into_cap() { + let mut conn = ServerConnection::new(); + let mut request = b"POST /r HTTP/1.1\r\nHost: x\r\nContent-Length: 65536\r\n\r\n".to_vec(); + request.extend_from_slice(&vec![b'p'; 65536]); + let split = 16; + + // Feed twice the cap in total; every dispatch leaves a partial + // successor in the buffer, so the pre-compaction offsets would reach + // READ_BUF_MAX about halfway through and reject with "request too + // large". + let rounds = 2 * READ_BUF_MAX / request.len(); + feed_all(&mut conn, &request[..split]); + for _ in 0..rounds { + feed_all(&mut conn, &request[split..]); + feed_all(&mut conn, &request[..split]); + assert!(conn.dispatch(&echo_path)); + drain(&mut conn); + assert_eq!(conn.after_response(&echo_path), AfterResponse::AwaitRequest); + } + } + + #[test] + fn request_split_across_growth_boundary_not_corrupted() { + let mut conn = ServerConnection::new(); + let body: Vec = (0..6000u32).map(|i| (i % 251) as u8).collect(); + let mut request = + format!("POST /grow HTTP/1.1\r\nHost: x\r\nContent-Length: {}\r\n\r\n", body.len()) + .into_bytes(); + let header_len = request.len(); + request.extend_from_slice(&body); + + feed_all(&mut conn, &request[..READ_BUF_INIT]); + assert!( + !conn.dispatch(&|_, _: &mut Vec| panic!("incomplete request must not dispatch")) + ); + feed_all(&mut conn, &request[READ_BUF_INIT..]); + + let seen = RefCell::new(Vec::new()); + assert!(conn.dispatch(&|req: &ParsedRequest<'_>, out: &mut Vec| { + seen.borrow_mut().extend_from_slice(req.body); + frame_response(out, "200 OK", None, b""); + })); + assert_eq!(*seen.borrow(), request[header_len..]); + } } From cc94b7f6847e9dd930d2ab2973c06efcbf1b6acb Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 18 Aug 2026 10:51:31 +0100 Subject: [PATCH 09/16] Add inbound and outbound API deadlines (CL-114, CL-115) Outbound (CL-114): EngineConfig::request_timeout_secs, default 12. Each pooled connection records its request's enqueue time; the poll sweep fails any request older than the deadline through the existing error path, freeing the connection and un-gating spine intake. Age is anchored at enqueue, so a request stuck behind a blackholed connect expires on the same clock. The default clears every per-method floor in the engine-api spec (1s getPayload-class, 8s newPayload/fcu, 10s getPayloadBodies) -- those floors are minimum waits before aborting, and this deadline is a wedge-breaker, not a latency target. Inbound (CL-115): Config::beacon_api_idle_timeout_secs, default 75 -- a keep-alive window spanning several 12s slots. Connections stamp activity on accept and on every read or written byte; a coarse sweep (at most once per second) reaps connections idle past the deadline, treating malformed, partial, and silent input uniformly: a stalled receiver is idle, a trickling-but-progressing peer is not. Reaped connections free their beacon_api_max_connections slot, closing the cap-exhaustion scenario. Assisted-by: Claude:claude-fable-5 --- crates/beacon_api/examples/srv.rs | 10 +- crates/beacon_api/src/server.rs | 255 +++++++++++++++++--- crates/bin/src/main.rs | 1 + crates/client_server/tests/tile.rs | 1 + crates/config/src/engine_config.rs | 11 + crates/config/src/lib.rs | 28 ++- crates/engine_api/src/api.rs | 1 + crates/engine_api/src/client.rs | 27 ++- crates/engine_api/src/pool.rs | 154 +++++++++++- crates/engine_api/tests/newpayload_alloc.rs | 3 +- 10 files changed, 448 insertions(+), 43 deletions(-) diff --git a/crates/beacon_api/examples/srv.rs b/crates/beacon_api/examples/srv.rs index e83466be..0660738c 100644 --- a/crates/beacon_api/examples/srv.rs +++ b/crates/beacon_api/examples/srv.rs @@ -12,7 +12,15 @@ fn main() { // Never-published reader: state endpoints answer 503, as pre-bootstrap. let state = BeaconStateOwner::empty_test(0).reader(); - let mut api = BeaconApi::new(&bind, 64, &keypair, local_enr, &Identify::default(), state); + let mut api = BeaconApi::new( + &bind, + 64, + Duration::from_secs(75), + &keypair, + local_enr, + &Identify::default(), + state, + ); println!("serving on {:?}", api.local_addr()); loop { api.pump(); diff --git a/crates/beacon_api/src/server.rs b/crates/beacon_api/src/server.rs index b8fab32b..eeac943d 100644 --- a/crates/beacon_api/src/server.rs +++ b/crates/beacon_api/src/server.rs @@ -1,7 +1,7 @@ use std::{ collections::HashMap, io::{self, Read, Write}, - time::Duration, + time::{Duration, Instant}, }; use mio::{Events, Interest, Poll, Token}; @@ -16,9 +16,35 @@ use crate::{ const LISTENER: Token = Token(0); +const MAX_SWEEP_INTERVAL: Duration = Duration::from_secs(1); + struct Connection { stream: Stream, http: ServerConnection, + last_activity: Instant, +} + +/// Schedules the idle scan so that `pump` walks the connection map at most +/// once per `interval` instead of on every busy-poll iteration. +struct IdleSweep { + timeout: Duration, + interval: Duration, + next: Instant, +} + +impl IdleSweep { + fn new(timeout: Duration) -> Self { + let interval = MAX_SWEEP_INTERVAL.min(timeout / 4); + Self { timeout, interval, next: Instant::now() + interval } + } + + fn due(&mut self, now: Instant) -> bool { + if now < self.next { + return false; + } + self.next = now + self.interval; + true + } } pub struct BeaconApi { @@ -26,6 +52,7 @@ pub struct BeaconApi { events: Events, listener: Listener, max_connections: usize, + idle: IdleSweep, current_token: Token, connections: HashMap, router: Router, @@ -36,6 +63,7 @@ impl BeaconApi { pub fn new( bind: &Bind, max_connections: usize, + idle_timeout: Duration, keypair: &Keypair, local_enr: Enr, identify: &Identify, @@ -51,6 +79,7 @@ impl BeaconApi { events: Events::with_capacity(1024), listener, max_connections, + idle: IdleSweep::new(idle_timeout), current_token: Token(LISTENER.0 + 1), connections: HashMap::new(), router: Router::new(ROUTES), @@ -64,6 +93,7 @@ impl BeaconApi { pub fn pump(&mut self) -> bool { self.poll.poll(&mut self.events, Some(Duration::ZERO)).unwrap(); + let now = Instant::now(); let mut did_work = false; for event in &self.events { @@ -91,13 +121,16 @@ impl BeaconApi { } let token = next(&mut self.current_token); self.poll.registry().register(&mut stream, token, Interest::READABLE).unwrap(); - self.connections - .insert(token, Connection { stream, http: ServerConnection::new() }); + self.connections.insert(token, Connection { + stream, + http: ServerConnection::new(), + last_activity: now, + }); }, token => { if let Some(conn) = self.connections.get_mut(&token) { did_work = true; - match handle_event(self.poll.registry(), conn, event, &|req, out| { + match handle_event(self.poll.registry(), conn, event, now, &|req, out| { self.router.dispatch(req, &self.ctx, out) }) { Ok(true) => { @@ -116,14 +149,34 @@ impl BeaconApi { } } + if self.idle.due(now) { + did_work |= self.close_idle(now); + } + did_work } + + fn close_idle(&mut self, now: Instant) -> bool { + let Self { connections, poll, idle, .. } = self; + let before = connections.len(); + connections.retain(|_, conn| { + let idle_for = now.duration_since(conn.last_activity); + if idle_for <= idle.timeout { + return true; + } + tracing::warn!("beacon api connection idle for {idle_for:?}, closing"); + let _ = poll.registry().deregister(&mut conn.stream); + false + }); + connections.len() != before + } } fn handle_event, &mut Vec)>( registry: &mio::Registry, conn: &mut Connection, event: &mio::event::Event, + now: Instant, request_handler: &F, ) -> io::Result { if event.is_readable() { @@ -131,7 +184,10 @@ fn handle_event, &mut Vec)>( let space = conn.http.read_space()?; match conn.stream.read(space) { Ok(0) => return Err(io::Error::from(io::ErrorKind::UnexpectedEof)), - Ok(n) => conn.http.commit_read(n), + Ok(n) => { + conn.last_activity = now; + conn.http.commit_read(n); + } Err(e) if would_block(&e) => break, Err(e) if interrupted(&e) => continue, Err(e) => return Err(e), @@ -152,6 +208,7 @@ fn handle_event, &mut Vec)>( return Err(io::Error::new(io::ErrorKind::WriteZero, "write returned 0")) } Ok(n) => { + conn.last_activity = now; conn.http.commit_write(n); if conn.http.pending_write().is_empty() { break; @@ -215,13 +272,39 @@ mod tests { assert_eq!(cur.0, LISTENER.0 + 1); } - fn pump_until(api: &mut BeaconApi, client: JoinHandle, msg: &str) -> T { + /// Longer than any test's 10 s spin deadline: the idle sweep never reaps. + const LONG_TIMEOUT: Duration = Duration::from_secs(60); + + fn api_with(max_connections: usize, idle_timeout: Duration) -> BeaconApi { + let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); + let local_enr = Enr::empty(keypair.secret_key()).unwrap(); + BeaconApi::new( + &Bind::parse("127.0.0.1:0"), + max_connections, + idle_timeout, + &keypair, + local_enr, + &Identify::default(), + BeaconStateOwner::empty_test(0).reader(), + ) + } + + fn tcp_addr(api: &BeaconApi) -> SocketAddr { + let Bind::Tcp(addr) = api.local_addr() else { panic!("expected tcp bind") }; + addr + } + + fn pump_until(api: &mut BeaconApi, msg: &str, mut done: impl FnMut(&BeaconApi) -> bool) { let deadline = Instant::now() + Duration::from_secs(10); - while !client.is_finished() { + while !done(api) { assert!(Instant::now() < deadline, "timeout: {msg}"); api.pump(); std::thread::sleep(Duration::from_millis(1)); } + } + + fn serve(api: &mut BeaconApi, client: JoinHandle, msg: &str) -> T { + pump_until(api, msg, |_| client.is_finished()); client.join().unwrap() } @@ -231,21 +314,24 @@ mod tests { stream } + fn read_to_eof(mut stream: TcpStream) -> Vec { + let mut received = Vec::new(); + let mut chunk = [0u8; 1024]; + loop { + match stream.read(&mut chunk) { + Ok(0) => return received, + Ok(n) => received.extend_from_slice(&chunk[..n]), + Err(e) => panic!("client read: {e}"), + } + } + } + #[test] fn connection_cap_drops_excess_then_recovers() { - let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); - let local_enr = Enr::empty(keypair.secret_key()).unwrap(); - let mut api = BeaconApi::new( - &Bind::parse("127.0.0.1:0"), - 1, - &keypair, - local_enr, - &Identify::default(), - BeaconStateOwner::empty_test(0).reader(), - ); - let Bind::Tcp(addr) = api.local_addr() else { panic!("expected tcp bind") }; + let mut api = api_with(1, LONG_TIMEOUT); + let addr = tcp_addr(&api); - let held_open = pump_until( + let held_open = serve( &mut api, std::thread::spawn(move || { let mut stream = connect(addr); @@ -263,7 +349,7 @@ mod tests { "first client served", ); - let denied = pump_until( + let denied = serve( &mut api, std::thread::spawn(move || { let mut stream = connect(addr); @@ -279,14 +365,9 @@ mod tests { ); drop(held_open); - let deadline = Instant::now() + Duration::from_secs(10); - while !api.connections.is_empty() { - assert!(Instant::now() < deadline, "timeout: closed connection reaped"); - api.pump(); - std::thread::sleep(Duration::from_millis(1)); - } + pump_until(&mut api, "closed connection reaped", |api| api.connections.is_empty()); - let response = pump_until( + let response = serve( &mut api, std::thread::spawn(move || { let mut stream = connect(addr); @@ -300,4 +381,124 @@ mod tests { ); assert!(response.starts_with(b"HTTP/1.1 200 OK\r\n")); } + + /// CL-115: a request that never completes holds its slot forever. Partial + /// and malformed input are treated alike — neither dispatches, so both are + /// reaped by the same idle deadline. + #[test] + fn partial_request_is_reaped_after_the_idle_deadline() { + let mut api = api_with(64, Duration::from_millis(200)); + let addr = tcp_addr(&api); + + let received = serve( + &mut api, + std::thread::spawn(move || { + let mut stream = connect(addr); + write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\n").unwrap(); + read_to_eof(stream) + }), + "partial request reaped", + ); + + assert!(received.is_empty(), "half a request must not be answered: {received:?}"); + assert!(api.connections.is_empty(), "reaped connection must leave the map"); + } + + #[test] + fn idle_keep_alive_connection_is_reaped_after_the_idle_deadline() { + let idle_timeout = Duration::from_millis(200); + let mut api = api_with(64, idle_timeout); + let addr = tcp_addr(&api); + + let (received, alive_for) = serve( + &mut api, + std::thread::spawn(move || { + let mut stream = connect(addr); + // Timed from before the request: the server's activity stamp + // cannot predate it, so the deadline it enforces is at least + // this long. + let sent_at = Instant::now(); + write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\n\r\n").unwrap(); + (read_to_eof(stream), sent_at.elapsed()) + }), + "idle keep-alive connection reaped", + ); + + assert!(received.starts_with(b"HTTP/1.1 200 OK\r\n")); + assert!(alive_for >= idle_timeout, "closed before the deadline, after {alive_for:?}"); + assert!(api.connections.is_empty(), "reaped connection must leave the map"); + } + + #[test] + fn traffic_refreshes_the_idle_deadline() { + let idle_timeout = Duration::from_millis(400); + let mut api = api_with(64, idle_timeout); + let addr = tcp_addr(&api); + + // Five requests spaced a quarter of the deadline apart run well past it + // in total; each read/write must push the deadline out. + let _still_open = serve( + &mut api, + std::thread::spawn(move || { + let mut stream = connect(addr); + let mut chunk = [0u8; 1024]; + for i in 0..5 { + write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\n\r\n").unwrap(); + let n = stream.read(&mut chunk).unwrap(); + assert!(n > 0, "server closed a connection that kept transferring (#{i})"); + std::thread::sleep(idle_timeout / 4); + } + stream + }), + "keep-alive client kept alive by its own traffic", + ); + + assert_eq!(api.connections.len(), 1, "an active connection must survive the sweep"); + } + + /// The CL-115 exhaustion scenario end to end: a hung client owns the only + /// slot, so every other client is refused until the sweep frees it. + #[test] + fn idle_sweep_frees_a_slot_held_at_the_cap() { + let mut api = api_with(1, Duration::from_millis(800)); + let addr = tcp_addr(&api); + + let hung = std::thread::spawn(move || { + let mut stream = connect(addr); + write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\n").unwrap(); + read_to_eof(stream) + }); + pump_until(&mut api, "hung client holds the only slot", |api| api.connections.len() == 1); + + let denied = serve( + &mut api, + std::thread::spawn(move || { + let mut stream = connect(addr); + let _ = write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\n\r\n"); + let mut chunk = [0u8; 1024]; + stream.read(&mut chunk) + }), + "second client refused while the slot is held", + ); + assert!( + !matches!(denied, Ok(n) if n > 0), + "the held slot must refuse other clients: {denied:?}" + ); + + assert!(serve(&mut api, hung, "hung client reaped").is_empty()); + + let response = serve( + &mut api, + std::thread::spawn(move || { + let mut stream = connect(addr); + write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") + .unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).unwrap(); + response + }), + "fresh client served once the sweep freed the slot", + ); + assert!(response.starts_with(b"HTTP/1.1 200 OK\r\n")); + } } diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index bfa5479b..db0338ce 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -235,6 +235,7 @@ fn main() -> Result<(), Box> { let beacon_api = BeaconApi::new( &Bind::parse(config.beacon_api_bind()), config.beacon_api_max_connections(), + config.beacon_api_idle_timeout(), &keypair, local_enr, &identify, diff --git a/crates/client_server/tests/tile.rs b/crates/client_server/tests/tile.rs index 7f07bc33..efaca5bf 100644 --- a/crates/client_server/tests/tile.rs +++ b/crates/client_server/tests/tile.rs @@ -32,6 +32,7 @@ fn beacon(bind: &Bind) -> BeaconApi { BeaconApi::new( bind, 64, + Duration::from_secs(75), &keypair, local_enr, &Identify::default(), diff --git a/crates/config/src/engine_config.rs b/crates/config/src/engine_config.rs index f48d9405..f22680f4 100644 --- a/crates/config/src/engine_config.rs +++ b/crates/config/src/engine_config.rs @@ -8,6 +8,13 @@ fn default_max_connections() -> usize { 32 } +// Clears every engine-api per-method minimum-wait floor (the highest is +// getPayloadBodiesBy* at 10 s) with margin: this deadline breaks wedged +// connections, it is not a latency target. +fn default_request_timeout_secs() -> u64 { + 12 +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct EngineConfig { pub execution_endpoint: String, @@ -17,6 +24,9 @@ pub struct EngineConfig { pub incoming_engine_resp_tcache_size: usize, #[serde(default = "default_max_connections")] pub max_connections: usize, + /// Measured from enqueue, so it also covers a connect that never completes. + #[serde(default = "default_request_timeout_secs")] + pub request_timeout_secs: u64, /// Unsafe testing mode: do not connect to the EL. The engine tile answers /// every spine request with a synthetic VALID response. Lets the CL run /// without an execution client. Never enable in production. @@ -31,6 +41,7 @@ impl Default for EngineConfig { jwt_secret: "0".into(), incoming_engine_resp_tcache_size: 2 << 24, max_connections: 32, + request_timeout_secs: default_request_timeout_secs(), unsafe_no_el: false, } } diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 55674e07..700c28a6 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1,4 +1,7 @@ -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}; +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}, + time::Duration, +}; use chain_config::ChainConfig; pub use discovery_config::DiscoveryConfig; @@ -131,6 +134,10 @@ pub struct Config { beacon_api_bind: String, #[serde(default = "default_usize::<64>")] beacon_api_max_connections: usize, + /// Refreshed by any byte read or written, so a slow but progressing + /// transfer never trips it. + #[serde(default = "default_u64::<75>")] + beacon_api_idle_timeout_secs: u64, #[serde(default)] disable_weak_subjectivity_check: bool, } @@ -167,6 +174,7 @@ impl Config { engine_config: Default::default(), beacon_api_bind: default_beacon_api_bind(), beacon_api_max_connections: 64, + beacon_api_idle_timeout_secs: 75, disable_weak_subjectivity_check: false, } } @@ -229,6 +237,11 @@ impl Config { self } + pub fn with_beacon_api_idle_timeout_secs(mut self, secs: u64) -> Self { + self.beacon_api_idle_timeout_secs = secs; + self + } + pub fn keypair(&self) -> Result { Keypair::from_secret(&self.secret_key) } @@ -365,6 +378,10 @@ impl Config { self.beacon_api_max_connections } + pub fn beacon_api_idle_timeout(&self) -> Duration { + Duration::from_secs(self.beacon_api_idle_timeout_secs) + } + pub fn disable_weak_subjectivity_check(&self) -> bool { self.disable_weak_subjectivity_check } @@ -397,6 +414,7 @@ mod tests { assert_eq!(cfg.gossip_topics().unwrap().len(), 8); assert_eq!(cfg.beacon_api_bind(), "0.0.0.0:5051"); assert_eq!(cfg.beacon_api_max_connections(), 64); + assert_eq!(cfg.beacon_api_idle_timeout(), Duration::from_secs(75)); } #[test] @@ -415,6 +433,14 @@ mod tests { assert_eq!(cfg.beacon_api_max_connections(), 2); } + #[test] + fn builder_sets_beacon_api_idle_timeout() { + let cfg = Config::new([1u8; 32], [0u8; 4], [0u8; 4], 0); + assert_eq!(cfg.beacon_api_idle_timeout(), Duration::from_secs(75)); + let cfg = cfg.with_beacon_api_idle_timeout_secs(5); + assert_eq!(cfg.beacon_api_idle_timeout(), Duration::from_secs(5)); + } + #[test] fn builders_set_external_ip_and_genesis() { let cfg = Config::new([1u8; 32], [0u8; 4], [0u8; 4], 0) diff --git a/crates/engine_api/src/api.rs b/crates/engine_api/src/api.rs index 42bed05f..3c485e1c 100644 --- a/crates/engine_api/src/api.rs +++ b/crates/engine_api/src/api.rs @@ -47,6 +47,7 @@ impl EngineApi { &config.execution_endpoint, &config.jwt_secret, config.max_connections, + Duration::from_secs(config.request_timeout_secs), )) }; Self { diff --git a/crates/engine_api/src/client.rs b/crates/engine_api/src/client.rs index 48b6ae34..a155f7d7 100644 --- a/crates/engine_api/src/client.rs +++ b/crates/engine_api/src/client.rs @@ -55,18 +55,33 @@ pub struct EngineClient { } impl EngineClient { - pub fn new(endpoint: &str, jwt: &str, max_connections: usize) -> Self { - Self::with_endpoint(parse_endpoint(endpoint), jwt, max_connections) + pub fn new( + endpoint: &str, + jwt: &str, + max_connections: usize, + request_timeout: Duration, + ) -> Self { + Self::with_endpoint(parse_endpoint(endpoint), jwt, max_connections, request_timeout) } - pub fn new_uds(path: impl Into, jwt: &str, max_connections: usize) -> Self { - Self::with_endpoint(Endpoint::Uds(path.into()), jwt, max_connections) + pub fn new_uds( + path: impl Into, + jwt: &str, + max_connections: usize, + request_timeout: Duration, + ) -> Self { + Self::with_endpoint(Endpoint::Uds(path.into()), jwt, max_connections, request_timeout) } - fn with_endpoint(endpoint: Endpoint, jwt: &str, max_connections: usize) -> Self { + fn with_endpoint( + endpoint: Endpoint, + jwt: &str, + max_connections: usize, + request_timeout: Duration, + ) -> Self { let jwt = JwtSecret::from_file(jwt).unwrap_or_else(|e| panic!("invalid JWT secret: {e}")); Self { - pool: HttpPool::new(endpoint, jwt, max_connections), + pool: HttpPool::new(endpoint, jwt, max_connections, request_timeout), poll: Poll::new().expect("mio Poll::new failed"), events: Events::with_capacity(EVENTS_CAPACITY), id: 1, diff --git a/crates/engine_api/src/pool.rs b/crates/engine_api/src/pool.rs index 2d06ba03..9691d3f7 100644 --- a/crates/engine_api/src/pool.rs +++ b/crates/engine_api/src/pool.rs @@ -2,6 +2,7 @@ use std::{ io::{self, Read, Write}, net::{SocketAddr, ToSocketAddrs}, path::PathBuf, + time::{Duration, Instant}, }; use mio::{Events, Interest, Poll, Token}; @@ -54,6 +55,7 @@ struct PooledConnection { machine: ClientConnection, in_flight: Option, pending_id: Option, + request_started: Option, } impl PooledConnection { @@ -69,6 +71,7 @@ impl PooledConnection { machine: ClientConnection::with_capacity(READ_BUF_CAPACITY, WRITE_BUF_CAPACITY), in_flight: None, pending_id: None, + request_started: None, } } @@ -76,11 +79,18 @@ impl PooledConnection { self.in_flight.is_none() && self.pending_id.is_none() } + /// Age is measured from enqueue rather than from the write hitting the + /// wire, so a connect that never completes expires on the same deadline. + fn expired(&self, now: Instant, timeout: Duration) -> bool { + self.request_started.is_some_and(|started| now.duration_since(started) > timeout) + } + fn enqueue(&mut self, rpc_id: u64, body: &[u8], poll: &mut Poll) { debug_assert!(self.is_free(), "enqueue on busy connection"); let out = self.machine.begin_request(); frame_request(out, &self.host, body, Some(self.jwt.bearer_token()), true); self.pending_id = Some(rpc_id); + self.request_started = Some(Instant::now()); match self.conn { Conn::Disconnected => self.connect(poll), @@ -213,11 +223,12 @@ impl PooledConnection { where F: FnMut(u64, Result<&mut [u8], EngineError>), { - let Self { conn, machine, in_flight, .. } = self; + let Self { conn, machine, in_flight, request_started, .. } = self; let Conn::Connected(stream) = conn else { return Ok(()) }; loop { while let Some(body) = machine.take_response() { if let Some(rpc_id) = in_flight.take() { + *request_started = None; on_complete(rpc_id, Ok(body)); } } @@ -243,6 +254,7 @@ impl PooledConnection { if let Some(rpc_id) = self.pending_id.take() { on_complete(rpc_id, Err(EngineError::Http(err.clone()))); } + self.request_started = None; self.machine.reset(); let old = std::mem::replace(&mut self.conn, Conn::Disconnected); if let Conn::Connecting(mut stream) | Conn::Connected(mut stream) = old { @@ -277,12 +289,18 @@ pub(crate) struct HttpPool { endpoint: Endpoint, jwt: JwtSecret, max_connections: usize, + request_timeout: Duration, } impl HttpPool { - pub(crate) fn new(endpoint: Endpoint, jwt: JwtSecret, max_connections: usize) -> Self { + pub(crate) fn new( + endpoint: Endpoint, + jwt: JwtSecret, + max_connections: usize, + request_timeout: Duration, + ) -> Self { let connections = vec![PooledConnection::new(endpoint.clone(), jwt.clone(), Token(0))]; - Self { connections, endpoint, jwt, max_connections } + Self { connections, endpoint, jwt, max_connections, request_timeout } } /// `enqueue` never refuses work; every caller gates on this before @@ -312,12 +330,15 @@ impl HttpPool { where F: FnMut(u64, Result<&mut [u8], EngineError>), { + let now = Instant::now(); for conn in &mut self.connections { // Disconnected with a request pending means connect() could not // even start (resolve/connect/register error): no event will ever // arrive for it, so fail the rpc here or it is stranded forever. if matches!(conn.conn, Conn::Disconnected) && conn.pending_id.is_some() { conn.fail(poll, on_complete, "connect failed to start"); + } else if conn.expired(now, self.request_timeout) { + conn.fail(poll, on_complete, "request timed out"); } conn.handle_events(events, poll, on_complete); } @@ -326,10 +347,11 @@ impl HttpPool { #[cfg(test)] mod tests { - use std::time::{Duration, Instant}; + use std::os::unix::net::UnixListener; use tempfile::TempDir; + use super::*; use crate::{ EngineClient, client::{ReqKind, poll, send_fcu}, @@ -337,6 +359,9 @@ mod tests { types::ForkchoiceState, }; + /// Longer than any test's 10 s spin deadline: the sweep never fires. + const LONG_TIMEOUT: Duration = Duration::from_secs(60); + fn fcu_state(byte: u8) -> ForkchoiceState { ForkchoiceState { head_block_hash: [byte; 32], @@ -360,7 +385,8 @@ mod tests { let socket = dir.path().join("engine.sock"); let mut el = FakeEl::uds(&socket); - let mut client = EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 32); + let mut client = + EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 32, LONG_TIMEOUT); let block_root = [7u8; 32]; send_fcu(&mut client, block_root, fcu_state(1), None); @@ -400,7 +426,8 @@ mod tests { // max_connections = 1: after the failure, has_capacity() can only be // true again if the zombie connection was actually freed. - let mut client = EngineClient::new_uds(&missing_socket, jwt_path.to_str().unwrap(), 1); + let mut client = + EngineClient::new_uds(&missing_socket, jwt_path.to_str().unwrap(), 1, LONG_TIMEOUT); let block_root = [3u8; 32]; send_fcu(&mut client, block_root, fcu_state(3), None); assert!(!client.has_capacity(), "request occupies the only connection"); @@ -426,7 +453,8 @@ mod tests { let socket = dir.path().join("engine.sock"); let mut el = FakeEl::uds(&socket); - let mut client = EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 32); + let mut client = + EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 32, LONG_TIMEOUT); let block_root = [9u8; 32]; send_fcu(&mut client, block_root, fcu_state(2), None); @@ -448,4 +476,116 @@ mod tests { assert_eq!(failure.unwrap(), block_root); } + + /// CL-114: an EL that accepts a request and never answers used to wedge the + /// connection — and with `max_connections` reached, the gated spine intake + /// behind it — for the lifetime of the process. + #[test] + fn unanswered_request_times_out_and_frees_connection() { + let dir = TempDir::new().unwrap(); + let jwt_path = write_jwt(dir.path()); + let socket = dir.path().join("engine.sock"); + let mut el = FakeEl::uds(&socket); + + let mut client = EngineClient::new_uds( + &socket, + jwt_path.to_str().unwrap(), + 1, + Duration::from_millis(200), + ); + send_fcu(&mut client, [1u8; 32], fcu_state(1), None); + + let mut timed_out: Option<[u8; 32]> = None; + spin_until("unanswered request times out", || { + poll(&mut client, |kind, response| { + let ReqKind::Fcu(root) = kind else { panic!("unexpected completion") }; + assert!(response.is_err(), "unanswered request must fail the rpc"); + timed_out = Some(root); + }); + el.pump(); + timed_out.is_some() + }); + + assert_eq!(timed_out.unwrap(), [1u8; 32]); + assert_eq!(el.requests.len(), 1, "the EL received the request it never answered"); + assert!(client.has_capacity(), "timed-out connection must be reusable"); + + send_fcu(&mut client, [2u8; 32], fcu_state(2), None); + let mut answered = false; + let mut completed: Option<[u8; 32]> = None; + spin_until("next request served on the freed connection", || { + poll(&mut client, |kind, response| { + let ReqKind::Fcu(root) = kind else { panic!("unexpected completion") }; + assert!(response.is_ok(), "answered request must succeed"); + completed = Some(root); + }); + el.pump(); + if !answered && el.requests.len() == 2 { + el.respond(1, FCU_VALID_RESULT); + answered = true; + } + completed.is_some() + }); + assert_eq!(completed.unwrap(), [2u8; 32]); + } + + #[test] + fn request_answered_within_the_deadline_does_not_time_out() { + let dir = TempDir::new().unwrap(); + let jwt_path = write_jwt(dir.path()); + let socket = dir.path().join("engine.sock"); + let mut el = FakeEl::uds(&socket); + + let mut client = + EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 1, Duration::from_secs(2)); + send_fcu(&mut client, [4u8; 32], fcu_state(4), None); + + let answer_at = Instant::now() + Duration::from_millis(400); + let mut answered = false; + let mut completed: Option<[u8; 32]> = None; + spin_until("slow but in-deadline response succeeds", || { + poll(&mut client, |kind, response| { + let ReqKind::Fcu(root) = kind else { panic!("unexpected completion") }; + assert!(response.is_ok(), "response inside the deadline must not fail"); + completed = Some(root); + }); + el.pump(); + if !answered && !el.requests.is_empty() && Instant::now() >= answer_at { + el.respond(0, FCU_VALID_RESULT); + answered = true; + } + completed.is_some() + }); + assert_eq!(completed.unwrap(), [4u8; 32]); + } + + /// A blackholed connect (SYN dropped) is not cheaply reproducible in a unit + /// test, so the pool is driven directly: with no events ever delivered the + /// connection stays in `Connecting`, which is the state such a connect is + /// stuck in, and the deadline must still fire. + #[test] + fn pending_request_times_out_while_still_connecting() { + let dir = TempDir::new().unwrap(); + let jwt_path = write_jwt(dir.path()); + let socket = dir.path().join("engine.sock"); + let _listener = UnixListener::bind(&socket).unwrap(); + + let jwt = JwtSecret::from_file(jwt_path.to_str().unwrap()).unwrap(); + let mut pool = HttpPool::new(Endpoint::Uds(socket), jwt, 1, Duration::from_millis(100)); + let mut poll = Poll::new().unwrap(); + let events = Events::with_capacity(1); + + pool.enqueue(7, b"{}", &mut poll); + assert!(matches!(pool.connections[0].conn, Conn::Connecting(_))); + assert!(!pool.has_capacity()); + + std::thread::sleep(Duration::from_millis(150)); + let mut failed: Option<(u64, bool)> = None; + pool.poll_events(&events, &mut poll, &mut |rpc_id, response| { + failed = Some((rpc_id, response.is_err())); + }); + + assert_eq!(failed, Some((7, true)), "a stuck connect must fail its rpc"); + assert!(pool.has_capacity(), "timed-out connection must be reusable"); + } } diff --git a/crates/engine_api/tests/newpayload_alloc.rs b/crates/engine_api/tests/newpayload_alloc.rs index 61e696a2..2bf8dbdd 100644 --- a/crates/engine_api/tests/newpayload_alloc.rs +++ b/crates/engine_api/tests/newpayload_alloc.rs @@ -82,7 +82,8 @@ fn warm_new_payload_send_allocates_nothing() { let jwt_path = write_jwt(dir.path()); let socket = dir.path().join("engine.sock"); let mut el = FakeEl::uds(&socket); - let mut client = EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 4); + let mut client = + EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 4, Duration::from_secs(60)); send_new_payload(&mut client, SIGNED_BLOCK_SSZ, [0u8; 32]).unwrap(); complete_round_trip(&mut client, &mut el, 0); From e68926de51fcb1db18aa27d6f7e383c6f8678e43 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 18 Aug 2026 11:13:38 +0100 Subject: [PATCH 10/16] Support multiple simultaneous beacon-api binds beacon_api_bind becomes a list: a TOML array in the config file (default ["0.0.0.0:5051"], single-bind behavior unchanged), comma-delimited values on --beacon-api-bind (a comma cannot appear in a socket address and is pathological in a socket path). BeaconApi holds one listener per bind -- TCP and unix sockets side by side, multiple interfaces, several UDS paths with distinct permissions. Listeners occupy the reserved token range 0..n; connection tokens allocate above it and wrap back to it. The connection cap and idle sweep count connections across all listeners. Bind::parse now rejects a string that contains ':' but is not a valid socket address instead of silently treating it as a unix path: hostnames are not resolved, and with several binds a typo'd address would otherwise bind a stray socket file and half-serve rather than fail loudly at startup. An empty bind list panics at construction: a node with no API surface is the same class of misconfiguration as an unbindable address. Assisted-by: Claude:claude-fable-5 --- Cargo.lock | 1 + crates/beacon_api/Cargo.toml | 3 + crates/beacon_api/examples/srv.rs | 7 +- crates/beacon_api/src/server.rs | 236 +++++++++++++++++++++++++---- crates/bin/src/main.rs | 36 ++++- crates/client_server/tests/tile.rs | 8 +- crates/config/src/lib.rs | 39 +++-- crates/httpcore/src/stream.rs | 24 ++- 8 files changed, 301 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2f2f6074..17c068ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4456,6 +4456,7 @@ dependencies = [ "silver_beacon_state_data", "silver_common", "silver_httpcore", + "tempfile", "tracing", ] diff --git a/crates/beacon_api/Cargo.toml b/crates/beacon_api/Cargo.toml index e62d76bf..f2452619 100644 --- a/crates/beacon_api/Cargo.toml +++ b/crates/beacon_api/Cargo.toml @@ -15,5 +15,8 @@ serde.workspace = true tracing.workspace = true serde_json = "1.0.149" +[dev-dependencies] +tempfile = "3" + [lints] workspace = true diff --git a/crates/beacon_api/examples/srv.rs b/crates/beacon_api/examples/srv.rs index 0660738c..a2885c81 100644 --- a/crates/beacon_api/examples/srv.rs +++ b/crates/beacon_api/examples/srv.rs @@ -6,14 +6,15 @@ use silver_common::{Enr, Identify, Keypair}; use silver_httpcore::Bind; fn main() { - let bind = Bind::parse(&std::env::args().nth(1).unwrap_or_else(|| "0.0.0.0:5051".into())); + let arg = std::env::args().nth(1).unwrap_or_else(|| "0.0.0.0:5051".into()); + let binds = arg.split(',').map(Bind::parse).collect::>(); let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); let local_enr = Enr::empty(keypair.secret_key()).unwrap(); // Never-published reader: state endpoints answer 503, as pre-bootstrap. let state = BeaconStateOwner::empty_test(0).reader(); let mut api = BeaconApi::new( - &bind, + &binds, 64, Duration::from_secs(75), &keypair, @@ -21,7 +22,7 @@ fn main() { &Identify::default(), state, ); - println!("serving on {:?}", api.local_addr()); + println!("serving on {:?}", api.local_addrs()); loop { api.pump(); std::thread::sleep(Duration::from_millis(1)); diff --git a/crates/beacon_api/src/server.rs b/crates/beacon_api/src/server.rs index eeac943d..eb07d8d1 100644 --- a/crates/beacon_api/src/server.rs +++ b/crates/beacon_api/src/server.rs @@ -14,8 +14,6 @@ use crate::{ routes::{ApiCtx, ROUTES}, }; -const LISTENER: Token = Token(0); - const MAX_SWEEP_INTERVAL: Duration = Duration::from_secs(1); struct Connection { @@ -50,7 +48,7 @@ impl IdleSweep { pub struct BeaconApi { poll: Poll, events: Events, - listener: Listener, + listeners: Vec, max_connections: usize, idle: IdleSweep, current_token: Token, @@ -61,7 +59,7 @@ pub struct BeaconApi { impl BeaconApi { pub fn new( - bind: &Bind, + binds: &[Bind], max_connections: usize, idle_timeout: Duration, keypair: &Keypair, @@ -69,26 +67,34 @@ impl BeaconApi { identify: &Identify, state: BeaconStateReader, ) -> Self { + assert!(!binds.is_empty(), "beacon api needs at least one bind"); let poll = Poll::new().unwrap(); - let mut listener = - Listener::bind(bind).unwrap_or_else(|e| panic!("beacon api bind {bind:?}: {e}")); - poll.registry().register(&mut listener, LISTENER, Interest::READABLE).unwrap(); + let listeners = binds + .iter() + .enumerate() + .map(|(index, bind)| { + let mut listener = Listener::bind(bind) + .unwrap_or_else(|e| panic!("beacon api bind {bind:?}: {e}")); + poll.registry().register(&mut listener, Token(index), Interest::READABLE).unwrap(); + listener + }) + .collect::>(); Self { poll, events: Events::with_capacity(1024), - listener, max_connections, idle: IdleSweep::new(idle_timeout), - current_token: Token(LISTENER.0 + 1), + current_token: Token(listeners.len()), + listeners, connections: HashMap::new(), router: Router::new(ROUTES), ctx: ApiCtx::new(keypair, &local_enr, identify, state), } } - pub fn local_addr(&self) -> Bind { - self.listener.local_addr() + pub fn local_addrs(&self) -> Vec { + self.listeners.iter().map(Listener::local_addr).collect() } pub fn pump(&mut self) -> bool { @@ -97,9 +103,9 @@ impl BeaconApi { let mut did_work = false; for event in &self.events { - match event.token() { - LISTENER => loop { - let mut stream = match self.listener.accept() { + match self.listeners.get(event.token().0) { + Some(listener) => loop { + let mut stream = match listener.accept() { Ok(stream) => stream, Err(e) if would_block(&e) => break, Err(e) => { @@ -119,7 +125,7 @@ impl BeaconApi { ); continue; } - let token = next(&mut self.current_token); + let token = next(&mut self.current_token, self.listeners.len()); self.poll.registry().register(&mut stream, token, Interest::READABLE).unwrap(); self.connections.insert(token, Connection { stream, @@ -127,7 +133,8 @@ impl BeaconApi { last_activity: now, }); }, - token => { + None => { + let token = event.token(); if let Some(conn) = self.connections.get_mut(&token) { did_work = true; match handle_event(self.poll.registry(), conn, event, now, &|req, out| { @@ -235,11 +242,12 @@ fn handle_event, &mut Vec)>( Ok(false) } -fn next(current: &mut Token) -> Token { +/// Connection tokens sit above the listener range `0..reserved`, which the +/// wrap must skip to avoid aliasing an accept socket. +fn next(current: &mut Token, reserved: usize) -> Token { let tok = Token(current.0); let n = current.0.wrapping_add(1); - // Skip Token(0) == LISTENER on wrap to avoid aliasing the accept socket. - current.0 = if n == LISTENER.0 { LISTENER.0 + 1 } else { n }; + current.0 = if n < reserved { reserved } else { n }; tok } @@ -255,6 +263,8 @@ fn interrupted(err: &io::Error) -> bool { mod tests { use std::{ net::{SocketAddr, TcpStream}, + os::unix::net::UnixStream, + path::Path, thread::JoinHandle, time::Instant, }; @@ -264,22 +274,27 @@ mod tests { use super::*; #[test] - fn token_wrap_skips_listener() { + fn token_wrap_skips_the_listener_range() { + let reserved = 3; + let mut cur = Token(usize::MAX); - let assigned = next(&mut cur); - assert_ne!(assigned, LISTENER, "returned token must not alias LISTENER"); - assert_ne!(cur, LISTENER, "next token must not alias LISTENER after wrap"); - assert_eq!(cur.0, LISTENER.0 + 1); + let assigned = next(&mut cur, reserved); + assert!(assigned.0 >= reserved, "returned token must not alias a listener"); + assert_eq!(cur, Token(reserved), "the wrap must land above the listener range"); + + let mut cur = Token(reserved); + assert_eq!(next(&mut cur, reserved), Token(reserved)); + assert_eq!(cur, Token(reserved + 1)); } /// Longer than any test's 10 s spin deadline: the idle sweep never reaps. const LONG_TIMEOUT: Duration = Duration::from_secs(60); - fn api_with(max_connections: usize, idle_timeout: Duration) -> BeaconApi { + fn api_bound_to(binds: &[Bind], max_connections: usize, idle_timeout: Duration) -> BeaconApi { let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); let local_enr = Enr::empty(keypair.secret_key()).unwrap(); BeaconApi::new( - &Bind::parse("127.0.0.1:0"), + binds, max_connections, idle_timeout, &keypair, @@ -289,9 +304,22 @@ mod tests { ) } + fn api_with(max_connections: usize, idle_timeout: Duration) -> BeaconApi { + api_bound_to(&[Bind::parse("127.0.0.1:0")], max_connections, idle_timeout) + } + + fn tcp_addrs(api: &BeaconApi) -> Vec { + api.local_addrs() + .into_iter() + .map(|bind| { + let Bind::Tcp(addr) = bind else { panic!("expected tcp bind") }; + addr + }) + .collect() + } + fn tcp_addr(api: &BeaconApi) -> SocketAddr { - let Bind::Tcp(addr) = api.local_addr() else { panic!("expected tcp bind") }; - addr + tcp_addrs(api)[0] } fn pump_until(api: &mut BeaconApi, msg: &str, mut done: impl FnMut(&BeaconApi) -> bool) { @@ -308,12 +336,45 @@ mod tests { client.join().unwrap() } + fn serve_both( + api: &mut BeaconApi, + first: JoinHandle, + second: JoinHandle, + msg: &str, + ) -> (T, T) { + pump_until(api, msg, |_| first.is_finished() && second.is_finished()); + (first.join().unwrap(), second.join().unwrap()) + } + fn connect(addr: SocketAddr) -> TcpStream { let stream = TcpStream::connect(addr).unwrap(); stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); stream } + fn connect_uds(path: &Path) -> UnixStream { + let stream = UnixStream::connect(path).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + stream + } + + fn get_identity(mut stream: impl Read + Write) -> Vec { + write!( + stream, + "GET /eth/v1/node/identity HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" + ) + .unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).unwrap(); + response + } + + fn assert_identity_ok(response: &[u8]) { + let text = String::from_utf8_lossy(response); + assert!(text.starts_with("HTTP/1.1 200 OK\r\n"), "unexpected response: {text}"); + assert!(text.contains("\"peer_id\""), "identity body missing: {text}"); + } + fn read_to_eof(mut stream: TcpStream) -> Vec { let mut received = Vec::new(); let mut chunk = [0u8; 1024]; @@ -326,6 +387,125 @@ mod tests { } } + #[test] + #[should_panic(expected = "at least one bind")] + fn an_empty_bind_list_is_rejected() { + api_bound_to(&[], 64, LONG_TIMEOUT); + } + + #[test] + fn every_tcp_listener_serves_the_api() { + let mut api = api_bound_to( + &[Bind::parse("127.0.0.1:0"), Bind::parse("127.0.0.1:0")], + 64, + LONG_TIMEOUT, + ); + + let addrs = tcp_addrs(&api); + assert_eq!(addrs.len(), 2, "one resolved address per bind"); + assert_ne!(addrs[0], addrs[1], "each bind resolves to its own port"); + assert!(addrs.iter().all(|addr| addr.port() != 0), "port-0 binds resolve: {addrs:?}"); + + let (first_addr, second_addr) = (addrs[0], addrs[1]); + let (first, second) = serve_both( + &mut api, + std::thread::spawn(move || get_identity(connect(first_addr))), + std::thread::spawn(move || get_identity(connect(second_addr))), + "both tcp listeners served", + ); + assert_identity_ok(&first); + assert_identity_ok(&second); + } + + #[test] + fn tcp_and_uds_listeners_serve_side_by_side() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("api.sock"); + let mut api = api_bound_to( + &[Bind::parse("127.0.0.1:0"), Bind::Unix(socket.clone())], + 64, + LONG_TIMEOUT, + ); + + let addrs = api.local_addrs(); + let [Bind::Tcp(tcp_addr), Bind::Unix(uds_path)] = &addrs[..] else { + panic!("expected a tcp bind and a uds bind: {addrs:?}") + }; + assert_eq!(uds_path, &socket); + + let tcp_addr = *tcp_addr; + let (over_tcp, over_uds) = serve_both( + &mut api, + std::thread::spawn(move || get_identity(connect(tcp_addr))), + std::thread::spawn(move || get_identity(connect_uds(&socket))), + "tcp and uds listeners served", + ); + assert_identity_ok(&over_tcp); + assert_identity_ok(&over_uds); + } + + /// The cap counts connections, not listeners: a slot held through one + /// listener refuses clients arriving on any other. + #[test] + fn connection_cap_is_shared_across_listeners() { + let mut api = api_bound_to( + &[Bind::parse("127.0.0.1:0"), Bind::parse("127.0.0.1:0")], + 1, + LONG_TIMEOUT, + ); + let addrs = tcp_addrs(&api); + let (held, other) = (addrs[0], addrs[1]); + + let held_open = serve( + &mut api, + std::thread::spawn(move || { + let mut stream = connect(held); + write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\n\r\n").unwrap(); + let mut response = Vec::new(); + let mut chunk = [0u8; 1024]; + while !response.windows(4).any(|w| w == b"\r\n\r\n") { + let n = stream.read(&mut chunk).unwrap(); + assert!(n > 0, "server closed the held connection"); + response.extend_from_slice(&chunk[..n]); + } + stream + }), + "first listener's client took the only slot", + ); + + assert_eq!(api.connections.len(), 1); + assert!( + api.connections.keys().all(|token| token.0 >= 2), + "connection tokens must clear the listener range: {:?}", + api.connections.keys().collect::>() + ); + + let denied = serve( + &mut api, + std::thread::spawn(move || { + let mut stream = connect(other); + let _ = write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\n\r\n"); + let mut chunk = [0u8; 1024]; + stream.read(&mut chunk) + }), + "second listener's client refused at the cap", + ); + assert!( + !matches!(denied, Ok(n) if n > 0), + "a slot held on one listener must refuse the other: {denied:?}" + ); + + drop(held_open); + pump_until(&mut api, "closed connection reaped", |api| api.connections.is_empty()); + + let response = serve( + &mut api, + std::thread::spawn(move || get_identity(connect(other))), + "second listener served once the slot freed", + ); + assert_identity_ok(&response); + } + #[test] fn connection_cap_drops_excess_then_recovers() { let mut api = api_with(1, LONG_TIMEOUT); diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index db0338ce..bd39ddd0 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -232,8 +232,10 @@ fn main() -> Result<(), Box> { !config.disable_weak_subjectivity_check(), state, ); + let beacon_api_binds = + config.beacon_api_bind().iter().map(String::as_str).map(Bind::parse).collect::>(); let beacon_api = BeaconApi::new( - &Bind::parse(config.beacon_api_bind()), + &beacon_api_binds, config.beacon_api_max_connections(), config.beacon_api_idle_timeout(), &keypair, @@ -331,10 +333,10 @@ fn load_config() -> Result { if args.iter().any(|a| a == "--unsafe-no-el") { config = config.with_unsafe_no_el(true); } - if let Some(bind) = + if let Some(binds) = args.iter().position(|a| a == "--beacon-api-bind").and_then(|i| args.get(i + 1)) { - config = config.with_beacon_api_bind(bind.clone()); + config = config.with_beacon_api_bind(comma_separated(binds)); } tracing::info!("loaded config: {config:#?}"); @@ -342,6 +344,13 @@ fn load_config() -> Result { Ok(config) } +/// List form for CLI flags whose config counterpart is a TOML array. A comma +/// is neither valid in a `SocketAddr` nor sane in a socket path, so it can +/// never be part of one value. +fn comma_separated(value: &str) -> Vec { + value.split(',').map(str::to_owned).collect() +} + fn load_checkpoint(config: &Config) -> Result<(Vec, Vec), std::io::Error> { let chain_config = config.chain_config(); match &chain_config.checkpoint_file { @@ -376,3 +385,24 @@ fn load_checkpoint(config: &Config) -> Result<(Vec, Vec), std::io::Error }, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn beacon_api_bind_flag_takes_one_value_or_a_comma_separated_list() { + assert_eq!(comma_separated("0.0.0.0:5051"), ["0.0.0.0:5051"]); + + let binds = comma_separated("0.0.0.0:5051,[::1]:5052,/run/silver/beacon.sock") + .iter() + .map(String::as_str) + .map(Bind::parse) + .collect::>(); + assert_eq!(binds, [ + Bind::Tcp("0.0.0.0:5051".parse().unwrap()), + Bind::Tcp("[::1]:5052".parse().unwrap()), + Bind::Unix("/run/silver/beacon.sock".into()), + ]); + } +} diff --git a/crates/client_server/tests/tile.rs b/crates/client_server/tests/tile.rs index efaca5bf..ac2cf5bc 100644 --- a/crates/client_server/tests/tile.rs +++ b/crates/client_server/tests/tile.rs @@ -30,7 +30,7 @@ fn beacon(bind: &Bind) -> BeaconApi { let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); let local_enr = Enr::empty(keypair.secret_key()).unwrap(); BeaconApi::new( - bind, + std::slice::from_ref(bind), 64, Duration::from_secs(75), &keypair, @@ -96,7 +96,7 @@ fn serves_identity_over_tcp() { }; let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); - let Bind::Tcp(addr) = tile.beacon.local_addr() else { panic!("expected tcp bind") }; + let [Bind::Tcp(addr)] = tile.beacon.local_addrs()[..] else { panic!("expected one tcp bind") }; assert_ne!(addr.port(), 0, "port-0 bind must resolve to an ephemeral port"); let client = std::thread::spawn(move || { @@ -125,7 +125,7 @@ fn serves_identity_over_uds() { }; let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); - assert_eq!(tile.beacon.local_addr(), Bind::Unix(socket.clone())); + assert_eq!(tile.beacon.local_addrs(), [Bind::Unix(socket.clone())]); let client = std::thread::spawn(move || { let stream = UnixStream::connect(&socket).unwrap(); @@ -190,7 +190,7 @@ fn serves_beacon_api_while_engine_call_in_flight() { // The FCU (and the startup healthcheck trio) sit unanswered on the EL; // the API request must be served anyway. - let Bind::Tcp(addr) = tile.beacon.local_addr() else { panic!("expected tcp bind") }; + let [Bind::Tcp(addr)] = tile.beacon.local_addrs()[..] else { panic!("expected one tcp bind") }; let client = std::thread::spawn(move || { let stream = TcpStream::connect(addr).unwrap(); stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 700c28a6..1f7a4ac1 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -36,8 +36,8 @@ const fn default_u64() -> u64 { V } -fn default_beacon_api_bind() -> String { - "0.0.0.0:5051".into() +fn default_beacon_api_bind() -> Vec { + vec!["0.0.0.0:5051".into()] } fn default_data_dir() -> String { @@ -129,9 +129,10 @@ pub struct Config { data_storage_dir: String, #[serde(default)] engine_config: EngineConfig, - /// TCP `addr:port` or a unix socket path. + /// Each entry is a TCP `addr:port` or a unix socket path; the API serves + /// all of them at once. #[serde(default = "default_beacon_api_bind")] - beacon_api_bind: String, + beacon_api_bind: Vec, #[serde(default = "default_usize::<64>")] beacon_api_max_connections: usize, /// Refreshed by any byte read or written, so a slow but progressing @@ -227,8 +228,8 @@ impl Config { self } - pub fn with_beacon_api_bind(mut self, bind: String) -> Self { - self.beacon_api_bind = bind; + pub fn with_beacon_api_bind(mut self, binds: Vec) -> Self { + self.beacon_api_bind = binds; self } @@ -370,7 +371,7 @@ impl Config { self.engine_config.clone() } - pub fn beacon_api_bind(&self) -> &str { + pub fn beacon_api_bind(&self) -> &[String] { &self.beacon_api_bind } @@ -412,17 +413,33 @@ mod tests { assert_eq!(cfg.next_fork_epoch, u64::MAX); assert_eq!(cfg.supported_protocols().unwrap().len(), 11); assert_eq!(cfg.gossip_topics().unwrap().len(), 8); - assert_eq!(cfg.beacon_api_bind(), "0.0.0.0:5051"); + assert_eq!(cfg.beacon_api_bind(), ["0.0.0.0:5051"]); assert_eq!(cfg.beacon_api_max_connections(), 64); assert_eq!(cfg.beacon_api_idle_timeout(), Duration::from_secs(75)); } + #[test] + fn beacon_api_bind_toml_array_keeps_every_entry() { + let toml_str = r#" + secret_key = "1111111111111111111111111111111111111111111111111111111111111111" + fork_digest = "8c9f62fe" + next_fork_version = "06000000" + beacon_api_bind = ["0.0.0.0:5051", "127.0.0.1:5052", "/run/silver/beacon.sock"] + "#; + let cfg: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(cfg.beacon_api_bind(), [ + "0.0.0.0:5051", + "127.0.0.1:5052", + "/run/silver/beacon.sock" + ]); + } + #[test] fn builder_sets_beacon_api_bind() { let cfg = Config::new([1u8; 32], [0u8; 4], [0u8; 4], 0); - assert_eq!(cfg.beacon_api_bind(), "0.0.0.0:5051"); - let cfg = cfg.with_beacon_api_bind("/run/beacon.sock".into()); - assert_eq!(cfg.beacon_api_bind(), "/run/beacon.sock"); + assert_eq!(cfg.beacon_api_bind(), ["0.0.0.0:5051"]); + let cfg = cfg.with_beacon_api_bind(vec!["/run/beacon.sock".into()]); + assert_eq!(cfg.beacon_api_bind(), ["/run/beacon.sock"]); } #[test] diff --git a/crates/httpcore/src/stream.rs b/crates/httpcore/src/stream.rs index e63eda53..a87a843c 100644 --- a/crates/httpcore/src/stream.rs +++ b/crates/httpcore/src/stream.rs @@ -20,7 +20,14 @@ impl Bind { pub fn parse(text: &str) -> Self { match text.parse() { Ok(addr) => Self::Tcp(addr), - Err(_) => Self::Unix(PathBuf::from(text)), + Err(_) => { + assert!( + !text.contains(':'), + "bind {text:?}: not a valid socket address (hostnames are not resolved), \ + and a unix socket path containing ':' is almost certainly a typo" + ); + Self::Unix(PathBuf::from(text)) + } } } } @@ -205,9 +212,18 @@ mod tests { fn parse_non_addr_is_unix_path() { assert_eq!(Bind::parse("/run/beacon.sock"), Bind::Unix("/run/beacon.sock".into())); assert_eq!(Bind::parse("beacon.sock"), Bind::Unix("beacon.sock".into())); - // Hostnames don't parse as SocketAddr (no resolution here), so they - // fall through to a path. - assert_eq!(Bind::parse("localhost:5051"), Bind::Unix("localhost:5051".into())); + } + + #[test] + #[should_panic(expected = "not a valid socket address")] + fn parse_rejects_hostname_with_port() { + Bind::parse("localhost:5051"); + } + + #[test] + #[should_panic(expected = "not a valid socket address")] + fn parse_rejects_typoed_socket_addr() { + Bind::parse("127.0.0.1:505x"); } #[test] From 828ebdb2ef2499cec7a0bbb3db718b34b28803cf Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 18 Aug 2026 11:22:05 +0100 Subject: [PATCH 11/16] Refresh ADRs 0002 and 0004 for team decisions of 2026-08-18 ADR-0002 records the QUIC/HTTP-3 rejection: QUIC mandates TLS 1.3 (RFC 9001), which the ADR already declares a non-goal, and no validator client speaks HTTP/3 -- noted so the alternative is not re-litigated. ADR-0004's SSE paragraph reflected a deferral the team has since reversed: /eth/v1/events will be served, in-process, as the single sanctioned exception to the materialized-response model, fed from a spine events queue. The 404 shipped today is interim behavior; implementation follows the initial endpoint surface, and the SSE design round will amend the ADR with the concrete mechanism. Both ADRs are still status: proposed, so they are amended in place rather than superseded. Assisted-by: Claude:claude-fable-5 --- docs/adr/0002-hand-rolled-http.md | 5 ++++- docs/adr/0004-sync-materialized-api.md | 25 ++++++++++++++++--------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/docs/adr/0002-hand-rolled-http.md b/docs/adr/0002-hand-rolled-http.md index a3593756..10a9a49e 100644 --- a/docs/adr/0002-hand-rolled-http.md +++ b/docs/adr/0002-hand-rolled-http.md @@ -15,7 +15,10 @@ existed twice (engine `http.rs` and the beacon_api prototype, plus a dead Transports are a closed set we control, so they are an enum (`Tcp | Uds`), not a trait. Unix sockets are supported on both sides: the beacon_api server bind and the execution endpoint. TLS is a non-goal — all -API connections run over trusted local LAN or VPN. Auth is protocol-layer, +API connections run over trusted local LAN or VPN. That transitively rules +out QUIC/HTTP-3 for API surfaces (considered and rejected 2026-08-18): QUIC +mandates TLS 1.3 (RFC 9001), and no validator client speaks HTTP/3, so +there would be no consumers even if the TLS stance changed. Auth is protocol-layer, not transport-layer: `engine_api` owns the JWT Authorization header; UDS relies on socket path permissions, and JWT-over-UDS can be added later as an `engine_api` config flag without touching the transport layer. diff --git a/docs/adr/0004-sync-materialized-api.md b/docs/adr/0004-sync-materialized-api.md index 63e1f0c1..d8be9652 100644 --- a/docs/adr/0004-sync-materialized-api.md +++ b/docs/adr/0004-sync-materialized-api.md @@ -11,13 +11,20 @@ non-blocking (`poll(Duration::ZERO)`), so serving and engine traffic interleave per readiness event: a slow API consumer never stalls engine calls, and vice versa. -This holds for the whole surface v1 targets: verified against the -beacon-APIs spec and five validator clients (see +This holds for every request/response endpoint in the targeted surface: +verified against the beacon-APIs spec and five validator clients (see `.local/beacon-api-vc-surface.md`, untracked), nothing a validator client -requires streams or long-polls except the optional `/eth/v1/events` SSE -stream, which every surveyed client can replace with polling. v1 answers it -with a clean 404 and tolerates client reconnect retries. If subscriptions -are ever wanted, they may be served out-of-process (e.g. a circular-buffer -export read by a separate serving process) rather than by adding streaming -here. Endpoints whose response cannot be materialized in a bounded buffer -are out of scope by construction; revisit this ADR before accepting one. +requires streams or long-polls except the `/eth/v1/events` SSE stream. + +Amended 2026-08-18: SSE is in scope — validator clients will not be asked +to poll. It will be served in-process as an explicit subscription-mode +carve-out on the server connection machine (a long-lived, mostly idle +connection with small appended writes — deliberately outside this ADR's +bounded-buffer model), fed from a spine events queue produced by the +beacon-state tile. Implementation is scheduled after the initial endpoint +surface; the 404 served for `/eth/v1/events` today is interim behavior, +not the decision, and the previously-floated out-of-process serving option +is no longer the plan of record. Everything else stays materialized in a +bounded buffer by construction — the SSE carve-out is the single +sanctioned exception, and its design round amends this ADR with the +concrete mechanism. From bbfee39445a6e82ccdb94cd00612768dd150a65b Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 18 Aug 2026 13:38:22 +0100 Subject: [PATCH 12/16] httpcore: negotiation headers, query decoding, 400 on provable garbage First M2 infrastructure commit (I1). ParsedRequest exposes the three request headers content negotiation needs -- Accept, Content-Type, Eth-Consensus-Version -- as borrowed fields (no general header map). A Query iterator percent-decodes key/value pairs, zero-alloc when no escape is present; '+' stays literal (RFC 3986, not form encoding), and malformed escapes pass through rather than panic. The parse path now distinguishes knowledge from ambiguity (CL-115's framing): definitively malformed input -- httparse errors including more than 64 headers, an unparseable or overflowing Content-Length -- gets an immediate 400-and-close instead of silently stalling until the idle sweep, while genuinely partial input still waits for more bytes. Verified against httparse 1.10.1 at every truncation offset that a request within limits can never be misclassified mid-stream. Side effect: an HTTP/2 preface now draws a 400 instead of a silent stall. Assisted-by: Claude:claude-fable-5 --- crates/beacon_api/src/router.rs | 15 +- crates/beacon_api/src/routes.rs | 3 + crates/beacon_api/src/server.rs | 8 +- crates/engine_api/src/pool.rs | 3 - crates/httpcore/src/lib.rs | 2 + crates/httpcore/src/query.rs | 141 +++++++++++++++++ crates/httpcore/src/server.rs | 273 +++++++++++++++++++++++++++----- 7 files changed, 399 insertions(+), 46 deletions(-) create mode 100644 crates/httpcore/src/query.rs diff --git a/crates/beacon_api/src/router.rs b/crates/beacon_api/src/router.rs index f0d258df..fc25b584 100644 --- a/crates/beacon_api/src/router.rs +++ b/crates/beacon_api/src/router.rs @@ -165,7 +165,17 @@ mod tests { use crate::routes::preboot_ctx; fn request<'a>(method: &'a str, path: &'a str) -> ParsedRequest<'a> { - ParsedRequest { method, path, query: "", body: b"", version: 1, keep_alive: true } + ParsedRequest { + method, + path, + query: "", + body: b"", + accept: None, + content_type: None, + eth_consensus_version: None, + version: 1, + keep_alive: true, + } } fn dispatch(router: &Router, method: &str, path: &str) -> Vec { @@ -252,6 +262,9 @@ mod tests { path: "/submit", query: "k=v", body: b"payload", + accept: None, + content_type: None, + eth_consensus_version: None, version: 1, keep_alive: true, }; diff --git a/crates/beacon_api/src/routes.rs b/crates/beacon_api/src/routes.rs index 66aad9ee..758ee28a 100644 --- a/crates/beacon_api/src/routes.rs +++ b/crates/beacon_api/src/routes.rs @@ -87,6 +87,9 @@ mod tests { path, query: "", body: b"", + accept: None, + content_type: None, + eth_consensus_version: None, version: 1, keep_alive: true, }; diff --git a/crates/beacon_api/src/server.rs b/crates/beacon_api/src/server.rs index eb07d8d1..443b216d 100644 --- a/crates/beacon_api/src/server.rs +++ b/crates/beacon_api/src/server.rs @@ -562,9 +562,9 @@ mod tests { assert!(response.starts_with(b"HTTP/1.1 200 OK\r\n")); } - /// CL-115: a request that never completes holds its slot forever. Partial - /// and malformed input are treated alike — neither dispatches, so both are - /// reaped by the same idle deadline. + /// A partial request that never completes holds its slot until the idle + /// deadline reaps it. Definitively malformed input gets 400-and-close + /// at parse time. #[test] fn partial_request_is_reaped_after_the_idle_deadline() { let mut api = api_with(64, Duration::from_millis(200)); @@ -636,7 +636,7 @@ mod tests { assert_eq!(api.connections.len(), 1, "an active connection must survive the sweep"); } - /// The CL-115 exhaustion scenario end to end: a hung client owns the only + /// Connection exhaustion scenario end to end: a hung client owns the only /// slot, so every other client is refused until the sweep frees it. #[test] fn idle_sweep_frees_a_slot_held_at_the_cap() { diff --git a/crates/engine_api/src/pool.rs b/crates/engine_api/src/pool.rs index 9691d3f7..9c03a400 100644 --- a/crates/engine_api/src/pool.rs +++ b/crates/engine_api/src/pool.rs @@ -477,9 +477,6 @@ mod tests { assert_eq!(failure.unwrap(), block_root); } - /// CL-114: an EL that accepts a request and never answers used to wedge the - /// connection — and with `max_connections` reached, the gated spine intake - /// behind it — for the lifetime of the process. #[test] fn unanswered_request_times_out_and_frees_connection() { let dir = TempDir::new().unwrap(); diff --git a/crates/httpcore/src/lib.rs b/crates/httpcore/src/lib.rs index 7efa53d4..54a14417 100644 --- a/crates/httpcore/src/lib.rs +++ b/crates/httpcore/src/lib.rs @@ -1,7 +1,9 @@ mod client; +mod query; mod server; mod stream; pub use client::{ClientConnection, frame_request}; +pub use query::Query; pub use server::{AfterResponse, ParsedRequest, ServerConnection, frame_response}; pub use stream::{Bind, Listener, Stream}; diff --git a/crates/httpcore/src/query.rs b/crates/httpcore/src/query.rs new file mode 100644 index 00000000..ade6d952 --- /dev/null +++ b/crates/httpcore/src/query.rs @@ -0,0 +1,141 @@ +use std::borrow::Cow; + +pub struct Query<'a> { + rest: &'a str, +} + +impl<'a> Query<'a> { + pub fn new(raw: &'a str) -> Self { + Self { rest: raw } + } +} + +impl<'a> Iterator for Query<'a> { + type Item = (Cow<'a, str>, Cow<'a, str>); + + fn next(&mut self) -> Option { + while !self.rest.is_empty() { + let (pair, rest) = self.rest.split_once('&').unwrap_or((self.rest, "")); + self.rest = rest; + if pair.is_empty() { + continue; + } + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + return Some((percent_decode(key), percent_decode(value))); + } + None + } +} + +// `+` stays literal: the `+`-means-space rule is HTML form encoding, and no +// validator client sends a form body here — beacon-API query values are hex +// strings, validator statuses and graffiti, escaped per RFC 3986. +fn percent_decode(raw: &str) -> Cow<'_, str> { + let Some(first_escape) = raw.find('%') else { + return Cow::Borrowed(raw); + }; + let bytes = raw.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + out.extend_from_slice(&bytes[..first_escape]); + + let mut i = first_escape; + while i < bytes.len() { + match decode_escape(&bytes[i..]) { + Some(byte) => { + out.push(byte); + i += 3; + } + None => { + out.push(bytes[i]); + i += 1; + } + } + } + Cow::Owned(String::from_utf8_lossy(&out).into_owned()) +} + +fn decode_escape(bytes: &[u8]) -> Option { + let &[b'%', high, low, ..] = bytes else { return None }; + let digit = |byte: u8| (byte as char).to_digit(16); + Some((digit(high)? * 16 + digit(low)?) as u8) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pairs(raw: &str) -> Vec<(String, String)> { + Query::new(raw).map(|(k, v)| (k.into_owned(), v.into_owned())).collect() + } + + #[test] + fn plain_pairs_split_on_ampersand_and_equals() { + assert_eq!(pairs("id=1&status=active_ongoing"), [ + ("id".to_string(), "1".to_string()), + ("status".to_string(), "active_ongoing".to_string()), + ]); + } + + #[test] + fn escape_free_pairs_borrow_the_raw_query() { + let (key, value) = Query::new("status=active_ongoing").next().unwrap(); + assert!(matches!(key, Cow::Borrowed(_))); + assert!(matches!(value, Cow::Borrowed(_))); + } + + #[test] + fn percent_escapes_decoded_in_key_and_value() { + assert_eq!(pairs("a%20b=c%2Fd%20e"), [("a b".to_string(), "c/d e".to_string())]); + } + + #[test] + fn lowercase_hex_escape_decoded() { + assert_eq!(pairs("g=%2f%7e"), [("g".to_string(), "/~".to_string())]); + } + + #[test] + fn plus_stays_literal_rather_than_becoming_a_space() { + assert_eq!(pairs("graffiti=a+b"), [("graffiti".to_string(), "a+b".to_string())]); + } + + #[test] + fn malformed_escape_kept_literally() { + assert_eq!(pairs("a=%zz&b=%4&c=100%&d=%"), [ + ("a".to_string(), "%zz".to_string()), + ("b".to_string(), "%4".to_string()), + ("c".to_string(), "100%".to_string()), + ("d".to_string(), "%".to_string()), + ]); + } + + #[test] + fn escape_decoding_to_invalid_utf8_does_not_panic() { + assert_eq!(pairs("a=%ff%fe"), [("a".to_string(), "\u{fffd}\u{fffd}".to_string())]); + } + + #[test] + fn empty_query_yields_nothing() { + assert!(pairs("").is_empty()); + } + + #[test] + fn empty_segments_skipped() { + assert_eq!(pairs("&&a=1&&"), [("a".to_string(), "1".to_string())]); + } + + #[test] + fn key_without_equals_yields_empty_value() { + assert_eq!(pairs("skip_randao_verification&slot=7"), [ + ("skip_randao_verification".to_string(), String::new()), + ("slot".to_string(), "7".to_string()), + ]); + } + + #[test] + fn repeated_key_yields_every_occurrence() { + assert_eq!(pairs("id=1&id=2"), [ + ("id".to_string(), "1".to_string()), + ("id".to_string(), "2".to_string()), + ]); + } +} diff --git a/crates/httpcore/src/server.rs b/crates/httpcore/src/server.rs index 540b0a8c..9177493c 100644 --- a/crates/httpcore/src/server.rs +++ b/crates/httpcore/src/server.rs @@ -11,46 +11,84 @@ pub struct ParsedRequest<'a> { pub path: &'a str, pub query: &'a str, pub body: &'a [u8], + pub accept: Option<&'a str>, + pub content_type: Option<&'a str>, + pub eth_consensus_version: Option<&'a str>, pub version: u8, pub keep_alive: bool, } +/// `Incomplete` means "no verdict yet, feed me more bytes"; `Malformed` means +/// the bytes can never become a request, so no amount of waiting helps. +enum ParseOutcome<'a> { + Complete { consumed: usize, request: ParsedRequest<'a> }, + Incomplete, + Malformed, +} + impl<'a> ParsedRequest<'a> { - fn parse(buf: &'a [u8]) -> Option<(usize, Self)> { + fn parse(buf: &'a [u8]) -> ParseOutcome<'a> { let mut headers = [httparse::EMPTY_HEADER; 64]; let mut req = httparse::Request::new(&mut headers); let headers_end = match req.parse(buf) { Ok(httparse::Status::Complete(n)) => n, - _ => return None, + Ok(httparse::Status::Partial) => return ParseOutcome::Incomplete, + Err(e) => { + tracing::warn!("unparseable request: {e}"); + return ParseOutcome::Malformed; + } + }; + let (Some(method), Some(raw_path), Some(version)) = (req.method, req.path, req.version) + else { + return ParseOutcome::Malformed; }; - let method = req.method?; - let raw_path = req.path?; let (path, query) = raw_path.split_once('?').unwrap_or((raw_path, "")); - let version = req.version?; let keep_alive = version == 1 && !headers.iter().any(|h| { h.name.eq_ignore_ascii_case("connection") && h.value.eq_ignore_ascii_case(b"close") }); - let content_length: usize = - match headers.iter().find(|h| h.name.eq_ignore_ascii_case("content-length")) { - None => 0, - Some(h) => std::str::from_utf8(h.value).ok().and_then(|v| v.trim().parse().ok())?, - }; - let total = headers_end + content_length; + + let header = |name: &str| { + headers.iter().find(|h| h.name.eq_ignore_ascii_case(name)).map(|h| h.value) + }; + let content_length = match header("content-length") { + None => 0, + Some(value) => match trimmed_utf8(value).and_then(|v| v.parse().ok()) { + Some(length) => length, + None => { + tracing::warn!("unusable Content-Length: {:?}", String::from_utf8_lossy(value)); + return ParseOutcome::Malformed; + } + }, + }; + let Some(total) = headers_end.checked_add(content_length) else { + return ParseOutcome::Malformed; + }; if buf.len() < total { - return None; + return ParseOutcome::Incomplete; + } + + ParseOutcome::Complete { + consumed: total, + request: Self { + method, + path, + query, + body: &buf[headers_end..total], + accept: header("accept").and_then(trimmed_utf8), + content_type: header("content-type").and_then(trimmed_utf8), + eth_consensus_version: header("eth-consensus-version").and_then(trimmed_utf8), + version, + keep_alive, + }, } - Some((total, Self { - method, - path, - query, - body: &buf[headers_end..total], - version, - keep_alive, - })) } } +fn trimmed_utf8(value: &[u8]) -> Option<&str> { + std::str::from_utf8(value).ok().map(str::trim) +} + #[derive(Debug, PartialEq)] #[must_use] pub enum AfterResponse { @@ -104,11 +142,20 @@ impl ServerConnection { } pub fn dispatch, &mut Vec)>(&mut self, handler: &F) -> bool { - let Some((consumed, req)) = - ParsedRequest::parse(&self.read_buf[self.read_pos..self.read_end]) - else { - return false; - }; + let (consumed, req) = + match ParsedRequest::parse(&self.read_buf[self.read_pos..self.read_end]) { + ParseOutcome::Complete { consumed, request } => (consumed, request), + ParseOutcome::Incomplete => return false, + // Framing is lost, so there is nothing left to resynchronise + // on: answer, drop the whole buffer and let the caller close. + ParseOutcome::Malformed => { + self.keep_alive = false; + frame_response(&mut self.write_buf, "400 Bad Request", None, b""); + self.read_pos = 0; + self.read_end = 0; + return true; + } + }; if req.version != 1 { tracing::warn!("rejecting HTTP/1.0 request"); self.keep_alive = false; @@ -184,6 +231,16 @@ mod tests { format!("GET {path} {version}\r\nHost: localhost\r\n\r\n").into_bytes() } + /// One header more than `parse`'s fixed slot array holds. + fn overlong_header_req() -> Vec { + let mut req = b"GET /metrics HTTP/1.1\r\n".to_vec(); + for i in 0..65 { + req.extend_from_slice(format!("X-Pad-{i}: v\r\n").as_bytes()); + } + req.extend_from_slice(b"\r\n"); + req + } + fn feed(conn: &mut ServerConnection, bytes: &[u8]) { let space = conn.read_space().unwrap(); space[..bytes.len()].copy_from_slice(bytes); @@ -226,10 +283,31 @@ mod tests { frame_response(out, "200 OK", None, req.path.as_bytes()); } + fn parsed(buf: &[u8]) -> (usize, ParsedRequest<'_>) { + match ParsedRequest::parse(buf) { + ParseOutcome::Complete { consumed, request } => (consumed, request), + ParseOutcome::Incomplete => panic!("expected a complete request, got Incomplete"), + ParseOutcome::Malformed => panic!("expected a complete request, got Malformed"), + } + } + + fn reject_and_close(request: &[u8]) { + let mut conn = ServerConnection::new(); + feed(&mut conn, request); + + assert!(conn.dispatch(&|_, _: &mut Vec| { + panic!("malformed request must not reach the handler") + })); + assert_eq!(conn.pending_write(), b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n"); + + drain(&mut conn); + assert_eq!(conn.after_response(&echo_path), AfterResponse::Close); + } + #[test] fn parse_http11_defaults_keep_alive() { let req = get_req("/eth/v1/node/identity", "HTTP/1.1"); - let (_, r) = ParsedRequest::parse(&req).unwrap(); + let (_, r) = parsed(&req); assert_eq!(r.path, "/eth/v1/node/identity"); assert!(r.keep_alive); } @@ -237,7 +315,7 @@ mod tests { #[test] fn parse_http11_connection_close() { let req = b"GET /metrics HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; - let (_, r) = ParsedRequest::parse(req).unwrap(); + let (_, r) = parsed(req); assert_eq!(r.path, "/metrics"); assert!(!r.keep_alive); } @@ -245,19 +323,20 @@ mod tests { #[test] fn parse_http10_defaults_close() { let req = get_req("/", "HTTP/1.0"); - let (_, r) = ParsedRequest::parse(&req).unwrap(); + let (_, r) = parsed(&req); assert!(!r.keep_alive); } #[test] - fn parse_partial_returns_none() { - assert!(ParsedRequest::parse(b"GET /eth/v1/node/identity HTTP/1.1\r\n").is_none()); + fn parse_partial_is_incomplete() { + let outcome = ParsedRequest::parse(b"GET /eth/v1/node/identity HTTP/1.1\r\n"); + assert!(matches!(outcome, ParseOutcome::Incomplete)); } #[test] fn parse_query_string_split() { let req = get_req("/eth/v1/beacon/states/head/validators?status=active", "HTTP/1.1"); - let (_, r) = ParsedRequest::parse(&req).unwrap(); + let (_, r) = parsed(&req); assert_eq!(r.path, "/eth/v1/beacon/states/head/validators"); assert_eq!(r.query, "status=active"); } @@ -270,10 +349,9 @@ mod tests { body.len() ); let mut buf = req.into_bytes(); - // incomplete — body not yet arrived - assert!(ParsedRequest::parse(&buf).is_none()); + assert!(matches!(ParsedRequest::parse(&buf), ParseOutcome::Incomplete), "body not arrived"); buf.extend_from_slice(body); - let (consumed, r) = ParsedRequest::parse(&buf).unwrap(); + let (consumed, r) = parsed(&buf); assert_eq!(r.method, "POST"); assert_eq!(r.body, body.as_ref()); assert_eq!(consumed, buf.len()); @@ -285,17 +363,136 @@ mod tests { let req2 = b"GET /eth/v1/node/identity HTTP/1.1\r\nHost: localhost\r\n\r\n"; let mut buf = req1.to_vec(); buf.extend_from_slice(req2); - let (consumed, r) = ParsedRequest::parse(&buf).unwrap(); + let (consumed, r) = parsed(&buf); assert_eq!(r.path, "/metrics"); assert_eq!(consumed, req1.len()); - let (_, r2) = ParsedRequest::parse(&buf[consumed..]).unwrap(); + let (_, r2) = parsed(&buf[consumed..]); assert_eq!(r2.path, "/eth/v1/node/identity"); } #[test] - fn parse_invalid_content_length_returns_none() { + fn parse_negotiation_headers() { + let req = b"POST /eth/v2/beacon/blocks HTTP/1.1\r\nHost: x\r\nAccept: application/octet-stream;q=1.0,application/json;q=0.9\r\nContent-Type: application/octet-stream\r\nEth-Consensus-Version: fulu\r\n\r\n"; + let (_, r) = parsed(req); + assert_eq!(r.accept, Some("application/octet-stream;q=1.0,application/json;q=0.9")); + assert_eq!(r.content_type, Some("application/octet-stream")); + assert_eq!(r.eth_consensus_version, Some("fulu")); + } + + #[test] + fn parse_negotiation_headers_absent_are_none() { + let req = get_req("/metrics", "HTTP/1.1"); + let (_, r) = parsed(&req); + assert_eq!(r.accept, None); + assert_eq!(r.content_type, None); + assert_eq!(r.eth_consensus_version, None); + } + + #[test] + fn parse_negotiation_header_names_are_case_insensitive() { + let req = b"POST /p HTTP/1.1\r\nACCEPT: application/json\r\ncontent-type: application/json\r\neTh-CoNsEnSuS-vErSiOn: gloas\r\n\r\n"; + let (_, r) = parsed(req); + assert_eq!(r.accept, Some("application/json")); + assert_eq!(r.content_type, Some("application/json")); + assert_eq!(r.eth_consensus_version, Some("gloas")); + } + + #[test] + fn parse_unparseable_request_line_is_malformed() { + assert!(matches!( + ParsedRequest::parse(b"NOT A VALID REQUEST\r\n\r\n"), + ParseOutcome::Malformed + )); + } + + #[test] + fn parse_more_headers_than_fit_is_malformed() { + assert!(matches!(ParsedRequest::parse(&overlong_header_req()), ParseOutcome::Malformed)); + } + + #[test] + fn parse_invalid_content_length_is_malformed() { let req = b"POST /foo HTTP/1.1\r\nHost: localhost\r\nContent-Length: abc\r\n\r\n"; - assert!(ParsedRequest::parse(req).is_none()); + assert!(matches!(ParsedRequest::parse(req), ParseOutcome::Malformed)); + } + + #[test] + fn parse_content_length_beyond_usize_is_malformed() { + let req = b"POST /foo HTTP/1.1\r\nHost: localhost\r\nContent-Length: 99999999999999999999\r\n\r\n"; + assert!(matches!(ParsedRequest::parse(req), ParseOutcome::Malformed)); + } + + #[test] + fn parse_content_length_overflowing_the_header_end_is_malformed() { + let req = format!( + "POST /foo HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\n\r\n", + usize::MAX + ); + assert!(matches!(ParsedRequest::parse(req.as_bytes()), ParseOutcome::Malformed)); + } + + #[test] + fn dispatch_unparseable_request_line_writes_400_then_closes() { + reject_and_close(b"NOT A VALID REQUEST\r\n\r\n"); + } + + #[test] + fn dispatch_more_headers_than_fit_writes_400_then_closes() { + reject_and_close(&overlong_header_req()); + } + + #[test] + fn dispatch_invalid_content_length_writes_400_then_closes() { + reject_and_close(b"POST /foo HTTP/1.1\r\nHost: x\r\nContent-Length: abc\r\n\r\n"); + } + + #[test] + fn dispatch_content_length_beyond_usize_writes_400_then_closes() { + reject_and_close( + b"POST /foo HTTP/1.1\r\nHost: x\r\nContent-Length: 99999999999999999999\r\n\r\n", + ); + } + + #[test] + fn dispatch_partial_request_writes_nothing() { + let mut conn = ServerConnection::new(); + feed(&mut conn, b"GET /eth/v1/node/identity HTTP/1.1\r\nHost: local"); + + assert!( + !conn.dispatch(&|_, _: &mut Vec| panic!("incomplete request must not dispatch")) + ); + assert!(conn.pending_write().is_empty(), "an unfinished request is not a bad one"); + + feed(&mut conn, b"host\r\n\r\n"); + assert!(conn.dispatch(&echo_path)); + assert_eq!( + conn.pending_write(), + b"HTTP/1.1 200 OK\r\nContent-Length: 21\r\n\r\n/eth/v1/node/identity" + ); + } + + #[test] + fn dispatch_partial_body_writes_nothing() { + let mut conn = ServerConnection::new(); + feed(&mut conn, b"POST /p HTTP/1.1\r\nHost: x\r\nContent-Length: 8\r\n\r\nhalf"); + + assert!(!conn.dispatch(&|_, _: &mut Vec| panic!("incomplete body must not dispatch"))); + assert!(conn.pending_write().is_empty()); + } + + #[test] + fn pipelined_garbage_after_valid_request_answers_first_then_rejects() { + let mut conn = ServerConnection::new(); + feed(&mut conn, b"GET /first HTTP/1.1\r\nHost: x\r\n\r\nNOT A VALID REQUEST\r\n\r\n"); + + assert!(conn.dispatch(&echo_path)); + assert_eq!(drain(&mut conn), b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\n/first"); + + assert_eq!(conn.after_response(&echo_path), AfterResponse::ResponsePending); + assert_eq!(conn.pending_write(), b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n"); + + drain(&mut conn); + assert_eq!(conn.after_response(&echo_path), AfterResponse::Close); } #[test] From 89d6bcd2c47f4fa6cee7bc8abca95aea479c738a Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 18 Aug 2026 14:03:10 +0100 Subject: [PATCH 13/16] Widen Response: any status, extra headers, indexed errors M2 infrastructure (I2), pure mechanics. Response::send frames any status via a code -> status-line map (zero-alloc for mapped codes; unmapped codes frame a bare numeric status line, legal per RFC 9112 s4.1 where the reason phrase is optional, with a warn preserving the diagnostic the old unreachable! carried). frame_response_with_headers emits extra response headers in caller order; frame_response delegates to it. Response::indexed_error writes the beacon-api IndexedErrorMessage shape for per-item publish failures; an empty failures array is emitted as-is (required but no minItems in the spec schema). All existing response bytes are unchanged, pinned by the pre-existing byte-exact tests. Assisted-by: Claude:claude-fable-5 --- crates/beacon_api/src/response.rs | 156 +++++++++++++++++++++++++++--- crates/httpcore/src/lib.rs | 4 +- crates/httpcore/src/server.rs | 70 ++++++++++++-- 3 files changed, 209 insertions(+), 21 deletions(-) diff --git a/crates/beacon_api/src/response.rs b/crates/beacon_api/src/response.rs index 732aa23c..1ba70055 100644 --- a/crates/beacon_api/src/response.rs +++ b/crates/beacon_api/src/response.rs @@ -1,40 +1,111 @@ -use silver_httpcore::frame_response; +use std::{borrow::Cow, fmt::Write}; + +use silver_httpcore::frame_response_with_headers; + +const JSON_CONTENT_TYPE: &str = "application/json"; pub(crate) struct Response<'a> { out: &'a mut Vec, } +/// One entry of a beacon-API `IndexedErrorMessage.failures` list; `index` is +/// the item's position in the submitted list, not a validator index. +pub(crate) struct Failure<'a> { + pub(crate) index: usize, + pub(crate) message: &'a str, +} + impl<'a> Response<'a> { pub(crate) fn new(out: &'a mut Vec) -> Self { Self { out } } pub(crate) fn json(&mut self, body: &[u8]) { - frame_response(self.out, "200 OK", Some("application/json"), body); + self.send(200, Some(JSON_CONTENT_TYPE), &[], body); } pub(crate) fn empty(&mut self, content_type: &str) { - frame_response(self.out, "200 OK", Some(content_type), b""); + self.send(200, Some(content_type), &[], b""); + } + + pub(crate) fn send( + &mut self, + code: u16, + content_type: Option<&str>, + headers: &[(&str, &str)], + body: &[u8], + ) { + let status = match status_line(code) { + Some(status) => Cow::Borrowed(status), + None => { + tracing::warn!("no reason phrase for status {code}"); + Cow::Owned(format!("{code} ")) + } + }; + frame_response_with_headers(self.out, &status, content_type, headers, body); } /// Beacon-API error shape: `{"code":,"message":"..."}`. pub(crate) fn error(&mut self, code: u16, message: &str) { - debug_assert!(!message.contains(['"', '\\']), "message goes into JSON unescaped"); - let status = match code { - 400 => "400 Bad Request", - 405 => "405 Method Not Allowed", - 503 => "503 Service Unavailable", - _ => unreachable!("unmapped error code {code}"), - }; + debug_assert!(json_safe(message), "message goes into JSON unescaped"); let body = format!("{{\"code\":{code},\"message\":\"{message}\"}}"); - frame_response(self.out, status, Some("application/json"), body.as_bytes()); + self.send(code, Some(JSON_CONTENT_TYPE), &[], body.as_bytes()); } + + /// Beacon-API `IndexedErrorMessage` shape, for requests carrying a list of + /// items of which only some failed. The schema requires `failures` but + /// sets no minimum, so an empty list stays a well-formed body. + // Live with the first endpoint that validates a submitted list item by item. + #[allow(dead_code)] + pub(crate) fn indexed_error(&mut self, code: u16, message: &str, failures: &[Failure<'_>]) { + debug_assert!(json_safe(message), "message goes into JSON unescaped"); + let mut body = format!("{{\"code\":{code},\"message\":\"{message}\",\"failures\":["); + for (position, failure) in failures.iter().enumerate() { + debug_assert!(json_safe(failure.message), "message goes into JSON unescaped"); + if position > 0 { + body.push(','); + } + write!(body, "{{\"index\":{},\"message\":\"{}\"}}", failure.index, failure.message) + .unwrap(); + } + body.push_str("]}"); + self.send(code, Some(JSON_CONTENT_TYPE), &[], body.as_bytes()); + } +} + +/// `None` for codes this API has no phrase for; those still frame, with the +/// empty reason-phrase RFC 9112 §4.1 permits (the space before it is grammar, +/// not part of the phrase). +fn status_line(code: u16) -> Option<&'static str> { + Some(match code { + 200 => "200 OK", + 202 => "202 Accepted", + 400 => "400 Bad Request", + 404 => "404 Not Found", + 405 => "405 Method Not Allowed", + 406 => "406 Not Acceptable", + 415 => "415 Unsupported Media Type", + 500 => "500 Internal Server Error", + 501 => "501 Not Implemented", + 503 => "503 Service Unavailable", + _ => return None, + }) +} + +fn json_safe(text: &str) -> bool { + !text.contains(['"', '\\']) } #[cfg(test)] mod tests { use super::*; + fn framed(write: impl FnOnce(&mut Response<'_>)) -> Vec { + let mut out = Vec::new(); + write(&mut Response::new(&mut out)); + out + } + #[test] fn error_writes_status_line_and_json_body() { let mut out = Vec::new(); @@ -62,4 +133,67 @@ mod tests { b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 0\r\n\r\n" ); } + + #[test] + fn error_frames_any_mapped_status() { + let out = framed(|resp| resp.error(415, "unsupported media type")); + let expected: &[u8] = b"HTTP/1.1 415 Unsupported Media Type\r\nContent-Type: application/json\r\nContent-Length: 47\r\n\r\n{\"code\":415,\"message\":\"unsupported media type\"}"; + assert_eq!(out, expected); + } + + #[test] + fn send_frames_a_bodyless_status() { + let out = framed(|resp| resp.send(202, None, &[], b"")); + assert_eq!(out, b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n"); + } + + #[test] + fn send_emits_extra_headers_in_order() { + let out = framed(|resp| { + resp.send( + 200, + Some("application/octet-stream"), + &[("Eth-Consensus-Version", "fulu"), ("Eth-Execution-Payload-Blinded", "false")], + b"\x01\x02\x03", + ) + }); + assert_eq!( + out, + b"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nEth-Consensus-Version: fulu\r\nEth-Execution-Payload-Blinded: false\r\nContent-Length: 3\r\n\r\n\x01\x02\x03" + ); + } + + #[test] + fn unmapped_status_frames_with_an_empty_reason_phrase() { + let out = framed(|resp| resp.send(599, None, &[], b"")); + assert_eq!(out, b"HTTP/1.1 599 \r\nContent-Length: 0\r\n\r\n"); + } + + #[test] + fn every_mapped_status_line_starts_with_its_own_code() { + for code in 100..=599u16 { + let Some(status) = status_line(code) else { continue }; + assert_eq!(status.split(' ').next(), Some(code.to_string().as_str()), "{status}"); + assert!(status.len() > 4, "reason phrase missing from {status}"); + } + } + + #[test] + fn indexed_error_lists_every_failure() { + let out = framed(|resp| { + resp.indexed_error(400, "some failures", &[ + Failure { index: 1, message: "invalid signature" }, + Failure { index: 3, message: "unknown validator" }, + ]) + }); + let expected: &[u8] = b"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: 135\r\n\r\n{\"code\":400,\"message\":\"some failures\",\"failures\":[{\"index\":1,\"message\":\"invalid signature\"},{\"index\":3,\"message\":\"unknown validator\"}]}"; + assert_eq!(out, expected); + } + + #[test] + fn indexed_error_without_failures_keeps_the_required_empty_array() { + let out = framed(|resp| resp.indexed_error(400, "some failures", &[])); + let expected: &[u8] = b"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: 52\r\n\r\n{\"code\":400,\"message\":\"some failures\",\"failures\":[]}"; + assert_eq!(out, expected); + } } diff --git a/crates/httpcore/src/lib.rs b/crates/httpcore/src/lib.rs index 54a14417..1365b477 100644 --- a/crates/httpcore/src/lib.rs +++ b/crates/httpcore/src/lib.rs @@ -5,5 +5,7 @@ mod stream; pub use client::{ClientConnection, frame_request}; pub use query::Query; -pub use server::{AfterResponse, ParsedRequest, ServerConnection, frame_response}; +pub use server::{ + AfterResponse, ParsedRequest, ServerConnection, frame_response, frame_response_with_headers, +}; pub use stream::{Bind, Listener, Stream}; diff --git a/crates/httpcore/src/server.rs b/crates/httpcore/src/server.rs index 9177493c..11abb56b 100644 --- a/crates/httpcore/src/server.rs +++ b/crates/httpcore/src/server.rs @@ -209,15 +209,26 @@ impl Default for ServerConnection { } pub fn frame_response(out: &mut Vec, status: &str, content_type: Option<&str>, body: &[u8]) { - match content_type { - Some(ct) => write!( - out, - "HTTP/1.1 {status}\r\nContent-Type: {ct}\r\nContent-Length: {}\r\n\r\n", - body.len() - ), - None => write!(out, "HTTP/1.1 {status}\r\nContent-Length: {}\r\n\r\n", body.len()), - } - .unwrap(); + frame_response_with_headers(out, status, content_type, &[], body); +} + +/// `headers` are emitted in the given order, after `Content-Type` and before +/// `Content-Length`. +pub fn frame_response_with_headers( + out: &mut Vec, + status: &str, + content_type: Option<&str>, + headers: &[(&str, &str)], + body: &[u8], +) { + write!(out, "HTTP/1.1 {status}\r\n").unwrap(); + if let Some(ct) = content_type { + write!(out, "Content-Type: {ct}\r\n").unwrap(); + } + for (name, value) in headers { + write!(out, "{name}: {value}\r\n").unwrap(); + } + write!(out, "Content-Length: {}\r\n\r\n", body.len()).unwrap(); out.extend_from_slice(body); } @@ -512,6 +523,47 @@ mod tests { ); } + #[test] + fn extra_headers_sit_between_content_type_and_content_length() { + let mut out = Vec::new(); + frame_response_with_headers( + &mut out, + "200 OK", + Some("application/octet-stream"), + &[("Eth-Consensus-Version", "fulu")], + b"\x01\x02", + ); + assert_eq!( + out, + b"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nEth-Consensus-Version: fulu\r\nContent-Length: 2\r\n\r\n\x01\x02" + ); + } + + #[test] + fn extra_headers_keep_their_given_order() { + let mut out = Vec::new(); + frame_response_with_headers( + &mut out, + "200 OK", + None, + &[("B-Header", "2"), ("A-Header", "1"), ("C-Header", "3")], + b"", + ); + assert_eq!( + out, + b"HTTP/1.1 200 OK\r\nB-Header: 2\r\nA-Header: 1\r\nC-Header: 3\r\nContent-Length: 0\r\n\r\n" + ); + } + + #[test] + fn no_extra_headers_frames_exactly_as_frame_response() { + let mut with_headers = Vec::new(); + frame_response_with_headers(&mut with_headers, "503 Service Unavailable", None, &[], b"x"); + let mut plain = Vec::new(); + frame_response(&mut plain, "503 Service Unavailable", None, b"x"); + assert_eq!(with_headers, plain); + } + #[test] fn dispatch_http10_writes_version_not_supported_then_closes() { let mut conn = ServerConnection::new(); From 29ee6f2a1ccbf23f12d23a5dfe61d91610199f6c Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 18 Aug 2026 15:55:17 +0100 Subject: [PATCH 14/16] Spec-flavour JSON writers for beacon-api responses M2 infrastructure (I3). New json.rs: writer primitives following the beacon-api conventions -- every integer a quoted decimal string, byte arrays lowercase 0x-hex, RFC 8259-complete string escaping -- plus the ten container writers Phase A consumes (genesis, fork, checkpoint, block header signed and bare, validator and its response entry, proposer/sync duties, liveness), each golden-tested against shapes verified in the beacon-APIs spec and cross-checked with Lighthouse's conformance-tested types. Writers append into a caller-borrowed buffer so handlers can render into reused scratch. Comma placement is stateless, derived from the preceding byte, so writers compose without threading state. This lands the encoder decision from the M2 plan in code shape: hand-written writers over SSZ views are the default (the SSZ-backed containers have no structs to derive Serialize on); serde_json stays reserved for startup-precomputed bodies as identity.rs already does, and beacon_api's off-workspace serde_json pin is normalized to the workspace entry (lockfile unchanged, identity golden bytes untouched). Assisted-by: Claude:claude-fable-5 --- crates/beacon_api/Cargo.toml | 2 +- crates/beacon_api/src/json.rs | 600 ++++++++++++++++++++++++++++++ crates/beacon_api/src/lib.rs | 1 + crates/beacon_api/src/response.rs | 6 +- 4 files changed, 604 insertions(+), 5 deletions(-) create mode 100644 crates/beacon_api/src/json.rs diff --git a/crates/beacon_api/Cargo.toml b/crates/beacon_api/Cargo.toml index f2452619..f4464793 100644 --- a/crates/beacon_api/Cargo.toml +++ b/crates/beacon_api/Cargo.toml @@ -12,8 +12,8 @@ silver_beacon_state_data.workspace = true silver_common.workspace = true silver_httpcore.workspace = true serde.workspace = true +serde_json.workspace = true tracing.workspace = true -serde_json = "1.0.149" [dev-dependencies] tempfile = "3" diff --git a/crates/beacon_api/src/json.rs b/crates/beacon_api/src/json.rs new file mode 100644 index 00000000..ab27ee65 --- /dev/null +++ b/crates/beacon_api/src/json.rs @@ -0,0 +1,600 @@ +//! Beacon-API bodies are written by hand: the spec quotes every integer as a +//! decimal string and every byte array as lowercase `0x`-hex, and the +//! SSZ-backed containers have no Rust struct to hang `Serialize` on. +//! `serde_json` is reserved for bodies built once at startup (`identity.rs`). +// Each writer lands ahead of the endpoint commit that calls it. +#![allow(dead_code)] + +use silver_beacon_state_data::{ + BLSPubkey, BLSSignature, BeaconBlockHeader, Checkpoint, Fork, Immutable, ValidatorsView, +}; + +const HEX_LOWER: &[u8; 16] = b"0123456789abcdef"; + +/// Appends JSON to a caller-owned buffer, so a handler can render into a +/// reused response scratch rather than a fresh allocation per request. +pub(crate) struct Json<'a> { + out: &'a mut Vec, + start: usize, +} + +impl<'a> Json<'a> { + pub(crate) fn new(out: &'a mut Vec) -> Self { + let start = out.len(); + Self { out, start } + } + + pub(crate) fn begin_object(&mut self) { + self.separate(); + self.out.push(b'{'); + } + + pub(crate) fn end_object(&mut self) { + self.out.push(b'}'); + } + + pub(crate) fn begin_array(&mut self) { + self.separate(); + self.out.push(b'['); + } + + pub(crate) fn end_array(&mut self) { + self.out.push(b']'); + } + + pub(crate) fn key(&mut self, name: &str) { + debug_assert!(json_safe(name), "field name goes into JSON unescaped"); + self.separate(); + self.out.push(b'"'); + self.out.extend_from_slice(name.as_bytes()); + self.out.extend_from_slice(b"\":"); + } + + pub(crate) fn quoted_u64(&mut self, value: u64) { + self.separate(); + let mut digits = [0u8; 20]; + let mut written = 0; + let mut rest = value; + loop { + digits[19 - written] = b'0' + (rest % 10) as u8; + rest /= 10; + written += 1; + if rest == 0 { + break; + } + } + self.out.push(b'"'); + self.out.extend_from_slice(&digits[20 - written..]); + self.out.push(b'"'); + } + + pub(crate) fn hex(&mut self, bytes: &[u8]) { + self.separate(); + self.out.extend_from_slice(b"\"0x"); + let base = self.out.len(); + self.out.resize(base + bytes.len() * 2, 0); + hex::encode_to_slice(bytes, &mut self.out[base..]).expect("hex encode_to_slice"); + self.out.push(b'"'); + } + + pub(crate) fn bool(&mut self, value: bool) { + self.separate(); + self.out.extend_from_slice(if value { b"true".as_slice() } else { b"false".as_slice() }); + } + + pub(crate) fn string(&mut self, text: &str) { + self.separate(); + self.out.push(b'"'); + for byte in text.bytes() { + match byte { + b'"' => self.out.extend_from_slice(b"\\\""), + b'\\' => self.out.extend_from_slice(b"\\\\"), + 0x08 => self.out.extend_from_slice(b"\\b"), + 0x0c => self.out.extend_from_slice(b"\\f"), + b'\n' => self.out.extend_from_slice(b"\\n"), + b'\r' => self.out.extend_from_slice(b"\\r"), + b'\t' => self.out.extend_from_slice(b"\\t"), + // Everything else below 0x20 has no short escape; multi-byte + // UTF-8 needs none, since JSON strings carry it verbatim. + 0x00..=0x1f => { + self.out.extend_from_slice(b"\\u00"); + self.out.push(HEX_LOWER[(byte >> 4) as usize]); + self.out.push(HEX_LOWER[(byte & 0xf) as usize]); + } + _ => self.out.push(byte), + } + } + self.out.push(b'"'); + } + + /// A comma belongs between two siblings and nowhere else, and the previous + /// byte says which case this is: only `{`, `[` and `:` can be followed by + /// a value that is not a sibling of one already written. + fn separate(&mut self) { + if self.out.len() > self.start && !matches!(self.out.last(), Some(b'{' | b'[' | b':')) { + self.out.push(b','); + } + } +} + +/// Containers, in the field order the beacon-API schemas declare. +impl Json<'_> { + pub(crate) fn genesis(&mut self, imm: &Immutable) { + self.begin_object(); + self.key("genesis_time"); + self.quoted_u64(imm.genesis_time); + self.key("genesis_validators_root"); + self.hex(&imm.genesis_validators_root); + self.key("genesis_fork_version"); + self.hex(&imm.genesis_fork_version); + self.end_object(); + } + + pub(crate) fn fork(&mut self, fork: &Fork) { + self.begin_object(); + self.key("previous_version"); + self.hex(&fork.previous_version); + self.key("current_version"); + self.hex(&fork.current_version); + self.key("epoch"); + self.quoted_u64(fork.epoch); + self.end_object(); + } + + pub(crate) fn checkpoint(&mut self, checkpoint: &Checkpoint) { + self.begin_object(); + self.key("epoch"); + self.quoted_u64(checkpoint.epoch); + self.key("root"); + self.hex(&checkpoint.root); + self.end_object(); + } + + pub(crate) fn block_header(&mut self, header: &BeaconBlockHeader) { + self.begin_object(); + self.key("slot"); + self.quoted_u64(header.slot); + self.key("proposer_index"); + self.quoted_u64(header.proposer_index); + self.key("parent_root"); + self.hex(&header.parent_root); + self.key("state_root"); + self.hex(&header.state_root); + self.key("body_root"); + self.hex(&header.body_root); + self.end_object(); + } + + pub(crate) fn signed_block_header( + &mut self, + header: &BeaconBlockHeader, + signature: &BLSSignature, + ) { + self.begin_object(); + self.key("message"); + self.block_header(header); + self.key("signature"); + self.hex(signature); + self.end_object(); + } + + pub(crate) fn validator(&mut self, validators: &ValidatorsView<'_>, index: usize) { + self.begin_object(); + self.key("pubkey"); + self.hex(validators.pubkey(index)); + self.key("withdrawal_credentials"); + self.hex(&validators.credentials(index).0); + self.key("effective_balance"); + self.quoted_u64(validators.effective_balance(index)); + self.key("slashed"); + self.bool(validators.is_slashed(index)); + self.key("activation_eligibility_epoch"); + self.quoted_u64(validators.activation_eligibility_epoch(index)); + self.key("activation_epoch"); + self.quoted_u64(validators.activation_epoch(index)); + self.key("exit_epoch"); + self.quoted_u64(validators.exit_epoch(index)); + self.key("withdrawable_epoch"); + self.quoted_u64(validators.withdrawable_epoch(index)); + self.end_object(); + } + + pub(crate) fn validator_entry( + &mut self, + validators: &ValidatorsView<'_>, + index: usize, + balance: u64, + status: &str, + ) { + self.begin_object(); + self.key("index"); + self.quoted_u64(index as u64); + self.key("balance"); + self.quoted_u64(balance); + self.key("status"); + self.string(status); + self.key("validator"); + self.validator(validators, index); + self.end_object(); + } + + pub(crate) fn proposer_duty(&mut self, pubkey: &BLSPubkey, validator_index: u64, slot: u64) { + self.begin_object(); + self.key("pubkey"); + self.hex(pubkey); + self.key("validator_index"); + self.quoted_u64(validator_index); + self.key("slot"); + self.quoted_u64(slot); + self.end_object(); + } + + pub(crate) fn sync_duty( + &mut self, + pubkey: &BLSPubkey, + validator_index: u64, + committee_indices: &[u64], + ) { + self.begin_object(); + self.key("pubkey"); + self.hex(pubkey); + self.key("validator_index"); + self.quoted_u64(validator_index); + self.key("validator_sync_committee_indices"); + self.begin_array(); + for &position in committee_indices { + self.quoted_u64(position); + } + self.end_array(); + self.end_object(); + } + + pub(crate) fn liveness(&mut self, index: u64, is_live: bool) { + self.begin_object(); + self.key("index"); + self.quoted_u64(index); + self.key("is_live"); + self.bool(is_live); + self.end_object(); + } +} + +/// Whether `text` survives being spliced into JSON without escaping — the +/// guard for compile-time field names and messages, not for user input +/// ([`Json::string`] escapes). +pub(crate) fn json_safe(text: &str) -> bool { + !text.contains(['"', '\\']) +} + +#[cfg(test)] +mod tests { + use silver_beacon_state_data::{ + BeaconState, BeaconStateOwner, EpochStateFinalized, FAR_FUTURE_EPOCH, StateId, ValSeed, + Withdrawals, + }; + + use super::*; + + fn write(render: impl FnOnce(&mut Json<'_>)) -> String { + let mut out = Vec::new(); + render(&mut Json::new(&mut out)); + String::from_utf8(out).unwrap() + } + + /// Byte-exact body plus a parse: a golden that is not valid JSON is a + /// golden that pinned a bug. + fn assert_body(render: impl FnOnce(&mut Json<'_>), expected: &str) { + let body = write(render); + assert_eq!(body, expected); + serde_json::from_str::(&body).expect("valid JSON"); + } + + #[test] + fn integers_are_quoted_decimal_strings() { + assert_eq!(write(|j| j.quoted_u64(0)), "\"0\""); + assert_eq!(write(|j| j.quoted_u64(7)), "\"7\""); + assert_eq!(write(|j| j.quoted_u64(10)), "\"10\""); + assert_eq!(write(|j| j.quoted_u64(1_606_824_023)), "\"1606824023\""); + assert_eq!(write(|j| j.quoted_u64(FAR_FUTURE_EPOCH)), "\"18446744073709551615\""); + assert_eq!(write(|j| j.quoted_u64(u64::MAX)), "\"18446744073709551615\""); + } + + #[test] + fn hex_is_lowercase_and_full_width_at_every_spec_size() { + assert_eq!(write(|j| j.hex(&[])), "\"0x\""); + assert_eq!(write(|j| j.hex(&[0x00, 0x0a, 0xff, 0xAB])), "\"0x000affab\""); + + for width in [4usize, 20, 32, 48, 96] { + let bytes = vec![0xdeu8; width]; + let rendered = write(|j| j.hex(&bytes)); + assert_eq!(rendered.len(), width * 2 + 4, "width {width}"); + assert!(rendered.starts_with("\"0x"), "width {width}: {rendered}"); + assert!(rendered.ends_with('"'), "width {width}: {rendered}"); + assert!(rendered[3..rendered.len() - 1].bytes().all(|b| b == b'd' || b == b'e')); + } + } + + #[test] + fn leading_zero_bytes_survive_hex_encoding() { + let mut root = [0u8; 32]; + root[31] = 1; + assert_eq!( + write(|j| j.hex(&root)), + "\"0x0000000000000000000000000000000000000000000000000000000000000001\"" + ); + } + + #[test] + fn bools_are_json_literals_not_strings() { + assert_eq!(write(|j| j.bool(true)), "true"); + assert_eq!(write(|j| j.bool(false)), "false"); + } + + #[test] + fn strings_escape_quotes_backslashes_and_control_bytes() { + assert_eq!(write(|j| j.string("active_ongoing")), "\"active_ongoing\""); + assert_eq!(write(|j| j.string("")), "\"\""); + assert_eq!(write(|j| j.string("a\"b")), "\"a\\\"b\""); + assert_eq!(write(|j| j.string("a\\b")), "\"a\\\\b\""); + assert_eq!(write(|j| j.string("\n\r\t")), "\"\\n\\r\\t\""); + assert_eq!(write(|j| j.string("\u{08}\u{0c}")), "\"\\b\\f\""); + assert_eq!(write(|j| j.string("\u{00}\u{01}\u{1f}")), "\"\\u0000\\u0001\\u001f\""); + assert_eq!(write(|j| j.string("\u{7f}")), "\"\u{7f}\""); + } + + #[test] + fn escaped_strings_round_trip_through_a_parser() { + let awkward = "silver/v0.1 \"quoted\"\\slashed\ttabbed\nnewline\u{01}\u{7f}é☃"; + let body = write(|j| j.string(awkward)); + let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(parsed.as_str(), Some(awkward)); + } + + #[test] + fn siblings_are_comma_separated_and_openers_are_not() { + assert_body( + |j| { + j.begin_object(); + j.key("empty_object"); + j.begin_object(); + j.end_object(); + j.key("empty_array"); + j.begin_array(); + j.end_array(); + j.key("values"); + j.begin_array(); + j.quoted_u64(1); + j.quoted_u64(2); + j.bool(false); + j.begin_object(); + j.key("nested"); + j.hex(&[0xab]); + j.end_object(); + j.end_array(); + j.end_object(); + }, + "{\"empty_object\":{},\"empty_array\":[],\"values\":[\"1\",\"2\",false,{\"nested\":\"0xab\"}]}", + ); + } + + #[test] + fn a_body_appended_after_existing_bytes_gets_no_leading_comma() { + let mut out = b"HTTP-ish prefix}".to_vec(); + let mut json = Json::new(&mut out); + json.begin_object(); + json.key("epoch"); + json.quoted_u64(3); + json.end_object(); + assert_eq!(String::from_utf8(out).unwrap(), "HTTP-ish prefix}{\"epoch\":\"3\"}"); + } + + #[test] + fn sibling_objects_in_an_array_are_comma_separated() { + let mut out = Vec::new(); + let mut json = Json::new(&mut out); + json.begin_array(); + for epoch in 1..=2 { + json.begin_object(); + json.key("epoch"); + json.quoted_u64(epoch); + json.end_object(); + } + json.begin_object(); + json.end_object(); + json.end_array(); + assert_eq!(String::from_utf8(out).unwrap(), "[{\"epoch\":\"1\"},{\"epoch\":\"2\"},{}]"); + } + + /// Field names/order: `GenesisData`, `apis/beacon/genesis.yaml`. + #[test] + fn genesis_golden() { + let mut imm = Immutable::default(); + imm.genesis_time = 1_606_824_023; + imm.genesis_validators_root = [0x4b; 32]; + imm.genesis_fork_version = [0x00, 0x00, 0x00, 0x01]; + assert_body( + |j| j.genesis(&imm), + "{\"genesis_time\":\"1606824023\",\"genesis_validators_root\":\"0x4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b\",\"genesis_fork_version\":\"0x00000001\"}", + ); + } + + /// Field names/order: SSZ `Fork` container + /// (`apis/config/fork_schedule.yaml` and `apis/beacon/states/fork.yaml` + /// share it). + #[test] + fn fork_golden() { + let fork = Fork { + previous_version: [0x05, 0x00, 0x00, 0x00], + current_version: [0x06, 0x00, 0x00, 0x00], + epoch: 269_568, + }; + assert_body( + |j| j.fork(&fork), + "{\"previous_version\":\"0x05000000\",\"current_version\":\"0x06000000\",\"epoch\":\"269568\"}", + ); + } + + /// Field names/order: SSZ `Checkpoint` container, as used by + /// `apis/beacon/states/finality_checkpoints.yaml`. + #[test] + fn checkpoint_golden() { + let checkpoint = Checkpoint { epoch: 12_345, root: [0xa1; 32] }; + assert_body( + |j| j.checkpoint(&checkpoint), + "{\"epoch\":\"12345\",\"root\":\"0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1\"}", + ); + } + + /// The three-checkpoint body of `getStateFinalityCheckpoints` — the one + /// place a container writer is called more than once per body. + #[test] + fn finality_checkpoints_body_reuses_the_checkpoint_writer() { + let previous = Checkpoint { epoch: 12_344, root: [0x01; 32] }; + let current = Checkpoint { epoch: 12_345, root: [0x02; 32] }; + let finalized = Checkpoint { epoch: 12_343, root: [0x03; 32] }; + let body = write(|j| { + j.begin_object(); + j.key("previous_justified"); + j.checkpoint(&previous); + j.key("current_justified"); + j.checkpoint(¤t); + j.key("finalized"); + j.checkpoint(&finalized); + j.end_object() + }); + let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(parsed["previous_justified"]["epoch"], "12344"); + assert_eq!(parsed["current_justified"]["epoch"], "12345"); + assert_eq!(parsed["finalized"]["epoch"], "12343"); + assert!(body.starts_with("{\"previous_justified\":{\"epoch\":\"12344\",")); + } + + /// Field names/order: SSZ `BeaconBlockHeader` / `SignedBeaconBlockHeader`, + /// as used by `apis/beacon/blocks/header.yaml`. + #[test] + fn signed_block_header_golden() { + let header = BeaconBlockHeader { + slot: 7_654_321, + proposer_index: 4_242, + parent_root: [0x11; 32], + state_root: [0x22; 32], + body_root: [0x33; 32], + }; + assert_body( + |j| j.signed_block_header(&header, &[0x44; 96]), + "{\"message\":{\"slot\":\"7654321\",\"proposer_index\":\"4242\",\ + \"parent_root\":\"0x1111111111111111111111111111111111111111111111111111111111111111\",\ + \"state_root\":\"0x2222222222222222222222222222222222222222222222222222222222222222\",\ + \"body_root\":\"0x3333333333333333333333333333333333333333333333333333333333333333\"},\ + \"signature\":\"0x444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444\"}", + ); + } + + /// One validator with every field distinct, so a golden catches a + /// swapped pair as well as a renamed key. + fn state_with_one_validator() -> (BeaconStateOwner, StateId) { + let mut pubkey = [0u8; 48]; + pubkey[0] = 0x93; + pubkey[47] = 0x07; + let seeds = [ValSeed { + pubkey, + withdrawal_credentials: Withdrawals::eth1(&[0xab; 20]), + effective_balance: 32_000_000_000, + balance: 32_500_000_000, + activation_epoch: 10, + exit_epoch: FAR_FUTURE_EPOCH, + }]; + let mut owner = + BeaconStateOwner::new(BeaconState::for_test(EpochStateFinalized::default(), &seeds, 0)); + let anchor = owner.roll_fresh(); + let (mut writer, _, _) = owner.apply_block_view(anchor); + writer.validators.set_slashed(0, true); + writer.validators.set_activation_eligibility_epoch(0, 9); + writer.validators.set_withdrawable_epoch(0, 8_192); + let head = writer.commit(None, None); + (owner, head) + } + + /// Field names/order: SSZ `Validator` container, as inlined by + /// `apis/beacon/states/validators.yaml`. + #[test] + fn validator_golden() { + let (owner, head) = state_with_one_validator(); + let view = owner.read_view(head); + assert_body( + |j| j.validator(&view.validators, 0), + "{\"pubkey\":\"0x930000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007\",\ + \"withdrawal_credentials\":\"0x010000000000000000000000abababababababababababababababababababab\",\ + \"effective_balance\":\"32000000000\",\"slashed\":true,\ + \"activation_eligibility_epoch\":\"9\",\"activation_epoch\":\"10\",\ + \"exit_epoch\":\"18446744073709551615\",\"withdrawable_epoch\":\"8192\"}", + ); + } + + /// Field names/order: `ValidatorResponse` of + /// `apis/beacon/states/validators.yaml`. + #[test] + fn validator_entry_golden() { + let (owner, head) = state_with_one_validator(); + let view = owner.read_view(head); + let body = + write(|j| j.validator_entry(&view.validators, 0, 32_500_000_000, "active_slashed")); + let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(parsed["index"], "0"); + assert_eq!(parsed["balance"], "32500000000"); + assert_eq!(parsed["status"], "active_slashed"); + assert_eq!(parsed["validator"]["effective_balance"], "32000000000"); + assert!(body.starts_with( + "{\"index\":\"0\",\"balance\":\"32500000000\",\"status\":\"active_slashed\",\"validator\":{" + )); + } + + /// Field names/order: `ProposerDuty` of + /// `apis/validator/duties/proposer.yaml`. + #[test] + fn proposer_duty_golden() { + let mut pubkey = [0u8; 48]; + pubkey[0] = 0xb0; + assert_body( + |j| j.proposer_duty(&pubkey, 17, 4_096), + "{\"pubkey\":\"0xb00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\ + \"validator_index\":\"17\",\"slot\":\"4096\"}", + ); + } + + /// Field names/order: `SyncCommitteeDuty` of + /// `apis/validator/duties/sync.yaml` — the committee positions are a + /// list of quoted integers. + #[test] + fn sync_duty_golden() { + let mut pubkey = [0u8; 48]; + pubkey[0] = 0xb0; + assert_body( + |j| j.sync_duty(&pubkey, 17, &[3, 511]), + "{\"pubkey\":\"0xb00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\ + \"validator_index\":\"17\",\"validator_sync_committee_indices\":[\"3\",\"511\"]}", + ); + } + + #[test] + fn sync_duty_with_no_committee_positions_keeps_an_empty_array() { + let pubkey = [0u8; 48]; + let body = write(|j| j.sync_duty(&pubkey, 17, &[])); + let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(parsed["validator_sync_committee_indices"].as_array().unwrap().len(), 0); + } + + /// Field names: `apis/validator/liveness.yaml`. + #[test] + fn liveness_golden() { + assert_body(|j| j.liveness(17, false), "{\"index\":\"17\",\"is_live\":false}"); + assert_body(|j| j.liveness(0, true), "{\"index\":\"0\",\"is_live\":true}"); + } + + #[test] + fn json_safe_rejects_what_would_break_an_unescaped_splice() { + assert!(json_safe("active_ongoing")); + assert!(!json_safe("say \"hi\"")); + assert!(!json_safe("back\\slash")); + } +} diff --git a/crates/beacon_api/src/lib.rs b/crates/beacon_api/src/lib.rs index 56e01769..d58a0d66 100644 --- a/crates/beacon_api/src/lib.rs +++ b/crates/beacon_api/src/lib.rs @@ -1,4 +1,5 @@ mod identity; +mod json; mod response; mod router; mod routes; diff --git a/crates/beacon_api/src/response.rs b/crates/beacon_api/src/response.rs index 1ba70055..ed4c4899 100644 --- a/crates/beacon_api/src/response.rs +++ b/crates/beacon_api/src/response.rs @@ -2,6 +2,8 @@ use std::{borrow::Cow, fmt::Write}; use silver_httpcore::frame_response_with_headers; +use crate::json::json_safe; + const JSON_CONTENT_TYPE: &str = "application/json"; pub(crate) struct Response<'a> { @@ -92,10 +94,6 @@ fn status_line(code: u16) -> Option<&'static str> { }) } -fn json_safe(text: &str) -> bool { - !text.contains(['"', '\\']) -} - #[cfg(test)] mod tests { use super::*; From 89850ce8adaa07763872a08d19d031a7a327d41e Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 18 Aug 2026 16:34:59 +0100 Subject: [PATCH 15/16] Thread NodeStatus and spec config into the beacon API Last M2 infrastructure commit (I4), and beacon_api's first spine-fed data. NodeStatus (head/wall slot, syncing flag, EL status) lives in ApiCtx as a single copy the owning tile refreshes in place each loop: ClientServerTile drains SyncUpdate and BeaconStateEvent unconditionally -- the flux broadcast cursor snaps on first consume, so a gated consume would silently miss early messages -- and copies the sibling engine client's sync status after its spin. consume_last was rejected deliberately: beacon_events is one multiplexed enum queue, so the newest message is usually a PersistBlock and taking only it would drop the Status behind it; the drain-and-match idiom Control and Columns already use keeps the last Status specifically. SpecConfig grows the full fork schedule (Altair through Gloas) and deposit-contract fields, serde defaults verified against both the consensus-specs mainnet config and Lighthouse's built-ins; the four hand-rolled fork-version defaults collapse into one const-generic that reads like the YAML. ForkName (closed enum, ADR-0003's principle) maps epoch/slot to the wire spelling for Eth-Consensus-Version and version fields. head_slot is Option-shaped: zero before the first Status is indistinguishable from genesis, and node/health's 503 needs the difference. No-EL mode now records the Synced status it advertises so NodeStatus agrees with what peers are told. Assisted-by: Claude:claude-fable-5 --- Cargo.lock | 1 + crates/beacon_api/examples/srv.rs | 5 +- crates/beacon_api/src/lib.rs | 2 + crates/beacon_api/src/node_status.rs | 29 +++ crates/beacon_api/src/routes.rs | 41 +++- crates/beacon_api/src/server.rs | 14 +- crates/beacon_state/data/src/lib.rs | 2 +- crates/bin/src/main.rs | 1 + crates/client_server/src/lib.rs | 26 ++- crates/client_server/tests/tile.rs | 122 +++++++++- crates/common/src/spine/messages.rs | 3 +- crates/config/chain_spec/Cargo.toml | 3 + crates/config/chain_spec/src/lib.rs | 320 +++++++++++++++++++++++---- crates/engine_api/src/api.rs | 7 + 14 files changed, 510 insertions(+), 66 deletions(-) create mode 100644 crates/beacon_api/src/node_status.rs diff --git a/Cargo.lock b/Cargo.lock index 17c068ef..445d2cf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4509,6 +4509,7 @@ version = "0.0.1" dependencies = [ "hex", "serde", + "toml", ] [[package]] diff --git a/crates/beacon_api/examples/srv.rs b/crates/beacon_api/examples/srv.rs index a2885c81..43a19a75 100644 --- a/crates/beacon_api/examples/srv.rs +++ b/crates/beacon_api/examples/srv.rs @@ -1,7 +1,7 @@ -use std::time::Duration; +use std::{sync::Arc, time::Duration}; use silver_beacon_api::BeaconApi; -use silver_beacon_state_data::BeaconStateOwner; +use silver_beacon_state_data::{BeaconStateOwner, SpecConfig}; use silver_common::{Enr, Identify, Keypair}; use silver_httpcore::Bind; @@ -20,6 +20,7 @@ fn main() { &keypair, local_enr, &Identify::default(), + Arc::new(SpecConfig::mainnet()), state, ); println!("serving on {:?}", api.local_addrs()); diff --git a/crates/beacon_api/src/lib.rs b/crates/beacon_api/src/lib.rs index d58a0d66..325fc4f3 100644 --- a/crates/beacon_api/src/lib.rs +++ b/crates/beacon_api/src/lib.rs @@ -1,8 +1,10 @@ mod identity; mod json; +mod node_status; mod response; mod router; mod routes; mod server; +pub use node_status::{NodeStatus, SlotStatus}; pub use server::BeaconApi; diff --git a/crates/beacon_api/src/node_status.rs b/crates/beacon_api/src/node_status.rs new file mode 100644 index 00000000..d41d1e2b --- /dev/null +++ b/crates/beacon_api/src/node_status.rs @@ -0,0 +1,29 @@ +use silver_common::ELSyncStatus; + +/// The node's own condition, as against the chain state a +/// `BeaconStateReader` serves. Assembled and refreshed by its single +/// writer; handlers read one consistent snapshot per dispatch. +#[derive(Clone, Copy, Debug, Default)] +pub struct NodeStatus { + /// `None` until the beacon-state tile publishes its first per-slot + /// status, i.e. while the node has nothing to report a head against. + pub slots: Option, + pub syncing: bool, + pub el: ELSyncStatus, +} + +/// Announced once per slot, not once per block, so `head_slot` trails the +/// imported head by up to a slot. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SlotStatus { + pub head_slot: u64, + pub wall_slot: u64, +} + +impl SlotStatus { + /// Saturating: a head ahead of the wall clock (a peer's block accepted + /// early in the slot) is zero distance, not an underflow. + pub fn sync_distance(&self) -> u64 { + self.wall_slot.saturating_sub(self.head_slot) + } +} diff --git a/crates/beacon_api/src/routes.rs b/crates/beacon_api/src/routes.rs index 758ee28a..b10bf2c3 100644 --- a/crates/beacon_api/src/routes.rs +++ b/crates/beacon_api/src/routes.rs @@ -1,9 +1,12 @@ +use std::sync::Arc; + #[cfg(test)] use silver_beacon_state_data::BeaconStateOwner; -use silver_beacon_state_data::{BeaconStateReader, StateReadView}; +use silver_beacon_state_data::{BeaconStateReader, SpecConfig, StateReadView}; use silver_common::{Enr, Identify, Keypair}; use crate::{ + NodeStatus, identity::build_identity_json, response::Response, router::{Handler, Method, Request}, @@ -17,6 +20,12 @@ pub(crate) const ROUTES: &[(Method, &str, Handler)] = pub(crate) struct ApiCtx { pub(crate) identity_json: Vec, pub(crate) state: BeaconStateReader, + // The config and node-status endpoints land after this; until then only + // the owning tile writes `node_status`. + #[allow(dead_code)] + pub(crate) spec: Arc, + #[allow(dead_code)] + pub(crate) node_status: NodeStatus, } impl ApiCtx { @@ -24,9 +33,15 @@ impl ApiCtx { keypair: &Keypair, local_enr: &Enr, identify: &Identify, + spec: Arc, state: BeaconStateReader, ) -> Self { - Self { identity_json: build_identity_json(keypair, local_enr, identify), state } + Self { + identity_json: build_identity_json(keypair, local_enr, identify), + state, + spec, + node_status: NodeStatus::default(), + } } #[allow(dead_code)] @@ -55,7 +70,17 @@ fn metrics(_req: &Request<'_>, _ctx: &ApiCtx, resp: &mut Response<'_>) { /// bootstrap. #[cfg(test)] pub(crate) fn preboot_ctx() -> ApiCtx { - ApiCtx { identity_json: Vec::new(), state: BeaconStateOwner::empty_test(0).reader() } + test_ctx(Vec::new(), BeaconStateOwner::empty_test(0).reader()) +} + +#[cfg(test)] +fn test_ctx(identity_json: Vec, state: BeaconStateReader) -> ApiCtx { + ApiCtx { + identity_json, + state, + spec: Arc::new(SpecConfig::mainnet()), + node_status: NodeStatus::default(), + } } #[cfg(test)] @@ -77,7 +102,13 @@ mod tests { let enr = Enr::builder().build(kp.secret_key()).unwrap(); let mut identify = Identify::default(); identify.tcp_ipv4 = Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 9000)); - ApiCtx::new(&kp, &enr, &identify, BeaconStateOwner::empty_test(0).reader()) + ApiCtx::new( + &kp, + &enr, + &identify, + Arc::new(SpecConfig::mainnet()), + BeaconStateOwner::empty_test(0).reader(), + ) } fn get(router: &Router, ctx: &ApiCtx, path: &str) -> Vec { @@ -173,7 +204,7 @@ mod tests { let mut owner = BeaconStateOwner::new(BeaconState::empty_test(0)); let anchor = owner.roll_fresh(); owner.publish_state_id(anchor); - let ctx = ApiCtx { identity_json: Vec::new(), state: owner.reader() }; + let ctx = test_ctx(Vec::new(), owner.reader()); let router = Router::new(&[(Method::Get, "/test/genesis_root", genesis_root)]); let resp = get(&router, &ctx, "/test/genesis_root"); diff --git a/crates/beacon_api/src/server.rs b/crates/beacon_api/src/server.rs index 443b216d..c0a7e7c8 100644 --- a/crates/beacon_api/src/server.rs +++ b/crates/beacon_api/src/server.rs @@ -1,15 +1,17 @@ use std::{ collections::HashMap, io::{self, Read, Write}, + sync::Arc, time::{Duration, Instant}, }; use mio::{Events, Interest, Poll, Token}; -use silver_beacon_state_data::BeaconStateReader; +use silver_beacon_state_data::{BeaconStateReader, SpecConfig}; use silver_common::{Enr, Identify, Keypair}; use silver_httpcore::{AfterResponse, Bind, Listener, ParsedRequest, ServerConnection, Stream}; use crate::{ + NodeStatus, router::Router, routes::{ApiCtx, ROUTES}, }; @@ -58,6 +60,7 @@ pub struct BeaconApi { } impl BeaconApi { + #[allow(clippy::too_many_arguments)] pub fn new( binds: &[Bind], max_connections: usize, @@ -65,6 +68,7 @@ impl BeaconApi { keypair: &Keypair, local_enr: Enr, identify: &Identify, + spec: Arc, state: BeaconStateReader, ) -> Self { assert!(!binds.is_empty(), "beacon api needs at least one bind"); @@ -89,7 +93,7 @@ impl BeaconApi { listeners, connections: HashMap::new(), router: Router::new(ROUTES), - ctx: ApiCtx::new(keypair, &local_enr, identify, state), + ctx: ApiCtx::new(keypair, &local_enr, identify, spec, state), } } @@ -97,6 +101,11 @@ impl BeaconApi { self.listeners.iter().map(Listener::local_addr).collect() } + /// In-place update seam for the status's single writer. + pub fn node_status_mut(&mut self) -> &mut NodeStatus { + &mut self.ctx.node_status + } + pub fn pump(&mut self) -> bool { self.poll.poll(&mut self.events, Some(Duration::ZERO)).unwrap(); let now = Instant::now(); @@ -300,6 +309,7 @@ mod tests { &keypair, local_enr, &Identify::default(), + Arc::new(SpecConfig::mainnet()), BeaconStateOwner::empty_test(0).reader(), ) } diff --git a/crates/beacon_state/data/src/lib.rs b/crates/beacon_state/data/src/lib.rs index 5e57384b..cedaafaf 100644 --- a/crates/beacon_state/data/src/lib.rs +++ b/crates/beacon_state/data/src/lib.rs @@ -25,7 +25,7 @@ pub use pending::{ PendingGroup, PendingId, PendingView, PendingWriteView, QueueItem, QueueView, QueueWriteView, }; pub use ring::{Id, Reset}; -pub use silver_chain_spec::{BlobParameters, SpecConfig}; +pub use silver_chain_spec::{BlobParameters, ForkName, SpecConfig}; pub(crate) use silver_ssz::{merkle, progressive}; pub use slot_state::{ EpochBalances, EpochBalancesRow, SlotStateFinalized, SlotStateGroup, SlotStateId, diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index bd39ddd0..191ed019 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -241,6 +241,7 @@ fn main() -> Result<(), Box> { &keypair, local_enr, &identify, + spec.clone(), beacon_state_tile.reader(), ); diff --git a/crates/client_server/src/lib.rs b/crates/client_server/src/lib.rs index c07638e9..9ff06462 100644 --- a/crates/client_server/src/lib.rs +++ b/crates/client_server/src/lib.rs @@ -1,6 +1,6 @@ use flux::{spine::SpineAdapter, tile::Tile}; -use silver_beacon_api::BeaconApi; -use silver_common::SilverSpine; +use silver_beacon_api::{BeaconApi, SlotStatus}; +use silver_common::{BeaconStateEvent, SilverSpine, SyncUpdate}; use silver_engine_api::EngineApi; pub struct ClientServerTile { @@ -12,8 +12,30 @@ impl Tile for ClientServerTile { fn loop_body(&mut self, adapter: &mut SpineAdapter) { self.engine.intake(adapter); self.engine.spin(adapter); + self.refresh_node_status(adapter); if self.beacon.pump() { adapter.mark_work(); } } } + +impl ClientServerTile { + /// Unconditional every iteration, and never behind the engine's capacity + /// gate: a consumer's first `consume` jumps its cursor to the producer's + /// write head, so a queue left unread while the pool is saturated loses + /// everything published in the meantime. + fn refresh_node_status(&mut self, adapter: &mut SpineAdapter) { + let status = self.beacon.node_status_mut(); + + adapter.consume(|event: BeaconStateEvent, _| { + if let BeaconStateEvent::Status { latest_block_slot, wall_slot, .. } = event { + status.slots = Some(SlotStatus { head_slot: latest_block_slot, wall_slot }); + } + }); + adapter.consume(|update: SyncUpdate, _| { + status.syncing = !matches!(update, SyncUpdate::Following); + }); + + status.el = self.engine.sync_status(); + } +} diff --git a/crates/client_server/tests/tile.rs b/crates/client_server/tests/tile.rs index ac2cf5bc..e235fcce 100644 --- a/crates/client_server/tests/tile.rs +++ b/crates/client_server/tests/tile.rs @@ -2,16 +2,17 @@ use std::{ io::{Read, Write}, net::TcpStream, os::unix::net::UnixStream, + sync::Arc, time::{Duration, Instant}, }; use flux::{spine::SpineAdapter, tile::Tile}; -use silver_beacon_api::BeaconApi; -use silver_beacon_state_data::BeaconStateOwner; +use silver_beacon_api::{BeaconApi, SlotStatus}; +use silver_beacon_state_data::{BeaconStateOwner, SpecConfig}; use silver_client_server::ClientServerTile; use silver_common::{ - EngineFcuReq, EngineReq, EngineResp, Enr, Identify, Keypair, SilverSpine, TCache, - TCacheProducer, + BeaconStateEvent, ELSyncStatus, EngineFcuReq, EngineReq, EngineResp, Enr, Identify, Keypair, + SilverSpine, SyncUpdate, TCache, TCacheProducer, ssz_view::STATUS_V2_SIZE, }; use silver_config::EngineConfig; use silver_engine_api::{ @@ -36,6 +37,7 @@ fn beacon(bind: &Bind) -> BeaconApi { &keypair, local_enr, &Identify::default(), + Arc::new(SpecConfig::mainnet()), BeaconStateOwner::empty_test(0).reader(), ) } @@ -86,6 +88,15 @@ fn head_block_hash_json(byte: u8) -> String { format!("\"headBlockHash\":\"0x{}\"", hex::encode([byte; 32])) } +fn status_event(head_slot: u64, wall_slot: u64) -> BeaconStateEvent { + BeaconStateEvent::Status { + ssz: [0u8; STATUS_V2_SIZE], + latest_block_slot: head_slot, + wall_slot, + enr_fork_id: [0u8; 16], + } +} + #[test] fn serves_identity_over_tcp() { let base = TempDir::new().unwrap(); @@ -299,3 +310,106 @@ fn pool_cap_gates_spine_intake() { }); assert_eq!(completed, vec![[12u8; 32]], "out-of-order completion correlated"); } + +/// A broadcast consumer's cursor jumps to the producer's write head on its +/// first read, so anything published before the tile's first `loop_body` is +/// gone — which is why the tile reads these queues unconditionally from that +/// first iteration on. +#[test] +fn node_status_tracks_the_spine_once_the_cursor_snaps() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let mut tile = ClientServerTile { + beacon: beacon(&Bind::parse("127.0.0.1:0")), + engine: engine(no_el(), ["cs_status_gossip", "cs_status_rpc", "cs_status_resp"]), + }; + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); + + inj.produce(status_event(1, 1)); + tile.loop_body(&mut adapter); + assert!( + tile.beacon.node_status_mut().slots.is_none(), + "a status published before the first consume is skipped, not delivered" + ); + + inj.produce(status_event(7, 9)); + inj.produce(SyncUpdate::SyncingHead { head_root: [3u8; 32], head_slot: 9 }); + tile.loop_body(&mut adapter); + + let status = *tile.beacon.node_status_mut(); + assert_eq!(status.slots, Some(SlotStatus { head_slot: 7, wall_slot: 9 })); + assert_eq!(status.slots.unwrap().sync_distance(), 2); + assert!(status.syncing); + + inj.produce(SyncUpdate::Following); + tile.loop_body(&mut adapter); + assert!(!tile.beacon.node_status_mut().syncing, "reaching the target clears the syncing flag"); +} + +/// The engine's spine intake is gated on free pool connections; node status +/// must not be. A queue left unread for a few iterations does not stall — it +/// loses its whole backlog. +#[test] +fn node_status_updates_while_the_engine_pool_is_at_cap() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let (mut el, endpoint) = FakeEl::tcp(); + let jwt_path = write_jwt(base.path()); + + let config = EngineConfig { + execution_endpoint: endpoint, + jwt_secret: jwt_path.to_str().unwrap().to_string(), + max_connections: 3, + ..EngineConfig::default() + }; + let mut tile = ClientServerTile { + beacon: beacon(&Bind::parse("127.0.0.1:0")), + engine: engine(config, ["cs_sat_gossip", "cs_sat_rpc", "cs_sat_resp"]), + }; + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); + inj.consume(|_: EngineResp, _| {}); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut crank = |tile: &mut ClientServerTile, el: &mut FakeEl, msg: &str| { + assert!(Instant::now() < deadline, "timeout: {msg}"); + tile.loop_body(&mut adapter); + el.pump(); + std::thread::sleep(Duration::from_millis(1)); + }; + + while el.requests.len() < 3 { + crank(&mut tile, &mut el, "startup healthcheck trio"); + } + // `eth_syncing: false` is the EL reporting itself synced; the trio also + // frees all three pooled connections. + for i in 0..3 { + el.respond(i, "false"); + } + while tile.beacon.node_status_mut().el != ELSyncStatus::Synced { + crank(&mut tile, &mut el, "EL sync status reaches the api"); + } + + for byte in [11u8, 12, 13, 14] { + inj.produce(fcu_req(byte)); + } + let fcu_count = |el: &FakeEl| { + el.requests.iter().filter(|r| r.method == "engine_forkchoiceUpdatedV3").count() + }; + while fcu_count(&el) < 3 { + crank(&mut tile, &mut el, "pool saturated with unanswered FCUs"); + } + + inj.produce(status_event(7, 9)); + inj.produce(SyncUpdate::Following); + while tile.beacon.node_status_mut().slots.is_none() { + crank(&mut tile, &mut el, "status consumed while the pool is at cap"); + assert_eq!(fcu_count(&el), 3, "the 4th request must stay gated on the spine"); + } + + let status = *tile.beacon.node_status_mut(); + assert_eq!(status.slots, Some(SlotStatus { head_slot: 7, wall_slot: 9 })); + assert!(!status.syncing); + assert_eq!(status.el, ELSyncStatus::Synced); +} diff --git a/crates/common/src/spine/messages.rs b/crates/common/src/spine/messages.rs index 0cca37d3..02c64861 100644 --- a/crates/common/src/spine/messages.rs +++ b/crates/common/src/spine/messages.rs @@ -952,9 +952,10 @@ pub enum EngineResp { } /// Sync status of the attached execution layer. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[repr(u8)] pub enum ELSyncStatus { + #[default] Unknown = 0, Syncing = 1, Synced = 2, diff --git a/crates/config/chain_spec/Cargo.toml b/crates/config/chain_spec/Cargo.toml index 97e7cf7c..9478cba8 100644 --- a/crates/config/chain_spec/Cargo.toml +++ b/crates/config/chain_spec/Cargo.toml @@ -9,5 +9,8 @@ version.workspace = true serde.workspace = true hex.workspace = true +[dev-dependencies] +toml.workspace = true + [lints] workspace = true diff --git a/crates/config/chain_spec/src/lib.rs b/crates/config/chain_spec/src/lib.rs index d8f9ef24..0e27adac 100644 --- a/crates/config/chain_spec/src/lib.rs +++ b/crates/config/chain_spec/src/lib.rs @@ -4,6 +4,18 @@ const fn default_u64() -> u64 { V } +/// Fork versions are written big-endian in every upstream config +/// (`0x06000000`), so the literal in a `#[serde(default)]` reads as the +/// config file does. +const fn default_fork_version() -> [u8; 4] { + V.to_be_bytes() +} + +/// `FAR_FUTURE_EPOCH`: a fork with no scheduled activation. +const fn unscheduled() -> u64 { + u64::MAX +} + /// Mainnet preset; every network we support uses it. const SLOTS_PER_EPOCH: u64 = 32; @@ -16,34 +28,93 @@ pub struct BlobParameters { pub max_blobs_per_block: u64, } +/// Every fork silver's config can name, in activation order. The set is +/// closed and minted upstream, so it is an enum rather than a table +/// (ADR-0003); forks past Gloas are added here as the spec schedules them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum ForkName { + Phase0, + Altair, + Bellatrix, + Capella, + Deneb, + Electra, + Fulu, + Gloas, +} + +impl ForkName { + /// Lowercase spec spelling, as the wire wants it in + /// `Eth-Consensus-Version` and in the `version` field of a beacon-API + /// body. + pub fn name(self) -> &'static str { + match self { + Self::Phase0 => "phase0", + Self::Altair => "altair", + Self::Bellatrix => "bellatrix", + Self::Capella => "capella", + Self::Deneb => "deneb", + Self::Electra => "electra", + Self::Fulu => "fulu", + Self::Gloas => "gloas", + } + } +} + /// Per-network spec parameters that vary across mainnet / testnets / devnets. /// /// Compile-time array dimensions (`SLOTS_PER_EPOCH`, /// `SYNC_COMMITTEE_SIZE`, etc.) stay hardcoded — every real testnet uses /// the mainnet preset; only the spec "minimal" preset differs and we don't /// support running it. -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub struct SpecConfig { /// Genesis (phase-0) fork version. Used as the `current_version` in the /// genesis fork-data root, which is the domain mixed into deposit /// signatures (`DOMAIN_DEPOSIT`). 0x00000000 mainnet, 0x10000910 Hoodi. - #[serde(default = "default_genesis_fork_version", with = "hex_0x")] + #[serde(default = "default_fork_version::<0x00000000>", with = "hex_0x")] pub genesis_fork_version: [u8; 4], + /// Altair through Electra gate none of silver's own consensus — it runs + /// Fulu and Gloas only. They are carried because a validator client + /// derives signing domains for historical epochs from the fork schedule + /// this node publishes. + #[serde(default = "default_fork_version::<0x01000000>", with = "hex_0x")] + pub altair_fork_version: [u8; 4], + #[serde(default = "default_u64::<74240>")] + pub altair_fork_epoch: u64, + #[serde(default = "default_fork_version::<0x02000000>", with = "hex_0x")] + pub bellatrix_fork_version: [u8; 4], + #[serde(default = "default_u64::<144896>")] + pub bellatrix_fork_epoch: u64, /// Capella fork version. Withdrawal-credential domain on Capella+. /// 0x03000000 mainnet, 0x40000910 Hoodi. - #[serde(default = "default_capella_fork_version", with = "hex_0x")] + #[serde(default = "default_fork_version::<0x03000000>", with = "hex_0x")] pub capella_fork_version: [u8; 4], + #[serde(default = "default_u64::<194048>")] + pub capella_fork_epoch: u64, + #[serde(default = "default_fork_version::<0x04000000>", with = "hex_0x")] + pub deneb_fork_version: [u8; 4], + #[serde(default = "default_u64::<269568>")] + pub deneb_fork_epoch: u64, + #[serde(default = "default_fork_version::<0x05000000>", with = "hex_0x")] + pub electra_fork_version: [u8; 4], + /// Doubles as the epoch of the active blob params when no + /// `blob_schedule` entry applies. + #[serde(default = "default_u64::<364032>")] + pub electra_fork_epoch: u64, /// Fulu fork version. Mixed into every Fulu /// fork digest. - #[serde(default = "default_fulu_fork_version", with = "hex_0x")] + #[serde(default = "default_fork_version::<0x06000000>", with = "hex_0x")] pub fulu_fork_version: [u8; 4], + #[serde(default = "default_u64::<411392>")] + pub fulu_fork_epoch: u64, /// Gloas (EIP-7732) fork version, compared against /// `state.fork.current_version` to gate Gloas state logic. - #[serde(default = "default_gloas_fork_version", with = "hex_0x")] + #[serde(default = "default_fork_version::<0x07000000>", with = "hex_0x")] pub gloas_fork_version: [u8; 4], /// Gloas activation epoch. - #[serde(default = "default_gloas_fork_epoch")] + #[serde(default = "unscheduled")] pub gloas_fork_epoch: u64, /// Per-epoch override on `max_blobs_per_block` (EIP-7892). Sorted by /// `epoch`; the active entry is the highest-epoch entry whose epoch @@ -51,15 +122,19 @@ pub struct SpecConfig { /// defaults (`electra_fork_epoch`, `max_blobs_per_block_electra`). #[serde(default = "default_blob_schedule")] pub blob_schedule: Vec, - /// Activation epoch of the Electra fork — used - /// as the epoch field of the active blob params when no `blob_schedule` - /// entry applies. - #[serde(default = "default_u64::<364032>")] - pub electra_fork_epoch: u64, /// Blob count active between Electra /// activation and the first BPO upgrade. 9 mainnet. #[serde(default = "default_u64::<9>")] pub max_blobs_per_block_electra: u64, + /// Deposit contract identity. Silver follows no eth1 deposit stream, so + /// nothing here is verified against; it is carried so the node can tell a + /// validator client which contract the network it joined deposits to. + #[serde(default = "default_u64::<1>")] + pub deposit_chain_id: u64, + #[serde(default = "default_u64::<1>")] + pub deposit_network_id: u64, + #[serde(default = "default_deposit_contract_address", with = "hex_0x")] + pub deposit_contract_address: [u8; 20], /// Seconds per beacon chain slot. 12 mainnet; testnets may use shorter. #[serde(default = "default_u64::<12>")] pub seconds_per_slot: u64, @@ -122,24 +197,13 @@ pub struct SpecConfig { pub ejection_balance: u64, } -fn default_genesis_fork_version() -> [u8; 4] { - [0x00, 0x00, 0x00, 0x00] -} - -fn default_capella_fork_version() -> [u8; 4] { - [0x03, 0x00, 0x00, 0x00] -} - -fn default_fulu_fork_version() -> [u8; 4] { - [0x06, 0x00, 0x00, 0x00] -} - -fn default_gloas_fork_version() -> [u8; 4] { - [0x07, 0x00, 0x00, 0x00] -} - -fn default_gloas_fork_epoch() -> u64 { - u64::MAX +/// Mainnet deposit contract, live since 2020-11-04. Hoodi reuses the very +/// same address. +fn default_deposit_contract_address() -> [u8; 20] { + [ + 0x00, 0x00, 0x00, 0x00, 0x21, 0x9a, 0xb5, 0x40, 0x35, 0x6c, 0xbb, 0x83, 0x9c, 0xbe, 0x05, + 0x30, 0x3d, 0x77, 0x05, 0xfa, + ] } fn default_blob_schedule() -> Vec { @@ -149,21 +213,27 @@ fn default_blob_schedule() -> Vec { }] } -/// Serde adapter for `0x`-prefixed lowercase hex (`0x06000000`), which is -/// the format used by upstream `consensus-specs/configs/*.yaml` for all -/// fork-version fields. The bare `hex::serde` adapter rejects the prefix. +/// Serde adapter for `0x`-prefixed hex (`0x06000000`), which is the format +/// used by upstream `consensus-specs/configs/*.yaml` for fork versions and +/// the deposit contract address. The bare `hex::serde` adapter rejects the +/// prefix. mod hex_0x { use serde::{Deserialize, Deserializer, Serializer, de::Error}; - pub fn serialize(bytes: &[u8; 4], s: S) -> Result { + pub fn serialize( + bytes: &[u8; N], + s: S, + ) -> Result { s.serialize_str(&format!("0x{}", hex::encode(bytes))) } - pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 4], D::Error> { + pub fn deserialize<'de, const N: usize, D: Deserializer<'de>>( + d: D, + ) -> Result<[u8; N], D::Error> { let s: String = Deserialize::deserialize(d)?; let body = s.strip_prefix("0x").unwrap_or(&s); let v = hex::decode(body).map_err(D::Error::custom)?; - v.try_into().map_err(|_: Vec| D::Error::custom("expected 4-byte hex")) + v.try_into().map_err(|_: Vec| D::Error::custom(format!("expected {N}-byte hex"))) } } @@ -179,6 +249,31 @@ impl SpecConfig { } } + pub fn fork_at(&self, epoch: u64) -> ForkName { + if epoch >= self.gloas_fork_epoch { + ForkName::Gloas + } else if epoch >= self.fulu_fork_epoch { + ForkName::Fulu + } else if epoch >= self.electra_fork_epoch { + ForkName::Electra + } else if epoch >= self.deneb_fork_epoch { + ForkName::Deneb + } else if epoch >= self.capella_fork_epoch { + ForkName::Capella + } else if epoch >= self.bellatrix_fork_epoch { + ForkName::Bellatrix + } else if epoch >= self.altair_fork_epoch { + ForkName::Altair + } else { + ForkName::Phase0 + } + } + + #[inline] + pub fn fork_at_slot(&self, slot: u64) -> ForkName { + self.fork_at(slot / SLOTS_PER_EPOCH) + } + /// Whether `epoch` is at or past the Gloas activation. #[inline] pub fn is_gloas_at(&self, epoch: u64) -> bool { @@ -223,8 +318,6 @@ impl SpecConfig { /// to mainnet (see `eth-clients/hoodi/metadata/config.yaml`). /// /// Diffs from mainnet: - /// - All pre-Fulu forks (Altair → Electra) activated at epoch 0 except - /// Electra, which activated at epoch 2048. /// - `*_FORK_VERSION` pattern is `0xN0000910` (N = fork ordinal) instead /// of mainnet's `0x0N000000`. /// - Hoodi-specific `BLOB_SCHEDULE` entries should be cross-checked @@ -232,17 +325,29 @@ impl SpecConfig { pub fn hoodi() -> Self { Self { // Hoodi fork-version pattern is `0xN0000910`. - genesis_fork_version: [0x10, 0x00, 0x09, 0x10], - capella_fork_version: [0x40, 0x00, 0x09, 0x10], - fulu_fork_version: [0x70, 0x00, 0x09, 0x10], - gloas_fork_version: [0x80, 0x00, 0x09, 0x10], - gloas_fork_epoch: u64::MAX, + genesis_fork_version: default_fork_version::<0x10000910>(), + altair_fork_version: default_fork_version::<0x20000910>(), + altair_fork_epoch: 0, + bellatrix_fork_version: default_fork_version::<0x30000910>(), + bellatrix_fork_epoch: 0, + capella_fork_version: default_fork_version::<0x40000910>(), + capella_fork_epoch: 0, + deneb_fork_version: default_fork_version::<0x50000910>(), + deneb_fork_epoch: 0, + electra_fork_version: default_fork_version::<0x60000910>(), + electra_fork_epoch: 2048, + fulu_fork_version: default_fork_version::<0x70000910>(), + fulu_fork_epoch: 50688, + gloas_fork_version: default_fork_version::<0x80000910>(), + gloas_fork_epoch: unscheduled(), // No BPO entries spec'd on Hoodi at time of writing. Empty ⇒ // always fall back to (`electra_fork_epoch`, // `max_blobs_per_block_electra`). blob_schedule: vec![], - electra_fork_epoch: 2048, max_blobs_per_block_electra: 9, + deposit_chain_id: 560048, + deposit_network_id: 560048, + deposit_contract_address: default_deposit_contract_address(), // Identical to mainnet preset / config below this line. seconds_per_slot: 12, shard_committee_period: 256, @@ -266,14 +371,26 @@ impl SpecConfig { pub fn mainnet() -> Self { Self { - genesis_fork_version: default_genesis_fork_version(), - capella_fork_version: default_capella_fork_version(), - fulu_fork_version: default_fulu_fork_version(), - gloas_fork_version: default_gloas_fork_version(), - gloas_fork_epoch: default_gloas_fork_epoch(), - blob_schedule: default_blob_schedule(), + genesis_fork_version: default_fork_version::<0x00000000>(), + altair_fork_version: default_fork_version::<0x01000000>(), + altair_fork_epoch: 74240, + bellatrix_fork_version: default_fork_version::<0x02000000>(), + bellatrix_fork_epoch: 144896, + capella_fork_version: default_fork_version::<0x03000000>(), + capella_fork_epoch: 194048, + deneb_fork_version: default_fork_version::<0x04000000>(), + deneb_fork_epoch: 269568, + electra_fork_version: default_fork_version::<0x05000000>(), electra_fork_epoch: 364032, + fulu_fork_version: default_fork_version::<0x06000000>(), + fulu_fork_epoch: 411392, + gloas_fork_version: default_fork_version::<0x07000000>(), + gloas_fork_epoch: unscheduled(), + blob_schedule: default_blob_schedule(), max_blobs_per_block_electra: 9, + deposit_chain_id: 1, + deposit_network_id: 1, + deposit_contract_address: default_deposit_contract_address(), seconds_per_slot: 12, shard_committee_period: 256, min_validator_withdrawability_delay: 256, @@ -300,3 +417,108 @@ impl Default for SpecConfig { Self::mainnet() } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Every default is the mainnet value from + /// `ethereum/consensus-specs` `configs/mainnet.yaml` (fork versions and + /// epochs, `BLOB_SCHEDULE`, `DEPOSIT_CHAIN_ID` / `DEPOSIT_NETWORK_ID` / + /// `DEPOSIT_CONTRACT_ADDRESS`), so a config file naming only its + /// network's diffs still describes mainnet everywhere else. + #[test] + fn toml_defaults_are_mainnet() { + let spec: SpecConfig = toml::from_str("").unwrap(); + assert_eq!(spec, SpecConfig::mainnet()); + + assert_eq!(spec.altair_fork_epoch, 74240); + assert_eq!(spec.bellatrix_fork_epoch, 144896); + assert_eq!(spec.capella_fork_epoch, 194048); + assert_eq!(spec.deneb_fork_epoch, 269568); + assert_eq!(spec.electra_fork_epoch, 364032); + assert_eq!(spec.fulu_fork_epoch, 411392); + assert_eq!(spec.gloas_fork_epoch, u64::MAX); + assert_eq!(spec.deposit_chain_id, 1); + assert_eq!(spec.deposit_network_id, 1); + } + + #[test] + fn every_fork_field_is_overridable() { + let spec: SpecConfig = toml::from_str( + r#" + ALTAIR_FORK_VERSION = "0x20000910" + ALTAIR_FORK_EPOCH = 0 + FULU_FORK_EPOCH = 50688 + DEPOSIT_CHAIN_ID = 560048 + "#, + ) + .unwrap(); + assert_eq!(spec.altair_fork_version, [0x20, 0x00, 0x09, 0x10]); + assert_eq!(spec.altair_fork_epoch, 0); + assert_eq!(spec.fulu_fork_epoch, 50688); + assert_eq!(spec.deposit_chain_id, 560048); + assert_eq!(spec.bellatrix_fork_epoch, 144896, "untouched fields keep the mainnet default"); + } + + /// Upstream writes the address checksummed (mixed case); `hex::decode` + /// must not be handed it case-sensitively. + #[test] + fn deposit_contract_address_parses_checksummed_hex() { + let spec: SpecConfig = toml::from_str( + r#"DEPOSIT_CONTRACT_ADDRESS = "0x00000000219ab540356cBB839Cbe05303d7705Fa""#, + ) + .unwrap(); + assert_eq!(spec.deposit_contract_address, SpecConfig::mainnet().deposit_contract_address); + assert_eq!(spec.deposit_contract_address[4], 0x21); + } + + #[test] + fn fork_at_switches_on_each_activation_epoch() { + let spec = SpecConfig::mainnet(); + assert_eq!(spec.fork_at(0), ForkName::Phase0); + + for (epoch, before, after) in [ + (spec.altair_fork_epoch, ForkName::Phase0, ForkName::Altair), + (spec.bellatrix_fork_epoch, ForkName::Altair, ForkName::Bellatrix), + (spec.capella_fork_epoch, ForkName::Bellatrix, ForkName::Capella), + (spec.deneb_fork_epoch, ForkName::Capella, ForkName::Deneb), + (spec.electra_fork_epoch, ForkName::Deneb, ForkName::Electra), + (spec.fulu_fork_epoch, ForkName::Electra, ForkName::Fulu), + ] { + assert_eq!(spec.fork_at(epoch - 1), before, "epoch {epoch} - 1"); + assert_eq!(spec.fork_at(epoch), after, "epoch {epoch}"); + assert_eq!(spec.fork_at(epoch + 1), after, "epoch {epoch} + 1"); + } + + assert_eq!(spec.fork_at(u64::MAX - 1), ForkName::Fulu, "Gloas is unscheduled on mainnet"); + } + + #[test] + fn fork_at_slot_switches_on_the_activation_epoch_boundary() { + let spec = SpecConfig { gloas_fork_epoch: 500_000, ..SpecConfig::mainnet() }; + let first_gloas_slot = 500_000 * SLOTS_PER_EPOCH; + assert_eq!(spec.fork_at_slot(first_gloas_slot - 1), ForkName::Fulu); + assert_eq!(spec.fork_at_slot(first_gloas_slot), ForkName::Gloas); + } + + /// These strings go on the wire in `Eth-Consensus-Version` and in the + /// `version` field of every versioned beacon-API body. + #[test] + fn fork_names_match_the_wire_spelling() { + assert_eq!( + [ + ForkName::Phase0, + ForkName::Altair, + ForkName::Bellatrix, + ForkName::Capella, + ForkName::Deneb, + ForkName::Electra, + ForkName::Fulu, + ForkName::Gloas, + ] + .map(ForkName::name), + ["phase0", "altair", "bellatrix", "capella", "deneb", "electra", "fulu", "gloas"] + ); + } +} diff --git a/crates/engine_api/src/api.rs b/crates/engine_api/src/api.rs index 3c485e1c..8da1caf3 100644 --- a/crates/engine_api/src/api.rs +++ b/crates/engine_api/src/api.rs @@ -64,6 +64,12 @@ impl EngineApi { } } + /// Last status the EL reported to `eth_syncing`; `Unknown` until the + /// first healthcheck completes. + pub fn sync_status(&self) -> ELSyncStatus { + self.sync_status + } + pub fn intake(&mut self, adapter: &mut SpineAdapter) { self.rpc_consumer.free(); self.gossip_consumer.free(); @@ -73,6 +79,7 @@ impl EngineApi { // gate on EL liveness, then answer every request with VALID. if self.first_run { adapter.produce(EngineHealthEvent { sync_status: ELSyncStatus::Synced }); + self.sync_status = ELSyncStatus::Synced; self.first_run = false; } let resp_producer = &mut self.resp_producer; From 24b5755b5fd0e3237f0c0ed100460f2641e5c00e Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 18 Aug 2026 18:16:16 +0100 Subject: [PATCH 16/16] Doc: ClientServer consumes beacon_events and sync_target for node status The spine-flow doc predated the NodeStatus wiring and still claimed the beacon_api server side has no spine edges. Add the two consumer edges (diagram, tile list, queue table) that refresh_node_status introduced. Assisted-by: Claude:claude-fable-5 --- docs/spine-message-flow.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/spine-message-flow.md b/docs/spine-message-flow.md index 1deeeee2..ea1a937c 100644 --- a/docs/spine-message-flow.md +++ b/docs/spine-message-flow.md @@ -9,8 +9,8 @@ The tiles: **Network** (QUIC + discv5), **Control** (`PeerManager` + `SyncEngine `GossipHandler` — gossipsub decode/encode runs in-tile, not as its own tile), **BeaconState** (state transition + fork choice), **Storage** (disk + backfill), **ClientServer** (hosting the `engine_api` client and the `beacon_api` server; the -server talks HTTP only, so it has no spine edges of its own), **DataColumns** (column -validation, DA tracking, EL blob fetch — split out of Storage). +server side consumes `beacon_events` and `sync_target` to report node status), +**DataColumns** (column validation, DA tracking, EL blob fetch — split out of Storage). ```mermaid flowchart LR @@ -46,6 +46,7 @@ flowchart LR BS -->|beacon_events : BeaconStateEvent| CTL BS -->|beacon_events : BeaconStateEvent| ST BS -->|beacon_events : BeaconStateEvent| DC + BS -->|beacon_events : BeaconStateEvent| EN DC -->|"data_columns : DataColumnsEvent (Available)"| BS DC -->|"data_columns : DataColumnsEvent (Persist)"| ST ST -->|replay_blocks : ReplayBlock| BS @@ -54,6 +55,7 @@ flowchart LR CTL -->|sync_target : SyncUpdate| BS CTL -->|sync_target : SyncUpdate| ST CTL -->|sync_target : SyncUpdate| DC + CTL -->|sync_target : SyncUpdate| EN CTL -->|syncing_strategy : SyncingStrategy| ST CTL -->|syncing_strategy : SyncingStrategy| DC @@ -94,9 +96,9 @@ rest. | `rpc_inbound` | `RpcInbound` | Network | Control, BeaconState, Storage, DataColumns | ref → `incoming_rpc` | | `peer_events` | `PeerEvent` | Network, BeaconState, Storage, DataColumns | Control | mostly inline; `SendGossip` ref → `outgoing_gossip`, `PublishDataColumn` ref → `incoming_rpc` | | `peer_control` | `PeerControl` | Control | Network, Storage | inline | -| `beacon_events` | `BeaconStateEvent` | BeaconState | Control, Storage, DataColumns | mostly inline; `PersistBlock`/`PersistEnvelope` refs → `ssz_gossip` / `incoming_rpc` (by source) | +| `beacon_events` | `BeaconStateEvent` | BeaconState | Control, Storage, DataColumns, ClientServer | mostly inline; `PersistBlock`/`PersistEnvelope` refs → `ssz_gossip` / `incoming_rpc` (by source) | | `data_columns` | `DataColumnsEvent` | DataColumns | BeaconState _(Available)_, Storage _(Persist)_ | `Available` inline; `Persist` ref → `ssz_gossip` / `incoming_rpc` / `el_data_columns` (by `ColumnSource`) | -| `sync_target` | `SyncUpdate` | Control | BeaconState, Storage, DataColumns | inline | +| `sync_target` | `SyncUpdate` | Control | BeaconState, Storage, DataColumns, ClientServer | inline | | `replay_blocks` | `ReplayBlock` | Storage | BeaconState | ref → `replay_blocks` tcache | | `syncing_strategy` | `SyncingStrategy` | Control | Storage, DataColumns | inline | | `engine_reqs` | `EngineReq` | BeaconState, DataColumns _(GetBlobs)_ | ClientServer | refs → `ssz_gossip` / `incoming_rpc`; GetBlobs inline |