Commit Graph
100 Commits
Author SHA1 Message Date
Michel de Graaf a6d4c8b3df 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.
2026-09-10 15:18:22 +02:00
Michel de Graaf 2a343dd4b9 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.
2026-09-10 15:17:54 +02:00
Michel de Graaf 7ffb85a6ad 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.
2026-09-10 14:46:11 +02:00
Michel de Graaf 2e23581513 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.
2026-09-10 14:46:11 +02:00
Michel de Graaf 3c5a3dd648 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.
2026-09-10 14:36:32 +02:00
Michel de Graaf 8854f8d005 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.
2026-09-10 14:35:37 +02:00
Michel de Graaf 8bee7745e1 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.
2026-09-10 14:33:23 +02:00
Michel de Graaf eeae8e2934 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.
2026-09-10 14:29:36 +02:00
Michel de Graaf 7c9f561d22 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.
2026-09-10 14:28:04 +02:00
Michel de Graaf 3f6433d219 Merge pull request #96 from michel/lib/room-roster-from-member-list
fix(rooms): start a room's roster from the member list, not the join echo
2026-09-10 14:23:24 +02:00
Michel de Graaf 6d1946e209 Merge pull request #95 from michel/test/queued-download-silent-peer
test(e2e): queue downloads on a silent peer, not a user who does not exist
2026-09-10 14:03:08 +02:00
Michel de Graaf 7060d3521a 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.
2026-09-10 13:59:04 +02:00
Michel de Graaf e39427ada5 test(e2e): queue downloads on a silent peer, not a user who does not exist
Two daemon tests cancel a download that has to still be waiting, and both
waited on e2e_ghost_peer, a user who never logs in. That only ever worked
inside a timer: the download sat unconnected until the 20-second broker
timeout failed it, and the tests cancelled inside that window. Since
CantConnectToPeer ends the wait on downloads for a peer the server cannot
reach, soulfind answers the brokered dial straight away and the download
fails before the cancel lands. The CLI test failed on every daemon-mode run,
which is what blocked the v19.0.0 release; the protocol suite's twin only
passed because its cancel raced the server's answer and won.

Both now download from a silent peer: a raw login whose announced listener
accepts the connection and never replies, so the download stays queued the
way it does behind a busy peer, for as long as the test needs it.
2026-09-10 13:30:50 +02:00
Michel de Graaf 35f50981ab ci: redeploy the site when a release changes the version it displays
website/vite.config.ts reads the root Cargo.toml at build time and stamps it
into __APP_VERSION__, which the footer and the page chrome render. Pages only
fired on `website/**`, and a release touches Cargo.toml and CHANGELOG.md and
nothing else, so the site never rebuilt for one: the footer kept advertising the
previous version until somebody happened to edit the website for other reasons.
That it currently reads v18.0.0 is luck — a docs commit landed the day after the
release.

Cargo.toml is left out of the pull_request paths on purpose. A dependency bump
changes nothing the site renders, and it is the deploy that needs the trigger.
2026-09-10 12:08:07 +02:00
Michel de Graaf 1b898adeeb docs: stop pinning a library version the instructions cannot keep current
Four places told people to write `soulseek-rs-lib = "8"` (RELEASE.md said "6"),
and every one of them had been left behind by ten majors or more. The failure is
silent rather than loud: 6.x and 8.0.0 are both real published versions, so cargo
resolves the snippet happily and hands over a library from August instead of
refusing it, and one of the four is soulseek-rs-lib's own README, which is the
page crates.io renders.

`cargo add` writes whatever version is current at the time it runs, so there is
no longer a number in the docs to go stale. The manifest pin in
soulseek-rs/Cargo.toml stays as it is — release-plz owns that one and keeps it
in step with the release.
2026-09-10 12:08:00 +02:00
Michel de Graaf 5581fa6d10 ci: back-merge master into develop after every release
release-plz owns the workspace version and the changelog, and writes both on
master. Nothing carried either back, so develop still declared 14.1.2 five
releases after v14.1.2 shipped. Nightly builds the tip of develop, which meant
every nightly binary reported --version 14.1.2 against a released 18.0.0, and
the changelog on the branch the work happens on stopped at v14.1.2 too.

