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
+66 -9
View File
@@ -17,9 +17,9 @@ use crate::cli::{Commands, MessageCommand, RoomCommand};
use crate::output::{CliError, CliResult, Exit, Out, PortmapRecord};
use crate::port_mapping::{self, PortMapper};
use soulseek_rs::types::{RoomEvent, RoomInfo};
use soulseek_rs::{Client, ClientSettings};
use soulseek_rs::{Client, ClientSettings, SessionLoss};
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::sync::Arc;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
/// Everything a one-shot command needs that does not come from its own flags.
@@ -29,6 +29,47 @@ pub struct Ctx {
pub download_dir: String,
pub max_concurrent_downloads: usize,
pub search_timeout: Duration,
/// The client of the session this command opened, once it has one, so the
/// verdict can be checked against the session that produced it.
pub session: OnceLock<Arc<Client>>,
}
impl Ctx {
/// Re-read a "found nothing", "timed out" or "transfer failed" verdict
/// against the session that produced it.
///
/// A session the server cut off — most often because another process
/// logged in under the same name — sees nothing at all from that moment
/// on. Reported as exit 4 it reads as "this file is not on the network",
/// and a caller acting on that gives up on files that were there.
fn checked(&self, result: CliResult) -> CliResult {
let Err(error) = result else {
return Ok(());
};
if !matches!(
error.exit,
Exit::NoResults | Exit::Timeout | Exit::Transfer
) {
return Err(error);
}
match self.session.get().and_then(|client| client.session_loss()) {
Some(loss) => Err(session_lost(loss)),
None => Err(error),
}
}
}
/// What to tell a caller whose session ended under it.
fn session_lost(loss: SessionLoss) -> CliError {
let fix = match loss {
SessionLoss::Displaced => {
"; give each concurrent run its own --username"
}
SessionLoss::Disconnected => "",
};
CliError::session_lost(format!(
"the session ended before this command finished: {loss}{fix}"
))
}
/// A logged-in client plus, while the listener is enabled, the port mapping
@@ -45,21 +86,33 @@ const LOGIN_TIMEOUT: Duration = Duration::from_secs(30);
impl Session {
pub fn open(ctx: &Ctx) -> CliResult<Self> {
let mapper = ctx
.settings
.enable_listen
.then(|| PortMapper::spawn(ctx.settings.listen_port));
let address = ctx.settings.server_address.to_string();
ctx.out.status(&format!("connecting to {address}…"));
reachable(&address, REACH_TIMEOUT)?;
let client = login(ctx.settings.clone(), &address)?;
let client = Arc::new(login(ctx.settings.clone(), &address)?);
ctx.out
.status(&format!("logged in as {}", ctx.settings.username));
let _ = ctx.session.set(Arc::clone(&client));
let bound = client.listen_port();
if let Some(bound) = bound
&& ctx.settings.listen_port != 0
&& bound != ctx.settings.listen_port
{
ctx.out.warn(&format!(
"listener port {} is in use; listening on {bound} instead",
ctx.settings.listen_port
));
}
// Ask the router for the port the listener actually holds: when the
// configured one was taken, mapping it would open a hole to whoever
// did take it.
let mapper = bound.map(PortMapper::spawn);
Ok(Self {
client: Arc::new(client),
client,
_mapper: mapper,
})
}
@@ -167,6 +220,10 @@ pub fn confirm_flush(client: &Client, timeout: Duration) -> bool {
}
pub fn run(ctx: &Ctx, command: Commands) -> CliResult {
ctx.checked(dispatch(ctx, command))
}
fn dispatch(ctx: &Ctx, command: Commands) -> CliResult {
match command {
Commands::Search(args) => transfer::search(ctx, &args),
Commands::Download(args) => transfer::download(ctx, &args),
+6 -3
View File
@@ -20,11 +20,14 @@ const PRIVILEGE_TIMEOUT: Duration = Duration::from_secs(5);
pub fn whoami(ctx: &Ctx) -> CliResult {
let session = Session::open(ctx)?;
let (folders, files) = session.client.shared_counts();
// The bound port, not the configured one: a caller checking whether it is
// reachable needs what is true, not what was asked for.
let bound = session.client.listen_port();
ctx.out.emit(&WhoamiRecord {
user: ctx.settings.username.clone(),
server: ctx.settings.server_address.to_string(),
listening: ctx.settings.enable_listen,
listen_port: ctx.settings.listen_port,
listening: bound.is_some(),
listen_port: bound.unwrap_or(0),
shared_folders: folders,
shared_files: files,
download_dir: ctx.download_dir.clone(),
@@ -119,7 +122,7 @@ pub fn serve(
let (folders, files) = session.client.shared_counts();
ctx.out.status(&format!(
"serving {files} files in {folders} folders on port {}",
ctx.settings.listen_port
session.client.listen_port().unwrap_or_default()
));
if files == 0 {
ctx.out