fix: reap failed outbound peer actors and bound the brokered connect
Two related lifecycle defects in the direct/firewalled download path: - On a failed direct connect, ClientOperation::PeerConnectFailed left the dead outbound actor registered and running. The actor pinned a thread-pool worker forever (its loop only exits on Stop), so a handful of unreachable-peer downloads could exhaust the fixed pool and wedge the whole client; and a later download() to the same peer saw registry.contains()==true and queued into the dead, streamless actor, hanging silently. The arm now removes and stops the dead actor before brokering, mirroring PeerDisconnected. - The server-brokered fallback had no timeout, so a peer that never connects back (offline / double-firewalled / unroutable address) left the download Queued forever and its Receiver blocked. Arm a bounded timeout: if no PierceFirewall consumes the correlation token within BROKER_CONNECT_TIMEOUT, fail the peer's queued downloads (on the channel and in the store) and reclaim the token. A successful pierce consumes the token first, so the timeout is a no-op on the happy path. Add a unit test for the queued-download failure helper; the direct and firewalled e2e tests still pass (pierce arrives well within the timeout).
This commit is contained in:
@@ -31,6 +31,10 @@ use std::{
|
|||||||
use crate::{debug, error, info, trace, warn};
|
use crate::{debug, error, info, trace, warn};
|
||||||
const DEFAULT_LISTEN_PORT: u16 = 2234;
|
const DEFAULT_LISTEN_PORT: u16 = 2234;
|
||||||
|
|
||||||
|
/// How long to wait for a server-brokered (firewalled) peer to connect back
|
||||||
|
/// before giving up and failing the download. Matches the direct-dial timeout.
|
||||||
|
const BROKER_CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
/// Source of non-zero correlation tokens for server-brokered connections.
|
/// Source of non-zero correlation tokens for server-brokered connections.
|
||||||
static NEXT_CONNECT_TOKEN: AtomicU32 = AtomicU32::new(1);
|
static NEXT_CONNECT_TOKEN: AtomicU32 = AtomicU32::new(1);
|
||||||
|
|
||||||
@@ -289,6 +293,39 @@ fn download_without_a_connection_resolves_failed() {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fail_queued_downloads_notifies_receiver_and_store() {
|
||||||
|
// When a brokered connect times out, every Queued download for the peer
|
||||||
|
// must resolve to Failed both on its channel and in the store.
|
||||||
|
let client = Client::new("u", "p");
|
||||||
|
let (sender, receiver) = mpsc::channel();
|
||||||
|
client.context.write().unwrap().add_download(Download {
|
||||||
|
username: "peer".to_string(),
|
||||||
|
filename: "f.mp3".to_string(),
|
||||||
|
token: 7,
|
||||||
|
size: 10,
|
||||||
|
download_directory: "d".to_string(),
|
||||||
|
status: DownloadStatus::Queued,
|
||||||
|
sender,
|
||||||
|
queue_position: None,
|
||||||
|
metadata: DownloadMetadata::default(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Client::fail_queued_downloads(&client.context, "peer");
|
||||||
|
|
||||||
|
assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Failed)));
|
||||||
|
assert!(matches!(
|
||||||
|
client
|
||||||
|
.context
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.get_download_by_token(7)
|
||||||
|
.unwrap()
|
||||||
|
.status,
|
||||||
|
DownloadStatus::Failed
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_client_removes_only_queued_downloads() {
|
fn test_client_removes_only_queued_downloads() {
|
||||||
let client = Client::new("test-user", "test-password");
|
let client = Client::new("test-user", "test-password");
|
||||||
@@ -783,6 +820,34 @@ impl Client {
|
|||||||
Ok((download, download_receiver))
|
Ok((download, download_receiver))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fail every still-`Queued` download for `username`, both on the caller's
|
||||||
|
/// status channel (so a blocked `Receiver` unblocks) and in the store.
|
||||||
|
fn fail_queued_downloads(
|
||||||
|
client_context: &Arc<RwLock<ClientContext>>,
|
||||||
|
username: &str,
|
||||||
|
) {
|
||||||
|
let mut context = match client_context.write_safe() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
error!("[client] fail_queued_downloads write: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let doomed: Vec<(u32, Sender<DownloadStatus>)> = context
|
||||||
|
.get_downloads()
|
||||||
|
.iter()
|
||||||
|
.filter(|d| {
|
||||||
|
d.username == username
|
||||||
|
&& matches!(d.status, DownloadStatus::Queued)
|
||||||
|
})
|
||||||
|
.map(|d| (d.token, d.sender.clone()))
|
||||||
|
.collect();
|
||||||
|
for (token, sender) in doomed {
|
||||||
|
let _ = sender.send(DownloadStatus::Failed);
|
||||||
|
context.update_download_with_status(token, DownloadStatus::Failed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn process_failed_uploads(
|
fn process_failed_uploads(
|
||||||
client_context: Arc<RwLock<ClientContext>>,
|
client_context: Arc<RwLock<ClientContext>>,
|
||||||
username: &str,
|
username: &str,
|
||||||
@@ -1318,6 +1383,19 @@ impl Client {
|
|||||||
.write_safe()
|
.write_safe()
|
||||||
{
|
{
|
||||||
Ok(mut ctx) => {
|
Ok(mut ctx) => {
|
||||||
|
// Reap the dead outbound actor so it
|
||||||
|
// stops pinning a pool worker and no
|
||||||
|
// longer shadows the brokered reconnect
|
||||||
|
// (a stale registry entry would make
|
||||||
|
// later downloads queue into a dead,
|
||||||
|
// streamless actor and hang).
|
||||||
|
if let Some(handle) =
|
||||||
|
ctx.peer_registry.as_ref().and_then(
|
||||||
|
|r| r.remove_peer(&username),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
let _ = handle.stop();
|
||||||
|
}
|
||||||
ctx.add_pending_connect(
|
ctx.add_pending_connect(
|
||||||
token,
|
token,
|
||||||
username.clone(),
|
username.clone(),
|
||||||
@@ -1332,7 +1410,9 @@ impl Client {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(sender) = server_sender {
|
let Some(sender) = server_sender else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let msg = crate::message::server::MessageFactory::build_connect_to_peer(
|
let msg = crate::message::server::MessageFactory::build_connect_to_peer(
|
||||||
token,
|
token,
|
||||||
&username,
|
&username,
|
||||||
@@ -1340,7 +1420,44 @@ impl Client {
|
|||||||
);
|
);
|
||||||
let _ = sender
|
let _ = sender
|
||||||
.send(ServerMessage::SendMessage(msg));
|
.send(ServerMessage::SendMessage(msg));
|
||||||
|
|
||||||
|
// Bound the brokered attempt: if no PierceFirewall
|
||||||
|
// arrives, fail the peer's queued downloads (so
|
||||||
|
// the caller's Receiver unblocks) and reclaim the
|
||||||
|
// token. A successful pierce consumes the token
|
||||||
|
// first, making this a no-op.
|
||||||
|
let timeout_ctx = client_context.clone();
|
||||||
|
let timeout_user = username.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
sleep(BROKER_CONNECT_TIMEOUT);
|
||||||
|
let still_pending = timeout_ctx
|
||||||
|
.write_safe()
|
||||||
|
.is_ok_and(|mut c| {
|
||||||
|
c.take_pending_connect(token)
|
||||||
|
.is_some()
|
||||||
|
});
|
||||||
|
if still_pending {
|
||||||
|
if let Some(handle) = timeout_ctx
|
||||||
|
.read_safe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|c| {
|
||||||
|
c.peer_registry
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|r| {
|
||||||
|
r.remove_peer(
|
||||||
|
&timeout_user,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
{
|
||||||
|
let _ = handle.stop();
|
||||||
}
|
}
|
||||||
|
Self::fail_queued_downloads(
|
||||||
|
&timeout_ctx,
|
||||||
|
&timeout_user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user