Chat file fetches failed 100% of the time with "file fetch: read failed: read error: connection lost" (both images and arbitrary files, both directions). Root cause: the FileRouter serve handler called send.finish() and immediately returned Ok(()), which dropped the Connection. In QUIC, finish() only marks the stream's EOF -- it does not wait for the written bytes to be delivered and acknowledged -- so the connection's CONNECTION_CLOSE raced ahead of the still-in-flight stream data and the fetcher's read_to_end aborted. Fix: after finishing, wait on connection.closed() (bounded by FILE_FETCH_TIMEOUT) so the link stays up until the fetcher has read everything and closed the connection itself, which is the signal the transfer landed. Wire-compatible (no protocol change), so version stays 0.3.0; both peers just need the rebuilt binary since either side can be the file server. Adds tests/file_transfer_loopback.rs: a real two-endpoint serve->fetch round-trip over FILES_ALPN with a 2 MiB multi-packet blob (deterministic A/B: 0/20 pass without the fix, 20/20 with it) plus an unknown-id "gone" case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
142 lines
5.0 KiB
Rust
142 lines
5.0 KiB
Rust
//! End-to-end loopback test for the chat file-transfer plane (`FILES_ALPN`).
|
|
//!
|
|
//! Spins up two real iroh endpoints on localhost (relay disabled, addresses
|
|
//! exchanged directly), registers the production [`FileRouter`] on each, serves a
|
|
//! multi-megabyte blob on one side, and fetches it from the other through the
|
|
//! real `serve_attachment`/`fetch_attachment` path.
|
|
//!
|
|
//! This is the regression guard for the "file fetch: read failed: connection
|
|
//! lost" bug: the serve handler used to return (and drop the connection) the
|
|
//! instant it called `finish()`, so the CONNECTION_CLOSE raced ahead of the
|
|
//! still-in-flight stream data and the fetcher's `read_to_end` aborted. A blob
|
|
//! large enough to span many packets makes that race deterministic — the fix
|
|
//! (waiting on `connection.closed()` before returning) keeps the link up until
|
|
//! the fetcher has the bytes.
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use iroh::address_lookup::memory::MemoryLookup;
|
|
use iroh::endpoint::presets;
|
|
use iroh::protocol::Router;
|
|
use iroh::{Endpoint, RelayMode};
|
|
|
|
use peerspeak::files::{ChatAttachment, AttachmentKind};
|
|
use peerspeak::network::NetworkTransport;
|
|
use peerspeak::network::iroh_impl::{FileRouter, IrohTransport};
|
|
use peerspeak::protocol::FILES_ALPN;
|
|
|
|
struct Node {
|
|
endpoint: Endpoint,
|
|
transport: Arc<IrohTransport>,
|
|
_router: Router,
|
|
lookup: MemoryLookup,
|
|
}
|
|
|
|
async fn spawn_node() -> Node {
|
|
let lookup = MemoryLookup::new();
|
|
let endpoint = Endpoint::builder(presets::Minimal)
|
|
.secret_key(iroh::SecretKey::generate())
|
|
.relay_mode(RelayMode::Disabled)
|
|
.address_lookup(lookup.clone())
|
|
.bind()
|
|
.await
|
|
.expect("bind endpoint");
|
|
|
|
let transport = Arc::new(IrohTransport::new(endpoint.clone()));
|
|
// Mirror production: a persistent FileRouter bound to this session's transport
|
|
// is what the router accepts inbound file fetches on.
|
|
let file_router = FileRouter::new();
|
|
file_router.bind(&transport);
|
|
let router = Router::builder(endpoint.clone())
|
|
.accept(FILES_ALPN, file_router)
|
|
.spawn();
|
|
|
|
Node { endpoint, transport, _router: router, lookup }
|
|
}
|
|
|
|
/// A pseudo-random-ish multi-megabyte payload spanning many QUIC packets, so a
|
|
/// premature connection close on the serve side reliably corrupts/aborts the read.
|
|
fn big_blob() -> Vec<u8> {
|
|
(0..(2 * 1024 * 1024u32))
|
|
.map(|i| (i.wrapping_mul(2654435761) >> 13) as u8)
|
|
.collect()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn loopback_attachment_round_trips_intact() {
|
|
let server = spawn_node().await;
|
|
let client = spawn_node().await;
|
|
|
|
// Seed each side with the other's full address so direct dialing works.
|
|
server.lookup.add_endpoint_info(client.endpoint.addr());
|
|
client.lookup.add_endpoint_info(server.endpoint.addr());
|
|
|
|
let server_id = server.endpoint.id();
|
|
let client_id = client.endpoint.id();
|
|
|
|
// The serve handler gates on room membership (the audio admission roster), so
|
|
// the server must admit the client before it will answer the fetch.
|
|
server.transport.admit_audio_sender(client_id);
|
|
client.transport.admit_audio_sender(server_id);
|
|
|
|
// The fetcher dials the retained full address; seed it so fetch_attachment
|
|
// doesn't have to fall back to a bare-id lookup.
|
|
client.transport.connect_peer(server.endpoint.addr()).await;
|
|
|
|
let blob = big_blob();
|
|
let id = [42u8; 32];
|
|
server.transport.serve_attachment(id, Arc::new(blob.clone()));
|
|
|
|
let att = ChatAttachment {
|
|
name: "exterior-landscape.jpg".to_string(),
|
|
size: blob.len() as u64,
|
|
kind: AttachmentKind::Image,
|
|
id,
|
|
};
|
|
|
|
let fetched = tokio::time::timeout(
|
|
Duration::from_secs(30),
|
|
client.transport.fetch_attachment(server_id, &att),
|
|
)
|
|
.await
|
|
.expect("fetch did not time out")
|
|
.expect("fetch succeeded");
|
|
|
|
assert_eq!(fetched.len(), blob.len(), "fetched the full blob");
|
|
assert_eq!(fetched, blob, "fetched bytes match served bytes exactly");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn loopback_unknown_id_reports_gone() {
|
|
let server = spawn_node().await;
|
|
let client = spawn_node().await;
|
|
|
|
server.lookup.add_endpoint_info(client.endpoint.addr());
|
|
client.lookup.add_endpoint_info(server.endpoint.addr());
|
|
|
|
let server_id = server.endpoint.id();
|
|
let client_id = client.endpoint.id();
|
|
server.transport.admit_audio_sender(client_id);
|
|
client.transport.admit_audio_sender(server_id);
|
|
client.transport.connect_peer(server.endpoint.addr()).await;
|
|
|
|
// Never served — the handler closes with an empty body and the fetcher must
|
|
// surface that as an error, not hang or return empty bytes.
|
|
let att = ChatAttachment {
|
|
name: "missing.bin".to_string(),
|
|
size: 4096,
|
|
kind: AttachmentKind::File,
|
|
id: [7u8; 32],
|
|
};
|
|
|
|
let result = tokio::time::timeout(
|
|
Duration::from_secs(30),
|
|
client.transport.fetch_attachment(server_id, &att),
|
|
)
|
|
.await
|
|
.expect("fetch did not time out");
|
|
|
|
assert!(result.is_err(), "unknown id should error, got {result:?}");
|
|
}
|