Phase 2c: streaming Blob RPC (BlobPutStream / BlobGetStream)

Removes the 16 MiB message cap for blob transfers. The bounded Blob*
methods from Phase 2b still exist; the streaming variants let a peer
push or pull a many-GB blob without either side holding it in memory.

## Wire format

Streaming methods use a slightly different reply shape so the client
can route on the first byte alone:

  Reply : status:u8 || payload:bytes...

Where `status` is either `STREAM_STATUS_OK` (0x00, content follows)
or a single-byte ErrorCode. `serve_connection` now peeks at the
method tag byte via read_exact and hands streaming methods the raw
send/recv streams; bounded methods still use the old read_to_end
path.

## Method additions

- BlobPutStream (0x07): client streams bytes → server pipes into
  BlobStore::put_stream → reply is 0x00 || 32-byte BlobId
- BlobGetStream (0x08): client sends 32-byte BlobId → server verifies
  existence, writes 0x00 status, then streams chunks from disk into
  the send stream

Method::is_streaming() introspection so callers can decide which
wire variant to use.

## BlobStore additions

- put_stream<R: AsyncRead + Unpin>(reader) -> BlobId
  Memory ceiling: one CHUNK_SIZE (4 MiB) buffer regardless of blob
  size. Handles short-reads correctly (loops until CHUNK_SIZE bytes
  are available or EOF), including the empty-reader case (produces
  the empty-blob BlobId, zero chunks).

- stream_to<W: AsyncWrite + Unpin>(id, writer) -> bool
  Ok(false) on NotFound (writer untouched). Verifies each chunk hash
  before emitting; corruption halts mid-stream with Err.

## Client helpers

- call_blob_put_stream(conn, reader) -> Result<BlobId>
  Uses tokio::io::copy directly onto quinn's SendStream.
- call_blob_get_stream(conn, id, writer) -> Result<bool>
  Ok(false) on NotFound; other errors surface as Err.

## Tests (11 new, all real — no mocks)

Blob store (6):
- put_stream_produces_same_hash_as_put_bytes (3-chunk blob via Cursor)
- put_stream_handles_empty_reader (produces empty-blob BlobId)
- put_stream_handles_short_reads (custom Trickle reader that only
  serves 100 bytes per read call — must still assemble full chunks)
- stream_to_writes_full_blob (2-chunk write to Vec<u8>)
- stream_to_returns_false_when_missing (writer untouched)
- stream_to_detects_chunk_corruption (bit-flip a chunk → Err with
  "chunk hash mismatch")

RPC (5):
- method_reports_streaming_variants
- end_to_end_stream_put_and_get_over_real_quic — 12 MiB + 777 bytes
  → 4 chunks, real 2-node QUIC + mTLS + stream round-trip
- stream_get_returns_false_for_missing_blob
- stream_methods_return_not_configured_without_store
- stream_put_deduplicates_with_prior_put_bytes — verify streaming
  put produces the same BlobId as a prior bounded put on identical
  content, and the manifest chunk count didn't fork

## Housekeeping

rpc.rs was tipping over the 1300-line ceiling with the streaming
handlers + helpers + tests. Tests split into `cluster/rpc/tests.rs`
via `#[path = "rpc/tests.rs"] mod tests;`. Result:
- rpc.rs: 748 lines
- rpc/tests.rs: 694 lines
- blob.rs: 1002 lines
- All under ceiling.

139 tests pass. Pre-existing macOS-only failure unchanged.

## What's next

- Phase 2d: chunk-level RPC (BlobPutChunk / BlobGetChunk) so a
  receiver can `LoadManifest` then request only the chunks it's
  missing — big bandwidth win on partially-overlapping caches.
- Phase 3: CRDT metadata for human-readable namespaces on top of
  content hashes.
- Phase 5: the killer feature — fingerprint the cargo target dir,
  BlobPutStream it, next node BlobGetStream by the same fingerprint.
  Now buildable directly on Phase 2c since target dirs run 100 MB
  to a few GB and the previous 16 MiB cap would have blocked us.
