nix run github:michel/soulseek-rs/master builds the release branch and
/develop the nightly. The lock is not optional: without one nix refuses
a github: reference outright and hides the commit from the build, so
the version stamp read +dirty.
Built by nfpm from the static musl binaries, with the man page and the
bash, zsh and fish completions. The names carry no version, so
releases/latest/download/soulseek-rs-amd64.deb stays a working link; the
nightly pair replaces itself on the rolling prerelease.
`completions install` writes into a home directory, which a package
build does not have. `completions print <shell>` and `man` give it the
files to put under /usr/share instead.
A nightly printed the same 19.0.0 as the release it followed, so a bug
report could not say which one it came from. SOULSEEK_RS_VERSION at
compile time replaces the crate version in --version, the TUI and the
daemon status.
With the login screen's own attempts no longer holding the port, a
session listens somewhere other than the configured port mainly when
another program already holds it. soulseek-rs still falls back to an
ephemeral port there, which is what lets several clients on one machine
share a configured port, and it advertises the port it really bound. A
manual port forward on the router then points at nothing, though, and
the TUI never said so; the CLI already warns.
After login the TUI compares the bound port with the configured one,
using the CLI's rule, and the status bar's title reads "port X in use,
listening on Y" for the rest of the session. A window attached to a
daemon shows nothing: the daemon owns its listener.
Nicotine+ keeps retrying the configured port every five seconds and
Soulseek.NET refuses it; neither falls back. Keeping the fallback and
making it visible is a deliberate difference.
Stopping or restarting an attempt used to drop only the channel its
result would arrive on. The thread kept its client for up to 45 seconds:
that client held the configured listener port, so the replacement fell
back to an ephemeral one, and a slow server could still accept the old
login after the new one and kick the new session.
Each attempt now carries its client's CancelHandle. Esc cancels it on the
spot. A restart cancels the attempt it replaces on the UI thread, and the
new attempt's thread starts by joining the old one, which has dropped its
client, and with it the port, by the time it ends. A result the old
attempt finished with but nobody collected is dropped before the new one
connects. An attempt replaced before its thread got going skips
connecting altogether, and a result that lands after a cancel is thrown
away instead of handed over. Restarts in a row chain through the joins,
so at most one client is connected or holds the port at a time, which is
what Soulseek.NET's connection lock and Nicotine+'s single server
connection guarantee too.
A result is acted on only while the screen shows Connecting. One that
lands after Esc, even a successful login, is dropped on the spot, so no
stray session stays online until the next submit. The one-second restart
floor applies only to restarting an attempt in flight, since a kept
attempt no longer means one is running.
A restart during the old attempt's share scan waits for that scan to
finish; the scan cannot be interrupted.
A caller that gives up on a login had no way to reach the client: the
thread that owns it sits in login() for up to 45 seconds, and disconnect()
needs the client to itself. Until that thread returned, the abandoned
client kept connecting and still wrote its login frame the moment the
server finished the TCP handshake. When that happened after a replacement
had logged in under the same name, the server kicked the replacement.
Client::cancel_handle() hands out a CancelHandle that can be moved to any
thread. After cancel():
- the server actor writes nothing more. The check and the write share a
lock with cancel(), so once cancel() returns no frame leaves, whether it
was waiting in the queue, in the dispatcher channel, or mid-flush;
- login() returns NotConnected within 100 ms, and hands no login to the
actor at all when the client was already cancelled;
- connect() refuses before binding the listener when the client was
already cancelled. A cancel that lands during that check can still let
the bind through.
Either way the owner drops the client as before, which disconnects it and
gives its port back.
A frame already written before cancel() returned cannot be recalled. The
API is additive, and login() on a client nobody cancels behaves as before.
Soulseek.NET tears the whole connection down when ConnectAsync is
cancelled, and Nicotine+ drops outgoing messages for a socket it closed;
this gives soulseek-rs the same end state from another thread.
disconnect() told the listener thread to stop and dialled it awake, then
returned while that thread was still unwinding, so the TcpListener was
often still open when the caller moved on. A client built straight after
a drop then found its configured port taken and fell back to an ephemeral
one. The TUI login screen does exactly that when it replaces an attempt.
The listener thread now raises a flag once it has dropped its socket, and
after the wake-up dial has landed, disconnect() waits for that flag,
bounded at two seconds, so the port can be bound again by the time
disconnect() or Drop returns. A listener that cannot be woken, or is held
past the bound by a full handshake queue, finishes on its own as before.
The flag is an Arc<AtomicBool>, like the stop flag beside it: keeping the
thread's JoinHandle instead would leave Client neither UnwindSafe nor
RefUnwindSafe, which is a breaking change for the library.
Nicotine+ and Soulseek.NET both close the listen socket as part of
disconnecting; this makes that hold by the time the call returns.
`Client::set_download_speed_limit(bytes_per_second)` puts one ceiling on
every download at once. Zero lifts it and is the default. It can be
changed at any time, and a change reaches transfers already running
within one 100 ms tick.
The limiter works the way Soulseek.NET's does, which slskd uses for its
own download limit: one token bucket shared by all downloads, refilled
to a tenth of a second's worth every 100 ms, handing out partial grants
and taking back what a short read or a timeout did not use. A download
is paced by reading less from the socket. The unread bytes stay in the
kernel's receive buffer and TCP flow control slows the uploader; no
protocol message is involved, as in Nicotine+ too.
With no cap set, each read costs one atomic load. A same-hour stress
run scored develop 88.98/88.98 and this branch 89.00/88.99/88.97, at
about 133 MiB/s both.
An upload ceiling is left out, as before.
The login screen ignored every key while a connect+login attempt ran, so
against an unreachable server it sat on a static "Connecting…" for 20s or
more, with a hint promising that Esc would quit.
While connecting it now shows a spinner: Enter or r restarts the attempt,
Esc stops it and returns to the form, q or Ctrl-C quits. Ctrl-C quits from
every state of the screen instead of typing a "c" into a field.
A failure that got no verdict (unreachable, timed out) keeps the password,
so Enter or r retries it; only a rejection, Ok(false) or
AuthenticationFailed, clears it. Because a retry can now reuse a password
that came from the CLI, env or keychain, entered_via_form compares the
credentials with the ones the flow started with instead of trusting any
submit, so a retried stored password is never written to the keychain.
A restart of an attempt younger than a second is ignored: each attempt
rescans the shares and opens a listener, and a held Enter would otherwise
start one per key repeat. The attempt result is read before drawing, so a
key is never handled against a phase the screen did not show.
A stopped or restarted attempt is abandoned, not cancelled: the server
actor blocks its own mailbox in connect_timeout, so a stop could not reach
it sooner. Its thread runs out its timeouts and drops the client, which
disconnects. Until then it holds the listener port, so a restarted session
can fall back to an ephemeral port, and should a slow server accept the
abandoned login after its replacement got in, the late login displaces the
new session. Fixing either needs a cancellable connect, or a bind after the
login verdict, in the library.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.