use std::{
future::Future,
io,
sync::{
Arc,
atomic::{AtomicBool, AtomicU32, Ordering},
},
time::Instant,
};
use async_trait::async_trait;
use bytes::Bytes;
use iroh_blobs::{
provider::{StreamPair, handle_stream, events::EventSender},
store::mem::MemStore,
Hash as IrohHash,
};
use iroh::endpoint::VarInt;
use tokio::sync::Mutex;
use xlb_net::{Endpoint, NodeAddr, NodeId};
use crate::{
BwCaps,
source::{BlobSource, FetchTier},
AssetClass, BlakeHash,
};
fn to_iroh(h: &BlakeHash) -> IrohHash {
IrohHash::from_bytes(*h.as_bytes())
}
#[derive(Debug)]
struct UploadLimiter {
cap_kbits: AtomicU32,
seeding_enabled: AtomicBool,
state: Mutex<LimiterState>,
}
#[derive(Debug)]
struct LimiterState {
tokens: f64,
last_tick: Instant,
}
impl UploadLimiter {
fn new() -> Arc<Self> {
Arc::new(Self {
cap_kbits: AtomicU32::new(0),
seeding_enabled: AtomicBool::new(true),
state: Mutex::new(LimiterState { tokens: 0.0, last_tick: Instant::now() }),
})
}
fn set_kbits(&self, cap_kbits: u32) {
self.cap_kbits.store(cap_kbits, Ordering::SeqCst);
}
fn set_enabled(&self, enabled: bool) {
self.seeding_enabled.store(enabled, Ordering::SeqCst);
}
async fn acquire(&self, n: usize) {
let cap = self.cap_kbits.load(Ordering::Relaxed);
if cap == 0 || n == 0 {
return;
}
let bytes_per_sec = (cap as f64 * 1_000.0) / 8.0;
let sleep = {
let mut s = self.state.lock().await;
let now = Instant::now();
let elapsed = now.duration_since(s.last_tick).as_secs_f64();
s.last_tick = now;
s.tokens = (s.tokens + elapsed * bytes_per_sec).min(bytes_per_sec * 2.0);
s.tokens -= n as f64;
if s.tokens < 0.0 {
std::time::Duration::from_secs_f64((-s.tokens) / bytes_per_sec)
} else {
std::time::Duration::ZERO
}
};
if !sleep.is_zero() {
tokio::time::sleep(sleep).await;
}
}
}
struct RateLimitedSendStream {
inner: iroh::endpoint::SendStream,
limiter: Arc<UploadLimiter>,
}
impl iroh_blobs::util::SendStream for RateLimitedSendStream {
async fn send_bytes(&mut self, bytes: Bytes) -> io::Result<()> {
self.limiter.acquire(bytes.len()).await;
Ok(self.inner.write_chunk(bytes).await
.map_err(|e| io::Error::other(e))?)
}
async fn send(&mut self, buf: &[u8]) -> io::Result<()> {
self.limiter.acquire(buf.len()).await;
Ok(self.inner.write_all(buf).await
.map_err(|e| io::Error::other(e))?)
}
async fn sync(&mut self) -> io::Result<()> {
Ok(())
}
fn reset(&mut self, code: VarInt) -> io::Result<()> {
self.inner.reset(code).map_err(|e| io::Error::other(e))
}
async fn stopped(&mut self) -> io::Result<Option<VarInt>> {
Ok(self.inner.stopped().await
.map_err(|e| io::Error::other(e))?
.map(|e| e.into()))
}
fn id(&self) -> u64 {
self.inner.id().index()
}
}
#[derive(Debug)]
struct RatedBlobsProtocol {
store: MemStore,
limiter: Arc<UploadLimiter>,
}
impl iroh::protocol::ProtocolHandler for RatedBlobsProtocol {
fn accept(
&self,
connection: iroh::endpoint::Connection,
) -> impl Future<Output = Result<(), iroh::protocol::AcceptError>> + Send {
let store: iroh_blobs::api::Store = (*self.store).clone();
let limiter = self.limiter.clone();
async move {
if !limiter.seeding_enabled.load(Ordering::Relaxed) {
connection.close(VarInt::from_u32(0), b"seeding disabled");
return Ok(());
}
let conn_id = connection.stable_id() as u64;
let events = EventSender::DEFAULT;
loop {
if !limiter.seeding_enabled.load(Ordering::Relaxed) {
break;
}
let (writer, reader) = match connection.accept_bi().await {
Ok(pair) => pair,
Err(_) => break,
};
let rated_writer = RateLimitedSendStream { inner: writer, limiter: limiter.clone() };
let pair = StreamPair::new(conn_id, reader, rated_writer, events.clone());
let store = store.clone();
tokio::spawn(async move {
let _ = handle_stream(pair, store).await;
});
}
Ok(())
}
}
}
pub struct BlobTransport {
store: MemStore,
router: iroh::protocol::Router,
endpoint: Endpoint,
upload_limiter: Arc<UploadLimiter>,
}
impl BlobTransport {
pub async fn new(endpoint: Endpoint) -> anyhow::Result<Self> {
let store = MemStore::new();
let upload_limiter = UploadLimiter::new();
let rated_proto = RatedBlobsProtocol { store: store.clone(), limiter: upload_limiter.clone() };
let router = iroh::protocol::Router::builder(endpoint.inner().clone())
.accept(iroh_blobs::ALPN, rated_proto)
.spawn();
Ok(Self { store, router, endpoint, upload_limiter })
}
pub fn set_upload_cap(&self, cap: Option<&BwCaps>) {
let kbits = cap.map(|c| c.up_mbit.saturating_mul(1000)).unwrap_or(0);
self.upload_limiter.set_kbits(kbits);
}
pub fn set_seeding_cap(&self, enabled: bool, kbits: u32) {
self.upload_limiter.set_enabled(enabled);
self.upload_limiter.set_kbits(if enabled { kbits } else { 0 });
}
pub async fn add_blob(&self, data: impl Into<Bytes>) -> anyhow::Result<BlakeHash> {
let data: Bytes = data.into();
let xlb_hash = BlakeHash::hash(&data);
let mut tt = self.store.add_bytes(data).temp_tag().await
.map_err(|e| anyhow::anyhow!("iroh-blobs add_bytes: {e}"))?;
tt.leak();
Ok(xlb_hash)
}
pub fn node_addr(&self) -> NodeAddr {
self.endpoint.endpoint_addr()
}
pub fn attach_fetcher(&self, class: &AssetClass, seeder: NodeAddr, tier: FetchTier) {
class.add_source(Arc::new(IrohFetcher {
tier,
endpoint: self.endpoint.clone(),
peer: seeder,
local_store: self.store.clone(),
}));
}
pub fn attach_permanent_seeds(&self, class: &AssetClass) -> SeedAttachReport {
let mut report = SeedAttachReport::default();
for (idx, raw) in class.permanent_seeds().iter().enumerate() {
match parse_seed_node_id(raw) {
Ok(addr) => {
self.attach_fetcher(class, addr, FetchTier::Seed);
report.attached += 1;
}
Err(err) => {
tracing::warn!(
class = class.name(),
index = idx,
entry = %raw,
"permanent_seeds: skipping malformed entry: {err}"
);
report.errors.push((idx, err));
}
}
}
report
}
pub async fn shutdown(self) {
if let Err(e) = self.router.shutdown().await {
tracing::warn!("BlobTransport::shutdown: {e}");
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum SeedParseError {
#[error("not valid hex")]
InvalidHex,
#[error("expected 32 bytes, got {got}")]
InvalidLength { got: usize },
#[error("not a valid Ed25519 public key")]
InvalidPubKey,
}
pub fn parse_seed_node_id(s: &str) -> Result<NodeAddr, SeedParseError> {
let bytes = hex::decode(s.trim()).map_err(|_| SeedParseError::InvalidHex)?;
let arr: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| SeedParseError::InvalidLength { got: bytes.len() })?;
let node_id = NodeId::from_bytes(&arr).map_err(|_| SeedParseError::InvalidPubKey)?;
Ok(NodeAddr::from(node_id))
}
#[derive(Debug, Default)]
pub struct SeedAttachReport {
pub attached: usize,
pub errors: Vec<(usize, SeedParseError)>,
}
struct IrohFetcher {
tier: FetchTier,
endpoint: Endpoint,
peer: NodeAddr,
local_store: MemStore,
}
#[async_trait]
impl BlobSource for IrohFetcher {
fn tier(&self) -> FetchTier {
self.tier
}
async fn fetch_raw(&self, hash: &BlakeHash) -> Option<Bytes> {
let iroh_hash = to_iroh(hash);
if let Ok(bytes) = self.local_store.get_bytes(iroh_hash).await {
return Some(bytes);
}
let conn = self
.endpoint
.inner()
.connect(self.peer.clone(), iroh_blobs::ALPN)
.await
.map_err(|e| tracing::warn!(%hash, "iroh-blobs connect: {e}"))
.ok()?;
self.local_store
.remote()
.fetch(conn, iroh_hash)
.await
.map_err(|e| tracing::warn!(%hash, "iroh-blobs fetch: {e}"))
.ok()?;
self.local_store
.get_bytes(iroh_hash)
.await
.map_err(|e| tracing::warn!(%hash, "iroh-blobs get_bytes: {e}"))
.ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use xlb_net::Keypair;
async fn make_transport() -> BlobTransport {
let kp = Keypair::generate();
let ep = Endpoint::builder().keypair(kp).bind().await.unwrap();
BlobTransport::new(ep).await.unwrap()
}
#[tokio::test]
async fn set_upload_cap_stores_kbits() {
let t = make_transport().await;
assert_eq!(t.upload_limiter.cap_kbits.load(Ordering::Relaxed), 0);
t.set_upload_cap(Some(&BwCaps { up_mbit: 5, down_mbit: 50 }));
assert_eq!(t.upload_limiter.cap_kbits.load(Ordering::Relaxed), 5_000);
t.set_upload_cap(None);
assert_eq!(t.upload_limiter.cap_kbits.load(Ordering::Relaxed), 0);
t.set_upload_cap(Some(&BwCaps { up_mbit: 0, down_mbit: 10 }));
assert_eq!(t.upload_limiter.cap_kbits.load(Ordering::Relaxed), 0);
t.set_seeding_cap(true, 500);
assert_eq!(t.upload_limiter.cap_kbits.load(Ordering::Relaxed), 500);
assert!(t.upload_limiter.seeding_enabled.load(Ordering::Relaxed));
t.set_seeding_cap(false, 500);
assert!(!t.upload_limiter.seeding_enabled.load(Ordering::Relaxed));
t.shutdown().await;
}
#[tokio::test]
async fn upload_limiter_uncapped_is_fast() {
let limiter = UploadLimiter::new();
let start = tokio::time::Instant::now();
for _ in 0..100 {
limiter.acquire(65536).await;
}
assert!(start.elapsed().as_millis() < 100, "uncapped acquire should be near-instant");
}
#[tokio::test]
async fn upload_limiter_cap_delays_writes() {
let limiter = UploadLimiter::new();
limiter.set_kbits(1000); let bytes_to_send: usize = 250_000; let start = tokio::time::Instant::now();
limiter.acquire(bytes_to_send).await;
let elapsed = start.elapsed();
assert!(
elapsed.as_millis() >= 1500,
"capped acquire should be rate-limited: {elapsed:?}"
);
}
#[tokio::test]
async fn upload_limiter_cap_can_be_lifted() {
let limiter = UploadLimiter::new();
limiter.set_kbits(1000); limiter.set_kbits(0);
let start = tokio::time::Instant::now();
limiter.acquire(1_000_000).await; assert!(
start.elapsed().as_millis() < 500,
"after lifting cap, acquire should be near-instant"
);
}
#[tokio::test]
async fn rate_limited_send_stream_id_delegates() {
let _ = make_transport().await.shutdown().await;
}
}