Commit Graph
68 Commits
Author SHA1 Message Date
Michel de Graaf 6f32a4e090 fix: review findings on the protocol work
Correctness:
- A child that stopped reading could freeze the whole client: the relay
  writes under the context lock and the socket was blocking, so one stalled
  peer took every download, upload and API call with it. Child writes now
  have a deadline, and a child that hits it loses its slot.
- Relaying searches to children was gated by the leaf's answer budget, so a
  parent forwarded a fraction of the stream while advertising itself as
  healthy. The budget bounds our own share scan, which is what it was for;
  the tree's stream goes down whole.
- A dropped client kept its listener port for the life of the process, so a
  retried login advertised an ephemeral port nobody was told about. The
  listener is now stopped and the operations thread let go.
- Excluded phrases were compared against a lowercased path without being
  lowercased themselves, so a capitalised phrase never matched. They are
  normalised where they arrive.
- Losing our own operatorship wiped the room's whole operator roster,
  though we are still a member who can see it.
- A brokered ConnectToPeer of type D was logged and dropped; the peer is
  now told, since we never dial out to adopt a child.
- A peer request made with no server connection returned Ok and parked
  forever; it says NotConnected, as its docs always claimed.
- An empty interest was refused locally but still sent, leaving the
  server's list and ours permanently apart.

Simplification:
- Deleted the excluded-phrase helper nothing called, and the field doc that
  promised behaviour it never had.
- The four roster handlers and four standing handlers were eight
  constructors carrying constants their own message code already implies:
  one newtype each, 100 lines less.
- One place registers a search; the phrase list is borrowed, not cloned per
  relayed search.
2026-09-09 18:38:34 +00:00
Michel de Graaf 0546d3a77c feat(peer): ask a peer what it says about itself
The client answered a UserInfoRequest but could never send one, so a
user's profile — description, upload slots, queue length — was something
this client served and never saw. The reply (peer code 16) is now parsed
too, with the tolerances other clients carry: a picture is skipped rather
than kept, a length that overruns the message is clamped, and a message
that stops early (older clients, and Museek+ writing a boolean as an
integer) still yields every field before it.
2026-09-09 17:14:17 +00:00
Michel de Graaf 773b5a4100 feat(interests): keep our own list and send it again each login
The server holds interests only for the session that set them, so a client
that does not re-send its list comes back from a reconnect with none. That
is why Nicotine+ sends its whole list at every login, and why it lowercases
each item before sending — the server matches them case-insensitively, so
two spellings would otherwise be held as two interests.

Ours now keeps the list, in one spelling, and puts it back on the wire when
a session starts; an interest set before there is a connection is kept
rather than dropped. A room's ticker board is also let go when we leave the
room, since the server sends the whole board again on the next join.
2026-09-09 17:06:08 +00:00
Michel de Graaf 0630b548b9 feat(protocol): folder requests, and heuristics matched to other clients
Reading Nicotine+ and slskd against what was just written turned up three
places where this client had the message right and the behaviour wrong.

Excluded search phrases (code 160) are not a filter on the searches we
send — the server already drops those. Nicotine+ applies them to the files
it offers, leaving out any whose path carries an excluded phrase, and a
reply with nothing left is not sent. Ours now does the same, and the
local refusal of outgoing searches is gone.

Serving children now follows the same rules as Nicotine+: the limit comes
from the server's ParentMinSpeed and ParentSpeedRatio against the upload
speed the server records for us (capped at ten, none at all below the
minimum), children are refused until something feeds us the search stream
and let go when it stops, a second link from a child we already carry is
refused, and AcceptChildren is re-sent whenever that answer changes. Where
the server has never reported our speed there is nothing to apply the
formula to, so the configured cap stands, which is how slskd runs.

Queued downloads now ask where they sit every five minutes, as Nicotine+
does, instead of only when a caller asks; a brokered file connection we
cannot complete reports CantConnectToPeer like a control connection does;
and a peer's shares can be asked for one folder at a time (peer code 36
and its reply, 37), which is what "download folder" is built on.
2026-09-09 17:01:19 +00:00
Michel de Graaf f5a97744f8 fix(messages): acknowledge stored private messages, and disconnect cleanly
Two bugs an e2e test found the moment one existed for this path.

A private message the server held while we were offline arrives with
new_message false, and the handler only acknowledged the ones flagged
new — so exactly the messages that need an ack never got one and the
server re-delivered them at every login, forever. Every delivery is now
acknowledged.

A dropped Client also left its server connection open, so the session
lived on: mail addressed to us went to a socket nobody was reading and a
later login collided with our own ghost. Client::disconnect closes the
server and peer connections, and Drop calls it.

Also covers two features that were never driven end to end: an offline
message delivered once and not again, and a wishlist search answered like
any other.
2026-09-09 16:39:10 +00:00
Michel de Graaf 19791d1699 feat(rooms): private room membership, operators and refusals
Private rooms were unreachable: the client could ask to join one but had
no way to invite, promote, resign or be told what happened, and a room
name already owned by someone else came back as silence rather than a
refusal (code 1003).

