fix(lib): serve uploads to downloaders we cannot dial

An accepted upload only ever dialled the downloader at the address the
server reported. Most downloaders sit behind a NAT, so the dial timed out
after 20s ("serve …: connection timed out") or, with port 0, the upload
sat in pending_serves forever. There was no fallback: an inbound
PierceFirewall was always treated as a P control connection.

Do what the reference clients do. When an upload starts, ask the server to
have the downloader connect back (ConnectToPeer type F) and dial them at
the same time; the first socket to arrive claims the transfer. Over the
pierced socket only the bare 4-byte transfer token precedes the offset
exchange. CantConnectToPeer and the brokered-connect timeout fail the
upload and free its slot; port 0 skips the dial instead of parking the job.

Covered by an e2e test where the leecher runs without a listener.

Closes #102
This commit is contained in:
Michel de Graaf
2026-10-26 17:03:23 +01:00
parent 68bc3067c3
commit 3205611609
7 changed files with 428 additions and 69 deletions
+180 -42
View File
@@ -1,11 +1,14 @@
use super::{
ActiveUpload, Arc, Client, ClientContext, DownloadStatus, PeerMessage,
RwLock, RwLockExt, ServerMessage, collect_failed_tokens, error,
next_upload_token, thread,
ActiveUpload, Arc, BrokeredUpload, Client, ClientContext, DownloadStatus,
PeerMessage, RwLock, RwLockExt, ServerMessage, collect_failed_tokens,
debug, error, next_connect_token, next_upload_token, thread,
};
use crate::message::server::MessageFactory;
use crate::peer::{ConnectionType, upload_peer};
use crate::types::UploadStatus;
use std::sync::atomic::{AtomicBool, AtomicU64};
use std::net::TcpStream;
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
impl Client {
/// Whether the server listed `username` as privileged. Privileged peers are
@@ -136,55 +139,190 @@ impl Client {
drop(ctx);
let own = own_username.to_string();
let real_path = job.real_path;
let downloader = job.downloader;
let context = client_context.clone();
thread::spawn(move || {
let result = crate::peer::upload_peer::serve_file(
&host,
port,
// Most downloaders sit behind a NAT, so dialling them is a gamble
// that costs a 20s timeout when it fails. Like the reference
// clients, ask the server to have them connect back at the same
// time; the first socket to arrive claims the transfer, and port 0
// (the server not knowing where they listen) skips the dial.
let Some((connect_token, claimed)) =
Self::broker_upload(&context, token, &real_path, &downloader)
else {
return;
};
if port == 0 {
return;
}
let stream = match upload_peer::dial(&host, port) {
Ok(stream) => stream,
Err(e) => {
debug!(
"[client] cannot dial {downloader} at {host}:{port} \
for upload {token} ({e}); waiting for them to connect"
);
return;
}
};
if claimed
.compare_exchange(
false,
true,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_err()
{
return;
}
if let Ok(mut ctx) = context.write_safe() {
ctx.take_brokered_upload(connect_token);
}
let result = upload_peer::serve_direct(
stream,
&own,
token,
&real_path,
&bytes_sent,
&cancel,
);
let streamed = result.as_ref().ok().copied();
let status = match &result {
Ok(_) => UploadStatus::Completed,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {
UploadStatus::Cancelled
}
Err(e) => {
error!("[client] serve {}: {}", real_path.display(), e);
UploadStatus::Failed(e.to_string())
}
};
let mut report = None;
if let Ok(mut ctx) = context.write_safe()
&& let Some(upload) = ctx.active_uploads.get_mut(&token)
{
let secs = upload.started.elapsed().as_secs_f64();
upload.status = status;
if let Some(bytes) = streamed
&& secs > 0.0
{
let speed = (bytes as f64 / secs) as u32;
ctx.last_upload_speed = speed;
report = ctx.server_sender.clone().map(|s| (s, speed));
}
}
// The server folds each finished upload's rate into the average
// speed it shows other users for us.
if let Some((server, speed)) = report {
let _ = server.send(ServerMessage::SendMessage(
MessageFactory::build_send_upload_speed(speed),
));
}
// The slot this transfer held is free now, so whoever is next in
// line gets it without waiting for another request to arrive.
Self::pump_upload_queue(&context);
Self::finish_upload(&context, token, &real_path, result);
});
}
/// Park the upload under a correlation token and ask the server to have
/// `downloader` connect back to us with it. Returns the token and the claim
/// flag the dialling route races against; `None` fails the upload.
fn broker_upload(
context: &Arc<RwLock<ClientContext>>,
token: u32,
real_path: &Path,
downloader: &str,
) -> Option<(u32, Arc<AtomicBool>)> {
let connect_token = next_connect_token();
let (claimed, sender) = match context.write_safe() {
Ok(mut ctx) => (
ctx.add_brokered_upload(
connect_token,
token,
real_path.to_path_buf(),
),
ctx.server_sender.clone(),
),
Err(_) => return None,
};
let Some(sender) = sender else {
Self::fail_upload(context, token, "not connected to the server");
return None;
};
let _ = sender.send(ServerMessage::SendMessage(
MessageFactory::build_connect_to_peer(
connect_token,
downloader,
ConnectionType::F,
),
));
Some((connect_token, claimed))
}
/// The downloader of a brokered upload connected back: stream the file
/// over its socket on a background thread, unless the dial beat it.
pub(crate) fn serve_pierced(
client_context: &Arc<RwLock<ClientContext>>,
upload: BrokeredUpload,
stream: TcpStream,
) {
if !upload.claim() {
return;
}
let Some((bytes_sent, cancel)) =
client_context.write_safe().ok().and_then(|mut ctx| {
let active = ctx.active_uploads.get_mut(&upload.token)?;
active.started = std::time::Instant::now();
Some((active.bytes_sent.clone(), active.cancel.clone()))
})
else {
return;
};
let context = client_context.clone();
thread::spawn(move || {
let result = upload_peer::serve_pierced(
stream,
upload.token,
&upload.real_path,
&bytes_sent,
&cancel,
);
Self::finish_upload(
&context,
upload.token,
&upload.real_path,
result,
);
});
}
/// Mark upload `token` failed and hand its slot to the next in line.
pub(crate) fn fail_upload(
client_context: &Arc<RwLock<ClientContext>>,
token: u32,
reason: &str,
) {
if let Ok(mut ctx) = client_context.write_safe()
&& let Some(upload) = ctx.active_uploads.get_mut(&token)
{
error!("[client] upload {token} to {}: {reason}", upload.username);
upload.status = UploadStatus::Failed(reason.to_string());
}
Self::pump_upload_queue(client_context);
}
/// Record how a served upload ended, report its rate to the server and free
/// its slot.
fn finish_upload(
context: &Arc<RwLock<ClientContext>>,
token: u32,
real_path: &Path,
result: std::io::Result<u64>,
) {
let streamed = result.as_ref().ok().copied();
let status = match &result {
Ok(_) => UploadStatus::Completed,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {
UploadStatus::Cancelled
}
Err(e) => {
error!("[client] serve {}: {}", real_path.display(), e);
UploadStatus::Failed(e.to_string())
}
};
let mut report = None;
if let Ok(mut ctx) = context.write_safe()
&& let Some(upload) = ctx.active_uploads.get_mut(&token)
{
let secs = upload.started.elapsed().as_secs_f64();
upload.status = status;
if let Some(bytes) = streamed
&& secs > 0.0
{
let speed = (bytes as f64 / secs) as u32;
ctx.last_upload_speed = speed;
report = ctx.server_sender.clone().map(|s| (s, speed));
}
}
// The server folds each finished upload's rate into the average
// speed it shows other users for us.
if let Some((server, speed)) = report {
let _ = server.send(ServerMessage::SendMessage(
MessageFactory::build_send_upload_speed(speed),
));
}
// The slot this transfer held is free now, so whoever is next in
// line gets it without waiting for another request to arrive.
Self::pump_upload_queue(context);
}
pub(crate) fn process_failed_uploads(
client_context: Arc<RwLock<ClientContext>>,
username: &str,