The job merges master whole rather than cherry-picking the bump, so the
changelog travels with it, and hangs off `release` rather than `binaries` so the
sync does not wait on six binary builds. develop never edits those lines itself,
which is what keeps the merge clean; a conflict fails the job rather than
resolving itself quietly. Nothing in the repository triggers on a push to
develop, so this neither loops back into the workflow nor spends a CI run.
2026-09-10 12:04:08 +02:00
Michel de Graaf 9ebaebca2a merge: back-merge master to pick up the v14.2.0-v18.0.0 releases
release-plz bumps the workspace version on master and nothing carried it
back, so develop still declared 14.1.2 five releases later. Nightly builds
come off develop, which meant every nightly binary reported --version
14.1.2 against a released 18.0.0, and the changelog on this branch stopped
at v14.1.2 as well.
2026-09-10 11:57:06 +02:00
Michel de Graaf 62252588a4 refactor: split the files that had grown past reading size
Five files ran between 1.2k and 2.4k lines, and one function past the
length the quality gate allows. None of it was doing anything wrong; they
had simply accumulated until you had to scroll to hold one in your head.

The move throughout is into *child* modules rather than siblings, because
a child still sees its parent's private items. So `ClientContext` keeps
its private fields and `ServerActor` keeps its private state, while the
methods that work on them move out — no encapsulation is widened for the
sake of the split. Three `ServerActor` methods become `pub(super)`, which
is only acknowledging the calls the parent already made.

The client's operation loop and the actor's standing-message match are
split by subject: the arms move out whole, so the bodies read exactly as
they did inside. Where an arm said `continue` it now says `return` — the
match was the last thing the loop did, and none of the arms that moved
holds a loop of its own, so skipping the rest of an arm and ending its
handler are the same move.

The test modules split by topic the same way, which is most of the size:
main_tui's alone was 2.1k of the 2.4k lines.

No behaviour changes. Verified against a live server: the full e2e suite
passes, including the 98 CLI and 19 daemon-protocol cases that drive the
dispatch loop this touches.
2026-09-10 11:48:17 +02:00
Michel de Graaf 26b551d997 feat: browse your own shares as a peer receives them
The window could show any peer's share tree but never this account's, so
"am I actually sharing that?" had no answer short of asking a friend to
browse you. The tree is already there; what was missing was feeding it
from our own index.

B opens it — the same collapsible tree, filter, and navigation the peer
browse offers, built from `Shares::directories()`, which is the listing
GetShareFileList answers with. So what it shows is what the network sees,
not what the config meant to share. r re-scans the disk rather than
re-asking a peer, o steps across to the folders that feed it, and nothing
here queues a download from ourselves.

Attached to a daemon the index lives on the daemon's host, so it comes
back over a new `shares.files` method rather than being scanned locally;
`shares files [--filter]` prints the same listing headless, through the
`emit_listing` that `browse` now shares with it — the two are only
comparable while they are built the same way.

Two allocation fixes came out of the review, both on paths this reaches:
the daemon hands its listing to the DTO instead of deep-copying one it
already owns, and `Shares::directories()` allocates its map key once per
folder rather than once per file, which the peer-facing serve path has
been paying for all along.
2026-09-10 11:44:57 +02:00
Michel de Graaf d6d870b39f feat(tui): account settings — stats, password change, and logout
The settings popup opens with an Account block: who the session is logged in
as, how much it shares, the port peers reach it on, and the daemon it borrows
when the window is attached to one. Two rows follow, both confirmed before
they act. A password is typed twice, because the server sends no verdict on a
change and a typo entered once would leave the account with a password nobody
knows. Logging out closes the window and comes back to the login screen with
the stored password deleted, so the next start asks rather than walking
straight back in. A window attached to a daemon offers no logout: the login is
the daemon's.