Adds the rosters the server pushes after login (133/148), the changes it
narrates to them (134/135, 143/144), our own standing (139/140, 145/146),
and the messages that drive them, with the rosters kept current in client
state. The daemon protocol and the TUI room transcript carry the new
events rather than dropping them.
2026-09-09 16:33:59 +00:00
Michel de Graaf 65c44cc66b feat(protocol): away status, excluded phrases, queue-place requests
Three features the client half-had. SetStatus (28) was only ever sent as
"online" at login, so there was no way to go away; excluded search phrases
(160) were parsed and logged into nowhere, so every client kept spending
searches on queries the server was never going to answer; and a queued
download could answer a peer asking about its place but could never ask
about its own.

Each is now driven end to end: the away status seen by another user, a
refused query on a stub server that announces phrases (soulfind keeps its
filters in a database the suite cannot seed), and a peer's answer landing
on the download as its queue position.
2026-09-09 16:27:20 +00:00
Michel de Graaf 886f1b7092 feat(protocol): interests, tickers, global room and cant-connect
Adds the server messages the client had no way to speak: user and room
search (42/120), room tickers (113-116), the global room feed (150-152),
interests and the recommendation queries built on them (51/52/54/56/57/
110/111/112/117/118), a keepalive ping (32), gifted privileges (123), a
password change (142) and multi-user private messages (149).

CantConnectToPeer (1001) closes a real gap in both directions: a dial the
server asked us to make now answers the waiting peer instead of falling
back to brokering the same unreachable link the other way, and an incoming
one ends the wait on downloads queued for that peer rather than letting
them sit until a timeout.
2026-09-09 16:19:40 +00:00
Michel de Graaf 85f26134ed fix(lib): free a silent parent's slot and pace the moves we report
A candidate that accepted the dial and then said nothing kept its slot
for the session, and ten of those blocked every later batch; a link now
closes after a minute of silence. A parent that flaps its level made us
tell the server four messages per flap; moves are now told at most once
a second, the latest one winning. A parent that reports level zero is
its own root from then on, whatever it said before. The wait for a
searcher's control connection runs from the last message queued for it,
not the first. A successful login is pinned to reset the leaf.
2026-09-08 01:45:07 +02:00
Michel de Graaf 9d6a5aaa98 fix(lib): harden the distributed leaf against hostile and slow parents
Review of the leaf found the seams a stranger on the other end of a D
connection could pull at, and a few that a slow one would.

- A code-93 envelope is unwrapped once, as Nicotine+ does; a frame made
  of nothing but envelopes used to recurse once per byte.
- A branch level is validated before it is used: negative levels leave
  the candidate unadoptable and the largest one no longer overflows.
- The link reads through the crate's MessageReader, so a frame split
  across a read timeout is no longer torn or mistaken for a closed link.
- Every event a link raises carries its link number, so a stale thread
  cannot adopt, close or move a newer link to the same user.
- Later branch facts from the parent move our own place and are told to
  the server; a fresh login resets the leaf, whose stance is now the one
  place the four announcement messages come from.
- Candidates from a later batch join the ones in flight instead of
  cancelling them, capped at ten; searches from the parent are budgeted
  at fifty a second; a reply queued for a searcher the server cannot
  place expires after a minute.
- Truncated frames and half-read PossibleParents entries are dropped
  instead of yielding an empty root, a level of zero or a phantom
  candidate.

The e2e now has a searcher of its own, so the answer must go to whoever
asked and not to the parent that relayed it.
2026-09-08 01:45:07 +02:00
Michel de Graaf 7d7b8fe98c feat(lib): join the distributed search network as a leaf
The server hands every client a list of possible parents and expects it to
hang from one so searches also travel peer to peer. Until now we told the
server we were a lone root and left it at that, which the network tolerates
but does not favour.

A leaf now dials each PossibleParents candidate over a D connection, adopts
the first that passes a search down after stating its branch, announces the
new branch root and level to the server, and answers those searches the same
way as ones from the server. Losing the parent or a ResetDistributed puts it
back to a parentless stance. The leaf takes no children.

