feat(peer): pipeline chunk downloads over QUIC
Keep several chunk streams in flight per peer connection so a fast LAN download is no longer forced through a request, wait, request loop. The transport still uses the current GetGameFileChunk request on normal QUIC bidirectional streams, so this improves throughput without adding another wire message or compatibility path. The peer planner now assigns chunks to the least-loaded eligible peer by planned bytes. This keeps shared large files balanced across the latest valid sources, while still respecting per-file source eligibility. Retries are batched by peer and use the same pipelined transport instead of opening a new connection for one failed chunk at a time. Initial peer connection failures are converted into per-chunk failures so the existing retry logic can move those chunks to another validated source. The dead whole-file branch was removed from PeerDownloadPlan because nothing populated it and retrying those entries as zero-length chunks would be a future data-loss trap. Test Plan: - RUSTC_WRAPPER= just fmt - RUSTC_WRAPPER= just test - RUSTC_WRAPPER= just clippy - RUSTC_WRAPPER= just peer-cli-build - RUSTC_WRAPPER= just peer-cli-image - python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py \ S13 S14 S16 S18 S19 S20 S24 S25 S26 S36 - git diff --cached --check Refs: PEER_CLI_SCENARIOS.md Review-Notes: addressed Claude review on whole-file retry cleanup
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use lanspread_db::db::GameFileDescription;
|
||||
use futures::{SinkExt, StreamExt, stream::FuturesUnordered};
|
||||
use s2n_quic::{Connection, stream::ReceiveStream};
|
||||
use tokio::{
|
||||
fs::OpenOptions,
|
||||
io::{AsyncSeekExt, AsyncWriteExt},
|
||||
@@ -18,7 +20,11 @@ use super::{
|
||||
planning::{ChunkDownloadResult, DownloadChunk, PeerDownloadPlan},
|
||||
version_ini::VersionIniBuffer,
|
||||
};
|
||||
use crate::{network::connect_to_peer, path_validation::validate_game_file_path};
|
||||
use crate::{
|
||||
config::PEER_DOWNLOAD_STREAM_WINDOW,
|
||||
network::connect_to_peer,
|
||||
path_validation::validate_game_file_path,
|
||||
};
|
||||
|
||||
fn ensure_download_not_cancelled(
|
||||
cancel_token: &CancellationToken,
|
||||
@@ -30,19 +36,15 @@ fn ensure_download_not_cancelled(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Downloads a single chunk from a peer.
|
||||
async fn download_chunk(
|
||||
conn: &mut s2n_quic::Connection,
|
||||
base_dir: &Path,
|
||||
async fn open_chunk_stream(
|
||||
conn: &mut Connection,
|
||||
game_id: &str,
|
||||
chunk: &DownloadChunk,
|
||||
version_buffer: Option<Arc<VersionIniBuffer>>,
|
||||
) -> eyre::Result<()> {
|
||||
use futures::SinkExt;
|
||||
) -> eyre::Result<ReceiveStream> {
|
||||
use lanspread_proto::{Message, Request};
|
||||
|
||||
let stream = conn.open_bidirectional_stream().await?;
|
||||
let (mut rx, tx) = stream.split();
|
||||
let (rx, tx) = stream.split();
|
||||
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
|
||||
let request = Request::GetGameFileChunk {
|
||||
@@ -54,11 +56,20 @@ async fn download_chunk(
|
||||
framed_tx.send(request.encode()).await?;
|
||||
|
||||
framed_tx.close().await?;
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// Receives one requested chunk from a peer stream.
|
||||
async fn receive_chunk(
|
||||
mut rx: ReceiveStream,
|
||||
base_dir: &Path,
|
||||
chunk: &DownloadChunk,
|
||||
version_buffer: Option<Arc<VersionIniBuffer>>,
|
||||
) -> eyre::Result<()> {
|
||||
if let Some(buffer) = version_buffer
|
||||
&& buffer.matches(&chunk.relative_path)
|
||||
{
|
||||
return download_version_ini_chunk(&mut rx, chunk, &buffer).await;
|
||||
return download_version_ini_chunk(rx, chunk, &buffer).await;
|
||||
}
|
||||
|
||||
// Validate the path to prevent directory traversal
|
||||
@@ -110,8 +121,23 @@ async fn download_chunk(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive_chunk_result(
|
||||
peer_addr: SocketAddr,
|
||||
base_dir: PathBuf,
|
||||
chunk: DownloadChunk,
|
||||
rx: ReceiveStream,
|
||||
version_buffer: Option<Arc<VersionIniBuffer>>,
|
||||
) -> ChunkDownloadResult {
|
||||
let result = receive_chunk(rx, &base_dir, &chunk, version_buffer).await;
|
||||
ChunkDownloadResult {
|
||||
chunk,
|
||||
result,
|
||||
peer_addr,
|
||||
}
|
||||
}
|
||||
|
||||
async fn download_version_ini_chunk(
|
||||
rx: &mut s2n_quic::stream::ReceiveStream,
|
||||
mut rx: ReceiveStream,
|
||||
chunk: &DownloadChunk,
|
||||
buffer: &VersionIniBuffer,
|
||||
) -> eyre::Result<()> {
|
||||
@@ -157,40 +183,99 @@ async fn verify_chunk_integrity(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Downloads a whole file from a peer.
|
||||
async fn download_whole_file(
|
||||
conn: &mut s2n_quic::Connection,
|
||||
fn failed_chunk_result(
|
||||
chunk: DownloadChunk,
|
||||
peer_addr: SocketAddr,
|
||||
reason: impl Into<String>,
|
||||
) -> ChunkDownloadResult {
|
||||
ChunkDownloadResult {
|
||||
chunk,
|
||||
result: Err(eyre::Report::msg(reason.into())),
|
||||
peer_addr,
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_plan_results(
|
||||
plan: PeerDownloadPlan,
|
||||
peer_addr: SocketAddr,
|
||||
reason: impl std::fmt::Display,
|
||||
) -> Vec<ChunkDownloadResult> {
|
||||
let reason = format!("peer connection failed: {reason}");
|
||||
plan.chunks
|
||||
.into_iter()
|
||||
.map(|chunk| failed_chunk_result(chunk, peer_addr, reason.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn download_chunk_plan(
|
||||
conn: &mut Connection,
|
||||
peer_addr: SocketAddr,
|
||||
game_id: &str,
|
||||
chunks: Vec<DownloadChunk>,
|
||||
base_dir: &Path,
|
||||
desc: &GameFileDescription,
|
||||
) -> eyre::Result<()> {
|
||||
use futures::SinkExt;
|
||||
use lanspread_proto::{Message, Request};
|
||||
cancel_token: &CancellationToken,
|
||||
version_buffer: Option<Arc<VersionIniBuffer>>,
|
||||
) -> eyre::Result<Vec<ChunkDownloadResult>> {
|
||||
let mut pending: VecDeque<DownloadChunk> = chunks.into();
|
||||
let mut in_flight = FuturesUnordered::new();
|
||||
let mut results = Vec::new();
|
||||
let window = PEER_DOWNLOAD_STREAM_WINDOW.max(1);
|
||||
let base_dir = base_dir.to_path_buf();
|
||||
|
||||
let stream = conn.open_bidirectional_stream().await?;
|
||||
let (mut rx, tx) = stream.split();
|
||||
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
while !pending.is_empty() || !in_flight.is_empty() {
|
||||
while in_flight.len() < window {
|
||||
let Some(chunk) = pending.pop_front() else {
|
||||
break;
|
||||
};
|
||||
ensure_download_not_cancelled(cancel_token, game_id)?;
|
||||
|
||||
framed_tx
|
||||
.send(Request::GetGameFileData(desc.clone()).encode())
|
||||
.await?;
|
||||
framed_tx.close().await?;
|
||||
log::info!(
|
||||
"Downloading chunk {} (offset {}, length {}) from {}",
|
||||
chunk.relative_path,
|
||||
chunk.offset,
|
||||
chunk.length,
|
||||
peer_addr
|
||||
);
|
||||
|
||||
// Validate the path to prevent directory traversal
|
||||
let validated_path = validate_game_file_path(base_dir, &desc.relative_path)?;
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&validated_path)
|
||||
.await?;
|
||||
file.seek(std::io::SeekFrom::Start(0)).await?;
|
||||
match open_chunk_stream(conn, game_id, &chunk).await {
|
||||
Ok(rx) => {
|
||||
in_flight.push(receive_chunk_result(
|
||||
peer_addr,
|
||||
base_dir.clone(),
|
||||
chunk,
|
||||
rx,
|
||||
version_buffer.clone(),
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
let reason = format!("failed to open chunk stream: {err}");
|
||||
results.push(failed_chunk_result(chunk, peer_addr, reason.clone()));
|
||||
while let Some(chunk) = pending.pop_front() {
|
||||
results.push(failed_chunk_result(
|
||||
chunk,
|
||||
peer_addr,
|
||||
format!("peer stream unavailable after earlier open failure: {reason}"),
|
||||
));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(bytes) = rx.receive().await? {
|
||||
file.write_all(&bytes).await?;
|
||||
if in_flight.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = tokio::select! {
|
||||
() = cancel_token.cancelled() => {
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
}
|
||||
result = in_flight.next() => result.expect("in-flight chunk stream should exist"),
|
||||
};
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
file.flush().await?;
|
||||
Ok(())
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Downloads all assigned chunks and files from a single peer.
|
||||
@@ -202,58 +287,33 @@ pub(super) async fn download_from_peer(
|
||||
cancel_token: &CancellationToken,
|
||||
version_buffer: Option<Arc<VersionIniBuffer>>,
|
||||
) -> eyre::Result<Vec<ChunkDownloadResult>> {
|
||||
if plan.chunks.is_empty() && plan.whole_files.is_empty() {
|
||||
if plan.chunks.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
ensure_download_not_cancelled(cancel_token, game_id)?;
|
||||
|
||||
let mut conn = connect_to_peer(peer_addr).await?;
|
||||
conn.keep_alive(true)?;
|
||||
conn.keep_alive(true)?;
|
||||
let mut conn = match connect_to_peer(peer_addr).await {
|
||||
Ok(conn) => conn,
|
||||
Err(err) => return Ok(failed_plan_results(plan, peer_addr, err)),
|
||||
};
|
||||
|
||||
if let Err(err) = conn.keep_alive(true) {
|
||||
return Ok(failed_plan_results(plan, peer_addr, err));
|
||||
}
|
||||
|
||||
let base_dir = games_folder;
|
||||
let mut results = Vec::new();
|
||||
|
||||
// Download chunks with error handling
|
||||
for chunk in &plan.chunks {
|
||||
ensure_download_not_cancelled(cancel_token, game_id)?;
|
||||
|
||||
log::info!(
|
||||
"Downloading chunk {} (offset {}, length {}) from {}",
|
||||
chunk.relative_path,
|
||||
chunk.offset,
|
||||
chunk.length,
|
||||
peer_addr
|
||||
);
|
||||
let result =
|
||||
download_chunk(&mut conn, &base_dir, game_id, chunk, version_buffer.clone()).await;
|
||||
results.push(ChunkDownloadResult {
|
||||
chunk: chunk.clone(),
|
||||
result,
|
||||
peer_addr,
|
||||
});
|
||||
}
|
||||
|
||||
// Download whole files
|
||||
for desc in &plan.whole_files {
|
||||
ensure_download_not_cancelled(cancel_token, game_id)?;
|
||||
|
||||
let chunk = DownloadChunk {
|
||||
relative_path: desc.relative_path.clone(),
|
||||
offset: 0,
|
||||
length: 0, // Indicates whole file
|
||||
retry_count: 0,
|
||||
last_peer: Some(peer_addr),
|
||||
};
|
||||
|
||||
let result = download_whole_file(&mut conn, &base_dir, desc).await;
|
||||
results.push(ChunkDownloadResult {
|
||||
chunk,
|
||||
result,
|
||||
peer_addr,
|
||||
});
|
||||
}
|
||||
let results = download_chunk_plan(
|
||||
&mut conn,
|
||||
peer_addr,
|
||||
game_id,
|
||||
plan.chunks,
|
||||
&base_dir,
|
||||
cancel_token,
|
||||
version_buffer,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user