"Change it on the server, then store it where the login lives" is one function
over the session and the secret store, so the window, the command and the
daemon share the rule rather than each spelling out its own idea of which
process owns the password.

Headless the same two acts are `account password` and `account logout`, and
the daemon answers `account.password` so a routed run is not a dead end —
`whoami` already reported who is logged in and what they share.
2026-09-10 11:10:58 +02:00
Michel de Graaf 4ea1e9c47c feat(tui): clear the whole queue from the transfers pane
Shift-C in the Downloads/Uploads pane now cancels every live download,
forgets every row — queued, finished or failed — in the session too,
and cancels uploads still streaming, so a shared queue empties for
every window at once. The existing c keeps clearing only finished
transfers.
2026-09-10 10:28:22 +02:00
Michel de Graaf f620ffa08f test: give the hung-up child's reap a wall-clock budget
A dead child only loses its slot once a write to it fails, and writes
keep landing in the send buffer until the peer's RST has crossed the
loopback. Fifty back-to-back writes finish in microseconds, so under
full-suite load the loop ran dry before the kernel noticed the close
and the assertion fired. Sleep 5 ms between attempts that have not
reaped yet, bounded at 100 tries: the fast path still exits on the
second write, a loaded machine gets up to half a second.
2026-09-10 10:15:46 +02:00
Michel de Graaf 1b4c3f3531 refactor: trim the folder mark's allocations and indirection
The unmarked-folder span and size string allocated per row per frame
for nothing, and a filter/map_or_else chain built a string that only
one state ever fills. A matches! fills it directly.
2026-09-10 09:55:04 +02:00
Michel de Graaf cfa81120ef feat: folders in the browse tree carry their download's mark too
A folder queued from the browse tree is really the queue of every file
under it, so its row reports the loudest status among those files:
working while anything moves, paused, queued, a failure once quiet, and
✓ only when every file is done. A working folder shows the percent the
queued bytes have come in its size cell.
2026-09-10 09:48:41 +02:00
Michel de Graaf 0703f22ad7 refactor: drop the per-row String behind the browse marker's gap
Span::raw takes a &'static str; the to_string allocated for every
unmarked row of every frame. Points the doc comment at the Downloads
pane, which is what actually marks downloads.
2026-09-10 09:23:18 +02:00
Michel de Graaf 3a49c551ea feat: mark browsed files with their download status
Queueing a file or folder from a user's browse tree left no trace in
the window it was queued from. A row whose download is in the transfer
list now carries the same status glyph the Downloads pane uses —
⋯ queued, ↯ active, ⏸ paused, ✓ done — and an active row shows its
percent beside the size. Folders mark through their files: queue a
folder and watch the ✓ spread down it.
2026-09-10 09:18:44 +02:00
Michel de Graaf e641644808 style: spell two Durations in their optimal units
New clippy flags duration_suboptimal_units; from_mins(5) and
from_secs(1) say what from_secs(300) and from_millis(1000) meant.
2026-09-10 09:04:48 +02:00
Michel de Graaf 611648c5d4 feat: browse the highlighted transfer's user with b
In the Downloads / Uploads pane, b now opens the share of whoever owns
the selected download or upload, the way it already browses the owner of
a highlighted result. With no row selected it still prompts.
2026-09-10 09:04:48 +02:00
Michel de Graaf 9a8f616f9b chore: keep tool attribution out of the history
An assistant is tooling, like an editor or a formatter, and tooling does
not sign the work — a co-author trailer or a stray commit identity puts
one on the repository's GitHub contributor list.