The Leaf state machine is pure so its rules are unit-tested without sockets;
the e2e test drives a real client from a mock parent, since soulfind never
hands out parents.
2026-09-08 01:45:07 +02:00
Michel de Graaf 1976b49792 feat(lib): decode distributed frames and announce a parentless stance
The distributed search network carries searches from parent to child
over connections whose frames have a one-byte code; the library had no
reader for them. This adds the codec for Search, BranchLevel,
BranchRoot and the embedded envelope, with the identifier check that
keeps malformed frames from being answered, and completes the post-login
stance Nicotine+ sends: HaveNoParent, BranchRoot (ourselves),
BranchLevel 0 and AcceptChildren false, in place of HaveNoParent alone.
2026-09-08 01:45:07 +02:00
Michel de Graaf 976dd706d7 refactor(lib): export the ConnectionType parse error and document what hosts can still match 2026-09-08 01:45:02 +02:00
Michel de Graaf 07dd163156 refactor(lib): keep the actors inside the crate and mark the growing enums
Every release since 14 has been a major, because the actor mailboxes,
the share index's struct and the CLI's command enum were all exhaustive
public API: adding a peer message, a scanned field or a subcommand read
as a breaking change to the library. The actors, dispatcher, peer
plumbing and download store are how the client is built, not what a host
programs against, so they move inside the crate; PeerMessage, SharedFile
and the CLI command enums are non-exhaustive; ConnectionType is
re-exported at the root for the one thing hosts used it for. A one-time
break so that the next additions are not.
2026-09-08 01:45:02 +02:00
Michel de Graaf f6423a2ed6 feat(lib): carry audio attributes in browse and folder listings
A browse listed each file as a name and a size, so a searcher who saw a
bitrate in a search reply lost it the moment they opened the sharer's
files. Listings (codes 5 and 37) now carry each file's attributes the
way Nicotine+ sends them, as a SharedFileEntry with name, size and
attributes in place of the (name, size) pair; the CLI's browse record
gains bitrate and duration, and the daemon protocol moves to version 2
for the new listing shape.
2026-09-08 01:44:49 +02:00
Michel de Graaf bcf71dabec fix(lib): parse what a peer sent before acting on its close
Draining several buffers a tick meant a reply and the close after it
could land in one call, and the close was acted on first: the reply sat
unparsed in the reader while the actor disconnected. Only the first read
of a drain reports an error now; the close is seen again on the next
tick, after the reply has been handled.
2026-09-08 01:23:19 +02:00
Michel de Graaf 2ab7902d1f fix(lib): retire a dialling actor at once and drain the socket while retired
A Retire that reached an actor still dialling was queued behind the
connection and, if that dial then failed, never applied: nothing in the
registry pointed at the actor any more, so its thread lived on. Nothing
has gone out on a dial in progress, so retiring one closes it on the
spot.

A retired actor reads only from its tick, and one socket buffer a tick
is 10 KiB a second: a real share listing would outlast the grace. Every
actor now drains up to 64 buffers a tick.
2026-09-08 01:00:21 +02:00
Michel de Graaf 2d4a6fdd51 refactor(lib): move the peer actor's tests to their own file 2026-09-08 00:36:03 +02:00
Michel de Graaf ea168605b5 fix(lib): keep a replaced peer connection open for the reply in flight
Two connections to the same peer race whenever both sides dial at once:
we open one to send a browse, the peer opens one to deliver a search
reply. The registry keeps one actor per username, and until now stopped
the older one on the spot, which closed the socket carrying our browse
before the listing could arrive. Under load the stress gate lost up to
two thirds of its browses that way.

A replaced actor is now retired instead: it sends nothing new, keeps
reading for thirty seconds so a reply already on the wire still reaches
the client, and then stops itself. The peer closing a retired connection
is expected and reported as a clean close, so it cannot fail transfers
running on the connection that took over.
2026-09-08 00:35:11 +02:00
Michel de Graaf b5dcef589c refactor(lib): split the server actor's value types and tests out
server_actor.rs had grown past twelve hundred lines, a fifth of them
tests and a hundred the value types it hands out. The types now live in
server_actor/types.rs and the tests in server_actor/tests.rs, as the
client's already do, and the room and user traffic in handle_message
moves to its own dispatch step beside the wishlist one. No behaviour
changes; the public paths are re-exported as before.
2026-09-07 17:38:59 +02:00
Michel de Graaf e4ce85ef2b feat(lib): surface server announcements as messages from "server"
An AdminMessage (server code 66) is how a server tells everyone it is
going down, and the client dropped it on the floor. It now arrives in
the private-message inbox as a message from the user "server", which is
also the name the server's own command replies already come from, so
every reader of that inbox shows it without a new API.
2026-09-07 17:14:54 +02:00
Michel de Graaf 662d40b79c fix(lib): keep the server connection alive with TCP keepalive
A NAT that drops an idle mapping leaves the server socket looking
connected while the server can no longer reach us, and nothing errors:
peers stop finding us and searches go unanswered until a restart. The
server socket now carries TCP keepalive (idle 10s, interval 2s, ten
probes), which keeps the mapping alive and turns a dead path into a read
error within about half a minute, as Nicotine+ does. The server stopped
answering ServerPing (code 32) long ago, so an application ping is no
substitute. Windows gets a no-op until the daemon ships there.
2026-09-07 15:39:00 +02:00
Michel de Graaf 73208ae927 feat(lib): answer UserInfoRequest with our slots and queue
Opening a user's profile in SoulseekQt or Nicotine+ asks that peer
directly (peer code 15), and a peer that never answers shows an endless
spinner. The reply (code 16) carries no description or picture, the
configured upload slot count, how many uploads are waiting, and whether a
slot is free right now, which is what the reference clients send.
2026-09-07 15:32:30 +02:00
Michel de Graaf 4839e36be8 feat(lib): answer FolderContentsRequest with the folder's listing
"Download folder" in SoulseekQt and Nicotine+ asks the sharer for that
one folder (peer code 36) instead of the whole share, and a sharer that
never answers leaves the folder download hanging. The requested folder
now comes back as a FolderContentsResponse (code 37), which carries a
directory listing in the same form a browse does, so the two share one
codec. The browse and folder replies also share one reply helper.
2026-09-07 15:21:42 +02:00
Michel de Graaf f1aa94fecc fix(lib): route on the full four-byte message code
Dispatch read one byte of the code, so a code with a non-zero high byte
was handled as its low byte. Message::get_message_code now reads the
whole little-endian uint32 and handlers register by u32. PeerInit and
PierceFirewall, the only frames with a one-byte code, get their own
accessor for the listener's first frame.
2026-09-07 15:03:49 +02:00
Michel de Graaf 67d979b809 fix(daemon): reconnect after the server session drops
A long-running daemon that lost its TCP connection to the server reported
session_loss "disconnected" and answered every command from the dead session
until a manual restart. Two causes:

