From 7060d3521a06c516bffa1a9b37d44fe8e3a0631b Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 13:59:04 +0200 Subject: [PATCH 01/23] fix(rooms): start a room's roster from the member list, not the join echo soulfind tells a room about a new member, the new member included, before it sends them the room's member list. The roster was started from that echo, so for a moment after joining a room read as holding nobody but us, and `room users` returns on the first roster it sees: it could print only the asking user. That is the flake in room_users_lists_who_is_in_the_room. A join or leave now only updates a roster the server has sent in full, which is also what room_members already promised for a room we have not joined. The e2e test waits for the resident's own member list instead of a fixed second, so it no longer races the resident's join either. --- soulseek-rs-lib/src/client/context.rs | 8 +++++--- soulseek-rs-lib/src/client/tests/rooms.rs | 19 +++++++++++++++++++ soulseek-rs/tests/cli_e2e.rs | 7 ++++++- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/soulseek-rs-lib/src/client/context.rs b/soulseek-rs-lib/src/client/context.rs index b51a58a..8606abf 100644 --- a/soulseek-rs-lib/src/client/context.rs +++ b/soulseek-rs-lib/src/client/context.rs @@ -100,10 +100,12 @@ impl ClientContext { // the whole board again on the next join. self.room_tickers.remove(room); } + // Only a roster the server has sent in full is kept current: the + // joiner hears of their own arrival before the member list. RoomEvent::UserJoined { room, username } => { - let members = - self.room_members.entry(room.clone()).or_default(); - if let Err(at) = members.binary_search(username) { + if let Some(members) = self.room_members.get_mut(room) + && let Err(at) = members.binary_search(username) + { members.insert(at, username.clone()); } } diff --git a/soulseek-rs-lib/src/client/tests/rooms.rs b/soulseek-rs-lib/src/client/tests/rooms.rs index 0af6784..d504839 100644 --- a/soulseek-rs-lib/src/client/tests/rooms.rs +++ b/soulseek-rs-lib/src/client/tests/rooms.rs @@ -39,6 +39,25 @@ fn the_room_roster_follows_the_joins_and_leaves_the_server_reports() { assert!(context.room_members("lobby").is_empty()); } +#[test] +fn a_join_announced_before_the_member_list_does_not_start_a_roster() { + // soulfind tells a room about a joiner, the joiner included, before it + // sends them the member list. A roster started from that echo reads as a + // room holding nobody else. + let mut context = ClientContext::new(); + context.apply_room_event(RoomEvent::UserJoined { + room: "lobby".to_string(), + username: "me".to_string(), + }); + assert!(context.room_members("lobby").is_empty()); + + context.apply_room_event(RoomEvent::Joined { + room: "lobby".to_string(), + users: vec!["me".to_string(), "alice".to_string()], + }); + assert_eq!(context.room_members("lobby"), ["alice", "me"]); +} + #[test] fn a_user_joining_twice_is_listed_once() { let mut context = ClientContext::new(); diff --git a/soulseek-rs/tests/cli_e2e.rs b/soulseek-rs/tests/cli_e2e.rs index 040e663..a65e3e4 100644 --- a/soulseek-rs/tests/cli_e2e.rs +++ b/soulseek-rs/tests/cli_e2e.rs @@ -2418,7 +2418,12 @@ fn room_users_lists_who_is_in_the_room() { let server = server_or_skip!(); let resident = server.client("cli_e2e_resident", Vec::new()); resident.join_room("cli_e2e_roster").expect("join"); - settle(); + // The member list reaches the resident once the server has them in. + let deadline = Instant::now() + Duration::from_secs(10); + while resident.room_members("cli_e2e_roster").is_empty() { + assert!(Instant::now() < deadline, "the resident never got in"); + std::thread::sleep(Duration::from_millis(50)); + } let output = cli( &server, From 7c9f561d2236b430f143e4502036ba066ca852e6 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 14:28:04 +0200 Subject: [PATCH 02/23] fix(client): record a watch before the server can answer it watch_user sent the request and only then recorded the watch. The server's answer is applied on the ops thread, so a reply that got there first saw nothing to drop for a user who does not exist, and the add that followed left that user watched for good. The lock is now taken before the send and held until the watch is recorded, so the answer always lands on it; a send that fails still records nothing. --- soulseek-rs-lib/src/client/rooms.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/soulseek-rs-lib/src/client/rooms.rs b/soulseek-rs-lib/src/client/rooms.rs index d11da0c..671ff53 100644 --- a/soulseek-rs-lib/src/client/rooms.rs +++ b/soulseek-rs-lib/src/client/rooms.rs @@ -150,8 +150,11 @@ impl Client { /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. pub fn watch_user(&self, username: &str) -> Result<()> { use crate::message::server::MessageFactory; + // Held across the send: the reply is applied under this lock, so it + // cannot land first and have its "does not exist" undone by the add. + let mut context = self.context.write_safe()?; self.send_server_message(MessageFactory::build_watch_user(username))?; - self.context.write_safe()?.add_watched_user(username); + context.add_watched_user(username); Ok(()) } From eeae8e293419bcda48b941f9f270c397e025ea4d Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 14:29:36 +0200 Subject: [PATCH 03/23] test(resume): bound what a paused transfer may still take, not when The pause test slept 1.2 s so a read already in flight could time out before bytes were queued "while paused", then required that none of them were taken. The reader's read timeout is 1 s, so any scheduling delay past 200 ms let the in-flight read take the probe and failed the test. The guarantee is that the pause is checked before every read, so after it lands at most the one read already in flight completes. The test now queues more than a read buffer while paused and asserts no more than one buffer was taken, which holds however the reader is scheduled and still fails a transfer that ignores the pause. It also drops the 1.2 s sleep. --- soulseek-rs-lib/tests/resume_e2e.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/soulseek-rs-lib/tests/resume_e2e.rs b/soulseek-rs-lib/tests/resume_e2e.rs index 7271c25..89ef307 100644 --- a/soulseek-rs-lib/tests/resume_e2e.rs +++ b/soulseek-rs-lib/tests/resume_e2e.rs @@ -379,18 +379,21 @@ fn a_live_download_pauses_and_resumes_on_the_same_connection() { matches!(status, DownloadStatus::Paused { .. }) }); - // Give a read already in flight one timeout cycle to observe the pause. - std::thread::sleep(Duration::from_millis(1_200)); + // The pause is checked before every read, so once it lands only the read + // already in flight can finish: one 64 KiB buffer at most. Queue more + // than that, which a transfer ignoring the pause would take all of. let stable_len = fs::metadata(&part_path).expect("paused partial").len(); - let queued_while_paused = 8 * 1024; + let in_flight = 64 * 1024; + let queued_while_paused = in_flight + 8 * 1024; peer.write_all(&content[first_chunk..first_chunk + queued_while_paused]) .expect("queue bytes while paused"); peer.flush().expect("flush bytes while paused"); std::thread::sleep(Duration::from_millis(300)); - assert_eq!( - fs::metadata(&part_path).expect("paused partial").len(), - stable_len, - "a paused transfer must stop consuming its socket" + let paused_len = fs::metadata(&part_path).expect("paused partial").len(); + assert!( + paused_len <= stable_len + in_flight as u64, + "a paused transfer must stop consuming its socket: {paused_len} \ + bytes after pausing at {stable_len}" ); assert!(subject.client.resume_download(&control.username, filename)); From 8bee7745e1360375cd6ad30d7a43a68a8b7a75ed Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 14:33:23 +0200 Subject: [PATCH 04/23] test(cli): hand a config command's session flags to the daemon cli_with_config passed every flag to the client, so in daemon mode the wishlist serve tests started their daemons with no --shared-dir. Those daemons fell back to sharing ~/Downloads/Soulseek, and the tests only passed because an earlier download test happened to create that folder; run alone, shuffled, or on a machine without it, both failed with "nothing to share". Session flags now go to the daemon the way cli() already routes them. --- soulseek-rs/tests/cli_e2e.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/soulseek-rs/tests/cli_e2e.rs b/soulseek-rs/tests/cli_e2e.rs index a65e3e4..6bdce12 100644 --- a/soulseek-rs/tests/cli_e2e.rs +++ b/soulseek-rs/tests/cli_e2e.rs @@ -884,21 +884,28 @@ fn split_session_flags(args: &[&str]) -> (Vec, Vec) { /// /// `--no-config` is dropped for the obvious reason, and `--quiet` with it: /// these commands report the schedule the *server* chose on stderr, which is -/// the only place a test can see it. +/// the only place a test can see it. Session flags go to the daemon, as in +/// [`cli`]. fn cli_with_config( server: &TestServer, user: &str, config: &Path, args: &[&str], ) -> Output { + let (session_flags, command_args) = match mode() { + Mode::Local => { + (Vec::new(), args.iter().map(|a| (*a).to_string()).collect()) + } + Mode::Daemon => split_session_flags(args), + }; let mut all: Vec = server - .args(user) + .args_with(user, &session_flags) .into_iter() .filter(|arg| arg != "--no-config" && arg != "--quiet") .collect(); all.push("--config".to_string()); all.push(config.to_str().expect("utf-8 path").to_string()); - all.extend(args.iter().map(|a| (*a).to_string())); + all.extend(command_args); let refs: Vec<&str> = all.iter().map(String::as_str).collect(); run(&refs) } From 8854f8d005423ea8fd3c9729970d8757ac53efa0 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 14:35:37 +0200 Subject: [PATCH 05/23] test(cli): wait for the listener to be in the room, and keep an early PM room_listen said its line three seconds after starting the listener and hoped it had joined by then; a line said before the join never reaches it. The speaker now waits for the room to announce the listener, which soulfind only does once it has them in. message_read sent its message three seconds after starting the reader. A reader not yet logged in is a name soulfind has never seen, and it drops a message to such a name. The reader is now registered first, so a message that beats its login is kept and delivered when it arrives. --- soulseek-rs/tests/cli_e2e.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/soulseek-rs/tests/cli_e2e.rs b/soulseek-rs/tests/cli_e2e.rs index 6bdce12..efea975 100644 --- a/soulseek-rs/tests/cli_e2e.rs +++ b/soulseek-rs/tests/cli_e2e.rs @@ -1895,8 +1895,19 @@ fn room_listen_streams_what_is_said_in_the_room() { .spawn() .expect("the binary should run"); - // Give the listener time to join before saying anything. - std::thread::sleep(Duration::from_secs(3)); + // The room announces the listener once the server has it in; a line + // said before that never reaches it. + let deadline = Instant::now() + Duration::from_secs(30); + while !speaker.take_room_events().iter().any(|event| { + matches!( + event, + soulseek_rs::types::RoomEvent::UserJoined { room, username } + if room == "cli_e2e_lobby_b" && username == "cli_e2e_room_reader" + ) + }) { + assert!(Instant::now() < deadline, "the listener never joined"); + std::thread::sleep(Duration::from_millis(50)); + } speaker .say_in_room("cli_e2e_lobby_b", "streamed line") .expect("say"); @@ -1965,6 +1976,11 @@ fn a_private_message_reaches_its_recipient() { fn message_read_streams_incoming_private_messages() { let server = server_or_skip!(); let sender = server.client("cli_e2e_pm_sender", Vec::new()); + // soulfind drops a message to a name it has never seen but keeps one for + // a known user until they log in, so however long the reader takes to + // get online, the line below reaches it. Registered before the reader's + // own session exists: a later login as them would take its place. + drop(server.client("cli_e2e_pm_reader", Vec::new())); settle(); let mut args = server.args("cli_e2e_pm_reader"); From 3c5a3dd64853869a1335cd935cc2db4a6bc9c49a Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 14:36:32 +0200 Subject: [PATCH 06/23] test(portmap): find the unmap among resent maps The NAT-PMP client resends a map the gateway has not answered within 250 ms, and the mock gateway records every request, so a slow mock put a resent map where the test expected the unmap and failed it. The test now looks for the lifetime-0 request among whatever arrives after the drop. On Windows the mock's answer to that resend meets an already-closed socket, and the next receive reports the bounce as a connection reset, which ended the mock's loop before the unmap. It now carries on past it. --- soulseek-rs/src/port_mapping/mod.rs | 38 +++++++++++++++++++---------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/soulseek-rs/src/port_mapping/mod.rs b/soulseek-rs/src/port_mapping/mod.rs index 4787084..f8e67b6 100644 --- a/soulseek-rs/src/port_mapping/mod.rs +++ b/soulseek-rs/src/port_mapping/mod.rs @@ -418,7 +418,18 @@ mod tests { thread::spawn(move || { let mut buf = [0u8; 64]; // Answer many requests (initial map + cleanup unmap + any renews). - while let Ok((n, from)) = socket.recv_from(&mut buf) { + loop { + let (n, from) = match socket.recv_from(&mut buf) { + Ok(received) => received, + // Windows reports a reply that met an already-closed + // client socket on the next receive; the gateway is fine. + Err(e) + if e.kind() == std::io::ErrorKind::ConnectionReset => + { + continue; + } + Err(_) => break, + }; let request = buf[..n].to_vec(); let internal = u16::from_be_bytes([request[4], request[5]]); let lifetime = u32::from_be_bytes([ @@ -465,20 +476,21 @@ mod tests { ); // Dropping the mapper must remove the mapping (a lifetime-0 request). + // A map the gateway was slow to answer is sent again, and that resend + // can arrive first. drop(mapper); - let unmap_req = requests - .recv_timeout(StdDuration::from_secs(3)) - .expect("dropping the mapper should send an unmap request"); - assert_eq!( + let unmapped = std::iter::from_fn(|| { + requests.recv_timeout(StdDuration::from_secs(3)).ok() + }) + .any(|request| { u32::from_be_bytes([ - unmap_req[8], - unmap_req[9], - unmap_req[10], - unmap_req[11] - ]), - 0, - "cleanup should request lifetime 0" - ); + request[8], + request[9], + request[10], + request[11], + ]) == 0 + }); + assert!(unmapped, "cleanup should request lifetime 0"); } /// A minimal in-process UPnP IGD: an HTTP server that answers every SOAP From 2e23581513c134afe0afe6a12d8e92a3a7799e4f Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 14:46:11 +0200 Subject: [PATCH 07/23] test(e2e): wait for the join and the password change, not a sleep The chat-room test gave both joins 500 ms before speaking; a line said before bob is in the room never reaches him. It now waits until each has the member list the server sends once they are in. The password test slept 500 ms and then disconnected. soulfind stores a new password only after hashing it, and drops the change if the session closes first, so a slow hash let the old password keep working. The session now stays up until a login with the new password is accepted. --- soulseek-rs-lib/tests/e2e.rs | 37 +++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 5727b75..a49db08 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -327,8 +327,15 @@ fn a_chat_room_message_is_delivered_between_users() { alice.join_room(room).expect("alice joins room"); bob.join_room(room).expect("bob joins room"); - // Give both joins time to register on the server before speaking. - std::thread::sleep(Duration::from_millis(500)); + // Each is in once the server has sent them the member list; a line said + // before bob is in never reaches him. + let deadline = Instant::now() + Duration::from_secs(5); + while alice.room_members(room).is_empty() + || bob.room_members(room).is_empty() + { + assert!(Instant::now() < deadline, "both should get into the room"); + std::thread::sleep(Duration::from_millis(50)); + } let _ = alice.take_room_events(); let _ = bob.take_room_events(); @@ -4256,7 +4263,23 @@ fn a_changed_password_is_what_the_next_login_needs() { client .change_password("second-pw") .expect("change password"); - std::thread::sleep(Duration::from_millis(500)); + // soulfind stores the new password once it has hashed it, and forgets the + // change if our session closes first. The session stays until a login + // with the new password shows it landed; a refused one leaves it alone. + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let mut fresh = + Client::with_settings(server.settings(user, "second-pw")); + fresh.connect().expect("connect with the new password"); + if matches!(fresh.login(), Ok(true)) { + break; + } + assert!( + Instant::now() < deadline, + "the new password must be accepted" + ); + std::thread::sleep(Duration::from_millis(100)); + } drop(client); let mut stale = Client::with_settings(server.settings(user, "first-pw")); @@ -4265,14 +4288,6 @@ fn a_changed_password_is_what_the_next_login_needs() { !matches!(stale.login(), Ok(true)), "the old password must stop working" ); - drop(stale); - - let mut fresh = Client::with_settings(server.settings(user, "second-pw")); - fresh.connect().expect("connect with the new password"); - assert!( - fresh.login().expect("login with the new password"), - "the new password must be accepted" - ); } #[test] From 7ffb85a6ada01b35b8946f663a322be592ca12d2 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 14:46:11 +0200 Subject: [PATCH 08/23] test(e2e): start a mock uploader once the client has registered it The mock peers signalled ready as soon as their PeerInit was written, and the tests slept 1.5 s before downloading so the client could register the connection. A download that got in first dialled a peer the server has never heard of, and since CantConnectToPeer fails queued downloads it failed at once instead of waiting for the connection. A mock now asks for the client's user info before signalling ready. The client answers through the peer it registered for that connection, so the answer means the download will queue on it. The six sleeps go. --- soulseek-rs-lib/tests/e2e.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index a49db08..b53c4a3 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -533,7 +533,14 @@ fn open_control_and_await_queue( let mut p = connect_retry(&cfg.listen_addr, Duration::from_secs(5))?; p.set_read_timeout(Some(Duration::from_secs(10)))?; p.write_all(&peer_init_bytes(&cfg.peer_username, "P", 0))?; + // The client answers a user-info request through the peer it registered + // for this connection, so the answer means a download queues on it + // rather than dialling a peer the server has never heard of. + p.write_all( + &soulseek_rs::message::peer::build_user_info_request().get_buffer(), + )?; p.flush()?; + expect_code(&mut p, 16, Duration::from_secs(10))?; let _ = cfg.ready.send(()); loop { @@ -867,10 +874,6 @@ fn file_downloads_over_p_and_f( .recv_timeout(Duration::from_secs(5)) .expect("mock uploader P connection"); - // The listener registers an incoming peer under its plain username; give - // that registration a moment to complete before queuing the download. - std::thread::sleep(Duration::from_millis(1500)); - let (_download, status_rx) = client .download( filename.to_string(), @@ -950,7 +953,6 @@ fn cancel_mid_transfer(peer_username: &str, stall_after: Option) { ready_rx .recv_timeout(Duration::from_secs(5)) .expect("mock uploader P connection"); - std::thread::sleep(Duration::from_millis(1500)); let (_download, status_rx) = client .download( @@ -1050,7 +1052,6 @@ fn a_cancelled_queued_download_declines_the_peers_offer() { ready_rx .recv_timeout(Duration::from_secs(5)) .expect("mock uploader P connection"); - std::thread::sleep(Duration::from_millis(1500)); let download_dir = unique_download_dir(); let (_download, status_rx) = client @@ -1124,7 +1125,6 @@ fn refusal_fails_the_download_quickly( ready_rx .recv_timeout(Duration::from_secs(5)) .expect("mock uploader P connection"); - std::thread::sleep(Duration::from_millis(1500)); let download_dir = unique_download_dir(); let (_download, status_rx) = client @@ -1224,7 +1224,6 @@ fn an_interrupted_download_resumes_from_its_partial_file() { ready_rx .recv_timeout(Duration::from_secs(5)) .expect("mock uploader P connection"); - std::thread::sleep(Duration::from_millis(1500)); let (_download, status_rx) = client .download( @@ -3359,7 +3358,6 @@ fn download_progress_reports_the_rate_actually_received() { ready_rx .recv_timeout(Duration::from_secs(5)) .expect("mock uploader P connection"); - std::thread::sleep(Duration::from_millis(1500)); let (_download, status_rx) = client .download( From 2a343dd4b9f82e58151ea463410862491e621ddb Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 14:46:11 +0200 Subject: [PATCH 09/23] test(e2e): give a soulfind that dies on startup a fresh port free_port() hands out a port that is free when asked, not when soulfind binds it, and soulfind exits when the port has gone. The library harness also gave soulfind only five seconds and did not notice a child that had died, so either read as "no soulfind server" and failed the test. It now waits ten seconds like the CLI harness, never takes a child that has exited as up, and retries on a fresh port; the CLI harness gets the same retry. A listener that takes the port first can still answer before soulfind has got as far as binding, so the two library tests that bind listeners outside the server gate now take it too: while a soulfind starts, no other test in that binary binds anything. --- soulseek-rs-lib/tests/e2e.rs | 73 ++++++++++++++++++++++----------- soulseek-rs/tests/common/mod.rs | 18 ++++---- 2 files changed, 59 insertions(+), 32 deletions(-) diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index b53c4a3..959ea9c 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -76,38 +76,51 @@ impl TestServer { } /// Spawn a local soulfind on an ephemeral port with a throwaway database. + /// + /// soulfind exits if its port is taken between `free_port()` handing it + /// out and soulfind binding it, so a child that dies is retried on a + /// fresh port rather than read as a missing server. fn spawn(gate: std::sync::MutexGuard<'static, ()>) -> Option { let bin = soulfind_binary()?; - let port = free_port()?; - let db = std::env::temp_dir().join(format!("soulfind-e2e-{port}.db")); - let _ = std::fs::remove_file(&db); + for _ in 0..3 { + let port = free_port()?; + let db = + std::env::temp_dir().join(format!("soulfind-e2e-{port}.db")); + let _ = std::fs::remove_file(&db); - let mut child = Command::new(&bin) - .arg("-p") - .arg(port.to_string()) - .arg("-d") - .arg(&db) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .ok()?; + let mut child = Command::new(&bin) + .arg("-p") + .arg(port.to_string()) + .arg("-d") + .arg(&db) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok()?; - if wait_until_listening("127.0.0.1", port, Duration::from_secs(5)) - .is_none() - { - // Server never came up (e.g. a toolchain/SQLite issue); skip. + // A child that has exited is never taken as up: soulfind exits + // when its port is gone, and whoever took it may answer instead. + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline + && child.try_wait().ok().flatten().is_none() + { + if TcpStream::connect(("127.0.0.1", port)).is_ok() { + return Some(Self { + host: "127.0.0.1".to_string(), + port, + child: Some(child), + db: Some(db), + _gate: gate, + }); + } + std::thread::sleep(Duration::from_millis(100)); + } + // Never came up (e.g. a toolchain/SQLite issue) or died. let _ = child.kill(); let _ = child.wait(); - return None; + let _ = std::fs::remove_file(&db); } - - Some(Self { - host: "127.0.0.1".to_string(), - port, - child: Some(child), - db: Some(db), - _gate: gate, - }) + None } fn settings(&self, username: &str, password: &str) -> ClientSettings { @@ -4449,6 +4462,11 @@ fn phrases_the_server_excludes_are_kept_for_our_replies() { // network (code 160). Nicotine+ applies them to the files it offers in a // search reply, not to the searches it sends, and so do we — this pins // that the announced list arrives and is kept for that use. + // It binds listeners, so it waits for the server tests: a port handed + // to a soulfind that is still starting is otherwise up for grabs. + let _gate = SERVER_GATE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let mut phrases = Message::new(); phrases .write_int32(160) @@ -5472,6 +5490,11 @@ fn a_disconnected_client_gives_its_listener_port_back() { // next one has to be able to advertise the port it was configured with, // not fall back to an ephemeral one nobody was told about. No server is // needed — binding the listener happens in connect(), before the dial. + // It binds listeners, so it waits for the server tests: a port handed + // to a soulfind that is still starting is otherwise up for grabs. + let _gate = SERVER_GATE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let port = free_port().expect("a port to hold"); let settings = |port: u16| ClientSettings { username: "e2e_port_release".to_string(), diff --git a/soulseek-rs/tests/common/mod.rs b/soulseek-rs/tests/common/mod.rs index d942a9e..82232af 100644 --- a/soulseek-rs/tests/common/mod.rs +++ b/soulseek-rs/tests/common/mod.rs @@ -60,12 +60,16 @@ pub struct Soulfind { } impl Soulfind { + /// soulfind exits if its port is taken between `free_port()` handing it + /// out and soulfind binding it, so a start that fails gets a fresh port. pub fn start() -> Option { - let port = free_port()?; - let db = std::env::temp_dir().join(format!("soulfind-{port}.db")); - let _ = std::fs::remove_file(&db); - let child = Self::spawn(port, &db)?; - Some(Self { child, db, port }) + (0..3).find_map(|_| { + let port = free_port()?; + let db = std::env::temp_dir().join(format!("soulfind-{port}.db")); + let _ = std::fs::remove_file(&db); + let child = Self::spawn(port, &db)?; + Some(Self { child, db, port }) + }) } fn spawn(port: u16, db: &Path) -> Option { @@ -78,8 +82,8 @@ impl Soulfind { let deadline = Instant::now() + Duration::from_secs(10); while Instant::now() < deadline { - // Whoever answers on the port has to be this child: one that - // lost the port and died would otherwise pass as up. + // A child that has exited is never taken as up: soulfind exits + // when its port is gone, and whoever took it may answer instead. if child.try_wait().ok().flatten().is_some() { return None; } From a6d4c8b3dfefcbebf6630e2d6c0749b9eacb6d25 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 15:14:25 +0200 Subject: [PATCH 10/23] test(e2e): fence on the server where a test slept for another client Tests that need one client's request to have landed before another client acts slept for it: a join before a room message, ticker or room search, a watcher's global-room join and leave, a guest accepting invitations, a room created before an outsider asks for it, share counts and interests before they are queried, a stored message before its recipient logs in, and an acknowledgement before the session ends. Several of those requests get no reply to wait on. soulfind takes each connection's messages in order, so a fence asks it about a user over the connection that has to be settled and waits for the answer, which only arrives once everything sent before it has been handled. Where the event itself is visible - a member list, alice's own ticker - the tests wait on that instead. --- soulseek-rs-lib/tests/e2e.rs | 71 ++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 19 deletions(-) diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 959ea9c..d859fa5 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -2955,9 +2955,9 @@ fn a_joined_room_reports_its_members_statistics() { assert!(sharer.login().expect("sharer login")); sharer.join_room(room).expect("sharer joins room"); - // Let the server register the sharer's membership and share counts, so - // the observer's own join reply describes a room that already has them. - std::thread::sleep(Duration::from_millis(750)); + // The sharer's membership and share counts have to land first, so the + // observer's own join reply describes a room that already has them. + fence(&sharer, "e2e_stats_observer"); let mut observer = Client::with_settings(server.settings("e2e_stats_observer", "pw")); @@ -3038,9 +3038,9 @@ fn watching_a_user_returns_their_status_and_share_counts() { watcher.connect().expect("watcher connect"); assert!(watcher.login().expect("watcher login")); - // Let the subject's share counts reach the server before watching, so - // the reply describes a user it already knows the statistics for. - std::thread::sleep(Duration::from_millis(750)); + // The subject's share counts have to reach the server before watching, + // so the reply describes a user it already knows the statistics for. + fence(&subject, "e2e_watch_watcher"); watcher .watch_user("e2e_watch_subject") @@ -3814,6 +3814,18 @@ fn await_room_event( None } +/// Wait until the server has handled everything `client` has sent, including +/// requests it never answers: soulfind takes a connection's messages in order, +/// so the answer to a question about `other` asked now comes after them. +fn fence(client: &Client, other: &str) { + client.request_user_info(other).expect("ask about a user"); + let deadline = Instant::now() + Duration::from_secs(5); + while client.user_info(other).is_none() { + assert!(Instant::now() < deadline, "the server should answer"); + std::thread::sleep(Duration::from_millis(50)); + } +} + #[test] fn a_ticker_set_in_a_room_reaches_the_other_members() { use soulseek_rs::types::RoomEvent; @@ -3830,7 +3842,12 @@ fn a_ticker_set_in_a_room_reaches_the_other_members() { alice.join_room(room).expect("alice joins"); bob.join_room(room).expect("bob joins"); - std::thread::sleep(Duration::from_millis(500)); + // A ticker reaches only who is in the room when it is set. + let deadline = Instant::now() + Duration::from_secs(5); + while bob.room_members(room).is_empty() { + assert!(Instant::now() < deadline, "bob should get into the room"); + std::thread::sleep(Duration::from_millis(50)); + } let _ = bob.take_room_events(); alice @@ -3874,11 +3891,15 @@ fn a_client_joining_later_receives_the_whole_ticker_board() { alice.connect().expect("alice connect"); assert!(alice.login().expect("alice login")); alice.join_room(room).expect("alice joins"); - std::thread::sleep(Duration::from_millis(300)); alice .set_room_ticker(room, "standing message") .expect("set ticker"); - std::thread::sleep(Duration::from_millis(500)); + // The room, alice included, is told once the board holds it. + await_room_event(&alice, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::TickerAdded { room: r, .. } if r == room) + .then_some(()) + }) + .expect("alice should see her ticker go up"); // Carol joins afterwards and must be handed the board that already exists. let mut carol = @@ -3919,7 +3940,7 @@ fn the_global_room_feed_carries_a_room_we_never_joined() { watcher.join_global_room().expect("join global room"); talker.join_room(room).expect("talker joins a room"); - std::thread::sleep(Duration::from_millis(500)); + fence(&watcher, "e2e_global_talk"); let _ = watcher.take_room_events(); let body = "spoken where nobody is watching"; @@ -3943,7 +3964,7 @@ fn the_global_room_feed_carries_a_room_we_never_joined() { // And leaving the feed stops it: the next message must not arrive. watcher.leave_global_room().expect("leave global room"); - std::thread::sleep(Duration::from_millis(500)); + fence(&watcher, "e2e_global_talk"); let _ = watcher.take_room_events(); talker .say_in_room(room, "after leaving") @@ -4009,7 +4030,16 @@ fn a_room_search_is_answered_by_the_rooms_members() { ); sharer.join_room(room).expect("sharer joins"); searcher.join_room(room).expect("searcher joins"); - std::thread::sleep(Duration::from_millis(750)); + // A room search goes to whoever is in the room when it is sent. + let deadline = Instant::now() + Duration::from_secs(5); + while !searcher + .room_members(room) + .iter() + .any(|member| member == "e2e_rs_sharer") + { + assert!(Instant::now() < deadline, "both should get into the room"); + std::thread::sleep(Duration::from_millis(50)); + } let query = "relic"; searcher.search_room(room, query).expect("room search"); @@ -4046,7 +4076,7 @@ fn shared_interests_make_two_users_similar() { alice .add_dislike("e2e_interest_muzak") .expect("alice dislikes something"); - std::thread::sleep(Duration::from_millis(500)); + fence(&bob, "e2e_like_alice"); // Who else likes this item (code 112). let deadline = Instant::now() + Duration::from_secs(5); @@ -4140,7 +4170,7 @@ fn recommendations_are_returned_for_our_interests() { alice.add_interest(shared).expect("alice likes shared"); bob.add_interest(shared).expect("bob likes shared"); bob.add_interest(other).expect("bob likes other"); - std::thread::sleep(Duration::from_millis(500)); + fence(&bob, "e2e_rec_alice"); let deadline = Instant::now() + Duration::from_secs(8); let mut recommended = Vec::new(); @@ -4635,7 +4665,8 @@ fn a_private_room_is_owned_granted_and_revoked() { owner .join_private_room(room) .expect("owner creates the room"); - std::thread::sleep(Duration::from_millis(750)); + // An invite reaches only a user already accepting invitations. + fence(&guest, "e2e_priv_owner"); // The guest is invited, and hears about it (code 139). owner @@ -4754,7 +4785,7 @@ fn a_private_room_someone_else_owns_cannot_be_taken() { owner .join_private_room(room) .expect("owner creates the room"); - std::thread::sleep(Duration::from_millis(750)); + fence(&owner, "e2e_taken_outsider"); // An outsider asking for the same name must be refused (code 1003), not // handed the room. @@ -4807,7 +4838,7 @@ fn an_acknowledged_offline_message_is_not_delivered_twice() { sender .send_private_message("e2e_ack_recipient", body) .expect("send to an offline user"); - std::thread::sleep(Duration::from_millis(500)); + fence(&sender, "e2e_ack_recipient"); let received_once = |label: &str| -> bool { let mut client = @@ -4825,7 +4856,9 @@ fn an_acknowledged_offline_message_is_not_delivered_twice() { std::thread::sleep(Duration::from_millis(100)); } } - std::thread::sleep(Duration::from_millis(500)); + // The acknowledgement goes out ahead of the message it answers, so + // once the server has handled what followed, it has the ack too. + fence(&client, "e2e_ack_sender"); got }; @@ -5379,7 +5412,7 @@ fn a_member_can_give_up_a_private_room_and_an_owner_can_disband_it() { owner .join_private_room(room) .expect("owner creates the room"); - std::thread::sleep(Duration::from_millis(750)); + fence(&guest, "e2e_leave_owner"); owner .add_room_member(room, "e2e_leave_guest") .expect("owner invites the guest"); From 18b53aa4ba8a8ed0e7f237f8134460ae7e25171f Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Sun, 13 Sep 2026 10:13:38 +0200 Subject: [PATCH 11/23] fix(tui): keep the login screen responsive while connecting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The login screen ignored every key while a connect+login attempt ran, so against an unreachable server it sat on a static "Connecting…" for 20s or more, with a hint promising that Esc would quit. While connecting it now shows a spinner: Enter or r restarts the attempt, Esc stops it and returns to the form, q or Ctrl-C quits. Ctrl-C quits from every state of the screen instead of typing a "c" into a field. A failure that got no verdict (unreachable, timed out) keeps the password, so Enter or r retries it; only a rejection, Ok(false) or AuthenticationFailed, clears it. Because a retry can now reuse a password that came from the CLI, env or keychain, entered_via_form compares the credentials with the ones the flow started with instead of trusting any submit, so a retried stored password is never written to the keychain. A restart of an attempt younger than a second is ignored: each attempt rescans the shares and opens a listener, and a held Enter would otherwise start one per key repeat. The attempt result is read before drawing, so a key is never handled against a phase the screen did not show. A stopped or restarted attempt is abandoned, not cancelled: the server actor blocks its own mailbox in connect_timeout, so a stop could not reach it sooner. Its thread runs out its timeouts and drops the client, which disconnects. Until then it holds the listener port, so a restarted session can fall back to an ephemeral port, and should a slow server accept the abandoned login after its replacement got in, the late login displaces the new session. Fixing either needs a cancellable connect, or a bind after the login verdict, in the library. --- soulseek-rs/src/ui/login.rs | 308 ++++++++++++++++++++++++++---------- 1 file changed, 228 insertions(+), 80 deletions(-) diff --git a/soulseek-rs/src/ui/login.rs b/soulseek-rs/src/ui/login.rs index 4bf2334..911dece 100644 --- a/soulseek-rs/src/ui/login.rs +++ b/soulseek-rs/src/ui/login.rs @@ -6,21 +6,25 @@ //! ([`run_login_flow`]) drives it against a real terminal and client. use crate::ui::{ - GLYPH_CURSOR, accent_style, dimmed_style, error_style, info_style, mask, - pane_block, primary_style, title_style, warning_style, + GLYPH_CURSOR, accent_style, dimmed_style, error_style, get_spinner_char, + info_style, mask, pane_block, primary_style, title_style, warning_style, }; use color_eyre::Result; use ratatui::{ DefaultTerminal, Frame, - crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, poll}, + crossterm::event::{ + self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, poll, + }, layout::{Constraint, Flex, Layout, Rect}, style::Modifier, text::{Line, Span}, widgets::{Clear, Paragraph}, }; -use soulseek_rs::{Client, ClientSettings}; +use soulseek_rs::{Client, ClientSettings, SoulseekRs}; use std::sync::mpsc::{Receiver, channel}; -use std::time::Duration; +use std::time::{Duration, Instant}; + +const RESTART_FLOOR: Duration = Duration::from_secs(1); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LoginField { @@ -36,15 +40,17 @@ pub enum LoginPhase { Connecting, /// The last attempt failed; the message is shown until the next key. Failed(String), + Rejected(String), } /// What the caller should do after feeding a key to the form. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LoginAction { None, - /// Both fields are filled and the user pressed Enter. + /// Both fields are filled and the user pressed Enter or r. Submit, - /// User pressed Esc — abort the whole program. + Stop, + /// User pressed Esc, q or Ctrl-C — abort the whole program. Cancel, } @@ -72,9 +78,33 @@ impl LoginForm { } pub fn handle_key(&mut self, key: KeyEvent) -> LoginAction { + if key.code == KeyCode::Char('c') + && key.modifiers.contains(KeyModifiers::CONTROL) + { + return LoginAction::Cancel; + } + match self.phase { - LoginPhase::Connecting => return LoginAction::None, + LoginPhase::Connecting => { + return match key.code { + KeyCode::Enter | KeyCode::Char('r') => LoginAction::Submit, + KeyCode::Esc => { + self.phase = LoginPhase::Editing; + LoginAction::Stop + } + KeyCode::Char('q') => LoginAction::Cancel, + _ => LoginAction::None, + }; + } LoginPhase::Failed(_) => { + self.phase = LoginPhase::Editing; + return match key.code { + KeyCode::Enter | KeyCode::Char('r') => self.submit(), + KeyCode::Esc => LoginAction::Cancel, + _ => LoginAction::None, + }; + } + LoginPhase::Rejected(_) => { // Any key acknowledges the error; the password was wrong (or // the name is taken), so make the user retype it. self.phase = LoginPhase::Editing; @@ -97,20 +127,7 @@ impl LoginForm { }; LoginAction::None } - KeyCode::Enter => { - if !self.username.is_empty() && !self.password.is_empty() { - self.phase = LoginPhase::Connecting; - LoginAction::Submit - } else { - // Move to the first empty field instead of submitting. - self.focused = if self.username.is_empty() { - LoginField::Username - } else { - LoginField::Password - }; - LoginAction::None - } - } + KeyCode::Enter => self.submit(), KeyCode::Backspace => { self.focused_field_mut().pop(); LoginAction::None @@ -123,6 +140,20 @@ impl LoginForm { } } + fn submit(&mut self) -> LoginAction { + if !self.username.is_empty() && !self.password.is_empty() { + self.phase = LoginPhase::Connecting; + return LoginAction::Submit; + } + // Move to the first empty field instead of submitting. + self.focused = if self.username.is_empty() { + LoginField::Username + } else { + LoginField::Password + }; + LoginAction::None + } + const fn focused_field_mut(&mut self) -> &mut String { match self.focused { LoginField::Username => &mut self.username, @@ -142,7 +173,7 @@ pub struct LoginOutcome { } /// Drive the login screen until a login succeeds (`Some`) or the user -/// cancels with Esc (`None`). When both credentials are already known an +/// quits (`None`). When both credentials are already known an /// attempt starts immediately and the form is only shown on failure. pub fn run_login_flow( terminal: &mut DefaultTerminal, @@ -150,27 +181,31 @@ pub fn run_login_flow( initial_username: Option, initial_password: Option, ) -> Result> { - let mut form = LoginForm::new(initial_username); - let mut entered_via_form = false; - let mut attempt: Option>> = - match initial_password { - Some(password) if !form.username.is_empty() => { - form.password = password; - form.phase = LoginPhase::Connecting; - Some(spawn_attempt(make_settings( - form.username.clone(), - form.password.clone(), - ))) - } - _ => None, - }; + let start = |form: &LoginForm| { + spawn_attempt(make_settings( + form.username.clone(), + form.password.clone(), + )) + }; + let mut form = LoginForm::new(initial_username.clone()); + let mut attempt = match &initial_password { + Some(password) if !form.username.is_empty() => { + form.password.clone_from(password); + form.phase = LoginPhase::Connecting; + Some(start(&form)) + } + _ => None, + }; + let mut started = Instant::now(); loop { - terminal.draw(|frame| render(frame, &form))?; - if let Some(rx) = &attempt { match rx.try_recv() { Ok(Ok(client)) => { + let entered_via_form = initial_username.as_deref() + != Some(form.username.as_str()) + || initial_password.as_deref() + != Some(form.password.as_str()); return Ok(Some(LoginOutcome { client, username: form.username, @@ -178,8 +213,8 @@ pub fn run_login_flow( entered_via_form, })); } - Ok(Err(message)) => { - form.phase = LoginPhase::Failed(message); + Ok(Err(failure)) => { + form.phase = failure; attempt = None; } Err(std::sync::mpsc::TryRecvError::Empty) => {} @@ -191,19 +226,24 @@ pub fn run_login_flow( } } + let spinner = + get_spinner_char(started.elapsed().subsec_millis() as usize / 100); + terminal.draw(|frame| render(frame, &form, spinner))?; + if poll(Duration::from_millis(100))? && let Event::Key(key) = event::read()? && key.kind == KeyEventKind::Press { match form.handle_key(key) { LoginAction::Cancel => return Ok(None), + LoginAction::Submit + if attempt.is_some() + && started.elapsed() < RESTART_FLOOR => {} LoginAction::Submit => { - entered_via_form = true; - attempt = Some(spawn_attempt(make_settings( - form.username.clone(), - form.password.clone(), - ))); + attempt = Some(start(&form)); + started = Instant::now(); } + LoginAction::Stop => attempt = None, LoginAction::None => {} } } @@ -211,27 +251,35 @@ pub fn run_login_flow( } /// Connect and log in on a background thread so the UI stays responsive. -fn spawn_attempt(settings: ClientSettings) -> Receiver> { +fn spawn_attempt( + settings: ClientSettings, +) -> Receiver> { let (tx, rx) = channel(); std::thread::spawn(move || { let mut client = Client::with_settings(settings); let result = client .connect() - .map_err(|e| format!("Failed to connect: {e}")) - .and_then(|()| match client.login() { - Ok(true) => Ok(()), - Ok(false) => Err( - "Login rejected: wrong password, or the username is taken" - .to_string(), - ), - Err(e) => Err(format!("Login failed: {e}")), - }); + .map_err(|e| LoginPhase::Failed(format!("Failed to connect: {e}"))) + .and_then(|()| verdict(client.login())); let _ = tx.send(result.map(|()| client)); }); rx } -fn render(frame: &mut Frame, form: &LoginForm) { +fn verdict(login: soulseek_rs::Result) -> Result<(), LoginPhase> { + match login { + Ok(true) => Ok(()), + Ok(false) | Err(SoulseekRs::AuthenticationFailed) => { + Err(LoginPhase::Rejected( + "Login rejected: wrong password, or the username is taken" + .to_string(), + )) + } + Err(e) => Err(LoginPhase::Failed(format!("Login failed: {e}"))), + } +} + +fn render(frame: &mut Frame, form: &LoginForm, spinner: &str) { let area = centered(frame.area(), 52, 12); frame.render_widget(Clear, area); @@ -290,21 +338,32 @@ fn render(frame: &mut Frame, form: &LoginForm) { let status = match &form.phase { LoginPhase::Editing => Paragraph::new(""), LoginPhase::Connecting => Paragraph::new(Line::from(Span::styled( - "Connecting…", + format!("{spinner} Connecting…"), warning_style(), ))), - LoginPhase::Failed(message) => Paragraph::new(Line::from( - Span::styled(message.clone(), error_style()), - )) - .wrap(ratatui::widgets::Wrap { trim: true }), + LoginPhase::Failed(message) | LoginPhase::Rejected(message) => { + Paragraph::new(Line::from(Span::styled( + message.clone(), + error_style(), + ))) + .wrap(ratatui::widgets::Wrap { trim: true }) + } }; frame.render_widget(status, rows[3]); + let keys = match form.phase { + LoginPhase::Connecting => { + "Enter/r: restart · Esc: stop · q/Ctrl-C: quit" + } + LoginPhase::Failed(_) => "Enter/r: retry · Esc: quit", + LoginPhase::Editing | LoginPhase::Rejected(_) => { + "Tab: switch · Enter: log in · Esc: quit" + } + }; frame.render_widget( - Paragraph::new( - "New usernames are registered automatically.\n\ - Tab: switch · Enter: log in · Esc: quit", - ) + Paragraph::new(format!( + "New usernames are registered automatically.\n{keys}" + )) .style(dimmed_style()), rows[5], ); @@ -323,7 +382,6 @@ fn centered(area: Rect, width: u16, height: u16) -> Rect { #[cfg(test)] mod tests { use super::*; - use ratatui::crossterm::event::KeyModifiers; fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) @@ -335,6 +393,13 @@ mod tests { } } + fn connecting() -> LoginForm { + let mut form = LoginForm::new(Some("alice".into())); + type_str(&mut form, "pw"); + assert_eq!(form.handle_key(key(KeyCode::Enter)), LoginAction::Submit); + form + } + #[test] fn prefilled_username_focuses_password() { let form = LoginForm::new(Some("alice".into())); @@ -402,11 +467,93 @@ mod tests { } #[test] - fn key_after_failure_returns_to_editing_and_clears_password() { - let mut form = LoginForm::new(Some("alice".into())); - type_str(&mut form, "wrong"); - form.handle_key(key(KeyCode::Enter)); - form.phase = LoginPhase::Failed("INVALIDPASS".into()); + fn ctrl_c_quits_from_every_phase_without_typing() { + let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + for phase in [ + LoginPhase::Editing, + LoginPhase::Connecting, + LoginPhase::Failed("unreachable".into()), + LoginPhase::Rejected("INVALIDPASS".into()), + ] { + let mut form = LoginForm::new(None); + form.phase = phase.clone(); + assert_eq!( + form.handle_key(ctrl_c), + LoginAction::Cancel, + "{phase:?}" + ); + assert_eq!(form.username, "", "{phase:?}"); + } + } + + #[test] + fn typing_is_ignored_while_connecting() { + let mut form = connecting(); + let action = form.handle_key(key(KeyCode::Char('x'))); + assert_eq!(action, LoginAction::None); + assert_eq!(form.password, "pw"); + assert_eq!(form.phase, LoginPhase::Connecting); + } + + #[test] + fn escape_while_connecting_stops_and_keeps_the_credentials() { + let mut form = connecting(); + assert_eq!(form.handle_key(key(KeyCode::Esc)), LoginAction::Stop); + assert_eq!(form.phase, LoginPhase::Editing); + assert_eq!(form.password, "pw"); + assert_eq!(form.handle_key(key(KeyCode::Enter)), LoginAction::Submit); + } + + #[test] + fn enter_or_r_while_connecting_restarts() { + for code in [KeyCode::Enter, KeyCode::Char('r')] { + let mut form = connecting(); + assert_eq!(form.handle_key(key(code)), LoginAction::Submit); + assert_eq!(form.phase, LoginPhase::Connecting); + assert_eq!(form.password, "pw"); + } + } + + #[test] + fn q_while_connecting_quits() { + let mut form = connecting(); + assert_eq!( + form.handle_key(key(KeyCode::Char('q'))), + LoginAction::Cancel + ); + } + + #[test] + fn enter_or_r_after_a_failed_connection_retries_with_the_same_password() { + for code in [KeyCode::Enter, KeyCode::Char('r')] { + let mut form = connecting(); + form.phase = LoginPhase::Failed("Login failed: timed out".into()); + assert_eq!(form.handle_key(key(code)), LoginAction::Submit); + assert_eq!(form.phase, LoginPhase::Connecting); + assert_eq!(form.password, "pw"); + } + } + + #[test] + fn other_key_after_a_failed_connection_edits_with_the_password_kept() { + let mut form = connecting(); + form.phase = LoginPhase::Failed("Login failed: timed out".into()); + assert_eq!(form.handle_key(key(KeyCode::Char('x'))), LoginAction::None); + assert_eq!(form.phase, LoginPhase::Editing); + assert_eq!(form.password, "pw"); + } + + #[test] + fn escape_after_a_failed_connection_quits() { + let mut form = connecting(); + form.phase = LoginPhase::Failed("Login failed: timed out".into()); + assert_eq!(form.handle_key(key(KeyCode::Esc)), LoginAction::Cancel); + } + + #[test] + fn key_after_rejection_returns_to_editing_and_clears_password() { + let mut form = connecting(); + form.phase = LoginPhase::Rejected("INVALIDPASS".into()); let action = form.handle_key(key(KeyCode::Char('x'))); assert_eq!(action, LoginAction::None); assert_eq!(form.phase, LoginPhase::Editing); @@ -414,13 +561,14 @@ mod tests { } #[test] - fn keys_ignored_while_connecting() { - let mut form = LoginForm::new(Some("alice".into())); - type_str(&mut form, "pw"); - form.handle_key(key(KeyCode::Enter)); - assert_eq!(form.phase, LoginPhase::Connecting); - let action = form.handle_key(key(KeyCode::Char('x'))); - assert_eq!(action, LoginAction::None); - assert_eq!(form.password, "pw"); + fn a_wrong_password_is_rejected_and_an_unanswered_login_failed() { + assert_eq!(verdict(Ok(true)), Ok(())); + for rejected in [Ok(false), Err(SoulseekRs::AuthenticationFailed)] { + assert!(matches!(verdict(rejected), Err(LoginPhase::Rejected(_)))); + } + for failed in [Err(SoulseekRs::Timeout), Err(SoulseekRs::NotConnected)] + { + assert!(matches!(verdict(failed), Err(LoginPhase::Failed(_)))); + } } } From 0255f6ad43c7bc0f7971765a96352c8c6154cb62 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Sun, 13 Sep 2026 21:32:05 +0200 Subject: [PATCH 12/23] feat(lib): cap the combined download speed `Client::set_download_speed_limit(bytes_per_second)` puts one ceiling on every download at once. Zero lifts it and is the default. It can be changed at any time, and a change reaches transfers already running within one 100 ms tick. The limiter works the way Soulseek.NET's does, which slskd uses for its own download limit: one token bucket shared by all downloads, refilled to a tenth of a second's worth every 100 ms, handing out partial grants and taking back what a short read or a timeout did not use. A download is paced by reading less from the socket. The unread bytes stay in the kernel's receive buffer and TCP flow control slows the uploader; no protocol message is involved, as in Nicotine+ too. With no cap set, each read costs one atomic load. A same-hour stress run scored develop 88.98/88.98 and this branch 89.00/88.99/88.97, at about 133 MiB/s both. An upload ceiling is left out, as before. --- soulseek-rs-lib/src/client/context.rs | 1 + soulseek-rs-lib/src/client/downloads.rs | 6 + soulseek-rs-lib/src/client/mod.rs | 2 + soulseek-rs-lib/src/peer/download_peer.rs | 10 +- soulseek-rs-lib/src/utils/mod.rs | 1 + soulseek-rs-lib/src/utils/rate_limit.rs | 136 ++++++++++++++++++++++ soulseek-rs-lib/tests/e2e.rs | 76 ++++++++++++ 7 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 soulseek-rs-lib/src/utils/rate_limit.rs diff --git a/soulseek-rs-lib/src/client/context.rs b/soulseek-rs-lib/src/client/context.rs index 8606abf..ff98041 100644 --- a/soulseek-rs-lib/src/client/context.rs +++ b/soulseek-rs-lib/src/client/context.rs @@ -42,6 +42,7 @@ impl ClientContext { private_messages: Vec::new(), pending_connect_tokens: HashMap::new(), max_peers: Arc::new(AtomicUsize::new(DEFAULT_MAX_PEERS)), + download_speed_limit: Arc::default(), shares: Arc::new(Shares::empty()), shared_directories: Vec::new(), peer_addresses: HashMap::new(), diff --git a/soulseek-rs-lib/src/client/downloads.rs b/soulseek-rs-lib/src/client/downloads.rs index 28c8177..e47db5d 100644 --- a/soulseek-rs-lib/src/client/downloads.rs +++ b/soulseek-rs-lib/src/client/downloads.rs @@ -14,6 +14,12 @@ impl Client { .unwrap_or_default() } + pub fn set_download_speed_limit(&self, bytes_per_second: u64) { + if let Ok(ctx) = self.context.read_safe() { + ctx.download_speed_limit.set(bytes_per_second); + } + } + #[must_use] pub fn pause_download(&self, username: &str, filename: &str) -> bool { match self.context.write_safe() { diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index 21d7dfd..7a146b1 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -9,6 +9,7 @@ use crate::types::{ SimilarUser, UserInfo, UserInterests, UserPresence, UserStats, UserStatus, }; use crate::utils::logger; +use crate::utils::rate_limit::RateLimit; use crate::{ Transfer, actor::{ActorSystem, peer_registry::PeerRegistry}, @@ -486,6 +487,7 @@ pub struct ClientContext { /// a token we sent in a ConnectToPeer to the peer we expect back. pending_connect_tokens: HashMap, max_peers: Arc, + pub(crate) download_speed_limit: Arc, /// Files we share with peers (read-only after connect). pub shares: Arc, /// The directories the current share index was built from. diff --git a/soulseek-rs-lib/src/peer/download_peer.rs b/soulseek-rs-lib/src/peer/download_peer.rs index 034e54f..fd2e231 100644 --- a/soulseek-rs-lib/src/peer/download_peer.rs +++ b/soulseek-rs-lib/src/peer/download_peer.rs @@ -363,6 +363,11 @@ impl DownloadPeer { stream .set_read_timeout(Some(CANCEL_POLL)) .map_err(DownloadError::ConnectionFailed)?; + let speed_limit = client_context + .read() + .map_err(|_| DownloadError::LockPoisoned)? + .download_speed_limit + .clone(); let mut last_data = Instant::now(); loop { @@ -384,7 +389,10 @@ impl DownloadPeer { break; } - match stream.read(&mut read_buffer) { + let allowance = speed_limit.take(READ_BUFFER_SIZE); + let read = stream.read(&mut read_buffer[..allowance]); + speed_limit.give_back(allowance - read.as_ref().map_or(0, |&n| n)); + match read { Ok(0) => { trace!( "[download_peer:{}] connection closed by peer", diff --git a/soulseek-rs-lib/src/utils/mod.rs b/soulseek-rs-lib/src/utils/mod.rs index e32ce7c..b7ea3bd 100644 --- a/soulseek-rs-lib/src/utils/mod.rs +++ b/soulseek-rs-lib/src/utils/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod keepalive; pub mod lock; pub mod md5; pub mod path; +pub(crate) mod rate_limit; pub(crate) mod semaphore; pub mod zlib; diff --git a/soulseek-rs-lib/src/utils/rate_limit.rs b/soulseek-rs-lib/src/utils/rate_limit.rs new file mode 100644 index 0000000..a5a261a --- /dev/null +++ b/soulseek-rs-lib/src/utils/rate_limit.rs @@ -0,0 +1,136 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, PoisonError}; +use std::time::{Duration, Instant}; + +const TICK: Duration = Duration::from_millis(100); +const TICKS_PER_SECOND: u64 = 1000 / TICK.as_millis() as u64; + +#[derive(Default)] +pub struct RateLimit { + bytes_per_second: AtomicU64, + bucket: Mutex, +} + +#[derive(Default)] +struct Bucket { + tokens: u64, + refilled: Option, +} + +impl RateLimit { + pub fn set(&self, bytes_per_second: u64) { + self.bytes_per_second + .store(bytes_per_second, Ordering::Relaxed); + } + + pub fn take(&self, want: usize) -> usize { + loop { + let rate = self.bytes_per_second.load(Ordering::Relaxed); + if rate == 0 { + return want; + } + let mut bucket = + self.bucket.lock().unwrap_or_else(PoisonError::into_inner); + let now = Instant::now(); + let since = bucket.refilled.map_or(TICK, |at| now - at); + if since >= TICK { + bucket.tokens = (rate / TICKS_PER_SECOND).max(1); + bucket.refilled = Some(now); + } + if bucket.tokens > 0 { + let grant = bucket.tokens.min(want as u64); + bucket.tokens -= grant; + return grant as usize; + } + drop(bucket); + std::thread::sleep(TICK.saturating_sub(since)); + } + } + + pub fn give_back(&self, unused: usize) { + if unused == 0 || self.bytes_per_second.load(Ordering::Relaxed) == 0 { + return; + } + self.bucket + .lock() + .unwrap_or_else(PoisonError::into_inner) + .tokens += unused as u64; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_limit_grants_everything_at_once() { + let limit = RateLimit::default(); + let started = Instant::now(); + assert_eq!(limit.take(1 << 20), 1 << 20); + assert!(started.elapsed() < TICK); + } + + #[test] + fn a_limit_paces_what_is_taken() { + let limit = RateLimit::default(); + limit.set(20_000); + let started = Instant::now(); + let mut taken = 0; + while taken < 6_000 { + let grant = limit.take(4096); + assert!((1..=4096).contains(&grant), "granted {grant}"); + taken += grant; + } + let elapsed = started.elapsed(); + assert!( + (2 * TICK..10 * TICK).contains(&elapsed), + "{taken} bytes at 20 kB/s took {elapsed:?}" + ); + } + + #[test] + fn concurrent_takers_share_one_budget() { + let limit = RateLimit::default(); + limit.set(20_000); + let started = Instant::now(); + std::thread::scope(|s| { + for _ in 0..3 { + s.spawn(|| { + let mut taken = 0; + while taken < 2_000 { + taken += limit.take(4096); + } + }); + } + }); + assert!( + started.elapsed() >= 2 * TICK, + "6 kB at 20 kB/s across three takers took {:?}", + started.elapsed() + ); + } + + #[test] + fn unused_allowance_goes_back_to_the_budget() { + let limit = RateLimit::default(); + limit.set(20_000); + let started = Instant::now(); + let grant = limit.take(4096); + limit.give_back(grant); + assert_eq!(limit.take(4096), grant); + assert!(started.elapsed() < TICK); + } + + #[test] + fn lifting_a_limit_frees_a_take_that_is_waiting() { + let limit = RateLimit::default(); + limit.set(100); + assert_eq!(limit.take(1000), 10); + std::thread::scope(|s| { + let waiting = s.spawn(|| limit.take(1000)); + std::thread::sleep(Duration::from_millis(10)); + limit.set(0); + assert_eq!(waiting.join().unwrap(), 1000); + }); + } +} diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index d859fa5..8e6cf70 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -936,6 +936,82 @@ fn a_download_completes_when_the_f_connection_beats_token_registration() { ); } +#[test] +fn a_download_speed_limit_paces_a_transfer_until_it_is_lifted() { + let server = server_or_skip!(); + let listen_port = free_port().expect("free listen port"); + let mut client = Client::with_settings(server.listening_settings( + "e2e_cappedpeer_dl", + "pw", + listen_port, + )); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + client.set_download_speed_limit(64 * 1024); + + let filename = "capped.mp3"; + let content: Vec = + (0..1024 * 1024u32).map(|i| (i % 251) as u8).collect(); + let download_dir = unique_download_dir(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let cfg = MockUpload { + listen_addr: format!("127.0.0.1:{listen_port}"), + peer_username: "e2e_cappedpeer".to_string(), + filename: filename.to_string(), + content: content.clone(), + token: 434_343, + ready: ready_tx, + token_delay: Duration::ZERO, + }; + let uploader = std::thread::spawn(move || { + if let Err(e) = run_mock_uploader(&cfg) { + eprintln!("[mock uploader] {e}"); + } + }); + ready_rx + .recv_timeout(Duration::from_secs(5)) + .expect("mock uploader P connection"); + + let started = Instant::now(); + let (_download, status_rx) = client + .download( + filename.to_string(), + "e2e_cappedpeer".to_string(), + content.len() as u64, + download_dir.display().to_string(), + ) + .expect("start download"); + let in_progress = || { + client + .get_all_downloads() + .iter() + .any(|d| matches!(d.status, DownloadStatus::InProgress { .. })) + }; + assert!(wait_for(in_progress), "the download should start"); + std::thread::sleep(Duration::from_secs(1)); + assert!( + in_progress(), + "1 MiB at 64 KiB/s must still be arriving a second in" + ); + + client.set_download_speed_limit(0); + assert!( + wait_for_completion(&client, &status_rx, Duration::from_secs(20)), + "the download should reach Completed" + ); + let elapsed = started.elapsed(); + let _ = uploader.join(); + assert!( + elapsed < Duration::from_secs(8), + "capped for the whole file this takes 16s; lifted it took {elapsed:?}" + ); + assert_eq!( + std::fs::read(download_dir.join(filename)).expect("downloaded file"), + content + ); + let _ = std::fs::remove_dir_all(&download_dir); +} + fn cancel_mid_transfer(peer_username: &str, stall_after: Option) { let server = server_or_skip!(); From 8cbee3203fd8b69b02b03baaebdf1f66b536a334 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 14 Sep 2026 12:03:53 +0200 Subject: [PATCH 13/23] fix(lib): give the listener port back before a dropped client returns disconnect() told the listener thread to stop and dialled it awake, then returned while that thread was still unwinding, so the TcpListener was often still open when the caller moved on. A client built straight after a drop then found its configured port taken and fell back to an ephemeral one. The TUI login screen does exactly that when it replaces an attempt. The listener thread now raises a flag once it has dropped its socket, and after the wake-up dial has landed, disconnect() waits for that flag, bounded at two seconds, so the port can be bound again by the time disconnect() or Drop returns. A listener that cannot be woken, or is held past the bound by a full handshake queue, finishes on its own as before. The flag is an Arc, like the stop flag beside it: keeping the thread's JoinHandle instead would leave Client neither UnwindSafe nor RefUnwindSafe, which is a breaking change for the library. Nicotine+ and Soulseek.NET both close the listen socket as part of disconnecting; this makes that hold by the time the call returns. --- soulseek-rs-lib/src/client/connection.rs | 21 +++++++-- soulseek-rs-lib/src/client/mod.rs | 2 + soulseek-rs-lib/tests/e2e.rs | 58 ++++++++---------------- 3 files changed, 39 insertions(+), 42 deletions(-) diff --git a/soulseek-rs-lib/src/client/connection.rs b/soulseek-rs-lib/src/client/connection.rs index 01bffb7..56103ec 100644 --- a/soulseek-rs-lib/src/client/connection.rs +++ b/soulseek-rs-lib/src/client/connection.rs @@ -5,6 +5,9 @@ use super::{ TcpStream, error, info, mpsc, thread, trace, warn, }; use std::sync::atomic::Ordering; +use std::time::{Duration, Instant}; + +const LISTENER_RELEASE_TIMEOUT: Duration = Duration::from_secs(2); /// Ceiling on the wait for a login verdict. Generous enough for a slow server /// and a retrying connect, short enough that an unattended caller ends. @@ -121,6 +124,8 @@ impl Client { let context = self.context.clone(); let own_username = self.username.clone(); let stopped = self.listener_stopped.clone(); + let released = Arc::new(AtomicBool::new(false)); + self.listener_released = released.clone(); thread::spawn(move || { Listen::serve( @@ -130,6 +135,8 @@ impl Client { own_username, stopped, ); + drop(listener); + released.store(true, Ordering::Relaxed); }); } @@ -151,7 +158,8 @@ impl Client { /// collide with our own ghost. /// /// Called automatically when the client is dropped. The peer listener's - /// bound port, if one was opened, is released with the process. + /// bound port, if one was opened, is free again when this returns, unless + /// the listener cannot be woken within two seconds. pub fn disconnect(&mut self) { if let Some(handle) = self.server_handle.take() { let _ = handle.stop(); @@ -173,8 +181,15 @@ impl Client { // this the port stays bound for the life of the process, and the next // client falls back to an ephemeral port nobody was told about. self.listener_stopped.store(true, Ordering::Relaxed); - if let Some(port) = self.bound_port.take() { - let _ = TcpStream::connect(("127.0.0.1", port)); + if let Some(port) = self.bound_port.take() + && TcpStream::connect(("127.0.0.1", port)).is_ok() + { + let deadline = Instant::now() + LISTENER_RELEASE_TIMEOUT; + while !self.listener_released.load(Ordering::Relaxed) + && Instant::now() < deadline + { + thread::sleep(Duration::from_millis(5)); + } } } diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index 7a146b1..eefc812 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -648,6 +648,7 @@ pub struct Client { /// Tells the peer listener to stop, so a disconnected client releases the /// port it bound. listener_stopped: Arc, + listener_released: Arc, } impl Drop for Client { @@ -682,6 +683,7 @@ impl Client { server_handle: None, session: SessionWatch::default(), listener_stopped: Arc::new(AtomicBool::new(false)), + listener_released: Arc::new(AtomicBool::new(false)), } } diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 8e6cf70..01c905b 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -5594,50 +5594,30 @@ fn privileges_can_be_handed_to_another_user() { } #[test] -fn a_disconnected_client_gives_its_listener_port_back() { - // A client that goes out of scope must release the port it bound: the - // next one has to be able to advertise the port it was configured with, - // not fall back to an ephemeral one nobody was told about. No server is - // needed — binding the listener happens in connect(), before the dial. - // It binds listeners, so it waits for the server tests: a port handed - // to a soulfind that is still starting is otherwise up for grabs. +fn a_dropped_client_has_released_its_listener_port_when_drop_returns() { let _gate = SERVER_GATE .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let port = free_port().expect("a port to hold"); - let settings = |port: u16| ClientSettings { - username: "e2e_port_release".to_string(), - password: "pw".to_string(), - server_address: PeerAddress::new("127.0.0.1".to_string(), 1), - enable_listen: true, - listen_port: port, - shared_directories: Vec::new(), - accept_children: false, - version: ClientVersion::default(), - }; - let mut first = Client::with_settings(settings(port)); - first.connect().expect("bind the listener"); - assert_eq!(first.listen_port(), Some(port)); - drop(first); + for round in 0..25 { + let mut client = Client::with_settings(ClientSettings { + username: "e2e_port_release_now".to_string(), + password: "pw".to_string(), + server_address: PeerAddress::new("127.0.0.1".to_string(), 1), + enable_listen: true, + listen_port: port, + shared_directories: Vec::new(), + accept_children: false, + version: ClientVersion::default(), + }); + client.connect().expect("bind the listener"); + assert_eq!(client.listen_port(), Some(port), "round {round}"); + drop(client); - // The listener thread notices the stop the moment it is woken, which the - // disconnect does by dialling it; give it a beat to unwind. - let deadline = Instant::now() + Duration::from_secs(5); - let mut second_port = None; - while Instant::now() < deadline && second_port != Some(port) { - let mut second = Client::with_settings(settings(port)); - second.connect().expect("bind again"); - second_port = second.listen_port(); - if second_port == Some(port) { - break; - } - drop(second); - std::thread::sleep(Duration::from_millis(200)); + assert!( + std::net::TcpListener::bind(("0.0.0.0", port)).is_ok(), + "round {round}: the port is still held once drop has returned" + ); } - assert_eq!( - second_port, - Some(port), - "the port should be free again once the first client is dropped" - ); } From c1a80b0d302d4e79064bda369eac8cb6f3e91071 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 14 Sep 2026 12:07:14 +0200 Subject: [PATCH 14/23] feat(lib): let another thread cancel a client's connect and login A caller that gives up on a login had no way to reach the client: the thread that owns it sits in login() for up to 45 seconds, and disconnect() needs the client to itself. Until that thread returned, the abandoned client kept connecting and still wrote its login frame the moment the server finished the TCP handshake. When that happened after a replacement had logged in under the same name, the server kicked the replacement. Client::cancel_handle() hands out a CancelHandle that can be moved to any thread. After cancel(): - the server actor writes nothing more. The check and the write share a lock with cancel(), so once cancel() returns no frame leaves, whether it was waiting in the queue, in the dispatcher channel, or mid-flush; - login() returns NotConnected within 100 ms, and hands no login to the actor at all when the client was already cancelled; - connect() refuses before binding the listener when the client was already cancelled. A cancel that lands during that check can still let the bind through. Either way the owner drops the client as before, which disconnects it and gives its port back. A frame already written before cancel() returned cannot be recalled. The API is additive, and login() on a client nobody cancels behaves as before. Soulseek.NET tears the whole connection down when ConnectAsync is cancelled, and Nicotine+ drops outgoing messages for a socket it closed; this gives soulseek-rs the same end state from another thread. --- soulseek-rs-lib/src/actor/server_actor.rs | 12 +++- .../src/actor/server_actor/tests.rs | 38 ++++++++++ soulseek-rs-lib/src/client/cancel.rs | 24 +++++++ soulseek-rs-lib/src/client/connection.rs | 27 +++++-- soulseek-rs-lib/src/client/mod.rs | 10 +++ soulseek-rs-lib/src/lib.rs | 2 +- soulseek-rs-lib/tests/e2e.rs | 71 +++++++++++++++++++ 7 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 soulseek-rs-lib/src/client/cancel.rs diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index 150de3e..658387f 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -1,5 +1,5 @@ use crate::actor::{Actor, ActorHandle, ConnectionState}; -use crate::client::ClientOperation; +use crate::client::{CancelHandle, ClientOperation}; use crate::dispatcher::MessageDispatcher; use crate::message::server::AdminMessageHandler; use crate::message::server::CantConnectToPeerHandler; @@ -288,6 +288,7 @@ pub struct ServerActor { shared_folder_count: u32, shared_file_count: u32, session: SessionWatch, + cancel: CancelHandle, } /// The messages a client sends right after a successful login: its shared-file @@ -340,6 +341,7 @@ impl ServerActor { shared_folder_count, shared_file_count, session: SessionWatch::default(), + cancel: CancelHandle::new(), } } @@ -348,6 +350,10 @@ impl ServerActor { self.session = session; } + pub fn set_cancel(&mut self, cancel: CancelHandle) { + self.cancel = cancel; + } + fn initiate_connection(&mut self) -> bool { let stream = (self.address.get_host(), self.address.get_port()) .to_socket_addrs() @@ -563,6 +569,10 @@ impl ServerActor { } fn send_message(&mut self, message: Message) { + let cancel = self.cancel.clone(); + let Some(_live) = cancel.live() else { + return; + }; let Some(stream) = self.stream.as_mut() else { error!("[server] Cannot send message: stream is None"); return; diff --git a/soulseek-rs-lib/src/actor/server_actor/tests.rs b/soulseek-rs-lib/src/actor/server_actor/tests.rs index 0809d99..064ba7e 100644 --- a/soulseek-rs-lib/src/actor/server_actor/tests.rs +++ b/soulseek-rs-lib/src/actor/server_actor/tests.rs @@ -188,6 +188,44 @@ fn a_login_with_nobody_listening_is_refused_at_once() { ); } +#[test] +fn a_cancelled_actor_never_writes_the_login_it_queued() { + use std::io::Read; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let client = TcpStream::connect(addr).unwrap(); + let (mut server_side, _) = listener.accept().unwrap(); + client.set_nonblocking(true).unwrap(); + + let cancel = CancelHandle::new(); + let mut actor = parked_actor(addr.port()); + actor.set_cancel(cancel.clone()); + actor.stream = Some(client); + actor.connection_state = ConnectionState::Connecting { + since: Instant::now(), + }; + let (response, _verdict) = std::sync::mpsc::channel(); + actor.handle_login( + "u".into(), + "p".into(), + ClientVersion::default(), + response, + ); + assert_eq!(actor.queued_messages.len(), 1); + + cancel.cancel(); + actor.tick(); + assert!(matches!(actor.connection_state, ConnectionState::Connected)); + actor.on_stop(); + + server_side + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut written = Vec::new(); + assert_eq!(server_side.read_to_end(&mut written).unwrap(), 0); +} + #[test] fn a_successful_login_marks_the_session_live_again() { let mut actor = parked_actor(1); diff --git a/soulseek-rs-lib/src/client/cancel.rs b/soulseek-rs-lib/src/client/cancel.rs new file mode 100644 index 0000000..ba8ebbe --- /dev/null +++ b/soulseek-rs-lib/src/client/cancel.rs @@ -0,0 +1,24 @@ +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +#[derive(Clone)] +pub struct CancelHandle(Arc>); + +impl CancelHandle { + pub(crate) fn new() -> Self { + Self(Arc::new(Mutex::new(false))) + } + + pub fn cancel(&self) { + *self.0.lock().unwrap_or_else(PoisonError::into_inner) = true; + } + + #[must_use] + pub fn is_cancelled(&self) -> bool { + *self.0.lock().unwrap_or_else(PoisonError::into_inner) + } + + pub(crate) fn live(&self) -> Option> { + let guard = self.0.lock().unwrap_or_else(PoisonError::into_inner); + (!*guard).then_some(guard) + } +} diff --git a/soulseek-rs-lib/src/client/connection.rs b/soulseek-rs-lib/src/client/connection.rs index 56103ec..cf0bb09 100644 --- a/soulseek-rs-lib/src/client/connection.rs +++ b/soulseek-rs-lib/src/client/connection.rs @@ -5,9 +5,11 @@ use super::{ TcpStream, error, info, mpsc, thread, trace, warn, }; use std::sync::atomic::Ordering; +use std::sync::mpsc::RecvTimeoutError; use std::time::{Duration, Instant}; const LISTENER_RELEASE_TIMEOUT: Duration = Duration::from_secs(2); +const LOGIN_POLL_INTERVAL: Duration = Duration::from_millis(100); /// Ceiling on the wait for a login verdict. Generous enough for a slow server /// and a retrying connect, short enough that an unattended caller ends. @@ -90,6 +92,10 @@ impl Client { ctx.shares = shares; ctx.shared_directories.clone_from(&self.shared_directories); + if self.cancel.is_cancelled() { + return Err(SoulseekRs::NotConnected); + } + // 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. @@ -111,6 +117,7 @@ impl Client { shared_file_count, ); server_actor.set_session_watch(self.session.clone()); + server_actor.set_cancel(self.cancel.clone()); self.server_handle = Some(ctx.actor_system.spawn_with_handle( server_actor, @@ -202,7 +209,9 @@ impl Client { /// control. pub fn login(&self) -> Result { info!("Logging in as {}", self.username); - if let Some(handle) = &self.server_handle { + if let Some(handle) = &self.server_handle + && !self.cancel.is_cancelled() + { let (tx, rx) = std::sync::mpsc::channel(); let _ = handle.send(ServerMessage::Login { username: self.username.clone(), @@ -211,9 +220,19 @@ impl Client { response: tx, }); - match rx.recv_timeout(LOGIN_RESPONSE_TIMEOUT) { - Ok(result) => result, - Err(_) => Err(SoulseekRs::Timeout), + let deadline = Instant::now() + LOGIN_RESPONSE_TIMEOUT; + loop { + match rx.recv_timeout(LOGIN_POLL_INTERVAL) { + Ok(result) => return result, + Err(RecvTimeoutError::Timeout) + if self.cancel.is_cancelled() => + { + return Err(SoulseekRs::NotConnected); + } + Err(RecvTimeoutError::Timeout) + if Instant::now() < deadline => {} + Err(_) => return Err(SoulseekRs::Timeout), + } } } else { Err(SoulseekRs::NotConnected) diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index eefc812..6ce58e5 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -649,6 +649,7 @@ pub struct Client { /// port it bound. listener_stopped: Arc, listener_released: Arc, + cancel: CancelHandle, } impl Drop for Client { @@ -684,6 +685,7 @@ impl Client { session: SessionWatch::default(), listener_stopped: Arc::new(AtomicBool::new(false)), listener_released: Arc::new(AtomicBool::new(false)), + cancel: CancelHandle::new(), } } @@ -724,6 +726,11 @@ impl Client { self.session.loss() } + #[must_use] + pub fn cancel_handle(&self) -> CancelHandle { + self.cancel.clone() + } + /// The directories whose files are currently shared with other peers. #[must_use] pub fn shared_directories(&self) -> Vec { @@ -849,6 +856,7 @@ impl Client { } } +mod cancel; mod children; mod connection; mod context; @@ -861,5 +869,7 @@ mod social; mod upload_queue; mod uploads; +pub use cancel::CancelHandle; + #[cfg(test)] mod tests; diff --git a/soulseek-rs-lib/src/lib.rs b/soulseek-rs-lib/src/lib.rs index a174fbd..45d3263 100644 --- a/soulseek-rs-lib/src/lib.rs +++ b/soulseek-rs-lib/src/lib.rs @@ -32,7 +32,7 @@ pub mod prelude { // Re-export commonly used types pub use actor::server_actor::{PeerAddress, UserMessage}; -pub use client::{Client, ClientSettings}; +pub use client::{CancelHandle, Client, ClientSettings}; pub use error::{Result, SoulseekRs}; pub use message::peer::{SharedDirectory, SharedFileEntry}; pub use peer::{ConnectionType, ParseConnectionTypeError}; diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 01c905b..0e45c9f 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -5621,3 +5621,74 @@ fn a_dropped_client_has_released_its_listener_port_when_drop_returns() { ); } } + +#[test] +fn a_cancelled_client_never_binds_its_port() { + let _gate = SERVER_GATE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let port = free_port().expect("a port to hold"); + let mut client = Client::with_settings(ClientSettings { + username: "e2e_cancelled_bind".to_string(), + password: "pw".to_string(), + server_address: PeerAddress::new("127.0.0.1".to_string(), 1), + enable_listen: true, + listen_port: port, + shared_directories: Vec::new(), + accept_children: false, + version: ClientVersion::default(), + }); + + client.cancel_handle().cancel(); + + assert!(client.connect().is_err()); + assert_eq!(client.listen_port(), None); + assert!(std::net::TcpListener::bind(("0.0.0.0", port)).is_ok()); +} + +#[test] +fn a_cancel_ends_a_login_waiting_for_its_verdict_and_nothing_follows_it() { + let server = + std::net::TcpListener::bind("127.0.0.1:0").expect("a silent server"); + let mut client = Client::with_settings(ClientSettings { + username: "e2e_cancelled_login".to_string(), + password: "pw".to_string(), + server_address: PeerAddress::new( + "127.0.0.1".to_string(), + server.local_addr().expect("server address").port(), + ), + enable_listen: false, + listen_port: 0, + shared_directories: Vec::new(), + accept_children: false, + version: ClientVersion::default(), + }); + client.connect().expect("start the session"); + let (mut conn, _) = server.accept().expect("the client dials the server"); + conn.set_read_timeout(Some(Duration::from_secs(5))) + .expect("read timeout"); + let cancel = client.cancel_handle(); + + std::thread::scope(|scope| { + let login = scope.spawn(|| client.login()); + let frame = + read_framed(&mut conn).expect("the login reaches the server"); + assert_eq!(frame.get_message_code(), 1); + + let cancelled_at = Instant::now(); + cancel.cancel(); + assert!(login.join().expect("the login thread").is_err()); + assert!(cancelled_at.elapsed() < Duration::from_secs(5)); + }); + client + .connect_peer("e2e_nobody") + .expect("the actor takes a request"); + drop(client); + + let mut rest = Vec::new(); + assert_eq!( + conn.read_to_end(&mut rest) + .expect("the server sees the session end"), + 0 + ); +} From bd502c216a027fb3ed6d9033c75221451a541a3d Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 14 Sep 2026 12:12:49 +0200 Subject: [PATCH 15/23] fix(tui): cancel a replaced login attempt and wait for it to let go Stopping or restarting an attempt used to drop only the channel its result would arrive on. The thread kept its client for up to 45 seconds: that client held the configured listener port, so the replacement fell back to an ephemeral one, and a slow server could still accept the old login after the new one and kick the new session. Each attempt now carries its client's CancelHandle. Esc cancels it on the spot. A restart cancels the attempt it replaces on the UI thread, and the new attempt's thread starts by joining the old one, which has dropped its client, and with it the port, by the time it ends. A result the old attempt finished with but nobody collected is dropped before the new one connects. An attempt replaced before its thread got going skips connecting altogether, and a result that lands after a cancel is thrown away instead of handed over. Restarts in a row chain through the joins, so at most one client is connected or holds the port at a time, which is what Soulseek.NET's connection lock and Nicotine+'s single server connection guarantee too. A result is acted on only while the screen shows Connecting. One that lands after Esc, even a successful login, is dropped on the spot, so no stray session stays online until the next submit. The one-second restart floor applies only to restarting an attempt in flight, since a kept attempt no longer means one is running. A restart during the old attempt's share scan waits for that scan to finish; the scan cannot be interrupted. --- soulseek-rs/src/ui/login.rs | 198 ++++++++++++++++++++++++++++++------ 1 file changed, 168 insertions(+), 30 deletions(-) diff --git a/soulseek-rs/src/ui/login.rs b/soulseek-rs/src/ui/login.rs index 911dece..6376ba0 100644 --- a/soulseek-rs/src/ui/login.rs +++ b/soulseek-rs/src/ui/login.rs @@ -20,8 +20,9 @@ use ratatui::{ text::{Line, Span}, widgets::{Clear, Paragraph}, }; -use soulseek_rs::{Client, ClientSettings, SoulseekRs}; +use soulseek_rs::{CancelHandle, Client, ClientSettings, SoulseekRs}; use std::sync::mpsc::{Receiver, channel}; +use std::thread::JoinHandle; use std::time::{Duration, Instant}; const RESTART_FLOOR: Duration = Duration::from_secs(1); @@ -181,26 +182,39 @@ pub fn run_login_flow( initial_username: Option, initial_password: Option, ) -> Result> { - let start = |form: &LoginForm| { - spawn_attempt(make_settings( + let start = |form: &LoginForm, previous: Option>| { + let mut client = Client::with_settings(make_settings( form.username.clone(), form.password.clone(), - )) + )); + let cancel = client.cancel_handle(); + Attempt::spawn(previous, cancel, move || { + let outcome = client + .connect() + .map_err(|e| { + LoginPhase::Failed(format!("Failed to connect: {e}")) + }) + .and_then(|()| verdict(client.login())); + outcome.map(|()| client) + }) }; let mut form = LoginForm::new(initial_username.clone()); let mut attempt = match &initial_password { Some(password) if !form.username.is_empty() => { form.password.clone_from(password); form.phase = LoginPhase::Connecting; - Some(start(&form)) + Some(start(&form, None)) } _ => None, }; let mut started = Instant::now(); loop { - if let Some(rx) = &attempt { - match rx.try_recv() { + if let Some(current) = &attempt { + let connecting = form.phase == LoginPhase::Connecting; + match current.result.try_recv() { + Ok(_) | Err(std::sync::mpsc::TryRecvError::Disconnected) + if !connecting => {} Ok(Ok(client)) => { let entered_via_form = initial_username.as_deref() != Some(form.username.as_str()) @@ -213,15 +227,11 @@ pub fn run_login_flow( entered_via_form, })); } - Ok(Err(failure)) => { - form.phase = failure; - attempt = None; - } + Ok(Err(failure)) => form.phase = failure, Err(std::sync::mpsc::TryRecvError::Empty) => {} Err(std::sync::mpsc::TryRecvError::Disconnected) => { form.phase = LoginPhase::Failed("Connection thread died".into()); - attempt = None; } } } @@ -234,36 +244,62 @@ pub fn run_login_flow( && let Event::Key(key) = event::read()? && key.kind == KeyEventKind::Press { + let restarting = form.phase == LoginPhase::Connecting; match form.handle_key(key) { LoginAction::Cancel => return Ok(None), LoginAction::Submit - if attempt.is_some() - && started.elapsed() < RESTART_FLOOR => {} + if restarting && started.elapsed() < RESTART_FLOOR => {} LoginAction::Submit => { - attempt = Some(start(&form)); + attempt = Some(start(&form, attempt.take())); started = Instant::now(); } - LoginAction::Stop => attempt = None, + LoginAction::Stop => { + if let Some(current) = &attempt { + current.cancel.cancel(); + } + } LoginAction::None => {} } } } } -/// Connect and log in on a background thread so the UI stays responsive. -fn spawn_attempt( - settings: ClientSettings, -) -> Receiver> { - let (tx, rx) = channel(); - std::thread::spawn(move || { - let mut client = Client::with_settings(settings); - let result = client - .connect() - .map_err(|e| LoginPhase::Failed(format!("Failed to connect: {e}"))) - .and_then(|()| verdict(client.login())); - let _ = tx.send(result.map(|()| client)); - }); - rx +/// One login attempt, run on a background thread so the UI stays responsive. +struct Attempt { + cancel: CancelHandle, + thread: JoinHandle<()>, + result: Receiver>, +} + +impl Attempt { + fn spawn( + previous: Option, + cancel: CancelHandle, + work: impl FnOnce() -> Result + Send + 'static, + ) -> Self { + if let Some(previous) = &previous { + previous.cancel.cancel(); + } + let (tx, result) = channel(); + let own = cancel.clone(); + let thread = std::thread::spawn(move || { + if let Some(previous) = previous { + let _ = previous.thread.join(); + } + if own.is_cancelled() { + return; + } + let outcome = work(); + if !own.is_cancelled() { + let _ = tx.send(outcome); + } + }); + Self { + cancel, + thread, + result, + } + } } fn verdict(login: soulseek_rs::Result) -> Result<(), LoginPhase> { @@ -571,4 +607,106 @@ mod tests { assert!(matches!(verdict(failed), Err(LoginPhase::Failed(_)))); } } + + struct Held(&'static str, std::sync::mpsc::Sender); + + impl Drop for Held { + fn drop(&mut self) { + let _ = self.1.send(format!("released {}", self.0)); + } + } + + fn cancel_handle() -> CancelHandle { + Client::new("attempt", "pw").cancel_handle() + } + + fn next(seen: &Receiver) -> String { + seen.recv_timeout(Duration::from_secs(5)) + .expect("an attempt event") + } + + #[test] + fn a_restart_builds_nothing_until_the_attempt_it_replaces_has_let_go() { + let (events, seen) = channel(); + let first_events = events.clone(); + let first = Attempt::spawn(None, cancel_handle(), move || { + let _ = first_events.send("started first".to_string()); + std::thread::sleep(Duration::from_millis(100)); + Ok(Held("first", first_events)) + }); + assert_eq!(next(&seen), "started first"); + + let _second = Attempt::spawn(Some(first), cancel_handle(), move || { + let _ = events.send("started second".to_string()); + Err(LoginPhase::Failed("second".into())) + }); + + assert_eq!(next(&seen), "released first"); + assert_eq!(next(&seen), "started second"); + } + + #[test] + fn a_finished_attempt_is_let_go_before_its_replacement_starts() { + let (events, seen) = channel(); + let first_events = events.clone(); + let first = Attempt::spawn(None, cancel_handle(), move || { + Ok(Held("first", first_events)) + }); + let deadline = Instant::now() + Duration::from_secs(5); + while !first.thread.is_finished() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + assert!(first.thread.is_finished()); + + let _second = Attempt::spawn(Some(first), cancel_handle(), move || { + let _ = events.send("started second".to_string()); + Err(LoginPhase::Failed("second".into())) + }); + + assert_eq!(next(&seen), "released first"); + assert_eq!(next(&seen), "started second"); + } + + #[test] + fn an_attempt_superseded_before_it_began_never_runs() { + let (events, seen) = channel(); + let (release, gate) = channel::<()>(); + let first_events = events.clone(); + let first = Attempt::spawn(None, cancel_handle(), move || { + let _ = first_events.send("started first".to_string()); + let _ = gate.recv(); + Err::(LoginPhase::Failed("first".into())) + }); + assert_eq!(next(&seen), "started first"); + + let second_events = events.clone(); + let second = Attempt::spawn(Some(first), cancel_handle(), move || { + let _ = second_events.send("started second".to_string()); + Err(LoginPhase::Failed("second".into())) + }); + let _third = Attempt::spawn(Some(second), cancel_handle(), move || { + let _ = events.send("started third".to_string()); + Err(LoginPhase::Failed("third".into())) + }); + release.send(()).expect("release the first attempt"); + + assert_eq!(next(&seen), "started third"); + } + + #[test] + fn a_result_that_lands_after_a_stop_is_let_go_on_the_attempts_thread() { + let (events, seen) = channel(); + let (release, gate) = channel::<()>(); + let attempt = Attempt::spawn(None, cancel_handle(), move || { + let _ = events.send("started late".to_string()); + let _ = gate.recv(); + Ok(Held("late", events)) + }); + assert_eq!(next(&seen), "started late"); + + attempt.cancel.cancel(); + release.send(()).expect("release the attempt"); + + assert_eq!(next(&seen), "released late"); + } } From deb846be1439d1d73dc6e12de3f8c8281b14cac6 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 14 Sep 2026 12:15:45 +0200 Subject: [PATCH 16/23] feat(tui): say so in the status bar when the listener took another port With the login screen's own attempts no longer holding the port, a session listens somewhere other than the configured port mainly when another program already holds it. soulseek-rs still falls back to an ephemeral port there, which is what lets several clients on one machine share a configured port, and it advertises the port it really bound. A manual port forward on the router then points at nothing, though, and the TUI never said so; the CLI already warns. After login the TUI compares the bound port with the configured one, using the CLI's rule, and the status bar's title reads "port X in use, listening on Y" for the rest of the session. A window attached to a daemon shows nothing: the daemon owns its listener. Nicotine+ keeps retrying the configured port every five seconds and Soulseek.NET refuses it; neither falls back. Keeping the fallback and making it visible is a deliberate difference. --- soulseek-rs/src/run.rs | 13 +++++++++---- soulseek-rs/src/ui/downloads.rs | 8 +++++++- soulseek-rs/src/ui/main_tui/mod.rs | 6 +++++- soulseek-rs/src/ui/main_tui/render/mod.rs | 1 + soulseek-rs/src/ui/main_tui/tests/layout.rs | 13 +++++++++++++ 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs index b0e8b58..eeecf45 100644 --- a/soulseek-rs/src/run.rs +++ b/soulseek-rs/src/run.rs @@ -502,14 +502,17 @@ fn run_default_tui( // Best-effort: make ourselves reachable behind a home router so // firewalled peers can connect back. Mapped only once the listener is // up, and for the port it really bound. Kept alive for the session. - let _port_mapper = outcome - .client - .listen_port() - .map(crate::port_mapping::PortMapper::spawn); + let bound_port = outcome.client.listen_port(); + let _port_mapper = + bound_port.map(crate::port_mapping::PortMapper::spawn); let store = crate::persist::paths::state_dir() .map(crate::persist::state::StateStore::new); + let listener_fallback = bound_port + .filter(|&bound| listen_port != 0 && bound != listen_port) + .map(|bound| (listen_port, bound)); + let exit = launch_main_tui( terminal, Arc::new(outcome.client), @@ -517,6 +520,7 @@ fn run_default_tui( Duration::from_secs(resolved.search_timeout), store, config_path.clone(), + listener_fallback, ) .map_err(|e| CliError::new(Exit::Failure, e.to_string()))?; @@ -592,6 +596,7 @@ fn run_attached_tui( Duration::from_secs(resolved.search_timeout), None, config_path, + None, ) .map(|_| ()) .map_err(|e| CliError::new(Exit::Failure, e.to_string())) diff --git a/soulseek-rs/src/ui/downloads.rs b/soulseek-rs/src/ui/downloads.rs index 0747f76..f79f718 100644 --- a/soulseek-rs/src/ui/downloads.rs +++ b/soulseek-rs/src/ui/downloads.rs @@ -12,6 +12,7 @@ use ratatui::{ widgets::Paragraph, }; use soulseek_rs::DownloadStatus; +use std::fmt::Write as _; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -54,6 +55,7 @@ pub fn render_download_stats( downloads: &[DownloadEntry], active_count: usize, daemon: Option<&str>, + listener_fallback: Option<(u16, u16)>, ) { let completed = downloads .iter() @@ -79,10 +81,14 @@ pub fn render_download_stats( let active = active_progress(downloads); - let title = match daemon { + let mut title = match daemon { Some(endpoint) => format!("Status · daemon {endpoint}"), None => "Status".to_string(), }; + if let Some((configured, bound)) = listener_fallback { + let _ = + write!(title, " · port {configured} in use, listening on {bound}"); + } let block = pane_block(false).title(plain_title(&title, false)); let inner_area = block.inner(area); diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 3658f4c..cf5d31e 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -33,6 +33,7 @@ pub struct MainTui { saved_snapshot: Snapshot, /// Which divider the left mouse button is holding, if any. resize_drag: Option, + listener_fallback: Option<(u16, u16)>, } impl MainTui { @@ -53,6 +54,7 @@ impl MainTui { config_path, saved_snapshot: Snapshot::default(), resize_drag: None, + listener_fallback: None, }; tui.restore_persisted_state(); tui @@ -257,9 +259,11 @@ pub fn launch_main_tui( search_timeout: Duration, store: Option, config_path: Option, + listener_fallback: Option<(u16, u16)>, ) -> Result { - let tui = + let mut tui = MainTui::new(client, download_dir, search_timeout, store, config_path); + tui.listener_fallback = listener_fallback; tui.run(terminal) } diff --git a/soulseek-rs/src/ui/main_tui/render/mod.rs b/soulseek-rs/src/ui/main_tui/render/mod.rs index bf16054..98cd750 100644 --- a/soulseek-rs/src/ui/main_tui/render/mod.rs +++ b/soulseek-rs/src/ui/main_tui/render/mod.rs @@ -40,6 +40,7 @@ impl MainTui { &self.state.downloads, self.state.active_downloads_count, daemon.as_deref(), + self.listener_fallback, ); self.render_content(frame, main_chunks[1]); diff --git a/soulseek-rs/src/ui/main_tui/tests/layout.rs b/soulseek-rs/src/ui/main_tui/tests/layout.rs index 8381637..1ea058f 100644 --- a/soulseek-rs/src/ui/main_tui/tests/layout.rs +++ b/soulseek-rs/src/ui/main_tui/tests/layout.rs @@ -21,6 +21,19 @@ fn the_results_pane_has_the_whole_width_to_itself() { assert!(searches < downloads && downloads < info, "{row}"); } +#[test] +fn a_listener_that_fell_back_says_so_in_the_status_bar() { + let mut tui = furnished_tui(); + tui.listener_fallback = Some((2234, 50123)); + + let screen = screen_of(&mut tui); + + assert!( + screen.contains("port 2234 in use, listening on 50123"), + "{screen}" + ); +} + #[test] fn tab_and_shift_tab_walk_the_panes_in_legend_order() { let mut tui = furnished_tui(); From 0b97a45d8bb9d03a7d5e8d0f3fca5bfe034f4c63 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 21 Sep 2026 12:15:43 +0200 Subject: [PATCH 17/23] build: declare Rust 1.91 as the minimum version Duration::from_mins needs it, and without the field cargo on an older toolchain fails on the edition instead of naming the version it wants. --- Cargo.toml | 1 + soulseek-rs-lib/Cargo.toml | 1 + soulseek-rs/Cargo.toml | 1 + 3 files changed, 3 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 310dc01..47a88b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ resolver = "2" version = "19.0.0" authors = ["Michel de Graaf"] edition = "2024" +rust-version = "1.91" license = "MIT" repository = "https://github.com/michel/soulseek-rs" homepage = "https://re-invention.nl/soulseek-rs/" diff --git a/soulseek-rs-lib/Cargo.toml b/soulseek-rs-lib/Cargo.toml index ebc7d14..659c074 100644 --- a/soulseek-rs-lib/Cargo.toml +++ b/soulseek-rs-lib/Cargo.toml @@ -3,6 +3,7 @@ name = "soulseek-rs-lib" version.workspace = true authors.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true diff --git a/soulseek-rs/Cargo.toml b/soulseek-rs/Cargo.toml index 1465749..23cd1f6 100644 --- a/soulseek-rs/Cargo.toml +++ b/soulseek-rs/Cargo.toml @@ -3,6 +3,7 @@ name = "soulseek-rs" version.workspace = true authors.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true From d71172542cdab4df02cab21b880eed7178f74c72 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 21 Sep 2026 12:15:43 +0200 Subject: [PATCH 18/23] feat(cli): let a build name its own version A nightly printed the same 19.0.0 as the release it followed, so a bug report could not say which one it came from. SOULSEEK_RS_VERSION at compile time replaces the crate version in --version, the TUI and the daemon status. --- soulseek-rs/src/cli.rs | 7 ++++++- soulseek-rs/src/daemon/server/mod.rs | 4 ++-- soulseek-rs/src/ui/downloads.rs | 3 +-- soulseek-rs/src/ui/panes/results_pane.rs | 3 +-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs index 8c5efd0..196a3c3 100644 --- a/soulseek-rs/src/cli.rs +++ b/soulseek-rs/src/cli.rs @@ -34,11 +34,16 @@ Exit codes: 0 success 2 usage/config 3 connect or login 4 no results 5 timed out 6 transfer failed 7 session lost 1 unexpected error"; +pub const VERSION: &str = match option_env!("SOULSEEK_RS_VERSION") { + Some(version) => version, + None => env!("CARGO_PKG_VERSION"), +}; + #[derive(Parser, Debug, Default)] #[command( name = "soulseek-rs", author, - version, + version = VERSION, about = "Soulseek client in Rust 🦀", long_about = "Soulseek client in Rust.\n\nRun without a subcommand for the \ interactive TUI, or use a subcommand for a scriptable \ diff --git a/soulseek-rs/src/daemon/server/mod.rs b/soulseek-rs/src/daemon/server/mod.rs index ca7fe3c..201a6d0 100644 --- a/soulseek-rs/src/daemon/server/mod.rs +++ b/soulseek-rs/src/daemon/server/mod.rs @@ -522,7 +522,7 @@ impl Daemon { fn identify(&self) -> AuthResult { AuthResult { protocol: PROTOCOL_VERSION, - daemon_version: env!("CARGO_PKG_VERSION").to_string(), + daemon_version: crate::cli::VERSION.to_string(), username: self.session.username(), } } @@ -532,7 +532,7 @@ impl Daemon { DaemonStatus { username: self.session.username(), server: self.server.clone(), - daemon_version: env!("CARGO_PKG_VERSION").to_string(), + daemon_version: crate::cli::VERSION.to_string(), protocol: PROTOCOL_VERSION, listen_port: self.session.listen_port(), shared_folders: folders, diff --git a/soulseek-rs/src/ui/downloads.rs b/soulseek-rs/src/ui/downloads.rs index f79f718..24eec71 100644 --- a/soulseek-rs/src/ui/downloads.rs +++ b/soulseek-rs/src/ui/downloads.rs @@ -1,3 +1,4 @@ +use crate::cli::VERSION; use crate::models::DownloadEntry; use crate::ui::{ BYTES_PER_MB, accent_style, dimmed_style, error_style, @@ -14,8 +15,6 @@ use ratatui::{ use soulseek_rs::DownloadStatus; use std::fmt::Write as _; -const VERSION: &str = env!("CARGO_PKG_VERSION"); - /// Bytes transferred, bytes expected and combined speed across the downloads /// that are actually running — or `None` when none are, so the caller can hide /// the progress readout entirely. diff --git a/soulseek-rs/src/ui/panes/results_pane.rs b/soulseek-rs/src/ui/panes/results_pane.rs index 1b71f59..e111655 100644 --- a/soulseek-rs/src/ui/panes/results_pane.rs +++ b/soulseek-rs/src/ui/panes/results_pane.rs @@ -1,4 +1,5 @@ use super::name_scroll::{column_width, end_offset, scroll_text}; +use crate::cli::VERSION; use crate::models::FileDisplayData; use crate::ui::{ BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes, @@ -13,8 +14,6 @@ use ratatui::{ }; use std::collections::HashSet; -const VERSION: &str = env!("CARGO_PKG_VERSION"); - // The file name and its folder share whatever width the fixed columns leave, // the name getting the larger part: it is what a search is after, and a // folder's tail is a scroll away. From 14491768a5815c4c77b32b739b8266da41e88e80 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 21 Sep 2026 12:15:43 +0200 Subject: [PATCH 19/23] feat(cli): print completion scripts and the man page to stdout `completions install` writes into a home directory, which a package build does not have. `completions print ` and `man` give it the files to put under /usr/share instead. --- Cargo.lock | 31 +++++++++++++++++++------ soulseek-rs/Cargo.toml | 1 + soulseek-rs/src/SKILL.md | 3 +++ soulseek-rs/src/cli.rs | 9 +++++++ soulseek-rs/src/commands/completions.rs | 25 ++++++++++++++++++-- soulseek-rs/src/commands/mod.rs | 3 ++- soulseek-rs/src/run.rs | 11 ++++++--- soulseek-rs/tests/cli_e2e.rs | 22 ++++++++++++++++++ 8 files changed, 92 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e316a04..d57a443 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -476,9 +476,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.2" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -486,9 +486,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -508,14 +508,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.2", ] [[package]] @@ -524,6 +524,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clap_mangen" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "color-eyre" version = "0.6.5" @@ -2251,6 +2261,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -2550,6 +2566,7 @@ dependencies = [ "chrono", "clap", "clap_complete", + "clap_mangen", "color-eyre", "directories", "dotenv", diff --git a/soulseek-rs/Cargo.toml b/soulseek-rs/Cargo.toml index 23cd1f6..f40af90 100644 --- a/soulseek-rs/Cargo.toml +++ b/soulseek-rs/Cargo.toml @@ -30,6 +30,7 @@ soulseek-rs-lib = { version = "19.0.0", path = "../soulseek-rs-lib" } clap = { version = "4.6.2", features = ["derive", "color", "wrap_help", "env"] } clap_complete = "4.6.7" +clap_mangen = "0.3.3" ratatui = "0.30.2" unicode-width = "0.2" color-eyre = "0.6.5" diff --git a/soulseek-rs/src/SKILL.md b/soulseek-rs/src/SKILL.md index 2dc21fc..387db6d 100644 --- a/soulseek-rs/src/SKILL.md +++ b/soulseek-rs/src/SKILL.md @@ -236,6 +236,9 @@ this skill file into the local agent directories, or remove it: `agent`, completion script where bash, zsh, or fish looks for it, or take it back again: `shell`, `path`, `action`. For a human at a prompt, not for you. +**`completions print `** and **`man`** — the completion script or the +man page on stdout, raw rather than records. For whoever builds a package. + ## Idioms ```bash diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs index 196a3c3..37b3270 100644 --- a/soulseek-rs/src/cli.rs +++ b/soulseek-rs/src/cli.rs @@ -249,6 +249,9 @@ pub enum Commands { /// Tab completion for bash, zsh, and fish #[command(subcommand)] Completions(CompletionsCommand), + + /// Print the man page to stdout, for packaging + Man, } #[derive(Args, Debug, Default)] @@ -315,6 +318,12 @@ pub enum CompletionsCommand { /// Remove the completion script again Uninstall(CompletionsArgs), + + /// Print the completion script to stdout, for packaging + Print { + #[arg(value_enum)] + shell: Shell, + }, } #[derive(Args, Debug)] diff --git a/soulseek-rs/src/commands/completions.rs b/soulseek-rs/src/commands/completions.rs index d6ca9ef..cd5582c 100644 --- a/soulseek-rs/src/commands/completions.rs +++ b/soulseek-rs/src/commands/completions.rs @@ -8,10 +8,10 @@ use super::{unwritable, xdg}; use crate::cli::{Cli, CompletionsArgs, CompletionsCommand, Shell}; -use crate::output::{CliError, CliResult, CompletionRecord, Out}; +use crate::output::{CliError, CliResult, CompletionRecord, Exit, Out}; use clap::CommandFactory; use directories::UserDirs; -use std::io::Write; +use std::io::{ErrorKind, Write}; use std::path::{Path, PathBuf}; /// The command being completed, and the file name the scripts are written as. @@ -65,6 +65,27 @@ pub fn run(out: &Out, command: &CompletionsCommand) -> CliResult { match command { CompletionsCommand::Install(args) => install(out, &targets(args)?), CompletionsCommand::Uninstall(args) => uninstall(out, &targets(args)?), + CompletionsCommand::Print { shell } => print(&script(*shell)), + } +} + +pub fn man() -> CliResult { + let mut page = Vec::new(); + clap_mangen::Man::new(Cli::command().about("Soulseek client in Rust")) + .render(&mut page) + .expect("a Vec accepts every write"); + print(&page) +} + +fn print(bytes: &[u8]) -> CliResult { + match std::io::stdout().write_all(bytes) { + Err(error) if error.kind() != ErrorKind::BrokenPipe => { + Err(CliError::new( + Exit::Failure, + format!("cannot write stdout: {error}"), + )) + } + _ => Ok(()), } } diff --git a/soulseek-rs/src/commands/mod.rs b/soulseek-rs/src/commands/mod.rs index c5a009e..65192e9 100644 --- a/soulseek-rs/src/commands/mod.rs +++ b/soulseek-rs/src/commands/mod.rs @@ -330,7 +330,8 @@ fn dispatch(ctx: &Ctx, command: Commands) -> CliResult { | Commands::Config(_) | Commands::Shares(_) | Commands::Skills(_) - | Commands::Completions(_) => { + | Commands::Completions(_) + | Commands::Man => { Err(CliError::usage("handled without a session; see main::run")) } } diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs index eeecf45..0d653b3 100644 --- a/soulseek-rs/src/run.rs +++ b/soulseek-rs/src/run.rs @@ -35,6 +35,14 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { } } + match &cli.command { + Some(Commands::Completions(command)) => { + return crate::commands::completions::run(out, command); + } + Some(Commands::Man) => return crate::commands::completions::man(), + _ => {} + } + let config_path = config_path(&cli); let file_config = match &config_path { Some(path) => crate::persist::config::FileConfig::load(path) @@ -84,9 +92,6 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { resolved.username.as_deref(), ); } - Commands::Completions(ref command) => { - return crate::commands::completions::run(out, command); - } Commands::Config(ConfigCommand::Path) => { return crate::commands::settings::config_path(out, &store); } diff --git a/soulseek-rs/tests/cli_e2e.rs b/soulseek-rs/tests/cli_e2e.rs index efea975..6a44531 100644 --- a/soulseek-rs/tests/cli_e2e.rs +++ b/soulseek-rs/tests/cli_e2e.rs @@ -412,6 +412,28 @@ fn installing_completions_is_repeatable_and_reversible() { assert_eq!(action(&again), "absent"); } +#[test] +fn a_package_build_reads_completions_and_the_man_page_from_stdout() { + let dir = Scratch::new("package-build"); + let config = dir.path().join("config.toml"); + std::fs::write(&config, "not = [toml").expect("a config file"); + let broken = config.display().to_string(); + + for (shell, marker) in [ + ("bash", "complete -F"), + ("zsh", "#compdef soulseek-rs"), + ("fish", "complete -c soulseek-rs"), + ] { + let output = run(&["--config", &broken, "completions", "print", shell]); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + assert!(stdout(&output).contains(marker), "{shell} script"); + } + + let man = run(&["--config", &broken, "man"]); + assert_eq!(code(&man), EXIT_OK, "stderr: {}", stderr(&man)); + assert!(stdout(&man).contains(".TH soulseek-rs 1")); +} + // --- wishlist, the half that only touches the config file ------------------ #[test] From 645d24e29de4bc3f765ce03d9941ffb639824a28 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 21 Sep 2026 12:15:43 +0200 Subject: [PATCH 20/23] ci(nightly): stamp each build with its commit The version reads 19.0.0+git.: later than the release it follows, earlier than the next, and ordered among nightlies. --- .github/workflows/nightly.yml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 005f468..e86af72 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -24,6 +24,7 @@ jobs: timeout-minutes: 5 outputs: sha: ${{ steps.commit.outputs.sha }} + version: ${{ steps.commit.outputs.version }} steps: # workflow_dispatch has a branch selector, but a Nightly run always means # develop. Resolve it once so every target builds the identical commit @@ -36,13 +37,22 @@ jobs: - name: Record source commit id: commit shell: bash - run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + run: | + set -euo pipefail + sha=$(git rev-parse HEAD) + base=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2) + stamp=$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y%m%d%H%M) + [[ "$base" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$stamp" =~ ^[0-9]{12}$ ]] + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "version=$base+git$stamp.${sha:0:7}" >> "$GITHUB_OUTPUT" binaries: name: ${{ matrix.target }} needs: source runs-on: ${{ matrix.os }} timeout-minutes: 45 + env: + SOULSEEK_RS_VERSION: ${{ needs.source.outputs.version }} strategy: fail-fast: false matrix: @@ -219,9 +229,11 @@ jobs: smoke-test: name: Install published nightly - needs: publish + needs: [source, publish] runs-on: ubuntu-latest timeout-minutes: 5 + env: + VERSION: ${{ needs.source.outputs.version }} steps: - uses: actions/checkout@v4 with: @@ -233,7 +245,7 @@ jobs: SOULSEEK_RS_INSTALL_DIR: ${{ runner.temp }}/curl-install run: | sh website/public/install.sh --nightly - "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version + "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version | grep -F "$VERSION" - name: Install with wget and no curl on PATH env: @@ -246,4 +258,5 @@ jobs: ln -s "$(command -v "$tool")" "$RUNNER_TEMP/wget-path/$tool" done PATH="$RUNNER_TEMP/wget-path" /bin/sh website/public/install.sh --nightly - "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version + "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version | grep -F "$VERSION" + From f09d550e4b17389d434b21b67bb6283efc5f14e6 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 21 Sep 2026 12:15:43 +0200 Subject: [PATCH 21/23] ci: attach .deb and .rpm packages to releases and nightlies Built by nfpm from the static musl binaries, with the man page and the bash, zsh and fish completions. The names carry no version, so releases/latest/download/soulseek-rs-amd64.deb stays a working link; the nightly pair replaces itself on the rolling prerelease. --- .github/workflows/nightly.yml | 29 ++++++++++++++ .github/workflows/packaging.yml | 67 +++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 10 +++++ RELEASE.md | 17 +++++++-- packaging/nfpm.yaml | 55 +++++++++++++++++++++++++++ scripts/package-linux.sh | 54 ++++++++++++++++++++++++++ 6 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/packaging.yml create mode 100644 packaging/nfpm.yaml create mode 100755 scripts/package-linux.sh diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e86af72..4a5c9ee 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -105,6 +105,12 @@ jobs: dry-run: true dry-run-intended: true + - name: Build .deb and .rpm + if: endsWith(matrix.target, '-musl') + env: + TARGET: ${{ matrix.target }} + run: sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "$SOULSEEK_RS_VERSION" "$TARGET" . soulseek-rs-nightly + - name: Store packaged binary uses: actions/upload-artifact@v4 with: @@ -112,6 +118,8 @@ jobs: path: | ${{ steps.package.outputs.tar || steps.package.outputs.zip }} ${{ steps.package.outputs.sha256 }} + soulseek-rs-nightly-*.deb + soulseek-rs-nightly-*.rpm if-no-files-found: error retention-days: 1 @@ -147,6 +155,10 @@ jobs: soulseek-rs-nightly-aarch64-apple-darwin.sha256 soulseek-rs-nightly-x86_64-pc-windows-msvc.sha256 soulseek-rs-nightly-aarch64-pc-windows-msvc.sha256 + soulseek-rs-nightly-amd64.deb + soulseek-rs-nightly-arm64.deb + soulseek-rs-nightly-x86_64.rpm + soulseek-rs-nightly-aarch64.rpm ) printf '%s\n' "${expected[@]}" | sort > "$RUNNER_TEMP/expected-assets" find dist -maxdepth 1 -type f -exec basename {} \; | sort > "$RUNNER_TEMP/actual-assets" @@ -234,6 +246,7 @@ jobs: timeout-minutes: 5 env: VERSION: ${{ needs.source.outputs.version }} + DOWNLOAD: https://github.com/${{ github.repository }}/releases/download/nightly steps: - uses: actions/checkout@v4 with: @@ -260,3 +273,19 @@ jobs: PATH="$RUNNER_TEMP/wget-path" /bin/sh website/public/install.sh --nightly "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version | grep -F "$VERSION" + - name: Install the .deb + run: | + curl -fsSLO "$DOWNLOAD/soulseek-rs-nightly-amd64.deb" + sudo apt-get install -y ./soulseek-rs-nightly-amd64.deb + soulseek-rs --version | grep -F "$VERSION" + dpkg -L soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + dpkg -L soulseek-rs | grep -Fx /usr/share/zsh/vendor-completions/_soulseek-rs + + - name: Install the .rpm + run: | + docker run --rm fedora:latest sh -ec " + dnf install -y '$DOWNLOAD/soulseek-rs-nightly-x86_64.rpm' + soulseek-rs --version | grep -F '$VERSION' + rpm -ql soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + rpm -ql soulseek-rs | grep -Fx /usr/share/zsh/site-functions/_soulseek-rs + " diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml new file mode 100644 index 0000000..99da052 --- /dev/null +++ b/.github/workflows/packaging.yml @@ -0,0 +1,67 @@ +name: Packaging + +on: + pull_request: + paths: + - 'Cargo.lock' + - 'Cargo.toml' + - 'packaging/**' + - 'scripts/package-linux.sh' + - 'soulseek-rs/src/cli.rs' + - 'soulseek-rs/src/run.rs' + - 'soulseek-rs/src/commands/**' + - '.github/workflows/packaging.yml' + +permissions: + contents: read + +concurrency: + group: packaging-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + VERSION: 0.0.0+git202601010000.0000000 + +jobs: + packages: + name: .deb and .rpm + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-musl + + - uses: Swatinem/rust-cache@v2 + + - name: Install musl toolchain + run: sudo apt-get update && sudo apt-get install -y musl-tools + + - name: Build the packages + env: + SOULSEEK_RS_VERSION: ${{ env.VERSION }} + TARGET: x86_64-unknown-linux-musl + run: | + cargo build --locked --release --target "$TARGET" --package soulseek-rs + sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "$VERSION" "$TARGET" . soulseek-rs-nightly + + - name: Install the .deb + run: | + sudo apt-get install -y ./soulseek-rs-nightly-amd64.deb + soulseek-rs --version | grep -F "$VERSION" + dpkg -L soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + dpkg -L soulseek-rs | grep -Fx /usr/share/zsh/vendor-completions/_soulseek-rs + + - name: Install the .rpm + run: | + docker run --rm -v "$PWD:/packages:ro" fedora:latest sh -ec " + dnf install -y /packages/soulseek-rs-nightly-x86_64.rpm + soulseek-rs --version | grep -F '$VERSION' + rpm -ql soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + rpm -ql soulseek-rs | grep -Fx /usr/share/zsh/site-functions/_soulseek-rs + " diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac7f993..a7bbc65 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -200,6 +200,16 @@ jobs: ref: refs/tags/${{ needs.release.outputs.tag }} token: ${{ secrets.GITHUB_TOKEN }} + - name: Build and upload .deb and .rpm + if: endsWith(matrix.target, '-musl') && hashFiles('scripts/package-linux.sh') != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.release.outputs.tag }} + TARGET: ${{ matrix.target }} + run: | + sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "${TAG#v}" "$TARGET" dist + gh release upload "$TAG" dist/* --clobber + # The tap regenerates its formula from whatever `gh release view` calls latest, # so it only needs a nudge once every archive and .sha256 is on the release — # hence `needs: binaries` rather than firing alongside it. The tap also polls diff --git a/RELEASE.md b/RELEASE.md index b6356d0..941db0f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -28,6 +28,11 @@ Releases are automated by [release-plz](https://release-plz.dev). You do not run | `soulseek-rs-vX.Y.Z-x86_64-pc-windows-msvc.zip` | | | `soulseek-rs-vX.Y.Z-aarch64-pc-windows-msvc.zip` | | + - packages the two Linux binaries as `soulseek-rs-amd64.deb`, `soulseek-rs-arm64.deb`, + `soulseek-rs-x86_64.rpm` and `soulseek-rs-aarch64.rpm`, with the man page and the + bash, zsh and fish completions. The names carry no version, so + `releases/latest/download/soulseek-rs-amd64.deb` is a link that keeps working, + - rewrites `Formula/soulseek-rs.rb` in the `michel/homebrew-tap` repository so `brew install michel/tap/soulseek-rs` picks up the new macOS and Linux archives, - merges `master` back into `develop`, so the branch the work happens on carries the @@ -44,9 +49,15 @@ conflict. `.github/workflows/nightly.yml` builds the tip of `develop` at 02:17 UTC every day. It resolves the commit once, builds the same six targets as a numbered -release, verifies all twelve archives and checksum files as one set, then -replaces the mutable `nightly` prerelease and tag. The published release is -smoke-tested through both the curl and wget installer paths. +release, packages the two Linux ones as `soulseek-rs-nightly-*.deb` and `.rpm`, +verifies all sixteen files as one set, then replaces the mutable `nightly` +prerelease and tag. Every nightly binary reports `X.Y.Z+git.` +instead of the crate version. The published release is smoke-tested through the +curl and wget installer paths, and by installing the `.deb` and the `.rpm`. + +Never enable GitHub's Immutable Releases on this repository: nightly replaces +its assets and moves its tag, and a tag name burnt by that setting cannot be +reused. Use **Actions → Nightly → Run workflow** to force a build. The workflow always checks out `develop`, regardless of the ref selected in the dispatch form. The diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..1493db5 --- /dev/null +++ b/packaging/nfpm.yaml @@ -0,0 +1,55 @@ +name: soulseek-rs +arch: ${PKG_ARCH} +platform: linux +version: ${PKG_VERSION} +version_schema: none +release: "1" +section: net +priority: optional +maintainer: Michel de Graaf +description: | + Soulseek client for the terminal + A TUI, a scriptable command line and a daemon for the Soulseek + peer-to-peer network. +vendor: Michel de Graaf +homepage: https://re-invention.nl/soulseek-rs/ +license: MIT +suggests: + - gnome-keyring +contents: + - src: target/nfpm/soulseek-rs + dst: /usr/bin/soulseek-rs + file_info: + mode: 0755 + - src: target/nfpm/soulseek-rs.1.gz + dst: /usr/share/man/man1/soulseek-rs.1.gz + - src: target/nfpm/soulseek-rs.bash + dst: /usr/share/bash-completion/completions/soulseek-rs + - src: target/nfpm/soulseek-rs.fish + dst: /usr/share/fish/vendor_completions.d/soulseek-rs.fish + - src: target/nfpm/_soulseek-rs + dst: /usr/share/zsh/vendor-completions/_soulseek-rs + packager: deb + - src: target/nfpm/_soulseek-rs + dst: /usr/share/zsh/site-functions/_soulseek-rs + packager: rpm + - dst: /usr/share/doc/soulseek-rs + type: dir + packager: rpm + - dst: /usr/share/licenses/soulseek-rs + type: dir + packager: rpm + - src: README.md + dst: /usr/share/doc/soulseek-rs/README.md + packager: deb + - src: README.md + dst: /usr/share/doc/soulseek-rs/README.md + packager: rpm + type: doc + - src: LICENSE + dst: /usr/share/doc/soulseek-rs/copyright + packager: deb + - src: LICENSE + dst: /usr/share/licenses/soulseek-rs/LICENSE + packager: rpm + type: license diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh new file mode 100755 index 0000000..8216098 --- /dev/null +++ b/scripts/package-linux.sh @@ -0,0 +1,54 @@ +#!/bin/sh +set -eu +umask 022 + +[ $# -ge 4 ] || { + echo "usage: $0 []" >&2 + exit 2 +} + +NFPM_VERSION=2.47.0 + +binary=$1 +PKG_VERSION=$2 +target=$3 +out=$4 +package=${5:-soulseek-rs} + +case "$target" in + x86_64-*) PKG_ARCH=amd64 rpm_arch=x86_64 ;; + aarch64-*) PKG_ARCH=arm64 rpm_arch=aarch64 ;; + *) echo "no package architecture for $target" >&2; exit 1 ;; +esac + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) nfpm_asset=Linux_x86_64 nfpm_sha256=0660ca602b2d2d2ae4781a06c692b3eeb9d437ffea05b831d76e41f4a3188783 ;; + Linux-aarch64) nfpm_asset=Linux_arm64 nfpm_sha256=1c0f5f2999b9a974bfb04fdb0cc3306096de530ac5dbb25d739cc5f5219c919c ;; + *) echo "no pinned nfpm build for this machine" >&2; exit 1 ;; +esac + +root=$(cd "$(dirname "$0")/.." && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +stage=$root/target/nfpm +rm -rf "$stage" +mkdir -p "$stage" "$out" +out=$(cd "$out" && pwd) + +cp "$binary" "$stage/soulseek-rs" +"$binary" completions print bash > "$stage/soulseek-rs.bash" +"$binary" completions print zsh > "$stage/_soulseek-rs" +"$binary" completions print fish > "$stage/soulseek-rs.fish" +"$binary" man > "$stage/soulseek-rs.1" +gzip -9n "$stage/soulseek-rs.1" + +archive=nfpm_${NFPM_VERSION}_$nfpm_asset.tar.gz +curl -fsSL -o "$work/$archive" \ + "https://github.com/goreleaser/nfpm/releases/download/v$NFPM_VERSION/$archive" +echo "$nfpm_sha256 $work/$archive" | sha256sum -c - >/dev/null +tar -xzf "$work/$archive" -C "$work" nfpm + +export PKG_ARCH PKG_VERSION +cd "$root" +"$work/nfpm" package --config packaging/nfpm.yaml --packager deb --target "$out/$package-$PKG_ARCH.deb" +"$work/nfpm" package --config packaging/nfpm.yaml --packager rpm --target "$out/$package-$rpm_arch.rpm" From 4c5a808762db62b6849d3902ea2b3827b143c238 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 21 Sep 2026 12:15:43 +0200 Subject: [PATCH 22/23] build(nix): add a flake nix run github:michel/soulseek-rs/master builds the release branch and /develop the nightly. The lock is not optional: without one nix refuses a github: reference outright and hides the commit from the build, so the version stamp read +dirty. --- .github/workflows/packaging.yml | 20 +++++++++++++ flake.lock | 27 +++++++++++++++++ flake.nix | 51 +++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml index 99da052..bb1b304 100644 --- a/.github/workflows/packaging.yml +++ b/.github/workflows/packaging.yml @@ -3,6 +3,8 @@ name: Packaging on: pull_request: paths: + - 'flake.nix' + - 'flake.lock' - 'Cargo.lock' - 'Cargo.toml' - 'packaging/**' @@ -65,3 +67,21 @@ jobs: rpm -ql soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz rpm -ql soulseek-rs | grep -Fx /usr/share/zsh/site-functions/_soulseek-rs " + + flake: + name: Nix flake + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: cachix/install-nix-action@v31 + + - name: Build and run + run: | + nix build --print-build-logs + ./result/bin/soulseek-rs --version | grep -E '\+git[0-9]{12}\.[0-9a-f]{7}$' + test -s result/share/man/man1/soulseek-rs.1.gz + test -s result/share/fish/vendor_completions.d/soulseek-rs.fish diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d9c0837 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1789785513, + "narHash": "sha256-B44WL6h0XoLjJ41bUPJk0X5SDinLCII//6EcBLXKiJ0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "20b1ddd1aa5ace70c9468305030aa4f9ef79671b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..c954c07 --- /dev/null +++ b/flake.nix @@ -0,0 +1,51 @@ +{ + description = "Soulseek client for the terminal, with a scriptable CLI and daemon"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = + { self, nixpkgs }: + let + inherit (nixpkgs) lib; + version = (lib.importTOML ./Cargo.toml).workspace.package.version; + systems = [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + ]; + in + { + packages = lib.genAttrs systems (system: { + default = nixpkgs.legacyPackages.${system}.callPackage ( + { rustPlatform, installShellFiles }: + rustPlatform.buildRustPackage { + pname = "soulseek-rs"; + inherit version; + src = self; + cargoLock.lockFile = ./Cargo.lock; + cargoBuildFlags = [ + "--package" + "soulseek-rs" + ]; + doCheck = false; + nativeBuildInputs = [ installShellFiles ]; + env.SOULSEEK_RS_VERSION = "${version}+git${lib.substring 0 12 self.lastModifiedDate}.${self.shortRev or "dirty"}"; + postInstall = '' + installShellCompletion --cmd soulseek-rs \ + --bash <($out/bin/soulseek-rs completions print bash) \ + --zsh <($out/bin/soulseek-rs completions print zsh) \ + --fish <($out/bin/soulseek-rs completions print fish) + $out/bin/soulseek-rs man > soulseek-rs.1 + installManPage soulseek-rs.1 + ''; + meta = { + description = "Soulseek client for the terminal, with a scriptable CLI and daemon"; + homepage = "https://re-invention.nl/soulseek-rs/"; + license = lib.licenses.mit; + mainProgram = "soulseek-rs"; + }; + } + ) { }; + }); + }; +} From b6b4ec04c159edcd9cd49a2c3873b8d5596b61f1 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 21 Sep 2026 12:15:43 +0200 Subject: [PATCH 23/23] docs: install by system, stable and nightly --- README.md | 61 +++++++- website/src/lib/links.ts | 4 + website/src/pages/install-routes.tsx | 218 +++++++++++++++++++++++++++ website/src/pages/install.tsx | 100 ++---------- 4 files changed, 290 insertions(+), 93 deletions(-) create mode 100644 website/src/pages/install-routes.tsx diff --git a/README.md b/README.md index db9eb9d..bf7808b 100644 --- a/README.md +++ b/README.md @@ -107,17 +107,66 @@ numbered release. A failed download, checksum, or startup check leaves an existing binary untouched. Windows archives for both channels are on the [releases page](https://github.com/michel/soulseek-rs/releases). -The other stable routes need no installer script: +Or install it with a package manager. Stable follows the latest release: ```bash -brew install michel/tap/soulseek-rs # macOS and Linux -cargo install soulseek-rs # or from crates.io +# Debian, Ubuntu, Mint (on ARM: arm64 for amd64) +curl -fsSLO https://github.com/michel/soulseek-rs/releases/latest/download/soulseek-rs-amd64.deb +sudo apt install ./soulseek-rs-amd64.deb + +# Fedora, RHEL (on ARM: aarch64 for x86_64) +sudo dnf install https://github.com/michel/soulseek-rs/releases/latest/download/soulseek-rs-x86_64.rpm + +# Nix +nix run github:michel/soulseek-rs/master + +# Homebrew, on macOS and Linux +brew install michel/tap/soulseek-rs + +# cargo-binstall fetches the release binary and compiles nothing +cargo binstall soulseek-rs + +# From source: Rust 1.91 or newer. Debian 13 and Ubuntu 24.04 default to an older one +cargo install --locked soulseek-rs ``` +Nightly is the tip of `develop`. The `.deb` and `.rpm` are downloads; Nix, +Homebrew and cargo build it from source: + +```bash +curl -fsSLO https://github.com/michel/soulseek-rs/releases/download/nightly/soulseek-rs-nightly-amd64.deb +sudo apt install ./soulseek-rs-nightly-amd64.deb + +sudo dnf install https://github.com/michel/soulseek-rs/releases/download/nightly/soulseek-rs-nightly-x86_64.rpm + +nix run github:michel/soulseek-rs/develop + +brew install --HEAD michel/tap/soulseek-rs + +cargo install --locked --git https://github.com/michel/soulseek-rs --branch develop soulseek-rs +``` + +A `.deb` or `.rpm` does not update itself. Install the newer file over it. A +nightly sorts above the release it follows, so going back to stable is a +downgrade. In a script apt refuses one without `--allow-downgrades`, while dnf +goes ahead. Both carry the man page and completions for bash, zsh and fish. +A release download prints the plain number, `19.0.0`. Any Nix build, and a +nightly download, reads `19.0.0+git202609141144.deb846b`: the last release, the +commit's time in UTC, and the commit. A Homebrew `--HEAD` build reports +`HEAD-deb846b`. A `cargo install` from git sets no version, so it prints +`19.0.0` too. + From source, `cargo build --release` leaves the binary at `target/release/soulseek-rs`. To build your own client on the protocol library, run `cargo add soulseek-rs-lib`. +To package it for a distribution: a musl build is static, a glibc build links +only libc, libm and libgcc_s, +`soulseek-rs completions print ` and `soulseek-rs man` write the +completion scripts and the man page to stdout, and a build with +`SOULSEEK_RS_VERSION` set reports that instead of the crate version. The nfpm +recipe behind the `.deb` and `.rpm` is in [`packaging/`](packaging). + ## Usage Run it with no subcommand for the interactive TUI: @@ -332,6 +381,8 @@ soulseek-rs config path|list|get|set soulseek-rs portmap # test automatic port mapping soulseek-rs skills install|uninstall|list # teach a coding agent this CLI soulseek-rs completions install|uninstall # tab completion for bash/zsh/fish +soulseek-rs completions print # the script on stdout, for packaging +soulseek-rs man # the man page on stdout ``` A script usually starts with `whoami`, which answers "are these credentials @@ -343,9 +394,9 @@ download folder. `--no-daemon` tests this machine's own login instead. `shares list` reads the config file either way, while `shares status` and `shares reindex` ask the session and report what peers can see. -Twenty need no credentials: `config path|list|get|set`, +Twenty-two need no credentials: `config path|list|get|set`, `shares list|add|remove`, `wish add|remove|list`, `portmap`, -`skills install|uninstall|list`, `completions install|uninstall`, +`skills install|uninstall|list`, `completions install|uninstall|print`, `man`, `account logout` and `daemon token|status|stop`. Most never touch the network; `daemon status` and `daemon stop` talk to a daemon that is already logged in, so a machine with no account of its own can still ask whether one is running. `shares status` and diff --git a/website/src/lib/links.ts b/website/src/lib/links.ts index fdb4f37..54385ed 100644 --- a/website/src/lib/links.ts +++ b/website/src/lib/links.ts @@ -29,3 +29,7 @@ export const INSTALL_CMD = `curl -fsSL ${SITE_URL}install.sh | sh` export const NIGHTLY_INSTALL_CMD = 'curl -fsSL https://raw.githubusercontent.com/michel/soulseek-rs/develop/website/public/install.sh | sh -s -- --nightly' + +export const STABLE_DOWNLOAD = `${LINKS.releases}/latest/download` + +export const NIGHTLY_DOWNLOAD = `${LINKS.releases}/download/nightly` diff --git a/website/src/pages/install-routes.tsx b/website/src/pages/install-routes.tsx new file mode 100644 index 0000000..2aa1ffc --- /dev/null +++ b/website/src/pages/install-routes.tsx @@ -0,0 +1,218 @@ +import { ExtLink } from '@/components/ui/ext-link' +import { Code } from '@/components/ui/inline-code' +import { OsIcon, type OsName } from '@/components/ui/os-icon' +import { Terminal, type TermLine } from '@/components/ui/terminal' +import { LINKS, NIGHTLY_DOWNLOAD, STABLE_DOWNLOAD } from '@/lib/links' + +interface InstallRoute { + id: string + os: OsName + best: string + lines: readonly TermLine[] + alt: React.ReactNode +} + +// aislop-ignore-file code-quality/duplicate-block -- data tables: the INSTALLS and LINUX_ROUTES records repeat the field names their interfaces require. The rule matches punctuation, not logic. +const INSTALLS: readonly InstallRoute[] = [ + { + id: 'macos', + os: 'macOS', + best: 'Homebrew', + lines: [ + { t: 'cmd', text: 'brew install michel/tap/soulseek-rs' }, + { t: 'cmd', text: 'soulseek-rs' }, + ], + alt: ( + <> + A prebuilt binary for Apple silicon and Intel, no Rust toolchain needed. With a + toolchain, cargo install --locked soulseek-rs builds from crates.io instead. + + ), + }, + { + id: 'linux', + os: 'Linux', + best: 'your package manager', + lines: [ + { t: 'cm', text: '# Debian and Ubuntu; the others are below' }, + { t: 'cmd', text: `curl -fsSLO ${STABLE_DOWNLOAD}/soulseek-rs-amd64.deb` }, + { t: 'cmd', text: 'sudo apt install ./soulseek-rs-amd64.deb' }, + { t: 'cmd', text: 'soulseek-rs' }, + ], + alt: ( + <> + Fedora, Nix, Homebrew and cargo are{' '} + + listed below + + , each with a nightly. Without any of them, the{' '} + releases page has static musl archives for x86-64 + and arm64 that run on any distribution. + + ), + }, + { + id: 'windows', + os: 'Windows', + best: 'cargo or a prebuilt .exe', + lines: [ + { t: 'cmd', text: 'cargo install --locked soulseek-rs' }, + { t: 'cmd', text: 'soulseek-rs.exe' }, + ], + alt: ( + <> + Cargo needs the MSVC build tools. To skip both, take the{' '} + pc-windows-msvc zip from the{' '} + releases page, unpack it, and put{' '} + soulseek-rs.exe on your PATH. + + ), + }, +] + +interface LinuxRoute { + id: string + name: string + stable: readonly TermLine[] + nightly: readonly TermLine[] + note: React.ReactNode +} + +const LINUX_ROUTES: readonly LinuxRoute[] = [ + { + id: 'deb', + name: 'Debian, Ubuntu, Mint', + stable: [ + { t: 'cmd', text: `curl -fsSLO ${STABLE_DOWNLOAD}/soulseek-rs-amd64.deb` }, + { t: 'cmd', text: 'sudo apt install ./soulseek-rs-amd64.deb' }, + ], + nightly: [ + { t: 'cmd', text: `curl -fsSLO ${NIGHTLY_DOWNLOAD}/soulseek-rs-nightly-amd64.deb` }, + { t: 'cmd', text: 'sudo apt install ./soulseek-rs-nightly-amd64.deb' }, + ], + note: ( + <> + On ARM, arm64 takes the place of amd64. There is no apt + repository behind this, so an update means installing the newer file over the old one. + A nightly sorts above the release it follows, so going back to stable is a downgrade, + and apt asks before doing it. + + ), + }, + { + id: 'rpm', + name: 'Fedora, RHEL', + stable: [{ t: 'cmd', text: `sudo dnf install ${STABLE_DOWNLOAD}/soulseek-rs-x86_64.rpm` }], + nightly: [ + { t: 'cmd', text: `sudo dnf install ${NIGHTLY_DOWNLOAD}/soulseek-rs-nightly-x86_64.rpm` }, + ], + note: ( + <> + On ARM, aarch64 takes the place of x86_64. Run the same + command again to update. dnf fetches the file each time, so a newer nightly at the same + address is picked up. An older file downgrades without asking. + + ), + }, + { + id: 'nix', + name: 'Nix', + stable: [{ t: 'cmd', text: 'nix run github:michel/soulseek-rs/master' }], + nightly: [{ t: 'cmd', text: 'nix run github:michel/soulseek-rs/develop' }], + note: ( + <> + Both build from source. With no branch at all, Nix takes the repository’s + default one, and here that is develop. + + ), + }, + { + id: 'brew', + name: 'Homebrew', + stable: [{ t: 'cmd', text: 'brew install michel/tap/soulseek-rs' }], + nightly: [{ t: 'cmd', text: 'brew install --HEAD michel/tap/soulseek-rs' }], + note: ( + <> + Homebrew installs under /home/linuxbrew, outside the image that a system + update replaces on Bazzite and Silverblue. --HEAD compiles{' '} + develop with Homebrew’s own Rust. + + ), + }, + { + id: 'cargo', + name: 'cargo', + stable: [{ t: 'cmd', text: 'cargo install --locked soulseek-rs' }], + nightly: [ + { + t: 'cmd', + text: 'cargo install --locked --git https://github.com/michel/soulseek-rs --branch develop soulseek-rs', + }, + ], + note: ( + <> + Needs Rust 1.91 or newer. Debian 13 and Ubuntu 24.04 default to an older one, so take + the toolchain from rustup.rs.{' '} + cargo binstall soulseek-rs fetches the release binary and compiles nothing. + + ), + }, +] + +export const InstallCards = () => ( +
+ {INSTALLS.map((route) => ( +
+
+ +

+ {route.os} +

+
+ + {route.best} + + +

+ {route.alt} +

+
+ ))} +
+) + +export const LinuxRoutes = () => ( +
+

+ Linux, by distribution +

+

+ Stable is the latest release, or for Nix the master branch it was cut + from. Nightly is the tip of develop, rebuilt every day. A release download + prints the plain number, 19.0.0. Any Nix build, and a nightly download, + reads 19.0.0+git202609141144.deb846b: the last release, the + commit’s time in UTC, and the commit. A Homebrew --HEAD build + reports HEAD-deb846b, and a cargo install from git sets no + version, so it prints 19.0.0 too. The .deb and .rpm + carry the man page and completions for bash, zsh and fish. +

+ {LINUX_ROUTES.map((route) => ( +
+ + {route.name} + +
+ + +
+

+ {route.note} +

+
+ ))} +
+) diff --git a/website/src/pages/install.tsx b/website/src/pages/install.tsx index f17cf70..9e03fb0 100644 --- a/website/src/pages/install.tsx +++ b/website/src/pages/install.tsx @@ -3,10 +3,10 @@ import { FeatureCard } from '@/components/ui/feature-card' import { ExtLink } from '@/components/ui/ext-link' import { Code } from '@/components/ui/inline-code' import { Cols, PageHead, Prose, Section, SectionHead } from '@/components/ui/layout' -import { OsIcon, type OsName } from '@/components/ui/os-icon' import { Callout } from '@/components/ui/panel' import { Terminal, type TermLine } from '@/components/ui/terminal' import { INSTALL_CMD, LINKS, NIGHTLY_INSTALL_CMD, SITE_URL } from '@/lib/links' +import { InstallCards, LinuxRoutes } from '@/pages/install-routes' const INSTALL_SCRIPT_LINES: readonly TermLine[] = [ { t: 'cm', text: '# latest stable release' }, @@ -36,66 +36,6 @@ const LIB_LINES: TermLine[] = LIB_SRC.split('\n').map((text) => ({ text, })) -interface InstallRoute { - id: string - os: OsName - best: string - lines: readonly TermLine[] - alt: React.ReactNode -} - -// aislop-ignore-file code-quality/duplicate-block -- three findings here, all data tables: the INSTALLS records repeat the field names InstallRoute requires, and the two uninstall `lines` arrays repeat the `{ t, text }` shape around different commands. The rule matches punctuation, not logic. -const INSTALLS: readonly InstallRoute[] = [ - { - id: 'macos', - os: 'macOS', - best: 'Homebrew', - lines: [ - { t: 'cmd', text: 'brew install michel/tap/soulseek-rs' }, - { t: 'cmd', text: 'soulseek-rs' }, - ], - alt: ( - <> - A prebuilt binary for Apple silicon and Intel, no Rust toolchain needed. With a - toolchain, cargo install soulseek-rs builds from crates.io instead. - - ), - }, - { - id: 'linux', - os: 'Linux', - best: 'cargo', - lines: [ - { t: 'cmd', text: 'cargo install soulseek-rs' }, - { t: 'cmd', text: 'soulseek-rs' }, - ], - alt: ( - <> - Homebrew on Linux works the same as on macOS. Without either, the{' '} - releases page has static musl archives for x86-64 and - arm64 that run on any distribution. - - ), - }, - { - id: 'windows', - os: 'Windows', - best: 'cargo or a prebuilt .exe', - lines: [ - { t: 'cmd', text: 'cargo install soulseek-rs' }, - { t: 'cmd', text: 'soulseek-rs.exe' }, - ], - alt: ( - <> - Cargo needs the MSVC build tools. To skip both, take the{' '} - pc-windows-msvc zip from the{' '} - releases page, unpack it, and put{' '} - soulseek-rs.exe on your PATH. - - ), - }, -] - interface PlatformNote { id: string name: string @@ -291,32 +231,12 @@ const Steps = () => ( place.

- Or pick a route per platform. Cargo needs a Rust toolchain from{' '} - rustup.rs; the other routes don’t. + Or pick a route per platform. Cargo needs Rust 1.91 or newer from{' '} + rustup.rs. Nix and Homebrew{' '} + --HEAD also build from source, but bring their own Rust.

-
- {INSTALLS.map((route) => ( -
-
- -

- {route.os} -

-
- - {route.best} - - -

- {route.alt} -

-
- ))} -
+ +

Every route puts the same binary on your PATH. Run soulseek-rs in a terminal and the TUI opens, in a script or a pipe it wants a subcommand @@ -522,11 +442,13 @@ const Config = () => ( ) +// aislop-ignore-file code-quality/duplicate-block -- the two uninstall `lines` arrays repeat the `{ t, text }` shape around different commands. The rule matches punctuation, not logic. const Uninstall = () => (

Three things exist on disk: the binary, the config, and the state, plus the completion - scripts and the agent skill if you asked for those. Nothing else, unless you point{' '} + scripts and the agent skill if you asked for those. A .deb or .rpm also brings a man page + and its own completions, and takes them back when removed. Nothing else, unless you point{' '} --log-file somewhere. No telemetry to opt out of. @@ -539,6 +461,8 @@ const Uninstall = () => ( { t: 'cm', text: '# whichever way you installed it' }, { t: 'cmd', text: 'brew uninstall soulseek-rs' }, { t: 'cmd', text: 'cargo uninstall soulseek-rs' }, + { t: 'cmd', text: 'sudo apt remove soulseek-rs' }, + { t: 'cmd', text: 'sudo dnf remove soulseek-rs' }, { t: 'cm', text: '# script install without brew: the binary itself' }, { t: 'cmd', text: 'rm -f /usr/local/bin/soulseek-rs ~/.local/bin/soulseek-rs' }, { t: 'cm', text: '# brew only: drop the tap it came from' }, @@ -581,7 +505,7 @@ const Uninstall = () => ( export const Install = () => ( <> - One command with the install script, Homebrew or cargo. Build from source if + One command with the install script, your package manager, Homebrew or cargo. Build from source if you’d rather. Or depend on the library and write your own client.