Both guards are git's own, so they need no editor-specific directory in
the tree. .mailmap folds a tool identity into the author who owns the
work, which git honours in log and shortlog and GitHub honours when it
builds the contributor list. The commit-msg hook strips the trailers if
one is written anyway; CLAUDE.md says so in words and names the one-time
config that turns the hook on.
2026-09-09 20:10:07 +00:00
Michel de Graaf 31136a5e5c merge: soulseek protocol e2e coverage, stress fixes and coverage docs 2026-09-09 19:45:19 +00:00
Michel de Graaf cd580e5173 docs: lead with the protocol coverage, and drop a stale upload assertion
The message-set coverage is the crate's strongest claim, so say it where
people look first: the README features list and About, the website home
and community pages, each pointing at the coverage table. The table now
carries a section naming what the message set does not reach, so the
claim has something behind it rather than standing on its own.

The upload streaming test still asserted the uploader was blocked
waiting for the downloader's close — the wait that came out when it
started costing the upload slot. It passed only while the thread had yet
to be scheduled, and failed whenever it had.
2026-09-09 19:45:06 +00:00
Michel de Graaf 9bca573bc0 fix(uploads): finish a transfer when the file lands, not when the peer closes
The stress harness found this: 38 completed uploads logged as errors, and a
run that took 30s to move what should take 6.

After the last byte we waited up to 30 seconds for the downloader to close
the file connection. Soulseek does leave that close to the downloader, but a
peer that simply leaves the socket open — or whose FIN a NAT swallows — is
not a failure: it has every byte. We were marking those uploads Failed,
skipping the SendUploadSpeed report that sets our advertised speed, and
holding the upload slot the next peer was queued for.

Cutting the wait to five seconds was not enough, and stress said so: with
two slots and sixty-four queued leechers, five seconds apiece is a hundred
and sixty seconds of waiting, and ten uploads never got served inside the
run. The slot has to be free the moment the file is delivered, so the wait
is gone — Nicotine+ finishes the transfer there too and closes from its own
side. That profile went from 146s with ten uploads lost to 9.7s with none;
the default profile from 30.7s and 25 MiB/s to 5.8s and 133 MiB/s.

Also: a decompression ceiling on peer payloads, the numbers Nicotine+ uses
(128 MiB for a search or folder reply, 2 GiB for a browse listing). Deflate
reaches roughly 1000:1, so a few kilobytes from an unknown peer could
otherwise expand until the process died.

And the stress harness counted threads with `ps -M`, which is macOS
spelling: on Linux it returns one unrelated line, so the guard against
thread explosions has been reporting a single thread on CI all along.
/proc/self/status has the real number.
2026-09-09 19:35:58 +00:00
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 435ff382d8 fix(distributed): a server with no parent figures leaves our cap alone
The speed-derived child limit read a missing ratio as a refusal, so on any
server that never announces ParentSpeedRatio — soulfind among them — the
client carried no children at all, however it was configured. A ratio we
were never told is not a decision: the server's figures set the limit only
when it has given them, and otherwise the configured cap stands, which is
how slskd runs throughout. A speed below the announced minimum still means
none, which is the safety property the rule exists for.

Also stops repeating AcceptChildren: it is a standing state on the server,
so it goes out when the answer changes rather than on every message that
happens to prompt a recount.
2026-09-09 17:23:12 +00:00
Michel de Graaf d26f6353a5 test(e2e): the operator roster of a private room
Promotion already proved the promoted user is told; this pins the other
half — the roster the owner sees (code 143), and an operator standing
down of their own accord (147).
2026-09-09 17:18:40 +00:00
Michel de Graaf 17769568d2 test(e2e): resigning a private room, disbanding it, gifting privileges
Three messages that were implemented and only encoder-tested: a member
giving up their own membership (136), an owner disbanding the room and
freeing its name (137), and a privileged account handing time to another
user (123), which the recipient then sees as privilege time of their own.
2026-09-09 17:17:41 +00:00
Michel de Graaf e30cca6825 test(e2e): a room announces members arriving and leaving
UserJoinedRoom and UserLeftRoom (codes 16 and 17) were parsed and applied
to the roster with nothing proving either end of that, which is the kind
of gap that only shows up when someone relies on it.
2026-09-09 17:16:10 +00:00
Michel de Graaf 68586639ac docs: record what the client speaks and where it is proven 2026-09-09 17:15:29 +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 6d6f4f282e feat(distributed): learn our own upload speed by watching ourselves
The child limit is derived from the upload speed the server records for
us, and the server pushes a user's stats to whoever watches that user —
so a client that does not watch itself never learns its own figure and
would carry the default cap forever. Nicotine+ reads the same number off
the stats the server sends about the logged-in user.