- the daemon owned one session for its lifetime with no reconnect at all; and
- the lib's ServerActor queued a Login sent while disconnected instead of
  dialing, so even asking it to log in again never re-established a connection.

Reconnect on a dedicated thread with 1s-60s capped backoff, only for a dropped
connection to a session this process owns (never a displaced or borrowed one,
which would start a login war or fight another daemon). Let a Login through the
disconnected guard so a parked actor dials again, and bound that dial with
connect_timeout so a blackholed route cannot park the actor thread. Reset the
message reader, the queued backlog and the login verdict on disconnect so a
partial frame or stale Some(false) cannot wedge the reconnected session.
Announce the drop once per outage and re-join rooms and re-watch users on
recovery; the daemon main loop is back to save-and-stop so `daemon stop` stays
responsive during an outage. Update the session_loss event summary, which no
longer means the session is gone for good.
2026-09-02 11:21:19 +02:00
Michel de Graaf 0ba00c98d7 Merge remote-tracking branch 'origin/develop' into feat/room-member-stats
# Conflicts:
#	soulseek-rs-lib/src/client/tests.rs
#	soulseek-rs-lib/tests/e2e.rs
#	soulseek-rs/src/daemon/openrpc.rs
#	soulseek-rs/src/daemon/proto/method.rs
#	soulseek-rs/src/daemon/server/mod.rs
#	soulseek-rs/src/remote/mod.rs
2026-08-28 15:18:37 +00:00
Michel de Graaf b505718db9 feat(lib): watch and unwatch users (WatchUser 5 / UnwatchUser 6)
Adds the server-side watch subscription a buddy list needs: WatchUser (5)
asks the server to keep pushing a user's status, UnwatchUser (6) cancels it.
The reply to a watch carries the user's current status and share statistics,
which merge into the existing per-user snapshot read via Client::user_info.

Issue #47 asked for AddUserList (141) / WatchedUserAdded (142) /
WatchedUserRemoved (143). Those codes are EnableRoomInvitations,
ChangePassword and AddRoomOperator in the Soulseek protocol -- there are no
such messages on the wire. Watching is codes 5 and 6, so that is what this
implements.

- message/server/watch_user.rs: WatchUserHandler (code 5), tolerant of an
  absent user and of a truncated frame
- MessageFactory::build_watch_user / build_unwatch_user
- Client::watch_user / unwatch_user / watched_users
- The watch reply has no privileged flag, so it preserves whatever an
  earlier GetUserStatus recorded rather than asserting false
2026-08-28 12:44:43 +00:00
Michel de Graaf fa49ebd2eb feat(lib): per-member room statistics from the JoinRoom reply
The JoinRoom (code 14) reply carries, after the membership list, four
parallel vectors describing each member: status, share statistics, free
upload slots and country. The handler stopped after the usernames, so all of
it was discarded. It is now parsed and exposed as Client::room_member_stats.

Issue #48 asked for RoomStats (57) and RoomListStats (55). Those codes are
UserInterests and MyRecommendations in the Soulseek protocol; there is no
RoomStats message on the wire. Per-room user counts already arrive with
RoomList (64), which the client handles, and the per-member statistics are
part of the JoinRoom reply -- so that is where this adds them.

- join_room.rs: parse the stat vectors, each bounded by the payload so a
  hostile count cannot hang or overallocate
- types::RoomUserStats; every field looked up positionally so a server
  sending short vectors (or omitting countries) does not shift the roster
