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.
310 lines
11 KiB
Rust
310 lines
11 KiB
Rust
//! One-shot commands: everything the binary can do without a terminal.
|
|
//!
|
|
//! Each command does one job, writes records to stdout and progress to stderr,
|
|
//! and returns an exit code through [`CliError`]. Most open a [`Session`]
|
|
//! first; the ones that only read the config file or the local machine are
|
|
//! dispatched by `main::run` before any credentials are demanded.
|
|
|
|
pub mod completions;
|
|
pub mod peer;
|
|
pub mod settings;
|
|
pub mod skills;
|
|
pub mod social;
|
|
pub mod transfer;
|
|
pub mod wish;
|
|
|
|
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, SessionLoss};
|
|
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
|
|
use std::sync::{Arc, OnceLock};
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Everything a one-shot command needs that does not come from its own flags.
|
|
pub struct Ctx {
|
|
pub out: Out,
|
|
pub settings: ClientSettings,
|
|
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
|
|
/// that makes us reachable. Both live until the command ends.
|
|
pub struct Session {
|
|
pub client: Arc<Client>,
|
|
_mapper: Option<PortMapper>,
|
|
}
|
|
|
|
/// How long to wait for the TCP handshake with the server.
|
|
const REACH_TIMEOUT: Duration = Duration::from_secs(10);
|
|
/// How long to wait for the login exchange once the socket is open.
|
|
const LOGIN_TIMEOUT: Duration = Duration::from_secs(30);
|
|
|
|
impl Session {
|
|
pub fn open(ctx: &Ctx) -> CliResult<Self> {
|
|
let address = ctx.settings.server_address.to_string();
|
|
ctx.out.status(&format!("connecting to {address}…"));
|
|
reachable(&address, REACH_TIMEOUT)?;
|
|
|
|
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,
|
|
_mapper: mapper,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Check the server answers at all before handing the socket to the client.
|
|
///
|
|
/// The connect and login the library performs happen on its own threads and
|
|
/// report a refused connection only indirectly, so an unattended run would sit
|
|
/// there. One handshake up front turns that into an immediate, accurate error.
|
|
fn reachable(address: &str, timeout: Duration) -> CliResult {
|
|
let resolved: Vec<SocketAddr> = address
|
|
.to_socket_addrs()
|
|
.map_err(|e| {
|
|
CliError::connection(format!("cannot resolve {address}: {e}"))
|
|
})?
|
|
.collect();
|
|
|
|
let mut last = None;
|
|
for candidate in &resolved {
|
|
match TcpStream::connect_timeout(candidate, timeout) {
|
|
Ok(_) => return Ok(()),
|
|
Err(e) => last = Some(e),
|
|
}
|
|
}
|
|
Err(CliError::connection(match last {
|
|
Some(e) => format!("cannot reach {address}: {e}"),
|
|
None => format!("cannot reach {address}: no address resolved"),
|
|
}))
|
|
}
|
|
|
|
/// Connect and log in under a deadline.
|
|
///
|
|
/// `Client::login` blocks on a channel the library only answers once its actor
|
|
/// has a working connection, so it is run on its own thread: a server that
|
|
/// accepts the socket and then says nothing must still end the command.
|
|
fn login(settings: ClientSettings, address: &str) -> CliResult<Client> {
|
|
let (sender, receiver) = std::sync::mpsc::channel();
|
|
std::thread::spawn(move || {
|
|
let mut client = Client::with_settings(settings);
|
|
let outcome = match client.connect() {
|
|
Ok(()) => client.login(),
|
|
Err(e) => Err(e),
|
|
};
|
|
let _ = sender.send((client, outcome));
|
|
});
|
|
|
|
let (client, outcome) =
|
|
receiver.recv_timeout(LOGIN_TIMEOUT).map_err(|_| {
|
|
CliError::connection(format!(
|
|
"{address} accepted the connection but did not answer the \
|
|
login within {}s",
|
|
LOGIN_TIMEOUT.as_secs()
|
|
))
|
|
})?;
|
|
|
|
match outcome {
|
|
Ok(true) => Ok(client),
|
|
Ok(false) => Err(CliError::connection(
|
|
"login rejected by the server (wrong password, or the name is \
|
|
taken)",
|
|
)),
|
|
Err(e) => Err(CliError::connection(format!("login failed: {e}"))),
|
|
}
|
|
}
|
|
|
|
/// Ask for the room list and wait for the server's reply.
|
|
///
|
|
/// Events already queued are discarded first: servers push a room list at
|
|
/// login, and counting that one would answer a question we had not asked yet.
|
|
pub fn await_room_list(
|
|
client: &Client,
|
|
timeout: Duration,
|
|
poll: Duration,
|
|
) -> Option<Vec<RoomInfo>> {
|
|
let _ = client.take_room_events();
|
|
client.request_room_list().ok()?;
|
|
|
|
let deadline = Instant::now() + timeout;
|
|
while Instant::now() < deadline {
|
|
let listed =
|
|
client.take_room_events().into_iter().find_map(
|
|
|event| match event {
|
|
RoomEvent::List(rooms) => Some(rooms),
|
|
_ => None,
|
|
},
|
|
);
|
|
if listed.is_some() {
|
|
return listed;
|
|
}
|
|
std::thread::sleep(poll);
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Wait until the server has answered something we asked for after `send`.
|
|
///
|
|
/// The server connection is a single ordered stream served by one actor, so a
|
|
/// reply to a request queued *after* an earlier message proves that earlier
|
|
/// message reached the socket. The room list is the cheapest such request and
|
|
/// every server answers it, which lets `message send` and `room say` report
|
|
/// delivery honestly instead of sleeping and hoping.
|
|
pub fn confirm_flush(client: &Client, timeout: Duration) -> bool {
|
|
await_room_list(client, timeout, Duration::from_millis(50)).is_some()
|
|
}
|
|
|
|
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),
|
|
Commands::Get(args) => transfer::get(ctx, &args),
|
|
Commands::Browse(args) => social::browse(ctx, &args),
|
|
Commands::Room(RoomCommand::List { timeout }) => {
|
|
social::room_list(ctx, Duration::from_secs(timeout))
|
|
}
|
|
Commands::Room(RoomCommand::Say { room, message }) => {
|
|
social::room_say(ctx, &room, &message)
|
|
}
|
|
Commands::Room(RoomCommand::Users { room, timeout }) => {
|
|
social::room_users(ctx, &room, Duration::from_secs(timeout))
|
|
}
|
|
Commands::Room(RoomCommand::Listen {
|
|
room,
|
|
duration,
|
|
follow,
|
|
}) => social::room_listen(ctx, &room, listen_span(duration, follow)),
|
|
Commands::Message(MessageCommand::Send { user, message }) => {
|
|
social::message_send(ctx, &user, &message)
|
|
}
|
|
Commands::Message(MessageCommand::Read { duration, follow }) => {
|
|
social::message_read(ctx, listen_span(duration, follow))
|
|
}
|
|
Commands::User(args) => peer::user(ctx, &args),
|
|
Commands::Whoami => peer::whoami(ctx),
|
|
// These need no account, so main.rs answers them before asking for
|
|
// credentials; the arms exist only to keep the match exhaustive.
|
|
Commands::Portmap => portmap(&ctx.out, ctx.settings.listen_port),
|
|
// These need the config file as well as a session, so main.rs runs
|
|
// them where the store is in scope.
|
|
Commands::Serve(_)
|
|
| Commands::Wish(_)
|
|
| Commands::Config(_)
|
|
| Commands::Shares(_)
|
|
| Commands::Skills(_)
|
|
| Commands::Completions(_) => {
|
|
Err(CliError::usage("handled without a session; see main::run"))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `--follow` means "until interrupted": a span nothing will outlive.
|
|
fn listen_span(duration: u64, follow: bool) -> Option<Duration> {
|
|
(!follow).then(|| Duration::from_secs(duration))
|
|
}
|
|
|
|
/// Probe automatic port mapping. A router that does not answer is a negative
|
|
/// answer, not a crash, so it exits 4 the way `grep` exits 1 on no match.
|
|
pub fn portmap(out: &Out, port: u16) -> CliResult {
|
|
out.status(&format!("probing automatic port mapping for TCP {port}…"));
|
|
let probe = port_mapping::probe(port);
|
|
out.emit(&PortmapRecord {
|
|
ok: probe.ok,
|
|
backend: probe.backend.to_string(),
|
|
external: probe.external.clone(),
|
|
port,
|
|
});
|
|
out.status(&probe.advice);
|
|
if probe.ok {
|
|
Ok(())
|
|
} else {
|
|
// The advice is already on stderr; the error line only has to say
|
|
// what the exit code means.
|
|
Err(CliError::new(
|
|
Exit::NoResults,
|
|
format!("no router mapped TCP {port}"),
|
|
))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::listen_span;
|
|
use std::time::Duration;
|
|
|
|
#[test]
|
|
fn follow_listens_without_a_deadline() {
|
|
assert_eq!(listen_span(30, true), None);
|
|
assert_eq!(listen_span(30, false), Some(Duration::from_secs(30)));
|
|
}
|
|
}
|