We now watch ourselves as each session starts, and the stats that come
back — the initial reply and every later push — set the figure the limit
is derived from. Client::own_average_speed exposes it.
2026-09-09 17:11:04 +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 bf3e3ec4ad docs(readme): document the accept_children setting 2026-09-09 16:48:58 +00:00
Michel de Graaf 460031b532 feat(distributed): serve children in the search tree
The client read the distributed network but never carried it: a peer
dialling with a D connection was logged and dropped, and AcceptChildren
was hardcoded false, so this client took from the tree without ever
relaying a search for anyone else.

With accept_children set, peers hang from us: each is told where our
branch sits when it connects and again when the branch moves, every
search that reaches us is passed down, and a child that goes away loses
its slot. Ten at a time, matching Nicotine+, since each costs a socket
and a copy of the network's whole search stream — which is also why it is
opt-in, through the new accept_children config key (ignored without a
listener, since a child would have nothing to dial).
2026-09-09 16:47:55 +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 60a0f8fc91 test(e2e): drive the new protocol messages against soulfind
Eleven scenarios over a real server: tickers reaching other members and
the board handed to a late joiner, the global room feed starting and
stopping, user and room searches answered by their targets, interests
driving similar users and recommendations, a password change taking
effect on the next login, one message reaching several users, and a
brokered dial we cannot complete reporting CantConnectToPeer to the peer
that is waiting on it.
2026-09-09 16:22:32 +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 374a81bec1 feat(tui): resize panes by dragging pane borders
Press and drag the border under Results or the seam at a row pane's
right edge; the panes resize live and release ends the drag. Dragged
sizes are cell counts on PaneLayout, so W resets panes, zoom and sizes
in one key, and the reset-layout hint shows once anything is dragged.
Undragged panes keep their default Fill proportions.
2026-09-09 17:20:12 +02:00
Michel de Graaf 926c1c6697 feat(download): resume interrupted transfers 2026-09-09 15:49:35 +02:00
Michel de Graaf 7f84998c2e Merge pull request #93 from michel/nightly
fix(nightly): harden publishing and the live install command
2026-09-08 14:56:43 +02:00
Michel de Graaf a3105fb060 fix(docs): use deploy-independent nightly installer 2026-09-08 14:34:10 +02:00
Michel de Graaf 922ad6fc35 fix(ci): detect a missing nightly tag 2026-09-08 14:29:01 +02:00
Michel de Graaf 2975438949 fix(ci): retry nightly tag updates 2026-09-08 14:17:16 +02:00
Michel de Graaf 108c897fdf Merge pull request #92 from michel/nightly
feat(ci): publish installable nightly builds
2026-09-08 14:07:35 +02:00
Michel de Graaf 99205026f8 feat(ci): publish installable nightly builds 2026-09-08 13:40:50 +02:00
Michel de Graaf 0456ccbf59 Merge pull request #90 from michel/tui/rerun-search
feat(tui): run the highlighted search again with S
2026-09-08 13:33:52 +02:00
Michel de Graaf 3ef78c6de1 refactor(tests): share one soulfind fixture across the e2e suites
The TUI suite had grown its own copy of the soulfind spawner the protocol
suite already carried, and its own copy of the CLI suite's in-process
client and settle helpers. All four suites now spawn, log in, settle and
skip through `tests/common`: the CLI suite's own server goes too, and
the fixture checks its child is still alive before calling the port
answered, so a lost port cannot pass another server off as ours.
2026-09-08 13:12:12 +02:00
Michel de Graaf 76f4854169 feat(tui): run the highlighted search again with S
The search list survives a restart, but a restored query was only ever a
reminder: seeing what the network has now meant typing it out again. In
the Searches pane, `S` now re-runs the highlighted query in its place — no
duplicate row, no reordering — cancelling a run still collecting first and
starting the Results display over for what comes back.