- ServerMessage/ClientOperation::RoomMemberStats and the context snapshot
  behind Client::room_member_stats, replaced on each rejoin
2026-08-28 12:44:37 +00:00
Michel de Graaf 2a90619a64 refactor(client): dedupe peer registration and order eviction by id
The registry stored a registration Instant only to pick the eviction
victim, but the actor id is already monotonic per registration and
refreshed at the same moments, so it orders identically. The
register-refuse-fail sequence was also copy-pasted between the ops
loop and the listener; both now call one register_peer_or_fail.
2026-08-13 15:28:36 +02:00
Michel de Graaf 9fe1c9984c fix(client): bound the threads a peer flood can create
One popular search produced a ConnectToPeer for every responder, and
each one cost a 2 MiB wrapper thread, an unbounded 256 KiB actor, a
listener thread per inbound socket, and on failure a 2 MiB thread that
slept for 20 seconds. A container with a pids limit died on one album
search (issues #32, #33).

Four bounds, no behavior change for a quiet session:
- P-type registrations run inline on the ops loop; only F transfers
  keep a worker thread. The dial already ran on the actor thread.
- The broker timeout is a deadline swept by the ops loop instead of a
  sleeping thread per failed dial.
- The registry caps live actors (default 512, Client::set_max_peers).
  At capacity the oldest peer outside the caller's protected set
  (live downloads, uploads, pending serves, queued messages, browses
  in flight) is evicted; if none qualify the registration is refused
  before any thread is spawned.
- Concurrent listener handshakes are bounded by a 128-permit
  semaphore released once the peer-init arrives, so slow handshakes
  cannot multiply threads while transfers stay unthrottled.

A replayed TransferResponse for an InProgress or Completed token is
dropped instead of starting a second transfer thread over the same
.part file.

Fixes #32
Fixes #33
2026-08-13 13:46:18 +02:00
Michel de Graaf 9ce0a851dc fix(peer): handle UploadDenied so refused downloads fail fast
Peer message code 50 had no handler, so the dispatcher dropped it and a
download refused by the peer stayed Queued until the caller's timeout.

The new handler reads the filename and reason and routes the denial into
the same failure path as UploadFailed. A reason of "Queued" is status,
not a failure: some clients send it for a download that is still held in
their queue, and failing it would kill healthy downloads after a
reconnect, so that one is only logged.

Fixes #29
2026-08-13 12:45:57 +02:00
Michel de Graaf d30ca757bc fix(peer): forward UploadFailed to the download store
The code 46 handler parsed the filename and only logged it, so a queued
download whose peer reported an upload failure never received
DownloadStatus::Failed and hung until the caller's own timeout.

The handler now sends PeerMessage::UploadFailed through the dispatcher
and the actor stamps the peer's username on it before forwarding
ClientOperation::UploadFailed into the existing failure path. The
variant's username field stays for API compatibility; the wire message
cannot name anyone but the connected peer, so the actor supplies it.

Fixes #30
2026-08-13 12:39:42 +02:00
Michel de Graaf 77f36b1e98 fix(actor): leave Disconnected state behind after every disconnect
A failed outbound dial left PeerActor in Connecting, so
check_connection_status re-fired the 20-second timeout error on every
100ms tick until the actor was stopped. ServerActor had the same bug:
its disconnect paths only cleared state when already Connected.

Reset connection_state to Disconnected in all four disconnect paths.

Fixes #31
2026-08-13 12:21:14 +02:00
Michel de Graaf 0bbb559f68 fix: survive busy sessions without exhausting file descriptors
The peer listener died with "Too many open files (os error 24)" on any
long-lived non-daemon session: only the daemon raised RLIMIT_NOFILE, and
peer sockets were unbounded — no idle reaping, no deadlines on accepted
transfer streams, and one peer could hold two connections under split
registry identities.

- raise the open-file limit at the top of run(), for every entry point,
  and warn from Client::connect when the soft limit is below 1024
- reap peer control connections idle for five minutes; a clean
  disconnect no longer discards the peer's queued uploads
- give listener-supplied download streams and upload serving the same
  read/write deadlines dialled sockets already had
- register inbound peers under their plain username so the registry
  holds one connection per peer, and announce them like pierced ones
- hold the registry lock across actor spawn + insert and surface
  thread-spawn failure, so an instantly-dead peer cannot leave a
  zombie entry
2026-08-09 22:34:36 +02:00
Michel de Graaf f1b667f7f9 refactor: cut what a whole-repo audit proved dead, padded, or hand-rolled
Seven region audits, every finding adversarially verified against the
whole workspace before being applied; 80 landed and the two that failed
in application were refuted honestly (ParseError has a from_str_radix
producer the audit missed; NatPmpError's Display is the only reader of
its diagnostic payloads).

The bigger cuts:

- The NewPeer/PierceFireWall cluster: an operation variant nothing ever
  sent, its 77-line arm, a protocol-wrong Client::pierce_firewall that
  wrote a peer-init frame down the server socket, and the struct only
  they used. The live paths in peer/listen.rs are untouched.
- The ops loop reads `for operation in reader` instead of hand-rolling
  recv/break, de-indenting the whole dispatch body.
- Dead lib surface: Message::print_hex and four unused accessors,
  UploadFailed, registry and actor getters, build_watch_user,
  strip_ansi_codes, delegating shims on ClientContext. Removing public
  items makes this a semver major, which the release pipeline enforces.
- Never-used flexibility: the state-migration framework that never held
  a migration, border-type helpers that all answered Rounded, the TUI's
  max_concurrent_downloads field nothing read.
- Convergence: six poll loops into poll_until, three wrap-around
  navigation blocks into cycle, hit tests into Rect::contains, char
  walkers into floor/ceil_char_boundary, five Download literals into
  one helper, the suites' shared scaffolding into tests/common.

Deliberately not taken: swapping the hand-rolled zlib inflate and MD5
for crates (they replace wire-facing machinery whose tolerance of
real-world peer quirks no local test exercises) and pulling chrono into
the lib for a log timestamp. Recorded for their own decision.

Net -1002 lines. Both e2e modes stay green: 94 CLI tests each way, 16
protocol, 33 lib, unit suites, clippy pedantic clean, aislop 100/100
with the four scanner false positives suppressed inline where they
fire.
2026-07-28 12:52:18 +02:00
Michel de Graaf 7435d48b32 perf: raise transfer defaults and stop uploads sleeping on their slot
The slot cap merged from master defaults to 2 uploads, and the CLI capped
downloads at 5. Those are the numbers you pick when your own bandwidth is the
scarce resource, but a Soulseek transfer is paced by the peer on the other end
— usually a few hundred KiB/s — so what fills a modern link is many transfers
at once, not faster ones. Upload slots go to 10, downloads to 20. The queue
itself stays: privileged users being able to jump it is a condition of being a
tolerated third-party client.

serve_file also slept half a second after the last byte so the downloader could
drain before the socket closed. With a cap that is time no other peer can use,
and 64 uploads over 10 slots paid it six times over. A half-close says "that is
the whole file" and TCP still delivers everything written before the FIN.

Measured with 64 waiting peers: 2 slots 24.2s, 8 slots 6.2s, 32 slots 3.1s; at
10 slots the linger alone was 5.4s to 3.05s.
2026-07-26 18:25:50 +02:00
Michel de Graaf 5b4ef30f08 feat: lift the client's concurrency ceiling from ~15 peers to 1024
The client had never been driven at scale. A stress harness that mixes
searches, downloads, uploads and browses against a local soulfind found four
ceilings, three of which lose work silently rather than merely being slow.

Peers were capped at one per core. ActorSystem ran each actor's whole lifetime
as one job on a fixed-size ThreadPool, and actor loops block until stopped, so
the pool acted as a semaphore: past the cap an actor never started, never
connected and never errored, and uploads stopped working entirely. The pool had
exactly one consumer and every job it received was non-terminating, so it is
gone; actors get their own thread.

TransferResponse raced the bookkeeping it depends on. It was sent straight back
to the peer while UpdateDownloadTokens was still queued for the client loop —
but the response is what invites the peer's file connection, and that connection
is matched by token. Under load the client loop lost, the file connection
matched nothing, and the download sat on Queued forever. The response now goes
out after the token is recorded.

Download tokens were md5(filename) truncated to 20 bits, and the store deletes
every entry matching a token, so two downloads sharing one destroyed each other
— which fetching the same filename from two peers does every time. They now
come from a counter.

Port 0 from GetPeerAddress means the server does not know where a user listens.
It was cached and dialled, and the upload waiting on it was dropped. It is no
longer cached or served against; the connect attempt stays, because its failure
is what makes an unreachable peer fall back to server brokering.

Mixed profile at 1024 peers: 512/512 downloads, 256/256 uploads, 400-850 MiB/s
in ~7s, against 71/96 downloads at 7.9 MiB/s and a 60s timeout before.
2026-07-26 17:01:32 +02:00
Michel de Graaf e8a48c6b06 fix!: make concurrent invocations work instead of silently seeing nothing (#22)
Running several one-shot commands at once returned "no results" for queries that
had plenty. Four defects, each of which made a dead session look like an empty
network:

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

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

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

BREAKING CHANGE: `Listen::start` is replaced by `Listen::bind` and
`Listen::serve`, so the caller owns the socket and can read the port it actually
bound. `Client::listen_port` and `Client::session_loss` are new.
2026-07-26 14:52:31 +02:00
Michel de Graaf 9b87bd6a0f feat!: wishlist, upload-queue privilege recognition, and search filters (#20)
Closes the last two gaps in Soulseek's list of features an alternative client
must implement to be tolerated, and adds the result filters Nicotine+ has had
all along.

Wishlist: server code 103 is sent and code 104's announced interval is kept
rather than logged and discarded, so the re-search timer runs off the schedule
the server set. `wish add|remove|list|run` plus `serve --wishlist`; wishes
persist in config.toml as a TOML array so a comma inside a query survives.
`wish run` prints search-shaped records, so it pipes into `download --stdin`.

Privilege recognition: the code-69 user list is parsed instead of counted and
dropped, and code 92 answers `whoami` as `privilege_seconds`. Uploads now go
through a queue with a slot cap (`--upload-slots`, default 2) that serves
privileged users first — the cap is what makes the ordering mean anything, so
they are one change. `PlaceInQueueRequest` (peer code 51) is answered, where it
previously had no handler at all, and a departed peer's slot is released so a
stalled peer cannot wedge uploads shut.

Search filters: `--exclude`, `--extension`, `--min-size`, `--max-size` beside
the existing `--min-bitrate` and `--free-slots`, in one flattened arg group
shared by `search`, `get` and `wish run`.

Also serialises the library e2e suite behind the same gate `cli_e2e` already
uses: the new queue tests hold peer sockets open on purpose, which starved
concurrently-spawned soulfinds and failed unrelated download tests with
`login: Timeout`.

BREAKING CHANGE: `UploadStatus` gains a `Queued(u32)` variant, so an exhaustive
match on it no longer compiles, and `cli::Commands` gains `Wish`.
2026-07-26 14:20:07 +02:00
Michel de Graaf d2f3dd13d7 feat: log in with soulseek-rs's own client major version (176) (#13)
The Login message hardcoded SoulseekQt's major version 157, so the
server could not tell soulseek-rs apart from the official client. The
protocol document now reserves a unique major version per project, so
claim 176 and send it via a new ClientVersion on ClientSettings.

Clients and bots built on the library keep major 176 and pick their own
minor version (default 1); the reference CLI/TUI logs in with minor 100.

Refs #12
2026-07-25 21:51:58 +02:00
Michel de Graaf 5090af97a2 fix(lib): give the login verdict the wait the caller already allows
The actor gave the server 5 seconds to answer a login before reporting
Timeout, while Client::login documents and allows 45. Under load — 24
e2e tests sharing one soulfind — 5 seconds is not always enough, which
is how CI failed on a docs-only commit.
2026-07-25 05:28:44 +02:00
Michel de Graaf 03e8924a6b feat(cli): expose sharing, peer lookup and settings as one-shot commands
An agent driving the binary could search, fetch and chat, but it could not be
a peer: nothing kept a session online to serve files, nothing reported an
upload, shares could only be set at startup, and there was no way to ask
whether a peer was worth queueing to or to check credentials without doing
something else.

New commands:

  serve [--follow]                stay online sharing; one record per upload
  whoami                          credentials, connection and what we offer
  user <NAME>                     a peer's presence and share counts
  room users <ROOM>               the member roster
  shares list|add|remove|status|reindex
  config path|list|get|set

`config` and `shares list|add|remove` need no account, so they run before the
credential check — asking a script to log in for a `config get` would be a
poor trade. `serve` is the only place uploads are visible: they live in the
process serving them, so a one-shot `uploads list` could print nothing but an
empty list.

Library additions, all request/poll pairs matching the existing conventions:

- Client::room_members, kept current from the join and leave events
- Client::request_user_info / user_info, backed by new handlers for
  GetUserStatus (7) and GetUserStats (36) — codes the dispatcher previously
  logged as unhandled. The wire format is taken from soulfind's own source
  and pinned by an e2e test that asserts a peer's real share count.
- UserInfo carries presence and stats as Options, so a reply that never
  arrived cannot be read as "offline" or "shares nothing"
- request_user_info clears the previous answer, so the documented poll loop
  observes this request rather than the last one
- ServerMessage and ClientOperation are now #[non_exhaustive]: every protocol
  message the client learns adds a variant, and that should not break callers
- the crate root and prelude re-export every type a Client method returns

Also pins the output contract with tests: under --json, stdout carries only
newline-delimited JSON across fifteen command shapes while progress still
goes to stderr, and a failing command writes nothing to stdout at all.
2026-07-25 01:30:48 +02:00
Michel de Graaf 54feb3bc5a fix(peer): log disconnect cause; bind e2e browse listener on all interfaces
The brokered third-party browse e2e failed deterministically on CI. The test
bound its listener to 127.0.0.1 only, but the server brokers back the address
it observed for that client - the host's routable IP (172.17.0.2 under Docker,
10.x on a CI runner). The sharer's dial was refused, it fell back to
re-brokering, and the accept never arrived. The sibling third-party browse
tests already bind 0.0.0.0.

disconnect_with_error logged only "disconnect" and dropped the io::Error on the
floor, which is what made this invisible in the logs; it now logs the cause.
2026-07-24 22:21:06 +02:00
Michel de Graaf be5dc6c064 fix(peer): serve full browse listings + harden the listener; TUI transfer/UX work
Peer layer:
- Stream peer messages with a partial-write queue instead of write_all, so a
  large SharedFileListResponse over the non-blocking socket is no longer
  truncated (third-party clients like SoulseekQt can now browse our shares).
- Bound the peer-init handshake with a read timeout and handle each accepted
  connection on its own thread, so one stalled peer can't wedge the listener.
- e2e: third-party direct/brokered/large-share browse + stalled-peer coverage.

TUI:
- Uploads share the downloads table columns (real Speed) and the info pane now
  renders details for a selected upload.
- Download progress bar counts only in-progress transfers and hides when idle.
- Always-visible unread badge (private messages + rooms) in the shortcuts bar,
  self-drawn blink so it works under tmux.
- Persist the unread private-message count across restarts.
2026-07-24 21:18:20 +02:00
Michel de Graaf 2918b0ce75 refactor: resolve all lint findings
- remove 34 trivial restating comments (auto-fix)
- replace all 20 production .unwrap() calls with graceful handling:
  ?-propagation via LockPoisoned, unwrap_or_default on infallible
  conversions, let _ = on actor-channel sends, and a downloads_sender()
  helper in the TUI that also dedupes three channel-init blocks
- collapse nested if into match guard (clippy::collapsible_match)
- split client.rs (2275 lines) into a client/ module: connection,
  search, downloads, uploads, rooms, operations
- split ui/main_tui.rs (2027 lines) into a main_tui/ module: render,
  input, browse, rooms, downloads, search
- extract oversized functions: peer_actor/server_actor handle_message
  into per-message helpers, main() into init_logging/run_default_tui,
  download_selector and main_tui render into per-section helpers

Mechanical only, zero behavior change. Verified: clippy clean
(pedantic+nursery), fmt clean, 143 unit tests + 16 e2e tests against
soulfind pass, lint clean.
2026-07-20 10:53:57 +02:00
michel 7bc0ee2c3d feat(lib): chat-room protocol (list, join, leave, say) with events
Implement the Soulseek chat-room server messages against soulfind's
wire format:
- MessageFactory builders: room-list request (64), join (14),
  leave (15), say (13).
- Handlers parsing the server side: RoomList (64) names+counts,
  JoinRoom (14) membership, SayChatroom (13), UserJoinedRoom (16),
  UserLeftRoom (17), LeaveRoom (15).
- Surface everything to the client as a drained RoomEvent stream plus
  a room-list snapshot; new Client API: request_room_list, join_room,
  leave_room, say_in_room, room_list, take_room_events.

Unit tests cover each builder and handler; server-gated e2e tests
exercise message delivery and room listing against soulfind.
2026-07-19 19:47:58 +02:00
michel f3218475dc feat: browse another user's shared files
Add Client::browse_user (sends GetShareFileList, code 4, connecting first if
needed via the same queue-and-flush path as search replies) and
take_browse_result. A new SharedFileListResponseHandler parses the peer's
code-5 reply into SharedDirectory entries, forwarded as
ClientOperation::BrowseResult and stored per user. Verified end to end: a
second real client browses a sharer and receives its file listing.
2026-07-19 18:56:19 +02:00
michel f3b24f34c0 feat(shares): reply to browse requests with a SharedFileListResponse (code 5)
GetShareFileList (peer code 4) previously replied with a malformed message (a
server SharedFolders stats message over a peer connection). It now emits
ShareListRequested, and the client builds a real SharedFileListResponse
(code 5) from its shares grouped by directory. Adds a std-only code-5
encoder/decoder (build_shared_file_list / parse_shared_file_list) with a
roundtrip test, and Shares::directories() to group files. The decoder is the
foundation for the Browse feature.
2026-07-19 18:51:55 +02:00
michel 393f69e478 feat(shares): serve queued files to peers over F connections
Implement the upload path so a peer can download our shared files:
- fix the mis-coded PlaceInQueueResponse (43 -> 44) and add a real QueueUpload
  handler (code 43) -> PeerMessage::IncomingQueueUpload;
- the peer actor forwards it as ClientOperation::QueueUpload; the client looks
  the file up in its shares, mints an upload token (high half of the token
  space, disjoint from md5 download tokens), and offers it via ServeUpload ->
  build_upload_transfer_request (peer code 40, direction=upload);
- a TransferResponse for one of our serving tokens is handled by a surgical
  early-return that emits StartUpload, leaving the download path untouched;
- StartUpload resolves the downloader's address (GetPeerAddress, cached) and
  streams the file via a new peer::upload_peer::serve_file over an F connection.

Tested: build_upload_transfer_request decodes via the production Transfer
parser; serve_file streams a file correctly over a loopback F connection.
2026-07-19 18:46:06 +02:00