fix!: make concurrent invocations work instead of silently seeing nothing (#22)

Running several one-shot commands at once returned "no results" for queries that
had plenty. Four defects, each of which made a dead session look like an empty
network:

- The peer listener bound its port inside a detached thread and panicked on
  EADDRINUSE, unwinding that thread alone. The process carried on with no
  listener, so no peer could deliver a search response. `Listen::bind` now takes
  the socket before login and falls back to an ephemeral port when the
  configured one is held.
- SetWaitPort advertised the configured port whether or not it was bound, so
  peers dialled a sibling process that discarded responses to tickets it never
  issued. The port the listener really holds is what gets advertised, mapped
  through UPnP, and reported by `whoami` and `serve`.
- A zero-byte read is EOF on a TCP socket, but MessageReader reported it as
  success, so a server that closed cleanly left the actor polling a dead socket.
  It is now an error, and Relogged (code 41) has a handler. The peer actor
  classifies that error: a control connection that was established and then
  closed is a clean disconnect, because peers drop idle sockets while the bytes
  move over their own connection, and reporting an error there fails every
  download queued with that peer including the one streaming. A connection that
  never established keeps the error path, which is what the server-brokered
  fallback reads to know a peer is unreachable.
- A session the server cut off - usually because another process logged in under
  the same name - exited 4, documented as "it worked and found nothing". Callers
  concluded the file was unavailable. Sessions now record why they ended and
  those verdicts become exit 7.

The server allows one session per account, so concurrency still needs a distinct
--username per run; SKILL.md documents that. The listener port needs no such
care any more.

Six e2e tests against soulfind cover it, each of which fails without the fix: a
taken listen port, a displaced session, three clients searching at once under
one configured port, the two CLI equivalents, and a peer that hangs up its
control connection while a transfer is still to come.

BREAKING CHANGE: `Listen::start` is replaced by `Listen::bind` and
`Listen::serve`, so the caller owns the socket and can read the port it actually
bound. `Client::listen_port` and `Client::session_loss` are new.
This commit is contained in:
Michel de Graaf
2026-07-26 14:52:31 +02:00
committed by GitHub
parent 1854784b22
commit e8a48c6b06
20 changed files with 801 additions and 54 deletions
+19
View File
@@ -481,6 +481,25 @@ impl PeerActor {
);
}
}
// A peer hanging up a control connection it had already
// established is ordinary: other clients close idle peer
// sockets while a transfer runs on its own connection. Report
// the clean disconnect it is — an error here fails every
// download queued with that peer, the one streaming included.
// A connection that never established is the other story: the
// peer accepted and vanished, which is what the server-brokered
// fallback exists for, so that one keeps the error path.
Err(ref e)
if e.kind() == io::ErrorKind::UnexpectedEof
&& self.established =>
{
debug!(
"[peer:{}] peer closed the connection",
self.peer_username()
);
self.disconnect();
return;
}
Err(e) => {
let username = self.peer_username();
error!(
+34 -1
View File
@@ -14,6 +14,7 @@ use crate::message::server::MessageUser;
use crate::message::server::ParentMinSpeedHandler;
use crate::message::server::ParentSpeedRatioHandler;
use crate::message::server::PrivilegedUsersHandler;
use crate::message::server::ReloggedHandler;
use crate::message::server::SayChatroomHandler;
use crate::message::server::UserJoinedRoomHandler;
use crate::message::server::UserLeftRoomHandler;
@@ -25,7 +26,9 @@ use crate::message::{Handlers, MessageType};
use crate::message::{Message, MessageReader};
use crate::peer::ConnectionType;
use crate::peer::Peer;
use crate::types::{ClientVersion, RoomEvent, RoomInfo};
use crate::types::{
ClientVersion, RoomEvent, RoomInfo, SessionLoss, SessionWatch,
};
use crate::utils::lock::RwLockExt;
use std::io::{self, Error, Write};
@@ -156,6 +159,9 @@ impl UserMessage {
pub enum ServerMessage {
ProcessRead,
LoginStatus(bool),
/// The server is closing this connection: the same username logged in
/// elsewhere.
Relogged,
SendMessage(Message),
Login {
username: String,
@@ -252,6 +258,7 @@ pub struct ServerActor {
queued_messages: Vec<ServerMessage>,
shared_folder_count: u32,
shared_file_count: u32,
session: SessionWatch,
}
/// The messages a client sends right after a successful login: its shared-file
@@ -304,9 +311,15 @@ impl ServerActor {
queued_messages: Vec::new(),
shared_folder_count,
shared_file_count,
session: SessionWatch::default(),
}
}
/// Share the client's view of whether this session is still alive.
pub fn set_session_watch(&mut self, session: SessionWatch) {
self.session = session;
}
#[must_use]
pub const fn get_address(&self) -> &PeerAddress {
&self.address
@@ -392,6 +405,7 @@ impl ServerActor {
let mut handlers = Handlers::new();
handlers.register_handler(LoginHandler);
handlers.register_handler(ReloggedHandler);
handlers.register_handler(RoomListHandler);
handlers.register_handler(GetUserStatusHandler);
handlers.register_handler(GetUserStatsHandler);
@@ -459,6 +473,7 @@ impl ServerActor {
ServerMessage::LoginStatus(message) => {
self.handle_login_status(message);
}
ServerMessage::Relogged => self.handle_relogged(),
ServerMessage::PierceFirewall(token) => {
self.send_message(
MessageFactory::build_pierce_firewall_message(token),
@@ -866,9 +881,27 @@ impl ServerActor {
}
}
/// The server is closing this connection because the same account logged
/// in elsewhere. Nothing reconnects it, so the session is over.
fn handle_relogged(&mut self) {
error!(
"[server] another login took this username; this session has been \
closed by the server"
);
self.session.record(SessionLoss::Displaced);
self.connection_state = ConnectionState::Disconnected;
self.disconnect();
}
fn disconnect_with_error(&mut self, _error: Error) {
debug!("[server] disconnect");
// Losing an established connection ends the session: nothing reconnects
// it, so anything still waiting on the network is waiting for good.
if matches!(self.connection_state, ConnectionState::Connected) {
self.session.record(SessionLoss::Disconnected);
self.connection_state = ConnectionState::Disconnected;
}
self.stream.take();
}
+16 -10
View File
@@ -53,14 +53,26 @@ impl Client {
ctx.shares = shares;
ctx.shared_directories.clone_from(&self.shared_directories);
let server_actor = ServerActor::new(
// Bind before logging in: the port we advertise has to be the port we
// hold, and a bind that fails outright is the caller's to see rather
// than a panic on a thread nobody is watching.
let listener = if self.enable_listen {
let listener = Listen::bind(self.listen_port)?;
self.bound_port = Some(listener.local_addr()?.port());
Some(listener)
} else {
None
};
let mut server_actor = ServerActor::new(
self.address.clone(),
sender,
self.listen_port,
self.bound_port.unwrap_or(self.listen_port),
self.enable_listen,
shared_folder_count,
shared_file_count,
);
server_actor.set_session_watch(self.session.clone());
self.server_handle = Some(ctx.actor_system.spawn_with_handle(
server_actor,
@@ -69,19 +81,13 @@ impl Client {
},
));
if self.enable_listen {
let listen_port = self.listen_port;
if let Some(listener) = listener {
let client_sender = listen_sender;
let context = self.context.clone();
let own_username = self.username.clone();
thread::spawn(move || {
Listen::start(
listen_port,
client_sender,
context,
own_username,
);
Listen::serve(&listener, client_sender, context, own_username);
});
}
+24 -1
View File
@@ -5,7 +5,7 @@ use crate::actor::server_actor::{
use crate::download_store::{DownloadStore, collect_failed_tokens};
use crate::types::{
ClientVersion, DownloadMetadata, DownloadStatus, RoomEvent, RoomInfo,
UserInfo, UserPresence, UserStats, UserStatus,
SessionLoss, SessionWatch, UserInfo, UserPresence, UserStats, UserStatus,
};
use crate::utils::logger;
use crate::{
@@ -811,6 +811,9 @@ impl ClientContext {
pub struct Client {
enable_listen: bool,
listen_port: u16,
/// The port the listener actually holds, known once [`Client::connect`]
/// has bound it.
bound_port: Option<u16>,
address: PeerAddress,
username: String,
password: String,
@@ -818,6 +821,7 @@ pub struct Client {
shared_directories: Vec<String>,
server_handle: Option<ActorHandle<ServerMessage>>,
context: Arc<RwLock<ClientContext>>,
session: SessionWatch,
}
impl Client {
@@ -834,6 +838,7 @@ impl Client {
Self {
enable_listen: settings.enable_listen,
listen_port: settings.listen_port,
bound_port: None,
address: settings.server_address,
username: settings.username,
password: settings.password,
@@ -841,6 +846,7 @@ impl Client {
shared_directories: settings.shared_directories,
context: Arc::new(RwLock::new(ClientContext::new())),
server_handle: None,
session: SessionWatch::default(),
}
}
@@ -850,6 +856,23 @@ impl Client {
&self.username
}
/// The port peers can reach this client on, or `None` when it is not
/// listening (or has not connected yet). This is the port that was really
/// bound, which is not always the one that was configured.
#[must_use]
pub const fn listen_port(&self) -> Option<u16> {
self.bound_port
}
/// Why the server session ended, or `None` while it is alive.
///
/// A lost session sees nothing on the network, so an empty result set from
/// one says nothing about what the network holds.
#[must_use]
pub fn session_loss(&self) -> Option<SessionLoss> {
self.session.loss()
}
/// The directories whose files are currently shared with other peers.
#[must_use]
pub fn shared_directories(&self) -> Vec<String> {
+2 -2
View File
@@ -33,6 +33,6 @@ pub use error::{Result, SoulseekRs};
pub use message::peer::SharedDirectory;
pub use types::{
ClientVersion, DownloadStatus, File, RoomEvent, RoomInfo, Search,
SearchResult, Transfer, UploadInfo, UploadStatus, UserInfo, UserPresence,
UserStats, UserStatus,
SearchResult, SessionLoss, Transfer, UploadInfo, UploadStatus, UserInfo,
UserPresence, UserStats, UserStatus,
};
+35 -1
View File
@@ -39,8 +39,14 @@ impl MessageReader {
) -> io::Result<()> {
let mut temp_buffer = [0; 1024]; // Temporary buffer for reading from the socket
let bytes_read = stream.read(&mut temp_buffer)?;
// On a TCP socket a zero-byte read is the other end hanging up. Passing
// that back as success left the caller polling a dead socket forever,
// which is how a server that closes cleanly went unnoticed.
if bytes_read == 0 {
return Ok(());
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"the remote end closed the connection",
));
}
// Add the read bytes to the internal buffer
@@ -121,4 +127,32 @@ mod tests {
assert!(buffered_reader.buffer.is_empty());
assert_eq!(vec![1, 2, 3], rest);
}
/// A hangup has to reach the caller as an error. Reported as success it
/// leaves the actors polling a socket that will never speak again, which is
/// how a server that closed cleanly went unnoticed.
#[test]
fn a_closed_connection_reads_as_an_error_not_as_success() {
use std::io::Write;
use std::net::{TcpListener, TcpStream};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let mut ours = TcpStream::connect(addr).unwrap();
let (mut theirs, _) = listener.accept().unwrap();
theirs.write_all(&[1, 2, 3]).unwrap();
theirs.flush().unwrap();
drop(theirs);
let mut reader = MessageReader::new();
// Whatever was sent before the close still arrives.
while reader.buffer_len() < 3 {
reader.read_from_socket(&mut ours).unwrap();
}
let error = reader
.read_from_socket(&mut ours)
.expect_err("a closed connection is not a successful read");
assert_eq!(error.kind(), std::io::ErrorKind::UnexpectedEof);
}
}
@@ -10,6 +10,7 @@ mod message_user;
mod parent_min_speed;
mod parent_speed_ratio;
mod privileged_users;
mod relogged;
mod room_list;
mod say_chatroom;
mod user_info;
@@ -29,6 +30,7 @@ pub use message_user::MessageUser;
pub use parent_min_speed::ParentMinSpeedHandler;
pub use parent_speed_ratio::ParentSpeedRatioHandler;
pub use privileged_users::{CheckPrivilegesHandler, PrivilegedUsersHandler};
pub use relogged::ReloggedHandler;
pub use room_list::RoomListHandler;
pub use say_chatroom::SayChatroomHandler;
pub use user_info::{GetUserStatsHandler, GetUserStatusHandler};
@@ -0,0 +1,18 @@
use crate::actor::server_actor::ServerMessage;
use crate::message::{Message, MessageHandler};
use std::sync::mpsc::Sender;
/// `Relogged` (code 41): the server is about to cut this connection because
/// the same username logged in somewhere else.
pub struct ReloggedHandler;
impl MessageHandler<ServerMessage> for ReloggedHandler {
fn get_code(&self) -> u8 {
41
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let _ = message;
let _ = sender.send(ServerMessage::Relogged);
}
}
+26 -17
View File
@@ -36,20 +36,11 @@ fn read_peer_init_message(
reader: &mut MessageReader,
) -> io::Result<Message> {
// An untrusted peer gets a bounded handshake. Without this a peer that
// connects and stays silent parks this read forever, and one that hangs up
// spins it (a zero-byte read is EOF, which `read_from_socket` reports as
// success) — either way pinning a thread that owes us a peer init.
// connects and stays silent parks this read forever, pinning a thread that
// owes us a peer init.
stream.set_read_timeout(Some(PEER_INIT_TIMEOUT))?;
let message = loop {
let buffered = reader.buffer_len();
reader.read_from_socket(stream)?;
if reader.buffer_len() == buffered {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"peer closed before sending a peer init",
));
}
if let Ok(Some(msg)) = reader.extract_message() {
break msg;
}
@@ -374,16 +365,34 @@ fn handle_incoming_connection(stream: TcpStream, context: ConnectionContext) {
pub struct Listen {}
impl Listen {
pub fn start(
port: u16,
/// Take the peer-listening socket, falling back to a port the operating
/// system picks when the configured one is already held.
///
/// Several clients on one machine share one configured port, and losing
/// the race for it must not cost a session its listener: an unreachable
/// client is one that never receives search responses or transfers. The
/// caller advertises [`TcpListener::local_addr`], so peers are always told
/// the port that was really bound.
pub fn bind(port: u16) -> io::Result<TcpListener> {
match TcpListener::bind(("0.0.0.0", port)) {
Err(e) if e.kind() == io::ErrorKind::AddrInUse && port != 0 => {
info!(
"[listener] port {port} is taken, falling back to an \
ephemeral port"
);
TcpListener::bind(("0.0.0.0", 0))
}
other => other,
}
}
pub fn serve(
listener: &TcpListener,
client_sender: Sender<ClientOperation>,
client_context: Arc<RwLock<ClientContext>>,
own_username: String,
) {
info!("[listener] starting listener on port {port}");
let listener = TcpListener::bind(format!("0.0.0.0:{port}"))
.expect("Failed to bind listener to port");
info!("[listener] listening on {:?}", listener.local_addr());
let context = ConnectionContext {
client_sender,
+59
View File
@@ -399,3 +399,62 @@ pub struct UploadInfo {
/// download does. Zero unless the upload is in progress.
pub speed_bytes_per_sec: f64,
}
/// Why a server session ended before the client was done with it.
///
/// A session that ends stops seeing the network entirely, which is not the
/// same thing as the network having nothing to show — telling the two apart is
/// the difference between "retry" and "give up".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum SessionLoss {
/// Another login claimed this username. The server keeps one session per
/// name, so the older one is cut off.
Displaced = 1,
/// The connection to the server dropped.
Disconnected = 2,
}
impl std::fmt::Display for SessionLoss {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Displaced => f.write_str(
"another login took over this username; the server allows one \
session per account",
),
Self::Disconnected => {
f.write_str("the connection to the server dropped")
}
}
}
}
/// A lock-free view, shared with the server actor, of whether the session is
/// still alive.
///
/// The first loss recorded wins: a displaced session also drops its socket a
/// moment later, and "displaced" is the reason worth reporting.
#[derive(Debug, Clone, Default)]
pub struct SessionWatch(std::sync::Arc<std::sync::atomic::AtomicU8>);
impl SessionWatch {
const LIVE: u8 = 0;
pub fn record(&self, loss: SessionLoss) {
let _ = self.0.compare_exchange(
Self::LIVE,
loss as u8,
std::sync::atomic::Ordering::Relaxed,
std::sync::atomic::Ordering::Relaxed,
);
}
#[must_use]
pub fn loss(&self) -> Option<SessionLoss> {
match self.0.load(std::sync::atomic::Ordering::Relaxed) {
1 => Some(SessionLoss::Displaced),
2 => Some(SessionLoss::Disconnected),
_ => None,
}
}
}