The key is listed in the shortcut bar, the `?` overlay, the README and the
website. `start_search` is split into launching a search and showing it,
so both paths share one implementation.

A new `tui_e2e` suite drives the real window over a real session against
soulfind: keys in, a screen out, a sharing peer on the other end. It
restores a query from a previous run's state directory, presses `S`, and
checks the peer's file lands on screen — then again once the query has
been on the wire, proving a repeat is answered and the display does not
stack. To make that possible `MainTui` is exported with `handle_key_event`,
`render`, `poll_session` and a `state()` accessor, and CI runs the suite
with `SOULSEEK_E2E_REQUIRED=1`.

Closes #85
2026-09-08 13:11:40 +02:00
Michel de Graaf af1f9241e4 Merge pull request #91 from michel/tui/results-folder-column
feat(tui): folder column, paged long lists, and filters in every popup
2026-09-08 12:32:08 +02:00
Michel de Graaf 0cacc8cc10 refactor(tui): one page window and one filter editor for every list
Four panes had grown the same dance around ratatui's table: clamp the
selection, take the visible page, hand the table a page-local state,
keep the page start. It is now page_window and render_page in a paging
module beside page_of, and the two popups page by the same count they
draw, tab bar or header included.

Five typed filters had grown the same key handling too. edit_filter now
takes a key for all of them, which also stops a control or alt chord
from typing its letter into a filter, stops Enter, which keeps a filter,
from resetting the view it is over, and lets the results and room-list
filters page like the others. Their titles and the typing shortcuts
come from one place as well.
2026-09-08 11:24:59 +02:00
Michel de Graaf 607532cc44 feat(tui): search a room's log and members, and a conversation
A busy room's log scrolls past and its member list runs to a thousand
names, with nothing to narrow either. In a room / now filters the log
to lines mentioning the text and u filters the member list, the same
way the room list and the results filter: type, Enter keeps it, Esc
clears it before it leaves the room. The inbox takes / over the open
conversation. The member keys, b and m among them, act on the narrowed
list, and the titles say what is filtered and how many made it.
2026-09-08 11:24:53 +02:00
Michel de Graaf 6c4e60bb0e perf(tui): wrap a chat log as it arrives instead of on every frame
A room's chat log was wrapped to the pane width from the first line on
every frame, so a busy room lagged more the longer it stayed open: in
the harness 30,000 lines cost 16.6ms a frame. The wrapped rows now
live with the log and a draw only wraps the lines that arrived since
the last one, starting over when the width changes. The same cache
serves the private-message box, and the room list draws only its
visible page like the other tables. The 30,000-line room now draws in
0.4ms.
2026-09-08 11:24:46 +02:00
Michel de Graaf b8da8668b4 feat(tui): folder jumps, open-all and a path filter in the browse popup
A browsed share only moved a row at a time, so a folder of a few
hundred files stood between the user and the next folder. J and K now
jump between folder rows, H and L close or open every folder, and /
filters the whole tree by path the way the results and the room list
filter: type to narrow, Enter keeps it, Esc clears it, then hides. A
folder under a filter shows the paths that match and d downloads just
those. The keys popup gains a browse section and the bar lists them.
2026-09-08 11:24:41 +02:00
Michel de Graaf 0931bb01b4 perf(tui): flatten a browsed share once and draw one page of it
Moving through a browsed share felt laggy for the same reason the
results did: the popup re-flattened the whole expanded tree on every
frame, cloning every row's name and path, then built a table row for
each, and every keypress flattened it twice more. A heavy sharer's
tree runs to tens of thousands of rows once a few folders are open.

