CI's Lint job runs current stable, which gained two lints that failed
develop: `redundant_else` in `Client::login`, now written with early
returns, and `assert_is_empty`, which asks every `assert!(x.is_empty())`
in the tests to become `assert_eq!(x, [] as [T; 0])`. The second trades a
clear check for a type-cast chore, so it joins the curated allow-list.
An accepted upload only ever dialled the downloader at the address the
server reported. Most downloaders sit behind a NAT, so the dial timed out
after 20s ("serve …: connection timed out") or, with port 0, the upload
sat in pending_serves forever. There was no fallback: an inbound
PierceFirewall was always treated as a P control connection.
Do what the reference clients do. When an upload starts, ask the server to
have the downloader connect back (ConnectToPeer type F) and dial them at
the same time; the first socket to arrive claims the transfer. Over the
pierced socket only the bare 4-byte transfer token precedes the offset
exchange. CantConnectToPeer and the brokered-connect timeout fail the
upload and free its slot; port 0 skips the dial instead of parking the job.
Covered by an e2e test where the leecher runs without a listener.
Closes#102
Move the sanitizer next to the directory helpers so the CLI download
command and the daemon honour use_username_dirs too, not only the TUI.
Resumed downloads keep the folder they were saved with instead of
nesting the username a second time, and toggling the setting applies
to the next download without a restart. Document the config key.
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.