This commit is contained in:
Omar Sobh
2026-07-11 23:14:11 -07:00
parent f43b34ad15
commit 1fd1027da4
3 changed files with 1146 additions and 510 deletions
+694
View File
@@ -0,0 +1,694 @@
//! Tests split out of rpc.rs to keep the parent under the 1300-line ceiling.
//! Same visibility as `#[cfg(test)] mod tests { ... }` — everything reaches
//! parent private items via `use super::*`.
use super::*;
use crate::cluster::transport::{NodeIdentity, QuicClient, QuicServer};
use crate::config::{ClusterConfig, PeerEntry};
use std::net::SocketAddr;
use std::sync::atomic::{AtomicU16, Ordering};
use std::time::Duration;
/// Dedicated port range for RPC tests, distinct from gossip (41000+)
/// and transport (42000+) so parallel tests never conflict.
static NEXT_PORT: AtomicU16 = AtomicU16::new(43001);
fn next_port() -> u16 {
NEXT_PORT.fetch_add(1, Ordering::Relaxed)
}
fn loopback(port: u16) -> SocketAddr {
format!("127.0.0.1:{port}").parse().unwrap()
}
async fn bootstrap_gossip(name: &str, port: u16) -> Arc<ClusterGossip> {
let cfg = ClusterConfig {
zone: "fabric-10g".into(),
bind_lan: Some(loopback(port)),
..Default::default()
};
Arc::new(ClusterGossip::bootstrap(&cfg, name).await.unwrap())
}
#[test]
fn method_round_trips_byte_encoding() {
assert_eq!(Method::Ping.as_byte(), 0x01);
assert_eq!(Method::PeerStatus.as_byte(), 0x02);
assert_eq!(Method::BlobStat.as_byte(), 0x03);
assert_eq!(Method::BlobGet.as_byte(), 0x04);
assert_eq!(Method::BlobPut.as_byte(), 0x05);
assert_eq!(Method::BlobLoadManifest.as_byte(), 0x06);
for m in [
Method::Ping,
Method::PeerStatus,
Method::BlobStat,
Method::BlobGet,
Method::BlobPut,
Method::BlobLoadManifest,
] {
assert_eq!(Method::from_byte(m.as_byte()), Some(m));
}
assert_eq!(Method::from_byte(0x00), None);
assert_eq!(Method::from_byte(0xff), None);
}
fn open_blob_store() -> (tempfile::TempDir, Arc<BlobStore>) {
let tmp = tempfile::TempDir::new().unwrap();
let store = Arc::new(BlobStore::open(tmp.path().to_path_buf()).unwrap());
(tmp, store)
}
async fn router_with_blobs(name: &str, port: u16) -> (tempfile::TempDir, Arc<RpcRouter>) {
let gossip = bootstrap_gossip(name, port).await;
let (tmp, store) = open_blob_store();
let router = Arc::new(
RpcRouter::new(gossip, name.into(), "fabric-10g".into())
.with_blob_store(store),
);
(tmp, router)
}
#[tokio::test]
async fn dispatch_returns_pong_for_ping() {
let gossip = bootstrap_gossip("solo", next_port()).await;
let router = RpcRouter::new(gossip.clone(), "solo".into(), "test-zone".into());
// Direct dispatch — no network involved.
let reply = dispatch(&router, &[Method::Ping.as_byte(), b'h', b'i']).await;
assert_eq!(reply, b"pong:hi");
gossip.peer("nobody").await; // touch to keep gossip alive
}
#[tokio::test]
async fn dispatch_returns_json_for_peer_status() {
let gossip = bootstrap_gossip("architect", next_port()).await;
let router = RpcRouter::new(
gossip.clone(),
"architect".into(),
"fabric-10g".into(),
);
let reply = dispatch(&router, &[Method::PeerStatus.as_byte()]).await;
let decoded: PeerStatusReply = serde_json::from_slice(&reply).unwrap();
assert_eq!(decoded.local_name, "architect");
assert_eq!(decoded.local_zone, "fabric-10g");
// Solo node — no peers yet.
assert!(decoded.peers.is_empty(), "solo node has no peers");
}
#[tokio::test]
async fn dispatch_returns_empty_request_error() {
let gossip = bootstrap_gossip("solo", next_port()).await;
let router = RpcRouter::new(gossip.clone(), "solo".into(), "z".into());
let reply = dispatch(&router, &[]).await;
assert_eq!(reply, vec![ErrorCode::EmptyRequest.as_byte()]);
}
#[tokio::test]
async fn dispatch_returns_unknown_method_error() {
let gossip = bootstrap_gossip("solo", next_port()).await;
let router = RpcRouter::new(gossip.clone(), "solo".into(), "z".into());
let reply = dispatch(&router, &[0xab, 0x01, 0x02]).await;
assert_eq!(reply, vec![ErrorCode::UnknownMethod.as_byte()]);
}
#[tokio::test]
async fn rpc_call_rejects_oversize_payload() {
// No network involved — the size check runs client-side before we
// even try to open the bidi stream. Use a dummy connection built
// via generate_test_pair; we won't actually connect.
let (id_a, id_b) = NodeIdentity::generate_test_pair("a", "b").unwrap();
let server = QuicServer::bind(loopback(0), id_b).unwrap();
let server_addr = server.local_addr().unwrap();
let _accept = tokio::spawn(async move {
let _ = server.accept().await;
});
let client = QuicClient::new(loopback(0), id_a).unwrap();
let conn = client.connect(server_addr, "b").await.unwrap();
let huge = vec![0u8; MAX_MESSAGE_BYTES];
let err = rpc_call(&conn, Method::Ping, &huge)
.await
.err()
.expect("must reject oversize");
assert!(err.to_string().contains("exceeds cap"));
conn.close(quinn::VarInt::from_u32(0), b"done");
client.shutdown().await;
}
#[tokio::test]
async fn end_to_end_ping_and_peer_status_over_real_quic() {
// Two full nodes: A runs gossip + a QuicServer serving RpcRouter.
// B is a client that dials A and calls both RPCs.
let (id_a, id_b) = NodeIdentity::generate_test_pair("a", "b").unwrap();
let port_a = next_port();
let gossip_a = bootstrap_gossip("a", port_a).await;
let router = Arc::new(RpcRouter::new(
gossip_a.clone(),
"a".into(),
"fabric-10g".into(),
));
// A's advertised state so the PeerStatus reply exercises the
// gossip → PeerView path even though B isn't in the peer table.
gossip_a.set_hot_used(4096).await;
gossip_a.set_hot_max(1_000_000).await;
gossip_a
.set_warm_projects(&["osobh/clawverse", "osobh/clawmates"])
.await;
let server = QuicServer::bind(loopback(0), id_a).unwrap();
let server_addr = server.local_addr().unwrap();
let accept_task = tokio::spawn(async move {
if let Some(Ok(conn)) = server.accept().await {
let _ = serve_connection(conn, router).await;
}
});
let client = QuicClient::new(loopback(0), id_b).unwrap();
let conn = client.connect(server_addr, "a").await.unwrap();
// Ping.
let pong = call_ping(&conn, b"hello").await.unwrap();
assert_eq!(pong, b"hello");
// PeerStatus.
let status = call_peer_status(&conn).await.unwrap();
assert_eq!(status.local_name, "a");
assert_eq!(status.local_zone, "fabric-10g");
assert!(status.peers.is_empty(), "A has no peers configured");
conn.close(quinn::VarInt::from_u32(0), b"done");
client.shutdown().await;
tokio::time::sleep(Duration::from_millis(50)).await;
accept_task.abort();
// Keep gossip_a alive to the end so its background task doesn't
// drop mid-serve.
let _ = gossip_a.peers().await;
}
#[tokio::test]
async fn peer_status_reflects_peer_gossip_state() {
// A and B both run gossip; A serves RPC. When A's PeerStatus is
// called by a third-party client, the reply's `peers` field
// contains B (as long as gossip has converged).
let (id_a, id_b) = NodeIdentity::generate_test_pair("a", "b").unwrap();
let port_a = next_port();
let port_b = next_port();
let cfg_a = ClusterConfig {
zone: "fabric-10g".into(),
bind_lan: Some(loopback(port_a)),
..Default::default()
};
let cfg_b = ClusterConfig {
zone: "lan-1g".into(),
bind_lan: Some(loopback(port_b)),
peers: vec![PeerEntry {
name: "a".into(),
zone: "fabric-10g".into(),
lan_addr: Some(loopback(port_a)),
tailscale_addr: None,
}],
..Default::default()
};
let gossip_a = Arc::new(ClusterGossip::bootstrap(&cfg_a, "a").await.unwrap());
let gossip_b = Arc::new(ClusterGossip::bootstrap(&cfg_b, "b").await.unwrap());
let router = Arc::new(RpcRouter::new(
gossip_a.clone(),
"a".into(),
"fabric-10g".into(),
));
let server = QuicServer::bind(loopback(0), id_a).unwrap();
let server_addr = server.local_addr().unwrap();
let accept_task = tokio::spawn(async move {
if let Some(Ok(conn)) = server.accept().await {
let _ = serve_connection(conn, router).await;
}
});
// Wait for gossip convergence: A must see B.
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
if let Some(v) = gossip_a.peer("b").await {
if v.alive {
break;
}
}
if std::time::Instant::now() >= deadline {
panic!("A never saw B alive within 10s");
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
let client = QuicClient::new(loopback(0), id_b).unwrap();
let conn = client.connect(server_addr, "a").await.unwrap();
let status = call_peer_status(&conn).await.unwrap();
assert_eq!(status.local_name, "a");
assert_eq!(status.peers.len(), 1, "A should report exactly B");
assert_eq!(status.peers[0].name, "b");
assert_eq!(status.peers[0].zone, "lan-1g");
assert!(status.peers[0].alive);
conn.close(quinn::VarInt::from_u32(0), b"done");
client.shutdown().await;
tokio::time::sleep(Duration::from_millis(50)).await;
accept_task.abort();
// Keep gossip services alive until end.
drop(gossip_b);
}
#[test]
fn error_code_describe_covers_all_variants() {
assert_eq!(ErrorCode::EmptyRequest.describe(), "empty request");
assert_eq!(ErrorCode::UnknownMethod.describe(), "unknown method");
assert_eq!(ErrorCode::HandlerFailure.describe(), "handler failure");
assert_eq!(ErrorCode::NotFound.describe(), "not found");
assert_eq!(ErrorCode::InvalidRequest.describe(), "invalid request");
assert_eq!(
ErrorCode::NotConfigured.describe(),
"server subsystem not configured"
);
}
#[test]
fn decode_error_covers_all_known_codes() {
for code in [
ErrorCode::EmptyRequest,
ErrorCode::UnknownMethod,
ErrorCode::HandlerFailure,
ErrorCode::NotFound,
ErrorCode::InvalidRequest,
ErrorCode::NotConfigured,
] {
assert_eq!(decode_error(code.as_byte()), Some(code));
}
assert_eq!(decode_error(0x00), None);
assert_eq!(decode_error(0xff), None);
}
// ── Phase 2b: Blob RPC ─────────────────────────────────────────────
#[tokio::test]
async fn blob_rpcs_return_not_configured_without_store() {
// Router built via `new` alone (no `.with_blob_store`) must
// refuse Blob* methods with a well-known error code.
let gossip = bootstrap_gossip("solo", next_port()).await;
let router = RpcRouter::new(gossip, "solo".into(), "z".into());
for method in [
Method::BlobStat,
Method::BlobGet,
Method::BlobPut,
Method::BlobLoadManifest,
] {
let mut req = vec![method.as_byte()];
req.extend_from_slice(&[0u8; 32]);
let reply = dispatch(&router, &req).await;
assert_eq!(
reply,
vec![ErrorCode::NotConfigured.as_byte()],
"method {method:?} should be NotConfigured without a store"
);
}
}
#[tokio::test]
async fn blob_stat_returns_not_found_for_missing() {
let (_tmp, router) = router_with_blobs("solo", next_port()).await;
let missing = BlobId::from_bytes([0u8; 32]);
let mut req = vec![Method::BlobStat.as_byte()];
req.extend_from_slice(missing.as_bytes());
let reply = dispatch(&router, &req).await;
assert_eq!(reply, vec![ErrorCode::NotFound.as_byte()]);
}
#[tokio::test]
async fn blob_stat_returns_json_for_existing() {
let (_tmp, router) = router_with_blobs("solo", next_port()).await;
let id = router
.blob_store()
.unwrap()
.put_bytes(b"tiny content")
.await
.unwrap();
let mut req = vec![Method::BlobStat.as_byte()];
req.extend_from_slice(id.as_bytes());
let reply = dispatch(&router, &req).await;
let stat: BlobStat = serde_json::from_slice(&reply).unwrap();
assert_eq!(stat.total_size, b"tiny content".len() as u64);
assert_eq!(stat.chunk_count, 1);
}
#[tokio::test]
async fn blob_stat_returns_invalid_request_for_bad_length() {
let (_tmp, router) = router_with_blobs("solo", next_port()).await;
// Payload is only 5 bytes; a valid BlobId is 32.
let req = vec![Method::BlobStat.as_byte(), 1, 2, 3, 4, 5];
let reply = dispatch(&router, &req).await;
assert_eq!(reply, vec![ErrorCode::InvalidRequest.as_byte()]);
}
#[tokio::test]
async fn blob_get_returns_content_bytes() {
let (_tmp, router) = router_with_blobs("solo", next_port()).await;
let payload: &[u8] = b"contents to retrieve";
let id = router.blob_store().unwrap().put_bytes(payload).await.unwrap();
let mut req = vec![Method::BlobGet.as_byte()];
req.extend_from_slice(id.as_bytes());
let reply = dispatch(&router, &req).await;
assert_eq!(reply, payload);
}
#[tokio::test]
async fn blob_put_stores_bytes_and_returns_hash() {
let (_tmp, router) = router_with_blobs("solo", next_port()).await;
let payload = b"put via rpc";
let mut req = vec![Method::BlobPut.as_byte()];
req.extend_from_slice(payload);
let reply = dispatch(&router, &req).await;
assert_eq!(reply.len(), 32);
let mut id_bytes = [0u8; 32];
id_bytes.copy_from_slice(&reply);
let assigned = BlobId::from_bytes(id_bytes);
let expected = BlobId::from_bytes(blake3::hash(payload).into());
assert_eq!(assigned, expected);
// Round-trip: the bytes are now readable via the store.
let round = router
.blob_store()
.unwrap()
.get_bytes(&assigned)
.await
.unwrap();
assert_eq!(round.as_deref(), Some(payload.as_slice()));
}
#[tokio::test]
async fn blob_load_manifest_returns_json_for_existing() {
let (_tmp, router) = router_with_blobs("solo", next_port()).await;
let data = vec![0x77u8; 4 * 1024 * 1024 + 1]; // 2 chunks
let id = router.blob_store().unwrap().put_bytes(&data).await.unwrap();
let mut req = vec![Method::BlobLoadManifest.as_byte()];
req.extend_from_slice(id.as_bytes());
let reply = dispatch(&router, &req).await;
let manifest: BlobManifest = serde_json::from_slice(&reply).unwrap();
assert_eq!(manifest.blob_id, id);
assert_eq!(manifest.total_size, data.len() as u64);
assert_eq!(manifest.chunks.len(), 2);
}
#[tokio::test]
async fn blob_load_manifest_returns_not_found_for_missing() {
let (_tmp, router) = router_with_blobs("solo", next_port()).await;
let missing = BlobId::from_bytes([0u8; 32]);
let mut req = vec![Method::BlobLoadManifest.as_byte()];
req.extend_from_slice(missing.as_bytes());
let reply = dispatch(&router, &req).await;
assert_eq!(reply, vec![ErrorCode::NotFound.as_byte()]);
}
#[tokio::test]
async fn end_to_end_blob_put_stat_get_over_real_quic() {
// The full loop: B → A over real QUIC + mTLS.
// 1. B puts a blob on A (BlobPut).
// 2. B queries stat + fetches it back (BlobStat + BlobGet).
// 3. B asks for the manifest (BlobLoadManifest).
// Every step goes through the actual wire, no shortcuts.
let (id_a, id_b) = NodeIdentity::generate_test_pair("a", "b").unwrap();
let (_tmp, router) = router_with_blobs("a", next_port()).await;
let server = QuicServer::bind(loopback(0), id_a).unwrap();
let server_addr = server.local_addr().unwrap();
let router_srv = router.clone();
let accept_task = tokio::spawn(async move {
if let Some(Ok(conn)) = server.accept().await {
let _ = serve_connection(conn, router_srv).await;
}
});
let client = QuicClient::new(loopback(0), id_b).unwrap();
let conn = client.connect(server_addr, "a").await.unwrap();
let payload: &[u8] = b"cross-node payload";
let assigned = call_blob_put(&conn, payload).await.unwrap();
let expected = BlobId::from_bytes(blake3::hash(payload).into());
assert_eq!(assigned, expected);
let stat = call_blob_stat(&conn, &assigned).await.unwrap().unwrap();
assert_eq!(stat.total_size, payload.len() as u64);
assert_eq!(stat.chunk_count, 1);
let round = call_blob_get(&conn, &assigned).await.unwrap().unwrap();
assert_eq!(round, payload);
let manifest = call_blob_load_manifest(&conn, &assigned)
.await
.unwrap()
.unwrap();
assert_eq!(manifest.blob_id, assigned);
assert_eq!(manifest.chunks.len(), 1);
// NotFound path also works over the wire.
let ghost = BlobId::from_bytes([0u8; 32]);
assert!(call_blob_stat(&conn, &ghost).await.unwrap().is_none());
assert!(call_blob_get(&conn, &ghost).await.unwrap().is_none());
assert!(call_blob_load_manifest(&conn, &ghost)
.await
.unwrap()
.is_none());
conn.close(quinn::VarInt::from_u32(0), b"done");
client.shutdown().await;
tokio::time::sleep(Duration::from_millis(50)).await;
accept_task.abort();
}
#[tokio::test]
async fn end_to_end_multi_chunk_blob_over_real_quic() {
// 6 MB blob → 2 chunks. Round-trips whole via BlobPut/Get.
// Also confirms the RPC layer's MAX_MESSAGE_BYTES bump from
// 16 KiB to 16 MiB actually took effect end-to-end.
let (id_a, id_b) = NodeIdentity::generate_test_pair("a", "b").unwrap();
let (_tmp, router) = router_with_blobs("a", next_port()).await;
let server = QuicServer::bind(loopback(0), id_a).unwrap();
let server_addr = server.local_addr().unwrap();
let router_srv = router.clone();
let accept_task = tokio::spawn(async move {
if let Some(Ok(conn)) = server.accept().await {
let _ = serve_connection(conn, router_srv).await;
}
});
let client = QuicClient::new(loopback(0), id_b).unwrap();
let conn = client.connect(server_addr, "a").await.unwrap();
let payload: Vec<u8> = (0..6 * 1024 * 1024)
.map(|i| (i % 251) as u8)
.collect();
let assigned = call_blob_put(&conn, &payload).await.unwrap();
let manifest = call_blob_load_manifest(&conn, &assigned)
.await
.unwrap()
.unwrap();
assert_eq!(manifest.chunks.len(), 2, "6 MB should split into 2 chunks");
let round = call_blob_get(&conn, &assigned).await.unwrap().unwrap();
assert_eq!(round.len(), payload.len());
assert_eq!(round, payload);
conn.close(quinn::VarInt::from_u32(0), b"done");
client.shutdown().await;
tokio::time::sleep(Duration::from_millis(50)).await;
accept_task.abort();
}
// ── Phase 2c: streaming Blob RPC ─────────────────────────────────
#[test]
fn method_reports_streaming_variants() {
assert!(!Method::Ping.is_streaming());
assert!(!Method::PeerStatus.is_streaming());
assert!(!Method::BlobStat.is_streaming());
assert!(!Method::BlobGet.is_streaming());
assert!(!Method::BlobPut.is_streaming());
assert!(!Method::BlobLoadManifest.is_streaming());
assert!(Method::BlobPutStream.is_streaming());
assert!(Method::BlobGetStream.is_streaming());
}
#[tokio::test]
async fn end_to_end_stream_put_and_get_over_real_quic() {
// The whole point of Phase 2c: put a big blob without holding
// it in memory on either side. This test drives a 12 MiB
// payload (3 chunks) through BlobPutStream and BlobGetStream.
let (id_a, id_b) = NodeIdentity::generate_test_pair("a", "b").unwrap();
let (_tmp, router) = router_with_blobs("a", next_port()).await;
let server = QuicServer::bind(loopback(0), id_a).unwrap();
let server_addr = server.local_addr().unwrap();
let router_srv = router.clone();
let accept_task = tokio::spawn(async move {
if let Some(Ok(conn)) = server.accept().await {
let _ = serve_connection(conn, router_srv).await;
}
});
let client = QuicClient::new(loopback(0), id_b).unwrap();
let conn = client.connect(server_addr, "a").await.unwrap();
// 12 MiB payload; deliberately not a chunk multiple so the last
// chunk is short.
let mut payload: Vec<u8> = Vec::with_capacity(12 * 1024 * 1024 + 777);
for i in 0..12 * 1024 * 1024 + 777 {
payload.push((i % 251) as u8);
}
let reader = std::io::Cursor::new(payload.clone());
let id = call_blob_put_stream(&conn, reader).await.unwrap();
let expected = BlobId::from_bytes(blake3::hash(&payload).into());
assert_eq!(id, expected);
// Manifest verifies the chunk split.
let manifest = call_blob_load_manifest(&conn, &id).await.unwrap().unwrap();
assert_eq!(manifest.chunks.len(), 4, "12 MiB + 777 → 4 chunks");
assert_eq!(manifest.total_size, payload.len() as u64);
// Stream it back.
let mut sink: Vec<u8> = Vec::new();
let ok = call_blob_get_stream(&conn, &id, &mut sink).await.unwrap();
assert!(ok);
assert_eq!(sink.len(), payload.len());
assert_eq!(sink, payload);
conn.close(quinn::VarInt::from_u32(0), b"done");
client.shutdown().await;
tokio::time::sleep(Duration::from_millis(50)).await;
accept_task.abort();
}
#[tokio::test]
async fn stream_get_returns_false_for_missing_blob() {
let (id_a, id_b) = NodeIdentity::generate_test_pair("a", "b").unwrap();
let (_tmp, router) = router_with_blobs("a", next_port()).await;
let server = QuicServer::bind(loopback(0), id_a).unwrap();
let server_addr = server.local_addr().unwrap();
let router_srv = router.clone();
let accept_task = tokio::spawn(async move {
if let Some(Ok(conn)) = server.accept().await {
let _ = serve_connection(conn, router_srv).await;
}
});
let client = QuicClient::new(loopback(0), id_b).unwrap();
let conn = client.connect(server_addr, "a").await.unwrap();
let ghost = BlobId::from_bytes([0u8; 32]);
let mut sink: Vec<u8> = Vec::new();
let ok = call_blob_get_stream(&conn, &ghost, &mut sink)
.await
.unwrap();
assert!(!ok);
assert!(sink.is_empty());
conn.close(quinn::VarInt::from_u32(0), b"done");
client.shutdown().await;
tokio::time::sleep(Duration::from_millis(50)).await;
accept_task.abort();
}
#[tokio::test]
async fn stream_methods_return_not_configured_without_store() {
// Router built without a store: streaming methods must reply
// with NotConfigured as their first status byte.
let (id_a, id_b) = NodeIdentity::generate_test_pair("a", "b").unwrap();
let gossip = bootstrap_gossip("a", next_port()).await;
// NOTE: no `.with_blob_store(...)` — Blob* methods should error.
let router = Arc::new(RpcRouter::new(gossip, "a".into(), "z".into()));
let server = QuicServer::bind(loopback(0), id_a).unwrap();
let server_addr = server.local_addr().unwrap();
let router_srv = router.clone();
let accept_task = tokio::spawn(async move {
if let Some(Ok(conn)) = server.accept().await {
let _ = serve_connection(conn, router_srv).await;
}
});
let client = QuicClient::new(loopback(0), id_b).unwrap();
let conn = client.connect(server_addr, "a").await.unwrap();
// BlobPutStream on a store-less router → NotConfigured.
let reader = std::io::Cursor::new(b"never lands".to_vec());
let put_err = call_blob_put_stream(&conn, reader)
.await
.err()
.expect("no store → error");
assert!(
put_err.to_string().contains("not configured"),
"put_stream err: {put_err}"
);
// BlobGetStream: same error surface.
let ghost = BlobId::from_bytes([0u8; 32]);
let mut sink: Vec<u8> = Vec::new();
let get_err = call_blob_get_stream(&conn, &ghost, &mut sink)
.await
.err()
.expect("no store → error");
assert!(
get_err.to_string().contains("not configured"),
"get_stream err: {get_err}"
);
conn.close(quinn::VarInt::from_u32(0), b"done");
client.shutdown().await;
tokio::time::sleep(Duration::from_millis(50)).await;
accept_task.abort();
}
#[tokio::test]
async fn stream_put_deduplicates_with_prior_put_bytes() {
// Uploading the same content twice — once bounded, once
// streaming — yields the same BlobId AND doesn't double-store
// chunks. Proves stream + bounded are consistent addresses.
let (id_a, id_b) = NodeIdentity::generate_test_pair("a", "b").unwrap();
let (_tmp, router) = router_with_blobs("a", next_port()).await;
// Pre-populate via the local store's bounded API.
let payload = vec![0xdeu8; 4 * 1024 * 1024 + 100];
let pre_id = router
.blob_store()
.unwrap()
.put_bytes(&payload)
.await
.unwrap();
let server = QuicServer::bind(loopback(0), id_a).unwrap();
let server_addr = server.local_addr().unwrap();
let router_srv = router.clone();
let accept_task = tokio::spawn(async move {
if let Some(Ok(conn)) = server.accept().await {
let _ = serve_connection(conn, router_srv).await;
}
});
let client = QuicClient::new(loopback(0), id_b).unwrap();
let conn = client.connect(server_addr, "a").await.unwrap();
let reader = std::io::Cursor::new(payload.clone());
let stream_id = call_blob_put_stream(&conn, reader).await.unwrap();
assert_eq!(pre_id, stream_id);
// The manifest is still there and its chunk count matches the
// pre-existing one — no fork.
let manifest = router
.blob_store()
.unwrap()
.load_manifest(&stream_id)
.await
.unwrap()
.unwrap();
assert_eq!(manifest.chunks.len(), 2);
conn.close(quinn::VarInt::from_u32(0), b"done");
client.shutdown().await;
tokio::time::sleep(Duration::from_millis(50)).await;
accept_task.abort();
}