The flattened rows now live on the browse state and are rebuilt only
when a folder opens or closes, and the popup draws the visible page
alone. In the harness a 30,000-row tree went from 11ms to 0.35ms a
frame and a keypress from 2.7ms to under a microsecond.

The downloads pane had the same shape (every transfer became a row
each frame, and a select-all can queue ten thousand), so it takes the
same page window, which now lives beside page_of for every pane to
share.
2026-09-08 11:24:36 +02:00
Michel de Graaf 1fcde0a78b feat(tui): W puts the pane layout back
Hiding panes with w and zooming with z had no way back short of
pressing each hidden pane's number and Esc. W shows every pane and
leaves zoom in one go, and the shortcut bar offers it only while there
is a layout to reset.
2026-09-08 11:24:31 +02:00
Michel de Graaf 995fe98c01 feat(tui): show the clear-all key in the searches pane's shortcuts
C has cleared every search for a while, and the keys popup says so, but
the pane's own shortcut bar never mentioned it, so it read as if d on
each search were the only way. The bar now lists it next to d → remove.
2026-09-08 11:11:45 +02:00
Michel de Graaf 320d0a81a6 perf(tui): build only the visible page of result rows
Scrolling a long results list was laggy: every frame built a table row
for every result, and ratatui then drew the forty-odd that fit. At the
10,000-file cap that cost 25-32ms a frame in release (69ms in debug), in
attached and standalone mode alike, so a held key stuttered.

The pane now slices to the page ratatui would show, by its own rule:
stay where the last frame left off, pulled along only as far as keeps
the selection in view. The table gets a page-local state and the page
start is written back into the real one. The page is counted with the
same page_of that PgUp/PgDn use, so paging and drawing agree, and a
selection past the end lands on the last row as ratatui put it. Idle
frames drop to 1.5ms and a keypress to 0.6ms at the same size, measured
live in both modes.

ratatui has no lazy rows of its own: the lazy_rows proposal was not
merged and moved to the experimental ratatui-table crate, so windowing
in the caller is the recommended approach today.
2026-09-08 11:11:39 +02:00
Michel de Graaf b0abb90f4f Enhance README with installation instructions and links
Added quick install instructions and updated project description with a link to the website.
2026-09-08 11:02:41 +02:00
Michel de Graaf 8a9a74460b docs: redraw the demo's results rows with the folder column
The README's recording still showed the whole path in one column. Every
results line in it, the rows a popup half covers and the ones the
recording caught mid-paint included, is redrawn from what the pane now
renders at that width, with the same names, sizes and scroll position.
2026-09-08 07:01:19 +00:00
Michel de Graaf 95cadc49d2 docs(website): describe the results pane's folder column
The pane guide and key table on the docs page, and the note above the
hero terminal, still spoke of one column for the whole path.
2026-09-08 06:40:17 +00:00
Michel de Graaf 2733ee26f7 refactor(tui): trim the folder column change to what it needs
The path splitter is a one-line stdlib call with a single caller, so it
lives beside that caller as a private fn, without its own tests. The pane
tests assert the two columns in one screen fragment each, the end-offset
test keeps only the two cases the existing name tests do not cover, and
the key-handling test that repeated one of them goes. The website mock
splits on the backslash the same way its Info pane already does.
2026-09-08 05:59:39 +00:00
Michel de Graaf da371d4176 feat(tui): show a result's file name and folder in columns of their own
The results list held the whole shared path in one column, so a search
was read through its folder: every row began with the same peer prefix
and the file name, the part being searched for, sat off the right edge.
Split the column at the last separator, the file name first with the
larger share of the width and the folder dimmed beside it, so names line
up and read at a glance.

The one scroll offset moves both columns together, and $ runs to the end
of whichever overflows further. The website's mock of the pane follows.

Suggested in #55.
2026-09-08 05:59:38 +00:00
Michel de Graaf 7aec33a8d5 Merge pull request #87 from michel/release-plz-2026-09-08T00-09-33Z 2026-09-08 06:34:42 +02:00
Michel de Graaf bb31c9e7a4 chore: release v18.0.0 2026-09-08 02:09:34 +02:00
Michel de Graaf cb43ab4390 Merge branch 'develop' 2026-09-08 02:07:54 +02:00
Michel de Graaf 1a1d4d529d Merge pull request #84 from michel/lib/distributed-leaf
feat(lib): join the distributed search network as a leaf
2026-09-08 02:06:39 +02:00
Michel de Graaf 7157ba247c Merge pull request #83 from michel/lib/api-hygiene
refactor(lib): keep the actors inside the crate and mark the growing enums
2026-09-08 02:06:33 +02:00
Michel de Graaf 93bfa053e9 Merge pull request #82 from michel/lib/listing-attributes
feat(lib): carry audio attributes in browse and folder listings
2026-09-08 02:06:27 +02:00
Michel de Graaf 2c15e0600e Merge pull request #81 from michel/lib/audio-attributes
feat(lib): read audio attributes from shared files at scan time
2026-09-08 02:06:22 +02: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 5a20d69056 fix(lib): count the ID3 tag once and honour CRC frames when probing an MP3
The tag's length was subtracted twice from the audio length when the
tag fit in the first read, and not at all when it did not, so durations
came out short or long by the tag; a frame with the protection bit
clear carries a two-byte CRC before its side info, which put the Xing
block two bytes off; and a Xing block without a frame count threw the
whole file's attributes away instead of falling back to the frame
header. Each has a test now.
2026-09-08 01:44:19 +02:00
Michel de Graaf a58aedf479 feat(lib): read audio attributes from shared files at scan time
Every search reply we sent carried no bitrate or duration, so our files
failed every bitrate filter in other clients and sorted last in
SoulseekQt, whatever their quality. The share scan now reads each MP3's
first frame header (and Xing block for variable bitrate) and each FLAC's
STREAMINFO, advertising bitrate, duration, VBR, sample rate and bit
depth as far as the format gives them; other files stay bare. Only the
file's head is read, no crate is needed, and a file that does not parse
is shared without attributes rather than skipped.
2026-09-08 01:44:19 +02:00
Michel de Graaf d28e33802f Merge pull request #86 from michel/ci/stress-serve-browses-while-offering
fix(lib): keep a replaced peer connection open for the reply in flight
2026-09-08 01:43:42 +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 3f1494a4f9 fix(stress): serve a browse that lands while an offer awaits its answer
The mock seeder drained every message but the TransferResponse while it
waited for the client to allow an upload, so a browse arriving in that
window was silently dropped and counted against the client.
2026-09-08 00:35:11 +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 ee8d285505 Merge pull request #79 from michel/lib/offer-expiry
fix(lib): let an unanswered upload offer expire
2026-09-07 23:51:01 +02:00
Michel de Graaf 431080699d fix(lib): let an unanswered upload offer expire
An upload offer counted against the slot cap until the peer accepted
it, and a peer that never answered, having gone away or hung, held that
slot for the life of the process; with two slots by default, two such
peers shut uploads down until a restart. An offer nobody answers within
thirty seconds is now dropped, recorded as a failed upload so the
operator sees why, and the slot goes to the next in line.
2026-09-07 23:30:33 +02:00
Michel de Graaf 2d774c779f Merge pull request #80 from michel/ci/stress-gate
ci: gate pull requests on the stress benchmark losing no work
2026-09-07 23:30:02 +02:00
Michel de Graaf 43058d295b ci: gate pull requests on the stress benchmark losing no work
Every ceiling this project has hit only appeared under a mixed load, and
three of the four lost work silently while single-feature e2e stayed
green, yet the benchmark only ever ran by hand on one laptop. With
STRESS_REQUIRE_FUNCTIONAL=1 the stress example exits 1 when a download,
upload, search or browse was lost; throughput never fails it because it
measures the machine, not the client. CI now runs a quarter-size profile
against soulfind on every pull request.
2026-09-07 23:07:26 +02:00