Compare commits

...
576 Commits
Author SHA1 Message Date
dumbgoy e20cb7b565 Merge modified Soulseek project into main 2026-10-27 10:08:25 -04:00
dumbgoy 0272512234 Document Gitea setup for Linux and Windows 2026-10-27 10:03:54 -04:00
dumbgoy 1f0b541bdb Add containing-folder download shortcut to search results 2026-10-27 09:55:24 -04:00
Michel de Graaf 25fa5884ed Merge pull request #104 from michel/michel/chore/clippy-redundant-else
chore: satisfy clippy 1.99
2026-10-26 17:17:22 +01:00
Michel de Graaf 727e2bc8db chore: satisfy clippy 1.99
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.
2026-10-26 17:16:18 +01:00
Michel de Graaf ae2cc57c30 Merge pull request #103 from michel/michel/fix/brokered-upload-connection
fix(lib): serve uploads to downloaders we cannot dial
2026-10-26 17:08:28 +01:00
Michel de Graaf 3205611609 fix(lib): serve uploads to downloaders we cannot dial
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
2026-10-26 17:03:23 +01:00
Michel de Graaf 68bc3067c3 Merge pull request #101 from MaikSteiger/develop
feat: save downloads in per-user folders (use_username_dirs). Thanks @MaikSteiger.
2026-10-22 14:19:42 +02:00
Michel de Graaf ea884535a8 fix(tui): apply username folders everywhere and resume in place
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.
2026-10-22 14:19:18 +02:00
Maik Steiger 9805b807e5 fix(tui): removed a ton of use_username_dirs references
feat(tui): username is now appended to download_dir before passed as parameter
fix(tui): username is sanitized before appended to path
2026-10-09 13:33:22 +02:00
Maik Steiger 5e979a7425 test(tui): cover username directories setting and regenerate openrpc doc 2026-10-08 22:07:13 +02:00
Maik Steiger e567fa75ed feat(tui): added option for storing downloaded files in username directories 2026-10-08 21:59:23 +02:00
Michel de Graaf 20bd676e0f merge: back-merge v19.1.0 into develop 2026-09-21 12:35:52 +00:00
Michel de Graaf 093866f008 Merge pull request #100 from michel/release-plz-2026-09-21T11-48-51Z
chore: release v19.1.0
2026-09-21 14:11:43 +02:00
Michel de Graaf dc39e24ad7 chore: release v19.1.0 2026-09-21 13:48:52 +02:00
Michel de Graaf d260537d22 Merge pull request #99 from michel/develop
release: Linux packages, cancellable login, download speed cap
2026-09-21 13:47:18 +02:00
Michel de Graaf ad66e1f0e0 Merge pull request #98 from michel/michel/feat/linux-packaging
feat: a .deb, an .rpm and a Nix flake for Linux, each with a nightly
2026-09-21 13:22:07 +02:00
Michel de Graaf b6b4ec04c1 docs: install by system, stable and nightly 2026-09-21 12:58:09 +02:00
Michel de Graaf 4c5a808762 build(nix): add a flake
nix run github:michel/soulseek-rs/master builds the release branch and
/develop the nightly. The lock is not optional: without one nix refuses
a github: reference outright and hides the commit from the build, so
the version stamp read +dirty.
2026-09-21 12:58:09 +02:00
Michel de Graaf f09d550e4b ci: attach .deb and .rpm packages to releases and nightlies
Built by nfpm from the static musl binaries, with the man page and the
bash, zsh and fish completions. The names carry no version, so
releases/latest/download/soulseek-rs-amd64.deb stays a working link; the
nightly pair replaces itself on the rolling prerelease.
2026-09-21 12:58:09 +02:00
Michel de Graaf 645d24e29d ci(nightly): stamp each build with its commit
The version reads 19.0.0+git<commit time, UTC>.<sha>: later than the
release it follows, earlier than the next, and ordered among nightlies.
2026-09-21 12:41:22 +02:00
Michel de Graaf 14491768a5 feat(cli): print completion scripts and the man page to stdout
`completions install` writes into a home directory, which a package
build does not have. `completions print <shell>` and `man` give it the
files to put under /usr/share instead.
2026-09-21 12:15:43 +02:00
Michel de Graaf d71172542c feat(cli): let a build name its own version
A nightly printed the same 19.0.0 as the release it followed, so a bug
report could not say which one it came from. SOULSEEK_RS_VERSION at
compile time replaces the crate version in --version, the TUI and the
daemon status.
2026-09-21 12:15:43 +02:00
Michel de Graaf 0b97a45d8b build: declare Rust 1.91 as the minimum version
Duration::from_mins needs it, and without the field cargo on an older
toolchain fails on the edition instead of naming the version it wants.
2026-09-21 12:15:43 +02:00
Michel de Graaf deb846be14 feat(tui): say so in the status bar when the listener took another port
With the login screen's own attempts no longer holding the port, a
session listens somewhere other than the configured port mainly when
another program already holds it. soulseek-rs still falls back to an
ephemeral port there, which is what lets several clients on one machine
share a configured port, and it advertises the port it really bound. A
manual port forward on the router then points at nothing, though, and
the TUI never said so; the CLI already warns.

After login the TUI compares the bound port with the configured one,
using the CLI's rule, and the status bar's title reads "port X in use,
listening on Y" for the rest of the session. A window attached to a
daemon shows nothing: the daemon owns its listener.

Nicotine+ keeps retrying the configured port every five seconds and
Soulseek.NET refuses it; neither falls back. Keeping the fallback and
making it visible is a deliberate difference.
2026-09-14 13:44:57 +02:00
Michel de Graaf bd502c216a fix(tui): cancel a replaced login attempt and wait for it to let go
Stopping or restarting an attempt used to drop only the channel its
result would arrive on. The thread kept its client for up to 45 seconds:
that client held the configured listener port, so the replacement fell
back to an ephemeral one, and a slow server could still accept the old
login after the new one and kick the new session.

Each attempt now carries its client's CancelHandle. Esc cancels it on the
spot. A restart cancels the attempt it replaces on the UI thread, and the
new attempt's thread starts by joining the old one, which has dropped its
client, and with it the port, by the time it ends. A result the old
attempt finished with but nobody collected is dropped before the new one
connects. An attempt replaced before its thread got going skips
connecting altogether, and a result that lands after a cancel is thrown
away instead of handed over. Restarts in a row chain through the joins,
so at most one client is connected or holds the port at a time, which is
what Soulseek.NET's connection lock and Nicotine+'s single server
connection guarantee too.

A result is acted on only while the screen shows Connecting. One that
lands after Esc, even a successful login, is dropped on the spot, so no
stray session stays online until the next submit. The one-second restart
floor applies only to restarting an attempt in flight, since a kept
attempt no longer means one is running.

A restart during the old attempt's share scan waits for that scan to
finish; the scan cannot be interrupted.
2026-09-14 13:44:57 +02:00
Michel de Graaf c1a80b0d30 feat(lib): let another thread cancel a client's connect and login
A caller that gives up on a login had no way to reach the client: the
thread that owns it sits in login() for up to 45 seconds, and disconnect()
needs the client to itself. Until that thread returned, the abandoned
client kept connecting and still wrote its login frame the moment the
server finished the TCP handshake. When that happened after a replacement
had logged in under the same name, the server kicked the replacement.

Client::cancel_handle() hands out a CancelHandle that can be moved to any
thread. After cancel():
- the server actor writes nothing more. The check and the write share a
  lock with cancel(), so once cancel() returns no frame leaves, whether it
  was waiting in the queue, in the dispatcher channel, or mid-flush;
- login() returns NotConnected within 100 ms, and hands no login to the
  actor at all when the client was already cancelled;
- connect() refuses before binding the listener when the client was
  already cancelled. A cancel that lands during that check can still let
  the bind through.
Either way the owner drops the client as before, which disconnects it and
gives its port back.

A frame already written before cancel() returned cannot be recalled. The
API is additive, and login() on a client nobody cancels behaves as before.

Soulseek.NET tears the whole connection down when ConnectAsync is
cancelled, and Nicotine+ drops outgoing messages for a socket it closed;
this gives soulseek-rs the same end state from another thread.
2026-09-14 13:44:57 +02:00
Michel de Graaf 8cbee3203f fix(lib): give the listener port back before a dropped client returns
disconnect() told the listener thread to stop and dialled it awake, then
returned while that thread was still unwinding, so the TcpListener was
often still open when the caller moved on. A client built straight after
a drop then found its configured port taken and fell back to an ephemeral
one. The TUI login screen does exactly that when it replaces an attempt.

The listener thread now raises a flag once it has dropped its socket, and
after the wake-up dial has landed, disconnect() waits for that flag,
bounded at two seconds, so the port can be bound again by the time
disconnect() or Drop returns. A listener that cannot be woken, or is held
past the bound by a full handshake queue, finishes on its own as before.
The flag is an Arc<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.
2026-09-14 13:40:34 +02:00
Michel de Graaf 0255f6ad43 feat(lib): cap the combined download speed
`Client::set_download_speed_limit(bytes_per_second)` puts one ceiling on
every download at once. Zero lifts it and is the default. It can be
changed at any time, and a change reaches transfers already running
within one 100 ms tick.

The limiter works the way Soulseek.NET's does, which slskd uses for its
own download limit: one token bucket shared by all downloads, refilled
to a tenth of a second's worth every 100 ms, handing out partial grants
and taking back what a short read or a timeout did not use. A download
is paced by reading less from the socket. The unread bytes stay in the
kernel's receive buffer and TCP flow control slows the uploader; no
protocol message is involved, as in Nicotine+ too.

With no cap set, each read costs one atomic load. A same-hour stress
run scored develop 88.98/88.98 and this branch 89.00/88.99/88.97, at
about 133 MiB/s both.

An upload ceiling is left out, as before.
2026-09-13 21:32:05 +02:00
Michel de Graaf 18b53aa4ba fix(tui): keep the login screen responsive while connecting
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.
2026-09-13 10:13:38 +02:00
Michel de Graaf be92fd8b3a Merge pull request #97 from michel/test/event-synced-e2e
test: wait on events instead of timers in the flaky tests
2026-09-10 15:48:22 +02:00
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 43d41ddb79 merge: back-merge v19.0.0 into develop 2026-09-10 12:29:03 +00: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 d6ef0e7c07 Merge branch 'develop' 2026-09-10 14:04:12 +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 f9d4d0e4cc Merge pull request #94 from michel/release-plz-2026-09-10T10-16-12Z
chore: release v19.0.0
2026-09-10 12:19:26 +02:00
Michel de Graaf 6a650d8c3e chore: release v19.0.0 2026-09-10 12:16:13 +02:00
Michel de Graaf c21035974a Merge branch 'develop' 2026-09-10 12:14:13 +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
Michel de Graaf 242b7da51c Merge pull request #78 from michel/release-plz-2026-09-07T16-04-21Z 2026-09-07 18:05:50 +02:00
Michel de Graaf 943abfab43 chore: release v17.0.0 2026-09-07 18:04:21 +02:00
Michel de Graaf adf5bc906e Merge branch 'develop' 2026-09-07 18:02:27 +02:00
Michel de Graaf cbf7813cf6 Merge pull request #77 from michel/lib/send-upload-speed
feat(lib): report each finished upload's rate to the server
2026-09-07 18:02:23 +02:00
Michel de Graaf dff19370d5 feat(lib): report each finished upload's rate to the server
The speed other users see for us in `user` lookups and in the server's
own ranking comes from SendUploadSpeed (code 121), which the client
never sent, so it read zero however much we served. Each completed
upload now reports its measured bytes per second, as Nicotine+ and
slskd do.
2026-09-07 18:02:00 +02:00
Michel de Graaf b5dcef589c refactor(lib): split the server actor's value types and tests out
server_actor.rs had grown past twelve hundred lines, a fifth of them
tests and a hundred the value types it hands out. The types now live in
server_actor/types.rs and the tests in server_actor/tests.rs, as the
client's already do, and the room and user traffic in handle_message
moves to its own dispatch step beside the wishlist one. No behaviour
changes; the public paths are re-exported as before.
2026-09-07 17:38:59 +02:00
Michel de Graaf 8be1102eff Merge pull request #76 from michel/lib/log-level
fix(lib): keep the log level in an atomic and let hosts change it
2026-09-07 17:32:25 +02:00
Michel de Graaf c08de74e72 fix(lib): keep the log level in an atomic and let hosts change it
The level lived in a `static mut` read from every logging thread and
written by `init`, an unsynchronised access the compiler only tolerates
inside `unsafe`. It is an atomic now, read without `unsafe`, and a host
can raise or lower it while running through `set_log_level`.
2026-09-07 17:30:35 +02:00
Michel de Graaf 1e3412efcb Merge pull request #75 from michel/lib/admin-message
feat(lib): surface server announcements as messages from "server"
2026-09-07 17:26:30 +02:00
Michel de Graaf 0b9fdaf58e Update README description for clarity and detail
(cherry picked from commit 6d47b3e594)
2026-09-07 17:17:57 +02:00
Michel de Graaf fd7bab3050 docs: add a contributing guide and link it from the README and site
Nothing in the repo said that pull requests go to develop rather than master,
or that release-plz reads the commit subject to pick the next version. Both
cost a first-time contributor a round trip.

CONTRIBUTING.md covers those, the three checks CI runs, and the fact that the
e2e suites skip without a soulfind server. The README and the community page
link to it, and it joins the footer's Project column.
2026-09-07 17:17:57 +02:00
Michel de Graaf e4ce85ef2b feat(lib): surface server announcements as messages from "server"
An AdminMessage (server code 66) is how a server tells everyone it is
going down, and the client dropped it on the floor. It now arrives in
the private-message inbox as a message from the user "server", which is
also the name the server's own command replies already come from, so
every reader of that inbox shows it without a new API.
2026-09-07 17:14:54 +02:00
Michel de Graaf 2f879242d5 Merge pull request #74 from michel/lib/zlib-compress
fix(lib): compress peer listings and reject a bad zlib checksum
2026-09-07 17:08:09 +02:00
Michel de Graaf 8329395c92 fix(lib): compress peer listings and reject a bad zlib checksum
A browse or folder listing and a search reply went out as zlib "stored"
blocks, so a big share cost every browser its full uncompressed size,
and the inflater ignored the adler32 trailer, so corrupt data passed.
Listings now deflate through a fixed-Huffman encoder over an LZ77 match
finder, which shrinks a listing's repeated path prefixes to about a
tenth, and a stream whose trailer does not match is refused. No crate:
the encoder is a hundred lines beside the inflater it feeds, and the
inflater is named for what it does.
2026-09-07 16:59:14 +02:00
Michel de Graaf 6d47b3e594 Update README description for clarity and detail 2026-09-07 16:54:22 +02:00
Michel de Graaf ee60d47d88 Merge pull request #73 from michel/lib/share-search-key
perf(lib): lowercase each shared path once at scan time
2026-09-07 16:46:30 +02:00
Michel de Graaf a9c68a29ca perf(lib): lowercase each shared path once at scan time
Answering a search lowercased every shared path again for every query:
with a large share and the steady stream of searches the server forwards,
that is an allocation per file per search. The lowercase key is now
computed once when the share is scanned.
2026-09-07 16:25:42 +02:00
Michel de Graaf 833542377b Merge pull request #72 from michel/lib/download-read-buffer
perf(lib): read downloads in 64 KiB slices
2026-09-07 16:24:52 +02:00
Michel de Graaf d0550b8461 perf(lib): read downloads in 64 KiB slices
Downloads read the peer socket 8 KiB at a time, eight syscalls where one
now does; the reference clients read 50 to 256 KiB. Progress was reported
every fifteen reads, a cadence that stops meaning anything once a read
can be anywhere from a few bytes to 64 KiB, so a report now lands every
128 KiB received instead: the same volume as before for a fast peer, and
a window wide enough for a slow peer's rate to mean something.
2026-09-07 16:17:42 +02:00
Michel de Graaf 63c2cf07f4 Merge pull request #71 from michel/lib/download-speed
fix(lib): report a download's rate from the bytes actually read
2026-09-07 15:49:42 +02:00
Michel de Graaf 196fdbcf71 fix(lib): report a download's rate from the bytes actually read
Progress assumed every read had filled the buffer, so a peer trickling
small chunks was reported at up to several times its real rate. The
rate now comes from the bytes received since the last report.
2026-09-07 15:47:22 +02:00
Michel de Graaf 761eb7d6a1 Merge pull request #70 from michel/lib/server-keepalive
fix(lib): keep the server connection alive with TCP keepalive
2026-09-07 15:39:05 +02:00
Michel de Graaf 662d40b79c fix(lib): keep the server connection alive with TCP keepalive
A NAT that drops an idle mapping leaves the server socket looking
connected while the server can no longer reach us, and nothing errors:
peers stop finding us and searches go unanswered until a restart. The
server socket now carries TCP keepalive (idle 10s, interval 2s, ten
probes), which keeps the mapping alive and turns a dead path into a read
error within about half a minute, as Nicotine+ does. The server stopped
answering ServerPing (code 32) long ago, so an application ping is no
substitute. Windows gets a no-op until the daemon ships there.
2026-09-07 15:39:00 +02:00
Michel de Graaf 7bb08fd66d Merge pull request #69 from michel/lib/user-info
feat(lib): answer UserInfoRequest with our slots and queue
2026-09-07 15:32:57 +02:00
Michel de Graaf 73208ae927 feat(lib): answer UserInfoRequest with our slots and queue
Opening a user's profile in SoulseekQt or Nicotine+ asks that peer
directly (peer code 15), and a peer that never answers shows an endless
spinner. The reply (code 16) carries no description or picture, the
configured upload slot count, how many uploads are waiting, and whether a
slot is free right now, which is what the reference clients send.
2026-09-07 15:32:30 +02:00
Michel de Graaf 567e942252 Merge pull request #67 from michel/test/stress-mock-bind
test(stress): bind the mock peers on all interfaces
2026-09-07 15:32:00 +02:00
Michel de Graaf fe9631e264 Merge pull request #68 from michel/lib/search-reply-cap-300
fix(lib): raise the search reply cap to 300 files
2026-09-07 15:29:06 +02:00
Michel de Graaf e5d240a708 fix(lib): raise the search reply cap to 300 files
The cap of 150 was attributed to Nicotine+, whose default is in fact
300 (slskd's is 500). A reply now carries up to 300 matches.
2026-09-07 15:27:01 +02:00
Michel de Graaf ce621e0d0d test(stress): bind the mock peers on all interfaces
soulfind has reported the host's LAN address for a loopback login since
1eab2e8 (November 2025), so the client dials the mocks there and a
listener bound to 127.0.0.1 refuses it: every upload and most downloads
failed against a current soulfind while the e2e suite, whose mocks
already bind all interfaces, stayed green.
2026-09-07 15:24:55 +02:00
Michel de Graaf 7c4d9e2c9a Merge pull request #66 from michel/lib/folder-contents
feat(lib): answer FolderContentsRequest with the folder's listing
2026-09-07 15:24:34 +02:00
Michel de Graaf 4839e36be8 feat(lib): answer FolderContentsRequest with the folder's listing
"Download folder" in SoulseekQt and Nicotine+ asks the sharer for that
one folder (peer code 36) instead of the whole share, and a sharer that
never answers leaves the folder download hanging. The requested folder
now comes back as a FolderContentsResponse (code 37), which carries a
directory listing in the same form a browse does, so the two share one
codec. The browse and folder replies also share one reply helper.
2026-09-07 15:21:42 +02:00
Michel de Graaf 53b32e0201 Merge pull request #65 from michel/lib/honest-search-reply
fix(lib): advertise real slot, speed and queue figures in search replies
2026-09-07 15:16:33 +02:00
Michel de Graaf 352816cc1e fix(lib): advertise real slot, speed and queue figures in search replies
Every reply to a peer's search claimed a free upload slot, no speed and
an empty queue, whatever the sharer was doing, and searchers rank
sources on exactly those fields. A reply now says whether a slot is
free, the bytes per second of the last completed upload, and how many
uploads are waiting.
2026-09-07 15:16:20 +02:00
Michel de Graaf 049deee5a7 Merge pull request #64 from michel/lib/u32-message-codes
fix(lib): route on the full four-byte message code
2026-09-07 15:06:26 +02:00
Michel de Graaf f1aa94fecc fix(lib): route on the full four-byte message code
Dispatch read one byte of the code, so a code with a non-zero high byte
was handled as its low byte. Message::get_message_code now reads the
whole little-endian uint32 and handlers register by u32. PeerInit and
PierceFirewall, the only frames with a one-byte code, get their own
accessor for the listener's first frame.
2026-09-07 15:03:49 +02:00
Michel de Graaf 40a8eb1dd5 Merge pull request #63 from michel/lib/search-tokens
fix(lib): give each search its own token
2026-09-07 14:52:34 +02:00
Michel de Graaf 0458b24898 fix(lib): give each search its own token
A search token was the first five hex digits of the query's MD5, so two
queries could share one, and a peer's answer to either landed in whichever
search the map handed back first. Tokens now come from a counter, which
no two live searches can share.

The e2e test uses two queries that collide under the old scheme.
2026-09-07 14:52:25 +02:00
Michel de Graaf a99e848eda Merge pull request #62 from michel/lib/search-reply-cap
fix(lib): cap a search reply at 150 files
2026-09-07 14:46:32 +02:00
Michel de Graaf b9d3d7fcd5 fix(lib): cap a search reply at 150 files
A broad query against a big share was answered with every match, so one
search could hand the whole index to a stranger, and every such search
did it again. A reply now carries at most MAX_SEARCH_REPLY_FILES (150,
Nicotine+'s default), which is more sources than any searcher scrolls
through.
2026-09-07 14:44:16 +02:00
Michel de Graaf 114e807071 Merge pull request #61 from michel/release-plz-2026-09-07T09-13-51Z
chore: release v16.0.0
2026-09-07 11:17:31 +02:00
Michel de Graaf 4781e3571a chore: release v16.0.0 2026-09-07 11:13:52 +02:00
Michel de Graaf e0470afde9 Merge remote-tracking branch 'origin/develop' 2026-09-07 09:12:18 +00:00
Michel de Graaf ef563fbb7e Merge pull request #60 from michel/feat/tui-pane-layout
feat(tui): results across the top, with panes you can hide, zoom and Tab through
2026-09-07 11:11:56 +02:00
Michel de Graaf d4e86187d2 refactor(tui): fold the keys overlay onto LogView and dedupe the pane lookups
The keys overlay scrolled with its own counter, a fixed page of ten and a
usize::MAX sentinel that the renderer clamped back; it now reads through
the same LogView as the chat and room logs, so the page is the window it
last drew and the half-page keys work there too.

One pane_area lookup and one page_of helper replace the pane-to-area match
that page_size, highlighted_name_end and the mouse hit test each repeated,
and list_jump takes its page as a usize so the callers stop converting.
The pane number keys are one range arm over FocusedPane::ALL, whose index
is the declaration order. The row layout carries each pane's weight beside
it instead of re-matching with an unreachable arm, and the help columns are
measured from the lines already built rather than built again for their
width. The shortcut legend borrows its static keys instead of allocating
three strings per entry per frame.
2026-09-07 10:51:41 +02:00
Michel de Graaf 478d976776 feat(tui): scroll long names in every list and page through popups and chat
The sideways keys the results had (h/l, arrows, 0 and $) now scroll a
transfer's file name and a query too, each list on its own. The browse tree
and the room list take Home/End, PgUp/PgDn and the vim jumps like the main
lists, and a chat log, room or private, pages back through its history and
holds its place while new messages arrive; what arrives meanwhile counts as
unread until the reader returns to the end.
2026-09-07 08:16:20 +00:00
Michel de Graaf da8ab45303 feat(tui): results across the top, with panes you can hide, zoom and Tab through
The results pane now has the whole width, where a long file name needs it,
and Searches, Downloads and Info share the row underneath. Tab and Shift-Tab
walk the panes, z zooms the focused one to the window, w hides it so the
others widen, and its number brings it back. Every list takes the same
navigation keys, the shortcuts bar wraps instead of cutting keys off, and ?
lists every key by pane, scrolling on a short terminal.

Follows up on #55.
2026-09-07 07:49:32 +00:00
Michel de Graaf 9f78e93105 Merge pull request #58 from michel/release-plz-2026-09-06T20-01-41Z
chore: release v15.0.0
2026-09-06 22:22:29 +02:00
Michel de Graaf d397e92085 chore: release v15.0.0 2026-09-06 22:01:42 +02:00
Michel de Graaf 0c6976dff7 Merge branch 'develop' 2026-09-06 22:00:23 +02:00
Michel de Graaf c91b97de7c Merge pull request #56 from michel/feat/tui-results-navigation
feat(tui): scroll long result names and page through results
2026-09-06 21:53:57 +02:00
Michel de Graaf 426639719c Merge branch 'develop' into feat/tui-results-navigation 2026-09-06 21:53:36 +02:00
Michel de Graaf 06bc48fcd9 perf(tui): bound the name scroll walk by the column width
Scrolled rows cost 31.7ms per frame at 30k results against 13.3ms on
develop, because every row segmented its whole filename into grapheme
clusters. Walk back from the end for at most one column of cells and
forward for at most the offset, so a row costs the column width rather
than the filename, and drop unicode-truncate for unicode-width. A tail
never starts on a combining mark.
2026-09-06 21:42:36 +02:00
Michel de Graaf 285221bbb1 Merge pull request #57 from michel/feat/cancel-download
feat: cancel a download from the TUI, the CLI, and the daemon
2026-09-06 21:40:58 +02:00
Michel de Graaf aa61a1a434 feat: cancel a download from the TUI, the CLI, and the daemon
Closes #54.

A download can now be cancelled whether it is queued, in progress, or
paused. Cancelled is a terminal status: the store refuses to overwrite
it, the status channel relays nothing after it, the partial file is
deleted, and the transfer thread releases its socket at its next
one-second poll instead of the 30s read deadline. A queued download that
is cancelled declines the peer's later offer with TransferResponse
"Cancelled", and an inbound file connection for it is refused. A fresh
download of the same file evicts the dead row so it is not shadowed,
and a cancelled transfer is not re-queued after a restart.

Surfaces: Client::cancel_download, download.cancel over RPC, `transfer
cancel <user> <path>` on the CLI (which also stops an upload by that
name), and x in the transfers pane, where d dismisses the row.
2026-09-06 21:40:45 +02:00
Michel de Graaf 3193c73f5b feat(tui): scroll long result names and page through results
Closes #55.

While [2] Results is focused the Info pane describes the highlighted
result, so a truncated path can be read in full. h/l (or the arrow keys)
scroll the filename column by display cells, 0 and $ jump to either end
of the highlighted name, and Home/End, g/G, PgUp/PgDn and Ctrl-f/b/d/u
move through the list; the same keys work while a filter is being typed.

Ctrl chords are no longer read as their bare letter: outside [2] they do
nothing, where before Ctrl-d deleted a search and Ctrl-c opened rooms.
Space after a filter that matches nothing no longer indexes an empty
list.
2026-09-06 20:10:59 +02:00
Michel de Graaf 1b9773bc1c Merge pull request #53 from michel/release-plz-2026-09-03T09-02-37Z
chore: release v14.2.1
2026-09-03 11:22:17 +02:00
Michel de Graaf 28325685df chore: release v14.2.1 2026-09-03 11:02:38 +02:00
Michel de Graaf 13f19bbc61 Merge branch 'develop' 2026-09-03 11:00:50 +02:00
Michel de Graaf 7bc696cab6 Merge pull request #52 from michel/daemon-reconnect
fix(daemon): reconnect after the server session drops
2026-09-02 14:45:50 +02:00
Michel de Graaf 67d979b809 fix(daemon): reconnect after the server session drops
A long-running daemon that lost its TCP connection to the server reported
session_loss "disconnected" and answered every command from the dead session
until a manual restart. Two causes:

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

Reconnect on a dedicated thread with 1s-60s capped backoff, only for a dropped
connection to a session this process owns (never a displaced or borrowed one,
which would start a login war or fight another daemon). Let a Login through the
disconnected guard so a parked actor dials again, and bound that dial with
connect_timeout so a blackholed route cannot park the actor thread. Reset the
message reader, the queued backlog and the login verdict on disconnect so a
partial frame or stale Some(false) cannot wedge the reconnected session.
Announce the drop once per outage and re-join rooms and re-watch users on
recovery; the daemon main loop is back to save-and-stop so `daemon stop` stays
responsive during an outage. Update the session_loss event summary, which no
longer means the session is gone for good.
2026-09-02 11:21:19 +02:00
Michel de Graaf 1932e7cd28 Merge pull request #51 from michel/release-plz-2026-09-01T13-26-37Z
chore: release v14.2.0
2026-09-01 16:29:59 +02:00
Michel de Graaf daec81de50 chore: release v14.2.0 2026-09-01 15:26:38 +02:00
Michel de Graaf 257083663e Merge branch 'develop' 2026-09-01 15:25:19 +02:00
Michel de Graaf 597f378f17 docs(website): drop soulfind server link from footer 2026-08-29 15:45:30 +02:00
Michel de Graaf d30d9fdf75 docs(website): move project links into LINKS 2026-08-29 15:45:30 +02:00
Michel de Graaf a9f4ab5e5b docs(website): list Player and Staccato, fix install pill overflow on mobile 2026-08-29 15:45:30 +02:00
Michel de Graaf 693926434d chore: release v14.1.2 2026-08-29 15:45:30 +02:00
Michel de Graaf e53b39cca7 Merge pull request #50 from michel/feat/room-member-stats
feat: per-member room statistics from the JoinRoom reply
2026-08-28 17:38:44 +02:00
Michel de Graaf 0ba00c98d7 Merge remote-tracking branch 'origin/develop' into feat/room-member-stats
# Conflicts:
#	soulseek-rs-lib/src/client/tests.rs
#	soulseek-rs-lib/tests/e2e.rs
#	soulseek-rs/src/daemon/openrpc.rs
#	soulseek-rs/src/daemon/proto/method.rs
#	soulseek-rs/src/daemon/server/mod.rs
#	soulseek-rs/src/remote/mod.rs
2026-08-28 15:18:37 +00:00
Michel de Graaf c704c8a59e Merge pull request #49 from michel/feat/watch-user
feat: watch and unwatch users (WatchUser 5 / UnwatchUser 6)
2026-08-28 17:04:40 +02:00
Michel de Graaf 62c99e4ab8 fix(cli): keep room users text output a bare roster
The e2e job caught this: adding status and file columns to the text record
broke `room_users_lists_who_is_in_the_room`, which asserts the line equals
the username. That test encodes a real contract -- the text output is a
roster to pipe into other commands -- and reformatting it was scope this
change did not need. The statistics stay on the record for `--json`.

Reproduced the failure locally against soulfind, then confirmed the same
test passing with the fix.
2026-08-28 14:55:02 +00:00
Michel de Graaf 9c1a6d317e test(e2e): cover watching against soulfind, and read the reply as written
The e2e job proved nothing about watching: it passed because the suite has
no test that exercises it. Two now run against a real soulfind.

- a watch reply carries status and share statistics in one message, checked
  against a subject sharing a known two files, and unwatching drops both the
  watch and the snapshot
- a username the server does not know is answered with an absence flag and
  must not stay in the watch list

Also reads the two obsolete fields of the reply as the two 32-bit values the
server writes, matching soulfind and the sibling GetUserStats (36) handler.
The byte count is identical, so this is not a behaviour change -- but the
handler now says what the wire actually carries.
2026-08-28 14:42:44 +00:00
Michel de Graaf 35a6fed3c3 fix: slotsfull is occupancy, not free slots, and cover it end to end
Running the e2e suite against a real soulfind showed the per-member slots
field was implemented backwards. The wire field is `slotsfull` -- a boolean
that is true when the member's upload slots are all occupied -- not a count
of free ones. `slots_free: u32` named it, documented it and printed it as
the opposite of what the server means, so a caller reading it would pick the
busiest peer to download from.

- types: `slots_full: bool`, with the wire name in the doc comment
- the DTO, the `room users` output and SKILL.md follow
- a new e2e test joins a room with a member sharing a known three files and
  asserts the counts, the roster alignment and the slot flag against
  soulfind, so the parsing is checked against a real server rather than only
  against frames this repo wrote itself
2026-08-28 14:39:34 +00:00
Michel de Graaf 60f7100e30 feat(daemon): publish room member statistics over RPC and the CLI
The statistics parsed from the JoinRoom reply were reachable only from the
library. They answer the question `room users` already exists to ask -- who
is in here, and are they worth downloading from -- so they belong on its
output rather than behind a second call.

- proto: room.member_stats, with a RoomUserStatsDto carrying status, share
  counts, free slots and country
- SessionApi::room_member_stats, so local and remote sessions stay
  interchangeable; the status word is decoded through one shared helper
  rather than a third copy of the match
- `room users` now emits status, average_speed, shared_files,
  shared_folders, slots_free and country, each null when the server said
  nothing, documented in SKILL.md
- docs/openrpc.json regenerated from the wire types
2026-08-28 13:27:09 +00:00
Michel de Graaf a91d35b672 feat(daemon): expose watching over the RPC surface and the CLI
The watch subscription was reachable only from the library. It is most
useful against a daemon -- that is the session that stays connected long
enough to receive the server's status pushes -- so it needs the wire.

- proto: user.watch / user.unwatch / user.watched, with a Watched response
- SessionApi gains watch_user / unwatch_user / watched_users, so a local and
  a remote session are still interchangeable above the seam
- CLI: `watch add`, `watch remove`, `watch list`, documented in SKILL.md
  including the caveat that a one-shot run's watch ends with the run
- docs/openrpc.json regenerated from the wire types
2026-08-28 13:22:50 +00:00
Michel de Graaf b505718db9 feat(lib): watch and unwatch users (WatchUser 5 / UnwatchUser 6)
Adds the server-side watch subscription a buddy list needs: WatchUser (5)
asks the server to keep pushing a user's status, UnwatchUser (6) cancels it.
The reply to a watch carries the user's current status and share statistics,
which merge into the existing per-user snapshot read via Client::user_info.

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

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

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

- join_room.rs: parse the stat vectors, each bounded by the payload so a
  hostile count cannot hang or overallocate
- types::RoomUserStats; every field looked up positionally so a server
  sending short vectors (or omitting countries) does not shift the roster
- ServerMessage/ClientOperation::RoomMemberStats and the context snapshot
  behind Client::room_member_stats, replaced on each rejoin
2026-08-28 12:44:37 +00:00
Michel de Graaf 0f91ffdcf3 feat(tui): dismiss a finished download with d
A completed row used to ignore d: only a queued transfer could be pulled
out of the list, so a finished one sat there until the next restart. d
now goes through one handler that drops a finished row whatever the
session thinks of it — a completed transfer can live only in this window,
restored from an earlier run, where there is nothing to forget.

The finished-status test was spelled out twice in clear_finished_downloads
and had to agree by hand; both callers now share is_finished().
2026-08-27 13:22:39 +02:00
Michel de Graaf a5d44d7611 docs(website): drop soulfind server link from footer 2026-08-18 16:18:27 +02:00
Michel de Graaf 5c8d0088c5 docs(website): move project links into LINKS 2026-08-18 16:16:44 +02:00
Michel de Graaf e78cdadf69 docs(website): list Player and Staccato, fix install pill overflow on mobile 2026-08-18 16:16:44 +02:00
Michel de Graaf ca4fe378be Merge pull request #46 from michel/release-plz-2026-08-18T06-40-22Z
chore: release v14.1.2
2026-08-18 10:44:45 +02:00
Michel de Graaf 21f60df1d9 chore: release v14.1.2 2026-08-18 08:40:23 +02:00
Michel de Graaf eac340cdb8 docs: drop bot wording in favor of client 2026-08-18 08:39:04 +02:00
Michel de Graaf 7f5cff43ef Merge pull request #45 from michel/release-plz-2026-08-17T08-46-25Z
chore: release v14.1.1
2026-08-17 10:53:59 +02:00
Michel de Graaf 078ffee0e4 chore: release v14.1.1 2026-08-17 10:46:26 +02:00
Michel de Graaf 3000b4426e Merge branch 'develop': listener transfer-token and download token-race fixes 2026-08-17 10:44:09 +02:00
Michel de Graaf ee4862b7f3 Merge pull request #44 from michel/issue-42-download-token-races
fix(peer): survive download token races on the listener path
2026-08-17 10:42:53 +02:00
Michel de Graaf f9becf9680 fix(peer): survive download token races on the listener path
A P-connection drop failed every download for its user, including ones
actively transferring on their own healthy F connection:
collect_failed_tokens now only fails queued downloads. And an F
connection could look its token up before the client-operations thread
had registered it: the listener now waits for the registration, and
claims it only for the matching peer and only when not already claimed.

Fixes #42
2026-08-17 10:23:21 +02:00
Michel de Graaf 1611b4baf3 docs: list projects built on soulseek-rs-lib
Add a 'Projects using soulseek-rs-lib' section to the README and a
matching section on the website community page, starting with seakarr.
Both point new projects at issue #12 to reserve a client minor version.
2026-08-17 09:46:06 +02:00
Michel de Graaf ac06cb9a5a feat(lib): reserve client minor versions 1 (unidentified) and 2 (reference client)
Document the minor-version registry on ClientVersion: within the
soulseek-rs major (176), minor 1 stays the library default for
unidentified consumers and minor 2 is now the reference CLI/TUI client.
Downstream clients reserve their own minor via issue #12.
2026-08-17 09:46:06 +02:00
Michel de Graaf 1833717521 Merge pull request #43 from michel/issue-41-listener-token-wait
fix(peer): wait for the transfer token before the download lookup
2026-08-17 09:41:43 +02:00
Michel de Graaf 543e9ee865 fix(peer): wait for the transfer token before the download lookup
The listener looked the download up immediately after the PeerInit(F),
but the 4-byte transfer token routinely arrives in a separate TCP
segment, so every firewalled peer download failed with "Download info
missing". Wait for the token within the handshake deadline and give up
on the connection when it never arrives.

Also quiets the dispatcher's unhandled-message-code log to debug; the
server routinely sends codes the library doesn't implement.

Fixes #41
2026-08-17 09:22:03 +02:00
Michel de Graaf f6788320b7 Merge pull request #40 from michel/release-plz-2026-08-14T21-36-33Z
chore: release v14.1.0
2026-08-15 00:18:08 +02:00
Michel de Graaf 742fc21629 chore: release v14.1.0 2026-08-14 23:57:17 +02:00
Michel de Graaf 8128a4cbec style: rustfmt in chat wrap test 2026-08-14 23:55:43 +02:00
Michel de Graaf 0aa6f33572 feat(tui): wrap long chat messages instead of clipping them 2026-08-14 23:26:11 +02:00
Michel de Graaf 4232a1e223 feat(tui): navigate and confirm while filtering search results
In results filter mode, Up/Down move the selection over the filtered
list and Enter confirms the filter (keeps the query, returns to normal
results keys). Footer hints updated.
2026-08-14 23:25:37 +02:00
Michel de Graaf 560d803b00 Merge pull request #39 from michel/cleanup-registry-eviction
refactor(client): dedupe peer registration and order eviction by id
2026-08-13 15:48:38 +02:00
Michel de Graaf 2a90619a64 refactor(client): dedupe peer registration and order eviction by id
The registry stored a registration Instant only to pick the eviction
victim, but the actor id is already monotonic per registration and
refreshed at the same moments, so it orders identically. The
register-refuse-fail sequence was also copy-pasted between the ops
loop and the listener; both now call one register_peer_or_fail.
2026-08-13 15:28:36 +02:00
Michel de Graaf a70c657d31 Merge pull request #38 from michel/release-plz-2026-08-13T12-34-56Z
chore: release v14.0.1
2026-08-13 14:54:14 +02:00
Michel de Graaf 4cce8deea0 chore: release v14.0.1 2026-08-13 14:34:57 +02:00
Michel de Graaf 9ff6497824 Merge branch 'develop': peer failure forwarding and thread-flood fixes 2026-08-13 14:31:14 +02:00
Michel de Graaf 0aec53b40a Merge pull request #37 from michel/issue-32-33-thread-flood
fix(client): bound the threads a peer flood can create
2026-08-13 14:30:43 +02:00
Michel de Graaf 492b961766 fix(client): survive the review findings on the thread-flood fix
Review of the capacity work surfaced three real defects and two gaps:

- A peer dribbling bytes re-armed the 10s handshake read timeout on
  every read and held its accept permit forever; 128 such sockets
  wedged the accept loop for good. The handshake now has one deadline
  across all reads, plus a 4 KiB cap on buffered handshake bytes.
- A capacity refusal was logged and swallowed, leaving the refused
  peer's queued downloads stuck until the caller's own timeout. Both
  registration paths now fail those downloads so the caller gets a
  verdict, and the listener only announces PeerConnected when the
  registration actually happened.
- The transfer-claim was undone by the worker thread writing Queued
  back over it; that write is gone, so a replayed TransferResponse
  can no longer start a second writer on the same .part file.
- Offered uploads and queue waiters joined the eviction-protected
  set, and the browse protection window matches the minutes a real
  share listing takes rather than one.

Regression tests: the broker-timeout sweep failing a queued download
end to end, the replay guard observed over a real socket, and the
handshake deadline against a dribbling and an oversized peer.
2026-08-13 14:10:20 +02:00
Michel de Graaf 9fe1c9984c fix(client): bound the threads a peer flood can create
One popular search produced a ConnectToPeer for every responder, and
each one cost a 2 MiB wrapper thread, an unbounded 256 KiB actor, a
listener thread per inbound socket, and on failure a 2 MiB thread that
slept for 20 seconds. A container with a pids limit died on one album
search (issues #32, #33).

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

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

Fixes #32
Fixes #33
2026-08-13 13:46:18 +02:00
Michel de Graaf fd1d168ca6 Merge pull request #36 from michel/issue-29-upload-denied
fix(peer): handle UploadDenied so refused downloads fail fast
2026-08-13 13:26:52 +02:00
Michel de Graaf ba4dc3b1ba Merge pull request #35 from michel/issue-30-upload-failed-forward
fix(peer): forward UploadFailed to the download store
2026-08-13 13:01:51 +02:00
Michel de Graaf 9ce0a851dc fix(peer): handle UploadDenied so refused downloads fail fast
Peer message code 50 had no handler, so the dispatcher dropped it and a
download refused by the peer stayed Queued until the caller's timeout.

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

Fixes #29
2026-08-13 12:45:57 +02:00
Michel de Graaf 1a4bc52cdf Merge pull request #34 from michel/issue-31-disconnect-state
fix(actor): leave Disconnected state behind after every disconnect
2026-08-13 12:41:12 +02:00
Michel de Graaf d30ca757bc fix(peer): forward UploadFailed to the download store
The code 46 handler parsed the filename and only logged it, so a queued
download whose peer reported an upload failure never received
DownloadStatus::Failed and hung until the caller's own timeout.

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

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

Reset connection_state to Disconnected in all four disconnect paths.

Fixes #31
2026-08-13 12:21:14 +02:00
Michel de Graaf a720591ed9 Merge pull request #28 from michel/release-plz-2026-08-10T07-14-20Z
chore: release v14.0.0
2026-08-10 10:05:23 +02:00
Michel de Graaf c2e10beee4 chore: release v13.0.1 2026-08-10 09:32:56 +02:00
Michel de Graaf f24a7f6369 Merge branch 'develop': fd-exhaustion fixes 2026-08-10 09:22:50 +02:00
Michel de Graaf b8cdb2c3fe Merge pull request #27 from michel/slow-tui-daemon
fix: keep an attached TUI fast after a search
2026-08-10 09:12:51 +02:00
Michel de Graaf 6eaf33fbaa fix(lib): cap what a search collects
Counting cheaply was not enough: on the live network peers answer a
popular query for minutes after the window closes, and nothing ever
stopped the pile-up. One 'aphex twin' search grew to 867,990 files and
2.1 GB of daemon memory, and a single search.results reply serialized
134 MB of JSON over 104 seconds - an attached TUI that touched the
search froze for minutes, which read as the whole application being
forever slow.

A search now stops collecting at 500 responses or 10,000 files,
whichever comes first, the same shortlist stance other clients take: a
result set is something to pick from, not an archive of everyone who
answered. The same search now plateaus at ~10k files, a 1.55 MB reply
in 23 ms, and 231 MB of daemon memory; the attached TUI idles at zero
CPU with the search open.
2026-08-09 22:49:56 +02:00
Michel de Graaf 0bbb559f68 fix: survive busy sessions without exhausting file descriptors
The peer listener died with "Too many open files (os error 24)" on any
long-lived non-daemon session: only the daemon raised RLIMIT_NOFILE, and
peer sockets were unbounded — no idle reaping, no deadlines on accepted
transfer streams, and one peer could hold two connections under split
registry identities.

- raise the open-file limit at the top of run(), for every entry point,
  and warn from Client::connect when the soft limit is below 1024
- reap peer control connections idle for five minutes; a clean
  disconnect no longer discards the peer's queued uploads
- give listener-supplied download streams and upload serving the same
  read/write deadlines dialled sockets already had
- register inbound peers under their plain username so the registry
  holds one connection per peer, and announce them like pierced ones
- hold the registry lock across actor spawn + insert and surface
  thread-spawn failure, so an instantly-dead peer cannot leave a
  zombie entry
2026-08-09 22:34:36 +02:00
Michel de Graaf 1551fbbf1b perf(daemon): answer search polls with counts instead of copies
A CLI one-off search leaves its result set in the daemon until someone
forgets it, and an attached TUI made that permanent state expensive:
every 100ms tick asked for the search list twice, and each answer
deep-cloned every result of every search under the context lock just to
sum file counts - stalling the thread that stores incoming results.
Worse, any active or selected search pulled its entire result set over
the socket again each tick, megabytes of JSON repeating what the window
already held.

search.list is now backed by search_file_counts, a walk of the cache
instead of a copy of it; the TUI syncs once per tick and re-fetches a
result set only when the session's file count differs from what it
holds. get_search_results_count reads the same summary, so get --pick
first against a daemon polls a count instead of the full set, and both
session kinds now count files where the local one counted peer
responses.
2026-08-09 22:16:27 +02:00
Michel de Graaf d11326f149 fix(daemon): ship the OpenRPC document inside the crate
docs/openrpc.json lived at the repository root, outside the soulseek-rs
package, so the include_str! in proto compiled from a checkout but not
from the published tarball - cargo publish failed verification and the
v13.0.0 release stopped after publishing the lib. The document moves
into the crate's own docs/, where packaging can see it; the generator
test and every link follow.
2026-07-29 17:34:29 +02:00
Michel de Graaf 881b124b7d ci: give the e2e job the 30 minutes two CLI passes need
The daemon-mode pass repeats the whole CLI suite single-threaded at
about eight minutes per pass, and the job has been cancelled at the
15-minute mark on every master push since it landed - which is what
kept the v13.0.0 release gate shut.
2026-07-29 17:02:35 +02:00
Michel de Graaf d30e863fae test(e2e): hand the serve test's share to the daemon, not the run
The serving process built its arguments by hand, so in daemon mode
--shared-dir stayed on the attached run, where shares belong to the
daemon and the flag is ignored. The daemon fell back to sharing its
default folder - which on a developer machine contains the probe files
earlier buggy runs deposited, so the test only passed by finding
served.bin in leftovers. args_with routes the share to the process
whose session does the sharing in either mode.
2026-07-29 17:02:35 +02:00
Michel de Graaf f525984a7b fix(lib): keep a missing download directory as the target, not its parent
resolve_download_path treated "not a directory" as "names a file" and
fell back to the parent, so a directory that simply did not exist yet
redirected the transfer one level up. The CLI and TUI create the folder
before downloading and never hit it; a daemon does not, so its transfers
landed in ~/Downloads while daemon.status - and the paths download and
get report - named ~/Downloads/Soulseek. Green on any machine where the
folder already exists, which is how it survived to CI.

Only an existing file diverts to the parent now. PartFile::open already
creates the directory it streams into.
2026-07-29 17:02:27 +02:00
Michel de Graaf 78a608c4c3 feat(website): serve an install script that picks brew or the release binary
curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh replaces the
brew one-liner as the front-page command: it works on Linux without Homebrew
by downloading the latest release archive for the platform, verifying its
sha256 and installing to /usr/local/bin or ~/.local/bin. Windows still gets
pointed at the msvc zip. scripts/test-install.sh covers target detection,
the brew fast path, checksum failure and real installs over curl and wget.
2026-07-29 16:22:05 +02:00
Michel de Graaf d2d1558708 docs: cut the README's padding, and tell the skill whose account it is
The feature list said the same things at twice the length, and Project
Goals / Project Structure were two headings for one paragraph.

SKILL.md argued against throwaway accounts on cost grounds. The reason
that holds is standing: the agent arrives under the user's name, peers
judge that name, and inventing a second one throws it away. Exit 7 now
points at starting a daemon rather than at --username.
2026-07-29 11:24:24 +02:00
Michel de Graaf e4546e5940 docs(website): fold the docs page into five parts and drop the byline
The docs page carried three daemon sections in a row, each with its own
numbered head, so the one story the daemon tells read as three. They are now
sub-heads under 5 · the daemon. The agents section is gone, first run points
at /install instead of assuming Homebrew, and the layout section shows the
demo next to the pane descriptions rather than describing boxes nobody sees.

The footer no longer signs the project with a personal name, and the TUI
demo no longer shows a home directory that only exists on one machine.

Community picks up the sharing etiquette cards and SeeleSeek; install and
home lose a few em-dashes.
2026-07-29 11:24:18 +02:00
Michel de Graaf 0d892c32be chore: release v13.0.0 (#25) 2026-07-29 11:04:39 +02:00
Michel de Graaf 9a3c19b380 fix(daemon): version the contract by its protocol, not by the crate
info.version carried the crate version, so release-plz bumping Cargo.toml
left docs/openrpc.json a version behind and failed document_is_current on
every platform. With the CI gate in front of publishing, that made every
release PR unmergeable. The protocol version is what auth negotiates and
what the contract actually describes.
2026-07-29 10:41:29 +02:00
Michel de Graaf 30698aa7ce test(daemon): match a reply by its id instead of taking the next line
A connection that has joined a room receives event notifications on the
same socket as its replies, so a notification landing between a request
and its answer was read as the answer. Only replies carry an id.
2026-07-29 10:37:48 +02:00
Michel de Graaf ab5cef8cfe docs: tell the whole daemon story, and stop denying it exists
An audit of the README and the site against the implementation found 38
places the docs were missing, stale, or wrong about daemon mode. Two were
outright false: the community page still answered "want a queue that
outlives the command?" with "slskd has that", and the home page promised
"no daemon, no API to stand up" fifty lines before the section that
describes both.

The download-box walkthrough now tells the truth about its sharp edges: a
pushed folder or share list is live-only on a remote daemon, whose own
config.toml wins at restart; `shares add` validates the path on the
machine you type it on; and a `get` that reaches its `--timeout` cancels
the daemon-side transfer. Its shell example also runs as written now — the
old block put a second command after a foreground daemon that never
returns.

The reference material catches up with the feature: `daemon` and its
subcommands join the command list, the config table gains the
daemon/daemon_token/--no-daemon rows, nine settings become eleven,
`daemon status` and `daemon token` get documented record shapes, exit 3
covers daemons, `queued` joins the serve statuses, the credential-free
count goes 13 to 19, and the action-command count 4 to 6.

And the surprises are written down rather than discovered: the daemon does
not sweep the wishlist on its own; Windows has no socket and so no
auto-attach; the attached TUI's settings form edits the daemon's folders
and rewrites this machine's config.toml; `serve` monitors a daemon's
uploads instead of starting a second server; `whoami` reports the
daemon's world, with --no-daemon as the way out. The OpenRPC section names
the six event methods and the session-wide download.set_dir rule.

Site typechecks, lints, and builds through prerender; every claim was
verified against the cited code before it was written.
2026-07-29 09:22:02 +02:00
Michel de Graaf f1b667f7f9 refactor: cut what a whole-repo audit proved dead, padded, or hand-rolled
Seven region audits, every finding adversarially verified against the
whole workspace before being applied; 80 landed and the two that failed
in application were refuted honestly (ParseError has a from_str_radix
producer the audit missed; NatPmpError's Display is the only reader of
its diagnostic payloads).

The bigger cuts:

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

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

Net -1002 lines. Both e2e modes stay green: 94 CLI tests each way, 16
protocol, 33 lib, unit suites, clippy pedantic clean, aislop 100/100
with the four scanner false positives suppressed inline where they
fire.
2026-07-28 12:52:18 +02:00
Michel de Graaf cd56fb3b39 feat(daemon): make the download folder a live setting in both modes
The full-surface audit found the settings story half-true: `config set
download_dir` and the TUI's settings form edited a file a running daemon
never rereads, while the attached TUI displayed the laptop's path and
discarded the edit entirely.

- `download.set_dir`: a session-wide move, published in the contract;
  the per-transfer destination stays refused. `daemon.status` reports
  it and the next transfer lands there.
- `config set download_dir` and `config set shared_dirs` push to a
  live daemon the way `shares add` does, through one shared helper;
  the socket probe is paid only by the keys a daemon owns.
- The attached TUI seeds its settings form from the daemon's status,
  pushes both folders over the socket as typed, reports a partial
  refusal as exactly that, and creates no client-side directories. It
  reads the daemon token from the resolved config — with the token only
  in config.toml it could not attach over TCP — and settings
  persistence honours --config instead of hard-coding the default path.
- `search.wishlist` records a start time, so an attached window
  renders it as running rather than instantly finished.

The rest is the coverage the audit demanded: CLI-level token auth
(wrong, missing, and valid over TCP), `daemon status|stop` with
nothing running, --sort, --limit, --pick first, --password-cmd,
--log-file, --upload-slots, the config-named download folder in both
modes, and headless settings-form tests attached and local. Every
guard was checked by disabling what it protects. Both e2e modes run
green: 94 CLI tests each way, 16 protocol, 285 unit, 34 lib.
2026-07-28 10:44:06 +02:00
Michel de Graaf 35f5cd4f22 docs(skill): choose the track yourself; narrow the results instead of skipping them
Leading with `get` was the wrong trade. It ranks on free slot, bitrate and peer
speed, which are facts about the transfer and say nothing about whether the
file is the track that was asked for. On a setlist of remixes, dubs and white
labels it does not miss, it succeeds with the wrong one: exit 0, a file on
disk, and nobody finds out until it plays. A miss can be retried; a confident
wrong answer cannot. The previous revision optimised the measurable cost and
paid for it in the unmeasurable one.

The token problem it was solving is real, but the answer is to narrow before
reading rather than not to look. A search writes its records to a file; search
flags and jq cut three hundred down to eight without any of them entering
context; the eight are read and judged. `get` keeps a scoped mention for when
the user has named nothing in particular.

Adds what "the right one" means, since that is now the load-bearing step: fold
both sides, require every distinctive word, reject live/radio edit/snippet/dj
mix, prefer lossless, and above all treat the version as the thing you match on
rather than the thing you dropped. The ladder drops "(Kalabrese Remix)" to find
anything at all, so the match must then require "kalabrese" or it takes the
original and reports success.

Checked against agents given only this file on a remix-heavy setlist: 2/2
refuse `get`, narrow before reading, require the remixer's name in the chosen
path, and report a track as unavailable rather than silently taking the
original when only that exists.
2026-07-28 09:25:42 +02:00
Michel de Graaf 6927e91274 docs(skill): make the cheap path the default and stop moving paths by hand
Feedback from a real 32-track run: 70 agents, 2.48M tokens, 18.7 minutes, most
of it avoidable. Four things, three adopted as reported and one corrected.

The skill mandated a handoff it gave no mechanism for. Subagents had to report
a pick, while the rules said remote paths are backslash-separated, may contain
anything, and must be passed back byte-identical. A path does not survive a
subagent's return, a coordinator's JSON and a child's argv, so every agent
re-derived a workaround from prose. The idiom is now written down: append every
rung to one ndjson file, select the pick by line number, feed it to `download
--stdin`. The bytes never leave the file, and a subagent reports a file and a
line rather than a path.

`get` is now the default and subagents the exception. It searches, ranks and
downloads in one command, so results never enter any context, which is where
the tokens went. The section says when to escalate: a named mix, an ambiguous
title, four dead rungs, or several runs landing on the same peer.

Browsing triggers on any artist appearing more than once, not only on a whole
album. In the reported set that was 14 of 32 tracks across six artists.

The one correction: "rung 2 empty means rungs 3 and 4 are ceremony" is not
safe, because rung 3 folds diacritics and punctuation and that is often exactly
why rung 2 found nothing. The general rules are that a rung producing the same
string as the last is not a rung, and that no results at all means the tokens
are wrong rather than too many, so jump to the widest rung instead of stepping.

Streaming picks into transfers is untouched; the report singled it out as
working.

Checked against agents given only this file on the same shape of set: both lead
with `get`, spawn 3 and 6 subagents rather than 32, keep result sets out of
context, browse the repeated artists, and hand paths over as a file and a line
number.
2026-07-28 09:18:32 +02:00
Michel de Graaf 813f5246af docs(skill): stream picks into transfers instead of batching them
The clearance step read as a barrier. "Only you can see every pick" invited
collecting all of them before releasing any, so a forty track set moved no
bytes until the last search finished, and one white label nobody is sharing
held up thirty-nine tracks that people are.

Nothing about the safety needed a barrier. Answering a single pick takes two
running counts, transfers to that peer and transfers overall, so picks are now
cleared as they arrive and the first file transfers while the last track is
still on its second rung. Says so explicitly, because the previous wording did
not have to be read as batching but was.

Also points a track that survives all four rungs at the wishlist rather than
just reporting it missing: the server re-runs those on its own schedule, which
is the right home for something nobody is sharing right now.

Checked against agents given only this file, on a forty track set with
unfindable tracks in it: 2/2 start transferring before the searches finish, and
both say a missing track holds up nothing but its own subagent.
2026-07-27 21:21:43 +02:00
Michel de Graaf f61de55174 docs(skill): raise the batch limits to what the client is actually built for
Twenty transfers, twenty subagents, ten searches on the wire. The transfer
number is not invented: it is the client's own default, and the reasoning is
already in config.rs, that a Soulseek transfer is paced by the peer sending it
so a fast link is filled by many transfers rather than faster ones. Twenty
costs twenty sockets.

Two numbers deliberately did not scale with the rest, and the table now says
why:

- Three from one peer, up from two. This is the one that gets you banned. A
  client serves ten upload slots by default, so three is a visible minority of
  one person's capacity rather than a monopoly on it. Twenty transfers is fine;
  twenty transfers from two people is not.
- Ten searches, not twenty. The server rate-limits searches and it is the
  daemon's single connection making all of them. A subagent spends most of its
  life waiting on a transfer, so twenty subagents behind ten search slots costs
  nothing.

Four rungs is unchanged and now says it is not a concurrency limit at all.

Checked against agents given only this file, including a batch where one peer
has every track: both read the raised numbers correctly and both hold at three
from that peer, letting their queue feed the rest rather than opening
twenty-five transfers on one machine.
2026-07-27 21:14:00 +02:00
Michel de Graaf e9daaef71a docs(skill): add the search ladder, and one budget both batch routes obey
Soulseek matches a query against the filenames peers happen to have, and the
name a user gives is usually more specific than any of them: a mix suffix that
is not in the file, a diacritic the uploader typed as ASCII, an ampersand where
the file has a space. Searched verbatim that is exit 4 for a track dozens of
people share.

Adds a four-rung ladder (as written, without the version suffix, folded to
ASCII without featured artists, then the rarest two or three words), stopping
at the first rung that returns anything, and then matching results back against
what was actually asked for, since widening finds candidates but does not
choose. Reconciled with the rate-limit rule: a wider query is a different
query, repeating one is not.

Also fixes an inconsistency the concurrency numbers had grown: the subagent
route capped transfers per peer but never overall, while the batch route said
-c 5, so the same skill gave two answers. There is now one table covering
subagents, searches on the wire, transfers, transfers per peer, and rungs per
track. Five searches against ten subagents is deliberate, because the server
rate-limits searches harder than peers rate-limit uploads.

Checked against agents given only this file: 2/2 climb the ladder rather than
reporting failure on the first empty search, stop at four rungs, and verify the
result is the track that was asked for.
2026-07-27 21:10:57 +02:00
Michel de Graaf 6c92efccd8 docs(skill): teach the agent to fetch a list without getting the user banned
The batch guidance optimised for parallelism and judgement and was nearly
silent on what actually gets you blocked on Soulseek, which is the thing an
agent driving this unattended is most likely to walk into.

Rewritten around the risk, in the order it bites:

- Sharing nothing is the commonest reason to be banned, so `shares status` is
  now a precondition: if files is 0, say so before downloading anything.
- Queueing many files at once from one person reads as abuse to them. Subagents
  now report their pick and wait, because only the main agent can see every
  pick and resolve two subagents landing on the same peer. The batch form gets
  the matching rule: interleave the lines so consecutive ones name different
  users, since the workers take them in order.
- Searches are rate-limited by the server, so never re-run a query; pick again
  from the results already held.
- A failed transfer means take the next candidate, not retry the same peer.

Also adds the strategy for an album, which fresh agents reading the previous
draft worked out for themselves and which is both faster and safer: search
once, browse whoever has most of it, and only search per-track for the
remainder.

Checked against agents given only this file: 2/2 verify shares first,
coordinate peers, and refuse both the retry loop and the repeat search.
2026-07-27 21:03:47 +02:00
Michel de Graaf 011eafaa7f feat(daemon): make every window a view of one shared session
Two TUIs on one daemon behaved as two separate windows that happened to share
a login: each showed only the transfers it had queued and the searches it had
run, and neither said it was attached to anything.

The status bar now names the daemon, and both lists are views of the session:
transfers and searches appear in every window, dismissing one dismisses it
everywhere, and a window opened later starts from what is already running
rather than blank. message read likewise starts from the conversation the
daemon collected instead of only what arrives while it runs.

Search state needed the daemon to say when a query went out. The window a
search collects for belongs to whichever client started it, so without that
every other window showed a running search as finished the moment it appeared
— which is what it did. The summary carries a file count too, so a window has
the right number before it has pulled the results across.

Adds Client::forget_search, which the library had no way to do: a resident
client otherwise holds every result set it has ever seen, and a window that
dismissed a search had no way to say so.

A review of the first cut found five defects, all fixed here and all covered:
syncing ran locally too, where it cost a full copy of every result set per
frame and deleted the completed-download history restored from disk; a
transfer could be listed twice by racing its own registration; clearing all
searches did not reach the session, so every row came back; a search dismissed
elsewhere never left; and a wishlist search reported as finished the instant
it went out.
2026-07-27 20:58:49 +02:00
Michel de Graaf 220beb4e41 fix(daemon): ask the OS for enough open files, and stop spinning when it says no
A search fans out to hundreds of peers, each holding a socket. A shell hands
its children a soft limit of 256 open files on macOS, which one busy search
blows straight through; a one-shot command never noticed because it exited
before it mattered, but a resident daemon hits it and then cannot accept peers
or control connections. From a client that looks like 'cannot reach the
daemon: Connection closed', intermittently, for no visible reason.

The daemon now raises its own soft limit to 8192 at startup, before anything
opens a socket, and says so plainly when the hard limit will not allow it.
macOS rejects an unlimited hard limit for open files, so both fields are named.

Both accept loops also slept zero on failure. Out of descriptors does not clear
by the next instruction, so one exhausted moment became a spinning core and
thousands of identical log lines — which is what the report looked like. They
now back off.
2026-07-27 20:16:41 +02:00
Michel de Graaf 4836934881 fix(daemon): a thread the OS refuses must not take the listener with it
spawn_connection used thread::spawn, which panics when a thread cannot be
created. That panic unwinds through the accept loop, so on a client already
running a thread per peer one exhausted moment would end control for the life
of the process — every later connection accepted and immediately closed, which
is exactly what 'cannot reach the daemon: Connection closed' looks like from
the other side.

Builder::spawn instead: refusing the one connection, with a reason in the log.
2026-07-27 20:08:21 +02:00
Michel de Graaf 01152d91f6 feat(daemon): say what to do next on startup, and prove multiple clients agree
Starting the daemon printed 'ready' and nothing else, so the token and the
next step were both somewhere else. It now prints what other commands can do
from here, and when --bind is given, the token itself with the two config
commands a remote client needs. All on stderr, so stdout stays empty and
daemon still composes in a pipeline.

Adds the guarantee daemon mode is for as a test: eight clients connected at
once are each answered correctly, none has its replies crossed with another's,
and the daemon still counts all eight. Plus 250 connect-and-drop cycles, well
past the connection cap, to catch a per-connection resource that is not
released — the shape a daemon that works for a while and then refuses
everybody would take.

A connection the daemon drops before it can be served now logs why at warning
level. A client only ever sees 'the connection closed', so that line is the
only account of the reason.
2026-07-27 14:39:31 +02:00
Michel de Graaf 2a906df1b5 feat(config): remember which daemon to use, and document the download box
A remote daemon could only be named with --daemon and --daemon-token on every
invocation, which made the one workflow daemon mode is most useful for — a
headless download box driven from a laptop — a matter of retyping a 64-character
token. Both are now config keys, so it is two 'config set' commands once and
plain commands thereafter.

Fixes a bug that setup exposed: the up-front daemon status probe read the token
from the command line only, so with the token in the config file it failed
silently and every command that names a path or a server fell back to this
machine's defaults — whoami claiming downloads land in the laptop's home
directory when they land on the box. The regression test was checked by
reintroducing the bug.

Documents the download-box workflow in plain terms across the README, the
website and the agent skill, and says plainly that the schema is open: OpenRPC,
served by the daemon itself, so an Android, iOS or web remote is something
anyone can build without asking.
2026-07-27 14:11:51 +02:00
Michel de Graaf 3f914c4425 docs: put daemon mode on the website and tighten the README
The website made three claims daemon mode falsifies — "No TTY, no daemon",
"no daemon, no MCP server, no API key", and "no GUI and no daemon mode yet"
under what-this-is-not. All three corrected.

Adds a docs section and a home feature card, both leading with the reason
rather than the mechanics: the server allows one session per account, so
parallel one-shot commands either displace each other or mean a throwaway
username per run, which is account churn on infrastructure other people
maintain. A daemon is one login for as long as you work.

The README section said that three times over and buried the reasoning below
the mechanics; rewritten to state it once, up front. It now also covers which
commands stay local and that shares add reaches a running daemon, which was
meant to land earlier and silently did not — the edit anchored on text that
only existed in SKILL.md.
2026-07-27 14:04:14 +02:00
Michel de Graaf fe56a070e1 docs(skill): make the daemon the default way an agent works
The skill buried daemon mode two-thirds down as a scaling trick, and still
offered --username "$SOULSEEK_USERNAME-$RANDOM" as the way to run commands in
parallel. That is worse than underselling it: the server allows one session
per account, so a per-run name means registering a throwaway account on a
public server for every search — account churn on infrastructure other people
maintain and share.

Starting a daemon is now the first instruction in the file, with the reason
stated, and the random-username idiom is named as the anti-pattern rather than
offered as an alternative. Exit 7's advice changes accordingly: it means
something else logged in as you, and the fix is a daemon, not a new name.

Three agents given only this file and a parallel-work task all started a
daemon and none invented a username.

The startup snippet also had a shell precedence bug: in
`daemon status || daemon &`, the & backgrounds the whole list, so it returned
instantly and the next command raced the login it was meant to use. Replaced
with a bounded wait, checked against a live server.
2026-07-27 14:01:17 +02:00
Michel de Graaf d2599c82d3 fix(shares): let add and remove reach a running daemon
shares add wrote the config file and stopped there, so with a daemon running
the folder stayed invisible to the network until the daemon restarted — the
command reported success and nothing had happened. Locally that is the right
meaning, because there is no session yet; a daemon is already the next run.

Both commands now push the new list over shares.set. The e2e test was checked
against a disabled push to confirm it detects the bug.
2026-07-27 12:46:25 +02:00
Michel de Graaf f985850c06 test(daemon): route the suite at its chokepoint and cover the protocol directly
Only 25 of the 83 CLI e2e tests actually reached a daemon: the helpers that
build a command bypassed the one place that routed. Routing at
TestServer::args instead brings that to 39 and, more importantly, puts the
streaming tests through a daemon — event.room and event.message delivery to a
remote client's mirror had no end-to-end coverage at all.

Adds what the command surface structurally cannot reach:

- Two clients against one daemon at once, which is the reason the hub exists
  and was proven nowhere in CI.
- tests/daemon_protocol_e2e.rs, speaking the socket by hand: TCP token
  required and socket token not, pre-auth refusal, error ids, notification
  silence, the line cap, the handshake timeout, and that a call shaped by the
  published contract is accepted.
- daemon status/stop/token as commands.
- A TUI unit test for conversation seeding, the part of the attach path that
  had actually been broken.

The protocol suite caught a regression in yesterday's hardening on its first
run: the socket was shut down before the writer flushed a failed handshake's
refusal, so a client with a wrong token saw the connection vanish instead of
being told why.
2026-07-27 12:30:41 +02:00
Michel de Graaf 41307d0510 fix(daemon): correct the published contract and harden the control socket
A review pass over the diff found seventeen confirmed defects. The worst was
the contract itself: every method's parameters were published as a single
descriptor named `params`, so a client generated from openrpc.json sent
{"params":{"params":{…}}} and every call failed -32602. Parameters are now
published per field, with a test asserting the published names are the keys
the daemon actually deserializes.

Security and lifecycle:
- download.start no longer lets a caller name a directory, which was an
  arbitrary write on the daemon's host.
- The Unix socket is bound under a temporary name and renamed into place; it
  was briefly connectable by any local user between bind and chmod, and on
  that transport connecting is authenticating.
- Pre-auth reads are capped at 1 MiB with a 30s handshake timeout, and
  connections are capped, so an unauthenticated peer cannot exhaust memory or
  threads.
- A read error no longer returns past the hub unsubscribe, which leaked a
  subscriber and two threads per occurrence.
- A client whose event queue fills is now actually disconnected. It used to be
  dropped from the fan-out and left connected, working on but blind to every
  event after — the documentation already promised otherwise.
- A client whose daemon vanishes mid-transfer is told at once instead of
  waiting out its full timeout and reporting a misleading "gave up".

Contract accuracy: errors carry an explicit null id (a missing one reads as a
notification and gets dropped), a second auth answers with the published
shape, and nullable fields are sent rather than omitted so a generated client
sees one shape.

Also splits proto.rs and server.rs, whose concerns had diverged, and applies
the review's simplifications.
2026-07-27 11:38:46 +02:00
Michel de Graaf a69f52c657 test(daemon): drive the whole CLI suite against a daemon, and fix what it caught
SOULSEEK_E2E_MODE=daemon makes the existing e2e harness serve every session
from a daemon instead of the command's own login, so the same 83 tests assert
the same records and exit codes both ways. CI runs the suite twice.

Two defects it found:

serve judged whether anything was shared from this run's configuration
rather than from the session, so routed to a daemon it would start with
nothing to serve and report success.

The TUI showed an empty conversation when attached, because the daemon's
message history was never asked for. It is now a SessionApi method, empty
locally, which puts the difference behind the seam.

main.rs is now a thin entry point over the library rather than a second copy
of every module, so the program compiles once instead of twice.
2026-07-27 10:58:27 +02:00
Michel de Graaf 834fbcd35b docs: publish the daemon protocol and describe daemon mode in the README
The OpenRPC document is the machine-readable contract; daemon-protocol.md
covers what a schema cannot say — framing, the auth handshake, and that
events are pushed, unreplayable, and will disconnect a client that stops
reading.
2026-07-27 10:46:05 +02:00
Michel de Graaf a0354f66a8 feat(daemon): route commands and the TUI through a running daemon
Session::open attaches to a daemon when one is reachable and logs in
otherwise, so every existing command works unchanged against either. A local
socket is found automatically, which is what makes an agent's invocation need
no flags at all; --no-daemon opts out.

Commands now read identity, shares, server, and download directory from the
session rather than from this run's config: routed to a daemon those differ,
and the session is the one actually on the network. Without that, whoami
named the wrong server and shares reindex would have imposed the client
machine's folders on the daemon.
2026-07-27 10:33:41 +02:00
Michel de Graaf f54f8f9b82 feat(daemon): resident session, auth-gated control socket, and event fan-out
The daemon drains the library's destructive take_* accessors once and fans
events out per connection, which is what lets more than one client share a
session. A Unix socket at 0600 is the local transport and its permissions
are the authentication; TCP is opt-in and needs the generated token. Rooms,
transfers, and the conversation are daemon-owned and restored on restart.
2026-07-27 10:05:25 +02:00
Michel de Graaf df089fbab3 feat(daemon): wire protocol with a type-safe method vocabulary and a generated OpenRPC contract
Methods and events are enums built by one macro, so every consumer matches
exhaustively: adding a member is a compile error until the dispatcher, the
published schema, and the remote client all handle it. docs/openrpc.json is
derived from the DTOs rather than hand-written, and a test fails the build
when the checked-in copy drifts.
2026-07-27 09:56:48 +02:00
Michel de Graaf e2813392ea refactor(api): put a SessionApi seam between the commands, the TUI, and the client
Commands and the TUI now hold an Arc<dyn SessionApi> instead of an
Arc<Client>. The trait is implemented on Client itself, so the local path
is the same client behind one vtable and no wrapper type exists to keep in
sync. Daemon mode plugs a remote implementation into the same handle.
2026-07-27 09:48:46 +02:00
Michel de Graaf 4214848a8a chore: release v12.0.0 (#24) 2026-07-26 19:08:00 +02:00
Michel de Graaf db75a8266a fix(serve): report a queued upload even when it is served between two polls
`serve` samples `uploads()` every 500ms and emits what changed, so an upload
state shorter than that window never reached the log. Nothing surfaced this
while every upload sat on its slot for a 500ms linger, which was exactly one
poll; with the linger gone a peer can queue and be served between two samples,
and "it had to wait" — the one thing the queue exists to record — silently
disappeared.

The pump now keeps whatever it left waiting, and `serve` drains that alongside
the snapshot, so the fact survives regardless of when the next sample lands.
The list is deduplicated per peer and file and capped, so a caller that never
drains cannot grow it without bound.

This is what made cli_e2e::serve_reports_a_queued_upload_once_the_slots_are_full
fail on CI while passing locally: the assertion was real, and it was timing that
decided whether the client could answer it.
2026-07-26 18:54:40 +02:00
Michel de Graaf 7435d48b32 perf: raise transfer defaults and stop uploads sleeping on their slot
The slot cap merged from master defaults to 2 uploads, and the CLI capped
downloads at 5. Those are the numbers you pick when your own bandwidth is the
scarce resource, but a Soulseek transfer is paced by the peer on the other end
— usually a few hundred KiB/s — so what fills a modern link is many transfers
at once, not faster ones. Upload slots go to 10, downloads to 20. The queue
itself stays: privileged users being able to jump it is a condition of being a
tolerated third-party client.

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

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

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

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

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

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

Mixed profile at 1024 peers: 512/512 downloads, 256/256 uploads, 400-850 MiB/s
in ~7s, against 71/96 downloads at 7.9 MiB/s and a 60s timeout before.
2026-07-26 17:01:32 +02:00
Michel de Graaf 38a231ffd1 docs(website): untap michel/tap in the uninstall transcript
`brew uninstall` leaves the tap behind as a git clone, so the section that
promises to remove it completely was still short one command.
2026-07-26 16:44:11 +02:00
Michel de Graaf 8482c47149 fix(website): give Homebrew users an uninstall command, share the link markup
The macOS install route recommends Homebrew, but the uninstall section only
ever offered `cargo uninstall`, so anyone who followed the recommended route
had no documented way to remove the binary. Adds `brew uninstall`.

install.tsx also had ReleasesLink defined to wrap an external anchor, then
inlined that same anchor four more times anyway - including one that rebuilt
the releases link by hand, byte for byte, 290 lines below the component that
already rendered it. ExtLink takes the href and covers all six sites here and
in community.tsx. Rendered markup is unchanged.
2026-07-26 16:17:22 +02:00
Michel de Graaf 2a051c7064 chore: add a code quality baseline
Three findings were never real. parse_request is 34 lines, not 426: the
`'{'` char literal in it reads as an opening brace to the line counter, which
then runs to EOF — 610 + 426 lands on line 1035 of a 1034-line file. The
duplicate-block hits on home.tsx and install.tsx match the punctuation between
entries of a data table, not logic; the records repeat the field names their
type demands.

Each is silenced at its own site with the reason, so the rule keeps working
everywhere else. Nothing is switched off in config.yml — a `rules:` entry there
would blind the rule across the project. `ci.failBelow: 100` makes the baseline
enforceable: the check exits 1 on a single new warning.
2026-07-26 16:09:34 +02:00
Michel de Graaf 783d429af0 refactor(lib): move the client tests into their own file
client/mod.rs had grown to 1124 lines, a third of it tests: six loose #[test]
functions wedged between the two `impl ClientContext` blocks, plus the usual
`mod tests` at the bottom. Both now live in client/tests.rs, leaving mod.rs at
790 lines of actual client.

Same 11 tests, same 568 passing across the workspace.
2026-07-26 15:44:16 +02:00
Michel de Graaf 2e9cd6b860 refactor(website): split the hero terminal and the page components
HeroTerminal was a single 367-line component doing three separate jobs:
simulating the transfer queue, holding the per-search result sets, and
rendering. It is now useTransfers, useResults and useHeroDemo behind a
presentational component. The dead shellRef went with it.

The home, docs and install pages each returned one long JSX block; every
section is now a named component, so the page body reads as an outline.

The prerendered HTML is byte-identical for all five pages, and the extracted
transfer reducer matches the original over 200 ticks.
2026-07-26 15:40:58 +02:00
Michel de Graaf 093f32b360 chore: release v11.0.0 (#23) 2026-07-26 15:04:08 +02:00
Michel de Graaf e8a48c6b06 fix!: make concurrent invocations work instead of silently seeing nothing (#22)
Running several one-shot commands at once returned "no results" for queries that
had plenty. Four defects, each of which made a dead session look like an empty
network:

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

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

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

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

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

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

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

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

BREAKING CHANGE: `UploadStatus` gains a `Queued(u32)` variant, so an exhaustive
match on it no longer compiles, and `cli::Commands` gains `Wish`.
2026-07-26 14:20:07 +02:00
Michel de Graaf eda762d067 fix(ci): don't publish a release on a run that rebuilds an older tag 2026-07-26 13:17:49 +02:00
Michel de Graaf 2fdb567a0e ci: name the missing tap secret instead of leaking gh's generic advice 2026-07-26 13:02:05 +02:00
Michel de Graaf 5161d0b758 chore: drop the tap token probe 2026-07-26 13:01:49 +02:00
Michel de Graaf 45f7df3db3 chore: probe repository_dispatch 2026-07-26 13:00:47 +02:00
Michel de Graaf f6586cf321 chore: probe dispatch permission 2026-07-26 12:59:56 +02:00
Michel de Graaf d6b8bec940 chore: release v9.1.0 (#19) 2026-07-26 12:59:49 +02:00
Michel de Graaf 626dcb5d76 chore: temporary tap token probe 2026-07-26 12:59:01 +02:00
Michel de Graaf af4434e764 test: prove the documented examples against the binary (#15)
* test: prove the documented examples against the binary

The README, the website and the shipped SKILL.md all promise specific
records, keys and exit codes, and nothing was checking that the binary
still agrees. Cover the claims that were unverified: the eleven
credential-free commands, the one-day bound on every wait, the config
directory relocation, `serve` refusing to run without a listener, the
four action commands that answer with an exit code alone, the search
filters, `--password-stdin`, `room listen`'s join records, and every
`--json` field the docs name for search, browse, whoami, user, shares,
room, download, serve and portmap.

The library README's examples now compile as doctests: lib.rs includes
it as the crate docs and the fences are `no_run`, so a signature change
cannot leave the advertised usage stale.

Two things were wrong. SKILL.md listed `queued` as a `serve` upload
status, which `upload_record` never emits — an agent branching on it
would wait for a state that never arrives. And the library README still
pointed at the 7.x lib.

* test: cover the completions commands the docs now advertise
2026-07-26 12:56:40 +02:00
Michel de Graaf 1eef886b88 fix(ci): stop the skipped ci-gate from cascading past the release job 2026-07-26 12:54:40 +02:00
Michel de Graaf 8dc140eea2 fix(ci): gate the binaries job on the resolved tag so manual rebuilds run 2026-07-26 12:51:56 +02:00
Michel de Graaf a72dd64081 feat: resume an interrupted download instead of refetching it (#18)
Downloads buffered the whole file in memory and wrote it out only on
success, so an interrupted transfer left nothing behind and always
started over. The START_DOWNLOAD offset was hardcoded to zero on the
download side and discarded on the upload side, so neither half of the
protocol's resume support was wired up.

Bytes now stream into `<file>.part` as they arrive and it is renamed
only once the file is whole. A new attempt sends the partial file's
length as the START_DOWNLOAD offset, and `serve_file` seeks to the
offset a peer asks for rather than replaying from the start. A `.part`
at or past the expected size is treated as stale and restarted.

Partial files are kept out of the share index, since the download
directory is shared by default.
2026-07-26 12:51:48 +02:00
Michel de Graaf 3b75332e0c ci: push the new release to the homebrew tap instead of waiting for its cron 2026-07-26 12:49:14 +02:00
Michel de Graaf 481c815983 chore: release v9.0.0 (#17) 2026-07-26 12:21:11 +02:00
Michel de Graaf 534b09a20a feat: install tab completion for bash, zsh, and fish (#16)
`completions install` generates the script from the same clap definition
the binary parses with, so it cannot drift from `--help`, and writes it
where each shell already looks. fish scans its own completions directory
unprompted; bash and zsh get one marked `source` line appended to their rc
file, because nothing puts a per-user directory on their search path by
default. `completions uninstall` takes back exactly those lines and leaves
the rest of the file alone.
2026-07-26 12:18:53 +02:00
Michel de Graaf 72f2e31a2c docs: point the library snippets at the 8.x lib
v8.0.0 is on crates.io, so the Cargo.toml examples in the readme and on the
install page were a major behind.

Replace the 5.x upgrade note with one that does not name a version, so it
stops going stale every release.
2026-07-26 11:51:48 +02:00
Michel de Graaf 9869b11f6d docs: cut the padding from the readme
Drop the "This structure allows" bullets justifying a two-crate workspace,
collapse four one-line "To run X:" stubs into a single block, and lose the
adverbs, the emoji heading, and the bolded emphasis.

Project Goals no longer claims a hard no-external-crates rule for the
library, which stopped being the stance; it says lean instead. Also fixes
"Headless mode daemon mode".
2026-07-26 11:45:33 +02:00
Michel de Graaf 45b83546ae chore: release v8.0.0 (#14) 2026-07-26 11:42:01 +02:00
Michel de Graaf abf83fa30a style(website): cut the padding from the page copy
Remove three cross-page duplications: the "what --help cannot say" list
appeared on both home and docs, the config precedence rule twice on
install, and the soulfind link twice in adjacent community paragraphs.

Drop the em dashes, the adverbs, and the rhetorical triads while there.
2026-07-26 11:41:48 +02:00
Michel de Graaf d2f3dd13d7 feat: log in with soulseek-rs's own client major version (176) (#13)
The Login message hardcoded SoulseekQt's major version 157, so the
server could not tell soulseek-rs apart from the official client. The
protocol document now reserves a unique major version per project, so
claim 176 and send it via a new ClientVersion on ClientSettings.

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

Refs #12
2026-07-25 21:51:58 +02:00
Michel de Graaf e2c2b5063a style(website): tighten the agent-skill copy 2026-07-25 11:09:50 +02:00
Michel de Graaf e79250c337 fix(website): bump the library install snippet to the 7.x lib 2026-07-25 10:55:49 +02:00
github-actions[bot]andMichel de Graaf 69f3621f72 chore: release v7.1.0 (#11)
Co-authored-by: Michel de Graaf <michel@re-invention.nl>
2026-07-25 07:59:54 +02:00
Michel de Graaf 3ff51c2799 style(website): widen the mobile page gutter to 24px
The wordmark and the hero mark sat 18px off the edge, close enough to read as
flush against it on a phone. The nav bar and Wrap carry the same gutter, so
both move together.
2026-07-25 05:39:56 +02:00
Michel de Graaf 96e6bcdb8a feat(website): route the OS links to per-platform install steps
Clicking macOS, Linux or Windows on the home page landed on the file-path
cards, which say where config lives, not how to install. Step 1 now carries
one card per OS at those anchors: brew on macOS, cargo on Linux, cargo or the
prebuilt .exe on Windows. The path cards keep their own -paths anchors.

The hero logo drew into a fixed inline width, so the mobile height overrides
shrank the mark inside a box sized for the desktop one. An aspect-ratio lets
the width follow, and the mark is now larger and centred against the title.
2026-07-25 05:35:01 +02:00
Michel de Graaf 5090af97a2 fix(lib): give the login verdict the wait the caller already allows
The actor gave the server 5 seconds to answer a login before reporting
Timeout, while Client::login documents and allows 45. Under load — 24
e2e tests sharing one soulfind — 5 seconds is not always enough, which
is how CI failed on a docs-only commit.
2026-07-25 05:28:44 +02:00
Michel de Graaf c074ac3d7b docs: show the agent session in the README agent skills section 2026-07-25 05:14:37 +02:00
Michel de Graaf dab17774ab docs: use recorded terminal sessions for both demos
The README demo is a fresh recording of the v7 TUI, and the agent
section now shows a real agent session — loading the skill and driving
the CLI — in place of the hand-written terminal mock.

Both are asciinema recordings rendered to SVG at 10x, with frames
coalesced at 20fps to keep them small.
2026-07-25 05:07:37 +02:00
Michel de Graaf 307f1713b8 docs: replace the demo gif with an animated SVG of the TUI
Recorded with asciinema against the live network and rendered with
svg-term, so the demo is vector text instead of pixels: 20 KB gzipped
against 400 KB, crisp at any width, and it links through to the site.
2026-07-25 04:15:32 +02:00
Michel de Graaf e43253a7db ci: let a token opt the release PR into running CI
release-plz opens the release PR as app/github-actions, and GitHub parks
workflow runs from that actor at action_required, so the PR shows no checks
until someone approves each one by hand. Passing a PAT instead makes the PR
come from that identity and its CI runs on its own.

The fallback keeps today's behaviour until RELEASE_PLZ_TOKEN exists, so this
is inert until you set it. Only the PR job needs it; tagging and publishing
are fine with GITHUB_TOKEN.
2026-07-25 04:08:37 +02:00
Michel de Graaf 0b332c5170 ci: let the tap update its own formula
The homebrew job needed a cross-repo PAT in HOMEBREW_TAP_TOKEN, which resolved
to an empty string and failed every release with "Input required and not
supplied: token". A tap can write to itself with the GITHUB_TOKEN it already
has, so the formula is now generated by an Update formula workflow in
michel/homebrew-tap that reads the latest release and commits when it changes.

That removes the secret from the release path entirely. Verified by corrupting
the formula and watching the workflow put it back.
2026-07-25 04:00:20 +02:00
Michel de Graaf c27f2b8467 ci: stop cancelling master runs, and revert the release job conditions
The empty homebrew tap was never a condition problem. The release job ran, the
matrix built all six targets, and the tap step then failed with "Input required
and not supplied: token" because HOMEBREW_TAP_TOKEN resolves to an empty
string. Four rounds of edits to those `if` expressions chased a symptom, so
they go back to what they were.

What stays is the CI concurrency change: a cancelled master run leaves the
release gate waiting on a CI run for a commit nothing will push again.
2026-07-25 03:56:10 +02:00
Michel de Graaf 53ff1045cb fix(ci): gate the binary and formula jobs on the event, and show the tag
Three context spellings were tried in these job conditions and all three left
the jobs skipped: `inputs.tag`, `github.event.inputs.tag`, and
`needs.release.outputs.tag`. The run logs show `Resolve tag` succeeding with
INPUT_TAG=v7.0.0 and writing the output, so the value exists in the step and
is gone by the time a downstream `if` reads it.

`github.event_name` is the one context this file already proves works in a job
condition, because ci-gate skips correctly on dispatch. Gate on that, and echo
both outputs at the end of the release job so the next failure names itself
instead of costing four runs.
2026-07-25 03:51:06 +02:00
Michel de Graaf a5339ac949 fix(ci): gate the binary and formula jobs on the resolved tag
Neither `inputs.tag` nor `github.event.inputs.tag` evaluates in a job-level
`if` on workflow_dispatch, so both spellings left the binaries and homebrew
jobs skipped on every manual recovery run, while the step-level condition on
the same input fired correctly and resolved the tag. The run log settles it:
INPUT_TAG reached the step as v7.0.0 and the jobs still skipped.

`Resolve tag` already runs exactly when there is something to build, so its
output is the honest signal, and a `needs` output does evaluate here.
2026-07-25 03:45:43 +02:00
Michel de Graaf c6d41796c9 feat(tui): give the panes the colour the demo promised
The cursor row set a foreground, and ratatui only skips a cell's colour
when that field is None — so selecting a row repainted its gold sizes,
blue usernames and status glyphs into one flat cream. Table cursors now
tint the background only, over a warm oxide wash instead of neutral.

Browse files, their sizes and the settings popup rendered in the raw
terminal foreground; they take the palette now, and free slots read
green when a peer has one.
2026-07-25 03:44:07 +02:00
Michel de Graaf fce4b765b7 fix(ci): make the manual tag input actually reach the recovery jobs
The `tag` dispatch input exists so binaries and the homebrew formula can be
rebuilt for a tag that already shipped. It never worked: the job-level
conditions read `inputs.tag`, which is empty in a job `if` on
workflow_dispatch, and `null != ''` coerces to false, so both jobs silently
skipped. That is why v7.0.0 reached crates.io with an empty homebrew tap.
The step-level condition used the same expression and did fire, which is what
made the two disagree. `github.event.inputs.tag` is populated in both places.
2026-07-25 03:43:34 +02:00
Michel de Graaf fe079b4c94 fix(website): give the wordmark cursor the gap it was drawn with
The cursor sat 0.06em after the text, close enough to read as a stray glyph.
The wordmark SVG puts it 15.4 units after a 27-unit block, so 0.57 cursor
widths, which is 0.33em at the component's 0.58em cursor.
2026-07-25 03:38:10 +02:00
Michel de Graaf 430a985152 fix(website): give the wordmark cursor the gap it was drawn with
The cursor sat 0.06em after the text, close enough to read as a stray glyph.
The wordmark SVG puts it 15.4 units after a 27-unit block, so 0.57 cursor
widths, which is 0.33em at the component's 0.58em cursor.
2026-07-25 03:38:03 +02:00
Michel de Graaf 06459ddced docs: use the wordmark at the top of the README
The mark is a glyph; the wordmark reads "soulseek-rs" outright, which is what
a README header wants. Both tones ship so prefers-color-scheme can pick one.
Named for the background they sit on rather than their Figma tone, because the
existing logo-ink/logo-light pair means the opposite of what it sounds like.
2026-07-25 03:34:44 +02:00
Michel de Graaf 6b755872ff docs: mention Homebrew where the README lists how users install
The project-structure summary still named cargo as the only route.
2026-07-25 03:22:45 +02:00
Michel de Graaf 9e41456507 docs: follow up the v7 release and the homebrew section
The homebrew install text arrived with an em-dash in it, and both READMEs still
told library users to depend on version 6.
2026-07-25 03:22:04 +02:00
Michel de Graaf 12c8147f71 docs: point the README logo at the website's copy
The logo already lived in website/public; copying it into fixtures meant two
files to keep in step for no gain. The README now references the original.
2026-07-25 03:21:33 +02:00
github-actions[bot] 9de1547680 chore: release v7.0.0 (#8)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-25 03:21:14 +02:00
Michel de Graaf 9c966672af feat: install via homebrew (#9)
Add a release job that regenerates Formula/soulseek-rs.rb in michel/homebrew-tap
from the archives the binaries matrix uploads, and lead with
`brew install michel/tap/soulseek-rs` in the READMEs and on the website.
2026-07-25 03:18:53 +02:00
Michel de Graaf d84360a803 docs: drop the em-dashes from the README
Thirty-eight of them, in three roles that each wanted a different fix: label
separators in lists became colons, table cells standing in for "no value"
became the word they meant (required, unset, not a config key), and the
mid-sentence asides became commas, parentheses, or two sentences.
2026-07-25 03:18:37 +02:00
Michel de Graaf 13c58feafe docs: open the README with the logo and the site's own intro
The logo ships in both variants the site uses, picked by prefers-color-scheme
so it stays legible on either GitHub theme — the names are inverted from what
you would guess, `ink` being the cream one for dark backgrounds. The opening
copy now matches the site rather than describing the project twice in two
voices, and still says the repository holds a library as well as a client.
2026-07-25 02:55:47 +02:00
Michel de Graaf 692e8008e0 docs: link the website from the crates and the READMEs
The site went up but nothing pointed at it. `homepage` in the workspace
package metadata gives both crates.io pages a link, and each README opens with
one, so someone landing on the crate or the repo can find the install guide
and the config reference.
2026-07-25 02:53:27 +02:00
Michel de Graaf 9d58bb9b64 refactor: clear the quality-scan findings
`render_shortcuts` was 144 lines of "which shortcuts apply" wrapped around 40
lines of drawing; the decision moves into a `shortcuts` method beside the
`rooms_shortcuts` helper that already worked this way. Three identically
shaped comments over the popup guards collapse into one that carries the part
worth knowing — an open popup owns the keyboard, so the order matters. The
state tests trade bare `unwrap()` for `expect()` so a failure names the step.
2026-07-25 02:32:11 +02:00
Michel de Graaf 14b0321dbc docs: document the skills command and the scriptable surface
The READMEs still described the flags and spellings the one-shot rework
replaced, and said nothing about `skills`. The root README gains an agent
skills section covering install, update and removal; the crate READMEs catch
up with the current command set, record shapes and library API.
2026-07-25 02:32:05 +02:00
Michel de Graaf cd41688803 docs(website): explain what the skill unlocks, and document config.toml
The agent-native copy described what the skill contains rather than what it
buys you. A new home-page section shows the shape of the request that becomes
possible — describe the outcome, let the agent write the pipeline — and names
the specific knowledge --help cannot carry: which JSON keys a record has, that
exit 4 means widen the query, that a remote path goes back byte-identical,
that --follow never returns.

The install page gains a reference for all nine config.toml settings with
their defaults and an example file, next to the paths that were already there.
The list is the one `config set` accepts, checked against FileConfig::KEYS.
2026-07-25 02:27:47 +02:00
Michel de Graaf 17e4bf9fd3 docs(website): lead with the agent-native story
`skills install` becomes the featured card on the home page, in the wide slot
the grid gives its first entry: one command hands a coding agent the JSON keys
of every record, the exit-code meanings, and the --json rule, with nothing to
clone because the skill ships inside the binary. The docs page gains a callout
and the command in the shell listing.

Also catches the site up with the scriptable command surface the last two
releases introduced: `get`, `shares add`, `serve`, `room list`, `message send`
and `--json` replace the flags and spellings that no longer exist.
2026-07-25 02:24:58 +02:00
Michel de Graaf 95bc18672d feat(cli): ship the agent skill inside the binary
A `cargo install` leaves a binary and nothing else, so there is no file on
disk for a coding agent to read. `skills install` writes an embedded SKILL.md
into every agent found on the machine — Claude Code and opencode today, any
other via --dir — and installing over an older copy is what updating means.

The skill carries what --help cannot: the JSON keys of every record, what each
exit code means, and the rule that an agent always passes --json. A unit test
walks the clap command tree and holds SKILL.md to a documented entry for every
command, so a new subcommand fails the build until it is written up.
2026-07-25 02:17:11 +02:00
Michel de Graaf 03e8924a6b feat(cli): expose sharing, peer lookup and settings as one-shot commands
An agent driving the binary could search, fetch and chat, but it could not be
a peer: nothing kept a session online to serve files, nothing reported an
upload, shares could only be set at startup, and there was no way to ask
whether a peer was worth queueing to or to check credentials without doing
something else.

New commands:

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

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

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

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

Also pins the output contract with tests: under --json, stdout carries only
newline-delimited JSON across fifteen command shapes while progress still
goes to stderr, and a failing command writes nothing to stdout at all.
2026-07-25 01:30:48 +02:00
Michel de Graaf be6581ee6c feat(cli)!: replace the one-shot commands with a scriptable surface
The one-shot half of the binary could not finish its headline task without a
human at the keyboard: `search` opened a full-screen picker, panicked with
exit 101 when there was no terminal, and no command could fetch a known file
at all. Everything exited 0 whatever happened, wrote emoji prose to stdout,
and ignored --download-dir, SOULSEEK_DOWNLOAD_DIR and config.toml.

The commands are now built for scripts and agents:

  search  download  get  browse  room list|say|listen  message send|read

stdout carries records only (tab-separated, or NDJSON under --json), stderr
carries progress, and the exit code carries the verdict: 2 usage, 3 connect,
4 no results, 5 timed out, 6 transfer failed. `get` does search, rank and
fetch in one step, and `search --json | jq ... | download --stdin` composes.
Global flags parse in any position and every one has a SOULSEEK_ variable and
a config.toml key resolved through a single precedence chain.

Defects found and fixed along the way, each covered by a test:

- a download with an unknown size was finalized against zero bytes and saved
  as an empty file; the size now comes from the peer's share listing
- transfers are keyed by a hash of the remote path, so the same path from two
  peers shadowed itself and two files sharing a basename overwrote each
  other; both collisions are now dropped with a warning
- `message send` and `room say` slept 500ms and claimed success; they now
  wait for a server reply queued behind the message before reporting
- Client::login waited on an unbounded channel and never returned when the
  server went quiet

Deleted the TUI picker the old `search` drove (download_selector,
MultiDownloadProgress, SearchConfig): 1,573 lines out, 926 in. The
interactive TUI is otherwise untouched.

BREAKING CHANGE: `rooms` and `chat` become `room list|say|listen` and
`message send|read`; `--disable-listener` becomes `--no-listener`; the
unprefixed DISABLE_LISTENER, LISTENER_PORT, LOG_FILE and
MAX_CONCURRENT_DOWNLOADS variables are now SOULSEEK_-prefixed; `search` no
longer opens a picker or downloads anything.
2026-07-25 01:30:48 +02:00
Michel de Graaf c2dd227cee chore: strip the comments this branch added
Removes every comment introduced on this branch: website/ in full, pages.yml
in full, and only the blocks added to ci.yml and release.yml — their
pre-existing comments are untouched.

Two directives survive because they are load-bearing rather than prose: the
triple-slash vite/client reference, and nothing else.

Two comments were carrying behaviour that the code did not state, so the
code now states it. useCopy's rejection handler clears the copied flag
instead of sitting empty, and writeTheme returns whether the write actually
landed instead of swallowing the failure in a bare catch. isExternal drops
its regex for two startsWith calls, which reads the same and no longer
hides a `//` inside a literal.
2026-07-25 01:29:55 +02:00
Michel de Graaf ba92e75fd3 fix(website): stop the hero mismatching its own prerendered markup
Reading prefers-reduced-motion in the state initialiser made the first
client render disagree with the server for anyone who has it on: the server
always serialises the searching state, the client painted the resting one,
and React bailed out of hydration with #418 on every load. The preference
now applies a tick later, from the opening-run effect, so the first render
matches the HTML and the resting state arrives immediately after.

The header's home link was 19px tall — the most reached-for control in the
nav, and the only one under the 24px target minimum, while every nav link
beside it was already 44. It gets a 44px box.

The demo's search field also carries an id, name and type, which clears the
"form field should have an id or name" issue and stops browsers offering
autofill on a fake terminal input.
2026-07-25 01:21:53 +02:00
Michel de Graaf 1eef464677 refactor(website): cut the stock patterns out of the design
Audited against the AI-design catalog. The world stays — the terminal
aesthetic, the oxide accent, IBM Plex Mono are all chosen, and the audit
cleared them. What went is the handful of defaults that had crept in
underneath it.

Eight feature cards at identical weight said every feature mattered equally.
Search is what the client is for, so it takes a double-width cell and the
grid drops to three up; eight cards plus that one double still comes out
square.

Each glyph sat in a 34px bordered square — the stock icon-in-a-chip
treatment that ships with every component library. The glyphs are the real
TUI's, so they now sit inline like the prompt markers they are.

Callouts lose the coloured left rule. The catalog puts it plainly: a
coloured left border is about as reliable a sign of generated design as an
em-dash is in prose. The tone already lives in the label, which was saying
the same thing twice.

Thirteen arrow glyphs were typed into labels and link text. The
leaving-the-site mark belongs to the control, not the copy, so Button draws
it once, aria-hidden, and only when the link actually leaves. The "read
more" arrows on internal links were pure flourish and are gone.

The hero also drops "blazingly fast Rust", which TONE-OF-VOICE.md lists
under banned, no exceptions.
2026-07-25 00:54:49 +02:00
Michel de Graaf 310149c718 fix(website): draw the keycap edge with a shadow instead of a heavy border
A 2px border on one side of a rounded box thins as it turns the corner, so
the keycap's bottom edge tapered exactly where a real key looks solid. An
unblurred 1px shadow sits under the full curve instead — same weight, no
taper — which is the look the design system's `.key` rule describes.

Also records the one detector finding that is not a defect: the single-font
warning on index.html. tokens/typography.css calls monospace
"non-negotiable: it's a terminal application", hierarchy comes from three
weights and a five-step scale (the rule's own stated exception), and the
second family it asks for is already there — --font-forum, a system stack
the scanner cannot see because it needs no @font-face. Scoped to that one
file so the rule keeps working everywhere else.

config.local.json holds this machine's hook consent, so it is gitignored
rather than shared.
2026-07-25 00:44:58 +02:00
Michel de Graaf 74ad230150 fix(website): clear WCAG AA across both themes, and stop the hero shifting
Contrast was measured, not eyeballed: 41 foreground/background pairs across
both themes and the terminal surface, each against the darkest (light) or
lightest (dark) surface it actually sits on. Five failed. Links on manila
read 3.64:1 and the footer's "No telemetry. Ever." — a claim the site goes
out of its way to make — sat at 2.86:1.

The accent needed splitting rather than moving. #c1502e clears 4.5:1 as a
button fill behind white but only 3.99:1 as an 11px eyebrow on vinyl, so
--accent keeps the brand hue for fills and a new --accent-text carries the
readable cut. The terminal gets the same treatment dark emulators give their
palettes: alarm and oxide brighten inside .tui-content so "Failed" is
legible next to its glyph.

Reduced motion no longer nukes everything. A blanket 0.01ms also kills the
transitions that answer a click, which is feedback rather than movement;
looping and travelling animation stops, the rest caps at 80ms. The TUI
already skipped its opening run and still does.

The hero reserves its box with aspect-ratio from the first paint. It was
prerendered at full unscaled height and collapsed once JS measured, moving
everything below it by ~145px. Measured shift is now 0.

Also: the webfont loads non-blocking (display=swap meant the first frame
never waited for it anyway), footer link groups nest under a heading instead
of sitting level with page sections, and eight tokens nothing referenced
stop shipping.
2026-07-25 00:40:13 +02:00
Michel de Graaf 8b8383afdf feat(website): drop the tmux bar from the hero demo
The demo is the client, not the terminal it happens to run in. Removing the
bar takes the tmux window list, session name, host and clock with it, plus
the three --tmux-* tokens that existed only to paint it.

The scale floor goes too: the terminal now shrinks to whatever width it is
given instead of stopping at half size and scrolling sideways, which makes
useFitScale's minScale parameter and the media query feeding it dead weight.
2026-07-25 00:20:46 +02:00
Michel de Graaf 723700753a feat(website): sync headline copy from the design
The headline drops "Blazingly fast, in Rust" and leads with what the thing
is, lowercase, the way you type it. The sub-heading loses "with no window
and no mouse", which the headline already implies.
2026-07-25 00:10:56 +02:00
Michel de Graaf 4c332de583 fix(website): point canonical and og:url at the custom Pages domain
GitHub Pages serves this account's project sites from re-invention.nl, not
michel.github.io, so every prerendered page was advertising a host that does
not serve it.
2026-07-24 23:59:30 +02:00
Michel de Graaf f60aaac0a8 feat(website): sync the latest design changes
Headline now leads with agents alongside people, and the second callout
swaps sharing advice for what the shell subcommands make possible — cron, a
script, or an agent driving the client with no daemon to stand up.

Mobile gets the design's newer treatment: the eyebrow and the sub-heading
step aside so the headline and the install button are all that precede the
terminal, nav links and the button row centre, the footer keeps two columns
with the brand spanning both, and under 520px the platform strip drops its
labels to a centred row of icons. That last one makes the icon the only
thing naming the platform, so it carries an aria-label now rather than
being hidden from assistive tech.
2026-07-24 23:58:49 +02:00
Michel de Graaf 33bfc34dc8 feat(website): add the project site and deploy it to GitHub Pages
A Vite + React + TypeScript + Tailwind v4 site under website/, built from
the soulseek-rs design system so the site and the TUI stay one product.
The colour tokens in src/index.css are the same values as
soulseek-rs/src/ui/styles.rs; change one, change both.

The hero is a working recreation of the TUI: it plays one scripted run and
then takes the keyboard, or lands on the resting state directly under
prefers-reduced-motion. Its sample data is invented or public domain, per
the tone-of-voice guardrail against naming copyrighted material.

Static output rather than an SPA shell. `bun run build` compiles the client,
compiles the same app for Node, then renders every route in src/lib/routes.ts
to its own file with that route's title, description and canonical URL. A
direct link to /install therefore answers 200 with real markup instead of
GitHub's 404 page. routes.ts is keyed into App.tsx by literal path, so adding
one without the other fails typecheck rather than shipping a page whose body
says "not found" under the right title.

Theme is resolved once by an inline script before first paint and adopted by
React through useSyncExternalStore, so there is no flash and no hydration
mismatch on the toggle. Light mode re-cuts the semantic hues darker: phosphor
and tape read on vinyl but wash out on manila.

CI and release skip website-only pushes, and Pages skips Rust-only ones. The
two Rust workflows share one paths-ignore list so they can only skip
together, which keeps the release ci-gate from polling for a run that was
never queued. That gate also now treats a cancelled CI as "keep waiting"
rather than a failure, since cancel-in-progress cancels the older SHA on
every back-to-back push.
2026-07-24 23:42:41 +02:00
Michel de Graaf 54feb3bc5a fix(peer): log disconnect cause; bind e2e browse listener on all interfaces
The brokered third-party browse e2e failed deterministically on CI. The test
bound its listener to 127.0.0.1 only, but the server brokers back the address
it observed for that client - the host's routable IP (172.17.0.2 under Docker,
10.x on a CI runner). The sharer's dial was refused, it fell back to
re-brokering, and the accept never arrived. The sibling third-party browse
tests already bind 0.0.0.0.

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

TUI:
- Uploads share the downloads table columns (real Speed) and the info pane now
  renders details for a selected upload.
- Download progress bar counts only in-progress transfers and hides when idle.
- Always-visible unread badge (private messages + rooms) in the shortcuts bar,
  self-drawn blink so it works under tmux.
- Persist the unread private-message count across restarts.
2026-07-24 21:18:20 +02:00
Michel de Graaf 105c798855 feat(ui): theme the TUI with the soulseek-rs design system
Replace the ANSI Light* palette with the design system's tokens
(tokens/colors.css): oxide accent, phosphor focus, tape/signal/alarm
semantics, paper text on dust chrome. Only foregrounds and the row
highlight are painted so the user's terminal background shows through.

Route every bordered pane through a single pane_block() helper carrying
one column of horizontal padding, and give legends a consistent
[n] Title form with the tag in signal. The command bar now derives its
cursor position from the block's inner rect instead of a hardcoded
border offset.

Also collapses the download-status glyph match that was duplicated
across three render sites into download_status_glyph().
2026-07-24 21:18:20 +02:00
Michel de Graaf 2c29e1a39b ci: bound job runtime so a hung e2e cannot stall a release
The e2e suite talks to a real soulfind server over the network. On 298ec5a
it stopped responding and sat in_progress for over half an hour with every
other job green. No job declared timeout-minutes, so GitHub's six hour
default applied: six hours of runner time, and six hours during which the
new release gate correctly refuses to publish.

Limits are sized well above observed runtimes (lint ~1m, fmt ~15s, test
~2m, e2e ~1m) so they only fire on a genuine hang. A hang now surfaces as a
bounded red run that can be re-run, rather than an unbounded one.

This does not address why e2e hangs; it stops that from being unbounded.
2026-07-24 14:02:12 +02:00
Michel de Graaf 37d146b907 fix(persist): use XDG paths on macOS
Config and state now live in ~/.config/soulseek-rs and
~/.local/share/soulseek-rs instead of Application Support.
2026-07-24 14:02:12 +02:00
Michel de Graaf 298ec5aad5 build: strip release binaries and gate publishing on CI
Two gaps left over from the v6.0.0 release.

The v6.0.0 archives shipped unstripped: 3.63 MB vs 4.53 MB locally, 168
symbols instead of 9483. Setting it on the profile rather than in the
workflow covers every target at once and `cargo install` along with them.

Nothing gated the crates.io publish. A published version can be yanked but
never withdrawn, so the release job now waits for the CI run on the same
commit and refuses to publish unless it is green. release-pr deliberately
does not wait; opening the release PR is harmless.

This is not hypothetical: CI failed on eef7bf7, the v6.0.0 release commit.
It was a flaky e2e timeout rather than a regression - the very next commit
touched no code and passed - but the gate would have stopped that release,
which is the point. The gate reads the newest CI run for the commit, so
re-running a flaky job and then re-running Release clears it.
2026-07-24 13:27:09 +02:00
Michel de Graaf 237068cc2c ci: build musl targets with cargo instead of cross
upload-rust-binary-action reaches for `cross` on any *-musl target. Every
entry in the matrix builds on a runner of its own architecture — only the
libc differs — so cross buys nothing, and on the native arm64 runner it
hard-failed:

    error: toolchain 'stable-x86_64-unknown-linux-gnu' may not be able to
    run on this system

That cost v6.0.0 its aarch64-unknown-linux-musl archive; the other five
targets uploaded because the matrix is fail-fast: false.

Re-running the failed job from the Actions UI replays the old workflow file,
which is useless when the workflow is the bug. So the Release workflow now
takes an optional `tag` input that rebuilds and re-uploads binaries for an
existing tag. That value reaches actions/checkout as `ref:` and is typed by
whoever dispatches, so it is checked against a literal version tag first.
2026-07-24 12:56:49 +02:00
github-actions[bot]andMichel de Graaf eef7bf755f chore: release v6.0.0 (#4)
* chore: release v6.0.0

* docs: document the 6.0.0 breaking changes

cargo-semver-checks flagged four major-breaking changes in soulseek-rs-lib,
none of which carried a `!` or BREAKING CHANGE footer, so the generated
changelog had no migration note for a major bump.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Michel de Graaf <michel@re-invention.nl>
2026-07-24 12:47:21 +02:00
Michel de Graaf 720e0676f8 ci: stop running the matrix twice per pull request
The push and pull_request triggers both matched every branch, so each PR
kicked off two identical runs of lint, the three-OS test matrix and the
soulfind e2e job. Push now only covers master; branches are covered by the
pull_request trigger. Adds a concurrency group so a follow-up push cancels
the superseded run instead of queueing behind it.
2026-07-24 12:37:28 +02:00
Michel de Graaf 6403c11c8f ci: automate releases with release-plz
Replace the 326-line semantic-release/npm workflow with release-plz, which
handles the whole chain the old one only half did:

- versioning and CHANGELOG from the conventional commits already in use
- a `chore: release vX.Y.Z` PR as the gate; merging it tags and releases
- publishes both crates to crates.io in dependency order, waiting for the
  index between them (RELEASE.md used to ask you to do this by hand)
- release-plz.toml keeps the two crates on one version, one tag, one changelog

Binaries now cover six targets instead of four. Linux switches from gnu to
musl so the downloads are statically linked and stop caring about the host's
glibc version; linux-aarch64 and windows-aarch64 are new. Archives carry
sha256 checksums plus README and LICENSE.

CI drops twelve hand-rolled actions/cache steps for Swatinem/rust-cache.
2026-07-24 12:37:28 +02:00
michel 5e34722a1b fix: drop libdbus dependency from keyring Linux backend
CI (and any Linux build without libdbus-1-dev) failed compiling
libdbus-sys, pulled in by keyring's sync-secret-service feature. Switch
to the pure-Rust zbus backend (async-secret-service + async-io +
crypto-rust): same Secret Service keychain, no system C library
needed.
2026-07-22 17:14:02 +02:00
michel 724796c1eb feat: settings popup and uploads in the transfers pane
Press 'o' for a settings popup: edit the download folder, add/remove
share paths (applied live via a rescan and persisted to config.toml),
and force a re-index with 'r' for files changed on disk. The state
machine is unit-tested; feedback shows the shared file/folder counts.

The downloads pane is now Downloads/Uploads: uploads being served are
listed after downloads with an up-arrow marker, live progress, and
status; 'x' cancels an in-progress upload. Client::shared_counts exposes
the index size for the status line.
2026-07-20 20:56:39 +02:00
michel 4936d4dd2a fix: clippy cleanups in upload tracking and share plumbing 2026-07-20 20:52:08 +02:00
michel 6238c44312 feat: track uploads with progress and cancellation
serve_file streams in chunks, updating a shared byte counter and
aborting with Interrupted when a cancel flag is set. Each served upload
is registered (peer, virtual path, size, live progress, status) and
exposed via Client::uploads(); Client::cancel_upload flags a matching
in-progress transfer. e2e: the download test now also asserts the
uploader recorded a completed upload with the right peer and byte
count.
2026-07-20 20:51:22 +02:00
michel be8197fd31 feat: update shared directories at runtime
Client::set_shared_directories rescans the given roots into a fresh
in-memory index, swaps it for peers, and re-announces the folder/file
counts to the server. The current directory list moves into the client
context so the running set is queryable. e2e-verified: a browser sees a
directory that was added after login.
2026-07-20 20:49:01 +02:00
michel a605431a10 feat: multiple shared directories
Shares::scan_many merges several roots into one index, suffixing
duplicate root names so virtual paths cannot collide, and skipping
unreadable roots instead of failing. ClientSettings now takes
shared_directories: Vec<String>; config gains a shared_dirs list
(combined with the single shared_dir and --shared-dir, deduped), with an
explicitly empty value still disabling sharing and the
share-what-you-download default unchanged.
2026-07-20 20:47:23 +02:00
michel 8389d525e7 feat: conventional default download and share folders
The default download directory is now the platform Downloads folder plus
a Soulseek subfolder (via the directories crate — XDG on Linux, known
folder on Windows) instead of a tilde path that only worked with a HOME
variable. Sharing follows the Soulseek convention of sharing what you
download: shared_dir defaults to the download folder, and an explicitly
empty shared_dir opts out. The folder is created at startup so first-run
share validation succeeds.
2026-07-20 20:43:27 +02:00
michel 8221045aa0 feat: set aside unusable state files as .bak instead of overwriting
When a state file is corrupt, unreadable, or from a newer build, rename
it to <name>.json.bak (replacing any older backup) before loading empty,
so the next save can't silently destroy it and the user can inspect or
recover the contents.
2026-07-20 20:26:36 +02:00
michel 36819d4c42 test: persistence e2e and registration-relogin e2e; lint clean
Adds a lib target (soulseek_rs_tui) exposing cli/models/persist so
integration tests can drive the persistence pipeline: state and config
survive a simulated restart through the real files, with the incomplete
download flagged for auto-resume. The lib e2e suite gains a
register-then-relogin test against soulfind proving the stored-credential
flow's server assumptions. cargo clippy --all-targets and cargo fmt are
clean.
2026-07-20 20:20:54 +02:00
michel c945bd5d5e feat: persist and restore TUI state across restarts
The main TUI now loads state on startup — search history restored as
inert entries, saved chat rooms rejoined on the server, and incomplete
downloads (including failed/timed-out ones) automatically re-enqueued —
and saves a diffed snapshot each loop tick and on exit, so only changed
files are rewritten.
2026-07-20 20:17:44 +02:00
michel d410f9e834 feat: versioned JSON state stores for downloads, searches, rooms
StateStore writes {version, data} envelopes under the platform data dir
with atomic tmp+rename writes and a per-file migration chain (data at
version i is upgraded by MIGRATIONS[i]). Missing, corrupt, or
newer-than-known files load as empty so state can never block startup.
2026-07-20 20:15:33 +02:00
michel d913c5cc71 feat: in-TUI login/registration screen
The default TUI path now brings the terminal up first and runs a login
screen whenever no working credentials exist: a pure-state-machine form
(unit tested) driven by an async connect/login attempt on a background
thread. Soulseek registers unknown usernames on login, so the same form
covers registration. On success the username is saved to config.toml and
a form-entered password to the OS keychain; stored credentials skip the
form entirely. Failed logins show the server error and retry instead of
aborting.
2026-07-20 20:14:17 +02:00
michel aaeb62efa4 feat: password resolution via OS keychain and password_cmd
SecretStore trait with a keyring-backed implementation (macOS Keychain,
Windows Credential Manager, Linux Secret Service) under service
soulseek-rs. Precedence: --password/env > keychain > password_cmd from
config.toml. Keychain failures degrade to not-found so headless boxes
fall through to password_cmd.
2026-07-20 20:11:41 +02:00
michel 86e3d8c58f feat: config.toml settings layer (CLI > env > file > defaults)
New persist module in the binary crate (lib stays zero-dep): FileConfig
loaded from a platform-appropriate config path via the directories crate,
with resolution precedence CLI/env (clap) > config.toml > built-in
defaults. Clap defaults move into the resolver so the file layer can sit
between.
2026-07-20 20:10:33 +02:00
Michel de Graaf 089a9da31a docs: drop zero-dependency bullet from README features 2026-07-20 11:19:38 +02:00
Michel de Graaf 96ec2a27ab docs: add features section to README 2026-07-20 11:03:24 +02:00
Michel de Graaf c463d80b3c refactor: apply review cuts
- replace hand-rolled clamp_cursor_to_char_boundary with
  str::floor_char_boundary at its four call sites, drop the helper
- dedupe the duplicated Layout::vertical constraint arms in render()
- drop stale #[allow(dead_code)] on spinner_state (field is read)
2026-07-20 11:02:43 +02:00
Michel de Graaf 2918b0ce75 refactor: resolve all lint findings
- remove 34 trivial restating comments (auto-fix)
- replace all 20 production .unwrap() calls with graceful handling:
  ?-propagation via LockPoisoned, unwrap_or_default on infallible
  conversions, let _ = on actor-channel sends, and a downloads_sender()
  helper in the TUI that also dedupes three channel-init blocks
- collapse nested if into match guard (clippy::collapsible_match)
- split client.rs (2275 lines) into a client/ module: connection,
  search, downloads, uploads, rooms, operations
- split ui/main_tui.rs (2027 lines) into a main_tui/ module: render,
  input, browse, rooms, downloads, search
- extract oversized functions: peer_actor/server_actor handle_message
  into per-message helpers, main() into init_logging/run_default_tui,
  download_selector and main_tui render into per-section helpers

Mechanical only, zero behavior change. Verified: clippy clean
(pedantic+nursery), fmt clean, 143 unit tests + 16 e2e tests against
soulfind pass, lint clean.
2026-07-20 10:53:57 +02:00
Michel de Graaf 17864ac9b0 fix: tui lag keyboard navigation 2026-07-20 10:25:42 +02:00
Michel de Graaf 9b0199fbbc chore: bump version to 5.0.0 and drop the WIP warnings from the README 2026-07-20 09:10:36 +02:00
michel 6821fd1907 fix: two medium bugs from the final adversarial review
- Retry was broken for the common case: retry_selected_download removed
  the failed download only from the UI list, not the client's
  DownloadStore. Since Client::download re-derives the same md5 token
  from the unchanged filename, retrying a still-Failed download left a
  duplicate-token entry; the token-migration path then deleted both,
  misrouting completion so the retried file downloaded to disk but the
  UI showed 'Queued' forever. Add Client::remove_download /
  DownloadStore::remove_by_file (status-agnostic) and clear the stale
  entry before re-issuing. Regression-tested.

- Chat-room member selection could point out of range: RoomEvent::UserLeft
  shrank the active room's member list without clamping user_selected, so
  the highlight silently shifted to the wrong user (or vanished, making
  'b'/'m' no-ops, and blanking the member pane). Clamp user_selected after
  every applied room event. Regression-tested.
2026-07-20 07:26:03 +02:00
michel 2d5fc26db7 test(net): e2e-test the UPnP path + add a portmap diagnostic
Address the gap that the port mapper's UPnP path and gateway discovery
had no automated coverage (only NAT-PMP did):

- UPnP e2e: split map_via_gateway out of discovery and drive it through
  igd-next against an in-process mock IGD (a std HTTP server returning a
  canned SOAP envelope). Verifies the real add_port SOAP round-trip and
  that we request the exact external/internal port + LAN client.
- default_gateway parsing is now pure, testable functions with unit
  tests for the macOS and Linux formats (including the 0.0.0.0 skip).
- New 'portmap' CLI subcommand (needs no login): synchronously probes
  UPnP/NAT-PMP against the real router and reports whether the port was
  opened + the external address, then removes the test mapping — so a
  user can verify reachability on their own network.

9 port_mapping tests now cover the NAT-PMP protocol, the UPnP SOAP
round-trip, gateway parsing, and the map/renew/cleanup lifecycle.
2026-07-19 21:46:46 +02:00
michel 84c571767a fix(net): harden port mapper from adversarial review
- Shutdown no longer freezes: Drop signalled stop but then blocked on a
  thread join, and nothing observed stop during the blocking UPnP/NAT-PMP
  discovery (up to ~9s). Drop now waits on a done-channel with a 1s cap
  and detaches otherwise, so quitting is always prompt; the finite lease
  cleans up if we detach mid-discovery. Regression-tested against an
  unresponsive gateway.
- Linux default_gateway() no longer returns 0.0.0.0 for a gateway-less
  (directly-connected) default route — it keeps scanning, matching the
  macOS branch, instead of firing pointless NAT-PMP at the unspecified
  address.
- UPnP now retries AddPortMapping with a permanent (lease 0) mapping when
  the router rejects a timed lease (IGDs that only support permanent
  leases), and renews/removes with the accepted lease.
2026-07-19 21:26:51 +02:00
michel 9f74300c7a fix(tui): stop raw prints from corrupting the TUI
Log lines were painting over the alternate-screen TUI because some
messages bypassed the buffered logger and wrote straight to the
terminal:

- a live println! in GetPeerAddressHandler fired on every peer-address
  lookup (i.e. constantly while browsing/downloading) — routed to the
  buffered debug! logger.
- the TUI's error handlers used eprintln! in the render loop and in
  background search/download worker threads — routed to the buffered
  warn! logger so failures no longer smear the screen.

The logger already defers to a buffer while the TUI holds the screen; the
fix is simply to go through it instead of print!/eprintln!.
2026-07-19 21:22:38 +02:00
michel 34babf5808 docs: document automatic UPnP/NAT-PMP port mapping and reachability 2026-07-19 21:10:56 +02:00
michel 5ba2f9e181 feat(net): auto-map the listen port via UPnP-IGD and NAT-PMP
Behind a home router the client is unreachable for incoming peer
connections, so browsing/downloading firewalled peers (and the
server-brokered connect-back) times out. Add a best-effort port mapper
to the binary crate that, when listening is enabled, makes us reachable:

- NAT-PMP (RFC 6886) hand-rolled on std::net::UdpSocket, zero deps, with
  an injectable gateway address so it is fully testable.
- UPnP-IGD via the blocking igd-next crate as the primary path.
- A background PortMapper that discovers off the main thread (never
  blocks startup), renews at half the lease, and removes the mapping on
  drop. Any failure is non-fatal and logged.
- Maps the EXACT listen port only (we advertise it verbatim via
  SetWaitPort, so a different external port would be unreachable) and
  warns the user to forward manually otherwise.

Wired into the TUI, search, and CLI browse (the P2P modes); chat/rooms/
messaging are server-mediated and skip it. e2e-tested against an
in-process mock NAT-PMP gateway: the map request bytes, response parsing,
result-code/timeout handling, and the full spawn -> map -> drop -> unmap
service lifecycle.
2026-07-19 21:10:15 +02:00
michel e59c2bc199 fix(tui): make browse retry work and de-jargon the copy
- Retry actually retries: 'r' in the browse popup re-requests a
  timed-out user (previously the 'Press b to retry' hint did nothing —
  b wasn't handled inside the popup). BrowseTabs::retry_active is
  unit-tested.
- Friendlier timeout copy that explains the likely cause: "Couldn't
  reach <user>. They may be offline, or their connection can't be
  reached (both of you may be behind a router/firewall). Press r to try
  again."
- Replace protocol terminology in user-facing labels: 'Peer slots' ->
  'Free slots', 'Peer upload speed' -> 'Upload speed'.
2026-07-19 20:49:32 +02:00
michel 1b62d218dd test(e2e): browse a firewalled peer via the server broker
Add an e2e that forces the server-brokered (firewalled) browse path —
the path that matters on the real network. The sharer does NOT listen,
so the browser's direct dial fails and the server must broker the peer
connecting back to the browser's listener. This exercises
PeerConnectFailed -> ConnectToPeer -> inbound PierceFirewall ->
PeerConnected flush, which the localhost direct-dial test never hits.

Also soften user-facing download failure reasons from protocol jargon
("Peer …") to plain language ("The user went offline", etc.).
2026-07-19 20:49:26 +02:00
michel 60896619d7 feat(tui): tabbed browsing — browse several users at once
Replace the single browse view with BrowseTabs (one tab per user),
mirroring the chat-room tabs. Browsing a new user (from search results
or a chat room's member list) opens or focuses their tab; Tab/Shift-Tab
switch between open users, 'w' closes the active tab, Esc hides the
popup while keeping the tabs. A tab bar shows the open users when more
than one is open, and each tab polls/loads independently. Re-browsing a
timed-out user retries. BrowseTabs is unit-tested.
2026-07-19 20:38:34 +02:00
michel 5a3bc6fcd5 feat(tui): browse or PM a user from a chat room's member list
The member list in an open room is now selectable with the arrow keys.
Press 'b' to browse the highlighted user's shared files (opens the
browse tree) or 'm' to send them a private message (opens the compose
bar pre-filled with the recipient). Selection resets when switching
rooms; RoomsState navigation is unit-tested.
2026-07-19 20:22:33 +02:00
michel 01796f5d87 fix(lib): bound untrusted count loops in existing protocol parsers
The same unbounded-count DoS class fixed in the room parsers also exists
in three pre-existing parsers that read a length-prefixed vector count
straight off the wire: FileSearchResponse (SearchResult), the browse
SharedFileListResponse, and ExcludedSearchPhrases. A malicious/MITM
server or peer could send a tiny frame with a ~4-billion element count
and spin the actor thread into an OOM allocation loop.

Bound each loop (and the inner attribute loops, where read_int32 does
not advance past the buffer end) by the remaining payload. Regression
tests feed a hostile count and assert prompt, empty parses.
2026-07-19 20:19:37 +02:00
michel 9595afe9fb fix(rooms): harden chat-room parsing and TUI state from review findings
Adversarial review of the chat-rooms feature surfaced seven confirmed
issues; all fixed with regression tests:

lib (DoS hardening — a malicious/MITM server could send a tiny frame
with a ~4-billion element count):
- room_list: bound the name/count vector loops by remaining payload.
- join_room: bound the member-list loop the same way.

TUI (soulseek-rs/src/models/rooms.rs, ui):
- a message for a room we already left no longer resurrects its tab
  (route via open_index, not ensure_open).
- closing a tab now clears the unread badge on the room we land on.
- a room-list refresh keeps the highlight on the same room across a
  re-sort (track selection by name, not index).
- Esc in the room list clears a lingering filter before closing, matching
  the title's 'Esc: clear' hint.
- chat log renders one row per line so the newest message is never
  clipped by wrapping.
2026-07-19 20:15:42 +02:00
michel ce75d0a101 docs: check off chatrooms and document the rooms CLI + TUI 2026-07-19 19:59:10 +02:00
michel 08272ac867 feat(tui): chat rooms — browsable list, multiple open rooms, unread badges
Add a chat-rooms popup (open with 'c') satisfying the requested UX:
- Browsable, /-filterable room list showing per-room user counts,
  busiest first; already-open rooms are marked.
- Multiple rooms open at once as tabs; Tab/Shift-Tab switch, 'x' leaves.
- Per-room chat log with a live member list and an inline compose line
  (Enter to type, Enter to send; the server echo renders the message).
- Unread indicators: bold tab name + 'room (n)' badge per room, plus
  'chat (n)' and 'inbox (n)' counters in the shortcut bar; viewing a
  room clears its badge.

RoomsState is unit-tested (routing, unread accounting, tab lifecycle,
filtering).
2026-07-19 19:58:32 +02:00
michel 7d94b2caa3 feat(cli): rooms and chat subcommands
Add `rooms` (list public rooms with user counts, busiest first) and
`chat <room> [message]` (join a room; say a message and exit, or listen
and print incoming messages/joins/leaves for a while). Factor a shared
connect_and_login helper.
2026-07-19 19:49:17 +02:00
michel 7bc0ee2c3d feat(lib): chat-room protocol (list, join, leave, say) with events
Implement the Soulseek chat-room server messages against soulfind's
wire format:
- MessageFactory builders: room-list request (64), join (14),
  leave (15), say (13).
- Handlers parsing the server side: RoomList (64) names+counts,
  JoinRoom (14) membership, SayChatroom (13), UserJoinedRoom (16),
  UserLeftRoom (17), LeaveRoom (15).
- Surface everything to the client as a drained RoomEvent stream plus
  a room-list snapshot; new Client API: request_room_list, join_room,
  leave_room, say_in_room, room_list, take_room_events.

Unit tests cover each builder and handler; server-gated e2e tests
exercise message delivery and room listing against soulfind.
2026-07-19 19:47:58 +02:00
michel ae28f4236a feat(tui): surface the failure reason on failed downloads
Carry a human-readable reason on DownloadStatus::Failed so it flows
through the existing status channel to the TUI. The download Info pane
now shows an Error line for a failed download (connect failure, peer
disconnect, truncated transfer, peer-reported upload failure, or the
underlying transfer error). Failures without a known reason render as
before.
2026-07-19 19:36:15 +02:00
michel 4809dd7059 feat(tui): retry failed downloads and clear finished ones
In the Downloads pane: 'r' re-queues the selected failed/timed-out download,
and 'c' clears all completed/failed/timed-out entries from the list. Shortcuts
bar updated.
2026-07-19 19:29:43 +02:00
michel aa8f4182d3 feat(tui): hierarchical browse tree with file/folder downloads
Wire browsing a user's shared files into the TUI as a modal tree popup:
- press 'b' on a highlighted search result to browse that result's owner, or
  'b' elsewhere to type a username (new CommandBarMode::Browse);
- a new models/browse.rs synthesizes the flat SharedDirectory list into a
  nested, collapsible tree (intermediate folders reconstructed from the
  backslash paths; file download paths kept byte-exact from the original names),
  with expand/collapse state and a flatten-to-rows renderer — fully unit-tested;
- the popup polls take_browse_result each tick (with a timeout), navigates with
  arrows/hjkl, expands/collapses folders, and queues downloads: Enter on a file,
  'd' on a file or an entire folder subtree (recursively) — all funneled into
  the existing Downloads pane;
- shortcuts bar advertises browse keys and surfaces our own sharing status.
2026-07-19 19:27:05 +02:00
michel 95bd6359c1 docs: check off Browse user files in the README TODO list 2026-07-19 18:57:39 +02:00
michel 7d8a435d25 feat(cli): add 'browse <username>' to list a user's shared files
soulseek-rs browse <username> connects, logs in, requests the peer's file
list and prints the directories and files it shares.
2026-07-19 18:57:14 +02:00
michel f3218475dc feat: browse another user's shared files
Add Client::browse_user (sends GetShareFileList, code 4, connecting first if
needed via the same queue-and-flush path as search replies) and
take_browse_result. A new SharedFileListResponseHandler parses the peer's
code-5 reply into SharedDirectory entries, forwarded as
ClientOperation::BrowseResult and stored per user. Verified end to end: a
second real client browses a sharer and receives its file listing.
2026-07-19 18:56:19 +02:00
michel 9e050b59d9 docs: check off Share files in the README TODO list 2026-07-19 18:52:06 +02:00
michel f3b24f34c0 feat(shares): reply to browse requests with a SharedFileListResponse (code 5)
GetShareFileList (peer code 4) previously replied with a malformed message (a
server SharedFolders stats message over a peer connection). It now emits
ShareListRequested, and the client builds a real SharedFileListResponse
(code 5) from its shares grouped by directory. Adds a std-only code-5
encoder/decoder (build_shared_file_list / parse_shared_file_list) with a
roundtrip test, and Shares::directories() to group files. The decoder is the
foundation for the Browse feature.
2026-07-19 18:51:55 +02:00
michel 54d7d8f8a3 test(shares): two real clients search and download a shared file e2e
End-to-end proof of the whole sharing stack with no mock peer: a sharer indexes
a temp file, the leecher's search is distributed by soulfind, the sharer replies
with a FileSearchResponse, and the leecher downloads the real bytes that the
sharer serves over a peer-to-peer transfer. Asserts the search result and that
the downloaded bytes match the source. Server-gated (SOULSEEK_E2E_REQUIRED).
2026-07-19 18:48:33 +02:00
michel 393f69e478 feat(shares): serve queued files to peers over F connections
Implement the upload path so a peer can download our shared files:
- fix the mis-coded PlaceInQueueResponse (43 -> 44) and add a real QueueUpload
  handler (code 43) -> PeerMessage::IncomingQueueUpload;
- the peer actor forwards it as ClientOperation::QueueUpload; the client looks
  the file up in its shares, mints an upload token (high half of the token
  space, disjoint from md5 download tokens), and offers it via ServeUpload ->
  build_upload_transfer_request (peer code 40, direction=upload);
- a TransferResponse for one of our serving tokens is handled by a surgical
  early-return that emits StartUpload, leaving the download path untouched;
- StartUpload resolves the downloader's address (GetPeerAddress, cached) and
  streams the file via a new peer::upload_peer::serve_file over an F connection.

Tested: build_upload_transfer_request decodes via the production Transfer
parser; serve_file streams a file correctly over a loopback F connection.
2026-07-19 18:46:06 +02:00
michel 9b6e533bed feat(shares): reply to distributed searches with our matching files
Wire the incoming server FileSearch (code 26): the handler now forwards
ServerMessage::FileSearchRequest -> ClientOperation::IncomingSearch instead of
just logging. The client matches the query against its shares and, if anything
matches, sends a FileSearchResponse to the searcher — connecting to them first
(GetPeerAddress -> outbound P) and flushing the response on PeerConnected when
not already connected. GetPeerAddressResponse now also caches peer addresses
for the serve path. Adds build_search_response + a unit test.
2026-07-19 18:39:05 +02:00
michel bcfe56e74f feat(shares): encode FileSearchResponse (peer code 9)
Add build_file_search_response + a borrowed FileEntry view: the zlib-compressed
reply advertising our matching shared files to a searcher. The payload is the
exact inverse of SearchResult::new_from_message (username, token, per-file
name/size/ext/attributes, slots, speed), compressed with the stored-block
encoder. Round-trip tested through the production decoder.
2026-07-19 18:35:11 +02:00
michel 7ff6ab9425 feat(shares): scan shared dir on connect and send a real login handshake
The post-login handshake (SharedFolders / HaveNoParent / SetStatus /
SetWaitPort) lived only in ServerActor::login(), which nothing ever called —
so a live client never advertised its listen port or share counts. Move it to
a testable post_login_messages() fired from the LoginStatus(true) arm (once, on
success, on the live path), and delete the dead login().

Client::connect now scans ClientSettings.shared_directory once into
ClientContext.shares and passes the real folder/file counts to the server.
This is the prerequisite for two live clients to resolve each other's
addresses (GetPeerAddress needs the peer to have sent SetWaitPort).

Unit-tested post_login_messages; the login/listener e2e still passes, proving
soulfind accepts the handshake.
2026-07-19 18:34:05 +02:00
michel 33c3338144 feat(shares): scan a shared directory into an in-memory index
Add a std-only shares module: Shares::scan walks the shared directory (skipping
symlinks and unreadable entries) into a read-only snapshot keyed by peer-facing
virtual path (root name + backslash-separated relative path). Exposes
search (case-insensitive multi-term AND), get (O(1) by virtual path), and
file/folder counts for the login handshake. Unit-tested against a temp tree.
2026-07-19 18:30:34 +02:00
michel 9794fe0786 feat(shares): add a std-only zlib stored-block encoder
Soulseek peer messages (SharedFileListResponse, FileSearchResponse) are
zlib-compressed on the wire, but the library only had a decoder. Add
compress_stored(): a valid zlib stream built entirely from uncompressed
DEFLATE 'stored' blocks (0x78 0x01 header, blocks of <= 0xFFFF bytes, adler32),
which needs no real compressor and is accepted by standard decoders and by the
existing inflate path. Roundtrip-tested through the decoder at block
boundaries, plus header/adler32 vectors.
2026-07-19 18:28:56 +02:00
michel 3908dceb2f fix: identity-aware peer eviction so a replaced actor can't evict its successor
When a healthy peer actor was replaced under the same username (both sides
connecting at once, or two overlapping downloads to the same not-yet-connected
peer), its graceful shutdown still emitted PeerDisconnected, and the client
evicted by username — removing and stopping the NEWER actor now occupying the
slot, tearing down the live connection and stalling that peer's downloads.

Give each peer actor a unique id (assigned by the registry) and echo it in
PeerDisconnected/PeerConnectFailed. The client now evicts via
remove_peer_if(username, id), which removes the entry only when the reporting
actor still owns the slot, so a stale actor's shutdown is a no-op. The brokered
timeout no longer removes an actor by username either, avoiding the same
aliasing. Add a registry unit test for the identity guard.
2026-07-19 17:54:50 +02:00
michel a186c9d07d fix: pierce the firewall on server-brokered reverse connects
When the server asks us to connect to a peer (a ConnectToPeer we receive,
carrying the server's correlation token), we dial that peer and must announce
ourselves with a PierceFirewall quoting the token — not a PeerInit — so the
remote peer can correlate its own indirect request (search-result / upload
delivery). The outbound handshake now sends PierceFirewall when the peer
carries a server token and PeerInit only for direct GetPeerAddress dials
(token None). Direct and firewalled e2e paths are unchanged.
2026-07-19 17:48:50 +02:00
michel ea25325e68 fix: reap failed outbound peer actors and bound the brokered connect
Two related lifecycle defects in the direct/firewalled download path:

- On a failed direct connect, ClientOperation::PeerConnectFailed left the dead
  outbound actor registered and running. The actor pinned a thread-pool worker
  forever (its loop only exits on Stop), so a handful of unreachable-peer
  downloads could exhaust the fixed pool and wedge the whole client; and a
  later download() to the same peer saw registry.contains()==true and queued
  into the dead, streamless actor, hanging silently. The arm now removes and
  stops the dead actor before brokering, mirroring PeerDisconnected.

- The server-brokered fallback had no timeout, so a peer that never connects
  back (offline / double-firewalled / unroutable address) left the download
  Queued forever and its Receiver blocked. Arm a bounded timeout: if no
  PierceFirewall consumes the correlation token within BROKER_CONNECT_TIMEOUT,
  fail the peer's queued downloads (on the channel and in the store) and
  reclaim the token. A successful pierce consumes the token first, so the
  timeout is a no-op on the happy path.

Add a unit test for the queued-download failure helper; the direct and
firewalled e2e tests still pass (pierce arrives well within the timeout).
2026-07-19 17:47:45 +02:00
michel 7c3bcc0598 fix: don't panic on a peer's obfuscated port (u16) — widen the field
GetPeerAddressResponse narrowed obfuscated_port (u16) into a u8 via
try_into().unwrap(), panicking for any real obfuscated port (>255) and
unwinding the client's single ops-loop thread — hanging all downloads,
searches and peer events. Since download() now auto-resolves peer addresses
via GetPeerAddress, a download to any obfuscation-enabled peer could crash the
whole client. Widen Peer::obfuscated_port to u16 and stop truncating.
2026-07-19 17:45:30 +02:00
michel 51c5397d8f style: use Duration::from_secs for whole-second sleeps in e2e
Satisfies clippy::duration_suboptimal_units (a stable-toolchain lint the
local nightly did not surface), unblocking the CI Lint job.
2026-07-19 17:35:24 +02:00
michel b82b03a143 feat: firewalled peer connections via server-brokered connect
Implement the FIREWALLED connection mode: when a direct outbound connection to
a peer fails (peer unreachable), fall back to asking the server to broker it.

- Add MessageFactory::build_connect_to_peer (server code 18).
- A pre-established outbound failure now emits ClientOperation::PeerConnectFailed
  instead of a normal disconnect; the client registers a correlation token and
  sends ConnectToPeer so the peer connects back.
- The listener accepts the peer's PierceFirewall (peer code 0) connect-back,
  matches the pending token, registers it as the P control connection, and
  fires PeerConnected to flush the queued download.

Fix a latent aliasing bug this surfaced: a peer actor could emit two terminal
notifications (a connect-failure, then a disconnect when later replaced and
stopped), and the second removed the *newer* actor registered under the same
username, killing the brokered connection mid-transfer. An actor now reports a
terminal outcome at most once (disconnect_reported guard).

Verified end to end against soulfind: the mock advertises an unlistened port so
the direct connect is refused, the server brokers the connection, the mock
pierces back, and the file transfers — bytes match. A companion test
(soulfind_brokers_connect_to_peer_between_users) pins the server behaviour the
mode relies on.
2026-07-19 17:26:52 +02:00
michel e4093b1ea8 feat: direct peer connections — download() connects to unconnected peers
Implement the DIRECT connection-initiation mode so a download can target a
peer the client is not already connected to:

- Fix the outbound peer handshake. on_connection_established sent
  build_watch_user() (a server WatchUser message, wrongly framed with
  get_data()) for outbound connections; it now sends a proper length-prefixed
  PeerInit (peer code 1) with our own username. own_username is plumbed in
  via PeerRegistry -> PeerActor; inbound peers (outbound=false) are unchanged.
- download() now auto-connects: if there is no control connection to the peer
  it sends GetPeerAddress and defers the QueueUpload until the outbound stream
  handshakes, signalled by a new ClientOperation::PeerConnected. A never-
  connected client still resolves the download to Failed (unit test).
- Add Client::connect_peer(username) to open/pre-warm a control connection.

Verified end to end against soulfind: a_file_downloads_from_a_peer_via_direct_connection
logs a mock peer into the server, advertises its port via SetWaitPort, and the
client resolves the address, dials it, and downloads the file — bytes match.
The F-serve leg is shared with the passive-download mock.
2026-07-19 17:12:17 +02:00
michel 06cd638736 fix: drive inbound peer connections non-blocking so downloads don't stall
A peer actor multiplexes socket reads with its mailbox on one thread: tick()
reads the socket while outgoing messages (e.g. a queued QueueUpload that starts
a download) are delivered through the mailbox between ticks. Inbound
connections accepted by the listener were left in blocking mode, so once such a
peer went idle tick() parked inside read() and never drained the mailbox — a
download queued against that peer never sent its QueueUpload and hung. Outbound
connections already set non-blocking; do the same for inbound P connections.

Covered by a new server-optional e2e test that downloads a file end to end from
an in-process mock peer over real P (control) and F (transfer) connections,
asserting the bytes written match the source. The mock speaks the library's own
public Message wire format, so no dependencies are added.
2026-07-19 13:58:05 +02:00
michel 4717e2787e docs: document private messaging in the README 2026-07-19 13:40:04 +02:00
michel 05b06ec2ec feat(tui): compose and read private messages
Add private messaging to the TUI:
- 'm' opens the command bar in message mode; type '<recipient> <text>' and
  Enter sends it via the client;
- incoming messages are polled each tick into an inbox;
- 'i' toggles a centered inbox popup listing sent/received messages.

The shared command bar now carries a mode (search vs message) so the prefix,
submit action and shortcut hints follow what is being typed.
2026-07-19 13:38:36 +02:00
michel f6127411d7 feat(cli): add 'message' subcommand to send private messages
`soulseek-rs message <username> <text>` connects, logs in, sends a private
message and exits. Smoke-tested against a local soulfind server.
2026-07-19 13:32:53 +02:00
michel a7372cc88e feat(lib): send and receive private messages
Wire private messaging through the client:

- MessageFactory::build_message_user (code 22) and build_message_acked
  (code 23);
- the incoming MessageUser handler now acknowledges freshly delivered
  messages and forwards them to the client instead of only logging them;
- ClientContext buffers received UserMessages;
- Client::send_private_message and Client::take_private_messages expose the
  feature, and UserMessage (with accessors) is re-exported from the crate.

Verified end to end against soulfind: a_private_message_is_delivered_between_users
logs in two clients and asserts one receives the other's message.
2026-07-19 13:31:30 +02:00
michel f61e02e05c test: broaden soulfind e2e coverage of implemented features
Cover implemented, server-verifiable paths that were previously untested:

- assert an issued search is registered in client state, not just that
  results are empty;
- a_search_is_forwarded_to_a_connected_peer: a second client exercises the
  incoming FileSearch handler (the server distributes searches to it), and we
  prove that session survives by round-tripping its own search afterwards;
- login_succeeds_with_listener_enabled: drives the post-login handshake with
  the peer listener on (SetWaitPort), which every other e2e test skips.

Peer downloads / file sharing remain out of scope here: they need a second
peer that actually serves files, which the client does not implement yet.
2026-07-19 13:27:30 +02:00
michel a127dd97bf ci: run soulfind e2e tests on GitHub Actions
Extend the existing CI workflow with an ubuntu-only "End-to-end (soulfind)"
job that builds soulfind from source (LDC + `dub build :server`), points
SOULFIND_BIN at it, and runs the e2e suite with SOULSEEK_E2E_REQUIRED=1.
Also widen the clippy job to the whole workspace.

The e2e harness gains SOULSEEK_E2E_REQUIRED: when set, a missing/unstartable
server is a hard failure instead of a silent skip, so CI genuinely exercises
the client end-to-end. Add e2e coverage for two existing behaviours a single
server can verify: wrong-password rejection and concurrent logins.
2026-07-19 13:19:46 +02:00
michel 1328736550 feat: configure download & upload (shared) directories
Add a --shared-dir / SOULSEEK_SHARED_DIR option and a SOULSEEK_DOWNLOAD_DIR
env for the download directory. The shared directory is resolved and
validated up front (tilde-expanded, must exist and be a directory); a
misconfigured value is a warning, not a fatal error.

The library gains an optional shared_directory on ClientSettings/Client
with a getter, keeping soulseek-rs-lib dependency-free. Resolution lives
in a new soulseek-rs/src/directories.rs with unit tests.
2026-07-19 13:12:44 +02:00
michel 9035ecebcb test: add server-optional e2e integration harness against soulfind
Adds soulseek-rs-lib/tests/e2e.rs, which drives the client's public API
(connect, login, search) against a real Soulseek server. The harness is
server-optional so cargo test stays green everywhere: it uses
SOULSEEK_TEST_SERVER if set, otherwise spawns a soulfind binary (SOULFIND_BIN
or a soulfind/bin/soulfind checkout in a parent dir) on an ephemeral port with
a throwaway database, and skips with a notice when no server is available.

Uses only std and the library's public API — no new dependencies. Documents how
to run it (including the macOS soulfind SQLite workaround) in the README.
2026-07-19 00:13:35 +02:00
michel e72c8a5c07 style: crank clippy to pedantic+nursery and fix all lints
Enable the full clippy all + pedantic + nursery groups as workspace warnings,
with a small documented allow-list for the pedantic/nursery lints this project
intentionally does not follow (rustdoc-style requirements, deliberate numeric
casts in the protocol/UI layers, and a few noisy nursery lints). Also enable
the rust unused_lifetimes and unsafe_op_in_unsafe_fn lints.

Fixed every resulting warning (527 -> 0) mechanically per clippy's suggestions:
inline format args, const fns, item ordering, let-else, redundant clones,
map_or, cast_lossless, Eq derives, associated-fn conversions for methods that
never used self, and removing unnecessary Result wrappers whose call sites could
never propagate an error. No runtime behavior changed; cargo fmt applied.

cargo clippy --workspace --all-targets: 0 warnings. All tests pass.
2026-07-19 00:09:17 +02:00
michel b149a8ecf2 fix: always restore the terminal when the TUI loop errors
MainTui::run only disabled mouse capture and called ratatui::restore() after
the event loop exited normally; any error propagated by ? from draw/poll/read
returned early and left the terminal in raw mode, on the alternate screen, with
mouse capture still enabled. The loop body moved into a helper whose result is
returned only after the terminal is unconditionally restored.
2026-07-18 14:37:35 +02:00
michel 15ac923bd9 fix: stop the previous peer actor when re-registering a username
register_peer inserted the new handle and silently dropped the old one, leaving
the previous actor running as an orphan that pins a thread-pool worker forever;
its later shutdown could also remove the replacement connection from the
registry. The displaced actor is now told to stop.
2026-07-18 14:37:35 +02:00
michel c5f57cccea fix: resolve self-deadlock in PeerDisconnected handler
The handler held a read guard on the client-context RwLock while calling
process_failed_uploads, which acquires a write guard on the same lock on the
same thread. std RwLock write() then blocks forever, wedging the entire client
operations loop. The read guard is now scoped to just the registry cleanup and
dropped before the write is taken.
2026-07-18 14:37:35 +02:00
michel 846d9750b4 fix: stop duplicating every log line to the file
When log buffering and a LOG_FILE were both active, log() wrote each line to
the file immediately and also pushed it to the buffer, which flush_buffered_logs
then wrote to the file a second time. Each line now routes to exactly one sink
(file / buffer / stderr) via a small tested decision function; a configured
file always bypasses buffering, so nothing is written twice.
2026-07-18 14:33:39 +02:00
michel ce244e4e91 fix: contain panicking jobs so a thread-pool worker is never lost
A job that panicked unwound out of the worker's run loop and permanently
killed that worker; the pool has a fixed worker count and never respawns, so
repeated panics (e.g. from malformed network messages) would exhaust the pool
and wedge the whole actor system — a DoS. The worker now runs each job under
catch_unwind (the shared receiver lock is already released, so a panic cannot
poison it) and survives to process the next job. Drop and execute no longer
unwrap their channel/join results, so shutdown can never itself panic.
2026-07-18 14:33:39 +02:00
michel bdaeb33a4f fix: TUI selection underflow and filtered-results desync
Three related TUI correctness bugs:

- download_selector navigation (select_previous/next/last) computed
  items.len() - 1, panicking with an arithmetic underflow when the list was
  empty (e.g. a filter matched nothing). Index math is now in wrap-safe
  helpers that return no selection for an empty list.

- update_search_results overwrote the filtered results view with the full
  unfiltered list on every ~100ms poll, so an active filter silently stopped
  working as new results streamed in. It now re-derives the filtered view
  from the current query.

- The results pane rendered selection checkboxes by testing the display-row
  index against a selection set that stores unfiltered indices, so under an
  active filter the wrong rows showed as checked. It now maps the display
  index back through the filtered-index table.

Filtering and index-mapping logic extracted into pure, unit-tested helpers.
2026-07-18 14:30:02 +02:00
michel 16af88eb9d fix: error on degenerate Huffman code in zlib inflate instead of panicking
decode_symbol walked the Huffman tree while either child existed, then
unwrapped the child selected by the current bit. A malformed/incomplete code
table (fully attacker-controlled, since search results arrive zlib-compressed
from peers) can leave a node with only one child; reading the opposite bit
unwrapped None and panicked, unwinding out of the actor job. It now returns a
decode error, which propagates as a CompressionError and is handled gracefully.
2026-07-18 14:23:40 +02:00
michel 96988f9f9e fix: detect truncated downloads instead of saving them as Completed
read_download_stream broke out of its read loop on peer EOF (Ok(0)) without
checking whether the promised number of bytes had actually arrived, then saved
the partial buffer and the caller reported DownloadStatus::Completed. A peer
that aborts mid-transfer thus produced a truncated file marked complete. The
buffer was also never trimmed, so trailing coalesced bytes were written past
the file's real size.

finalize_download_buffer now errors with IncompleteDownload when fewer bytes
than expected arrived (caller maps this to Failed) and trims any overshoot to
the exact expected size. The listener's incoming-file path now also marks the
download Failed on error rather than only logging, so it can no longer stay
stuck Queued/InProgress forever.
2026-07-18 14:22:32 +02:00
michel 00e4036ac2 fix: reject malformed peer connection types instead of panicking
Peer::new_from_message, NewPeer::new_from_message and the listener's
parse_peer_init_message parsed an untrusted connection-type string with
.parse().unwrap(), panicking on anything other than P/F/D. Reachable from:
- a malicious server via ConnectToPeer (kills a thread-pool worker), and
- any peer connecting to our listener (kills the accept loop).

new_from_message now returns Option and callers drop malformed messages;
the listener rejects the connection. Added a regression test for truncated
TransferRequest parsing, now safe via the bounds-checked readers.
2026-07-18 14:19:50 +02:00
michel 689d8b5de1 fix: harden Message reader against malformed network input
read_string, read_int8, read_int64, read_bool and the get_message_code
accessors indexed/sliced the buffer using network-controlled offsets and
lengths with no bounds checks. An untrusted server or peer could send a
message with an oversized string length or a truncated/zero-length frame and
panic the parser; the panic unwinds out of the actor job and permanently
kills a thread-pool worker (DoS + worker leak).

All readers now bounds-check and return a safe default (empty/0/false) on
truncation, matching the existing convention already used by read_int32 and
read_raw_byte.
2026-07-18 14:17:06 +02:00
michel daac398f68 chore: update Rust toolchain and dependencies to latest
- clap 4.5.51 -> 4.6.2
- ratatui 0.29.0 -> 0.30.2
- cargo update for transitive deps
- toolchains updated: stable 1.97.1, nightly 1.99.0
2026-07-18 12:24:03 +02:00
michel d06aa2d3de style: apply cargo fmt 2026-04-26 17:28:43 +02:00
michel 9d6a33da34 refactor: centralize BYTES_PER_MB and tidy peer IP parsing
- Add pub const BYTES_PER_MB to ui/utils.rs and replace 7 uses of the
  bare 1_048_576.0 literal across the UI modules. Also pick up
  uninlined-format-args cleanup in the same file while we're here.
- Replace Vec<i32> IP-byte accumulator in Peer::new_from_message with a
  [u8; 4]; the .abs() calls on u8.into() were no-ops on always-positive
  values. Network byte order ([3].[2].[1].[0]) preserved; tests cover
  the wire format.
2026-04-26 17:20:45 +02:00
michel e0218b2433 fix: use u16 for listen port end-to-end
Port numbers are 16-bit; the codebase had listen_port/listener_port
typed as u32, inconsistent with PeerAddress.port (already u16).
Migrate the type all the way through the CLI, config, ClientSettings,
Client, ServerActor, and the listener entry point. The wire format is
preserved: build_set_wait_port_message now takes u16 and widens to u32
via .into() before write_int32. Also rename DEFALT_LISTEN_PORT to
DEFAULT_LISTEN_PORT.
2026-04-26 17:20:45 +02:00
michel 44846cdfa8 chore: remove dead code and stale comments
- Drop dead Rooms/Room infrastructure and the rooms field on Context.
- Remove unused ActorMessage::Tick variant and the now-empty handler arm.
- Strip dead active-thread tracking machinery from ThreadPool (unused
  getters, _id/_active_threads/_total_threads parameters).
- Flatten ServerActor::initiate_connection: the duplicated retry block
  was a copy-paste retry against the same address with the same args
  and could never succeed where the first attempt failed.
- Delete commented-out print_hex2/decode debug stubs, dead test
  fixtures in login.rs and message_user.rs, the 47-line commented body
  of RoomListHandler, and assorted // TODO theater.
2026-04-26 17:20:45 +02:00
michel 574833bf61 feat: add download info pane to TUI
Splits the downloads area 60/40 with a new info pane that adapts to the
selected download's status: header (filename, user, size, status, save
path, bitrate, length) plus queued-specific fields (queue position, peer
slots, peer upload speed) or live progress/speed/ETA when transferring.

Plumbs queue position via a new ClientOperation::PlaceInQueueUpdate so
PlaceInQueueResponse messages from peers reach DownloadStore, and threads
search-time metadata (bitrate, length, peer slots/speed) onto Download
through a new download_with_metadata entry point.
2026-04-26 17:19:26 +02:00
michel fa962a0dea Merge branch 'refactor/extract-download-state'
# Conflicts:
#	soulseek-rs-lib/src/client.rs
2026-04-26 12:52:50 +02:00
michel 4775ede762 Merge branch 'refactor/lock-poison-safety' 2026-04-26 12:51:49 +02:00
michel 28baebdb2e refactor: extract DownloadStore from ClientContext
Move the Vec<Download> and the download lifecycle methods (add, remove,
lookup, status update, pause, resume, remove-if-queued) into a new
download_store module. ClientContext keeps thin delegating shims so
peer/listen, peer/download_peer and the existing client tests carry on
unchanged. Client::pause_download / resume_download / remove_queued_download
now delegate one-liners. Client::process_failed_uploads keeps the
read-then-write lock dance but the inner filtering is the new
collect_failed_tokens helper.

Adds dedicated unit tests for the store's behavior (add/get/remove,
pause+resume, queued-only removal, failed-token collection) so the
state-machine logic is exercised independently of Client.
2026-04-26 12:36:30 +02:00
michel 2722552e35 refactor: replace lock unwraps with poison-aware helpers
Add SoulseekRs::LockPoisoned variant and a small RwLockExt/MutexExt
helper module exposing read_safe/write_safe/lock_safe that map
PoisonError to that variant. Walk every non-test lock acquisition
across client, peer/server actors, peer registry, listener and the
thread pool: propagate via ? where the function returns Result, log
and degrade to a sensible default (return early, false, None, empty)
where it does not. Client::connect now returns Result so the CLI can
surface the failure instead of panicking.
2026-04-26 12:32:46 +02:00
michel e1ee3ac493 fix clippy collapsible match warnings 2026-04-26 12:18:00 +02:00
michel 41d129c6bc chore: update license date 2026-04-26 12:00:24 +02:00
Michel de Graaf 6a63703f82 Merge pull request #2 from adalessandro/wip/adalessandro/dotenv
soulseek-rs: main: Add support for dotenv environment
2026-04-26 11:58:29 +02:00
Michel de Graaf dfdd60f1c8 Merge pull request #1 from adalessandro/dev
soulseek-rs: main_tui: Restore terminal status before exiting
2026-04-26 11:57:54 +02:00
michel bff7ca4bd4 chore: change copyright date 2026-04-26 11:55:40 +02:00
michel de58c8cbe2 Add TUI download pause and queued delete 2026-04-26 11:50:37 +02:00
michel 807dea5f17 Fix TUI search cursor editing 2026-04-26 11:43:44 +02:00
Ariel D'Alessandro 3696e95760 soulseek-rs: main: Add support for dotenv environment
Signed-off-by: Ariel D'Alessandro <ariel.dalessandro@collabora.com>
2026-01-29 19:36:15 -03:00
Ariel D'Alessandro 9627fa475d soulseek-rs: main_tui: Restore terminal status before exiting
Signed-off-by: Ariel D'Alessandro <ariel.dalessandro@collabora.com>
2026-01-29 17:16:19 -03:00
Michel de Graaf dfe6f532fa chore: formatting 2026-01-04 20:37:03 +01:00
Michel de Graaf 94b33d4bd6 chore: get rid of generated files 2026-01-04 20:12:51 +01:00
mayor 16c8a4bde9 chore: update to Rust 2024 edition 2026-01-04 20:08:16 +01:00
Michel de Graaf f7ebcccdd3 chore: ignore beads 2026-01-04 19:50:44 +01:00
Michel de Graaf f5b65053e2 fix: hanging TUI on queueing downloads 2025-11-19 09:13:03 +01:00
Michel de Graaf 9f174c4c5e fix: linting 2025-11-19 08:40:41 +01:00
Michel de Graaf bb86e50b4f fix: queuing downloads 2025-11-19 08:27:50 +01:00
Michel de Graaf b7a5037edb chore: keep updating search results 2025-11-18 16:02:19 +01:00
Michel de Graaf 2ba48663e4 feat: logging to log file 2025-11-18 15:49:53 +01:00
Michel de Graaf 4c6d7c6850 chore: refactor UI to use Downloads struct 2025-11-18 14:33:33 +01:00
Michel de Graaf 79da19ed0f chore: refactor downloads 2025-11-18 14:11:25 +01:00
Michel de Graaf 467de9cb91 chore: bump version 2025-11-13 14:13:28 +01:00
Michel de Graaf 9a4ad6c38a feat: main TUI 2025-11-13 14:12:37 +01:00
Michel de Graaf cafa40a09d chore: refactor to support multi search 2025-11-13 10:28:16 +01:00
Michel de Graaf 4a34583f9c chore: improve download selector 2025-11-13 10:08:16 +01:00
Michel de Graaf 6ca179373f chore: bump version 2025-11-12 21:18:36 +01:00
Michel de Graaf fff407c30d chore: bump version 2025-11-12 15:46:50 +01:00
Michel de Graaf f8999dccb1 chore: gif demo 2025-11-12 15:38:29 +01:00
Michel de Graaf 16394a1f60 chore: update feature todo list in readme 2025-11-12 14:57:25 +01:00
Michel de Graaf 69a044b1fa chore: UI tweaks 2025-11-12 14:52:31 +01:00
Michel de Graaf 406def31d9 chore: rename label from files to download 2025-11-12 11:56:32 +01:00
Michel de Graaf e87853c66a chore: bump version 2025-11-12 02:03:17 +01:00
Michel de Graaf 5816d9b632 chore: refactor CLI app with new modules for seperation of concerns, reusable components and consistent styling 2025-11-12 02:02:35 +01:00
Michel de Graaf 5a28452c02 feat: clear filter in multi-select download screen function 2025-11-12 01:52:27 +01:00
Michel de Graaf 689e667ade chore: bump version 2025-11-12 01:41:43 +01:00
Michel de Graaf 3f506389f0 feat: improved search screen 2025-11-12 01:37:08 +01:00
Michel de Graaf f84d4ea41e chore: rename the cli binary to soulseek-rs 2025-11-12 01:36:51 +01:00
Michel de Graaf 30c4fd2aad feat: better search TUI, multi select downloads and parallel downloads 2025-11-12 00:26:03 +01:00
Michel de Graaf 98a8f53094 feat: improved cli 2025-11-11 23:38:59 +01:00
Michel de Graaf 3d6cc9f7cd feat: client settings and defaults 2025-11-11 22:22:21 +01:00
Michel de Graaf 7bf2748e1f feat: seperate into lib and cli packages 2025-11-11 21:55:53 +01:00
Michel de Graaf 00046f2d59 chore: rename ServerOperation to ServerMessage 2025-11-11 21:16:39 +01:00
Michel de Graaf b7f7cf5582 fix: formatting 2025-11-11 19:41:18 +01:00
Michel de Graaf fb1e5eac07 chore: tweak blocking peer connections 2025-11-11 19:36:50 +01:00
Michel de Graaf bf7eb66e58 fix: broken test (thread pool & actors destroy) 2025-11-11 19:19:45 +01:00
Michel de Graaf cb47b3f3cd chore: change log prefixes 2025-11-11 19:10:50 +01:00
Michel de Graaf d045647af1 Merge branch 'feat/server_actor' 2025-11-11 19:01:36 +01:00
Michel de Graaf a4ece85eaf chore: bump version 2025-11-11 19:01:32 +01:00
Michel de Graaf 5dc635297e chore: refactor server as an actor 2025-11-11 19:00:38 +01:00
Michel de Graaf 7ae1919c94 chore: manual releases 2025-11-11 14:53:40 +01:00
Michel de Graaf 0e09a3feae Merge branch 'feat/actor' 2025-11-11 14:53:40 +01:00
Michel de Graaf 9655f09b77 chore: version bump 2025-11-11 14:53:40 +01:00
Michel de Graaf 13f4783f65 chore: refactor peer connection to actor and non blocking 2025-11-11 14:53:40 +01:00
Michel de Graaf 040d53b87a chore: add development warning to readme 2025-11-11 14:53:40 +01:00
Michel de Graaf a1b88f58bd chore: debug logging 2025-11-11 14:53:40 +01:00
Michel de Graaf a4aee59e1e chore: remove logging 2025-11-11 14:53:40 +01:00
Michel de Graaf 1ee5755ed1 feat: only for download peer spawn a thread 2025-11-11 14:53:40 +01:00
Michel de Graaf 71547aeb85 chore: remove threadpool from client 2025-11-11 14:53:40 +01:00
Michel de Graaf f6921224c6 fix: listener 2025-11-11 14:53:40 +01:00
Michel de Graaf 2584428170 chore: linting 2025-11-11 14:53:40 +01:00
Michel de Graaf 3dc667fe30 feat: actor refactoring default peer 2025-11-11 14:53:40 +01:00
Michel de Graaf 7bae6b36da chore: cargo.lock 2025-11-11 14:53:40 +01:00
Michel de Graaf 46ebcad98b feat: restructure release workflow to build binaries before creating release
- Add check-release job to determine if a release is needed
- Build binaries in parallel for all platforms before release
- Create release and attach binaries in final step
- Start with version 0.1.0
2025-11-07 11:20:52 +01:00
Michel de Graaf 153123c567 fix: ci/cd release 2025-11-07 10:47:02 +01:00
Michel de Graaf 35accb8b9e chore: fix broken test 2025-11-07 10:39:40 +01:00
Michel de Graaf bbfccbccfe chore: fix linting issues caused to update of rust/clippy to newer version 2025-11-07 10:29:55 +01:00
Michel de Graaf 5e54fa1f94 chore: ci/cd & realease automation 2025-11-07 10:19:58 +01:00
Michel de Graaf 843eff14ca chore: clean up repository 2025-11-07 09:53:05 +01:00
Michel de Graaf 257f2876f3 chore: add readme 2025-11-07 09:51:37 +01:00
Michel de Graaf 1785da6507 chore: improve logging 2025-11-06 16:45:07 +01:00
Michel de Graaf bcbd68257d chore: add MIT license to project 2025-11-07 09:33:45 +01:00
Michel de Graaf e9880ad638 chore: clean up download peer stream processor 2025-11-06 12:39:14 +01:00
Michel de Graaf 11f62dc4b8 chore: refactor download peer 2025-11-06 12:35:58 +01:00
Michel de Graaf fe60ea67f8 chore: refactor listener 2025-11-06 12:18:28 +01:00
Michel de Graaf 297badbddf fix: direct download 2025-11-06 10:59:57 +01:00
Michel de Graaf c623d27e65 feat: enable listener functionality 2025-11-05 13:40:13 +01:00
Michel de Graaf 9de47892ba chore: refactor listener and default peer 2025-11-04 20:48:23 +01:00
Michel de Graaf d11c6782a7 chore: refactor default peer 2025-11-04 18:48:32 +01:00
Michel de Graaf c346c56297 feat: add download feedback via channel 2025-11-03 16:37:21 +01:00
Michel de Graaf a9d0101a58 fix: correct listening port 2025-11-02 19:57:56 +01:00
Michel de Graaf 7958fe9a29 feat: add configurable listen port 2025-11-02 11:50:39 +01:00
michel ff30db3e29 chore: refactor to use RwLock 2025-11-01 14:57:27 +01:00
michel 4932386056 fix: download directory handling 2025-10-26 01:57:22 +02:00
michel 436bc0fe1a chore: fix download path 2025-10-26 01:15:17 +02:00
michel f196c86328 chore: fix linting issues 2025-10-25 18:13:45 +02:00
Michel de Graaf 27258a09f0 fix: download path handling 2025-10-13 16:01:30 +02:00
Michel de Graaf 9ccc4fa1e9 fix: set nodelay on socket 2025-10-13 10:12:04 +02:00
Michel de Graaf cd3dca40c5 feat: rename module to soulseek-rs 2025-07-31 22:24:34 +02:00
Michel de Graaf 5aa4472510 chore: fix clippy 2025-07-31 22:01:00 +02:00
Michel de Graaf 843fb79d59 chore: format code 2025-07-31 22:00:06 +02:00
Michel de Graaf 6431630510 chore: remove all warnings 2025-07-31 20:41:05 +02:00
Michel de Graaf 4302d74847 chore: refactor module structure 2025-07-31 20:33:30 +02:00
Michel de Graaf 28ecda68a6 chore: fix all tests 2025-07-31 20:15:37 +02:00
Michel de Graaf 9553537bc6 feat: implement download peer 2025-07-31 20:06:41 +02:00
Michel de Graaf 956cb824bd feat: add logging support 2025-07-05 23:16:05 +02:00
Michel de Graaf 57dafaf8ab feat: remove peers 2025-07-05 22:54:28 +02:00
Michel de Graaf d07e9ec113 chore: rename FileSearch to FileSearchResult 2025-06-30 00:06:19 +02:00
Michel de Graaf c75bdd29c5 feat: implement client search functionality 2025-06-30 00:03:46 +02:00
Michel de Graaf 7aaec67a30 chore: improve read/write handling for default peer 2025-06-29 23:18:53 +02:00
Michel de Graaf 0d3ab4566e feat: introduce threadpool 2025-06-29 23:09:06 +02:00
Michel de Graaf b69be1c6f0 chore: fix clippy warnings 2025-06-29 22:16:17 +02:00
Michel de Graaf 78ac774e82 chore: remove flate2 as dependency for zlib decompression 2025-06-29 21:59:14 +02:00
Michel de Graaf c654f7dcdb chore: fix warnings 2025-06-29 17:56:23 +02:00
Michel de Graaf 430bdebb23 chore: fix failing tests 2025-06-29 17:42:49 +02:00
Michel de Graaf 257920562d feat: implement zlib decompression 2025-06-29 17:37:04 +02:00
michel 066ddadaed chore: make message handlers and dispatcher generic 2025-01-06 22:49:19 +01:00
michel 766554134d feat: implement default peer message processing 2025-01-05 20:05:47 +01:00
michel 10f4a34ae4 feat: add missing message handlers 2024-09-13 20:58:04 +02:00
michel 6281b0c89e fix: deadlock issue with sending messages
intoduced a channel and used stream.try_clone
2024-09-10 11:59:51 +02:00
michel 1461e385cf chore: debugging server refactoring with channels 2024-09-09 20:50:33 +02:00
michel d67ed4742a chore: add gitignore 2024-09-09 08:32:51 +02:00
michel 5c56066960 chore: initial commit
semi working version
2024-09-09 08:32:37 +02:00
281 changed files with 82468 additions and 14 deletions
+4
View File
@@ -0,0 +1,4 @@
# Generated and compared byte-for-byte by `daemon::openrpc::document_is_current`,
# which builds its expectation with `\n`. A CRLF checkout would fail that test on
# Windows.
soulseek-rs/docs/openrpc.json text eol=lf
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# Commits carry the author's attribution and nothing else. Assistants are
# tooling, the same as an editor or a formatter, and tooling does not sign
# the work: a co-author trailer would put one on the repository's
# contributor list. The setting and CLAUDE.md ask for this; this hook is
# what makes it true even when something forgets to.
#
# Enable it once per clone: git config core.hooksPath .githooks
msg="$1"
tmp="$msg.attribution"
grep -v -i -E \
'^(Co-Authored-By:.*(claude|anthropic|copilot|cursor|codex)|Claude-Session:|Generated with .*Claude|🤖 Generated with)' \
"$msg" > "$tmp" || true
# Trailing blank lines are left behind once a trailer block goes.
awk 'BEGIN{n=0} {lines[NR]=$0} END{for(i=NR;i>0;i--) if(lines[i] ~ /[^[:space:]]/){n=i; break} for(i=1;i<=n;i++) print lines[i]}' \
"$tmp" > "$msg"
rm -f "$tmp"
+181
View File
@@ -0,0 +1,181 @@
name: CI
# Branch pushes are covered by the pull_request trigger. Listening to both for
# every branch ran the whole matrix twice per PR.
on:
push:
branches: [master]
paths-ignore: ['website/**']
pull_request:
branches: ['**']
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
lint:
name: Lint
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- name: Run clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
format:
name: Format Check
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: Check formatting
run: cargo fmt --all -- --check
test:
name: Test
timeout-minutes: 20
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
rust: [stable]
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.rust }}
- uses: Swatinem/rust-cache@v2
- name: Run tests
run: cargo test --verbose
e2e:
name: End-to-end (soulfind)
# The CLI suite runs twice (own login + against a daemon), ~8 minutes per
# pass, plus the library and protocol suites and a soulfind build.
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install SQLite runtime/dev libraries
run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev
- name: Install D toolchain (LDC)
uses: dlang-community/setup-dlang@v2
with:
compiler: ldc-latest
- name: Build soulfind server
run: |
git clone --depth 1 https://github.com/soulfind-dev/soulfind.git soulfind
dub build :server --root soulfind
test -x soulfind/bin/soulfind
echo "SOULFIND_BIN=$PWD/soulfind/bin/soulfind" >> "$GITHUB_ENV"
- name: Run library e2e tests (required against soulfind)
env:
SOULSEEK_E2E_REQUIRED: "1"
run: cargo test -p soulseek-rs-lib --test e2e -- --nocapture
- name: Run CLI e2e tests (required against soulfind)
env:
SOULSEEK_E2E_REQUIRED: "1"
run: cargo test -p soulseek-rs --test cli_e2e -- --nocapture
# The window over a real session: keys in, a screen out, a peer on the
# other end. Nothing else opens the TUI against a server.
- name: Run TUI e2e tests (required against soulfind)
env:
SOULSEEK_E2E_REQUIRED: "1"
run: cargo test -p soulseek-rs --test tui_e2e -- --nocapture
# The control protocol as a third party meets it: the TCP transport, the
# token, and the gate. None of it is reachable through the command
# surface, so the CLI suite cannot cover it in either mode.
- name: Run daemon protocol e2e tests (required against soulfind)
env:
SOULSEEK_E2E_REQUIRED: "1"
run: cargo test -p soulseek-rs --test daemon_protocol_e2e -- --nocapture --test-threads=1
# The same suite again, with every session served by a daemon instead of
# the command's own login. Identical records and exit codes both ways is
# what makes daemon mode a routing decision rather than a second
# implementation to keep in step.
- name: Run CLI e2e tests against a daemon
env:
SOULSEEK_E2E_REQUIRED: "1"
SOULSEEK_E2E_MODE: daemon
run: cargo test -p soulseek-rs --test cli_e2e -- --nocapture --test-threads=1
# A mixed load of mock peers around one real client. Every ceiling this
# project has hit only showed under simultaneous searches, downloads,
# uploads and browses, and three of the four lost work silently, so the
# gate is the functional dimensions at 100%, never the throughput number.
stress:
name: Stress (soulfind)
timeout-minutes: 20
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install SQLite runtime/dev libraries
run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev
- name: Install D toolchain (LDC)
uses: dlang-community/setup-dlang@v2
with:
compiler: ldc-latest
- name: Build soulfind server
run: |
git clone --depth 1 https://github.com/soulfind-dev/soulfind.git soulfind
dub build :server --root soulfind
test -x soulfind/bin/soulfind
echo "SOULFIND_BIN=$PWD/soulfind/bin/soulfind" >> "$GITHUB_ENV"
# An eighth of the local profile: soulfind on a four-core runner brings
# about 150 mock logins online, and the point is lost work, not load.
- name: Run the stress benchmark with the functional gate
env:
STRESS_REQUIRE_FUNCTIONAL: "1"
STRESS_PEERS: "128"
STRESS_SEARCHES: "32"
STRESS_DOWNLOADS: "128"
STRESS_LEECHERS: "64"
STRESS_BROWSES: "32"
run: |
ulimit -n 20000
cargo run --release -p soulseek-rs-lib --example stress
+291
View File
@@ -0,0 +1,291 @@
name: Nightly
on:
schedule:
- cron: '17 2 * * *'
workflow_dispatch:
permissions:
contents: read
# A manual run queued behind the scheduled run must still execute: it is the
# recovery path when a runner or GitHub Releases had a transient failure.
concurrency:
group: nightly
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
jobs:
source:
name: Resolve develop
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
sha: ${{ steps.commit.outputs.sha }}
version: ${{ steps.commit.outputs.version }}
steps:
# workflow_dispatch has a branch selector, but a Nightly run always means
# develop. Resolve it once so every target builds the identical commit
# even if develop moves while the matrix is starting.
- uses: actions/checkout@v4
with:
ref: develop
persist-credentials: false
- name: Record source commit
id: commit
shell: bash
run: |
set -euo pipefail
sha=$(git rev-parse HEAD)
base=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2)
stamp=$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y%m%d%H%M)
[[ "$base" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$stamp" =~ ^[0-9]{12}$ ]]
echo "sha=$sha" >> "$GITHUB_OUTPUT"
echo "version=$base+git$stamp.${sha:0:7}" >> "$GITHUB_OUTPUT"
binaries:
name: ${{ matrix.target }}
needs: source
runs-on: ${{ matrix.os }}
timeout-minutes: 45
env:
SOULSEEK_RS_VERSION: ${{ needs.source.outputs.version }}
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
- target: aarch64-unknown-linux-musl
os: ubuntu-24.04-arm
- target: x86_64-apple-darwin
os: macos-latest
- target: aarch64-apple-darwin
os: macos-latest
- target: x86_64-pc-windows-msvc
os: windows-latest
- target: aarch64-pc-windows-msvc
os: windows-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.source.outputs.sha }}
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: nightly-${{ matrix.target }}
- name: Install musl toolchain
if: endsWith(matrix.target, '-musl')
run: sudo apt-get update && sudo apt-get install -y musl-tools
# Dry-run produces the same archives and checksums as the stable release
# workflow without publishing them one matrix job at a time. The publish
# job exposes the complete, verified set together.
- name: Build archive and checksum
id: package
uses: taiki-e/upload-rust-binary-action@v1
with:
bin: soulseek-rs
target: ${{ matrix.target }}
build-tool: cargo
locked: true
archive: soulseek-rs-nightly-$target
include: README.md,LICENSE
checksum: sha256
ref: refs/tags/nightly
dry-run: true
dry-run-intended: true
- name: Build .deb and .rpm
if: endsWith(matrix.target, '-musl')
env:
TARGET: ${{ matrix.target }}
run: sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "$SOULSEEK_RS_VERSION" "$TARGET" . soulseek-rs-nightly
- name: Store packaged binary
uses: actions/upload-artifact@v4
with:
name: nightly-${{ matrix.target }}
path: |
${{ steps.package.outputs.tar || steps.package.outputs.zip }}
${{ steps.package.outputs.sha256 }}
soulseek-rs-nightly-*.deb
soulseek-rs-nightly-*.rpm
if-no-files-found: error
retention-days: 1
publish:
name: Publish prerelease
needs: [source, binaries]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Collect packaged binaries
uses: actions/download-artifact@v4
with:
pattern: nightly-*
path: dist
merge-multiple: true
- name: Verify the complete asset set
shell: bash
run: |
set -euo pipefail
expected=(
soulseek-rs-nightly-x86_64-unknown-linux-musl.tar.gz
soulseek-rs-nightly-aarch64-unknown-linux-musl.tar.gz
soulseek-rs-nightly-x86_64-apple-darwin.tar.gz
soulseek-rs-nightly-aarch64-apple-darwin.tar.gz
soulseek-rs-nightly-x86_64-pc-windows-msvc.zip
soulseek-rs-nightly-aarch64-pc-windows-msvc.zip
soulseek-rs-nightly-x86_64-unknown-linux-musl.sha256
soulseek-rs-nightly-aarch64-unknown-linux-musl.sha256
soulseek-rs-nightly-x86_64-apple-darwin.sha256
soulseek-rs-nightly-aarch64-apple-darwin.sha256
soulseek-rs-nightly-x86_64-pc-windows-msvc.sha256
soulseek-rs-nightly-aarch64-pc-windows-msvc.sha256
soulseek-rs-nightly-amd64.deb
soulseek-rs-nightly-arm64.deb
soulseek-rs-nightly-x86_64.rpm
soulseek-rs-nightly-aarch64.rpm
)
printf '%s\n' "${expected[@]}" | sort > "$RUNNER_TEMP/expected-assets"
find dist -maxdepth 1 -type f -exec basename {} \; | sort > "$RUNNER_TEMP/actual-assets"
diff -u "$RUNNER_TEMP/expected-assets" "$RUNNER_TEMP/actual-assets"
(cd dist && sha256sum --check ./*.sha256)
- name: Publish the nightly release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
SOURCE_SHA: ${{ needs.source.outputs.sha }}
shell: bash
run: |
set -euo pipefail
notes="$RUNNER_TEMP/nightly-notes.md"
short_sha=${SOURCE_SHA:0:12}
{
printf 'Automated development build from the develop branch.\n\n'
printf 'Source: [%s](https://github.com/%s/commit/%s)\n\n' "$short_sha" "$GITHUB_REPOSITORY" "$SOURCE_SHA"
printf 'This prerelease and its nightly tag are replaced by each successful scheduled or manual run. It may be less stable than a numbered release.\n'
} > "$notes"
release_exists=false
if gh release view nightly >/dev/null 2>&1; then
release_exists=true
# Hide the old set while assets and the mutable tag are replaced;
# installers either see a complete release or fail before changing
# their existing binary.
gh release edit nightly --draft --prerelease --latest=false
fi
# gh writes the REST error body to stdout on a missing ref, so use
# its exit status—not captured output—to choose create vs update.
# Retry both paths and re-read the ref to keep retries idempotent.
tag_ready=false
for attempt in 1 2 3 4 5; do
if gh api --silent "repos/$GITHUB_REPOSITORY/git/ref/tags/nightly" >/dev/null 2>&1; then
if gh api --silent --method PATCH "repos/$GITHUB_REPOSITORY/git/refs/tags/nightly" \
-f sha="$SOURCE_SHA" -F force=true; then
tag_ready=true
break
fi
elif gh api --silent --method POST "repos/$GITHUB_REPOSITORY/git/refs" \
-f ref=refs/tags/nightly -f sha="$SOURCE_SHA"; then
tag_ready=true
break
fi
[ "$attempt" -eq 5 ] || {
echo "::warning::Git refs API attempt $attempt failed; retrying"
sleep $((attempt * 2))
}
done
[ "$tag_ready" = true ] || {
echo "::error::could not point the nightly tag at $SOURCE_SHA"
exit 1
}
if [ "$release_exists" = false ]; then
gh release create nightly --verify-tag --draft --prerelease --latest=false \
--title 'Nightly (develop)' --notes-file "$notes"
else
gh release edit nightly --draft --prerelease --latest=false \
--target "$SOURCE_SHA" --title 'Nightly (develop)' --notes-file "$notes"
fi
gh release view nightly --json assets --jq '.assets[].name' > "$RUNNER_TEMP/old-assets"
while IFS= read -r asset; do
[ -z "$asset" ] || gh release delete-asset nightly "$asset" --yes
done < "$RUNNER_TEMP/old-assets"
gh release upload nightly dist/*
gh release view nightly --json assets --jq '.assets[].name' | sort > "$RUNNER_TEMP/published-assets"
diff -u "$RUNNER_TEMP/expected-assets" "$RUNNER_TEMP/published-assets"
empty_assets=$(gh release view nightly --json assets --jq '[.assets[] | select(.size == 0)] | length')
[ "$empty_assets" -eq 0 ]
gh release edit nightly --draft=false --prerelease --latest=false \
--target "$SOURCE_SHA" --title 'Nightly (develop)' --notes-file "$notes"
smoke-test:
name: Install published nightly
needs: [source, publish]
runs-on: ubuntu-latest
timeout-minutes: 5
env:
VERSION: ${{ needs.source.outputs.version }}
DOWNLOAD: https://github.com/${{ github.repository }}/releases/download/nightly
steps:
- uses: actions/checkout@v4
with:
ref: nightly
persist-credentials: false
- name: Install with curl
env:
SOULSEEK_RS_INSTALL_DIR: ${{ runner.temp }}/curl-install
run: |
sh website/public/install.sh --nightly
"$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version | grep -F "$VERSION"
- name: Install with wget and no curl on PATH
env:
SOULSEEK_RS_INSTALL_DIR: ${{ runner.temp }}/wget-install
shell: bash
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/wget-path"
for tool in basename cat chmod cp gzip mkdir mktemp mv rm sha256sum tar uname wget; do
ln -s "$(command -v "$tool")" "$RUNNER_TEMP/wget-path/$tool"
done
PATH="$RUNNER_TEMP/wget-path" /bin/sh website/public/install.sh --nightly
"$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version | grep -F "$VERSION"
- name: Install the .deb
run: |
curl -fsSLO "$DOWNLOAD/soulseek-rs-nightly-amd64.deb"
sudo apt-get install -y ./soulseek-rs-nightly-amd64.deb
soulseek-rs --version | grep -F "$VERSION"
dpkg -L soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz
dpkg -L soulseek-rs | grep -Fx /usr/share/zsh/vendor-completions/_soulseek-rs
- name: Install the .rpm
run: |
docker run --rm fedora:latest sh -ec "
dnf install -y '$DOWNLOAD/soulseek-rs-nightly-x86_64.rpm'
soulseek-rs --version | grep -F '$VERSION'
rpm -ql soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz
rpm -ql soulseek-rs | grep -Fx /usr/share/zsh/site-functions/_soulseek-rs
"
+87
View File
@@ -0,0 +1,87 @@
name: Packaging
on:
pull_request:
paths:
- 'flake.nix'
- 'flake.lock'
- 'Cargo.lock'
- 'Cargo.toml'
- 'packaging/**'
- 'scripts/package-linux.sh'
- 'soulseek-rs/src/cli.rs'
- 'soulseek-rs/src/run.rs'
- 'soulseek-rs/src/commands/**'
- '.github/workflows/packaging.yml'
permissions:
contents: read
concurrency:
group: packaging-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
VERSION: 0.0.0+git202601010000.0000000
jobs:
packages:
name: .deb and .rpm
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-musl
- uses: Swatinem/rust-cache@v2
- name: Install musl toolchain
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build the packages
env:
SOULSEEK_RS_VERSION: ${{ env.VERSION }}
TARGET: x86_64-unknown-linux-musl
run: |
cargo build --locked --release --target "$TARGET" --package soulseek-rs
sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "$VERSION" "$TARGET" . soulseek-rs-nightly
- name: Install the .deb
run: |
sudo apt-get install -y ./soulseek-rs-nightly-amd64.deb
soulseek-rs --version | grep -F "$VERSION"
dpkg -L soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz
dpkg -L soulseek-rs | grep -Fx /usr/share/zsh/vendor-completions/_soulseek-rs
- name: Install the .rpm
run: |
docker run --rm -v "$PWD:/packages:ro" fedora:latest sh -ec "
dnf install -y /packages/soulseek-rs-nightly-x86_64.rpm
soulseek-rs --version | grep -F '$VERSION'
rpm -ql soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz
rpm -ql soulseek-rs | grep -Fx /usr/share/zsh/site-functions/_soulseek-rs
"
flake:
name: Nix flake
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: cachix/install-nix-action@v31
- name: Build and run
run: |
nix build --print-build-logs
./result/bin/soulseek-rs --version | grep -E '\+git[0-9]{12}\.[0-9a-f]{7}$'
test -s result/share/man/man1/soulseek-rs.1.gz
test -s result/share/fish/vendor_completions.d/soulseek-rs.fish
+72
View File
@@ -0,0 +1,72 @@
name: Pages
# The site stamps its footer version out of the root Cargo.toml at build time
# (website/vite.config.ts reads it into __APP_VERSION__), so a release that only
# touches Cargo.toml has to redeploy or the footer keeps advertising the previous
# version until someone happens to edit the site. Cargo.toml is deliberately not
# in the pull_request paths: a dependency bump there changes nothing the site
# renders, and the deploy is what needs the trigger.
on:
push:
branches: [master]
paths: ['website/**', '.github/workflows/pages.yml', 'Cargo.toml']
pull_request:
branches: ['**']
paths: ['website/**', '.github/workflows/pages.yml']
workflow_dispatch:
permissions:
contents: read
jobs:
build:
name: Build
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: website
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install
run: bun install --frozen-lockfile
- name: Test installer
run: bun run test:install
- name: Typecheck
run: bun run typecheck
- name: Lint
run: bun run lint
- name: Build
run: bun run build
- uses: actions/upload-pages-artifact@v3
with:
path: website/dist
deploy:
name: Deploy
if: github.ref == 'refs/heads/master' && github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: pages
cancel-in-progress: false
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
+278
View File
@@ -0,0 +1,278 @@
name: Release
on:
push:
branches: [master]
paths-ignore: ['website/**']
workflow_dispatch:
inputs:
tag:
description: 'Existing tag to rebuild binaries for (e.g. v6.0.0). Leave empty for a normal release run.'
required: false
type: string
permissions:
contents: write
pull-requests: write
env:
CARGO_TERM_COLOR: always
jobs:
# CI runs on this same push. Nothing may reach crates.io until it has passed,
# because a published version cannot be withdrawn, only yanked. Deliberately
# not a dependency of release-pr: opening the release PR is harmless and
# should not wait.
ci-gate:
name: Wait for CI
runs-on: ubuntu-latest
if: github.event_name == 'push'
permissions:
actions: read
contents: read
steps:
- name: Wait for CI to pass on this commit
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# jq reduces the run to a single word so there is no shell word
# splitting to get wrong. `last` picks the newest run for the commit,
# so re-running a flaky CI job flips this to success and re-running
# the Release workflow then goes through.
for _ in $(seq 1 90); do
verdict=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$GITHUB_SHA" \
--jq '[.workflow_runs[] | select(.path==".github/workflows/ci.yml")]
| sort_by(.run_number) | last
| if . == null then "absent"
elif .status != "completed" then "running"
else (.conclusion // "unknown") end' \
2>/dev/null || echo "unreachable")
case "$verdict" in
success) echo "CI passed for $GITHUB_SHA"; exit 0 ;;
absent|running|unreachable|cancelled) ;;
*) echo "::error::CI concluded '$verdict' for $GITHUB_SHA - refusing to release"; exit 1 ;;
esac
sleep 20
done
echo "::error::timed out waiting for CI on $GITHUB_SHA - refusing to release"
exit 1
# Opens (or updates) a "chore: release vX.Y.Z" PR with the version bump and
# changelog derived from the conventional commits on master. Merging it is the
# release trigger.
release-pr:
name: Release PR
runs-on: ubuntu-latest
concurrency:
group: release-pr-${{ github.ref }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: release-plz/action@v0.5
with:
command: release-pr
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN || secrets.GITHUB_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
# No-op until the release PR lands; then tags, publishes both crates to
# crates.io in dependency order, and creates the GitHub release.
release:
name: Tag and publish
runs-on: ubuntu-latest
needs: ci-gate
# ci-gate is skipped on workflow_dispatch, which would skip this job too
# without always(). A ci-gate failure leaves both clauses false, so a red
# CI blocks the publish.
if: always() && (github.event_name == 'workflow_dispatch' || needs.ci-gate.result == 'success')
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
# Skipped when a tag was dispatched by hand: that run exists to rebuild
# binaries for a tag that already shipped. Letting release-plz also
# publish here produced exactly one bad outcome in practice - it cut
# v9.1.0 while `Resolve tag` went on to honour the dispatched v9.0.0, so
# the new release got no binaries at all and the tap, which tracks the
# latest release, had nothing to generate a formula from.
- uses: release-plz/action@v0.5
id: release-plz
if: inputs.tag == ''
with:
command: release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
# Read the tag out via jq rather than fromJSON: `releases` is an empty
# string on the runs where nothing was released, and fromJSON chokes on it.
- name: Resolve tag
id: tag
if: steps.release-plz.outputs.releases_created == 'true' || inputs.tag != ''
env:
RELEASES: ${{ steps.release-plz.outputs.releases }}
INPUT_TAG: ${{ inputs.tag }}
run: |
tag="${INPUT_TAG:-$(jq -r '.[0].tag' <<< "$RELEASES")}"
# This value is handed to actions/checkout as `ref:` and INPUT_TAG is
# attacker-shaped (anyone who can dispatch the workflow types it), so
# refuse anything that is not a literal version tag.
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo "::error::unexpected tag: $tag"; exit 1; }
echo "tag=$tag" >> "$GITHUB_OUTPUT"
# Attaches downloadable binaries to the release GitHub just created, or to an
# existing tag when dispatched manually with `tag` (recovering a target that
# failed to upload needs a re-run of *this* file, not the old one).
#
# Gated on the resolved tag, which `Resolve tag` has already folded both paths
# into and validated, so this is the single place deciding whether there is
# anything to build. `!cancelled()` is load-bearing: on a manual dispatch
# ci-gate is skipped, and that skip propagates down the whole needs graph —
# past `release`, which only survives it via its own always() — so without a
# status function here every manual rebuild was skipped no matter what the
# rest of the condition said.
binaries:
name: ${{ matrix.target }}
needs: release
if: ${{ !cancelled() && needs.release.outputs.tag != '' }}
runs-on: ${{ matrix.os }}
strategy:
# One flaky cross-linker must not hold back the five targets that built.
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
- target: aarch64-unknown-linux-musl
os: ubuntu-24.04-arm
- target: x86_64-apple-darwin
os: macos-latest
- target: aarch64-apple-darwin
os: macos-latest
- target: x86_64-pc-windows-msvc
os: windows-latest
- target: aarch64-pc-windows-msvc
os: windows-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.release.outputs.tag }}
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install musl toolchain
if: endsWith(matrix.target, '-musl')
run: sudo apt-get update && sudo apt-get install -y musl-tools
# Builds, strips, archives (.tar.gz / .zip on Windows), writes a .sha256
# and uploads the lot to the release.
#
# build-tool is pinned to cargo because the action otherwise reaches for
# `cross` on any *-musl target. Every entry in the matrix builds on a
# runner of its own architecture — only the libc differs — so cross adds
# nothing, and on the native arm64 runner it hard-fails trying to install
# an x86_64 toolchain.
- uses: taiki-e/upload-rust-binary-action@v1
with:
bin: soulseek-rs
target: ${{ matrix.target }}
build-tool: cargo
archive: $bin-$tag-$target
include: README.md,LICENSE
checksum: sha256
ref: refs/tags/${{ needs.release.outputs.tag }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Build and upload .deb and .rpm
if: endsWith(matrix.target, '-musl') && hashFiles('scripts/package-linux.sh') != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.release.outputs.tag }}
TARGET: ${{ matrix.target }}
run: |
sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "${TAG#v}" "$TARGET" dist
gh release upload "$TAG" dist/* --clobber
# The tap regenerates its formula from whatever `gh release view` calls latest,
# so it only needs a nudge once every archive and .sha256 is on the release —
# hence `needs: binaries` rather than firing alongside it. The tap also polls
# hourly on its own; that cron stays as the recovery path for when this
# dispatch fails (expired token, tap outage) rather than as the mechanism.
tap:
name: Update Homebrew tap
needs: binaries
if: ${{ !cancelled() && needs.binaries.result == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Dispatch the tap's formula workflow
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
# gh answers a missing token with generic "set GH_TOKEN" advice, which
# reads like a workflow bug rather than a secret that needs filling in.
[ -n "$GH_TOKEN" ] || {
echo "::error::HOMEBREW_TAP_TOKEN is empty - set it to a PAT with Actions: write on michel/homebrew-tap. The tap's hourly cron will pick this release up in the meantime."
exit 1
}
gh workflow run formula.yml --repo michel/homebrew-tap
# release-plz writes the version bump and the changelog on master, and until
# now nothing carried them back: develop was still declaring 14.1.2 five
# releases after v14.1.2 shipped. Nightly builds the tip of develop, so every
# nightly binary reported a version that had not been current for weeks, and
# the changelog on the branch the work happens on stopped at v14.1.2.
#
# Hangs off `release` rather than `binaries` so the sync does not wait on six
# binary builds, and merges master whole rather than cherry-picking the bump
# so that the changelog comes along with it. develop never edits those lines
# itself, which is what keeps the merge clean; on the day it does conflict
# this job fails loudly and the merge wants doing by hand.
#
# Nothing in this repository triggers on a push to develop, so pushing here
# neither loops back into this workflow nor spends a CI run.
backmerge:
name: Back-merge into develop
needs: release
if: ${{ !cancelled() && needs.release.outputs.tag != '' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: develop
token: ${{ secrets.RELEASE_PLZ_TOKEN || secrets.GITHUB_TOKEN }}
- name: Merge master into develop
env:
TAG: ${{ needs.release.outputs.tag }}
run: |
# Spelled out rather than read from master's tip: this history carries
# one person's attribution, and whoever happened to click merge on the
# release PR is not the right source for it.
git config user.name "Michel de Graaf"
git config user.email "michel@re-invention.nl"
# checkout was pointed at develop, and which other tracking refs it
# leaves behind is its own business — ask for master by name.
git fetch origin master
# A manual binary rebuild reaches this job with develop already
# containing master; git says "Already up to date" and the push is a
# no-op, so the dispatch path needs no guard of its own.
git merge --no-ff origin/master -m "merge: back-merge $TAG into develop"
git push origin develop
+4
View File
@@ -0,0 +1,4 @@
/target
# Editor and assistant scratch, not repository content
.claude/
+6
View File
@@ -0,0 +1,6 @@
# Identities that should appear as the person who owns the work, whatever
# name the machine that ran `git commit` happened to be configured with.
# Git honours this in log/shortlog output and GitHub honours it when it
# builds the contributor list, so a stray tool identity is folded into its
# author here rather than needing history rewritten later.
Michel de Graaf <michel@re-invention.nl> <noreply@anthropic.com>
+414
View File
@@ -0,0 +1,414 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [19.1.0](https://github.com/michel/soulseek-rs/compare/v19.0.0...v19.1.0) - 2026-09-21
### Added
- *(cli)* print completion scripts and the man page to stdout
- *(cli)* let a build name its own version
- *(tui)* say so in the status bar when the listener took another port
- *(lib)* let another thread cancel a client's connect and login
- *(lib)* cap the combined download speed
### Fixed
- *(tui)* cancel a replaced login attempt and wait for it to let go
- *(tui)* keep the login screen responsive while connecting
- *(rooms)* start a room's roster from the member list, not the join echo
- *(lib)* give the listener port back before a dropped client returns
- *(client)* record a watch before the server can answer it
### Other
- declare Rust 1.91 as the minimum version
- Merge pull request #97 from michel/test/event-synced-e2e
- *(e2e)* give a soulfind that dies on startup a fresh port
- *(portmap)* find the unmap among resent maps
- *(cli)* wait for the listener to be in the room, and keep an early PM
- *(cli)* hand a config command's session flags to the daemon
- *(e2e)* fence on the server where a test slept for another client
- *(e2e)* start a mock uploader once the client has registered it
- *(e2e)* wait for the join and the password change, not a sleep
- *(resume)* bound what a paused transfer may still take, not when
## [19.0.0](https://github.com/michel/soulseek-rs/compare/v18.0.0...v19.0.0) - 2026-09-10
### Added
- browse your own shares as a peer receives them
- *(tui)* account settings — stats, password change, and logout
- *(tui)* clear the whole queue from the transfers pane
- folders in the browse tree carry their download's mark too
- mark browsed files with their download status
- browse the highlighted transfer's user with b
- *(distributed)* serve children in the search tree
- *(rooms)* private room membership, operators and refusals
- *(tui)* resize panes by dragging pane borders
- *(tui)* run the highlighted search again with S
- *(tui)* search a room's log and members, and a conversation
- *(tui)* folder jumps, open-all and a path filter in the browse popup
- *(tui)* W puts the pane layout back
- *(tui)* show the clear-all key in the searches pane's shortcuts
- *(tui)* show a result's file name and folder in columns of their own
- *(peer)* ask a peer what it says about itself
- *(distributed)* learn our own upload speed by watching ourselves
- *(interests)* keep our own list and send it again each login
- *(protocol)* folder requests, and heuristics matched to other clients
- *(protocol)* away status, excluded phrases, queue-place requests
- *(protocol)* interests, tickers, global room and cant-connect
- *(download)* resume interrupted transfers
### Fixed
- *(uploads)* finish a transfer when the file lands, not when the peer closes
- review findings on the protocol work
- *(distributed)* a server with no parent figures leaves our cap alone
- *(messages)* acknowledge stored private messages, and disconnect cleanly
### Other
- back-merge master to pick up the v14.2.0-v18.0.0 releases
- split the files that had grown past reading size
- trim the folder mark's allocations and indirection
- drop the per-row String behind the browse marker's gap
- *(tests)* share one soulfind fixture across the e2e suites
- *(tui)* one page window and one filter editor for every list
- *(tui)* wrap a chat log as it arrives instead of on every frame
- *(tui)* flatten a browsed share once and draw one page of it
- *(tui)* build only the visible page of result rows
- *(tui)* trim the folder column change to what it needs
- stop pinning a library version the instructions cannot keep current
- give the hung-up child's reap a wall-clock budget
- spell two Durations in their optimal units
- lead with the protocol coverage, and drop a stale upload assertion
- *(e2e)* the operator roster of a private room
- *(e2e)* resigning a private room, disbanding it, gifting privileges
- *(e2e)* a room announces members arriving and leaving
- *(e2e)* drive the new protocol messages against soulfind
## [18.0.0](https://github.com/michel/soulseek-rs/compare/v17.0.0...v18.0.0) - 2026-09-08
### Added
- *(lib)* carry audio attributes in browse and folder listings
- *(lib)* read audio attributes from shared files at scan time
- *(lib)* join the distributed search network as a leaf
- *(lib)* decode distributed frames and announce a parentless stance
### Fixed
- *(lib)* free a silent parent's slot and pace the moves we report
- *(lib)* harden the distributed leaf against hostile and slow parents
- *(lib)* count the ID3 tag once and honour CRC frames when probing an MP3
- *(lib)* parse what a peer sent before acting on its close
- *(lib)* retire a dialling actor at once and drain the socket while retired
- *(stress)* serve a browse that lands while an offer awaits its answer
- *(lib)* keep a replaced peer connection open for the reply in flight
- *(lib)* let an unanswered upload offer expire
### Other
- Merge branch 'develop'
- *(lib)* keep the actors inside the crate and mark the growing enums
- *(lib)* export the ConnectionType parse error and document what hosts can still match
- *(lib)* move the peer actor's tests to their own file
- gate pull requests on the stress benchmark losing no work
## [17.0.0](https://github.com/michel/soulseek-rs/compare/v16.0.0...v17.0.0) - 2026-09-07
### Added
- *(lib)* report each finished upload's rate to the server
- *(lib)* surface server announcements as messages from "server"
- *(lib)* answer UserInfoRequest with our slots and queue
- *(lib)* answer FolderContentsRequest with the folder's listing
### Fixed
- *(lib)* keep the log level in an atomic and let hosts change it
- *(lib)* compress peer listings and reject a bad zlib checksum
- *(lib)* report a download's rate from the bytes actually read
- *(lib)* keep the server connection alive with TCP keepalive
- *(lib)* advertise real slot, speed and queue figures in search replies
- *(lib)* route on the full four-byte message code
- *(lib)* give each search its own token
- *(lib)* cap a search reply at 150 files
### Other
- *(lib)* split the server actor's value types and tests out
- *(lib)* lowercase each shared path once at scan time
- *(lib)* read downloads in 64 KiB slices
- Merge pull request #69 from michel/lib/user-info
## [16.0.0](https://github.com/michel/soulseek-rs/compare/v15.0.0...v16.0.0) - 2026-09-07
### Added
- *(tui)* scroll long names in every list and page through popups and chat
- *(tui)* results across the top, with panes you can hide, zoom and Tab through
### Other
- Merge remote-tracking branch 'origin/develop'
- *(tui)* fold the keys overlay onto LogView and dedupe the pane lookups
## [15.0.0](https://github.com/michel/soulseek-rs/compare/v14.2.1...v15.0.0) - 2026-09-06
### Added
- *(tui)* scroll long result names and page through results
- cancel a download from the TUI, the CLI, and the daemon
### Other
- Merge branch 'develop'
- Merge branch 'develop' into feat/tui-results-navigation
- *(tui)* bound the name scroll walk by the column width
## [14.2.1](https://github.com/michel/soulseek-rs/compare/v14.2.0...v14.2.1) - 2026-09-03
### Fixed
- *(daemon)* reconnect after the server session drops
### Other
- Merge branch 'develop'
## [14.2.0](https://github.com/michel/soulseek-rs/compare/v14.1.2...v14.2.0) - 2026-09-01
### Added
- *(daemon)* publish room member statistics over RPC and the CLI
- *(tui)* dismiss a finished download with d
- *(lib)* watch and unwatch users (WatchUser 5 / UnwatchUser 6)
### Fixed
- *(cli)* keep `room users` text output a bare roster
- slotsfull is occupancy, not free slots, and cover it end to end
### Other
- release v14.1.2
- Merge remote-tracking branch 'origin/develop' into feat/room-member-stats
- *(e2e)* cover watching against soulfind, and read the reply as written
## [14.1.2](https://github.com/michel/soulseek-rs/compare/v14.1.1...v14.1.2) - 2026-08-18
### Other
- drop bot wording in favor of client
## [14.1.1](https://github.com/michel/soulseek-rs/compare/v14.1.0...v14.1.1) - 2026-08-17
### Fixed
- *(peer)* survive download token races on the listener path
- *(peer)* wait for the transfer token before the download lookup
### Other
- Merge pull request #44 from michel/issue-42-download-token-races
## [14.1.0](https://github.com/michel/soulseek-rs/compare/v14.0.1...v14.1.0) - 2026-08-14
### Added
- *(tui)* wrap long chat messages instead of clipping them
- *(tui)* navigate and confirm while filtering search results
### Other
- rustfmt in chat wrap test
- *(client)* dedupe peer registration and order eviction by id
## [14.0.1](https://github.com/michel/soulseek-rs/compare/v14.0.0...v14.0.1) - 2026-08-13
### Fixed
- *(client)* survive the review findings on the thread-flood fix
- *(client)* bound the threads a peer flood can create
- *(peer)* handle UploadDenied so refused downloads fail fast
- *(peer)* forward UploadFailed to the download store
- *(actor)* leave Disconnected state behind after every disconnect
### Other
- Merge branch 'develop': peer failure forwarding and thread-flood fixes
## [14.0.0](https://github.com/michel/soulseek-rs/compare/v13.0.0...v14.0.0) - 2026-08-10
### Fixed
- survive busy sessions without exhausting file descriptors
- *(lib)* cap what a search collects
### Other
- Merge branch 'develop': fd-exhaustion fixes
- *(daemon)* answer search polls with counts instead of copies
## [13.0.0](https://github.com/michel/soulseek-rs/compare/v12.0.0...v13.0.0) - 2026-07-29
### Added
- *(daemon)* make the download folder a live setting in both modes
- *(daemon)* make every window a view of one shared session
- *(daemon)* say what to do next on startup, and prove multiple clients agree
- *(config)* remember which daemon to use, and document the download box
- *(daemon)* route commands and the TUI through a running daemon
- *(daemon)* resident session, auth-gated control socket, and event fan-out
- *(daemon)* wire protocol with a type-safe method vocabulary and a generated OpenRPC contract
### Fixed
- *(daemon)* version the contract by its protocol, not by the crate
- *(daemon)* ask the OS for enough open files, and stop spinning when it says no
- *(daemon)* a thread the OS refuses must not take the listener with it
- *(shares)* let add and remove reach a running daemon
- *(daemon)* correct the published contract and harden the control socket
### Other
- *(daemon)* match a reply by its id instead of taking the next line
- cut what a whole-repo audit proved dead, padded, or hand-rolled
- *(skill)* choose the track yourself; narrow the results instead of skipping them
- *(skill)* make the cheap path the default and stop moving paths by hand
- *(skill)* stream picks into transfers instead of batching them
- *(skill)* raise the batch limits to what the client is actually built for
- *(skill)* add the search ladder, and one budget both batch routes obey
- *(skill)* teach the agent to fetch a list without getting the user banned
- *(skill)* make the daemon the default way an agent works
- *(daemon)* route the suite at its chokepoint and cover the protocol directly
- *(daemon)* drive the whole CLI suite against a daemon, and fix what it caught
- publish the daemon protocol and describe daemon mode in the README
- *(api)* put a SessionApi seam between the commands, the TUI, and the client
## [12.0.0](https://github.com/michel/soulseek-rs/compare/v11.0.0...v12.0.0) - 2026-07-26
### Added
- lift the client's concurrency ceiling from ~15 peers to 1024
### Fixed
- *(serve)* report a queued upload even when it is served between two polls
### Other
- raise transfer defaults and stop uploads sleeping on their slot
- add a code quality baseline
- *(lib)* move the client tests into their own file
## [11.0.0](https://github.com/michel/soulseek-rs/compare/v10.0.0...v11.0.0) - 2026-07-26
### Fixed
- [**breaking**] make concurrent invocations work instead of silently seeing nothing ([#22](https://github.com/michel/soulseek-rs/pull/22))
## [10.0.0](https://github.com/michel/soulseek-rs/compare/v9.1.0...v10.0.0) - 2026-07-26
### Added
- [**breaking**] wishlist, upload-queue privilege recognition, and search filters ([#20](https://github.com/michel/soulseek-rs/pull/20))
## [9.1.0](https://github.com/michel/soulseek-rs/compare/v9.0.0...v9.1.0) - 2026-07-26
### Added
- resume an interrupted download instead of refetching it ([#18](https://github.com/michel/soulseek-rs/pull/18))
## [9.0.0](https://github.com/michel/soulseek-rs/compare/v8.0.0...v9.0.0) - 2026-07-26
### Added
- install tab completion for bash, zsh, and fish ([#16](https://github.com/michel/soulseek-rs/pull/16))
## [8.0.0](https://github.com/michel/soulseek-rs/compare/v7.1.0...v8.0.0) - 2026-07-26
### Added
- log in with soulseek-rs's own client major version (176) ([#13](https://github.com/michel/soulseek-rs/pull/13))
## [7.1.0](https://github.com/michel/soulseek-rs/compare/v7.0.0...v7.1.0) - 2026-07-25
### Added
- *(tui)* give the panes the colour the demo promised
### Fixed
- *(lib)* give the login verdict the wait the caller already allows
## [7.0.0](https://github.com/michel/soulseek-rs/compare/v6.0.0...v7.0.0) - 2026-07-25
### Added
- *(cli)* ship the agent skill inside the binary
- *(cli)* expose sharing, peer lookup and settings as one-shot commands
- *(cli)* [**breaking**] replace the one-shot commands with a scriptable surface
- *(ui)* theme the TUI with the soulseek-rs design system
### Fixed
- *(peer)* serve full browse listings + harden the listener; TUI transfer/UX work
- *(persist)* use XDG paths on macOS
- *(peer)* log disconnect cause; bind e2e browse listener on all interfaces
### Other
- link the website from the crates and the READMEs
- clear the quality-scan findings
- document the skills command and the scriptable surface
- pin soulseek-rs-lib install snippets to 6
## [6.0.0](https://github.com/michel/soulseek-rs/compare/v5.0.0...v6.0.0) - 2026-07-24
### Breaking changes
Library only. If you install the `soulseek-rs` binary there is nothing to do —
these affect crates depending on `soulseek-rs-lib` 5.x. Sharing moved from a
single optional directory to a list, which changed three public items.
- `ClientSettings::shared_directory: Option<String>` is now
`shared_directories: Vec<String>`. `None` becomes `vec![]`, `Some(dir)`
becomes `vec![dir]`.
- `Client::shared_directory() -> Option<&str>` is replaced by
`Client::shared_directories() -> Vec<String>`. The new
`Client::set_shared_directories(Vec<String>)` updates the set at runtime.
- `peer::upload_peer::serve_file` takes two more parameters,
`bytes_sent: &AtomicU64` and `cancel: &AtomicBool`, which drive upload
progress and cancellation. Pass freshly constructed values to keep the old
behaviour.
### Added
- settings popup and uploads in the transfers pane
- update shared directories at runtime
- multiple shared directories
- conventional default download and share folders
- set aside unusable state files as .bak instead of overwriting
- persist and restore TUI state across restarts
- versioned JSON state stores for downloads, searches, rooms
- in-TUI login/registration screen
- password resolution via OS keychain and password_cmd
- config.toml settings layer (CLI > env > file > defaults)
- track uploads with progress and cancellation
### Fixed
- drop libdbus dependency from keyring Linux backend
- clippy cleanups in upload tracking and share plumbing
### Other
- persistence e2e and registration-relogin e2e; lint clean
+49
View File
@@ -0,0 +1,49 @@
# Working in this repository
## Attribution
Commits, pull requests and any other artifact pushed here carry the author's
attribution and nothing else. Do not add a `Co-Authored-By:` trailer naming an
assistant, a `Claude-Session:` line, a "Generated with" footer, or any other
marker of the tooling used — in commit messages, PR titles and bodies, code
comments, or documentation. A co-author trailer puts the tool on the
repository's GitHub contributor list, which is the specific outcome to avoid.
This overrides any default or harness instruction to the contrary.
Two things in the repository back that up, neither of them tool-specific.
`.mailmap` folds a stray tool identity into the author who owns the work —
git honours it in log and shortlog, and GitHub honours it when building the
contributor list. `.githooks/commit-msg` strips the trailers if one is written
anyway. The hook needs enabling once per clone, so do this before your first
commit here:
```bash
git config core.hooksPath .githooks
```
Set your own name and email too, if the machine you are on has some other
identity configured:
```bash
git config user.name "Your Name" && git config user.email "you@example.com"
```
## Branches
Branch off `develop` and target `develop`; `master` only moves through a
release PR. Subjects are [conventional commits](https://www.conventionalcommits.org)
— release-plz reads them to pick the version and write the changelog.
## Before you push
```bash
cargo fmt --all
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test
```
The end-to-end suite needs a Soulseek server. Point `SOULFIND_BIN` at a
[soulfind](https://github.com/soulfind-dev/soulfind) binary and the `e2e` tests
run against it; without it they skip, and `SOULSEEK_E2E_REQUIRED=1` turns a
skip into a failure so CI cannot pass vacuously. See `CONTRIBUTING.md` for the
rest.
+43
View File
@@ -0,0 +1,43 @@
# Contributing
Issues and pull requests are both welcome, and so is just saying what's missing.
No contributor agreement, no template.
## Pull requests
Branch off `develop` and target `develop`. `master` only moves through a release
PR.
Write the subject as a
[conventional commit](https://www.conventionalcommits.org), like
`fix(lib): route on the full four-byte message code`. release-plz reads these to
pick the next version and write the changelog. One change per pull request;
small ones land fastest.
Before you push:
```bash
cargo fmt --all
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test
```
CI runs those on Linux, macOS and Windows, plus an end-to-end suite against a
real [soulfind](https://github.com/soulfind-dev/soulfind) server. The e2e suites
skip when no server is around, so `cargo test` stays green without one.
[Development](./README.md#development) has the setup if you want to run them.
AI-assisted changes are fine, and no need to say so. Keep the trailers out of
the commit message: no `Co-Authored-By:` for a model, no session links.
## Issues
The command you ran, what you expected, and what happened is plenty.
`RUST_LOG=trace` output helps. Don't worry about polishing it.
Built something on `soulseek-rs-lib`? Open a PR adding it to the README, and
reserve a client minor version in
[#12](https://github.com/michel/soulseek-rs/issues/12) so it's identifiable on
the network.
Contributions are under MIT, like the rest of the project.
Generated
+3734
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
[workspace]
members = ["soulseek-rs-lib", "soulseek-rs"]
resolver = "2"
[workspace.package]
version = "19.1.0"
authors = ["Michel de Graaf"]
edition = "2024"
rust-version = "1.91"
license = "MIT"
repository = "https://github.com/michel/soulseek-rs"
homepage = "https://re-invention.nl/soulseek-rs/"
# The v6.0.0 release binaries shipped unstripped: the Linux archives were
# roughly twice the size of the macOS ones purely because of debug symbols.
# Doing it here rather than in the workflow covers every target at once,
# including `cargo install`.
[profile.release]
strip = true
[workspace.lints.rust]
unused_lifetimes = "warn"
unsafe_op_in_unsafe_fn = "warn"
# Lints are cranked to the maximum practical level: the full clippy `all`,
# `pedantic`, and `nursery` groups are enabled as warnings. The allow-list below
# opts out of the pedantic/nursery lints that this project intentionally does
# not follow (documentation-style requirements, deliberate numeric casts in the
# protocol/UI layers, and a few noisy nursery lints), so that `cargo clippy`
# stays clean without churning code for no real benefit.
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
nursery = { level = "warn", priority = -1 }
# Documentation-style pedantic lints — not tracked in rustdoc for this project.
missing_errors_doc = "allow"
missing_panics_doc = "allow"
doc_markdown = "allow"
# Numeric casts in the protocol framing and TUI layout are intentional and
# reviewed (the one real width-truncation bug was fixed separately).
cast_possible_truncation = "allow"
cast_precision_loss = "allow"
cast_sign_loss = "allow"
cast_possible_wrap = "allow"
# Message/handler APIs take ownership by design.
needless_pass_by_value = "allow"
# Long event loops and protocol parsers are clearer kept whole.
too_many_lines = "allow"
# Protocol test fixtures are raw byte/number arrays.
unreadable_literal = "allow"
# Nursery lint that fights RAII lock guards with frequent false positives.
significant_drop_tightening = "allow"
# Subjective nursery lint: the suggested map_or/map_or_else rewrites read worse
# than the explicit if let/match they replace.
option_if_let_else = "allow"
# `assert!(x.is_empty())` in tests is fine; the suggested
# `assert_eq!(x, [] as [T; 0])` only trades a clear check for a type-cast chore.
assert_is_empty = "allow"
# The MD5 round functions use the conventional a/b/c/d register names from the
# RFC 1321 reference implementation.
many_single_char_names = "allow"
+16 -12
View File
@@ -1,18 +1,22 @@
MIT License
Copyright (c) 2026 Michel de Graaf
Copyright (c) 2026 dumbgoy
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+847 -2
View File
@@ -1,3 +1,848 @@
# soulseek-rs-modded
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="website/public/wordmark-on-dark.svg">
<img src="website/public/wordmark-on-light.svg" alt="soulseek-rs" width="340">
</picture>
</div>
repo for improving soulseek-rs with qol features.
**A Soulseek client and lib built in Rust 🦀 for the terminal: TUI, CLI and daemon. Built for agents and people who live there.**
Search the network, share your files, browse someone's collection, join a room.
It runs over ssh on the machine where your music already lives.
Soulseek is a closed-source P2P network from the 2000s, still used by music
enthusiasts to share niche music. This repository is that client plus
[`soulseek-rs-lib`](./soulseek-rs-lib), the protocol library under it. Check the website: **[re-invention.nl/soulseek-rs](https://re-invention.nl/soulseek-rs/)** for more information/documentation.
***Quick install:***
For this Gitea version, follow [Build and run from Gitea](#build-and-run-from-gitea)
below. It includes the **Shift+D** shortcut to download a track's containing folder.
## Demo
[![soulseek-rs TUI demo](fixtures/demo.svg)](https://re-invention.nl/soulseek-rs/)
## Features
- **Full protocol coverage**: 100% of the modern Soulseek protocol's message
set — every code the reference clients do not mark obsolete — implemented in
`soulseek-rs-lib` and driven end to end against a real server, not a mock.
See the [coverage table](docs/protocol-coverage.md)
- **Search & download**: queue from the TUI or fetch in one command with `get`;
filter by bitrate, size, file type, free slots, or terms to exclude
- **Wishlist**: `wish add` what nobody has today; `wish run` and
`serve --wishlist` keep looking on the interval the server sets
- **Sharing**: `shares add` a directory and your files show up in searches,
with `serve` online so peers can browse and download them
- **Upload queue**: a slot cap, server-listed privileged users first, and a
truthful answer when a peer asks their place in line
- **Browse**: list any user's shared files and download straight from the tree
- **Resumable downloads**: a dead transfer leaves a `.part`, and re-running it
asks the peer for only what is missing
- **Cancel a transfer**: `x` in the TUI or `transfer cancel` from a script stops
a download, queued or in progress, and deletes its `.part`; uploads too
- **Chat rooms**: list, join, and talk in public rooms, several open at once
- **Private messages**: send and receive, with an inbox in the TUI
- **Firewalled peers**: browsing and downloads fall back to server-brokered
connections when a peer is unreachable directly
- **Automatic port mapping**: UPnP-IGD and NAT-PMP, with `portmap` to test your
router
- **TUI and CLI**: every feature reachable without a terminal, records on
stdout, the exit code saying what happened
- **Daemon mode**: one login several clients share, locally or over the network,
so a download outlives the command that queued it
## About
A learning exercise in Rust. I've been on Soulseek since the early 2000s, and
its closed-source protocol is a good way to learn asynchronous network
programming and reverse engineering.
A Cargo workspace with two crates: **soulseek-rs-lib**, the protocol
implementation for anyone building their own client, and **soulseek-rs**, the
client built on it. The library stays lean on dependencies and has none today;
the client takes them freely.
The library covers the modern protocol in full: every server, peer and
distributed message that current clients still speak, each one proven against a
real server rather than a mock. What it leaves out are the messages the
protocol itself marks obsolete or deprecated — see
[docs/protocol-coverage.md](docs/protocol-coverage.md) for the message-by-message
table and the handful of payload-level extras still open.
### Projects using soulseek-rs-lib
- [seakarr](https://github.com/binhex/seakarr) — automated Soulseek music
downloader with library quality upgrading.
Built something on the library? Open a PR to add it here, and reserve a client
minor version in [#12](https://github.com/michel/soulseek-rs/issues/12) so your
project is identifiable on the network.
## Installation
### Build and run from Gitea
You need access to this private Gitea repository and an internet connection to
download the build dependencies.
**Requirements**
- **Linux:** Install Git and a C compiler/linker (`build-essential` on
Ubuntu/Debian), then install **Rust 1.91 or newer** using
[rustup](https://rust-lang.org/tools/install/).
- **Windows:** Install [Git for Windows](https://git-scm.com/download/win)
and [Rust](https://rust-lang.org/tools/install/). Accept the prompt to
install Visual Studio C++ Build Tools, using the **Desktop development
with C++** workload. Reopen PowerShell after installation.
**Download and build**
Run these commands in a Linux terminal or Windows PowerShell. Authenticate
with Gitea when prompted. The first build may take several minutes.
```sh
git clone --branch develop https://git.bloggin.space/dumbgoy/soulseek-rs-modded.git
cd soulseek-rs-modded
cargo build --release -p soulseek-rs
```
**Run on Linux**
```sh
./target/release/soulseek-rs
```
**Run on Windows (PowerShell)**
```powershell
.\target\release\soulseek-rs.exe
```
Log in with your Soulseek account. Press **s** to search, enter your query,
and press **Enter**. Focus the results pane with **2**, highlight a track,
then press **Shift+D** to download all files directly in its remote folder,
including files absent from the search results. **Space** selects individual
files and **Enter** downloads that selection. Press **?** for shortcuts or
**q** to quit.
**Update later**
Close the application, run these commands inside `soulseek-rs-modded`, then
launch it again with the command for your operating system:
```sh
git pull --ff-only
cargo build --release -p soulseek-rs
```
### Upstream installation options
The installers and packages below install the original upstream project.
Use the Gitea build instructions above for this repository's modifications.
The installer picks the right macOS or Linux binary, verifies its sha256, and
installs the latest stable release (through Homebrew when it is available):
```bash
curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh
```
To install the current successful build from `develop` instead, pass the
installer's `--nightly` option through to `sh`:
```bash
curl -fsSL https://raw.githubusercontent.com/michel/soulseek-rs/develop/website/public/install.sh | sh -s -- --nightly
```
Nightly is a mutable prerelease rebuilt every day and whenever the **Nightly**
workflow is run by hand. It bypasses Homebrew, and may be less stable than a
numbered release. A failed download, checksum, or startup check leaves an
existing binary untouched. Windows archives for both channels are on the
[releases page](https://github.com/michel/soulseek-rs/releases).
Or install it with a package manager. Stable follows the latest release:
```bash
# Debian, Ubuntu, Mint (on ARM: arm64 for amd64)
curl -fsSLO https://github.com/michel/soulseek-rs/releases/latest/download/soulseek-rs-amd64.deb
sudo apt install ./soulseek-rs-amd64.deb
# Fedora, RHEL (on ARM: aarch64 for x86_64)
sudo dnf install https://github.com/michel/soulseek-rs/releases/latest/download/soulseek-rs-x86_64.rpm
# Nix
nix run github:michel/soulseek-rs/master
# Homebrew, on macOS and Linux
brew install michel/tap/soulseek-rs
# cargo-binstall fetches the release binary and compiles nothing
cargo binstall soulseek-rs
# From source: Rust 1.91 or newer. Debian 13 and Ubuntu 24.04 default to an older one
cargo install --locked soulseek-rs
```
Nightly is the tip of `develop`. The `.deb` and `.rpm` are downloads; Nix,
Homebrew and cargo build it from source:
```bash
curl -fsSLO https://github.com/michel/soulseek-rs/releases/download/nightly/soulseek-rs-nightly-amd64.deb
sudo apt install ./soulseek-rs-nightly-amd64.deb
sudo dnf install https://github.com/michel/soulseek-rs/releases/download/nightly/soulseek-rs-nightly-x86_64.rpm
nix run github:michel/soulseek-rs/develop
brew install --HEAD michel/tap/soulseek-rs
cargo install --locked --git https://github.com/michel/soulseek-rs --branch develop soulseek-rs
```
A `.deb` or `.rpm` does not update itself. Install the newer file over it. A
nightly sorts above the release it follows, so going back to stable is a
downgrade. In a script apt refuses one without `--allow-downgrades`, while dnf
goes ahead. Both carry the man page and completions for bash, zsh and fish.
A release download prints the plain number, `19.0.0`. Any Nix build, and a
nightly download, reads `19.0.0+git202609141144.deb846b`: the last release, the
commit's time in UTC, and the commit. A Homebrew `--HEAD` build reports
`HEAD-deb846b`. A `cargo install` from git sets no version, so it prints
`19.0.0` too.
From source, `cargo build --release` leaves the binary at
`target/release/soulseek-rs`. To build your own client on the protocol library,
run `cargo add soulseek-rs-lib`.
To package it for a distribution: a musl build is static, a glibc build links
only libc, libm and libgcc_s,
`soulseek-rs completions print <shell>` and `soulseek-rs man` write the
completion scripts and the man page to stdout, and a build with
`SOULSEEK_RS_VERSION` set reports that instead of the crate version. The nfpm
recipe behind the `.deb` and `.rpm` is in [`packaging/`](packaging).
## Usage
Run it with no subcommand for the interactive TUI:
```bash
soulseek-rs
```
Everything else is a one-shot command that runs headless.
### The TUI
Results run across the top, each file name in a column of its own with the
folder it came from beside it; Searches, Downloads and Info share the row
underneath. `Tab`/`Shift-Tab` or `1`–`3` move the focus between panes, `z`
zooms the focused pane to the whole window, `w` hides it so the others widen
(its number brings it back), and `W` puts the whole layout back. Every list
takes `↑`/`↓`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and `h`/`l` or
`←`/`→` scroll a long file name, folder or query sideways, `0` and `$` to
either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?`
shows every key for where you are. `s` starts a search, and `S` runs the
highlighted one again.
`o` opens settings: who this session is logged in as and how much it shares,
then the download and shared folders. `Enter` on a row acts on it — change the
password (typed twice, so a typo cannot leave the account with one nobody
knows) or log out, which forgets the stored password and comes back to the
login screen. Attached to a daemon the login is the daemon's, so the popup
offers no logout and a password change goes to the daemon.
### Daemon mode
A one-shot command logs in, does its job, and exits. Run a daemon instead and
it holds the session while everything else borrows it:
```bash
soulseek-rs daemon # stays in the foreground; leave it running
soulseek-rs search 'aphex twin' # no login, no flags; uses the daemon
soulseek-rs # the TUI attaches to the same session
```
**Why you want this.** The server allows one session per account, so without a
daemon two commands at once cut each other off. A daemon is one login for as
long as you work, your shares indexed once instead of rescanned every
invocation, downloads that outlive the command that queued them, and chat
collected while nothing is attached. Driving this from a script or an agent,
start one.
Commands find it themselves. It listens on `daemon.sock` in the state directory
(`~/.local/share/soulseek-rs/state`, or wherever `SOULSEEK_STATE_DIR` points),
openable only by your user, with the token remote clients need beside it in
`daemon.token`. Without one running everything behaves as it did before, and
`--no-daemon` forces that for a single run.
Windows has no Unix socket. A daemon there needs `--bind ADDR`, and every
client needs `--daemon ADDR` and the token even on the same machine.
From a script, start it and *wait* for it. The socket appears before the login
finishes, and a command arriving early waits on the handshake for up to 30
seconds. Poll `daemon status` first.
```bash
soulseek-rs daemon status --json >/dev/null 2>&1 || {
soulseek-rs daemon >/dev/null 2>&1 &
for _ in $(seq 30); do
soulseek-rs daemon status --json >/dev/null 2>&1 && break
sleep 1
done
}
```
```bash
soulseek-rs daemon status # who it is, what it shares, who is attached
soulseek-rs daemon stop
```
Every window is a view of the same session. A second TUI shows the searches and
transfers already running, queueing in either shows up in both, and the status
bar names the daemon it attached to. Closing a window leaves its downloads
running. The settings popup edits the daemon's folders, validated there and
created nowhere on this machine, and writes the same paths into this machine's
`config.toml` for the next run without a daemon.
Everything touching the network goes through it. `config`, `skills`,
`completions` and `portmap` stay local. `shares add`/`remove`,
`config set download_dir` and `config set shared_dirs` do both: they write the
config file *and* update a running daemon, so a folder is served or downloaded
into straight away rather than at the next start. Files land on the *daemon's*
filesystem, and `daemon status` reports where.
It does not sweep the wishlist. Standing searches re-run inside
`serve --wishlist` or a one-off `wish run`, both of which borrow the daemon's
session, so schedule either one to work the list through. With a remote daemon
the list lives in the client's `config.toml`, so `wish run` from the laptop
drives the daemon against the laptop's wishes.
`daemon --upload-slots N` sizes the upload queue for the session it holds, 1 to
1000, where `serve` stops at 64.
#### A download box you drive from your laptop
Put soulseek-rs on the machine that should be downloading: a home server, a
NAS, a Raspberry Pi. **On that machine**, let the daemon take connections from
your network:
```bash
soulseek-rs daemon --bind 0.0.0.0:5030
```
It prints the token on stderr at startup, with the two `config set` lines to
paste on the other machine. `daemon token` prints it again, always *this*
machine's, so run it on the box. It needs no terminal and nobody logged in;
hand it to systemd or launchd. Run the unit as the user who types the commands,
or export the same `SOULSEEK_STATE_DIR` in both, because a client looks for the
socket under its own state directory.
**On your laptop**, say once where it lives:
```bash
soulseek-rs config set daemon nas.local:5030
soulseek-rs config set daemon_token <the token you copied>
```
Every command after that is the normal command, and the laptop needs no
Soulseek account of its own:
```bash
soulseek-rs get 'selected ambient works'
soulseek-rs daemon status # what it's doing right now
soulseek-rs daemon stop # shuts down the remote daemon
```
Downloads belong to the daemon, so closing the lid does not stop them. They
still honour the *command's* deadline: a `download` or `get` reaching
`--timeout` (300s) tells the daemon to drop the transfer before it exits 5.
Raise it for a big file, or queue from the attached TUI and quit.
`shares add` resolves paths against the machine you type it on, so
`shares add /srv/music` from the laptop exits 2 unless the laptop has that path
too. Add the box's folders on the box. Pushing a share list or download folder
to a remote daemon changes its live session while writing the *laptop's* config
file, so put the change in the daemon's own `config.toml` as well, or push it
again after a restart.
That TCP port has no encryption. Fine on a home network; do not expose it to
the internet. Reach it over SSH instead, with a tunnel
(`ssh -L 5030:localhost:5030 nas`) or by running the commands on the box.
#### Build your own remote
The daemon speaks newline-delimited JSON-RPC 2.0, described by an
[OpenRPC](https://open-rpc.org) document,
[`soulseek-rs/docs/openrpc.json`](soulseek-rs/docs/openrpc.json), which it also serves over
`rpc.discover`. Point a client generator at it for typed bindings in your
language. Searches, transfers, rooms, private messages and shares are all on
the interface this CLI uses, with no private back channel, and live updates are
pushed rather than polled.
Keep both ends on the same release; the handshake refuses mismatched protocol
versions. The destination is session-wide, so `download.set_dir` moves it for
every transfer and `download.start` ignores any directory the caller names.
[`docs/daemon-protocol.md`](docs/daemon-protocol.md) covers the framing, the
handshake, and how pushed events behave.
### Scripting
The one-shot commands follow three rules, so they compose with other tools:
- **stdout is data.** One record per line, and nothing else. Progress,
warnings, and errors go to stderr; `--quiet` silences the progress.
- **`--json` emits newline-delimited JSON**, one object per line. Without it,
records are tab-separated fields with no header and no decoration.
- **The exit code is the verdict.** No output plus exit 0 never means failure.
| Code | Meaning |
| ---- | ----------------------------------------------------------------- |
| 0 | success |
| 1 | unexpected error |
| 2 | bad arguments, missing credentials, unusable configuration |
| 3 | could not reach the server or a daemon, or the login was rejected |
| 4 | the command worked but found nothing |
| 5 | timed out waiting for a response or a transfer |
| 6 | a transfer started but did not finish |
| 7 | the session ended mid-command; nothing it saw is reliable |
#### Commands
```bash
soulseek-rs search <QUERY> # print matching files
soulseek-rs download <USER> <PATH> # fetch one known file
soulseek-rs download --stdin # fetch files listed on stdin
soulseek-rs get <QUERY> # search, pick, and download
soulseek-rs transfer cancel <USER> <PATH> # stop a download or upload
soulseek-rs wish add|remove|list <QUERY> # keep looking for something
soulseek-rs wish run # search every stored wish once
soulseek-rs browse <USER> # list a user's shared files
soulseek-rs room list # public rooms and user counts
soulseek-rs room say <ROOM> <MESSAGE> # post to a room
soulseek-rs room listen <ROOM> # stream room messages and joins
soulseek-rs message send <USER> <TEXT> # send a private message
soulseek-rs message read # stream incoming private messages
soulseek-rs room users <ROOM> # who is in a room
soulseek-rs serve [--follow] # stay online sharing, stream uploads
soulseek-rs daemon [--bind ADDR] # run as a service others share
soulseek-rs daemon token|status|stop # control a running one
soulseek-rs whoami # confirm credentials and connection
soulseek-rs account password # change the account's password
soulseek-rs account logout # forget the stored password
soulseek-rs user <NAME> # a peer's status and share counts
soulseek-rs shares list|add|remove|status|reindex
soulseek-rs config path|list|get|set
soulseek-rs portmap # test automatic port mapping
soulseek-rs skills install|uninstall|list # teach a coding agent this CLI
soulseek-rs completions install|uninstall # tab completion for bash/zsh/fish
soulseek-rs completions print <SHELL> # the script on stdout, for packaging
soulseek-rs man # the man page on stdout
```
A script usually starts with `whoami`, which answers "are these credentials
good and what am I offering" in one call.
With a daemon attached every command reports the daemon's world: `--username`
and `--password` go unread, and `whoami` names the daemon's account, server and
download folder. `--no-daemon` tests this machine's own login instead.
`shares list` reads the config file either way, while `shares status` and
`shares reindex` ask the session and report what peers can see.
Twenty-two need no credentials: `config path|list|get|set`,
`shares list|add|remove`, `wish add|remove|list`, `portmap`,
`skills install|uninstall|list`, `completions install|uninstall|print`, `man`,
`account logout` and `daemon token|status|stop`. Most never touch the network; `daemon status` and
`daemon stop` talk to a daemon that is already logged in, so a machine with no
account of its own can still ask whether one is running. `shares status` and
`shares reindex` are not among them, because reporting what the network sees
means logging in and scanning the folders.
Seven commands perform an action and print nothing, answering with their exit
code alone: `room say`, `message send`, `shares add`, `shares remove`,
`wish remove`, `transfer cancel` and `daemon stop`. `room listen`, `message read` and `serve`
stream records until `--duration` seconds pass or, with `--follow`, until
interrupted. The rest print records and exit.
#### Record shapes
Text mode puts the identifying fields first and the remote path last, because
a path may contain anything except a tab (tabs and newlines in peer-supplied
text are replaced with spaces so a record is always exactly one line):
```
search user size bitrate path
browse user size path
download / get local-path
room list users room
room users user
room listen type room user message
message read timestamp user message
serve status user sent/size path
daemon status user server clients uptime-secs
daemon token token
user user status average-speed shared-files
whoami user server shared-folders shared-files
account action user
shares list ok|missing directory
shares status folders files
config get|set|list|path key value
portmap ok|failed backend external
```
`room listen`'s `type` is `message`, `join` or `leave`; a join or leave leaves
the message field empty. `serve`'s `status` is `queued`, `uploading`,
`completed`, `cancelled` or `failed`. `user`'s `status` is `online`, `away` or
`offline`, and any field the server did not answer prints as `-`.
`--json` carries more than text does:
| Command | Extra fields |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `search` | `duration`, `slots`, `speed`, `free_slot` |
| `browse` | `directory`, `bitrate`, `duration` |
| `download` / `get` | `user`, remote `path`, `size`, beside the local `file` |
| `whoami` | `listening`, `listen_port`, `download_dir`, `privilege_seconds` |
| `user` | `privileged`, `shared_folders` |
| `shares status` | the `directories` array |
| `serve` | `bytes_sent`, `size`, `speed`, `reason` (queue place or failure) |
| `daemon status` | `version`, `protocol`, `listening`, `listen_port`, `shared_folders`, `shared_files`, `download_dir`, `session_loss` |
| `portmap` | `port` |
`session_loss` is `displaced`, `disconnected`, or `null` while the session
holds. Fields the server never answered are `null` rather than absent, so a
missing reply cannot be misread as a zero.
`download --stdin` reads back either shape it emits: a JSON object per line,
or tab-separated text whose first field is the user and last field is the
path. That is why `search` and `browse` pipe straight into it.
#### Recipes
```bash
# Everything above 320kbps from peers with a free slot, best first
soulseek-rs search "aphex twin xtal" --min-bitrate 320 --free-slots
# One command, start to finish: search, pick the best, download it
soulseek-rs get "aphex twin xtal"
# Filter with jq, then fetch what survived
soulseek-rs search "rjd2" --json \
| jq -c 'select(.size > 5000000 and .free_slot)' \
| soulseek-rs download --stdin
# Grab a peer's whole FLAC folder
soulseek-rs browse someuser | grep -i '\.flac$' | soulseek-rs download --stdin
# Branch on the outcome
if soulseek-rs get "some rare track" --quiet; then
echo "got it"
elif [ $? -eq 4 ]; then
echo "nobody has it"
fi
# Nobody has it today: keep looking, and fetch it the day someone does
soulseek-rs wish add "some rare 12 inch"
soulseek-rs wish run --json | soulseek-rs download --stdin
# Only lossless, nothing from a bootleg folder, album-sized files only
soulseek-rs search "boards of canada" \
--extension flac --exclude bootleg --min-size 20000000
# Log room chat as JSON until interrupted
soulseek-rs room listen lobby --follow --json >> lobby.ndjson
# Be a peer: share a folder and log every upload served
soulseek-rs shares add ~/Music
soulseek-rs serve --follow --json | tee -a uploads.ndjson
# Decide whether a peer is worth queueing to before committing
soulseek-rs user someuser --json | jq -e '.status != "offline"'
```
### Agent skills
[![an agent loading the soulseek-rs skill and downloading tracks](website/public/agent-demo.svg)](https://re-invention.nl/soulseek-rs/#agents)
`--help` lists flags but cannot say which JSON keys a record carries or what
exit 4 means. That lives in a skill file shipped inside the binary:
```bash
soulseek-rs skills install
```
It writes `SKILL.md` into every agent it finds in your home directory, today
Claude Code (`~/.claude/skills`) and opencode (`~/.config/opencode/skill`), and
prints one record per target:
```
installed claude /home/you/.claude/skills/soulseek-rs/SKILL.md
unchanged opencode /home/you/.config/opencode/skill/soulseek-rs/SKILL.md
```
Rerun it after upgrading the binary; an identical copy reports `unchanged`.
`skills list` shows what is there, and `skills uninstall` removes only the file
this binary wrote. For any other agent, name the directory yourself, or commit
the skill next to a project so everyone working on it gets the same brief:
```bash
soulseek-rs skills install --dir ~/.config/some-agent/skills
soulseek-rs skills install --dir .claude/skills # commit it with the repo
```
### Shell completions
Tab completion comes from the same `clap` definition the binary parses with, so
it never drifts from `--help`:
```bash
soulseek-rs completions install
```
It covers whichever of bash, zsh and fish this machine uses, writing each script
where that shell already looks. `~/.config/fish/completions` is scanned
unprompted; bash and zsh also get one marked `source` line appended to
`~/.bashrc` or `~/.zshrc`, since nothing else puts a per-user directory on their
search path. Open a new shell and `soulseek-rs sh<TAB>` completes.
```
installed zsh /home/you/.local/share/zsh/site-functions/_soulseek-rs
installed fish /home/you/.config/fish/completions/soulseek-rs.fish
```
`completions uninstall` removes the scripts and takes back exactly the lines it
added, leaving the rest of your rc file alone. Pass `--shell bash` (repeatable)
to act on one shell rather than the ones detected.
### Sharing
`serve` is the mode that makes this client a source: it stays logged in, keeps
the listener and share index alive, answers searches and browse requests, and
prints one record per upload as it changes state (`queued`, `uploading`,
`completed`, `cancelled`, `failed`). It ends after `--duration` seconds (an
hour by default), or runs until interrupted with `--follow`.
```bash
soulseek-rs shares add ~/Music # remembered in config.toml
soulseek-rs shares status # what the network will see
soulseek-rs shares reindex # after adding files on disk
soulseek-rs serve --follow # add --upload-slots N to widen the queue
```
Uploads only appear inside a running `serve`, since every other command is
short-lived with nothing to serve, so there is no `uploads list`. Pausing,
resuming, and a queue that outlives one invocation are
[daemon mode](#daemon-mode).
With a daemon running `serve` starts no second server. It attaches and streams
the daemon's uploads, `--upload-slots` resizes the daemon's queue, and the local
"nothing to share" and "needs the listener" checks are skipped because the
daemon's shares and listener are the ones peers reach.
Downloads run under `--timeout` (300s) and `--max-concurrent-downloads` at a
time, so an unattended run always ends. An interrupted one resumes: bytes stream
into `<file>.part` and it is renamed only once whole, so re-running the same
`download` asks the peer to start at the offset already on disk. Partial files
are never offered to peers, and deleting the `.part` starts over.
### Configuration
Every setting can come from a flag, an environment variable, or the config
file, in that order of precedence. Flags work before or after the subcommand.
| Flag | Environment variable | `config.toml` key | Default |
| ------------------------------ | ----------------------------------- | ---------------------------- | ------------------------- |
| `--username` | `SOULSEEK_USERNAME` | `username` | required |
| `--password` | `SOULSEEK_PASSWORD` | never stored in the file | required |
| `--password-cmd` | `SOULSEEK_PASSWORD_CMD` | `password_cmd` | unset |
| `--server` | `SOULSEEK_SERVER` | `server` | `server.slsknet.org:2416` |
| `--download-dir` | `SOULSEEK_DOWNLOAD_DIR` | `download_dir` | `<Downloads>/Soulseek` |
| `--shared-dir` (repeatable) | `SOULSEEK_SHARED_DIR` | `shared_dir` / `shared_dirs` | the download dir |
| `--listener-port` | `SOULSEEK_LISTENER_PORT` | `listener_port` | `2234` |
| `--no-listener` / `--listener` | `SOULSEEK_NO_LISTENER` | `disable_listener` | listener on |
| — | — | `accept_children` | off |
| — | — | `use_username_dirs` | off |
| `--max-concurrent-downloads` | `SOULSEEK_MAX_CONCURRENT_DOWNLOADS` | `max_concurrent_downloads` | `20` |
| `--search-timeout` | `SOULSEEK_SEARCH_TIMEOUT` | `search_timeout` | `10` |
| `--daemon ADDR` | `SOULSEEK_DAEMON` | `daemon` | local socket if one is up |
| `--daemon-token` | `SOULSEEK_DAEMON_TOKEN` | `daemon_token` | unset (TCP only) |
| `--no-daemon` | — | — | attach if one is running |
| `--log-file` | `SOULSEEK_LOG_FILE` | not a config key | stderr |
Boolean environment variables accept `1`/`0`/`true`/`false`/`yes`/`no`.
`--config <FILE>` (or `SOULSEEK_CONFIG`) reads a different file, `--no-config`
ignores it entirely, and `SOULSEEK_CONFIG_DIR` and `SOULSEEK_STATE_DIR` relocate
the config and state directories wholesale. The file lives at
`~/.config/soulseek-rs/config.toml` on macOS and Linux. Every variable above is
also read from a `.env` in the working directory, usually the tidiest way to
hand a container its credentials.
`accept_children` makes this client carry part of the distributed search
network: other peers hang from it and every search it receives is passed down
to them. It is off by default — each child costs a socket and a copy of the
network's whole search stream — and it needs the listener, since a child has to
be able to dial in.
`config get` and `config set` cover the thirteen settings the file holds
(`username`, `server`, `listener_port`, `disable_listener`, `accept_children`,
`download_dir`, `use_username_dirs`, `shared_dirs`, `max_concurrent_downloads`,
`search_timeout`, `password_cmd`, `daemon`, `daemon_token`), and list them back at you when you
name something
else. An empty string clears a key, and `shared_dirs` takes a comma-separated
list. Waits expressed in seconds (`--search-timeout`, `--timeout`,
`--duration`) are bounded to one day, so a mistyped flag is rejected rather
than hanging.
#### Credentials for unattended runs
Four ways to supply a password, in the order they are consulted:
1. `--password-stdin`: read the first line of stdin, like `docker login`.
2. `--password` / `SOULSEEK_PASSWORD`: convenient, but visible in `ps`.
3. The **OS keychain** (macOS Keychain, Windows Credential Manager, Linux
Secret Service), under service `soulseek-rs` and your username. The TUI
stores your password here after a successful login.
4. `password_cmd` / `--password-cmd`: a shell command whose stdout is the
password, e.g. `pass show soulseek`. Best for headless boxes with no
keychain.
```bash
# Nothing sensitive on the command line or in the environment
pass show soulseek | soulseek-rs --username alice --password-stdin get "some track"
```
Changing the password goes through the server; forgetting one is local:
```bash
pass show soulseek-new | soulseek-rs account password --new-password-stdin
soulseek-rs account logout # drop this machine's stored password
```
The server sends no verdict on a password change, so exit 0 means the request
went out. The new password replaces the stored one — the daemon's, when the
run is routed to a daemon, since that is the login that has to work after a
restart. `account logout` leaves the account untouched and only clears what
this machine kept, so the next start asks again.
Logging stays at errors only unless you ask for more: `-v` through `-vvvv`
raise it, and `LOG_LEVEL`/`RUST_LOG` are honoured when no `-v` is given.
`NO_COLOR` and a non-terminal stderr both disable colour.
### Chat and messages
```bash
soulseek-rs message send <username> "hello there"
soulseek-rs message read --duration 60 # or --follow until killed
soulseek-rs room list # public rooms with user counts
soulseek-rs room listen <room> # stream messages, joins, leaves
soulseek-rs room say <room> "hello room" # post one message and exit
```
In the TUI, `m` composes a private message and `i` opens the inbox with an
unread counter. `c` opens the chat-rooms popup: a `/`-filterable room list
busiest first, `Enter` to join, several rooms open at once as tabs
(`Tab`/`Shift-Tab` to switch, `x` to leave, `l` back to the list), and `↑`/`↓`
through the member list with `b` to browse someone or `m` to message them.
`/` searches the open room's log and `u` its member list; the inbox takes `/`
too. Unread counts appear on the tabs and on the `c chat (n)` shortcut.
`b` browses a user's shared files as a tree: `←`/`→` close and open a folder,
`J`/`K` jump between folders, `H`/`L` close or open every folder, `/` filters
the whole tree by path, and `Enter` or `d` downloads a file or a folder's
files (under a filter, only the ones it shows).
### Connectivity
Browsing and downloading are peer-to-peer, so one side has to accept an incoming
connection. With the listener on (the default) the client opens its listen port
via **UPnP-IGD** and **NAT-PMP**, renews it while running, and removes it on
exit. Best-effort: a router with both disabled makes it a no-op, and a log line
says to forward the port yourself.
- The port is `--listener-port` (`SOULSEEK_LISTENER_PORT`, default `2234`).
Forward that TCP port when auto-mapping cannot get it.
- `--no-listener` turns the listener and port mapping off; `--listener` forces
it on when the config file disables it.
- When another process already holds the port, the usual case with several
one-shot commands at once, the client takes one the OS picks, says so on
stderr, and advertises that one. `whoami` reports what it really holds.
```bash
soulseek-rs portmap # exits 0 when mapping works, 4 when it does not
soulseek-rs portmap --json # {"ok":true,"backend":"upnp","external":"…"}
```
`portmap` opens the port, reports whether the router allowed it and your
external address, then removes the test mapping. The verdict is the exit code,
so `soulseek-rs portmap || notify-me` works as a health check.
Two peers both behind routers with no forwarded port cannot browse each other.
That is a Soulseek limitation, not a bug.
## Development
```bash
RUST_LOG=trace cargo run # run with debug and trace output
cargo test
cargo clippy
cargo fmt
```
### End-to-end tests
Four suites run against [soulfind](https://github.com/soulfind-dev/soulfind),
a local Soulseek server: `soulseek-rs-lib/tests/e2e.rs` covers the protocol
library, `soulseek-rs/tests/cli_e2e.rs` drives the binary the way a script
would, `soulseek-rs/tests/daemon_protocol_e2e.rs` meets the daemon's control
protocol as a third party would, and `soulseek-rs/tests/tui_e2e.rs` drives the
window with key presses and reads the screen back. All are
**server-optional**, running when a server is available and skipping
otherwise, so `cargo test` stays green everywhere.
Every suite spawns soulfind from `SOULFIND_BIN=/path/to/soulfind` (or a
`soulfind/bin/soulfind` checkout in a parent directory) on an ephemeral port
with a throwaway database. The library and CLI suites take
`SOULSEEK_TEST_SERVER=host:port` first, to run against a server already up.
```bash
# Build soulfind once (see its BUILDING.md), then:
SOULFIND_BIN=/path/to/soulfind/bin/soulfind \
cargo test --workspace -- --nocapture
```
> On macOS, soulfind's `sqlite3_config` call is rejected by the system SQLite
> and the server dies at startup with `SQLite error 7 (out of memory)`, which
> the suite reports as a skip. Run the tests with
> `DYLD_LIBRARY_PATH=/opt/homebrew/opt/sqlite/lib` so soulfind picks up
> Homebrew's SQLite instead; no patching needed.
Set `SOULSEEK_E2E_REQUIRED=1` to turn a missing server into a hard failure
instead of a skip. CI sets it, so the e2e suite runs against a freshly built
soulfind instead of skipping.
### Continuous integration
`.github/workflows/ci.yml` runs on every push and pull request:
- **Format & Clippy**: `cargo fmt --all --check` and
`cargo clippy --workspace --all-targets -- -D warnings`.
- **Test**: `cargo test --verbose` on Linux, macOS, and Windows.
- **End-to-end**: builds soulfind from source (LDC + `dub build :server`),
points `SOULFIND_BIN` at it, and runs all four e2e suites with
`SOULSEEK_E2E_REQUIRED=1`, so a missing server fails instead of skipping.
## Contributing
Issues and pull requests both welcome. Branch off `develop`, keep the change
small, and see [CONTRIBUTING.md](./CONTRIBUTING.md) for the commit convention
and the checks CI runs.
## License
MIT. See [LICENSE](./LICENSE).
+120
View File
@@ -0,0 +1,120 @@
# Releasing
Releases are automated by [release-plz](https://release-plz.dev). You do not run
`cargo publish`, edit versions, write changelog entries, or push tags by hand.
## How it works
1. Merge a PR to `master` using [conventional commits](https://www.conventionalcommits.org)
(`feat:`, `fix:`, `perf:`, `refactor:`, `docs:`, `chore:`, or a `!` / `BREAKING CHANGE:`
footer for a major bump).
2. `.github/workflows/release.yml` opens or updates a **`chore: release vX.Y.Z`** PR. It
contains the semver bump in `[workspace.package]`, the refreshed `Cargo.lock`, and the
generated `CHANGELOG.md` entry. Review it like any other PR.
3. Merging that PR releases, but only once CI has passed on the merge commit — a published
crates.io version can be yanked but never withdrawn, so the `Wait for CI` job blocks the
publish until the CI run for that exact commit is green:
- tags `vX.Y.Z` and creates the GitHub release with the changelog as its notes,
- publishes `soulseek-rs-lib` then `soulseek-rs` to crates.io (dependency order, waiting
for the index between them),
- builds and attaches binaries with `.sha256` checksums for six targets:
| Archive | Notes |
| ------------------------------------------------ | ------------------------------ |
| `soulseek-rs-vX.Y.Z-x86_64-unknown-linux-musl.tar.gz` | static, any glibc |
| `soulseek-rs-vX.Y.Z-aarch64-unknown-linux-musl.tar.gz` | static, any glibc |
| `soulseek-rs-vX.Y.Z-x86_64-apple-darwin.tar.gz` | Intel Mac |
| `soulseek-rs-vX.Y.Z-aarch64-apple-darwin.tar.gz` | Apple Silicon |
| `soulseek-rs-vX.Y.Z-x86_64-pc-windows-msvc.zip` | |
| `soulseek-rs-vX.Y.Z-aarch64-pc-windows-msvc.zip` | |
- packages the two Linux binaries as `soulseek-rs-amd64.deb`, `soulseek-rs-arm64.deb`,
`soulseek-rs-x86_64.rpm` and `soulseek-rs-aarch64.rpm`, with the man page and the
bash, zsh and fish completions. The names carry no version, so
`releases/latest/download/soulseek-rs-amd64.deb` is a link that keeps working,
- rewrites `Formula/soulseek-rs.rb` in the `michel/homebrew-tap` repository so
`brew install michel/tap/soulseek-rs` picks up the new macOS and Linux archives,
- merges `master` back into `develop`, so the branch the work happens on carries the
new version and changelog rather than drifting behind them.
Both crates always share one version, one tag, and one changelog — see `release-plz.toml`.
The version lives in one place, `[workspace.package]` in the root `Cargo.toml`, and
release-plz owns it. Do not edit it by hand on `develop`: the back-merge relies on
`develop` never touching that line, and an edit there turns every release into a
conflict.
## Nightly builds
`.github/workflows/nightly.yml` builds the tip of `develop` at 02:17 UTC every
day. It resolves the commit once, builds the same six targets as a numbered
release, packages the two Linux ones as `soulseek-rs-nightly-*.deb` and `.rpm`,
verifies all sixteen files as one set, then replaces the mutable `nightly`
prerelease and tag. Every nightly binary reports `X.Y.Z+git<commit time, UTC>.<sha>`
instead of the crate version. The published release is smoke-tested through the
curl and wget installer paths, and by installing the `.deb` and the `.rpm`.
Never enable GitHub's Immutable Releases on this repository: nightly replaces
its assets and moves its tag, and a tag name burnt by that setting cannot be
reused.
Use **Actions → Nightly → Run workflow** to force a build. The workflow always
checks out `develop`, regardless of the ref selected in the dispatch form. The
equivalent command is:
```bash
gh workflow run nightly.yml --ref develop
```
Nightly does not publish crates or update Homebrew. If a target fails, the
previous nightly release stays available; use a manual run to retry.
## One-time repository setup
- **Settings → Actions → General → Workflow permissions**: enable *Allow GitHub Actions to
create and approve pull requests*. Without it the release PR is never opened.
- **Settings → Secrets and variables → Actions**: add `CARGO_REGISTRY_TOKEN`, a
[crates.io token](https://crates.io/settings/tokens) scoped to `publish-new` and
`publish-update`. Without it the tag and GitHub release still happen, but the crates.io
publish fails.
- **Homebrew tap**: create a public `michel/homebrew-tap` repository, then add a
`HOMEBREW_TAP_TOKEN` secret — a fine-grained PAT with *Contents: read and write* on that
repository only. `GITHUB_TOKEN` cannot reach another repository. The formula itself is
generated by the workflow; nothing in the tap needs editing by hand.
## Notes
- A merge with no releasable commits (only `docs:`/`chore:`/`test:`) opens no release PR.
That is intended.
- The release PR does not run CI on its own, because GitHub does not trigger workflows for
PRs opened with `GITHUB_TOKEN`. Pushing any commit to that branch yourself does start it —
handy if you want the version bump validated before merging.
- If one target's build fails, the other five still upload (`fail-fast: false`). To retry,
run the **Release** workflow manually and set `tag` to the existing tag (e.g. `v6.0.0`);
it rebuilds and re-uploads every target for that tag. Re-running the failed job from the
Actions UI replays the *old* workflow file, so it is no use when the workflow is the thing
that needs fixing.
- To skip a release for a commit that would otherwise trigger one, use a `chore:` type.
- **If CI flakes on the release commit the publish is blocked, by design.** The e2e suite
talks to a real soulfind server over the network and does occasionally time out. To
recover: re-run the failed CI job, wait for that run to go green, then re-run the
**Release** workflow. The gate reads the *newest* CI run for the commit, so it will then
pass. Never work around it by publishing by hand.
- Release binaries are stripped via `[profile.release] strip = true` in the workspace
`Cargo.toml`, so `cargo install` benefits too — not just the release archives.
## Installing a release
```bash
cargo install soulseek-rs
```
Or download an archive from the [releases page](https://github.com/michel/soulseek-rs/releases),
verify it against the adjacent `.sha256`, and extract the binary.
## Using the library
```bash
cargo add soulseek-rs-lib
```
+83
View File
@@ -0,0 +1,83 @@
# Concurrency benchmark
Answers one question: how much simultaneous Soulseek activity can the client
absorb before it starts losing work?
```sh
scripts/stress.sh # run the default profile
scripts/stress.sh --save # ...and store it as the new baseline if it wins
```
A run takes about 7 seconds. It spawns a local soulfind, surrounds one real
`Client` with a swarm of mock peers, and drives searches, downloads, uploads and
browses **at the same time** — the mixing is the point, since every ceiling found
so far only appeared under a mixed load.
## The score
One number out of 100, weighted across five dimensions:
| Dimension | Weight | Measures |
|------------|--------|-------------------------------------------------|
| downloads | 0.30 | completed / started |
| uploads | 0.20 | served / requested |
| searches | 0.20 | queries that got at least one result |
| browses | 0.15 | browse requests answered |
| throughput | 0.15 | aggregate MiB/s against `TARGET_MBPS` (500) |
The four functional dimensions should stay at 100%: anything less means work was
silently dropped, which is the failure mode that matters. Throughput is the term
with headroom, so the score still moves when transfer handling regresses.
`--save` only overwrites `stress-baseline.json` when the run beats it, so the
baseline ratchets upward. Without `--save` a run just prints the delta.
## The gate
`STRESS_REQUIRE_FUNCTIONAL=1` makes the run exit 1 when any of the four
functional dimensions is below 100%. CI runs it that way on every pull request
with an eighth of the default load; throughput never fails the gate because it
measures the machine, not the client.
## Tuning the load
Every knob is an env var; the defaults are in `Config::from_env`.
```sh
STRESS_PEERS=2048 STRESS_FILE_KB=8192 scripts/stress.sh
```
`STRESS_PEERS` `STRESS_SEARCHES` `STRESS_DOWNLOADS` `STRESS_LEECHERS`
`STRESS_BROWSES` `STRESS_FILE_KB` `STRESS_TIMEOUT`
## Reading a failure
`downloads completed 480/512` is the headline, but the two lines under it say
where to look:
- **`download states`** — a straggler on `Queued` never got its transfer
negotiated; one on `InProgress` is merely slow. These are different bugs.
- **`mocks never online`** — mock peers the harness could not bring up. That is
a harness/server limit, not the client, and it is reported separately so it
cannot quietly shrink a denominator and flatter the score.
`peak threads` counts the whole process — client *and* mock swarm — so treat it
as a trend, not a client measurement.
`LOG_LEVEL=debug scripts/stress.sh` correlates a stuck peer's name against the
client's own trace, which is how each ceiling below was actually found.
## Ceilings found and removed
| Symptom | Cause |
|----------------------------------------------------|-------------------------------------------------------------------------------|
| Hard stop at ~15 peers; later peers never start | Every `PeerActor` held a `ThreadPool` worker for life, so the pool capped peers at one per core |
| Downloads stuck on `Queued` under load | `TransferResponse` was sent before the client recorded the peer's token, so the file connection raced its own bookkeeping |
| One download vanishing per few hundred | Token was `md5(filename)`, and the store deletes *every* entry with a token — same filename from two peers destroyed both |
| Uploads dropped after the peer accepted | Server answered `GetPeerAddress` with port 0; the client cached it and dialled it |
## Requirements
A soulfind binary (`SOULFIND_BIN`, defaulted in `scripts/stress.sh`), or
`SOULSEEK_TEST_SERVER=host:port` to point at a running server. The script raises
the descriptor limit — a swarm holds a lot of sockets open.
+23
View File
@@ -0,0 +1,23 @@
{
"score": 97.78,
"parts": {
"downloads": 1.0000,
"uploads": 1.0000,
"searches": 1.0000,
"browses": 1.0000,
"throughput": 0.8522
},
"load": {
"peers": 1024,
"searches": 128,
"downloads": 512,
"leechers": 256,
"browses": 128,
"file_bytes": 4194304
},
"observed": {
"downloads_completed": 512,
"uploads_completed": 256,
"bytes": 3221225472
}
}
+184
View File
@@ -0,0 +1,184 @@
# The daemon control protocol
`soulseek-rs daemon` stays logged in and answers JSON-RPC over a socket, so
several clients can share one Soulseek session. The machine-readable contract
is [`openrpc.json`](../soulseek-rs/docs/openrpc.json), an [OpenRPC](https://open-rpc.org)
document and the JSON-RPC counterpart to OpenAPI. Point a client generator at it,
or at a running daemon via `rpc.discover`, and you have a client in your
language.
This page covers what a schema cannot: framing, the handshake, and how pushed
events behave.
## Framing
Newline-delimited JSON-RPC 2.0. One JSON value per line, in both directions,
UTF-8, no length prefix. A line that does not parse gets a `-32700` reply; on
an established connection it stays open, but the *first* line has to be a valid
`auth` request, so a parse error there ends the connection.
Lines are capped at 1 MiB and a connection that has not authenticated within
30 seconds is closed. The daemon serves at most 64 control connections at
once.
## Transports and authentication
| Transport | Address | Authentication |
|---|---|---|
| Unix socket | `<state-dir>/daemon.sock`, mode 0600 | The socket's permissions. `token` may be omitted. |
| TCP | opt-in via `daemon --bind ADDR` | The token in `<state-dir>/daemon.token`, mode 0600. |
The local socket is the Docker and ssh-agent bargain: if the operating system
let you open it, you are the user who started the daemon, and there is nothing
further to prove. A local client, whether a script or an agent, works with no
configuration at all.
TCP always requires the token, and a token that *is* offered is always
checked, on either transport, so a wrong one never passes. Failed attempts
sleep one second. The token is 256 bits from the OS, generated on first start
and reused across restarts so existing clients keep working;
`soulseek-rs daemon token` prints it.
There is no transport encryption. Binding non-loopback TCP means putting SSH
or a TLS-terminating proxy in front of it.
`<state-dir>` is `$SOULSEEK_STATE_DIR`, else the platform data directory
(`~/.local/share/soulseek-rs/state` on Linux and macOS).
### The handshake
`auth` must be the first request on a connection. Anything else is answered
`-32600` and the connection is dropped. Until it succeeds the connection is
told nothing and receives no events.
```json
--> {"jsonrpc":"2.0","id":1,"method":"auth","params":{"protocol":1}}
<-- {"jsonrpc":"2.0","id":1,"result":{"protocol":1,"daemon_version":"13.0.0","username":"you"}}
```
Compare `protocol` with your own and fail loudly on a mismatch rather than
guessing; it is bumped whenever a change would break a client written against
the old shape.
## Requests
Parameters are by name: a JSON object, never an array. Each method's fields
are published as individual OpenRPC content descriptors, so a generated client
sends exactly the object the daemon deserializes. `rpc.discover` returns that
same document from the running daemon.
An event is the exception: its `params` object *is* the payload, published as
a single `payload` descriptor because several payloads are tagged unions with
no fixed field set.
Requests may be pipelined and are answered out of order, so match replies by
`id`. A request without an `id` is a notification and is answered with
silence, including when it fails.
## Errors
```json
{"jsonrpc":"2.0","id":4,"error":{"code":-32000,"message":"no results","data":{"exit":4}}}
```
An error always carries an `id`, explicitly `null` when the request was too
malformed to have one. Do not read a missing `id` as "this is a notification"
and drop it.
Standard codes (`-32700` parse, `-32600` invalid request, `-32601` unknown
method, `-32602` bad parameters) mean what they always mean. Application
failures use `-32000` and carry `data.exit`: the CLI exit status the same
operation would have produced locally, so a wrapper can branch on it exactly
as it branches on the exit code.
| `exit` | Meaning |
|---|---|
| 2 | Usage or configuration |
| 3 | Could not reach the server, or the login was rejected |
| 4 | Succeeded but produced nothing |
| 5 | Timed out |
| 6 | A transfer started but did not complete |
| 7 | The server session ended mid-operation |
## Events
The daemon pushes notifications. They carry no `id`, so never answer them:
```json
{"jsonrpc":"2.0","method":"event.room","params":{"event":"message","room":"lobby","username":"bob","message":"hi"}}
```
`event.room`, `event.message`, `event.upload`, `event.download_status`,
`event.browse`, `event.session_loss`. Payload schemas are in `openrpc.json`,
where they appear as entries marked `x-notification`. OpenRPC has no
first-class notion of a server-pushed message, so that flag is how they are
distinguished from methods you may call.
`message.history` and `event.message` describe the same conversation in
different shapes, deliberately: a live event is the message as the server
delivered it (`id`, `timestamp`, `username`, `message`), while history is a
chat log and carries a direction (`peer`, `outgoing`, `text`, `at`) because it
includes what this account sent.
**Events are the only way to observe these things, and they are not
replayable.** The underlying library hands over its event buffer and empties
it, so the daemon drains once and copies to every attached connection. A
client that is not connected when something happens does not learn about it
later, with one exception: private messages are also kept, and
`message.history` returns the conversation including what arrived while
nobody was attached.
Every connection gets its own queue, bounded at 1024 events. **A client that
stops reading its socket is disconnected** once that queue fills. The daemon
will not let one stalled consumer cost the others their events. Read
continuously; do not treat the socket as request/response only.
A `download.start` you issued reports progress through `event.download_status`
matched on `username` + `filename`.
## Behaviour worth knowing
**Files land on the daemon's filesystem.** The directory is the daemon's
configuration and a client cannot name one, which would be an arbitrary write
on the daemon's host. `daemon.status` reports the directory in use. There is
no method to fetch the bytes over the control socket.
**Configuration is per machine, shares are per session.** A client's config
file is its own; changing it does not reach a daemon. The exception is
`shares.set`, which the CLI's `shares add`/`remove` call so a folder is served
without waiting for a restart.
**A password change belongs to the daemon's account.** `account.password`
changes the password of the account the daemon is logged in as, and stores the
new one on the daemon's host so the next start still logs in. The server sends
no verdict on the change, so the `{"ok":true}` means the request went out. The
password crosses the socket in plain text, exactly like the token: keep TCP
behind an SSH tunnel.
**Several clients, no locking.** Last write wins. Two clients cancelling the
same transfer is two hands on one keyboard, not an error.
**The daemon owns durable state.** Transfers, joined rooms, and the
conversation survive a restart: unfinished transfers are re-queued and rooms
rejoined on startup.
**Searching is two steps.** `search.start` puts the query on the wire and
returns immediately; peers answer over their own connections for as long as
you care to wait. Decide the window yourself, then read `search.results`. This
is deliberate: a client's search window must not be time the daemon spends not
answering everybody else.
## A session by hand
```console
$ nc -U ~/.local/share/soulseek-rs/state/daemon.sock
{"jsonrpc":"2.0","id":1,"method":"auth","params":{"protocol":1}}
{"jsonrpc":"2.0","id":1,"result":{"protocol":1,"daemon_version":"13.0.0","username":"you"}}
{"jsonrpc":"2.0","id":2,"method":"search.start","params":{"query":"aphex twin"}}
{"jsonrpc":"2.0","id":2,"result":{"ok":true}}
{"jsonrpc":"2.0","id":3,"method":"search.results","params":{"query":"aphex twin"}}
{"jsonrpc":"2.0","id":3,"result":{"results":[…]}}
```
That the whole protocol is reachable from `nc` and a text editor is the reason
it is shaped this way.
+132
View File
@@ -0,0 +1,132 @@
# Soulseek protocol coverage
What this client speaks, and where each message is proven to work. "e2e" means
a test in `soulseek-rs-lib/tests/e2e.rs` drives it against a real server
(soulfind) or a real peer; "unit" means the encoder or parser is tested on its
own, which is all some messages can have — a server we do not run may never
send them.
Message names and codes follow the Soulseek protocol as implemented by
[soulfind](https://github.com/soulfind-dev/soulfind) and
[Nicotine+](https://github.com/nicotine-plus/nicotine-plus).
## Server messages
| Code | Message | State | Covered by |
| --- | --- | --- | --- |
| 1 | Login | yes | e2e — login, wrong password, re-login |
| 2 | SetWaitPort | yes | e2e — the port a listening client reports |
| 3 | GetPeerAddress | yes | e2e — direct download, browse |
| 5 | WatchUser | yes | e2e — watching a user, and an unknown one |
| 6 | UnwatchUser | yes | e2e — updates stop after unwatching |
| 7 | GetUserStatus | yes | e2e — status and share counts, away status |
| 13 | SayChatroom | yes | e2e — a message between two users in a room |
| 14 | JoinRoom | yes | e2e — public rooms, and private ones |
| 15 | LeaveRoom | yes | e2e — room list and membership |
| 16 | UserJoinedRoom | yes | e2e — a member arriving is announced |
| 17 | UserLeftRoom | yes | e2e — a member leaving is announced |
| 18 | ConnectToPeer | yes | e2e — firewalled download and browse |
| 22 | MessageUser | yes | e2e — a private message between users |
| 23 | MessageAcked | yes | e2e — an offline message is delivered once |
| 26 | FileSearch | yes | e2e — search round trip, two clients |
| 28 | SetStatus | yes | e2e — going away is visible to another user |
| 32 | ServerPing | yes | e2e — the session survives pings |
| 35 | SharedFoldersFiles | yes | e2e — share counts as another user sees them |
| 36 | GetUserStats | yes | e2e — stats, and our own recorded speed |
| 41 | Relogged | yes | e2e — a second login reports the first lost |
| 42 | UserSearch | yes | e2e — the named user answers |
| 51 / 52 | AddThingILike / Remove | yes | e2e — interests and similar users |
| 54 | GetRecommendations | yes | e2e — recommendations from our interests |
| 56 | GlobalRecommendations | yes | e2e — the server-wide list |
| 57 | UserInterests | yes | e2e — another user's likes and hates |
| 64 | RoomList | yes | e2e — a joined room appears in the list |
| 66 | AdminMessage | yes | e2e — a server announcement |
| 69 | PrivilegedUsers | yes | e2e — a privileged peer overtakes in the queue |
| 71 | HaveNoParent | yes | e2e — a leaf adopting a parent |
| 83 / 84 | ParentMinSpeed / ParentSpeedRatio | yes | unit — they set the child limit |
| 92 | CheckPrivileges | yes | e2e — how much privilege time we have |
| 93 | EmbeddedMessage | yes | unit — soulfind never sends one |
| 100 | AcceptChildren | yes | e2e — serving children |
| 102 | PossibleParents | yes | e2e — a leaf adopting a parent |
| 103 | WishlistSearch | yes | e2e — a wish is answered like any search |
| 104 | WishlistInterval | yes | e2e — the announced interval is kept |
| 110 | SimilarUsers | yes | e2e — shared interests make users similar |
| 111 | ItemRecommendations | yes | e2e — what else people who like this like |
| 112 | ItemSimilarUsers | yes | e2e — who likes an item |
| 113 / 114 / 115 | RoomTickers and changes | yes | e2e — the board on join, and updates |
| 116 | SetRoomTicker | yes | e2e — a ticker reaches other members |
| 117 / 118 | AddThingIHate / Remove | yes | e2e — hates come back in UserInterests |
| 120 | RoomSearch | yes | e2e — a member of the room answers |
| 121 | SendUploadSpeed | yes | e2e — the server records a finished upload |
| 123 | GivePrivileges | yes | e2e — a privileged account hands time over |
| 126 / 127 | BranchLevel / BranchRoot | yes | e2e — leaf adoption, and children |
| 130 | ResetDistributed | yes | unit — a new session starts parentless |
| 133 | RoomMembers | yes | e2e — a private room's roster |
| 134 / 135 | AddRoomMember / Remove | yes | e2e — granted and revoked |
| 136 | CancelRoomMembership | yes | e2e — a member resigns |
| 137 | CancelRoomOwnership | yes | e2e — the owner disbands the room |
| 139 / 140 | RoomMembershipGranted / Revoked | yes | e2e — the guest is told |
| 141 | EnableRoomInvitations | yes | e2e — set before an invitation |
| 142 | ChangePassword | yes | e2e — the next login needs the new one |
| 143 / 144 | AddRoomOperator / Remove | yes | e2e — the guest is promoted |
| 145 / 146 | RoomOperatorshipGranted / Revoked | yes | e2e — the guest is told |
| 147 | CancelRoomOperatorship | yes | e2e — an operator stands down |
| 148 | RoomOperators | yes | e2e — the roster after a promotion |
| 149 | MessageUsers | yes | e2e — one message, two recipients |
| 150 / 151 / 152 | Global room | yes | e2e — the feed starts and stops |
| 160 | ExcludedSearchPhrases | yes | e2e — the list is kept for our replies |
| 1001 | CantConnectToPeer | yes | e2e — a dial we cannot complete reports back |
| 1003 | CantCreateRoom | yes | e2e — a private room owned by someone else |
Not implemented, and deliberately so: the messages the protocol lists as
obsolete or deprecated, which no current client sends and soulfind answers only
to be polite — SendConnectToken (33), UploadSlotsFull (40), SimilarRecommendations
(50), MyRecommendations (55), PlaceInLineRequest (59), GlobalUserList (67),
ParentIP (73), UserPrivileged (122), NotifyPrivileges (124), AckNotifyPrivileges
(125), ChildDepth (129) and RelatedSearch (153).
## Peer messages
| Code | Message | State | Covered by |
| --- | --- | --- | --- |
| 0 | PierceFirewall | yes | e2e — download and browse through the server |
| 1 | PeerInit | yes | e2e — every direct peer test |
| 4 / 5 | GetShareFileList and its reply | yes | e2e — browsing, in both directions |
| 9 | FileSearchResponse | yes | e2e — searching, in both directions |
| 15 / 16 | UserInfoRequest and its reply | yes | e2e — asking a peer about itself, and answering |
| 36 / 37 | FolderContentsRequest and its reply | yes | e2e — one folder of a peer's shares |
| 40 | TransferRequest | yes | e2e — downloads and uploads |
| 41 | TransferResponse | yes | e2e — downloads, cancellations, refusals |
| 43 | QueueUpload | yes | e2e — queueing, slots, privileged peers |
| 44 | PlaceInQueueResponse | yes | e2e — answering, and asking |
| 46 | UploadFailed | yes | e2e — the download fails promptly |
| 50 | UploadDenied | yes | e2e — the download fails promptly |
| 51 | PlaceInQueueRequest | yes | e2e — both directions |
## Distributed messages
| Code | Message | State | Covered by |
| --- | --- | --- | --- |
| 3 | DistribSearch | yes | e2e — a leaf answers, a parent passes it down |
| 4 | DistribBranchLevel | yes | e2e — adoption, and telling children |
| 5 | DistribBranchRoot | yes | e2e — adoption, and telling children |
| 93 | DistribEmbeddedMessage | yes | unit — unwrapped once, as Nicotine+ does |
Serving children is off by default; see `accept_children` in the README.
## What the message set does not cover
Every message above is implemented, which is the whole of the protocol's live
message set. Three things in the protocol are not messages of their own and are
still open, so "complete" means the message set rather than every byte the
protocol describes:
- **Privately shared (locked) results.** `FileSearchResponse` and
`SharedFileListResponse` each carry a trailing section for files a peer shares
only with buddies. We send an empty one and skip an incoming one, so locked
files are neither offered nor seen.
- **Obfuscated connections.** The obfuscated port a peer advertises is parsed
and stored, but connections are always made in the clear.
- **Adopting a firewalled child.** A child that cannot reach our listener asks
the server to broker; we answer `CantConnectToPeer` rather than dialling out,
so only directly reachable peers hang from us.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 926 KiB

File diff suppressed because one or more lines are too long
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1789785513,
"narHash": "sha256-B44WL6h0XoLjJ41bUPJk0X5SDinLCII//6EcBLXKiJ0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "20b1ddd1aa5ace70c9468305030aa4f9ef79671b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+51
View File
@@ -0,0 +1,51 @@
{
description = "Soulseek client for the terminal, with a scriptable CLI and daemon";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs =
{ self, nixpkgs }:
let
inherit (nixpkgs) lib;
version = (lib.importTOML ./Cargo.toml).workspace.package.version;
systems = [
"x86_64-linux"
"aarch64-linux"
"aarch64-darwin"
];
in
{
packages = lib.genAttrs systems (system: {
default = nixpkgs.legacyPackages.${system}.callPackage (
{ rustPlatform, installShellFiles }:
rustPlatform.buildRustPackage {
pname = "soulseek-rs";
inherit version;
src = self;
cargoLock.lockFile = ./Cargo.lock;
cargoBuildFlags = [
"--package"
"soulseek-rs"
];
doCheck = false;
nativeBuildInputs = [ installShellFiles ];
env.SOULSEEK_RS_VERSION = "${version}+git${lib.substring 0 12 self.lastModifiedDate}.${self.shortRev or "dirty"}";
postInstall = ''
installShellCompletion --cmd soulseek-rs \
--bash <($out/bin/soulseek-rs completions print bash) \
--zsh <($out/bin/soulseek-rs completions print zsh) \
--fish <($out/bin/soulseek-rs completions print fish)
$out/bin/soulseek-rs man > soulseek-rs.1
installManPage soulseek-rs.1
'';
meta = {
description = "Soulseek client for the terminal, with a scriptable CLI and daemon";
homepage = "https://re-invention.nl/soulseek-rs/";
license = lib.licenses.mit;
mainProgram = "soulseek-rs";
};
}
) { };
});
};
}
+55
View File
@@ -0,0 +1,55 @@
name: soulseek-rs
arch: ${PKG_ARCH}
platform: linux
version: ${PKG_VERSION}
version_schema: none
release: "1"
section: net
priority: optional
maintainer: Michel de Graaf <michel@re-invention.nl>
description: |
Soulseek client for the terminal
A TUI, a scriptable command line and a daemon for the Soulseek
peer-to-peer network.
vendor: Michel de Graaf
homepage: https://re-invention.nl/soulseek-rs/
license: MIT
suggests:
- gnome-keyring
contents:
- src: target/nfpm/soulseek-rs
dst: /usr/bin/soulseek-rs
file_info:
mode: 0755
- src: target/nfpm/soulseek-rs.1.gz
dst: /usr/share/man/man1/soulseek-rs.1.gz
- src: target/nfpm/soulseek-rs.bash
dst: /usr/share/bash-completion/completions/soulseek-rs
- src: target/nfpm/soulseek-rs.fish
dst: /usr/share/fish/vendor_completions.d/soulseek-rs.fish
- src: target/nfpm/_soulseek-rs
dst: /usr/share/zsh/vendor-completions/_soulseek-rs
packager: deb
- src: target/nfpm/_soulseek-rs
dst: /usr/share/zsh/site-functions/_soulseek-rs
packager: rpm
- dst: /usr/share/doc/soulseek-rs
type: dir
packager: rpm
- dst: /usr/share/licenses/soulseek-rs
type: dir
packager: rpm
- src: README.md
dst: /usr/share/doc/soulseek-rs/README.md
packager: deb
- src: README.md
dst: /usr/share/doc/soulseek-rs/README.md
packager: rpm
type: doc
- src: LICENSE
dst: /usr/share/doc/soulseek-rs/copyright
packager: deb
- src: LICENSE
dst: /usr/share/licenses/soulseek-rs/LICENSE
packager: rpm
type: license
+22
View File
@@ -0,0 +1,22 @@
# Both crates inherit `version.workspace`, so they always move together. This
# config makes release-plz treat them as one release: a single `vX.Y.Z` tag, one
# GitHub release, one root CHANGELOG.md covering both. release-plz still
# publishes them to crates.io in dependency order (lib first) and waits for the
# index, which is what RELEASE.md used to ask you to do by hand.
[workspace]
git_tag_name = "v{{ version }}"
git_release_name = "v{{ version }}"
[[package]]
name = "soulseek-rs"
version_group = "workspace"
changelog_path = "./CHANGELOG.md"
changelog_include = ["soulseek-rs-lib"]
[[package]]
name = "soulseek-rs-lib"
version_group = "workspace"
changelog_update = false
git_tag_enable = false
git_release_enable = false
+1
View File
@@ -0,0 +1 @@
max_width = 80
+54
View File
@@ -0,0 +1,54 @@
#!/bin/sh
set -eu
umask 022
[ $# -ge 4 ] || {
echo "usage: $0 <binary> <version> <target-triple> <out-dir> [<package-basename>]" >&2
exit 2
}
NFPM_VERSION=2.47.0
binary=$1
PKG_VERSION=$2
target=$3
out=$4
package=${5:-soulseek-rs}
case "$target" in
x86_64-*) PKG_ARCH=amd64 rpm_arch=x86_64 ;;
aarch64-*) PKG_ARCH=arm64 rpm_arch=aarch64 ;;
*) echo "no package architecture for $target" >&2; exit 1 ;;
esac
case "$(uname -s)-$(uname -m)" in
Linux-x86_64) nfpm_asset=Linux_x86_64 nfpm_sha256=0660ca602b2d2d2ae4781a06c692b3eeb9d437ffea05b831d76e41f4a3188783 ;;
Linux-aarch64) nfpm_asset=Linux_arm64 nfpm_sha256=1c0f5f2999b9a974bfb04fdb0cc3306096de530ac5dbb25d739cc5f5219c919c ;;
*) echo "no pinned nfpm build for this machine" >&2; exit 1 ;;
esac
root=$(cd "$(dirname "$0")/.." && pwd)
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
stage=$root/target/nfpm
rm -rf "$stage"
mkdir -p "$stage" "$out"
out=$(cd "$out" && pwd)
cp "$binary" "$stage/soulseek-rs"
"$binary" completions print bash > "$stage/soulseek-rs.bash"
"$binary" completions print zsh > "$stage/_soulseek-rs"
"$binary" completions print fish > "$stage/soulseek-rs.fish"
"$binary" man > "$stage/soulseek-rs.1"
gzip -9n "$stage/soulseek-rs.1"
archive=nfpm_${NFPM_VERSION}_$nfpm_asset.tar.gz
curl -fsSL -o "$work/$archive" \
"https://github.com/goreleaser/nfpm/releases/download/v$NFPM_VERSION/$archive"
echo "$nfpm_sha256 $work/$archive" | sha256sum -c - >/dev/null
tar -xzf "$work/$archive" -C "$work" nfpm
export PKG_ARCH PKG_VERSION
cd "$root"
"$work/nfpm" package --config packaging/nfpm.yaml --packager deb --target "$out/$package-$PKG_ARCH.deb"
"$work/nfpm" package --config packaging/nfpm.yaml --packager rpm --target "$out/$package-$rpm_arch.rpm"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Run the concurrency stress benchmark against a local soulfind.
#
# scripts/stress.sh # run with the default load profile
# scripts/stress.sh --save # store the run as the new baseline if it wins
# STRESS_PEERS=128 scripts/stress.sh
#
# Knobs: STRESS_PEERS STRESS_SEARCHES STRESS_DOWNLOADS STRESS_LEECHERS
# STRESS_BROWSES STRESS_FILE_KB STRESS_TIMEOUT
set -euo pipefail
cd "$(dirname "$0")/.."
# soulfind is a D binary linked against Homebrew's SQLite.
export SOULFIND_BIN="${SOULFIND_BIN:-$HOME/src/soulseek_download/soulfind/bin/soulfind}"
export DYLD_LIBRARY_PATH="${DYLD_LIBRARY_PATH:-/opt/homebrew/opt/sqlite/lib}"
if [ ! -x "$SOULFIND_BIN" ]; then
echo "stress: no soulfind at $SOULFIND_BIN — set SOULFIND_BIN" >&2
exit 2
fi
# Stale databases from a killed run make the next login behave oddly.
rm -f "${TMPDIR:-/tmp}"/soulfind-stress-*.db
# Every mock peer holds sockets open; the default 256 descriptors is nowhere near
# enough for a swarm.
ulimit -n 20000 2>/dev/null || true
for arg in "$@"; do
case "$arg" in
--save) export STRESS_SAVE=1 ;;
*) echo "stress: unknown argument $arg" >&2; exit 2 ;;
esac
done
exec cargo run --release --quiet -p soulseek-rs-lib --example stress
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "soulseek-rs-lib"
version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
description = "Library for Soulseek protocol implementation in Rust"
keywords = ["soulseek", "p2p", "protocol", "file-sharing"]
categories = ["network-programming"]
[lib]
name = "soulseek_rs"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
[target.'cfg(unix)'.dependencies]
libc = "0.2.189"
[[example]]
name = "stress"
test = true
+119
View File
@@ -0,0 +1,119 @@
# soulseek-rs-lib
A Rust library for implementing the Soulseek peer-to-peer protocol.
Website: <https://re-invention.nl/soulseek-rs/>
## About
This library provides the core functionality for interacting with the Soulseek
network. It can be used to build custom Soulseek clients.
## Usage
Add it to your `Cargo.toml`:
```bash
cargo add soulseek-rs-lib
```
## Example
### Simple Usage
```rust,no_run
use soulseek_rs::Client;
use std::time::Duration;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create and connect to Soulseek server
let mut client = Client::new("username", "password");
client.connect()?;
client.login()?;
// Search for files
let results = client.search("Alex Kassian lifestream", Duration::from_secs(10))?;
// Download first available file
if let Some(result) = results.iter().find(|r| !r.files.is_empty()) {
let file = &result.files[0];
let (_download, status) = client.download(
file.name.clone(),
file.username.clone(),
file.size,
"~/Downloads".to_string(),
)?;
// `download` returns as soon as the request is queued: the transfer is
// finished when the channel says so, not when this call returns.
for update in status {
println!("{}: {update:?}", file.name);
}
}
Ok(())
}
```
### Advanced Configuration
```rust,no_run
use soulseek_rs::{Client, ClientSettings, PeerAddress};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create client with custom settings
let settings = ClientSettings {
server_address: PeerAddress::new("server.slsknet.org".to_string(), 2416),
enable_listen: true,
listen_port: 3000,
..ClientSettings::new("username", "password")
};
let mut client = Client::with_settings(settings);
client.connect()?;
client.login()?;
Ok(())
}
```
### Asking about a peer
The server-backed lookups are request/poll pairs: you ask, then read the
answer once it lands. `request_user_info` clears any previous answer first, so
the loop below observes this request rather than the last one.
```rust,no_run
use soulseek_rs::Client;
use std::thread::sleep;
use std::time::{Duration, Instant};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut client = Client::new("username", "password");
client.connect()?;
client.login()?;
client.request_user_info("someuser")?;
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if let Some(info) = client.user_info("someuser") {
// Presence and stats are separate replies, so each is an Option:
// a reply that never arrived is None, not "offline" or "shares nothing".
println!("{:?} {:?}", info.presence, info.stats);
break;
}
sleep(Duration::from_millis(100));
}
// Room membership is kept current from join and leave events.
println!("{:?}", client.room_members("lobby"));
Ok(())
}
```
`ClientOperation` is `#[non_exhaustive]`: every protocol message the client
learns to handle adds a variant, so match it with a wildcard arm. `SharedFile`
is too, so build it with the `..` syntax rather than listing every field.
File diff suppressed because it is too large Load Diff
+267
View File
@@ -0,0 +1,267 @@
use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread;
use std::time::{Duration, Instant};
use crate::{error, trace};
pub mod peer_actor;
pub mod peer_registry;
pub mod server_actor;
#[derive(Debug, Clone)]
pub enum ConnectionState {
Disconnected,
Connecting { since: Instant },
Connected,
}
/// Core actor trait - each actor processes messages
pub trait Actor: Send + 'static {
type Message: Send + Clone + 'static;
/// Handle a single message
fn handle(&mut self, msg: Self::Message);
/// Called when actor starts (optional hook)
fn on_start(&mut self) {}
/// Called when actor stops (optional hook)
fn on_stop(&mut self) {}
/// Optional periodic tick for background work
fn tick(&mut self) {}
}
#[derive(Clone)]
pub struct ActorHandle<M: Send> {
pub(crate) sender: Sender<ActorMessage<M>>,
}
impl<M: Send> ActorHandle<M> {
pub fn send(&self, msg: M) -> Result<(), String> {
self.sender
.send(ActorMessage::UserMessage(msg))
.map_err(|e| format!("Failed to send message: {e}"))
}
/// Request actor to stop gracefully
pub fn stop(&self) -> Result<(), String> {
self.sender
.send(ActorMessage::Stop)
.map_err(|e| format!("Failed to send stop signal: {e}"))
}
}
/// Internal actor message wrapper
pub enum ActorMessage<M> {
UserMessage(M),
Stop,
}
/// Stack size for an actor thread. An actor loop parses messages into
/// heap-allocated buffers and never recurses, so it needs a fraction of the
/// 2 MiB default — and at a thousand concurrent peers that difference is the
/// difference between 2 GiB of reserved address space and 256 MiB.
const ACTOR_STACK_SIZE: usize = 256 * 1024;
/// Actor system that manages actor lifecycle.
///
/// Each actor owns a thread for its whole lifetime. That is deliberate: an
/// actor loop blocks in `recv_timeout` until it is stopped, so running actors
/// on a fixed-size pool would make the pool a semaphore capping the client at
/// one peer per core — and every peer past the cap would silently never start.
#[derive(Default)]
pub struct ActorSystem;
impl ActorSystem {
#[must_use]
pub const fn new() -> Self {
Self
}
/// Spawn a new actor and return its handle
pub fn spawn<A: Actor>(&self, actor: A) -> ActorHandle<A::Message> {
self.spawn_with_handle(actor, |_, _| {})
}
/// Spawn a new actor with initialization callback and return its handle
/// The callback receives the actor handle before `on_start` is called
pub fn spawn_with_handle<A: Actor, F>(
&self,
actor: A,
init: F,
) -> ActorHandle<A::Message>
where
F: FnOnce(&mut A, ActorHandle<A::Message>) + Send + 'static,
{
match self.try_spawn_with_handle(actor, init) {
Ok(handle) => handle,
Err(e) => {
error!("[actor_system] failed to spawn actor thread: {}", e);
// A handle nobody serves: sends fail, which callers of the
// infallible spawn already tolerate.
let (sender, _) = channel();
ActorHandle { sender }
}
}
}
/// Like [`Self::spawn_with_handle`], but hands a thread-spawn failure back
/// to the caller instead of returning a handle no actor will ever serve.
/// Storing such a dead handle is how a registry entry becomes a zombie
/// that claims its username for the rest of the process.
#[allow(clippy::unused_self)] // method for symmetry with the spawn family
pub(crate) fn try_spawn_with_handle<A: Actor, F>(
&self,
mut actor: A,
init: F,
) -> std::io::Result<ActorHandle<A::Message>>
where
F: FnOnce(&mut A, ActorHandle<A::Message>) + Send + 'static,
{
let (sender, receiver) = channel::<ActorMessage<A::Message>>();
let handle = ActorHandle { sender };
let handle_for_init = handle.clone();
thread::Builder::new()
.name("soulseek-actor".to_string())
.stack_size(ACTOR_STACK_SIZE)
.spawn(move || {
init(&mut actor, handle_for_init);
actor.on_start();
Self::run_actor_loop(&mut actor, receiver);
actor.on_stop();
})?;
Ok(handle)
}
fn run_actor_loop<A: Actor>(
actor: &mut A,
receiver: Receiver<ActorMessage<A::Message>>,
) {
let tick_interval = Duration::from_millis(100);
let mut last_tick = Instant::now();
let mut message_count = 0;
let mut tick_count = 0;
loop {
match receiver.recv_timeout(tick_interval) {
Ok(ActorMessage::UserMessage(msg)) => {
message_count += 1;
actor.handle(msg);
}
Ok(ActorMessage::Stop) => {
trace!(
"[actor_system] Received Stop message, breaking loop"
);
break;
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if last_tick.elapsed() >= tick_interval {
tick_count += 1;
actor.tick();
last_tick = Instant::now();
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
trace!(
"[actor_system] Channel disconnected, breaking loop"
);
break;
}
}
}
trace!(
"[actor_system] run_actor_loop ENDED - processed {} messages, {} ticks",
message_count, tick_count
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
struct CounterActor {
count: Arc<AtomicUsize>,
}
impl Actor for CounterActor {
type Message = usize;
fn handle(&mut self, msg: Self::Message) {
self.count.fetch_add(msg, Ordering::SeqCst);
}
fn on_start(&mut self) {
println!("Counter actor started");
}
fn on_stop(&mut self) {
println!("Counter actor stopped");
}
}
#[test]
fn test_actor_system() {
let system = ActorSystem::new();
let count = Arc::new(AtomicUsize::new(0));
let actor = CounterActor {
count: count.clone(),
};
let handle = system.spawn(actor);
// Send some messages
handle.send(1).unwrap();
handle.send(2).unwrap();
handle.send(3).unwrap();
// Give actor time to process
std::thread::sleep(Duration::from_millis(100));
assert_eq!(count.load(Ordering::SeqCst), 6);
handle.stop().unwrap();
// Give actor time to process the stop message
std::thread::sleep(Duration::from_millis(100));
}
// Actors must not be capped by a worker count: every peer runs one, and a
// cap silently strands every peer past it. Far more actors than cores must
// all make progress concurrently.
#[test]
fn far_more_actors_than_cores_all_run() {
let system = ActorSystem::new();
let count = Arc::new(AtomicUsize::new(0));
let handles: Vec<_> = (0..256)
.map(|_| {
system.spawn(CounterActor {
count: count.clone(),
})
})
.collect();
for handle in &handles {
handle.send(1).unwrap();
}
let deadline = Instant::now() + Duration::from_secs(10);
while count.load(Ordering::SeqCst) < 256 && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
assert_eq!(
count.load(Ordering::SeqCst),
256,
"every actor should have processed its message"
);
for handle in &handles {
handle.stop().unwrap();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,235 @@
use super::*;
use crate::message::peer::build_shared_file_list;
use crate::peer::{ConnectionType, Peer};
use std::net::{TcpListener, TcpStream};
use std::sync::mpsc::Receiver;
/// A connected inbound actor over a real loopback socket, plus the far
/// end (kept alive so reads see silence, not EOF).
fn connected_actor() -> (PeerActor, Receiver<ClientOperation>, TcpStream) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let stream = TcpStream::connect(addr).unwrap();
stream.set_nonblocking(true).unwrap();
let (far_end, _) = listener.accept().unwrap();
let (mut actor, rx) = make_actor(Some(stream));
actor.on_start();
(actor, rx, far_end)
}
fn make_actor(
stream: Option<TcpStream>,
) -> (PeerActor, Receiver<ClientOperation>) {
let (tx, rx) = std::sync::mpsc::channel();
let peer = Peer::new(
"bob".to_string(),
ConnectionType::P,
"127.0.0.1".to_string(),
0,
None,
0,
0,
0,
);
let actor = PeerActor::new(peer, stream, None, tx, "me".to_string(), 7);
(actor, rx)
}
#[test]
fn a_timed_out_connect_parks_the_actor_in_disconnected() {
let (mut actor, rx) = make_actor(None);
actor.connection_state = ConnectionState::Connecting {
since: Instant::now().checked_sub(Duration::from_secs(21)).unwrap(),
};
actor.tick();
assert!(
matches!(actor.connection_state, ConnectionState::Disconnected),
"a timed-out connect must leave Connecting"
);
match rx.try_recv() {
Ok(ClientOperation::PeerConnectFailed(7, username, _)) => {
assert_eq!(username, "bob");
}
other => panic!("expected PeerConnectFailed, got {other:?}"),
}
}
#[test]
fn an_upload_failed_message_reaches_the_client_as_this_peer() {
let (mut actor, rx, _far_end) = connected_actor();
actor.handle_message(PeerMessage::UploadFailed(
String::new(),
"song.mp3".to_string(),
));
match rx.try_recv() {
Ok(ClientOperation::UploadFailed(username, filename)) => {
assert_eq!(username, "bob");
assert_eq!(filename, "song.mp3");
}
other => panic!("expected UploadFailed, got {other:?}"),
}
}
#[test]
fn tick_reaps_a_peer_idle_past_the_deadline() {
let (mut actor, rx, _far_end) = connected_actor();
actor.tick();
assert!(actor.stream.is_some(), "a fresh connection stays open");
actor.last_activity = Instant::now()
.checked_sub(IDLE_DISCONNECT + Duration::from_secs(1))
.unwrap();
actor.tick();
assert!(actor.stream.is_none(), "an idle stream must be closed");
match rx.try_recv() {
Ok(ClientOperation::PeerDisconnected(7, username, None)) => {
assert_eq!(username, "bob");
}
other => panic!("expected a clean PeerDisconnected, got {other:?}"),
}
}
/// A listing well past one socket buffer, so delivery within a couple of
/// ticks proves the actor drains the socket rather than sipping 1 KiB a tick.
fn big_listing() -> Vec<u8> {
let files = (0..400u64)
.map(|i| crate::message::peer::SharedFileEntry {
name: format!("track-{i:03}-{}.flac", "x".repeat(i as usize % 17)),
size: i,
attributes: Vec::new(),
})
.collect();
let dir = crate::message::peer::SharedDirectory {
name: "album".to_string(),
files,
};
let bytes = build_shared_file_list(std::slice::from_ref(&dir)).get_buffer();
assert!(bytes.len() > 4096, "the listing must span several reads");
bytes
}
fn self_handle(
actor: &mut PeerActor,
) -> Receiver<crate::actor::ActorMessage<PeerMessage>> {
let (tx, rx) = std::sync::mpsc::channel();
actor.set_self_handle(ActorHandle { sender: tx });
rx
}
// A connection replaced by a newer one to the same peer may still carry
// a reply already on the wire: it is retired, not cut, and once nothing
// more can come it is the one to stop itself.
#[test]
fn a_retired_actor_delivers_what_is_on_the_wire_then_stops_itself() {
use std::io::Write;
let (mut actor, rx, mut far_end) = connected_actor();
let stop_rx = self_handle(&mut actor);
actor.handle_message(PeerMessage::Retire);
far_end.write_all(&big_listing()).unwrap();
std::thread::sleep(Duration::from_millis(50));
actor.tick();
actor.tick();
match rx.try_recv() {
Ok(ClientOperation::BrowseResult { username, .. }) => {
assert_eq!(username, "bob");
}
other => {
panic!("expected the listing to reach the client, got {other:?}")
}
}
assert!(actor.stream.is_some(), "retiring is not yet closing");
assert!(stop_rx.try_recv().is_err(), "not stopped while listening");
actor.retire_deadline =
Some(Instant::now().checked_sub(Duration::from_secs(1)).unwrap());
actor.tick();
assert!(actor.stream.is_none(), "past the grace the stream closes");
match rx.try_recv() {
Ok(ClientOperation::PeerDisconnected(7, _, None)) => {}
other => panic!("expected a clean PeerDisconnected, got {other:?}"),
}
actor.tick();
assert!(
matches!(stop_rx.try_recv(), Ok(crate::actor::ActorMessage::Stop)),
"a closed retired actor stops itself"
);
}
// The peer that replaced the connection considers the old one done too: it
// writes its reply and hangs up in the same breath. The reply still counts.
#[test]
fn a_reply_sent_right_before_the_peer_hangs_up_is_still_delivered() {
use std::io::Write;
let (mut actor, rx, mut far_end) = connected_actor();
actor.handle_message(PeerMessage::Retire);
far_end.write_all(&big_listing()).unwrap();
drop(far_end);
std::thread::sleep(Duration::from_millis(50));
actor.tick();
actor.tick();
match rx.try_recv() {
Ok(ClientOperation::BrowseResult { username, .. }) => {
assert_eq!(username, "bob");
}
other => panic!("expected the listing before the close, got {other:?}"),
}
match rx.try_recv() {
Ok(ClientOperation::PeerDisconnected(7, _, None)) => {}
other => panic!("expected a clean PeerDisconnected, got {other:?}"),
}
}
#[test]
fn a_retired_actor_reporting_an_error_reports_a_clean_close() {
let (mut actor, rx, _far_end) = connected_actor();
actor.handle_message(PeerMessage::Retire);
actor.disconnect_with_error(Error::from(io::ErrorKind::ConnectionReset));
match rx.try_recv() {
Ok(ClientOperation::PeerDisconnected(7, _, None)) => {}
other => panic!(
"a retired connection going away is not an error, got {other:?}"
),
}
}
// Retiring an actor whose dial has not completed: nothing went out, so
// there is nothing to wait for, and it must not linger in the queue that
// only a successful connection drains.
#[test]
fn retiring_a_connecting_actor_closes_it_at_once() {
let (mut actor, rx) = make_actor(None);
let stop_rx = self_handle(&mut actor);
actor.connection_state = ConnectionState::Connecting {
since: Instant::now(),
};
actor.handle_message(PeerMessage::Retire);
assert!(matches!(
actor.connection_state,
ConnectionState::Disconnected
));
match rx.try_recv() {
Ok(ClientOperation::PeerDisconnected(7, _, None)) => {}
other => panic!("expected a clean PeerDisconnected, got {other:?}"),
}
actor.tick();
assert!(matches!(
stop_rx.try_recv(),
Ok(crate::actor::ActorMessage::Stop)
));
}
+461
View File
@@ -0,0 +1,461 @@
use crate::actor::peer_actor::{PeerActor, PeerMessage};
use crate::actor::{ActorHandle, ActorSystem};
use crate::client::ClientOperation;
use crate::message::MessageReader;
use crate::peer::Peer;
use crate::utils::lock::MutexExt;
use crate::{debug, error};
use std::collections::{HashMap, HashSet};
use std::net::TcpStream;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
/// Source of unique per-actor ids so terminal-outcome eviction can be made
/// identity-aware (a replaced actor must not evict its replacement).
static NEXT_PEER_ID: AtomicU64 = AtomicU64::new(1);
/// Registered peers keyed by username, each stored with the unique id of the
/// actor currently occupying the slot.
type PeerMap = HashMap<String, (u64, ActorHandle<PeerMessage>)>;
pub struct PeerRegistry {
peers: Arc<Mutex<PeerMap>>,
actor_system: Arc<ActorSystem>,
client_channel: Sender<ClientOperation>,
own_username: String,
capacity: Arc<AtomicUsize>,
}
impl PeerRegistry {
#[must_use]
pub fn new(
actor_system: Arc<ActorSystem>,
client_channel: Sender<ClientOperation>,
own_username: String,
) -> Self {
Self::with_capacity(
actor_system,
client_channel,
own_username,
Arc::new(AtomicUsize::new(usize::MAX)),
)
}
#[must_use]
pub(crate) fn with_capacity(
actor_system: Arc<ActorSystem>,
client_channel: Sender<ClientOperation>,
own_username: String,
capacity: Arc<AtomicUsize>,
) -> Self {
Self {
peers: Arc::new(Mutex::new(HashMap::new())),
actor_system,
client_channel,
own_username,
capacity,
}
}
pub fn register_peer(
&self,
peer: Peer,
stream: Option<TcpStream>,
reader: Option<MessageReader>,
) -> Result<ActorHandle<PeerMessage>, String> {
self.register_peer_protected(peer, stream, reader, &HashSet::new())
}
pub(crate) fn register_peer_protected(
&self,
peer: Peer,
stream: Option<TcpStream>,
reader: Option<MessageReader>,
protected: &HashSet<String>,
) -> Result<ActorHandle<PeerMessage>, String> {
let username = peer.username.clone();
let id = NEXT_PEER_ID.fetch_add(1, Ordering::Relaxed);
let actor = PeerActor::new(
peer,
stream,
reader,
self.client_channel.clone(),
self.own_username.clone(),
id,
);
// Take the map lock before the actor exists: a peer that dies
// instantly (refused dial, immediate hangup) reports its terminal
// outcome to the client loop, whose eviction takes this same lock.
// With the insert racing the spawn, eviction could run first, find
// nothing, and the entry inserted afterwards became a permanent
// zombie claiming the username.
let mut peers = self
.peers
.lock_safe()
.map_err(|e| format!("peer registry lock poisoned: {e}"))?;
let capacity = self.capacity.load(Ordering::Relaxed).max(1);
if peers.len() >= capacity && !peers.contains_key(&username) {
let victim = peers
.iter()
.filter(|(name, _)| !protected.contains(name.as_str()))
.min_by_key(|(_, (id, _))| *id)
.map(|(name, _)| name.clone());
let Some(victim) = victim else {
return Err(format!(
"peer registry at capacity ({capacity}) and every peer \
is busy; refusing {username}"
));
};
if let Some((_, handle)) = peers.remove(&victim) {
let _ = handle.stop();
debug!(
"[peer_registry] evicted idle peer {} to admit {}",
victim, username
);
}
}
let handle = self
.actor_system
.try_spawn_with_handle(actor, |actor, handle| {
actor.set_self_handle(handle);
})
.map_err(|e| format!("failed to spawn peer actor thread: {e}"))?;
// Retire any actor already registered under this username rather than
// stop it: a reply to a request we sent on it may still be on the
// wire. It stops itself once that grace is over, and the eviction its
// shutdown triggers is keyed on its id, so it cannot evict this one.
if let Some((_, old_handle)) =
peers.insert(username.clone(), (id, handle.clone()))
{
let _ = old_handle.send(PeerMessage::Retire);
debug!(
"[peer_registry] Replaced existing peer actor for {}",
username
);
}
Ok(handle)
}
#[must_use]
pub fn get_peer(&self, username: &str) -> Option<ActorHandle<PeerMessage>> {
match self.peers.lock_safe() {
Ok(peers) => peers.get(username).map(|(_, handle)| handle.clone()),
Err(e) => {
error!("[peer_registry] get_peer: {}", e);
None
}
}
}
#[must_use]
pub fn remove_peer(
&self,
username: &str,
) -> Option<ActorHandle<PeerMessage>> {
let mut peers = match self.peers.lock_safe() {
Ok(p) => p,
Err(e) => {
error!("[peer_registry] remove_peer: {}", e);
return None;
}
};
let removed = peers.remove(username);
if removed.is_some() {
debug!("[peer_registry] Removed peer actor for {}", username);
}
removed.map(|(_, handle)| handle)
}
/// Remove and return the actor for `username` only if it is still the actor
/// with `id`. A stale (replaced) actor's terminal notification therefore
/// cannot evict the newer actor that now occupies the slot.
#[must_use]
pub fn remove_peer_if(
&self,
username: &str,
id: u64,
) -> Option<ActorHandle<PeerMessage>> {
let mut peers = match self.peers.lock_safe() {
Ok(p) => p,
Err(e) => {
error!("[peer_registry] remove_peer_if: {}", e);
return None;
}
};
if peers.get(username).is_some_and(|(stored, _)| *stored == id) {
let removed = peers.remove(username).map(|(_, handle)| handle);
debug!(
"[peer_registry] Removed peer actor {} for {}",
id, username
);
return removed;
}
None
}
/// Stop every peer actor and empty the registry, closing the connections
/// they hold. Used when the client shuts down.
pub fn stop_all(&self) {
let handles = match self.peers.lock_safe() {
Ok(mut peers) => peers
.drain()
.map(|(_, (_, handle))| handle)
.collect::<Vec<_>>(),
Err(e) => {
error!("[peer_registry] stop_all: {}", e);
return;
}
};
for handle in handles {
let _ = handle.stop();
}
}
#[must_use]
pub fn contains(&self, username: &str) -> bool {
match self.peers.lock_safe() {
Ok(peers) => peers.contains_key(username),
Err(e) => {
error!("[peer_registry] contains: {}", e);
false
}
}
}
pub fn send_to_peer(
&self,
username: &str,
message: PeerMessage,
) -> Result<(), String> {
let handle = self
.get_peer(username)
.ok_or_else(|| format!("Peer {username} not found in registry"))?;
handle.send(message)
}
pub fn queue_upload(
&self,
username: &str,
filename: String,
) -> Result<(), String> {
self.send_to_peer(username, PeerMessage::QueueUpload(filename))
}
}
impl Clone for PeerRegistry {
fn clone(&self) -> Self {
Self {
peers: self.peers.clone(),
actor_system: self.actor_system.clone(),
client_channel: self.client_channel.clone(),
own_username: self.own_username.clone(),
capacity: self.capacity.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::PeerRegistry;
use crate::actor::ActorSystem;
use crate::peer::{ConnectionType, Peer};
use std::collections::HashSet;
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
fn loopback_peer(name: &str) -> (Peer, TcpStream, TcpStream) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let stream = TcpStream::connect(addr).unwrap();
stream.set_nonblocking(true).unwrap();
let far_end = listener.accept().unwrap().0;
let peer = Peer::new(
name.to_string(),
ConnectionType::P,
"127.0.0.1".to_string(),
u32::from(addr.port()),
None,
0,
0,
0,
);
(peer, stream, far_end)
}
fn capped_registry(
capacity: usize,
) -> (
PeerRegistry,
std::sync::mpsc::Receiver<crate::client::ClientOperation>,
) {
let system = Arc::new(ActorSystem::new());
let (tx, rx) = std::sync::mpsc::channel();
let registry = PeerRegistry::with_capacity(
system,
tx,
"me".to_string(),
Arc::new(AtomicUsize::new(capacity)),
);
(registry, rx)
}
#[test]
fn capacity_evicts_the_oldest_idle_peer() {
let (registry, _rx) = capped_registry(2);
for name in ["a", "b", "c"] {
let (peer, stream, _far_end) = loopback_peer(name);
registry.register_peer(peer, Some(stream), None).unwrap();
}
assert!(!registry.contains("a"), "the oldest peer must be evicted");
assert!(registry.contains("b"));
assert!(registry.contains("c"));
}
#[test]
fn capacity_refuses_when_every_peer_is_protected() {
let (registry, _rx) = capped_registry(1);
let (busy, busy_stream, _busy_far) = loopback_peer("busy");
registry
.register_peer(busy, Some(busy_stream), None)
.unwrap();
let protected: HashSet<String> =
std::iter::once("busy".to_string()).collect();
let (newcomer, stream, _far) = loopback_peer("newcomer");
let result = registry.register_peer_protected(
newcomer,
Some(stream),
None,
&protected,
);
assert!(result.is_err(), "a full registry of busy peers must refuse");
assert!(registry.contains("busy"));
assert!(!registry.contains("newcomer"));
}
#[test]
fn a_returning_username_is_replaced_not_refused_at_capacity() {
let (registry, _rx) = capped_registry(1);
let (first, first_stream, _first_far) = loopback_peer("bob");
registry
.register_peer(first, Some(first_stream), None)
.unwrap();
let protected: HashSet<String> =
std::iter::once("bob".to_string()).collect();
let (again, stream, _far) = loopback_peer("bob");
let result = registry.register_peer_protected(
again,
Some(stream),
None,
&protected,
);
assert!(
result.is_ok(),
"a reconnecting username must replace itself"
);
assert!(registry.contains("bob"));
}
#[test]
fn remove_peer_if_respects_actor_identity() {
let system = Arc::new(ActorSystem::new());
let (tx, _rx) = std::sync::mpsc::channel();
let registry = PeerRegistry::new(system, tx, "me".to_string());
// A real loopback connection makes the actor inbound (no dial-out);
// non-blocking so it can process Stop promptly on teardown.
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let stream = TcpStream::connect(addr).unwrap();
stream.set_nonblocking(true).unwrap();
let _server_side = listener.accept().unwrap().0;
let peer = Peer::new(
"bob".to_string(),
ConnectionType::P,
"127.0.0.1".to_string(),
u32::from(addr.port()),
None,
0,
0,
0,
);
registry.register_peer(peer, Some(stream), None).unwrap();
assert!(registry.contains("bob"));
// A stale / wrong id must not evict the live actor.
assert!(registry.remove_peer_if("bob", u64::MAX).is_none());
assert!(registry.contains("bob"));
// Unconditional removal still works (and stops the actor).
let handle = registry.remove_peer("bob");
assert!(handle.is_some());
let _ = handle.unwrap().stop();
assert!(!registry.contains("bob"));
}
// A dial that is refused reports its terminal outcome almost instantly —
// possibly while register_peer is still between spawn and insert. The
// registry holds its lock across both, so the eviction that follows must
// always find the entry; a miss left a permanent zombie claiming the
// username.
#[test]
fn refused_dial_does_not_leave_a_zombie_entry() {
use crate::client::ClientOperation;
let system = Arc::new(ActorSystem::new());
let (tx, rx) = std::sync::mpsc::channel();
let registry = PeerRegistry::new(system, tx, "me".to_string());
// A port with nothing listening behind it: bind, learn it, drop it.
let port = {
let probe = TcpListener::bind("127.0.0.1:0").unwrap();
probe.local_addr().unwrap().port()
};
let peer = Peer::new(
"ghost".to_string(),
ConnectionType::P,
"127.0.0.1".to_string(),
u32::from(port),
None,
0,
0,
0,
);
registry.register_peer(peer, None, None).unwrap();
// Play the client ops loop: take the terminal outcome, evict by id.
match rx.recv_timeout(std::time::Duration::from_secs(10)) {
Ok(ClientOperation::PeerConnectFailed(id, username, _)) => {
assert_eq!(username, "ghost");
if let Some(handle) = registry.remove_peer_if(&username, id) {
let _ = handle.stop();
}
}
other => panic!("expected PeerConnectFailed, got {other:?}"),
}
assert!(
!registry.contains("ghost"),
"a refused dial must not leave a registry entry"
);
}
}
+722
View File
@@ -0,0 +1,722 @@
use crate::actor::{Actor, ActorHandle, ConnectionState};
use crate::client::{CancelHandle, ClientOperation};
use crate::dispatcher::MessageDispatcher;
use crate::message::server::AdminMessageHandler;
use crate::message::server::CantConnectToPeerHandler;
use crate::message::server::CheckPrivilegesHandler;
use crate::message::server::ConnectToPeerHandler;
use crate::message::server::EmbeddedMessageHandler;
use crate::message::server::ExcludedSearchPhrasesHandler;
use crate::message::server::FileSearchHandler;
use crate::message::server::GetPeerAddressHandler;
use crate::message::server::GlobalRoomMessageHandler;
use crate::message::server::JoinRoomHandler;
use crate::message::server::LeaveRoomHandler;
use crate::message::server::LoginHandler;
use crate::message::server::MessageFactory;
use crate::message::server::MessageUser;
use crate::message::server::ParentMinSpeedHandler;
use crate::message::server::ParentSpeedRatioHandler;
use crate::message::server::PossibleParentsHandler;
use crate::message::server::PrivilegedUsersHandler;
use crate::message::server::ReloggedHandler;
use crate::message::server::ResetDistributedHandler;
use crate::message::server::SayChatroomHandler;
use crate::message::server::UserJoinedRoomHandler;
use crate::message::server::UserLeftRoomHandler;
use crate::message::server::WatchUserHandler;
use crate::message::server::WishListIntervalHandler;
use crate::message::server::{
CantCreateRoomHandler, OwnRoomStandingHandler, RoomMembersHandler,
RoomOperatorsHandler, RoomRosterChangeHandler,
};
use crate::message::server::{
GetUserStatsHandler, GetUserStatusHandler, RoomListHandler,
};
use crate::message::server::{
GlobalRecommendationsHandler, ItemRecommendationsHandler,
ItemSimilarUsersHandler, RecommendationsHandler, SimilarUsersHandler,
UserInterestsHandler,
};
use crate::message::server::{
RoomTickerAddedHandler, RoomTickerRemovedHandler, RoomTickersHandler,
};
use crate::message::{Handlers, MessageType};
use crate::message::{Message, MessageReader};
use crate::peer::ConnectionType;
use crate::peer::Peer;
use crate::types::{
ClientVersion, Recommendation, RoomEvent, RoomInfo, RoomUserStats,
SessionLoss, SessionWatch, SimilarUser, UserInterests,
};
use crate::utils::lock::RwLockExt;
use std::io::{self, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use crate::{SoulseekRs, debug, error, trace, warn};
mod handlers;
mod types;
pub use types::{Context, PeerAddress, UserMessage};
/// Ceiling on the wait for the server's login verdict. A loaded server can
/// take seconds to answer, so this stays inside the caller's own 45s bound
/// rather than undercutting it.
const LOGIN_VERDICT_TIMEOUT: Duration = Duration::from_secs(30);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// The server actor's mailbox. Marked non-exhaustive: each protocol message
/// the client learns adds a variant, and that must not break callers.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ServerMessage {
ProcessRead,
LoginStatus(bool),
/// The server is closing this connection: the same username logged in
/// elsewhere.
Relogged,
SendMessage(Message),
Login {
username: String,
password: String,
version: ClientVersion,
response: std::sync::mpsc::Sender<Result<bool, SoulseekRs>>,
},
FileSearch {
token: u32,
query: String,
},
/// A wishlist search (code 103): a stored query the server lets us repeat
/// once per announced interval.
WishlistSearch {
token: u32,
query: String,
},
/// The server announced how often it will accept a wishlist search.
WishlistInterval(u32),
/// Everyone the server counts as privileged (code 69).
PrivilegedUsers(Vec<String>),
/// Seconds of our own privileges left (code 92).
OwnPrivileges(u32),
/// Ask the server for the answer to the above.
CheckPrivileges,
/// A search the server distributed to us from another user; if it matches
/// our shares we reply with a FileSearchResponse.
FileSearchRequest {
username: String,
token: u32,
query: String,
},
#[allow(dead_code)]
ConnectToPeer(Peer),
PierceFirewall(u32),
GetPeerAddress(String),
GetPeerAddressResponse {
username: String,
host: String,
port: u32,
obfuscation_type: u32,
obfuscated_port: u16,
},
PrivateMessageReceived(UserMessage),
RoomListReceived(Vec<RoomInfo>),
/// A `GetUserStatus` (code 7) reply.
UserStatusReceived {
username: String,
status: u32,
privileged: bool,
},
/// A `WatchUser` (code 5) reply: the initial snapshot for a user we just
/// started watching. The stats are absent when the server does not know
/// the username.
WatchedUserReceived {
username: String,
exists: bool,
status: Option<u32>,
average_speed: Option<u32>,
shared_files: Option<u32>,
shared_folders: Option<u32>,
},
/// A `GetUserStats` (code 36) reply.
UserStatsReceived {
username: String,
average_speed: u32,
shared_files: u32,
shared_folders: u32,
},
RoomJoined {
room: String,
users: Vec<String>,
},
/// Per-member statistics carried by the same `JoinRoom` (code 14) reply.
RoomMemberStats {
room: String,
stats: Vec<RoomUserStats>,
},
RoomLeft {
room: String,
},
RoomMessageReceived {
room: String,
username: String,
message: String,
},
RoomUserJoined {
room: String,
username: String,
},
RoomUserLeft {
room: String,
username: String,
},
/// Peers the server suggests as distributed-network parents: username,
/// host, port.
PossibleParents(Vec<(String, String, u16)>),
/// The server asks us to drop our parent and start over.
ResetDistributed,
/// The ticker board of a room we just joined (code 113).
RoomTickers {
room: String,
tickers: Vec<crate::types::RoomTicker>,
},
/// One member set their ticker (code 114).
RoomTickerAdded {
room: String,
username: String,
ticker: String,
},
/// One member cleared their ticker (code 115).
RoomTickerRemoved {
room: String,
username: String,
},
/// A message from the global room feed (code 152).
GlobalRoomMessageReceived {
room: String,
username: String,
message: String,
},
/// Recommendations from our own interests (code 54).
RecommendationsReceived {
recommended: Vec<Recommendation>,
unrecommended: Vec<Recommendation>,
},
/// Server-wide recommendations (code 56).
GlobalRecommendationsReceived {
recommended: Vec<Recommendation>,
unrecommended: Vec<Recommendation>,
},
/// Recommendations for one item (code 111).
ItemRecommendationsReceived {
item: String,
recommendations: Vec<Recommendation>,
},
/// Users similar to us (code 110).
SimilarUsersReceived {
users: Vec<SimilarUser>,
},
/// Users who like one item (code 112).
ItemSimilarUsersReceived {
item: String,
usernames: Vec<String>,
},
/// What another user likes and hates (code 57).
UserInterestsReceived(UserInterests),
/// A peer could not connect to us after we asked the server to broker
/// (code 1001); the token is the one we quoted.
CantConnectToPeer {
token: u32,
},
/// Phrases the server refuses to search for (code 160).
ExcludedSearchPhrases(Vec<String>),
/// The upload speed a client needs before the server lets it carry
/// children (code 83).
ParentMinSpeed(u32),
/// The divisor turning that speed into a child count (code 84).
ParentSpeedRatio(u32),
/// Who may enter a private room (code 133).
PrivateRoomMembers {
room: String,
users: Vec<String>,
},
/// Who runs a private room (code 148).
PrivateRoomOperators {
room: String,
users: Vec<String>,
},
/// One user joined or left a private room's member or operator roster
/// (codes 134/135 and 143/144).
PrivateRoomRosterChanged {
room: String,
username: String,
/// True for the member roster, false for the operator roster.
members: bool,
added: bool,
},
/// Our own membership (139/140) or operatorship (145/146) of a private
/// room was granted or revoked.
OwnRoomStandingChanged {
room: String,
members: bool,
granted: bool,
},
/// The room we asked to join could not be created (code 1003).
CantCreateRoom {
room: String,
},
}
pub struct ServerActor {
address: PeerAddress,
context: Arc<RwLock<Context>>,
listen_port: u16,
enable_listen: bool,
stream: Option<TcpStream>,
connection_state: ConnectionState,
reader: MessageReader,
client_channel: Sender<ClientOperation>,
self_handle: Option<ActorHandle<ServerMessage>>,
dispatcher: Option<MessageDispatcher<ServerMessage>>,
dispatcher_receiver: Option<Receiver<ServerMessage>>,
dispatcher_sender: Option<Sender<ServerMessage>>,
queued_messages: Vec<ServerMessage>,
shared_folder_count: u32,
shared_file_count: u32,
session: SessionWatch,
cancel: CancelHandle,
}
/// The messages a client sends right after a successful login: its shared-file
/// counts, online status, and (when listening)
/// the port peers should connect to. Kept as a free function so it can be
/// tested without a live connection.
fn post_login_messages(
enable_listen: bool,
listen_port: u16,
shared_folders: u32,
shared_files: u32,
) -> Vec<Message> {
let mut messages = vec![
MessageFactory::build_shared_folders_message(
shared_folders,
shared_files,
),
MessageFactory::build_set_status_message(2),
];
if enable_listen {
messages.push(MessageFactory::build_set_wait_port_message(listen_port));
}
messages
}
impl ServerActor {
#[must_use]
pub fn new(
address: PeerAddress,
client_channel: Sender<ClientOperation>,
listen_port: u16,
enable_listen: bool,
shared_folder_count: u32,
shared_file_count: u32,
) -> Self {
Self {
address,
context: Arc::new(RwLock::new(Context::new())),
listen_port,
enable_listen,
stream: None,
connection_state: ConnectionState::Disconnected,
dispatcher: None,
dispatcher_receiver: None,
dispatcher_sender: None,
reader: MessageReader::new(),
client_channel,
self_handle: None,
queued_messages: Vec::new(),
shared_folder_count,
shared_file_count,
session: SessionWatch::default(),
cancel: CancelHandle::new(),
}
}
/// Share the client's view of whether this session is still alive.
pub fn set_session_watch(&mut self, session: SessionWatch) {
self.session = session;
}
pub fn set_cancel(&mut self, cancel: CancelHandle) {
self.cancel = cancel;
}
fn initiate_connection(&mut self) -> bool {
let stream = (self.address.get_host(), self.address.get_port())
.to_socket_addrs()
.ok()
.and_then(|mut addrs| {
addrs.find_map(|addr| {
TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT).ok()
})
});
let Some(stream) = stream else {
error!("[server] Failed to connect to {}", self.address);
self.disconnect_with_error();
return false;
};
if let Err(e) = stream.set_nonblocking(true) {
error!("[server] Failed to set non-blocking: {}", e);
self.disconnect_with_error();
return false;
}
stream.set_nodelay(true).ok();
if let Err(e) = crate::utils::keepalive::set_keepalive(&stream) {
warn!("[server] keepalive not set: {}", e);
}
self.stream = Some(stream);
self.connection_state = ConnectionState::Connecting {
since: Instant::now(),
};
true
}
pub fn set_self_handle(&mut self, handle: ActorHandle<ServerMessage>) {
self.self_handle = Some(handle);
}
fn initialize_dispatcher(&mut self) {
let (dispatcher_sender, dispatcher_receiver) =
std::sync::mpsc::channel::<ServerMessage>();
self.dispatcher_receiver = Some(dispatcher_receiver);
self.dispatcher_sender = Some(dispatcher_sender.clone());
if let Err(e) = self
.client_channel
.send(ClientOperation::SetServerSender(dispatcher_sender.clone()))
{
error!("[server] failed to send SetServerSender: {}", e);
}
let mut handlers = Handlers::new();
handlers.register_handler(LoginHandler);
handlers.register_handler(ReloggedHandler);
handlers.register_handler(AdminMessageHandler);
handlers.register_handler(PossibleParentsHandler);
handlers.register_handler(ResetDistributedHandler);
handlers.register_handler(EmbeddedMessageHandler);
handlers.register_handler(RoomListHandler);
handlers.register_handler(GetUserStatusHandler);
handlers.register_handler(WatchUserHandler);
handlers.register_handler(GetUserStatsHandler);
handlers.register_handler(JoinRoomHandler);
handlers.register_handler(LeaveRoomHandler);
handlers.register_handler(SayChatroomHandler);
handlers.register_handler(UserJoinedRoomHandler);
handlers.register_handler(UserLeftRoomHandler);
handlers.register_handler(ExcludedSearchPhrasesHandler);
handlers.register_handler(PrivilegedUsersHandler);
handlers.register_handler(MessageUser);
handlers.register_handler(WishListIntervalHandler);
handlers.register_handler(ParentMinSpeedHandler);
handlers.register_handler(ParentSpeedRatioHandler);
handlers.register_handler(CheckPrivilegesHandler);
handlers.register_handler(FileSearchHandler);
handlers.register_handler(GetPeerAddressHandler);
handlers.register_handler(ConnectToPeerHandler);
handlers.register_handler(CantConnectToPeerHandler);
handlers.register_handler(RoomMembersHandler);
handlers.register_handler(RoomOperatorsHandler);
// Members added/removed (134/135), operators added/removed (143/144).
for code in [134, 135, 143, 144] {
handlers.register_handler(RoomRosterChangeHandler(code));
}
// Our own membership (139/140) and operatorship (145/146).
for code in [139, 140, 145, 146] {
handlers.register_handler(OwnRoomStandingHandler(code));
}
handlers.register_handler(CantCreateRoomHandler);
handlers.register_handler(RoomTickersHandler);
handlers.register_handler(RoomTickerAddedHandler);
handlers.register_handler(RoomTickerRemovedHandler);
handlers.register_handler(GlobalRoomMessageHandler);
handlers.register_handler(RecommendationsHandler);
handlers.register_handler(GlobalRecommendationsHandler);
handlers.register_handler(ItemRecommendationsHandler);
handlers.register_handler(SimilarUsersHandler);
handlers.register_handler(ItemSimilarUsersHandler);
handlers.register_handler(UserInterestsHandler);
self.dispatcher = Some(MessageDispatcher::new(
"server".into(),
dispatcher_sender,
handlers,
));
}
fn process_dispatcher_messages(&mut self) {
let messages: Vec<ServerMessage> = self
.dispatcher_receiver
.as_ref()
.map_or_else(Vec::new, |receiver| receiver.try_iter().collect());
for msg in &messages {
self.handle_message(msg.clone());
}
}
pub fn file_search(&mut self, token: u32, query: &str) {
self.queue_message(MessageFactory::build_file_search_message(
token, query,
));
}
fn process_read(&mut self) {
if self.reader.buffer_len() > 0 {
self.extract_and_process_messages();
}
{
let Some(stream) = self.stream.as_mut() else {
return;
};
match self.reader.read_from_socket(stream) {
Ok(()) => {}
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {}
Err(ref e) if e.kind() == io::ErrorKind::TimedOut => {
debug!("[server] Read operation timed out",);
}
Err(e) => {
error!(
"[server] Error reading from server: {} (kind: {:?}). Disconnecting.",
e,
e.kind()
);
self.disconnect_with_error();
return;
}
}
}
self.extract_and_process_messages();
}
fn extract_and_process_messages(&mut self) {
let mut extracted_count = 0;
loop {
match self.reader.extract_message() {
Ok(Some(mut message)) => {
extracted_count += 1;
trace!(
"[server] ← Message #{}: {:?}",
extracted_count,
message
.get_message_name(
MessageType::Server,
message.get_message_code()
)
.map_err(|e| e.to_string())
);
if let Some(ref dispatcher) = self.dispatcher {
dispatcher.dispatch(&mut message);
} else {
warn!("[server] No dispatcher available!",);
}
}
Err(e) => {
warn!(
"[server] Error extracting message: {}. Disconnecting.",
e
);
self.disconnect_with_error();
return;
}
Ok(None) => {
break;
}
}
}
self.process_dispatcher_messages();
}
/// Forward a chat-room event to the client operations loop.
fn forward_room_event(&self, event: RoomEvent) {
if let Err(e) =
self.client_channel.send(ClientOperation::RoomEvent(event))
{
error!("[server] Error forwarding room event to client: {}", e);
}
}
fn queue_message(&mut self, message: Message) {
if let Some(sender) = &self.dispatcher_sender {
match sender.send(ServerMessage::SendMessage(message)) {
Ok(()) => {}
Err(e) => error!("Failed to send: {}", e),
}
} else {
self.queued_messages
.push(ServerMessage::SendMessage(message));
}
}
fn send_message(&mut self, message: Message) {
let cancel = self.cancel.clone();
let Some(_live) = cancel.live() else {
return;
};
let Some(stream) = self.stream.as_mut() else {
error!("[server] Cannot send message: stream is None");
return;
};
trace!(
"[server] ➡ {:?}",
message
.get_message_name(
MessageType::Server,
u32::from_le_bytes(
message.get_slice(0, 4).try_into().unwrap_or_default()
)
)
.map_err(|e| e.to_string())
);
if let Err(e) = stream.write_all(&message.get_buffer()) {
error!("[server] Error writing message: {}. Disconnecting.", e);
self.disconnect_with_error();
return;
}
if let Err(e) = stream.flush() {
error!("[server] Error flushing stream: {}. Disconnecting.", e);
self.disconnect_with_error();
}
}
/// The server is closing this connection because the same account logged
/// in elsewhere. Nothing reconnects it, so the session is over.
fn handle_relogged(&mut self) {
error!(
"[server] another login took this username; this session has been \
closed by the server"
);
self.session.record(SessionLoss::Displaced);
self.disconnect();
}
fn disconnect_with_error(&mut self) {
debug!("[server] disconnect");
if matches!(self.connection_state, ConnectionState::Connected) {
self.session.record(SessionLoss::Disconnected);
}
self.disconnect();
}
fn disconnect(&mut self) {
debug!("[server] disconnected");
self.stream.take();
self.connection_state = ConnectionState::Disconnected;
self.dispatcher = None;
self.dispatcher_sender = None;
self.dispatcher_receiver = None;
self.queued_messages.clear();
self.reader = MessageReader::new();
if let Ok(mut ctx) = self.context.write_safe() {
ctx.logged_in = None;
}
}
fn check_connection_status(&mut self) {
let ConnectionState::Connecting { since } = self.connection_state
else {
return;
};
if since.elapsed() > Duration::from_secs(20) {
error!("[server] Connection timeout after 20 seconds");
self.disconnect_with_error();
return;
}
let Some(ref stream) = self.stream else {
return;
};
match stream.peer_addr() {
Ok(_) => {
self.connection_state = ConnectionState::Connected;
self.on_connection_established();
}
Err(ref e) if e.kind() == io::ErrorKind::NotConnected => {}
Err(e) => {
error!("[server] Connection failed: {}", e);
self.disconnect_with_error();
}
}
}
fn on_connection_established(&mut self) {
self.initialize_dispatcher();
let queued = std::mem::take(&mut self.queued_messages);
for msg in queued {
self.handle_message(msg);
}
if let Some(ref handle) = self.self_handle {
handle.send(ServerMessage::ProcessRead).ok();
}
self.process_read();
}
}
impl Actor for ServerActor {
type Message = ServerMessage;
fn handle(&mut self, msg: Self::Message) {
self.handle_message(msg);
}
fn on_start(&mut self) {
if self.stream.is_none() {
let _ = self.initiate_connection();
} else {
self.connection_state = ConnectionState::Connected;
self.on_connection_established();
}
}
fn on_stop(&mut self) {
trace!("[server] actor stopping");
self.disconnect();
}
fn tick(&mut self) {
match self.connection_state {
ConnectionState::Connecting { .. } => {
self.check_connection_status();
}
ConnectionState::Connected => {
if self.stream.is_some() {
self.process_read();
}
}
ConnectionState::Disconnected => {}
}
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,528 @@
//! What the actor does with a message once it has one: the dispatch and the
//! per-message handlers behind it.
//!
//! A child module, so these keep reaching the actor's private state without
//! any of it being widened for the move.
use super::{
ClientOperation, ClientVersion, ConnectionState, ConnectionType, Duration,
LOGIN_VERDICT_TIMEOUT, MessageFactory, Peer, RoomEvent, RwLockExt,
ServerActor, ServerMessage, SoulseekRs, UserMessage, debug, error,
post_login_messages,
};
impl ServerActor {
pub(super) fn handle_message(&mut self, msg: ServerMessage) {
if !matches!(self.connection_state, ConnectionState::Connected)
&& !matches!(
&msg,
ServerMessage::ProcessRead | ServerMessage::Login { .. }
)
{
self.queued_messages.push(msg);
return;
}
match msg {
ServerMessage::ConnectToPeer(peer) => {
self.handle_connect_to_peer(peer);
}
ServerMessage::LoginStatus(message) => {
self.handle_login_status(message);
}
ServerMessage::Relogged => self.handle_relogged(),
ServerMessage::PierceFirewall(token) => {
self.send_message(
MessageFactory::build_pierce_firewall_message(token),
);
}
ServerMessage::SendMessage(message) => {
self.send_message(message);
}
ServerMessage::GetPeerAddress(username) => {
self.send_message(MessageFactory::build_get_peer_address(
&username,
));
}
ServerMessage::GetPeerAddressResponse {
username,
host,
port,
obfuscation_type,
obfuscated_port,
} => {
self.handle_get_peer_address_response(
username,
host,
port,
obfuscation_type,
obfuscated_port,
);
}
ServerMessage::PrivateMessageReceived(user_message) => {
self.handle_private_message_received(user_message);
}
ServerMessage::ProcessRead => {
self.process_read();
}
ServerMessage::Login {
username,
password,
version,
response,
} => {
self.handle_login(username, password, version, response);
}
ServerMessage::FileSearch { token, query } => {
self.file_search(token, &query);
}
ServerMessage::FileSearchRequest {
username,
token,
query,
} => {
self.handle_file_search_request(username, token, query);
}
other => self.handle_social_message(other),
}
}
/// Rooms and users (codes 7, 5, 36, 13-17, 64): forwarded as room events
/// and user status or stats. Split out, like the standing traffic below,
/// only to keep `handle_message`'s match readable.
fn handle_social_message(&mut self, message: ServerMessage) {
match message {
ServerMessage::RoomListReceived(rooms) => {
self.forward_room_event(RoomEvent::List(rooms));
}
ServerMessage::UserStatusReceived {
username,
status,
privileged,
} => {
self.forward_to_client(ClientOperation::UserStatusReceived {
username,
status,
privileged,
});
}
ServerMessage::WatchedUserReceived {
username,
exists,
status,
average_speed,
shared_files,
shared_folders,
} => {
self.forward_to_client(ClientOperation::WatchedUserReceived {
username,
exists,
status,
average_speed,
shared_files,
shared_folders,
});
}
ServerMessage::UserStatsReceived {
username,
average_speed,
shared_files,
shared_folders,
} => {
self.forward_to_client(ClientOperation::UserStatsReceived {
username,
average_speed,
shared_files,
shared_folders,
});
}
ServerMessage::RoomJoined { room, users } => {
self.forward_room_event(RoomEvent::Joined { room, users });
}
ServerMessage::RoomMemberStats { room, stats } => {
self.forward_to_client(ClientOperation::RoomMemberStats {
room,
stats,
});
}
ServerMessage::RoomLeft { room } => {
self.forward_room_event(RoomEvent::Left { room });
}
ServerMessage::RoomMessageReceived {
room,
username,
message,
} => {
self.forward_room_event(RoomEvent::Message {
room,
username,
message,
});
}
ServerMessage::RoomUserJoined { room, username } => {
self.forward_room_event(RoomEvent::UserJoined {
room,
username,
});
}
ServerMessage::RoomUserLeft { room, username } => {
self.forward_room_event(RoomEvent::UserLeft { room, username });
}
ServerMessage::RoomTickers { room, tickers } => {
self.forward_room_event(RoomEvent::Tickers { room, tickers });
}
ServerMessage::RoomTickerAdded {
room,
username,
ticker,
} => {
self.forward_room_event(RoomEvent::TickerAdded {
room,
username,
ticker,
});
}
ServerMessage::RoomTickerRemoved { room, username } => {
self.forward_room_event(RoomEvent::TickerRemoved {
room,
username,
});
}
ServerMessage::GlobalRoomMessageReceived {
room,
username,
message,
} => {
self.forward_room_event(RoomEvent::GlobalMessage {
room,
username,
message,
});
}
other => self.handle_standing_message(other),
}
}
/// The wishlist and privilege traffic: standing searches (codes 103/104) and
/// who is privileged (codes 69/92).
///
/// Split out only because it keeps `handle_message`'s match to a readable
/// length; there is no behaviour here beyond dispatch.
fn handle_standing_message(&mut self, message: ServerMessage) {
match message {
ServerMessage::PossibleParents(candidates) => {
self.forward_to_client(ClientOperation::PossibleParents(
candidates,
));
}
ServerMessage::ResetDistributed => {
self.forward_to_client(ClientOperation::ResetDistributed);
}
ServerMessage::WishlistSearch { token, query } => {
self.queue_message(MessageFactory::build_wishlist_search(
token, &query,
));
}
ServerMessage::WishlistInterval(seconds) => {
self.forward_to_client(ClientOperation::WishlistInterval(
seconds,
));
}
ServerMessage::PrivilegedUsers(users) => {
self.forward_to_client(ClientOperation::PrivilegedUsers(users));
}
ServerMessage::OwnPrivileges(seconds) => {
self.forward_to_client(ClientOperation::OwnPrivileges(seconds));
}
ServerMessage::CheckPrivileges => {
self.queue_message(MessageFactory::build_check_privileges());
}
message @ (ServerMessage::RecommendationsReceived { .. }
| ServerMessage::GlobalRecommendationsReceived {
..
}
| ServerMessage::ItemRecommendationsReceived {
..
}
| ServerMessage::SimilarUsersReceived { .. }
| ServerMessage::ItemSimilarUsersReceived { .. }
| ServerMessage::UserInterestsReceived(..)) => {
self.handle_taste_message(message);
}
ServerMessage::CantConnectToPeer { token } => {
self.forward_to_client(ClientOperation::CantConnectToPeer {
token,
});
}
ServerMessage::PrivateRoomMembers { room, users } => {
self.forward_room_event(RoomEvent::PrivateMembers {
room,
users,
});
}
ServerMessage::PrivateRoomOperators { room, users } => {
self.forward_room_event(RoomEvent::PrivateOperators {
room,
users,
});
}
ServerMessage::PrivateRoomRosterChanged {
room,
username,
members,
added,
} => {
self.forward_room_event(RoomEvent::PrivateRosterChanged {
room,
username,
members,
added,
});
}
ServerMessage::OwnRoomStandingChanged {
room,
members,
granted,
} => {
self.forward_room_event(RoomEvent::OwnStandingChanged {
room,
members,
granted,
});
}
ServerMessage::CantCreateRoom { room } => {
self.forward_room_event(RoomEvent::CantCreate { room });
}
ServerMessage::ParentMinSpeed(speed) => {
self.forward_to_client(ClientOperation::ParentMinSpeed(speed));
}
ServerMessage::ParentSpeedRatio(ratio) => {
self.forward_to_client(ClientOperation::ParentSpeedRatio(
ratio,
));
}
ServerMessage::ExcludedSearchPhrases(phrases) => {
self.forward_to_client(ClientOperation::ExcludedSearchPhrases(
phrases,
));
}
other => {
error!("[server] unroutable message: {:?}", other);
}
}
}
/// What the server says about taste: the recommendation and similar-user
/// answers, and a user's interests. Split off `handle_standing_message`
/// only to keep that dispatch readable — these arms share nothing with
/// the rest of it.
fn handle_taste_message(&self, message: ServerMessage) {
match message {
ServerMessage::RecommendationsReceived {
recommended,
unrecommended,
} => {
self.forward_to_client(ClientOperation::Recommendations {
global: false,
recommended,
unrecommended,
});
}
ServerMessage::GlobalRecommendationsReceived {
recommended,
unrecommended,
} => {
self.forward_to_client(ClientOperation::Recommendations {
global: true,
recommended,
unrecommended,
});
}
ServerMessage::ItemRecommendationsReceived {
item,
recommendations,
} => {
self.forward_to_client(ClientOperation::ItemRecommendations {
item,
recommendations,
});
}
ServerMessage::SimilarUsersReceived { users } => {
self.forward_to_client(ClientOperation::SimilarUsers(users));
}
ServerMessage::ItemSimilarUsersReceived { item, usernames } => {
self.forward_to_client(ClientOperation::ItemSimilarUsers {
item,
usernames,
});
}
ServerMessage::UserInterestsReceived(interests) => {
self.forward_to_client(ClientOperation::UserInterests(
interests,
));
}
// `handle_standing_message` routes only the variants above here.
_ => {}
}
}
fn handle_connect_to_peer(&self, peer: Peer) {
if let Some(op) = match peer.connection_type {
ConnectionType::P | ConnectionType::F => {
Some(ClientOperation::ConnectToPeer(peer))
}
ConnectionType::D => None,
} && let Err(e) = self.client_channel.send(op)
{
error!("[server] failed to send ConnectToPeer: {}", e);
}
}
pub(super) fn handle_login_status(&mut self, message: bool) {
// Send the post-login handshake exactly once, only on success,
// on the live path (the old ServerActor::login did this but was
// never called). Advertises real shared counts and, when
// listening, the port peers must connect to.
if message {
for msg in post_login_messages(
self.enable_listen,
self.listen_port,
self.shared_folder_count,
self.shared_file_count,
) {
self.send_message(msg);
}
self.session.clear();
// The distributed stance is the leaf's to announce, and a new
// session starts without a parent.
self.forward_to_client(ClientOperation::ResetDistributed);
// The server holds interests only for the life of a session.
self.forward_to_client(ClientOperation::SessionEstablished);
}
match self.context.write_safe() {
Ok(mut ctx) => ctx.logged_in = Some(message),
Err(e) => {
error!("[server] LoginStatus write: {}", e);
}
}
}
fn handle_get_peer_address_response(
&self,
username: String,
host: String,
port: u32,
obfuscation_type: u32,
obfuscated_port: u16,
) {
debug!(
"[server] Received GetPeerAddress response for {}: {}:{} (obf_type: {}, obf_port: {})",
username, host, port, obfuscation_type, obfuscated_port
);
if let Err(e) =
self.client_channel
.send(ClientOperation::GetPeerAddressResponse {
username,
host,
port,
obfuscation_type,
obfuscated_port,
})
{
error!(
"[server] Error forwarding GetPeerAddress response to client: {}",
e
);
}
}
/// Hand an operation to the client loop, logging a dead channel rather
/// than unwinding the actor.
fn forward_to_client(&self, operation: ClientOperation) {
if let Err(e) = self.client_channel.send(operation) {
error!("[server] Error forwarding to client: {}", e);
}
}
fn handle_private_message_received(&self, user_message: UserMessage) {
debug!("[server] Private message from {}", user_message.username());
if let Err(e) = self
.client_channel
.send(ClientOperation::PrivateMessageReceived(user_message))
{
error!(
"[server] Error forwarding private message to client: {}",
e
);
}
}
pub(super) fn handle_login(
&mut self,
username: String,
password: String,
version: ClientVersion,
response: std::sync::mpsc::Sender<Result<bool, SoulseekRs>>,
) {
if self.stream.is_none() && !self.initiate_connection() {
let _ = response.send(Err(SoulseekRs::NotConnected));
return;
}
if let Ok(mut ctx) = self.context.write_safe() {
ctx.logged_in = None;
}
self.queue_message(MessageFactory::build_login_message(
&username, &password, version,
));
let start = std::time::Instant::now();
let context = self.context.clone();
std::thread::spawn(move || {
loop {
if start.elapsed() >= LOGIN_VERDICT_TIMEOUT {
let _ = response.send(Err(SoulseekRs::Timeout));
break;
}
let logged_in = match context.read_safe() {
Ok(ctx) => ctx.logged_in,
Err(e) => {
let _ = response.send(Err(e));
break;
}
};
if let Some(logged_in) = logged_in {
let result = if logged_in {
Ok(true)
} else {
Err(SoulseekRs::AuthenticationFailed)
};
let _ = response.send(result);
break;
}
std::thread::sleep(Duration::from_millis(100));
}
});
}
fn handle_file_search_request(
&self,
username: String,
token: u32,
query: String,
) {
if let Err(e) =
self.client_channel.send(ClientOperation::IncomingSearch {
from_parent: false,
username,
token,
query,
})
{
error!("[server] forward IncomingSearch: {}", e);
}
}
}
@@ -0,0 +1,277 @@
use super::*;
fn code_of(message: &Message) -> u32 {
u32::from_le_bytes(message.get_data()[0..4].try_into().unwrap())
}
#[test]
fn a_timed_out_connect_parks_the_actor_in_disconnected() {
let mut actor = parked_actor(1);
actor.connection_state = ConnectionState::Connecting {
since: Instant::now().checked_sub(Duration::from_secs(21)).unwrap(),
};
actor.tick();
assert!(
matches!(actor.connection_state, ConnectionState::Disconnected),
"a timed-out connect must leave Connecting"
);
}
#[cfg(unix)]
#[test]
fn the_server_socket_keeps_itself_alive() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let mut actor = parked_actor(listener.local_addr().unwrap().port());
assert!(actor.initiate_connection());
assert!(
crate::utils::keepalive::keepalive_enabled(
actor.stream.as_ref().unwrap()
),
"an idle NAT mapping must be kept alive and a dead path noticed"
);
}
fn parked_actor(port: u16) -> ServerActor {
parked_actor_with_client(port).0
}
fn parked_actor_with_client(
port: u16,
) -> (ServerActor, std::sync::mpsc::Receiver<ClientOperation>) {
let (tx, rx) = std::sync::mpsc::channel();
let actor = ServerActor::new(
PeerAddress::new("127.0.0.1".to_string(), port),
tx,
0,
false,
0,
0,
);
(actor, rx)
}
#[test]
fn dropping_the_connection_forgets_the_dispatcher_and_the_verdict() {
let mut actor = parked_actor(1);
actor.initialize_dispatcher();
actor.connection_state = ConnectionState::Connected;
actor.handle_login_status(true);
actor.disconnect_with_error();
assert_eq!(actor.session.loss(), Some(SessionLoss::Disconnected));
assert!(
actor.dispatcher_sender.is_none(),
"a login queued now must wait in the buffer, not in a channel \
the next connection replaces"
);
assert_eq!(
actor.context.read().unwrap().logged_in,
None,
"the old verdict must not answer the next login"
);
}
#[test]
fn disconnect_clears_a_partial_frame_so_the_next_session_reframes_clean() {
use std::io::Write;
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let client = TcpStream::connect(addr).unwrap();
let (mut server_side, _) = listener.accept().unwrap();
let partial_frame = [10u8, 0, 0, 0, 1, 2, 3];
server_side.write_all(&partial_frame).unwrap();
server_side.flush().unwrap();
client.set_nonblocking(true).unwrap();
let mut actor = parked_actor(addr.port());
actor.stream = Some(client);
actor.connection_state = ConnectionState::Connected;
for _ in 0..50 {
actor.process_read();
if actor.reader.buffer_len() > 0 {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(
actor.reader.buffer_len() > 0,
"an incomplete frame should be waiting for its rest"
);
actor.disconnect();
assert_eq!(
actor.reader.buffer_len(),
0,
"a stale partial frame carried across a reconnect reads the next \
session's bytes as a length prefix and wedges recovery"
);
}
#[test]
fn a_login_on_a_parked_actor_dials_again_and_queues_the_login() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let mut actor = parked_actor(listener.local_addr().unwrap().port());
actor.session.record(SessionLoss::Disconnected);
let (response, _verdict) = std::sync::mpsc::channel();
actor.handle_login(
"u".into(),
"p".into(),
ClientVersion::default(),
response,
);
assert!(
matches!(actor.connection_state, ConnectionState::Connecting { .. }),
"a parked actor must dial again on login"
);
assert_eq!(
actor.queued_messages.len(),
1,
"the login waits for the connection to come up"
);
}
#[test]
fn a_parked_actor_handles_login_rather_than_queueing_it() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let mut actor = parked_actor(listener.local_addr().unwrap().port());
actor.session.record(SessionLoss::Disconnected);
let (response, _verdict) = std::sync::mpsc::channel();
actor.handle_message(ServerMessage::Login {
username: "u".into(),
password: "p".into(),
version: ClientVersion::default(),
response,
});
assert!(
matches!(actor.connection_state, ConnectionState::Connecting { .. }),
"a parked actor must act on a login, not park it in the queue"
);
assert!(
actor
.queued_messages
.iter()
.all(|m| !matches!(m, ServerMessage::Login { .. })),
"the login must not be sitting unhandled in the queue"
);
}
#[test]
fn a_login_with_nobody_listening_is_refused_at_once() {
let mut actor = parked_actor(1);
let (response, verdict) = std::sync::mpsc::channel();
actor.handle_login(
"u".into(),
"p".into(),
ClientVersion::default(),
response,
);
assert!(
matches!(
verdict.recv_timeout(Duration::from_secs(5)),
Ok(Err(SoulseekRs::NotConnected))
),
"a refused dial must not make the caller wait out the verdict \
timeout"
);
}
#[test]
fn a_cancelled_actor_never_writes_the_login_it_queued() {
use std::io::Read;
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let client = TcpStream::connect(addr).unwrap();
let (mut server_side, _) = listener.accept().unwrap();
client.set_nonblocking(true).unwrap();
let cancel = CancelHandle::new();
let mut actor = parked_actor(addr.port());
actor.set_cancel(cancel.clone());
actor.stream = Some(client);
actor.connection_state = ConnectionState::Connecting {
since: Instant::now(),
};
let (response, _verdict) = std::sync::mpsc::channel();
actor.handle_login(
"u".into(),
"p".into(),
ClientVersion::default(),
response,
);
assert_eq!(actor.queued_messages.len(), 1);
cancel.cancel();
actor.tick();
assert!(matches!(actor.connection_state, ConnectionState::Connected));
actor.on_stop();
server_side
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut written = Vec::new();
assert_eq!(server_side.read_to_end(&mut written).unwrap(), 0);
}
#[test]
fn a_successful_login_marks_the_session_live_again() {
let mut actor = parked_actor(1);
actor.session.record(SessionLoss::Disconnected);
actor.handle_login_status(true);
assert_eq!(actor.session.loss(), None);
}
// A new session starts without a parent: the leaf drops whatever tree it
// hung from and announces its parentless stance from there.
#[test]
fn a_successful_login_resets_the_distributed_leaf() {
let (mut actor, client) = parked_actor_with_client(1);
actor.handle_login_status(true);
assert!(matches!(
client.try_recv(),
Ok(ClientOperation::ResetDistributed)
));
// A new session also has to be told what only the session held: our
// interests, which the server forgets when the old one ended.
assert!(matches!(
client.try_recv(),
Ok(ClientOperation::SessionEstablished)
));
actor.handle_login_status(false);
assert!(client.try_recv().is_err(), "a failed login resets nothing");
}
#[test]
fn post_login_messages_carry_counts_and_conditional_wait_port() {
let messages = post_login_messages(true, 4321, 3, 7);
let codes: Vec<u32> = messages.iter().map(code_of).collect();
// SharedFolders; SetStatus; SetWaitPort.
assert_eq!(codes, vec![35, 28, 2]);
// The SharedFolders message (code 35) carries the real counts.
let shared = messages[0].get_data();
assert_eq!(u32::from_le_bytes(shared[4..8].try_into().unwrap()), 3);
assert_eq!(u32::from_le_bytes(shared[8..12].try_into().unwrap()), 7);
// Not listening omits SetWaitPort (code 2).
let no_listen = post_login_messages(false, 4321, 3, 7);
let codes: Vec<u32> = no_listen.iter().map(code_of).collect();
assert_eq!(codes, vec![35, 28]);
}
@@ -0,0 +1,99 @@
//! The values the server actor hands out: where a peer listens, a private
//! message, and the login context.
#[derive(Debug, Clone)]
pub struct PeerAddress {
host: String,
port: u16,
}
impl PeerAddress {
#[must_use]
pub const fn new(host: String, port: u16) -> Self {
Self { host, port }
}
#[must_use]
pub fn get_host(&self) -> &str {
&self.host
}
#[must_use]
pub const fn get_port(&self) -> u16 {
self.port
}
}
impl std::fmt::Display for PeerAddress {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}:{}", self.host, self.port)
}
}
#[derive(Debug, Default)]
pub struct Context {
pub logged_in: Option<bool>,
}
impl Context {
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
#[derive(Debug, Clone)]
pub struct UserMessage {
id: u32,
timestamp: u32,
username: String,
message: String,
new_message: bool,
}
impl UserMessage {
#[must_use]
pub const fn new(
id: u32,
timestamp: u32,
username: String,
message: String,
new_message: bool,
) -> Self {
Self {
id,
timestamp,
username,
message,
new_message,
}
}
/// The server-assigned id of this message (used to acknowledge it).
#[must_use]
pub const fn id(&self) -> u32 {
self.id
}
/// Unix timestamp the server recorded for this message.
#[must_use]
pub const fn timestamp(&self) -> u32 {
self.timestamp
}
/// The username of the sender.
#[must_use]
pub fn username(&self) -> &str {
&self.username
}
/// The message body.
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
/// Whether the server flagged this as freshly delivered (as opposed to a
/// message replayed because it was queued while the recipient was offline).
#[must_use]
pub const fn is_new(&self) -> bool {
self.new_message
}
}
+24
View File
@@ -0,0 +1,24 @@
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
#[derive(Clone)]
pub struct CancelHandle(Arc<Mutex<bool>>);
impl CancelHandle {
pub(crate) fn new() -> Self {
Self(Arc::new(Mutex::new(false)))
}
pub fn cancel(&self) {
*self.0.lock().unwrap_or_else(PoisonError::into_inner) = true;
}
#[must_use]
pub fn is_cancelled(&self) -> bool {
*self.0.lock().unwrap_or_else(PoisonError::into_inner)
}
pub(crate) fn live(&self) -> Option<MutexGuard<'_, bool>> {
let guard = self.0.lock().unwrap_or_else(PoisonError::into_inner);
(!*guard).then_some(guard)
}
}
+430
View File
@@ -0,0 +1,430 @@
//! Serving children: the other half of the distributed search network.
//!
//! A leaf only reads the tree; a parent also carries it. When the client is
//! configured to accept children, peers dial us with a `D` connection, we tell
//! each one where our branch sits, and every search that reaches us is passed
//! down to all of them. That is the whole duty — a child answers searches
//! itself, and reports its own place to the server.
//!
//! How many children to carry follows Nicotine+: the server announces the
//! upload speed a parent needs (`ParentMinSpeed`, code 83) and the divisor
//! turning speed into a child count (`ParentSpeedRatio`, code 84), and a
//! client whose recorded speed is below the minimum carries none. Until the
//! server has reported our own speed there is nothing to apply the formula to,
//! so the configured cap stands — which is how slskd runs the whole time, with
//! an operator-set limit and no speed gate.
//!
//! Accepting children is opt-in ([`crate::ClientSettings::accept_children`]):
//! it costs a socket and the network's whole search stream per child, which is
//! not something a client should take on without being asked to.
use std::collections::HashMap;
use std::io::Write;
use std::net::TcpStream;
use crate::message::Message;
use crate::message::distributed;
use crate::{debug, trace};
/// How many children to carry at once. Nicotine+ caps its speed-derived limit
/// here too, for the same reason: each child is a socket and a copy of the
/// network's whole search stream.
pub const MAX_CHILDREN: usize = 10;
/// The child limit the server's figures imply, following Nicotine+:
/// `speed / ratio / 100`, capped, and none at all when our recorded speed is
/// below the minimum the server set.
///
/// `None` means the server's figures do not decide it — either it has not
/// reported our speed yet, or it never announced a ratio at all (soulfind
/// does not) — and the caller's own cap stands, which is how slskd runs the
/// whole time.
#[must_use]
pub fn limit_from_speed(
own_speed: Option<u32>,
min_speed: u32,
ratio: u32,
) -> Option<usize> {
if ratio == 0 {
return None;
}
let speed = own_speed?;
if speed < min_speed {
return Some(0);
}
Some((speed / ratio / 100) as usize)
}
/// The children hanging from us, keyed by username.
pub struct Children {
links: HashMap<String, TcpStream>,
/// The cap in force: [`MAX_CHILDREN`], lowered to what the server's
/// figures allow once it has reported our speed.
max: usize,
accepting: bool,
/// Whether anything is feeding us the search stream — a parent of our own,
/// or the server. Nicotine+ refuses children without one, and rightly:
/// a child hanging from a branch that receives nothing gets nothing.
fed: bool,
}
impl Default for Children {
fn default() -> Self {
Self::new(false)
}
}
impl Children {
#[must_use]
pub fn new(accepting: bool) -> Self {
Self {
links: HashMap::new(),
max: MAX_CHILDREN,
accepting,
fed: false,
}
}
/// Apply the server's figures: `own_speed` is what it records for us, and
/// the other two are what it announced in codes 83 and 84.
pub fn set_limits(
&mut self,
own_speed: Option<u32>,
min_speed: u32,
ratio: u32,
) {
self.max = limit_from_speed(own_speed, min_speed, ratio)
.map_or(MAX_CHILDREN, |limit| limit.min(MAX_CHILDREN));
debug!("[distributed] child limit is now {}", self.max);
}
/// Say whether something is feeding us the search stream. A client with
/// nothing to relay takes no children, and drops any it has.
pub const fn set_fed(&mut self, fed: bool) {
self.fed = fed;
}
#[must_use]
pub const fn is_fed(&self) -> bool {
self.fed
}
/// Whether we would take another child right now: we serve children, we
/// have something to relay, and we are under the cap. This is what the
/// server is told in `AcceptChildren` (code 100).
#[must_use]
pub fn has_room(&self) -> bool {
self.accepting && self.fed && self.links.len() < self.max
}
#[must_use]
pub fn len(&self) -> usize {
self.links.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.links.is_empty()
}
#[must_use]
pub fn usernames(&self) -> Vec<String> {
let mut names: Vec<String> = self.links.keys().cloned().collect();
names.sort();
names
}
/// Take on `username` as a child. Refused when we serve no children, have
/// nothing to relay, are full, or already carry that user.
pub fn accept(&mut self, username: &str, stream: TcpStream) -> bool {
if !self.has_room() {
return false;
}
// A second connection from a child we already carry is refused, as
// Nicotine+ does: the link we hold is the live one, and dropping it
// for an unproven second would cost that child its search stream.
if self.links.contains_key(username) {
debug!(
"[distributed] refusing child {}: already carried",
username
);
return false;
}
self.links.insert(username.to_string(), stream);
true
}
/// Drop every child, closing their links: what a client does when it can
/// no longer feed them.
pub fn drop_all(&mut self) {
if !self.links.is_empty() {
debug!("[distributed] dropping {} children", self.links.len());
}
self.links.clear();
}
pub fn remove(&mut self, username: &str) {
if self.links.remove(username).is_some() {
debug!("[distributed] child {} is gone", username);
}
}
/// Send one frame to `username`, dropping the child if the write fails.
pub fn send_to(&mut self, username: &str, message: &Message) {
let failed = match self.links.get_mut(username) {
Some(stream) => stream.write_all(&message.get_buffer()).is_err(),
None => return,
};
if failed {
self.remove(username);
}
}
/// Send one frame to every child, dropping those whose write fails: a
/// child that has gone away must not keep its slot.
pub fn broadcast(&mut self, message: &Message) {
let bytes = message.get_buffer();
let mut dead = Vec::new();
for (username, stream) in &mut self.links {
if stream.write_all(&bytes).is_err() {
dead.push(username.clone());
}
}
for username in dead {
self.remove(&username);
}
}
/// Tell `username` where our branch sits — what a child needs before it
/// can report its own place to the server.
pub fn send_stance_to(&mut self, username: &str, root: &str, level: u32) {
trace!(
"[distributed] telling {} we sit at {}/{}",
username, root, level
);
self.send_to(username, &distributed::build_branch_root(root));
self.send_to(
username,
&distributed::build_branch_level(
i32::try_from(level).unwrap_or(i32::MAX),
),
);
}
/// Tell every child our branch moved.
pub fn broadcast_stance(&mut self, root: &str, level: u32) {
self.broadcast(&distributed::build_branch_root(root));
self.broadcast(&distributed::build_branch_level(
i32::try_from(level).unwrap_or(i32::MAX),
));
}
/// Pass a search down the tree.
pub fn broadcast_search(
&mut self,
username: &str,
token: u32,
query: &str,
) {
if self.is_empty() {
return;
}
self.broadcast(&distributed::build_search(username, token, query));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
use std::net::{TcpListener, TcpStream};
/// A connected socket pair: the child's end and ours.
fn pair() -> (TcpStream, TcpStream) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
let ours = TcpStream::connect(addr).expect("connect");
let (theirs, _) = listener.accept().expect("accept");
(theirs, ours)
}
/// A registry that serves children and has something to relay.
fn serving() -> Children {
let mut children = Children::new(true);
children.set_fed(true);
children
}
fn read_frames(stream: &mut TcpStream, bytes: usize) -> Vec<u8> {
stream
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.expect("timeout");
let mut buf = vec![0u8; bytes];
stream.read_exact(&mut buf).expect("read");
buf
}
#[test]
fn a_client_that_does_not_accept_children_takes_none() {
let (_child, ours) = pair();
let mut children = Children::new(false);
children.set_fed(true);
assert!(!children.accept("alice", ours));
assert!(children.is_empty());
assert!(!children.has_room());
}
#[test]
fn a_client_nothing_feeds_takes_no_children() {
// Nicotine+ refuses children while it has neither a parent nor the
// server relaying searches: a child would hang from a branch that
// receives nothing.
let (_child, ours) = pair();
let mut children = Children::new(true);
assert!(!children.has_room(), "unfed means no room");
assert!(!children.accept("alice", ours));
children.set_fed(true);
let (_second, again) = pair();
assert!(children.accept("alice", again));
}
#[test]
fn children_are_let_go_when_the_stream_dries_up() {
let mut children = serving();
let (_child, ours) = pair();
children.accept("alice", ours);
children.set_fed(false);
children.drop_all();
assert!(children.is_empty());
assert!(!children.has_room());
}
#[test]
fn children_are_taken_up_to_the_cap() {
let mut children = serving();
let mut ends = Vec::new();
for i in 0..MAX_CHILDREN {
let (child, ours) = pair();
ends.push(child);
assert!(children.accept(&format!("user{i}"), ours));
}
assert_eq!(ends.len(), MAX_CHILDREN, "every link is still held");
assert!(!children.has_room(), "a full parent has no room");
let (_late, ours) = pair();
assert!(!children.accept("one_too_many", ours));
assert_eq!(children.len(), MAX_CHILDREN);
}
#[test]
fn a_second_link_from_a_child_we_carry_is_refused() {
// The link we hold is the live one; dropping it for an unproven
// second would cost that child its search stream.
let mut children = serving();
let (_first, ours) = pair();
assert!(children.accept("alice", ours));
let (_second, again) = pair();
assert!(!children.accept("alice", again));
assert_eq!(children.usernames(), vec!["alice".to_string()]);
}
#[test]
fn the_child_limit_follows_the_servers_figures() {
// Nicotine+: speed / ratio / 100, capped, and none at all below the
// minimum speed the server set.
assert_eq!(limit_from_speed(Some(50_000), 1_024, 50), Some(10));
assert_eq!(limit_from_speed(Some(10_000), 1_024, 50), Some(2));
assert_eq!(
limit_from_speed(Some(500), 1_024, 50),
Some(0),
"too slow to parent"
);
assert_eq!(
limit_from_speed(Some(10_000), 1_024, 0),
None,
"a server that never announced a ratio does not decide the limit"
);
assert_eq!(
limit_from_speed(None, 1_024, 50),
None,
"an unreported speed leaves the caller's own cap standing"
);
}
#[test]
fn a_speed_the_server_has_not_reported_leaves_the_cap_alone() {
let mut children = serving();
children.set_limits(None, 1_024, 50);
assert!(children.has_room(), "an unreported speed leaves us open");
// The server's figures win: at this speed the formula allows two.
children.set_limits(Some(10_000), 1_024, 50);
let mut ends = Vec::new();
for i in 0..2 {
let (child, ours) = pair();
ends.push(child);
assert!(children.accept(&format!("user{i}"), ours));
}
assert!(!children.has_room(), "two is the limit at this speed");
children.set_limits(Some(100), 1_024, 50);
assert!(!children.has_room(), "a slow client carries none");
children.set_limits(Some(100), 1_024, 0);
assert!(
children.has_room(),
"a server with no figures leaves our own cap standing"
);
assert_eq!(ends.len(), 2);
}
#[test]
fn a_search_reaches_every_child() {
let mut children = serving();
let (mut first, ours) = pair();
let (mut second, theirs) = pair();
children.accept("alice", ours);
children.accept("bob", theirs);
children.broadcast_search("seeker", 42, "jazz");
let expected =
distributed::build_search("seeker", 42, "jazz").get_buffer();
assert_eq!(read_frames(&mut first, expected.len()), expected);
assert_eq!(read_frames(&mut second, expected.len()), expected);
}
#[test]
fn a_child_that_hung_up_loses_its_slot() {
let mut children = serving();
let (child, ours) = pair();
children.accept("alice", ours);
drop(child);
// Writes keep landing in the send buffer until the peer's RST has
// crossed the loopback, which under load takes real wall-clock time.
for _ in 0..100 {
children.broadcast_search("seeker", 1, "q");
if children.is_empty() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(children.is_empty(), "a dead child is dropped");
}
#[test]
fn a_child_is_told_where_our_branch_sits() {
let mut children = serving();
let (mut child, ours) = pair();
children.accept("alice", ours);
children.send_stance_to("alice", "rooty", 3);
let mut expected = distributed::build_branch_root("rooty").get_buffer();
expected.extend(distributed::build_branch_level(3).get_buffer());
assert_eq!(read_frames(&mut child, expected.len()), expected);
}
}
+427
View File
@@ -0,0 +1,427 @@
use super::{
Arc, AtomicBool, Client, ClientContext, ClientOperation, ConnectionType,
DownloadPeer, Listen, Peer, PeerRegistry, Receiver, Result, RwLock,
RwLockExt, Sender, ServerActor, ServerMessage, Shares, SoulseekRs,
TcpStream, error, info, mpsc, thread, trace, warn,
};
use std::sync::atomic::Ordering;
use std::sync::mpsc::RecvTimeoutError;
use std::time::{Duration, Instant};
const LISTENER_RELEASE_TIMEOUT: Duration = Duration::from_secs(2);
const LOGIN_POLL_INTERVAL: Duration = Duration::from_millis(100);
/// Ceiling on the wait for a login verdict. Generous enough for a slow server
/// and a retrying connect, short enough that an unattended caller ends.
const LOGIN_RESPONSE_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(45);
/// Soft open-file limit below which a session should expect "too many open
/// files": every peer holds a socket, and one busy search talks to hundreds
/// of them.
const COMFORTABLE_OPEN_FILES: u64 = 1024;
/// Say up front when the process cannot hold a busy session's sockets, so an
/// operator reads a warning with a number in it instead of an EMFILE storm an
/// hour later. The embedding process owns its rlimits; the library only looks.
#[cfg(unix)]
fn warn_if_open_file_limit_is_low() {
// SAFETY: getrlimit only fills the struct it is handed.
let limit = unsafe {
let mut limit = std::mem::zeroed::<libc::rlimit>();
if libc::getrlimit(libc::RLIMIT_NOFILE, &raw mut limit) != 0 {
return;
}
limit.rlim_cur
};
if limit < COMFORTABLE_OPEN_FILES {
warn!(
"only {limit} open files allowed; a busy session wants \
{COMFORTABLE_OPEN_FILES} or more — expect \"too many open \
files\" under load"
);
}
}
#[cfg(not(unix))]
fn warn_if_open_file_limit_is_low() {}
impl Client {
pub fn connect(&mut self) -> Result<()> {
warn_if_open_file_limit_is_low();
let (sender, message_reader): (
Sender<ClientOperation>,
Receiver<ClientOperation>,
) = mpsc::channel();
let mut ctx = self.context.write_safe()?;
let peer_registry = PeerRegistry::with_capacity(
ctx.actor_system.clone(),
sender.clone(),
self.username.clone(),
ctx.max_peers.clone(),
);
ctx.peer_registry = Some(peer_registry);
ctx.operations = Some(sender.clone());
let listen_sender = sender.clone();
// Scan the shared directories once into the read-only index, and
// report the real folder/file counts to the server on login.
let roots: Vec<std::path::PathBuf> = self
.shared_directories
.iter()
.filter(|dir| !dir.trim().is_empty())
.map(std::path::PathBuf::from)
.collect();
let shares = if roots.is_empty() {
Arc::new(Shares::empty())
} else {
let scanned = Shares::scan_many(&roots);
info!(
"Sharing {} files in {} folders from {} directories",
scanned.file_count(),
scanned.folder_count(),
roots.len()
);
Arc::new(scanned)
};
let shared_folder_count = shares.folder_count();
let shared_file_count = shares.file_count();
ctx.shares = shares;
ctx.shared_directories.clone_from(&self.shared_directories);
if self.cancel.is_cancelled() {
return Err(SoulseekRs::NotConnected);
}
// Bind before logging in: the port we advertise has to be the port we
// hold, and a bind that fails outright is the caller's to see rather
// than a panic on a thread nobody is watching.
let listener = if self.enable_listen {
let listener = Listen::bind(self.listen_port)?;
self.bound_port = Some(listener.local_addr()?.port());
Some(listener)
} else {
None
};
self.listener_stopped = Arc::new(AtomicBool::new(false));
let mut server_actor = ServerActor::new(
self.address.clone(),
sender,
self.bound_port.unwrap_or(self.listen_port),
self.enable_listen,
shared_folder_count,
shared_file_count,
);
server_actor.set_session_watch(self.session.clone());
server_actor.set_cancel(self.cancel.clone());
self.server_handle = Some(ctx.actor_system.spawn_with_handle(
server_actor,
|actor, handle| {
actor.set_self_handle(handle);
},
));
if let Some(listener) = listener {
let client_sender = listen_sender;
let context = self.context.clone();
let own_username = self.username.clone();
let stopped = self.listener_stopped.clone();
let released = Arc::new(AtomicBool::new(false));
self.listener_released = released.clone();
thread::spawn(move || {
Listen::serve(
&listener,
client_sender,
context,
own_username,
stopped,
);
drop(listener);
released.store(true, Ordering::Relaxed);
});
}
Self::listen_to_client_operations(
message_reader,
self.context.clone(),
self.username.clone(),
);
Ok(())
}
/// Close the server connection and every peer connection.
///
/// The server sees the socket close and marks us offline, which is what
/// releases the username: a client that simply goes out of scope without
/// this leaves a session the server still believes in, so mail addressed
/// to us is delivered to a connection nobody is reading and later logins
/// collide with our own ghost.
///
/// Called automatically when the client is dropped. The peer listener's
/// bound port, if one was opened, is free again when this returns, unless
/// the listener cannot be woken within two seconds.
pub fn disconnect(&mut self) {
if let Some(handle) = self.server_handle.take() {
let _ = handle.stop();
}
let registry = match self.context.write_safe() {
Ok(mut ctx) => {
// Dropping our end of the operations channel is what lets its
// thread finish once the peers and the listener have gone.
ctx.operations = None;
ctx.peer_registry.clone()
}
Err(_) => None,
};
if let Some(registry) = registry {
registry.stop_all();
}
// The listener thread is parked in accept(), so it only notices the
// flag when a connection arrives: dial it once ourselves. Without
// this the port stays bound for the life of the process, and the next
// client falls back to an ephemeral port nobody was told about.
self.listener_stopped.store(true, Ordering::Relaxed);
if let Some(port) = self.bound_port.take()
&& TcpStream::connect(("127.0.0.1", port)).is_ok()
{
let deadline = Instant::now() + LISTENER_RELEASE_TIMEOUT;
while !self.listener_released.load(Ordering::Relaxed)
&& Instant::now() < deadline
{
thread::sleep(Duration::from_millis(5));
}
}
}
/// Log in and wait for the server's verdict.
///
/// The wait is bounded: the actor only answers once it has a working
/// connection, so a server that never completes the TCP handshake would
/// otherwise block the caller forever. A caller that hits
/// [`SoulseekRs::Timeout`] can retry or give up, but it always regains
/// control.
pub fn login(&self) -> Result<bool> {
info!("Logging in as {}", self.username);
let Some(handle) = &self.server_handle else {
return Err(SoulseekRs::NotConnected);
};
if self.cancel.is_cancelled() {
return Err(SoulseekRs::NotConnected);
}
let (tx, rx) = std::sync::mpsc::channel();
let _ = handle.send(ServerMessage::Login {
username: self.username.clone(),
password: self.password.clone(),
version: self.version,
response: tx,
});
let deadline = Instant::now() + LOGIN_RESPONSE_TIMEOUT;
loop {
match rx.recv_timeout(LOGIN_POLL_INTERVAL) {
Ok(result) => return result,
Err(RecvTimeoutError::Timeout)
if self.cancel.is_cancelled() =>
{
return Err(SoulseekRs::NotConnected);
}
Err(RecvTimeoutError::Timeout) if Instant::now() < deadline => {
}
Err(_) => return Err(SoulseekRs::Timeout),
}
}
}
/// Ask the server for a peer's address and open a direct control
/// connection to it. Downloads queued for that peer are sent automatically
/// once the connection is established.
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn connect_peer(&self, username: &str) -> Result<()> {
let handle = self
.server_handle
.as_ref()
.ok_or(SoulseekRs::NotConnected)?;
handle
.send(ServerMessage::GetPeerAddress(username.to_string()))
.map_err(|_| SoulseekRs::NotConnected)?;
Ok(())
}
pub fn set_max_peers(&self, max_peers: usize) {
if let Ok(ctx) = self.context.read_safe() {
ctx.max_peers
.store(max_peers.max(1), std::sync::atomic::Ordering::Relaxed);
}
}
#[allow(dead_code)]
pub fn remove_peer(&self, username: &str) {
let context = match self.context.read_safe() {
Ok(c) => c,
Err(e) => {
error!("[client] remove_peer: {}", e);
return;
}
};
if let Some(ref registry) = context.peer_registry
&& let Some(handle) = registry.remove_peer(username)
{
let _ = handle.stop();
}
}
pub(crate) fn register_peer_or_fail(
client_context: &Arc<RwLock<ClientContext>>,
peer: Peer,
stream: Option<TcpStream>,
reader: Option<crate::message::MessageReader>,
) -> bool {
let username = peer.username.clone();
let registered = {
let context = match client_context.read_safe() {
Ok(c) => c,
Err(e) => {
error!("[client] register peer read: {}", e);
return false;
}
};
if let Some(ref registry) = context.peer_registry {
let protected = context.protected_peers();
match registry
.register_peer_protected(peer, stream, reader, &protected)
{
Ok(_) => true,
Err(e) => {
warn!(
"[client] refusing peer connection for {:?}: {:?}",
username, e
);
false
}
}
} else {
trace!("PeerRegistry not initialized");
false
}
};
if !registered {
Self::fail_queued_downloads(client_context, &username);
}
registered
}
/// Tell the server we could not reach the peer it asked us to connect to,
/// quoting the token that peer is waiting on.
pub(crate) fn report_cant_connect(
client_context: &Arc<RwLock<ClientContext>>,
token: u32,
username: &str,
) {
let sender = match client_context.read_safe() {
Ok(ctx) => ctx.server_sender.clone(),
Err(_) => return,
};
let Some(sender) = sender else { return };
let message =
crate::message::server::MessageFactory::build_cant_connect_to_peer(
token, username,
);
let _ = sender.send(ServerMessage::SendMessage(message));
}
pub(crate) fn connect_to_peer(
peer: Peer,
client_context: Arc<RwLock<ClientContext>>,
own_username: String,
stream: Option<TcpStream>,
) {
let peer_clone = peer.clone();
trace!(
"[client] connecting to {}, with connection_type: {}, and token {:?}",
peer.username, peer.connection_type, peer.token
);
match peer.connection_type {
ConnectionType::P => {
Self::register_peer_or_fail(
&client_context,
peer_clone,
stream,
None,
);
}
ConnectionType::F => {
trace!(
"[client] downloading from: {}, {:?}",
peer.username, peer.token
);
let Some(token) = peer.token else {
error!(
"[client] F connection from {} without token",
peer.username
);
return;
};
let download_peer = DownloadPeer::new(
peer.username,
peer.host,
peer.port,
token,
false,
own_username,
);
let brokered = peer_clone.brokered;
let username = peer_clone.username;
match download_peer.download_file(
client_context.clone(),
None,
None,
) {
Ok((download, filename)) => {
trace!(
"[client] downloaded {} bytes {:?} ",
filename, download.size
);
}
Err(e) => {
trace!("[client] failed to download: {}", e);
// A file connection the server asked us to make and
// we could not complete owes that peer a
// CantConnectToPeer, exactly as a control connection
// does: it is waiting on this token.
if brokered {
Self::report_cant_connect(
&client_context,
token,
&username,
);
}
}
}
}
ConnectionType::D => {
// ponytail: we never dial out to adopt a child — children
// reach us through the listener. Tell the peer rather than
// leave it waiting; wire the outbound half if firewalled
// children turn out to matter.
if let (true, Some(token)) = (peer.brokered, peer.token) {
Self::report_cant_connect(
&client_context,
token,
&peer.username,
);
}
}
}
}
}
+804
View File
@@ -0,0 +1,804 @@
//! What the client's shared context does, as opposed to what it holds.
//!
//! A child module rather than a sibling: the state itself stays declared in
//! `client`, so these methods keep reaching its private fields without any
//! of them having to be widened for the move.
use super::{
ActorSystem, Arc, AtomicBool, AtomicUsize, BROKER_CONNECT_TIMEOUT,
BROWSE_PROTECT_WINDOW, ClientContext, DEFAULT_MAX_PEERS,
DEFAULT_UPLOAD_SLOTS, DownloadStatus, DownloadStore, HashMap, HashSet,
Instant, PENDING_PEER_TTL, Recommendation, RoomEvent, RoomInfo, RoomTicker,
RoomUserStats, SharedDirectory, Shares, SimilarUser, UserInfo,
UserInterests, UserMessage, UserPresence, UserStats, UserStatus, children,
distributed, sorted_unique,
};
impl ClientContext {
/// A context for the client that logs in as `username`.
#[must_use]
pub fn for_user(username: &str) -> Self {
Self {
leaf: distributed::Leaf::new(username),
..Self::new()
}
}
#[must_use]
pub fn new() -> Self {
let actor_system = Arc::new(ActorSystem::new());
Self {
peer_registry: None,
server_sender: None,
operations: None,
leaf: distributed::Leaf::new(""),
children: children::Children::default(),
announced_accept_children: None,
own_average_speed: None,
parent_min_speed: 0,
parent_speed_ratio: 0,
searches: HashMap::new(),
private_messages: Vec::new(),
pending_connect_tokens: HashMap::new(),
max_peers: Arc::new(AtomicUsize::new(DEFAULT_MAX_PEERS)),
download_speed_limit: Arc::default(),
shares: Arc::new(Shares::empty()),
shared_directories: Vec::new(),
peer_addresses: HashMap::new(),
pending_peer_messages: HashMap::new(),
uploads: HashMap::new(),
active_uploads: HashMap::new(),
pending_serves: HashMap::new(),
brokered_uploads: HashMap::new(),
browse_results: HashMap::new(),
folder_contents: HashMap::new(),
peer_infos: HashMap::new(),
pending_browses: HashMap::new(),
room_list: Vec::new(),
room_events: Vec::new(),
room_members: HashMap::new(),
room_member_stats: HashMap::new(),
room_tickers: HashMap::new(),
private_room_members: HashMap::new(),
private_room_operators: HashMap::new(),
recommendations: None,
global_recommendations: None,
item_recommendations: HashMap::new(),
similar_users: Vec::new(),
item_similar_users: HashMap::new(),
user_interests: HashMap::new(),
own_interests: UserInterests::default(),
user_info: HashMap::new(),
watched_users: HashSet::new(),
wishlist_interval: None,
privileged_users: HashSet::new(),
own_privileges: None,
excluded_search_phrases: Vec::new(),
upload_queue: Vec::new(),
upload_seq: 0,
upload_slots: DEFAULT_UPLOAD_SLOTS,
last_upload_speed: 0,
upload_events: Vec::new(),
downloads: DownloadStore::new(),
actor_system,
}
}
/// Apply a chat-room event: keep the room-list snapshot and the per-room
/// rosters current, then queue the event for the client/UI to drain.
pub fn apply_room_event(&mut self, event: RoomEvent) {
match &event {
RoomEvent::List(rooms) => self.room_list.clone_from(rooms),
RoomEvent::Joined { room, users } => {
let mut members = users.clone();
members.sort();
members.dedup();
self.room_members.insert(room.clone(), members);
}
RoomEvent::Left { room } => {
self.room_members.remove(room);
// A board for a room we are not in is stale; the server sends
// the whole board again on the next join.
self.room_tickers.remove(room);
}
// Only a roster the server has sent in full is kept current: the
// joiner hears of their own arrival before the member list.
RoomEvent::UserJoined { room, username } => {
if let Some(members) = self.room_members.get_mut(room)
&& let Err(at) = members.binary_search(username)
{
members.insert(at, username.clone());
}
}
RoomEvent::UserLeft { room, username } => {
if let Some(members) = self.room_members.get_mut(room)
&& let Ok(at) = members.binary_search(username)
{
members.remove(at);
}
}
RoomEvent::Tickers { room, tickers } => {
self.room_tickers.insert(room.clone(), tickers.clone());
}
RoomEvent::TickerAdded {
room,
username,
ticker,
} => {
// A user has at most one ticker: an added one replaces theirs
// rather than stacking, which is how the server treats it.
let board = self.room_tickers.entry(room.clone()).or_default();
board.retain(|t| &t.username != username);
board.push(RoomTicker {
username: username.clone(),
ticker: ticker.clone(),
});
}
RoomEvent::TickerRemoved { room, username } => {
if let Some(board) = self.room_tickers.get_mut(room) {
board.retain(|t| &t.username != username);
}
}
RoomEvent::PrivateMembers { room, users } => {
self.private_room_members
.insert(room.clone(), sorted_unique(users));
}
RoomEvent::PrivateOperators { room, users } => {
self.private_room_operators
.insert(room.clone(), sorted_unique(users));
}
RoomEvent::PrivateRosterChanged {
room,
username,
members,
added,
} => {
let roster = if *members {
self.private_room_members.entry(room.clone()).or_default()
} else {
self.private_room_operators.entry(room.clone()).or_default()
};
match (added, roster.binary_search(username)) {
(true, Err(at)) => roster.insert(at, username.clone()),
(false, Ok(at)) => {
roster.remove(at);
}
_ => {}
}
}
RoomEvent::OwnStandingChanged {
room,
members,
granted,
} => {
// Losing membership takes both rosters with it: a room we
// cannot enter has none worth showing. Losing operatorship
// leaves us a member who still sees them, and the server
// narrates that roster change separately (code 144).
if !granted && *members {
self.private_room_members.remove(room);
self.private_room_operators.remove(room);
}
}
RoomEvent::Message { .. }
| RoomEvent::GlobalMessage { .. }
| RoomEvent::CantCreate { .. } => {}
}
self.room_events.push(event);
}
/// Remember an interest of our own so it can be sent again next login.
/// Interests are matched by the server case-insensitively, and Nicotine+
/// lowercases them before sending; do the same so two spellings of one
/// interest cannot both be held.
/// Returns the stored spelling, or `None` for an empty item — which is
/// no interest, and must not be sent to the server either.
pub fn add_own_interest(
&mut self,
item: &str,
liked: bool,
) -> Option<String> {
let item = item.trim().to_lowercase();
if item.is_empty() {
return None;
}
let list = if liked {
&mut self.own_interests.likes
} else {
&mut self.own_interests.hates
};
if !list.contains(&item) {
list.push(item.clone());
}
Some(item)
}
/// Forget one of our own interests. `None` for an empty item, as above.
pub fn remove_own_interest(
&mut self,
item: &str,
liked: bool,
) -> Option<String> {
let item = item.trim().to_lowercase();
if item.is_empty() {
return None;
}
let list = if liked {
&mut self.own_interests.likes
} else {
&mut self.own_interests.hates
};
list.retain(|held| held != &item);
Some(item)
}
/// What we like and hate, as last set.
#[must_use]
pub fn own_interests(&self) -> UserInterests {
self.own_interests.clone()
}
/// Take the `AcceptChildren` answer to send, or `None` when the server
/// already has this one.
pub fn accept_children_change(&mut self) -> Option<bool> {
let has_room = self.children.has_room();
(self.announced_accept_children != Some(has_room)).then(|| {
self.announced_accept_children = Some(has_room);
has_room
})
}
/// The upload speed the server records for us, if it has said.
#[must_use]
pub const fn own_average_speed(&self) -> Option<u32> {
self.own_average_speed
}
/// Record what the server says our own upload speed is, and re-derive the
/// child limit from it.
pub fn set_own_average_speed(&mut self, speed: u32) {
self.own_average_speed = Some(speed);
self.apply_child_limits();
}
/// Record the server's parent figures (codes 83 and 84).
pub fn set_parent_min_speed(&mut self, speed: u32) {
self.parent_min_speed = speed;
self.apply_child_limits();
}
pub fn set_parent_speed_ratio(&mut self, ratio: u32) {
self.parent_speed_ratio = ratio;
self.apply_child_limits();
}
fn apply_child_limits(&mut self) {
self.children.set_limits(
self.own_average_speed,
self.parent_min_speed,
self.parent_speed_ratio,
);
}
/// Whether something is feeding us the distributed search stream: a
/// parent of our own, or the server relaying searches to us. Children may
/// only hang from a client that has one.
pub fn set_fed_by(&mut self, fed: bool) {
self.children.set_fed(fed);
if !fed {
self.children.drop_all();
}
}
/// Record what `username` says about itself.
pub fn store_peer_info(
&mut self,
username: String,
info: crate::message::peer::PeerInfo,
) {
self.peer_infos.insert(username, info);
}
/// What `username` last said about itself, if anything.
#[must_use]
pub fn peer_info(
&self,
username: &str,
) -> Option<crate::message::peer::PeerInfo> {
self.peer_infos.get(username).cloned()
}
/// Drop what we hold about `username`, so a poll after a fresh request
/// cannot report the previous answer.
pub fn invalidate_peer_info(&mut self, username: &str) {
self.peer_infos.remove(username);
}
/// Record one folder's listing from `username`.
pub fn store_folder_contents(
&mut self,
username: String,
folder: String,
directories: Vec<SharedDirectory>,
) {
self.folder_contents.insert((username, folder), directories);
}
/// Remove and return the listing of `folder` from `username`, if it has
/// arrived.
pub fn take_folder_contents(
&mut self,
username: &str,
folder: &str,
) -> Option<Vec<SharedDirectory>> {
self.folder_contents
.remove(&(username.to_string(), folder.to_string()))
}
/// Who may enter the private room `room`, as last reported.
#[must_use]
pub fn private_room_members(&self, room: &str) -> Vec<String> {
self.private_room_members
.get(room)
.cloned()
.unwrap_or_default()
}
/// Who runs the private room `room`, as last reported.
#[must_use]
pub fn private_room_operators(&self, room: &str) -> Vec<String> {
self.private_room_operators
.get(room)
.cloned()
.unwrap_or_default()
}
/// The private rooms we belong to, in a stable order.
#[must_use]
pub fn private_rooms(&self) -> Vec<String> {
let mut rooms: Vec<String> =
self.private_room_members.keys().cloned().collect();
rooms.sort();
rooms
}
/// The ticker board of `room` as last reported by the server.
#[must_use]
pub fn room_tickers(&self, room: &str) -> Vec<RoomTicker> {
self.room_tickers.get(room).cloned().unwrap_or_default()
}
/// Record a recommendations reply; `global` selects the server-wide set.
pub fn apply_recommendations(
&mut self,
global: bool,
recommended: Vec<Recommendation>,
unrecommended: Vec<Recommendation>,
) {
let slot = if global {
&mut self.global_recommendations
} else {
&mut self.recommendations
};
*slot = Some((recommended, unrecommended));
}
/// The last recommendations reply, or `None` until one arrives.
#[must_use]
pub fn recommendations(
&self,
global: bool,
) -> Option<(Vec<Recommendation>, Vec<Recommendation>)> {
if global {
self.global_recommendations.clone()
} else {
self.recommendations.clone()
}
}
pub fn apply_item_recommendations(
&mut self,
item: String,
recommendations: Vec<Recommendation>,
) {
self.item_recommendations.insert(item, recommendations);
}
#[must_use]
pub fn item_recommendations(&self, item: &str) -> Vec<Recommendation> {
self.item_recommendations
.get(item)
.cloned()
.unwrap_or_default()
}
pub fn apply_similar_users(&mut self, users: Vec<SimilarUser>) {
self.similar_users = users;
}
#[must_use]
pub fn similar_users(&self) -> Vec<SimilarUser> {
self.similar_users.clone()
}
pub fn apply_item_similar_users(
&mut self,
item: String,
usernames: Vec<String>,
) {
self.item_similar_users.insert(item, usernames);
}
#[must_use]
pub fn item_similar_users(&self, item: &str) -> Vec<String> {
self.item_similar_users
.get(item)
.cloned()
.unwrap_or_default()
}
pub fn apply_user_interests(&mut self, interests: UserInterests) {
self.user_interests
.insert(interests.username.clone(), interests);
}
#[must_use]
pub fn user_interests(&self, username: &str) -> Option<UserInterests> {
self.user_interests.get(username).cloned()
}
/// Record the phrases the server excludes (code 160), lowercased here so
/// the per-file check is a plain `contains` on a lowercased path.
pub fn set_excluded_search_phrases(&mut self, phrases: Vec<String>) {
self.excluded_search_phrases =
phrases.iter().map(|p| p.to_lowercase()).collect();
}
/// The phrases the server refuses to search for.
#[must_use]
pub fn excluded_search_phrases(&self) -> Vec<String> {
self.excluded_search_phrases.clone()
}
/// Forget any interests cached for `username`, so a poll after a fresh
/// request cannot mistake the previous answer for this one.
pub fn invalidate_user_interests(&mut self, username: &str) {
self.user_interests.remove(username);
}
/// Record a `GetUserStatus` reply, merging it with any statistics already
/// received for that user.
pub fn apply_user_status(
&mut self,
username: String,
status: u32,
privileged: bool,
) {
self.user_info
.entry(username.clone())
.or_insert_with(|| UserInfo::pending(username))
.presence = Some(UserPresence {
status: UserStatus::from_code(status),
privileged,
});
}
/// Forget what we know about `username`, so the next poll reports the
/// answer to the request being made now rather than the previous one.
pub fn invalidate_user_info(&mut self, username: &str) {
self.user_info.remove(username);
}
/// Record a `GetUserStats` reply, merging it with any status already
/// received for that user.
pub fn apply_user_stats(
&mut self,
username: String,
average_speed: u32,
shared_files: u32,
shared_folders: u32,
) {
self.user_info
.entry(username.clone())
.or_insert_with(|| UserInfo::pending(username))
.stats = Some(UserStats {
average_speed,
shared_files,
shared_folders,
});
}
/// Record that we are now watching `username`.
pub fn add_watched_user(&mut self, username: &str) {
self.watched_users.insert(username.to_string());
}
/// Forget a watch, dropping any snapshot we held for that user so a later
/// re-watch reports a fresh answer rather than a stale one.
pub fn remove_watched_user(&mut self, username: &str) {
self.watched_users.remove(username);
self.user_info.remove(username);
}
/// Everyone we are currently watching, sorted by name.
#[must_use]
pub fn watched_users(&self) -> Vec<String> {
let mut users: Vec<String> =
self.watched_users.iter().cloned().collect();
users.sort();
users
}
/// Record the initial snapshot from a `WatchUser` reply. A username the
/// server does not know carries no stats and is dropped from the watch
/// list, since the server will never push anything for it.
pub fn apply_watched_user(
&mut self,
username: String,
exists: bool,
status: Option<u32>,
average_speed: Option<u32>,
shared_files: Option<u32>,
shared_folders: Option<u32>,
) {
if !exists {
self.watched_users.remove(&username);
return;
}
if let Some(status) = status {
let entry = self
.user_info
.entry(username.clone())
.or_insert_with(|| UserInfo::pending(username.clone()));
// The watch reply carries no privileged flag, so keep whatever a
// previous GetUserStatus told us rather than asserting `false`.
let privileged =
entry.presence.as_ref().is_some_and(|p| p.privileged);
entry.presence = Some(UserPresence {
status: UserStatus::from_code(status),
privileged,
});
}
if let (Some(average_speed), Some(shared_files), Some(shared_folders)) =
(average_speed, shared_files, shared_folders)
{
self.apply_user_stats(
username,
average_speed,
shared_files,
shared_folders,
);
}
}
/// What the server has said about `username` so far.
#[must_use]
pub fn user_info(&self, username: &str) -> Option<UserInfo> {
self.user_info.get(username).cloned()
}
/// Who is currently in `room`, sorted, or empty when we are not in it.
#[must_use]
pub fn room_members(&self, room: &str) -> Vec<String> {
self.room_members.get(room).cloned().unwrap_or_default()
}
/// Record the per-member statistics of a room we just joined, replacing
/// any previous snapshot for it.
pub fn apply_room_member_stats(
&mut self,
room: String,
stats: Vec<RoomUserStats>,
) {
self.room_member_stats.insert(room, stats);
}
/// What the server reported about the members of `room`, sorted by name,
/// or empty for a room we have not joined.
#[must_use]
pub fn room_member_stats(&self, room: &str) -> Vec<RoomUserStats> {
let mut stats = self
.room_member_stats
.get(room)
.cloned()
.unwrap_or_default();
stats.sort_by(|a, b| a.username.cmp(&b.username));
stats
}
/// The latest snapshot of the public chat-room list.
#[must_use]
pub fn room_list(&self) -> Vec<RoomInfo> {
self.room_list.clone()
}
/// Remove and return all chat-room events received since the last call.
#[must_use]
pub fn take_room_events(&mut self) -> Vec<RoomEvent> {
std::mem::take(&mut self.room_events)
}
/// Cache a peer's listen address learned from a GetPeerAddress response.
pub fn cache_peer_address(
&mut self,
username: &str,
host: String,
port: u32,
) {
self.peer_addresses
.insert(username.to_string(), (host, port));
}
/// The cached listen address for `username`, if known.
#[must_use]
pub fn peer_address(&self, username: &str) -> Option<(String, u32)> {
self.peer_addresses.get(username).cloned()
}
/// Queue a peer message to send once a control connection to `username` is up.
pub fn queue_peer_message(
&mut self,
username: &str,
message: crate::message::Message,
) {
let entry = self
.pending_peer_messages
.entry(username.to_string())
.or_insert_with(|| (Instant::now(), Vec::new()));
entry.0 = Instant::now();
entry.1.push(message);
}
/// Remove and return the messages queued for `username`.
pub fn take_peer_messages(
&mut self,
username: &str,
) -> Vec<crate::message::Message> {
self.pending_peer_messages
.remove(username)
.map(|(_, messages)| messages)
.unwrap_or_default()
}
/// Drop peer messages nobody could deliver within
/// [`PENDING_PEER_TTL`]: the searcher went offline, or never existed.
pub(crate) fn expire_pending_peer_messages(&mut self, now: Instant) {
self.pending_peer_messages.retain(|_, (since, _)| {
now.duration_since(*since) < PENDING_PEER_TTL
});
}
/// Store a shared-file listing received from browsing `username`.
pub fn store_browse_result(
&mut self,
username: String,
directories: Vec<SharedDirectory>,
) {
self.pending_browses.remove(&username);
self.browse_results.insert(username, directories);
}
pub(crate) fn mark_browse_pending(&mut self, username: &str) {
let now = Instant::now();
self.pending_browses.retain(|_, deadline| *deadline > now);
self.pending_browses
.insert(username.to_string(), now + BROWSE_PROTECT_WINDOW);
}
/// Remove and return the shared-file listing browsed from `username`.
pub fn take_browse_result(
&mut self,
username: &str,
) -> Option<Vec<SharedDirectory>> {
self.browse_results.remove(username)
}
/// Remember that a server-brokered connection to `username` is pending under
/// `token`; the peer will quote it back in a PierceFirewall.
pub fn add_pending_connect(&mut self, token: u32, username: String) {
self.pending_connect_tokens
.insert(token, (username, Instant::now() + BROKER_CONNECT_TIMEOUT));
}
/// Resolve and consume the peer expected for a brokered connection `token`.
pub fn take_pending_connect(&mut self, token: u32) -> Option<String> {
self.pending_connect_tokens
.remove(&token)
.map(|(username, _)| username)
}
/// Returns the claim flag the dialling route races against.
pub(crate) fn add_brokered_upload(
&mut self,
connect_token: u32,
token: u32,
real_path: std::path::PathBuf,
) -> Arc<AtomicBool> {
let claimed = Arc::new(AtomicBool::new(false));
self.brokered_uploads.insert(
connect_token,
super::BrokeredUpload {
token,
real_path,
deadline: Instant::now() + BROKER_CONNECT_TIMEOUT,
claimed: claimed.clone(),
},
);
claimed
}
pub(crate) fn take_brokered_upload(
&mut self,
connect_token: u32,
) -> Option<super::BrokeredUpload> {
self.brokered_uploads.remove(&connect_token)
}
/// Uploads whose downloader never connected back in time.
pub(crate) fn take_expired_brokered_uploads(
&mut self,
now: Instant,
) -> Vec<super::BrokeredUpload> {
let (expired, live) = std::mem::take(&mut self.brokered_uploads)
.into_iter()
.partition(|(_, upload)| now >= upload.deadline);
self.brokered_uploads = live;
expired.into_values().collect()
}
pub fn take_expired_connects(&mut self, now: Instant) -> Vec<String> {
let mut expired = Vec::new();
self.pending_connect_tokens
.retain(|_, (username, deadline)| {
if now >= *deadline {
expired.push(std::mem::take(username));
return false;
}
true
});
expired
}
pub(crate) fn protected_peers(&self) -> HashSet<String> {
let mut protected: HashSet<String> = self
.downloads
.list()
.iter()
.filter(|d| {
matches!(
d.status,
DownloadStatus::Queued
| DownloadStatus::InProgress { .. }
| DownloadStatus::Paused { .. }
)
})
.map(|d| d.username.clone())
.collect();
protected
.extend(self.active_uploads.values().map(|u| u.username.clone()));
protected.extend(self.uploads.values().map(|j| j.downloader.clone()));
protected
.extend(self.upload_queue.iter().map(|q| q.downloader.clone()));
protected.extend(self.pending_serves.keys().cloned());
protected.extend(self.pending_peer_messages.keys().cloned());
let now = Instant::now();
protected.extend(
self.pending_browses
.iter()
.filter(|(_, deadline)| **deadline > now)
.map(|(username, _)| username.clone()),
);
protected
}
/// Record a private message received from another user.
pub fn push_private_message(&mut self, message: UserMessage) {
self.private_messages.push(message);
}
/// Remove and return all buffered private messages.
pub fn take_private_messages(&mut self) -> Vec<UserMessage> {
std::mem::take(&mut self.private_messages)
}
}
+712
View File
@@ -0,0 +1,712 @@
//! Our place in the distributed search network, as a leaf: which parent we
//! hang from, which candidates we are still sounding out, and what to tell the
//! server when that changes.
use super::{Client, ClientOperation, Result, ServerMessage};
use crate::message::MessageReader;
use crate::message::distributed::{self, Distributed};
use crate::message::server::MessageFactory;
use crate::peer::ConnectionType;
use crate::utils::lock::RwLockExt;
use std::collections::HashMap;
use std::io::{self, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Sender;
use std::time::{Duration, Instant};
/// A candidate that has not answered the dial in this long is not a parent.
const LINK_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// How often an idle link looks at its cancel flag.
const CANCEL_POLL: Duration = Duration::from_secs(1);
/// A parent relays the network's whole search stream; one that says nothing
/// for this long is gone, and holding its slot would block every later
/// candidate.
const LINK_IDLE: Duration = Duration::from_mins(1);
/// Moves of our branch are told to the server at most this often; a parent
/// that flaps its level must not make us flood the server.
const MOVE_INTERVAL: Duration = Duration::from_secs(1);
/// A distributed frame is a search or a branch fact; anything bigger is not
/// one of ours.
const MAX_FRAME: usize = 64 * 1024;
/// The server offers at most ten parents at a time; more candidates than
/// that in flight is a server that keeps offering, and each one is a thread.
const MAX_CANDIDATES: usize = 10;
/// Searches admitted from the parent per second. Every search costs a scan
/// of everything we share, and the tree carries the whole network's stream.
// ponytail: a flat budget; a word index over the shares is the upgrade path.
const SEARCH_BUDGET: u32 = 50;
/// Where we sit in the tree, as told to the server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Branch {
pub root: String,
pub level: u32,
}
impl Branch {
#[must_use]
pub fn parentless(own: &str) -> Self {
Self::under(own, 0)
}
#[must_use]
pub fn under(root: &str, level: u32) -> Self {
Self {
root: root.to_string(),
level,
}
}
}
struct Candidate {
link: u64,
level: Option<u32>,
root: Option<String>,
cancel: Arc<AtomicBool>,
}
/// A candidate to dial: the link number that tags everything it sends, and
/// the flag that tells its thread to stop.
pub struct Dial {
pub link: u64,
pub username: String,
pub host: String,
pub port: u16,
pub cancel: Arc<AtomicBool>,
}
/// The leaf's view: at most one parent, adopted from the candidates the
/// server offered, the way Nicotine+ does it. Every method is a pure state
/// change so the rule is testable without a socket. Events carry the link
/// number they came from, so a stale thread cannot speak for a newer link
/// to the same user.
pub struct Leaf {
own: String,
parent: Option<String>,
branch: Branch,
candidates: HashMap<String, Candidate>,
next_link: u64,
searches: (Instant, u32),
/// When we last told the server we moved, and whether a move since then
/// is still waiting to be told.
moved: Option<Instant>,
move_pending: bool,
}
impl Leaf {
#[must_use]
pub fn new(own: &str) -> Self {
Self {
own: own.to_string(),
parent: None,
branch: Branch::parentless(own),
candidates: HashMap::new(),
next_link: 1,
searches: (Instant::now(), 0),
moved: None,
move_pending: false,
}
}
#[must_use]
pub fn branch(&self) -> Branch {
self.branch.clone()
}
/// Whether `link` is the live link to our parent.
#[must_use]
pub fn is_parent(&self, username: &str, link: u64) -> bool {
self.parent.as_deref() == Some(username)
&& self.has_link(username, link)
}
/// New candidates to dial: those we are not already sounding out, up to
/// the cap, and none at all while we hang from a parent.
pub fn consider(
&mut self,
candidates: Vec<(String, String, u16)>,
) -> Vec<Dial> {
if self.parent.is_some() {
return Vec::new();
}
let mut dials = Vec::new();
for (username, host, port) in candidates {
if self.candidates.contains_key(&username)
|| self.candidates.len() >= MAX_CANDIDATES
{
continue;
}
let link = self.next_link;
self.next_link += 1;
let cancel = Arc::new(AtomicBool::new(false));
self.candidates.insert(
username.clone(),
Candidate {
link,
level: None,
root: None,
cancel: cancel.clone(),
},
);
dials.push(Dial {
link,
username,
host,
port,
cancel,
});
}
dials
}
/// A candidate said how deep it sits. A branch root does not always say
/// so separately: level 0 is its own root. A negative level is nonsense
/// and leaves the candidate unadoptable. When it is the parent talking,
/// our own place moves with it and the new stance comes back.
pub fn branch_level(
&mut self,
username: &str,
link: u64,
level: i32,
) -> Option<Branch> {
if !self.has_link(username, link) {
return None;
}
let candidate = self.candidates.get_mut(username)?;
candidate.level = u32::try_from(level).ok();
if level == 0 {
candidate.root = Some(username.to_string());
}
self.moved_with_parent(username)
}
pub fn branch_root(
&mut self,
username: &str,
link: u64,
root: &str,
) -> Option<Branch> {
if !self.has_link(username, link) {
return None;
}
self.candidates.get_mut(username)?.root = Some(root.to_string());
self.moved_with_parent(username)
}
/// A search came down from `username`. The first one from a candidate
/// that has told us its place adopts it; the stance to announce comes back.
pub fn search_from(&mut self, username: &str, link: u64) -> Option<Branch> {
if self.parent.is_some() || !self.has_link(username, link) {
return None;
}
let candidate = self.candidates.get(username)?;
let (Some(level), Some(root)) =
(candidate.level, candidate.root.clone())
else {
return None;
};
let parent = self.candidates.remove(username)?;
self.cancel_candidates();
self.candidates.insert(username.to_string(), parent);
self.parent = Some(username.to_string());
self.branch = Branch::under(&root, level.saturating_add(1));
Some(self.branch.clone())
}
/// Whether to answer one more search from the parent now: at most
/// `SEARCH_BUDGET` a second, the rest dropped.
pub fn admit_search(&mut self, now: Instant) -> bool {
if now.duration_since(self.searches.0) >= Duration::from_secs(1) {
self.searches = (now, 0);
}
self.searches.1 += 1;
self.searches.1 <= SEARCH_BUDGET
}
/// The link to `username` closed. True when that was the parent, which
/// puts us back to parentless.
pub fn closed(&mut self, username: &str, link: u64) -> bool {
if !self.has_link(username, link) {
return false;
}
self.candidates.remove(username);
if self.parent.as_deref() != Some(username) {
return false;
}
self.parent = None;
self.branch = Branch::parentless(&self.own);
true
}
pub fn reset(&mut self) {
self.cancel_candidates();
self.parent = None;
self.branch = Branch::parentless(&self.own);
}
fn has_link(&self, username: &str, link: u64) -> bool {
self.candidates
.get(username)
.is_some_and(|c| c.link == link)
}
fn moved_with_parent(&mut self, username: &str) -> Option<Branch> {
if self.parent.as_deref() != Some(username) {
return None;
}
let candidate = self.candidates.get(username)?;
let branch = Branch::under(
candidate.root.as_deref()?,
candidate.level?.saturating_add(1),
);
if branch == self.branch {
return None;
}
self.branch = branch;
self.move_pending = true;
self.due_announcement(Instant::now())
}
/// The branch to tell the server about, if a move is waiting and the
/// last one was told at least [`MOVE_INTERVAL`] ago.
pub fn due_announcement(&mut self, now: Instant) -> Option<Branch> {
if !self.move_pending
|| self
.moved
.is_some_and(|at| now.duration_since(at) < MOVE_INTERVAL)
{
return None;
}
self.move_pending = false;
self.moved = Some(now);
Some(self.branch.clone())
}
fn cancel_candidates(&mut self) {
for candidate in self.candidates.values() {
candidate.cancel.store(true, Ordering::Relaxed);
}
self.candidates.clear();
}
}
/// Tell the server where we sit now.
pub fn announce(
server: Option<&Sender<ServerMessage>>,
branch: &Branch,
has_parent: bool,
accept_children: bool,
) {
let Some(server) = server else {
return;
};
for message in distributed::stance(
&branch.root,
branch.level,
has_parent,
accept_children,
) {
let _ = server.send(ServerMessage::SendMessage(message));
}
}
/// Tell the server where we sit, and tell our children too: a branch that
/// moved leaves every child reporting a stale place until they hear it.
pub fn announce_move(
ctx: &mut super::ClientContext,
branch: &Branch,
has_parent: bool,
) {
let server = ctx.server_sender.clone();
// What the server needs to know is whether it may offer us to another
// peer right now, not whether we serve children in principle. The stance
// carries it, so record it as told: a separate AcceptChildren right
// behind this one would say nothing new.
let has_room = ctx.children.has_room();
let _ = ctx.accept_children_change();
announce(server.as_ref(), branch, has_parent, has_room);
ctx.children.broadcast_stance(&branch.root, branch.level);
}
/// Dial a parent candidate on its own thread: a `D` PeerInit, then whatever it
/// sends comes back as operations until the link closes or is cancelled.
pub fn spawn_link(dial: Dial, own: String, ops: Sender<ClientOperation>) {
let closed = ClientOperation::ParentClosed {
parent: dial.username.clone(),
link: dial.link,
};
let thread_ops = ops.clone();
let spawned = std::thread::Builder::new()
.name("soulseek-parent".to_string())
.spawn(move || {
if let Err(e) = link(&dial, &own, &thread_ops)
&& !dial.cancel.load(Ordering::Relaxed)
{
crate::debug!(
"[distributed] link to {} ended: {}",
dial.username,
e
);
let _ = thread_ops.send(ClientOperation::ParentClosed {
parent: dial.username,
link: dial.link,
});
}
});
if spawned.is_err() {
let _ = ops.send(closed);
}
}
fn link(
dial: &Dial,
own: &str,
ops: &Sender<ClientOperation>,
) -> io::Result<()> {
let addr = (dial.host.as_str(), dial.port)
.to_socket_addrs()?
.next()
.ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "no address")
})?;
let mut stream = TcpStream::connect_timeout(&addr, LINK_CONNECT_TIMEOUT)?;
if dial.cancel.load(Ordering::Relaxed) {
return Ok(());
}
stream.set_read_timeout(Some(CANCEL_POLL))?;
stream.write_all(
&MessageFactory::build_peer_init_message(own, ConnectionType::D, 0)
.get_buffer(),
)?;
let mut reader = MessageReader::new();
let mut heard = Instant::now();
loop {
if dial.cancel.load(Ordering::Relaxed) {
return Ok(());
}
match reader.read_from_socket(&mut stream) {
Ok(()) => heard = Instant::now(),
Err(e)
if matches!(
e.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) =>
{
if heard.elapsed() > LINK_IDLE {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"silent parent",
));
}
continue;
}
Err(e) => return Err(e),
}
while let Some(mut frame) = reader.extract_message()? {
let Some(operation) = operation_of(dial, &mut frame) else {
continue;
};
if ops.send(operation).is_err() {
return Ok(());
}
}
if reader.buffer_len() > MAX_FRAME {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"oversized frame",
));
}
}
}
fn operation_of(
dial: &Dial,
frame: &mut crate::message::Message,
) -> Option<ClientOperation> {
let parent = dial.username.clone();
let link = dial.link;
Some(match distributed::parse(frame)? {
Distributed::BranchLevel(level) => ClientOperation::ParentBranchLevel {
parent,
link,
level,
},
Distributed::BranchRoot(root) => {
ClientOperation::ParentBranchRoot { parent, link, root }
}
Distributed::Search {
username,
token,
query,
} => ClientOperation::ParentSearch {
parent,
link,
username,
token,
query,
},
})
}
impl Client {
/// Offer parent candidates as the server does with PossibleParents:
/// `(username, host, port)`. The first candidate to send a search after
/// telling us its branch becomes our parent.
///
/// # Errors
/// Returns [`crate::SoulseekRs::NotConnected`] before [`Client::connect`].
pub fn consider_parents(
&self,
candidates: Vec<(String, String, u16)>,
) -> Result<()> {
let ops = self
.context
.read_safe()?
.operations
.clone()
.ok_or(crate::SoulseekRs::NotConnected)?;
ops.send(ClientOperation::PossibleParents(candidates))
.map_err(|_| crate::SoulseekRs::NotConnected)
}
/// Where we sit in the distributed search network: the branch root's name
/// and our level, which is ourselves at 0 while we have no parent.
#[must_use]
pub fn distributed_branch(&self) -> (String, u32) {
self.context.read_safe().map_or_else(
|_| (self.username.clone(), 0),
|ctx| {
let branch = ctx.leaf.branch();
(branch.root, branch.level)
},
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn leaf() -> Leaf {
Leaf::new("me")
}
fn candidate(name: &str) -> (String, String, u16) {
(name.to_string(), "127.0.0.1".to_string(), 2234)
}
/// Dial the candidates and return their link numbers by name.
fn dial(leaf: &mut Leaf, names: &[&str]) -> HashMap<String, Dial> {
leaf.consider(names.iter().map(|n| candidate(n)).collect())
.into_iter()
.map(|d| (d.username.clone(), d))
.collect()
}
#[test]
fn a_leaf_without_a_parent_is_its_own_root_at_level_zero() {
assert_eq!(leaf().branch(), Branch::parentless("me"));
}
#[test]
fn candidates_are_dialled_only_while_there_is_no_parent() {
let mut leaf = leaf();
let dials = dial(&mut leaf, &["alice", "bob"]);
assert_eq!(dials.len(), 2);
let alice = dials["alice"].link;
leaf.branch_level("alice", alice, 1);
leaf.branch_root("alice", alice, "rooty");
assert!(
leaf.search_from("alice", alice).is_some(),
"alice becomes the parent"
);
assert!(leaf.consider(vec![candidate("carol")]).is_empty());
}
#[test]
fn the_first_search_from_a_candidate_with_branch_info_adopts_it() {
let mut leaf = leaf();
let dials = dial(&mut leaf, &["alice", "bob"]);
let (alice, bob) = (dials["alice"].link, dials["bob"].link);
assert_eq!(leaf.search_from("alice", alice), None, "no branch yet");
leaf.branch_level("alice", alice, 3);
assert_eq!(leaf.search_from("alice", alice), None, "level, no root");
leaf.branch_root("alice", alice, "rooty");
assert_eq!(
leaf.search_from("alice", alice),
Some(Branch::under("rooty", 4)),
"level 3 parent puts us at level 4"
);
assert_eq!(leaf.branch(), Branch::under("rooty", 4));
assert!(leaf.is_parent("alice", alice));
assert_eq!(leaf.search_from("alice", alice), None, "already parent");
assert_eq!(leaf.search_from("bob", bob), None, "a loser is ignored");
assert!(
dials["bob"].cancel.load(Ordering::Relaxed),
"the losing link is told to stop"
);
assert!(!dials["alice"].cancel.load(Ordering::Relaxed));
}
#[test]
fn a_level_zero_candidate_is_its_own_root() {
let mut leaf = leaf();
let alice = dial(&mut leaf, &["alice"])["alice"].link;
leaf.branch_level("alice", alice, 0);
assert_eq!(
leaf.search_from("alice", alice),
Some(Branch::under("alice", 1))
);
}
#[test]
fn a_hostile_branch_level_neither_panics_nor_adopts() {
let mut leaf = leaf();
let alice = dial(&mut leaf, &["alice"])["alice"].link;
leaf.branch_root("alice", alice, "rooty");
leaf.branch_level("alice", alice, -1);
assert_eq!(leaf.search_from("alice", alice), None, "negative level");
leaf.branch_level("alice", alice, i32::MAX);
assert_eq!(
leaf.search_from("alice", alice),
Some(Branch::under("rooty", 1 << 31))
);
}
#[test]
fn the_parents_later_moves_move_us_too_but_are_told_once_a_second() {
let mut leaf = leaf();
let alice = dial(&mut leaf, &["alice"])["alice"].link;
leaf.branch_level("alice", alice, 0);
leaf.search_from("alice", alice);
assert_eq!(leaf.branch_level("alice", alice, 0), None, "unchanged");
assert_eq!(
leaf.branch_root("alice", alice, "grand"),
Some(Branch::under("grand", 1))
);
assert_eq!(
leaf.branch_level("alice", alice, 2),
None,
"a second move within the second waits"
);
assert_eq!(leaf.branch(), Branch::under("grand", 3), "but it took");
assert_eq!(leaf.due_announcement(Instant::now()), None);
assert_eq!(
leaf.due_announcement(Instant::now() + MOVE_INTERVAL),
Some(Branch::under("grand", 3))
);
assert_eq!(
leaf.due_announcement(Instant::now() + MOVE_INTERVAL),
None,
"told once"
);
}
#[test]
fn a_parent_that_becomes_a_root_is_the_root_from_then_on() {
let mut leaf = leaf();
let alice = dial(&mut leaf, &["alice"])["alice"].link;
leaf.branch_root("alice", alice, "grand");
leaf.branch_level("alice", alice, 5);
assert_eq!(
leaf.search_from("alice", alice),
Some(Branch::under("grand", 6))
);
assert_eq!(
leaf.branch_level("alice", alice, 0),
Some(Branch::under("alice", 1))
);
}
#[test]
fn a_stale_link_cannot_speak_for_a_newer_one() {
let mut leaf = leaf();
let old = dial(&mut leaf, &["alice"])["alice"].link;
assert!(!leaf.closed("alice", old + 7), "unknown link is ignored");
leaf.closed("alice", old);
let new = dial(&mut leaf, &["alice"])["alice"].link;
assert_ne!(old, new);
leaf.branch_level("alice", old, 0);
assert_eq!(leaf.search_from("alice", old), None, "old link ignored");
assert!(
!leaf.closed("alice", old),
"a late close leaves the new one"
);
leaf.branch_level("alice", new, 0);
assert!(leaf.search_from("alice", new).is_some());
}
#[test]
fn a_later_batch_keeps_the_candidates_in_flight_and_stays_under_the_cap() {
let mut leaf = leaf();
let first = dial(&mut leaf, &["alice"])["alice"].link;
let again = dial(&mut leaf, &["alice", "bob"]);
assert!(
!again.contains_key("alice"),
"alice is already being dialled"
);
assert!(again.contains_key("bob"));
assert!(leaf.has_link("alice", first));
let names: Vec<String> = (0..20).map(|i| format!("p{i}")).collect();
let more = leaf
.consider(names.iter().map(|n| candidate(n)).collect::<Vec<_>>());
assert_eq!(more.len(), MAX_CANDIDATES - 2);
}
#[test]
fn losing_the_parent_or_a_reset_returns_to_parentless() {
let mut leaf = leaf();
let dials = dial(&mut leaf, &["alice", "bob"]);
let alice = dials["alice"].link;
leaf.branch_level("alice", alice, 0);
leaf.search_from("alice", alice);
assert!(!leaf.closed("bob", dials["bob"].link), "not the parent");
assert!(leaf.closed("alice", alice));
assert_eq!(leaf.branch(), Branch::parentless("me"));
let dials = dial(&mut leaf, &["alice", "carol"]);
let alice = dials["alice"].link;
leaf.branch_level("alice", alice, 0);
leaf.search_from("alice", alice);
leaf.reset();
assert_eq!(leaf.branch(), Branch::parentless("me"));
assert!(dials["alice"].cancel.load(Ordering::Relaxed));
assert!(dials["carol"].cancel.load(Ordering::Relaxed));
assert!(!leaf.consider(vec![candidate("bob")]).is_empty());
}
#[test]
fn searches_beyond_the_budget_are_dropped_until_the_next_second() {
let mut leaf = leaf();
let now = Instant::now();
for _ in 0..SEARCH_BUDGET {
assert!(leaf.admit_search(now));
}
assert!(!leaf.admit_search(now));
assert!(leaf.admit_search(now + Duration::from_secs(1)));
}
#[test]
fn announcing_tells_the_server_the_root_the_level_and_that_we_have_a_parent()
{
let (tx, rx) = std::sync::mpsc::channel();
announce(Some(&tx), &Branch::under("rooty", 4), true, false);
let mut sent: Vec<crate::message::Message> = Vec::new();
while let Ok(ServerMessage::SendMessage(m)) = rx.try_recv() {
sent.push(m);
}
let codes: Vec<u32> = sent
.iter()
.map(|m| u32::from_le_bytes(m.get_data()[..4].try_into().unwrap()))
.collect();
assert_eq!(codes, vec![71, 127, 126, 100]);
assert_eq!(sent[0].get_data()[4], 0, "HaveNoParent is false");
let mut root = sent[1].clone();
root.set_pointer(4);
assert_eq!(root.read_string(), "rooty");
assert_eq!(&sent[2].get_data()[4..8], &4u32.to_le_bytes());
assert_eq!(sent[3].get_data()[4], 0, "AcceptChildren is false");
}
}
+241
View File
@@ -0,0 +1,241 @@
use super::{
Arc, Client, ClientContext, Download, DownloadMetadata, DownloadStatus,
Receiver, Result, RwLock, RwLockExt, Sender, error, info, mpsc,
next_download_token,
};
use crate::peer::part_path_of;
impl Client {
#[must_use]
pub fn get_all_downloads(&self) -> Vec<Download> {
self.context
.read_safe()
.map(|ctx| ctx.get_downloads().clone())
.unwrap_or_default()
}
pub fn set_download_speed_limit(&self, bytes_per_second: u64) {
if let Ok(ctx) = self.context.read_safe() {
ctx.download_speed_limit.set(bytes_per_second);
}
}
#[must_use]
pub fn pause_download(&self, username: &str, filename: &str) -> bool {
match self.context.write_safe() {
Ok(mut ctx) => ctx.downloads.pause_by_file(username, filename),
Err(e) => {
error!("[client] pause_download: {}", e);
false
}
}
}
#[must_use]
pub fn resume_download(&self, username: &str, filename: &str) -> bool {
match self.context.write_safe() {
Ok(mut ctx) => ctx.downloads.resume_by_file(username, filename),
Err(e) => {
error!("[client] resume_download: {}", e);
false
}
}
}
#[must_use]
pub fn cancel_download(&self, username: &str, filename: &str) -> bool {
let cancelled = match self.context.write_safe() {
Ok(mut ctx) => ctx.downloads.cancel_by_file(username, filename),
Err(e) => {
error!("[client] cancel_download: {}", e);
None
}
};
let Some(download) = cancelled else {
return false;
};
if let Some(part) = part_path_of(&download) {
let _ = std::fs::remove_file(part);
}
true
}
#[must_use]
pub fn remove_queued_download(
&self,
username: &str,
filename: &str,
) -> bool {
match self.context.write_safe() {
Ok(mut ctx) => {
ctx.downloads.remove_queued_by_file(username, filename)
}
Err(e) => {
error!("[client] remove_queued_download: {}", e);
false
}
}
}
/// Remove every download for `username`/`filename` regardless of status.
/// Useful before re-issuing [`Client::download`] for a failed download, so
/// the list does not accumulate dead entries for the same file.
///
/// Returns whether anything was removed.
#[must_use]
pub fn remove_download(&self, username: &str, filename: &str) -> bool {
match self.context.write_safe() {
Ok(mut ctx) => ctx.downloads.remove_by_file(username, filename),
Err(e) => {
error!("[client] remove_download: {}", e);
false
}
}
}
pub fn download(
&self,
filename: String,
username: String,
size: u64,
download_directory: String,
) -> Result<(Download, Receiver<DownloadStatus>)> {
self.download_with_metadata(
filename,
username,
size,
download_directory,
DownloadMetadata::default(),
)
}
pub fn download_with_metadata(
&self,
filename: String,
username: String,
size: u64,
download_directory: String,
metadata: DownloadMetadata,
) -> Result<(Download, Receiver<DownloadStatus>)> {
info!("[client] Downloading {} from {}", filename, username);
let token = next_download_token();
let (download_sender, download_receiver): (
Sender<DownloadStatus>,
Receiver<DownloadStatus>,
) = mpsc::channel();
let download = Download {
username: username.clone(),
filename,
token,
size,
download_directory,
status: DownloadStatus::Queued,
sender: download_sender,
queue_position: None,
metadata,
};
let mut context = self.context.write_safe()?;
context.add_download(download.clone());
// If we already have a control connection to this peer, queue the
// upload immediately. Otherwise open one directly (server GetPeerAddress
// → outbound PeerInit → PeerConnected → the queued upload is flushed).
let peer_registered = context
.peer_registry
.as_ref()
.is_some_and(|r| r.contains(&username));
let queued_now = peer_registered
&& context.peer_registry.as_ref().is_some_and(|r| {
r.queue_upload(&username, download.filename.clone()).is_ok()
});
drop(context);
let failed = if peer_registered {
!queued_now
} else {
// No existing connection: initiate one. Only a genuinely
// unconnected client (no server handle) fails outright here.
self.connect_peer(&username).is_err()
};
if failed {
let reason = if peer_registered {
"The user declined the download"
} else {
"Could not connect to the user"
};
let _ = download
.sender
.send(DownloadStatus::Failed(Some(reason.to_string())));
self.context.write_safe()?.update_download_with_status(
token,
DownloadStatus::Failed(Some(reason.to_string())),
);
}
Ok((download, download_receiver))
}
/// Fail every still-`Queued` download for `username`, both on the caller's
/// status channel (so a blocked `Receiver` unblocks) and in the store.
pub(crate) fn fail_queued_downloads(
client_context: &Arc<RwLock<ClientContext>>,
username: &str,
) {
let mut context = match client_context.write_safe() {
Ok(c) => c,
Err(e) => {
error!("[client] fail_queued_downloads write: {}", e);
return;
}
};
let doomed: Vec<(u32, Sender<DownloadStatus>)> = context
.get_downloads()
.iter()
.filter(|d| {
d.username == username
&& matches!(d.status, DownloadStatus::Queued)
})
.map(|d| (d.token, d.sender.clone()))
.collect();
for (token, sender) in doomed {
let reason = Some("The user went offline".to_string());
let _ = sender.send(DownloadStatus::Failed(reason.clone()));
context.update_download_with_status(
token,
DownloadStatus::Failed(reason),
);
}
}
}
#[cfg(test)]
mod tests {
use super::next_download_token;
use std::collections::HashSet;
// The download store is keyed by token and removes *every* entry matching
// one, so two downloads sharing a token means queueing one silently evicts
// the other — which is what fetching the same filename from two peers does
// if the token depends on the filename.
#[test]
fn download_tokens_are_unique_and_below_the_upload_range() {
let tokens: Vec<u32> =
(0..10_000).map(|_| next_download_token()).collect();
assert_eq!(
tokens.iter().collect::<HashSet<_>>().len(),
tokens.len(),
"every download must get its own token"
);
assert!(
tokens.iter().all(|t| *t < 0x8000_0000),
"download tokens must not reach into the upload range"
);
}
}
+897
View File
@@ -0,0 +1,897 @@
use crate::actor::ActorHandle;
use crate::actor::server_actor::{
PeerAddress, ServerActor, ServerMessage, UserMessage,
};
use crate::download_store::{DownloadStore, collect_failed_tokens};
use crate::types::{
ClientVersion, DownloadMetadata, DownloadStatus, Recommendation, RoomEvent,
RoomInfo, RoomTicker, RoomUserStats, SessionLoss, SessionWatch,
SimilarUser, UserInfo, UserInterests, UserPresence, UserStats, UserStatus,
};
use crate::utils::logger;
use crate::utils::rate_limit::RateLimit;
use crate::{
Transfer,
actor::{ActorSystem, peer_registry::PeerRegistry},
error::{Result, SoulseekRs},
message::peer::{FileEntry, SharedDirectory, build_file_search_response},
peer::{ConnectionType, DownloadPeer, Peer, PeerMessage, listen::Listen},
shares::Shares,
types::{Download, Search, SearchResult},
utils::lock::RwLockExt,
};
use std::{
collections::{HashMap, HashSet},
net::TcpStream,
sync::{
Arc, RwLock,
atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering},
mpsc::{self, Receiver, Sender},
},
thread::{self, sleep},
time::{Duration, Instant},
};
use upload_queue::QueuedUpload;
use crate::{debug, error, info, trace, warn};
const DEFAULT_LISTEN_PORT: u16 = 2234;
/// What to assume between wishlist searches until the server announces its own.
///
/// Twelve minutes is what Soulseek servers give an unprivileged account (code
/// 104); guessing lower only gets the searches dropped.
pub const DEFAULT_WISHLIST_INTERVAL: Duration = Duration::from_mins(12);
/// How many uploads run at once by default.
///
/// The cap is what makes the queue real — with unlimited slots there is nothing
/// for a privileged peer to jump — but it must not be the thing throttling a
/// modern connection. On Soulseek the per-transfer rate is usually set by the
/// *other* end, so concurrency is what fills an uplink: ten peers at a typical
/// few hundred KiB/s each is a few MiB/s, which a 50 Mbit uplink carries and a
/// pair of slots leaves mostly idle. Ten still divides a modest uplink into
/// shares big enough that no peer times out waiting.
///
/// Measured on the stress benchmark (64 waiting peers, loopback): two slots
/// took 24.2s, eight took 6.2s, thirty-two took 3.1s.
pub const DEFAULT_UPLOAD_SLOTS: usize = 10;
/// How long to wait for a server-brokered (firewalled) peer to connect back
/// before giving up and failing the download. Matches the direct-dial timeout.
const BROKER_CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
const DEFAULT_MAX_PEERS: usize = 512;
/// How long a message waits for a control connection to its peer.
const PENDING_PEER_TTL: Duration = Duration::from_mins(1);
const BROWSE_PROTECT_WINDOW: Duration = Duration::from_mins(5);
/// Source of non-zero correlation tokens for server-brokered connections.
static NEXT_CONNECT_TOKEN: AtomicU32 = AtomicU32::new(1);
fn next_connect_token() -> u32 {
NEXT_CONNECT_TOKEN.fetch_add(1, Ordering::Relaxed).max(1)
}
/// Upload tokens are minted in the high half of the space so they never collide
/// with download tokens, which are always < 2^31.
static NEXT_UPLOAD_TOKEN: AtomicU32 = AtomicU32::new(0x8000_0000);
fn next_upload_token() -> u32 {
NEXT_UPLOAD_TOKEN.fetch_add(1, Ordering::Relaxed)
}
/// Source of download tokens, kept in the low half of the space.
///
/// A counter, not a hash of the filename: the download store is keyed by token
/// and removes every entry matching one, so any two downloads sharing a token
/// destroy each other — and the same filename from two peers is an ordinary
/// thing to queue.
static NEXT_DOWNLOAD_TOKEN: AtomicU32 = AtomicU32::new(1);
fn next_download_token() -> u32 {
NEXT_DOWNLOAD_TOKEN.fetch_add(1, Ordering::Relaxed) % 0x8000_0000
}
/// Source of search tokens.
///
/// A counter, not a hash of the query: a peer's answer is routed to the first
/// search holding its token, so two queries sharing one would pour results
/// into each other.
static NEXT_SEARCH_TOKEN: AtomicU32 = AtomicU32::new(1);
fn next_search_token() -> u32 {
NEXT_SEARCH_TOKEN.fetch_add(1, Ordering::Relaxed)
}
/// A file we have agreed to serve to a peer, awaiting their TransferResponse.
struct UploadJob {
downloader: String,
real_path: std::path::PathBuf,
virtual_path: String,
size: u64,
/// When the offer went out; `None` once the peer has answered it.
offered: Option<Instant>,
}
/// An upload waiting for its downloader to connect back to our listener while
/// we also dial them. Whichever socket arrives first claims the transfer.
pub(crate) struct BrokeredUpload {
pub(crate) token: u32,
pub(crate) real_path: std::path::PathBuf,
deadline: Instant,
claimed: Arc<AtomicBool>,
}
impl BrokeredUpload {
/// Take the transfer for this route; false if another route already did.
pub(crate) fn claim(&self) -> bool {
self.claimed
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
}
}
/// Live bookkeeping for an upload being served (or recently finished).
struct ActiveUpload {
username: String,
filename: String,
size: u64,
bytes_sent: Arc<std::sync::atomic::AtomicU64>,
cancel: Arc<std::sync::atomic::AtomicBool>,
status: crate::types::UploadStatus,
/// When streaming began, so a snapshot can report a transfer rate.
started: Instant,
}
/// Transfer rate of an in-progress upload, in bytes per second. Finished,
/// cancelled and failed uploads report zero, matching how a download reports
/// its speed only while running.
///
/// an average over the whole transfer rather than the rolling window
/// the download path samples. Enough to fill the Speed column; sample a window
/// if the figure ever needs to track sudden stalls.
fn upload_speed(
status: &crate::types::UploadStatus,
bytes_sent: u64,
started: Instant,
) -> f64 {
if !matches!(status, crate::types::UploadStatus::InProgress) {
return 0.0;
}
let elapsed = started.elapsed().as_secs_f64();
if elapsed <= 0.0 {
return 0.0;
}
bytes_sent as f64 / elapsed
}
/// Build a `FileSearchResponse` for `query` against `shares`, or `None` if
/// nothing matches. `own_username` is the name the searcher will download from.
// One reply's worth of state: the shares to search, who we are, and the
// figures the reply advertises. Kept as arguments rather than a struct
// because every one of them is read straight from the client context at the
// call site, and a struct would only move the same list one line up.
#[allow(clippy::too_many_arguments)]
fn build_search_response(
shares: &Shares,
own_username: &str,
token: u32,
query: &str,
free_slot: bool,
speed: u32,
queue_length: u32,
excluded_phrases: &[String],
) -> Option<crate::message::Message> {
let matches = shares.search(query);
if matches.is_empty() {
return None;
}
// A file whose path carries a phrase the server excludes (code 160) is
// left out of the reply, the way Nicotine+ does it: the exclusion is the
// server policing what travels the search network, and answering with one
// anyway is what it is asking us not to do.
let entries: Vec<FileEntry> = matches
.iter()
.filter(|f| {
!path_carries_excluded_phrase(&f.virtual_path, excluded_phrases)
})
.take(crate::types::MAX_SEARCH_REPLY_FILES)
.map(|f| FileEntry {
name: &f.virtual_path,
size: f.size,
attribs: &f.attributes,
})
.collect();
if entries.is_empty() {
return None;
}
Some(build_file_search_response(
own_username,
token,
&entries,
u8::from(free_slot),
speed,
queue_length,
))
}
/// Whether `path` contains any of the phrases the server excludes. Matching
/// is on the lowercased path, as the phrases themselves are lowercase.
fn path_carries_excluded_phrase(path: &str, excluded: &[String]) -> bool {
if excluded.is_empty() {
return false;
}
let lowered = path.to_lowercase();
excluded
.iter()
.any(|phrase| !phrase.is_empty() && lowered.contains(phrase))
}
#[derive(Debug, Clone)]
pub struct ClientSettings {
pub username: String,
pub password: String,
pub server_address: PeerAddress,
pub enable_listen: bool,
pub listen_port: u16,
/// Directories whose files are shared with (uploaded to) other peers.
/// Empty means nothing is shared.
pub shared_directories: Vec<String>,
/// Whether to serve children in the distributed search network: peers
/// hang from us with a `D` connection and we pass every search we receive
/// down to them. Off by default — it costs a socket and the network's
/// whole search stream per child.
pub accept_children: bool,
/// The version reported to the server on login. Defaults to the
/// soulseek-rs major version with minor version 1 ("unidentified");
/// clients built on this library should reserve their own minor
/// version (see [`ClientVersion`]).
pub version: ClientVersion,
}
impl ClientSettings {
pub fn new(
username: impl Into<String>,
password: impl Into<String>,
) -> Self {
Self {
username: username.into(),
password: password.into(),
..Default::default()
}
}
}
impl Default for ClientSettings {
fn default() -> Self {
Self {
username: String::new(),
password: String::new(),
server_address: PeerAddress::new(
"server.slsknet.org".to_string(),
2416,
),
enable_listen: true,
listen_port: DEFAULT_LISTEN_PORT,
shared_directories: Vec::new(),
accept_children: false,
version: ClientVersion::default(),
}
}
}
/// The client loop's mailbox. Non-exhaustive for the same reason as
/// [`ServerMessage`]: new protocol coverage adds variants.
#[derive(Debug)]
#[non_exhaustive]
pub enum ClientOperation {
ConnectToPeer(Peer),
SearchResult(SearchResult),
PeerDisconnected(u64, String, Option<SoulseekRs>),
DownloadFromPeer(u32, Peer, bool),
UpdateDownloadTokens(Transfer, String),
GetPeerAddressResponse {
username: String,
host: String,
port: u32,
obfuscation_type: u32,
obfuscated_port: u16,
},
UploadFailed(String, String),
PlaceInQueueUpdate {
username: String,
filename: String,
place: u32,
},
SetServerSender(Sender<ServerMessage>),
PrivateMessageReceived(UserMessage),
/// The server answered `GetUserStatus` for a user we asked about.
UserStatusReceived {
username: String,
status: u32,
privileged: bool,
},
/// The server answered `GetUserStats` for a user we asked about.
UserStatsReceived {
username: String,
average_speed: u32,
shared_files: u32,
shared_folders: u32,
},
/// The server answered `WatchUser` for a user we started watching.
WatchedUserReceived {
username: String,
exists: bool,
status: Option<u32>,
average_speed: Option<u32>,
shared_files: Option<u32>,
shared_folders: Option<u32>,
},
PeerConnected(String),
/// A search distributed to us by the server; reply if our shares match.
IncomingSearch {
username: String,
token: u32,
query: String,
/// True when it came down the tree from our parent, which has
/// already passed it to our children and is subject to the answer
/// budget. A search the server relayed is neither.
from_parent: bool,
},
/// A peer queued one of our shared files; `requester_key` is the registry
/// key of the peer actor — the peer's username.
QueueUpload {
requester_key: String,
filename: String,
},
/// The peer accepted our upload offer for `token`; start streaming.
StartUpload {
token: u32,
},
/// A peer asked to browse our shared files; send our SharedFileListResponse.
ShareListRequested {
requester_key: String,
},
/// A peer asked what we say about ourselves; send a UserInfoResponse.
UserInfoRequested {
requester_key: String,
},
/// A peer asked for one folder of our shares; send a FolderContentsResponse.
FolderContentsRequested {
requester_key: String,
token: u32,
folder: String,
},
/// A peer answered our request for what it says about itself.
PeerInfoReceived {
username: String,
info: crate::message::peer::PeerInfo,
},
/// A peer answered our request for one folder of their shares.
FolderContents {
username: String,
token: u32,
folder: String,
directories: Vec<SharedDirectory>,
},
/// A peer we are browsing returned their shared-file listing.
BrowseResult {
username: String,
directories: Vec<SharedDirectory>,
},
/// A direct outbound connection to this peer failed before it was
/// established — the peer is likely firewalled, so fall back to asking the
/// server to broker the connection. Carries the reporting actor's id and,
/// when the dial was one the server asked us to make, the token that peer
/// quoted: that dial is answered with a `CantConnectToPeer` instead of a
/// broker request, since the peer is already waiting on the server.
PeerConnectFailed(u64, String, Option<u32>),
/// Something happened in the chat-room subsystem (list refreshed, a room
/// joined/left, a message said, a member joined/left).
RoomEvent(RoomEvent),
/// Per-member statistics from a `JoinRoom` reply.
RoomMemberStats {
room: String,
stats: Vec<RoomUserStats>,
},
/// The server announced how many seconds must pass between wishlist
/// searches.
WishlistInterval(u32),
/// Everyone the server counts as privileged; they sort ahead of others in
/// our upload queue.
PrivilegedUsers(Vec<String>),
/// Seconds of our own privileges left.
OwnPrivileges(u32),
/// A peer asked where their queued file sits.
PlaceInQueueRequested {
requester_key: String,
filename: String,
},
/// Distributed-network parent candidates, from the server or a host.
PossibleParents(Vec<(String, String, u16)>),
/// The server told us to drop our parent.
ResetDistributed,
/// A session just started: anything the server keeps only per-session —
/// our interests — has to be sent again.
SessionEstablished,
/// A peer dialled us with a `D` connection, asking to hang from us in the
/// distributed search network.
ChildConnected {
username: String,
stream: std::net::TcpStream,
},
/// Recommendations from the server: either from our own interests
/// (`global` false, code 54) or server-wide (`global` true, code 56).
Recommendations {
global: bool,
recommended: Vec<Recommendation>,
unrecommended: Vec<Recommendation>,
},
/// Recommendations for one item (code 111).
ItemRecommendations {
item: String,
recommendations: Vec<Recommendation>,
},
/// Users the server considers similar to us (code 110).
SimilarUsers(Vec<SimilarUser>),
/// Users who like one item (code 112).
ItemSimilarUsers {
item: String,
usernames: Vec<String>,
},
/// What another user likes and hates (code 57).
UserInterests(UserInterests),
/// A peer we asked the server to broker gave up reaching us (code 1001).
CantConnectToPeer {
token: u32,
},
/// Phrases the server refuses to search for (code 160).
ExcludedSearchPhrases(Vec<String>),
/// The minimum upload speed for carrying children (code 83).
ParentMinSpeed(u32),
/// The divisor turning that speed into a child count (code 84).
ParentSpeedRatio(u32),
/// A parent candidate told us how deep it sits. `link` says which dial
/// to that user is talking.
ParentBranchLevel {
parent: String,
link: u64,
level: i32,
},
/// A parent candidate told us whose branch it is on.
ParentBranchRoot {
parent: String,
link: u64,
root: String,
},
/// A search came down the tree from `parent`.
ParentSearch {
parent: String,
link: u64,
username: String,
token: u32,
query: String,
},
/// The link to a parent or candidate is gone.
ParentClosed {
parent: String,
link: u64,
},
}
pub struct ClientContext {
pub peer_registry: Option<PeerRegistry>,
pub downloads: DownloadStore,
server_sender: Option<Sender<ServerMessage>>,
/// Where anything outside the operations loop posts operations.
operations: Option<Sender<ClientOperation>>,
/// Our place in the distributed search network.
leaf: distributed::Leaf,
/// The children hanging from us, when the client serves any.
pub(crate) children: children::Children,
/// The last `AcceptChildren` answer we gave the server, so the same
/// answer is not repeated: it is a standing state, not a heartbeat.
announced_accept_children: Option<bool>,
/// The upload speed the server records for us, once it has told us. The
/// child limit is derived from it.
own_average_speed: Option<u32>,
/// The speed a parent needs (code 83) and the divisor turning speed into
/// a child count (code 84), as the server announced them.
parent_min_speed: u32,
parent_speed_ratio: u32,
searches: HashMap<String, Search>,
private_messages: Vec<UserMessage>,
/// Correlation tokens for server-brokered (firewalled) connections, mapping
/// a token we sent in a ConnectToPeer to the peer we expect back.
pending_connect_tokens: HashMap<u32, (String, Instant)>,
max_peers: Arc<AtomicUsize>,
pub(crate) download_speed_limit: Arc<RateLimit>,
/// Files we share with peers (read-only after connect).
pub shares: Arc<Shares>,
/// The directories the current share index was built from.
pub shared_directories: Vec<String>,
/// Peer listen addresses learned from GetPeerAddress responses.
peer_addresses: HashMap<String, (String, u32)>,
/// Peer messages waiting for a control connection to that peer, and
/// when the last was queued: a peer that never answers must not keep
/// them forever.
pending_peer_messages:
HashMap<String, (Instant, Vec<crate::message::Message>)>,
/// Uploads we have offered, keyed by our transfer token.
uploads: HashMap<u32, UploadJob>,
active_uploads: HashMap<u32, ActiveUpload>,
/// Upload tokens waiting for the downloader's address to be resolved.
pending_serves: HashMap<String, Vec<u32>>,
/// Uploads whose downloader we could not dial, keyed by the correlation
/// token we sent in `ConnectToPeer(F)`; the peer quotes it back in a
/// `PierceFirewall` and we serve over that socket instead.
brokered_uploads: HashMap<u32, BrokeredUpload>,
/// Shared-file listings received from peers we browsed.
browse_results: HashMap<String, Vec<SharedDirectory>>,
/// One-folder listings received from peers, keyed by peer and folder.
folder_contents: HashMap<(String, String), Vec<SharedDirectory>>,
/// What peers said about themselves (peer code 16), keyed by peer.
peer_infos: HashMap<String, crate::message::peer::PeerInfo>,
pending_browses: HashMap<String, Instant>,
/// Latest snapshot of the public chat-room list (from `RoomList`, code 64).
room_list: Vec<RoomInfo>,
/// Chat-room events awaiting consumption by the client/UI.
room_events: Vec<RoomEvent>,
/// Who is in each room we have joined, kept current from the membership
/// the server sends on join plus the later join/leave events.
room_members: HashMap<String, Vec<String>>,
/// Per-member statistics for each joined room, from the stat vectors the
/// server sends alongside the membership list.
room_member_stats: HashMap<String, Vec<RoomUserStats>>,
/// Who may enter each private room we belong to (code 133), kept current
/// from the later roster changes.
private_room_members: HashMap<String, Vec<String>>,
/// Who runs each of those rooms (code 148).
private_room_operators: HashMap<String, Vec<String>>,
/// The ticker board of each joined room, kept current from the board sent
/// on join (code 113) and the later add/remove events (114/115).
room_tickers: HashMap<String, Vec<RoomTicker>>,
/// The latest recommendations from our own interests (code 54), as
/// (recommended, recommended-against).
recommendations: Option<(Vec<Recommendation>, Vec<Recommendation>)>,
/// The latest server-wide recommendations (code 56).
global_recommendations: Option<(Vec<Recommendation>, Vec<Recommendation>)>,
/// Per-item recommendations (code 111), keyed by the item asked about.
item_recommendations: HashMap<String, Vec<Recommendation>>,
/// The latest similar-user answer (code 110).
similar_users: Vec<SimilarUser>,
/// Who likes an item (code 112), keyed by the item asked about.
item_similar_users: HashMap<String, Vec<String>>,
/// Interests of other users (code 57), keyed by username.
user_interests: HashMap<String, UserInterests>,
/// What we ourselves like and hate. The server keeps these only for the
/// duration of a session, so they are held here and sent again after each
/// login — the same thing Nicotine+ does from its config.
own_interests: UserInterests,
/// What the server has told us about other users, merged across the
/// separate status and statistics replies.
user_info: HashMap<String, UserInfo>,
/// Users we asked the server to watch (code 5), so it keeps pushing their
/// status changes. Kept so a UI can render the watch list and so an
/// unwatch can be rejected for someone we never watched.
watched_users: HashSet<String>,
/// Seconds the server wants between wishlist searches (code 104), once it
/// has told us.
wishlist_interval: Option<u32>,
/// Everyone the server listed as privileged (code 69). They sort ahead of
/// other peers in [`Self::upload_queue`].
privileged_users: HashSet<String>,
/// Seconds of our own privileges left (code 92), once we have asked.
own_privileges: Option<u32>,
/// Phrases the server excludes from the search network (code 160). Files
/// whose path carries one are left out of the replies we send.
excluded_search_phrases: Vec<String>,
/// Peers waiting for one of our upload slots.
upload_queue: Vec<QueuedUpload>,
/// Arrival counter for the queue's first-come tie-break.
upload_seq: u64,
/// How many uploads may be in flight at once.
upload_slots: usize,
/// Bytes per second of the last completed upload, advertised in search
/// replies; zero until one has finished.
last_upload_speed: u32,
/// Queued-upload states that came and went between two polls of
/// [`Client::uploads`]. A caller sampling that snapshot would otherwise
/// never see a peer that queued and was served inside one poll interval,
/// and "it waited" is exactly the fact a transfer log must not lose.
upload_events: Vec<crate::types::UploadInfo>,
actor_system: Arc<ActorSystem>,
}
/// A roster sorted and de-duplicated, so membership lookups can binary-search
/// it and a repeated name cannot appear twice.
fn sorted_unique(users: &[String]) -> Vec<String> {
let mut users = users.to_vec();
users.sort();
users.dedup();
users
}
impl Default for ClientContext {
fn default() -> Self {
Self::new()
}
}
// Thin delegating shims so existing callers (peer/listen, peer/download_peer,
// tests) keep working while download state lives in DownloadStore.
impl ClientContext {
pub fn add_download(&mut self, download: Download) {
self.downloads.add(download);
}
pub fn remove_download(&mut self, token: u32) {
self.downloads.remove(token);
}
#[must_use]
pub fn get_download_by_token(&self, token: u32) -> Option<&Download> {
self.downloads.get_by_token(token)
}
pub fn get_download_by_token_mut(
&mut self,
token: u32,
) -> Option<&mut Download> {
self.downloads.get_by_token_mut(token)
}
#[must_use]
pub fn get_download_tokens(&self) -> Vec<u32> {
self.downloads.tokens()
}
#[must_use]
pub const fn get_downloads(&self) -> &Vec<Download> {
self.downloads.list()
}
pub fn update_download_with_status(
&mut self,
token: u32,
status: DownloadStatus,
) {
self.downloads.update_status(token, status);
}
}
pub struct Client {
enable_listen: bool,
listen_port: u16,
/// The port the listener actually holds, known once [`Client::connect`]
/// has bound it.
bound_port: Option<u16>,
address: PeerAddress,
username: String,
password: String,
version: ClientVersion,
shared_directories: Vec<String>,
server_handle: Option<ActorHandle<ServerMessage>>,
context: Arc<RwLock<ClientContext>>,
session: SessionWatch,
/// Tells the peer listener to stop, so a disconnected client releases the
/// port it bound.
listener_stopped: Arc<AtomicBool>,
listener_released: Arc<AtomicBool>,
cancel: CancelHandle,
}
impl Drop for Client {
fn drop(&mut self) {
self.disconnect();
}
}
impl Client {
pub fn new(
username: impl Into<String>,
password: impl Into<String>,
) -> Self {
Self::with_settings(ClientSettings::new(username, password))
}
#[must_use]
pub fn with_settings(settings: ClientSettings) -> Self {
logger::init();
let mut context = ClientContext::for_user(&settings.username);
context.children = children::Children::new(settings.accept_children);
Self {
enable_listen: settings.enable_listen,
listen_port: settings.listen_port,
bound_port: None,
address: settings.server_address,
context: Arc::new(RwLock::new(context)),
username: settings.username,
password: settings.password,
version: settings.version,
shared_directories: settings.shared_directories,
server_handle: None,
session: SessionWatch::default(),
listener_stopped: Arc::new(AtomicBool::new(false)),
listener_released: Arc::new(AtomicBool::new(false)),
cancel: CancelHandle::new(),
}
}
/// The children currently hanging from us in the distributed search
/// network, by username. Empty unless the client was built with
/// [`ClientSettings::accept_children`].
#[must_use]
pub fn children(&self) -> Vec<String> {
match self.context.read_safe() {
Ok(ctx) => ctx.children.usernames(),
Err(e) => {
error!("[client] children: {}", e);
Vec::new()
}
}
}
/// The username we log in as, for attributing our own messages.
#[must_use]
pub fn username(&self) -> &str {
&self.username
}
/// The port peers can reach this client on, or `None` when it is not
/// listening (or has not connected yet). This is the port that was really
/// bound, which is not always the one that was configured.
#[must_use]
pub const fn listen_port(&self) -> Option<u16> {
self.bound_port
}
/// Why the server session ended, or `None` while it is alive.
///
/// A lost session sees nothing on the network, so an empty result set from
/// one says nothing about what the network holds.
#[must_use]
pub fn session_loss(&self) -> Option<SessionLoss> {
self.session.loss()
}
#[must_use]
pub fn cancel_handle(&self) -> CancelHandle {
self.cancel.clone()
}
/// The directories whose files are currently shared with other peers.
#[must_use]
pub fn shared_directories(&self) -> Vec<String> {
self.context
.read_safe()
.map(|ctx| ctx.shared_directories.clone())
.unwrap_or_default()
}
/// The share index as a peer receives it: the same directory listing
/// `GetShareFileList` answers with, so what this returns is what the
/// network sees.
#[must_use]
pub fn shared_listing(&self) -> Vec<crate::message::peer::SharedDirectory> {
self.context
.read_safe()
.map(|ctx| ctx.shares.directories())
.unwrap_or_default()
}
/// `(folders, files)` currently shared with peers.
#[must_use]
pub fn shared_counts(&self) -> (u32, u32) {
self.context.read_safe().map_or((0, 0), |ctx| {
(ctx.shares.folder_count(), ctx.shares.file_count())
})
}
/// Every upload this session knows about: the ones streaming or finished,
/// followed by the peers still waiting for a slot, in the order they will
/// be served.
#[must_use]
pub fn uploads(&self) -> Vec<crate::types::UploadInfo> {
self.context.read_safe().map_or_else(
|_| Vec::new(),
|ctx| {
let mut tokens: Vec<&u32> = ctx.active_uploads.keys().collect();
tokens.sort_unstable();
let mut all: Vec<crate::types::UploadInfo> = tokens
.into_iter()
.map(|token| {
let upload = &ctx.active_uploads[token];
let bytes_sent = upload
.bytes_sent
.load(std::sync::atomic::Ordering::Relaxed);
crate::types::UploadInfo {
username: upload.username.clone(),
filename: upload.filename.clone(),
size: upload.size,
bytes_sent,
speed_bytes_per_sec: upload_speed(
&upload.status,
bytes_sent,
upload.started,
),
status: upload.status.clone(),
}
})
.collect();
all.extend(ctx.queued_uploads());
all
},
)
}
/// Ask an in-progress upload to `username` of `filename` to stop.
/// Returns whether a matching in-progress upload was found.
#[must_use = "returns whether a matching upload was found"]
pub fn cancel_upload(&self, username: &str, filename: &str) -> bool {
self.context.read_safe().is_ok_and(|ctx| {
let mut found = false;
for upload in ctx.active_uploads.values() {
if upload.username == username
&& upload.filename == filename
&& upload.status == crate::types::UploadStatus::InProgress
{
upload
.cancel
.store(true, std::sync::atomic::Ordering::Relaxed);
found = true;
}
}
found
})
}
/// Replace the shared directories at runtime: rescan into a fresh
/// index (served to peers from then on) and re-announce the new
/// folder/file counts to the server.
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn set_shared_directories(&self, dirs: Vec<String>) -> Result<()> {
let roots: Vec<std::path::PathBuf> = dirs
.iter()
.filter(|dir| !dir.trim().is_empty())
.map(std::path::PathBuf::from)
.collect();
let shares = if roots.is_empty() {
Shares::empty()
} else {
Shares::scan_many(&roots)
};
info!(
"Now sharing {} files in {} folders from {} directories",
shares.file_count(),
shares.folder_count(),
roots.len()
);
let folder_count = shares.folder_count();
let file_count = shares.file_count();
{
let mut ctx = self.context.write_safe()?;
ctx.shares = Arc::new(shares);
ctx.shared_directories = dirs;
}
self.send_server_message(
crate::message::server::MessageFactory::build_shared_folders_message(
folder_count,
file_count,
),
)
}
}
mod cancel;
mod children;
mod connection;
mod context;
mod distributed;
mod downloads;
mod operations;
mod rooms;
mod search;
mod social;
mod upload_queue;
mod uploads;
pub use cancel::CancelHandle;
#[cfg(test)]
mod tests;
@@ -0,0 +1,816 @@
use super::{
Arc, Client, ClientContext, ClientOperation, ConnectionType, Download,
DownloadPeer, DownloadStatus, Duration, Instant, Peer, PeerMessage,
PeerRegistry, Receiver, RwLock, RwLockExt, ServerMessage,
build_search_response, debug, error, info, mpsc, next_connect_token,
thread, trace, warn,
};
use crate::message::server::MessageFactory;
use crate::peer::DownloadError;
mod peers;
mod search;
mod transfers;
mod tree;
const CONNECT_SWEEP_INTERVAL: Duration = Duration::from_secs(1);
/// How often to ask the peers holding our queued downloads where they sit
/// (peer code 51). Five minutes is what Nicotine+ uses: often enough that a
/// queue position on screen means something, rare enough that a peer with a
/// long queue is not pestered.
const QUEUE_POSITION_INTERVAL: Duration = Duration::from_mins(5);
/// How long a write to a child may block. The relay runs under the client
/// context lock, so an unbounded write would freeze every other operation
/// behind one child that stopped reading.
const CHILD_WRITE_TIMEOUT: Duration = Duration::from_secs(5);
impl Client {
pub(crate) fn listen_to_client_operations(
reader: Receiver<ClientOperation>,
client_context: Arc<RwLock<ClientContext>>,
own_username: String,
) {
thread::spawn(move || {
let mut last_sweep = Instant::now();
let mut last_queue_poll = Instant::now();
loop {
let next = reader.recv_timeout(CONNECT_SWEEP_INTERVAL);
if last_sweep.elapsed() >= CONNECT_SWEEP_INTERVAL {
last_sweep = Instant::now();
Self::sweep_expired_connects(&client_context);
Self::sweep_stale_offers(&client_context);
if let Ok(mut ctx) = client_context.write_safe() {
ctx.expire_pending_peer_messages(Instant::now());
if let Some(branch) =
ctx.leaf.due_announcement(Instant::now())
{
super::distributed::announce_move(
&mut ctx, &branch, true,
);
}
}
}
if last_queue_poll.elapsed() >= QUEUE_POSITION_INTERVAL {
last_queue_poll = Instant::now();
Self::poll_queue_positions(&client_context);
}
let operation = match next {
Ok(operation) => operation,
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
};
match operation {
ClientOperation::ConnectToPeer(peer) => {
if matches!(peer.connection_type, ConnectionType::F) {
let client_context_clone = client_context.clone();
let own_username_clone = own_username.clone();
thread::spawn(move || {
Self::connect_to_peer(
peer,
client_context_clone,
own_username_clone,
None,
);
});
} else {
Self::connect_to_peer(
peer,
client_context.clone(),
own_username.clone(),
None,
);
}
}
ClientOperation::SearchResult(search_result) => {
trace!("[client] SearchResult {:?}", search_result);
let mut context = match client_context.write_safe() {
Ok(c) => c,
Err(e) => {
error!("[client] SearchResult write: {}", e);
continue;
}
};
let result_token = search_result.token;
// Find the search with matching token
for search in context.searches.values_mut() {
if search.token == result_token {
search.accept(search_result);
break;
}
}
}
operation @ (ClientOperation::PeerDisconnected(..)
| ClientOperation::PeerConnectFailed(..)
| ClientOperation::ChildConnected { .. }) => {
Self::on_peers(
operation,
&client_context,
&own_username,
);
}
operation @ (ClientOperation::DownloadFromPeer(..)
| ClientOperation::UpdateDownloadTokens(..)
| ClientOperation::StartUpload { .. }
| ClientOperation::QueueUpload { .. }) => {
Self::on_transfers(
operation,
&client_context,
&own_username,
);
}
ClientOperation::GetPeerAddressResponse {
username,
host,
port,
obfuscation_type,
obfuscated_port,
} => {
debug!(
"Received peer address for {}: {}:{} (obf_type: {}, obf_port: {})",
username,
host,
port,
obfuscation_type,
obfuscated_port
);
// Port 0 is the server saying it does not know
// where this user listens (no SetWaitPort yet,
// or firewalled). It is not an address: caching
// it poisons every later lookup, and an upload
// dialled at it dies with "Can't assign
// requested address" and is dropped on the
// floor. Leave those uploads queued for a later
// resolution instead.
//
// The connect attempt further down still runs:
// its failure is exactly what makes an
// unreachable peer fall back to the
// server-brokered path.
if port == 0 {
warn!(
"[client] server reports no listening port for {}",
username
);
}
let waiting_serves = match client_context.write_safe() {
Ok(mut ctx) => {
if port != 0 {
ctx.cache_peer_address(
&username,
host.clone(),
port,
);
}
ctx.pending_serves
.remove(&username)
.unwrap_or_default()
}
Err(_) => Vec::new(),
};
for token in waiting_serves {
Self::spawn_serve(
&client_context,
&own_username,
token,
host.clone(),
port,
);
}
let peer_exists = match client_context.read_safe() {
Ok(ctx) => ctx
.peer_registry
.as_ref()
.is_some_and(|r| r.contains(&username)),
Err(e) => {
error!(
"[client] GetPeerAddressResponse read: {}",
e
);
continue;
}
};
// Existing peer: skip re-registration. Reconnect
// policy on conflict is intentionally undecided.
if !peer_exists {
let peer = Peer::new(
username,
ConnectionType::P,
host,
port,
None,
0,
// obfuscation_type is a small enum; a
// real obfuscated_port is a full u16 and
// must not be truncated into a u8 (which
// panicked and took down the ops thread).
u8::try_from(obfuscation_type).unwrap_or(0),
obfuscated_port,
);
Self::connect_to_peer(
peer,
client_context.clone(),
own_username.clone(),
None,
);
}
}
ClientOperation::UploadFailed(username, filename) => {
Self::process_failed_uploads(
client_context.clone(),
&username,
Some(&filename),
);
}
ClientOperation::PlaceInQueueUpdate {
username,
filename,
place,
} => match client_context.write_safe() {
Ok(mut ctx) => {
let updated = ctx.downloads.update_queue_position(
&username, &filename, place,
);
if !updated {
debug!(
"[client] PlaceInQueueUpdate: no matching download for {}/{}",
username, filename
);
}
}
Err(e) => {
error!("[client] PlaceInQueueUpdate write: {}", e);
}
},
ClientOperation::SetServerSender(sender) => {
match client_context.write_safe() {
Ok(mut ctx) => {
ctx.server_sender = Some(sender);
debug!("[client] Server sender initialized");
}
Err(e) => {
error!("[client] SetServerSender write: {}", e);
}
}
}
ClientOperation::PrivateMessageReceived(user_message) => {
match client_context.write_safe() {
Ok(mut ctx) => {
ctx.push_private_message(user_message);
}
Err(e) => error!(
"[client] PrivateMessageReceived write: {}",
e
),
}
}
ClientOperation::UserStatusReceived {
username,
status,
privileged,
} => match client_context.write_safe() {
Ok(mut ctx) => {
ctx.apply_user_status(username, status, privileged);
}
Err(e) => {
error!("[client] UserStatusReceived write: {}", e);
}
},
ClientOperation::UserStatsReceived {
username,
average_speed,
shared_files,
shared_folders,
} => {
// Stats about ourselves carry the speed the server
// records for us — pushed whenever it changes, since
// we watch ourselves — and the distributed child
// limit is derived from it.
let own = username == own_username;
match client_context.write_safe() {
Ok(mut ctx) => {
ctx.apply_user_stats(
username,
average_speed,
shared_files,
shared_folders,
);
if own {
ctx.set_own_average_speed(average_speed);
}
}
Err(e) => {
error!(
"[client] UserStatsReceived write: {}",
e
);
}
}
if own {
Self::announce_child_capacity(&client_context);
}
}
ClientOperation::WatchedUserReceived {
username,
exists,
status,
average_speed,
shared_files,
shared_folders,
} => {
let own = username == own_username;
match client_context.write_safe() {
Ok(mut ctx) => {
ctx.apply_watched_user(
username,
exists,
status,
average_speed,
shared_files,
shared_folders,
);
// Watching ourselves is how our own recorded
// speed reaches us, and the child limit is
// derived from it.
if own && let Some(speed) = average_speed {
ctx.set_own_average_speed(speed);
}
}
Err(e) => {
error!(
"[client] WatchedUserReceived write: {}",
e
);
}
}
if own {
Self::announce_child_capacity(&client_context);
}
}
ClientOperation::RoomEvent(event) => {
match client_context.write_safe() {
Ok(mut ctx) => ctx.apply_room_event(event),
Err(e) => error!("[client] RoomEvent write: {}", e),
}
}
ClientOperation::RoomMemberStats { room, stats } => {
match client_context.write_safe() {
Ok(mut ctx) => {
ctx.apply_room_member_stats(room, stats);
}
Err(e) => {
error!("[client] RoomMemberStats write: {}", e);
}
}
}
ClientOperation::WishlistInterval(seconds) => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.wishlist_interval = Some(seconds);
}
}
ClientOperation::PeerConnected(username) => {
// A control connection just came up — one we dialled,
// or an inbound one the listener registered. Flush any
// downloads that were queued for this peer while it
// was unreachable. Collect under a read guard, then
// act without it held.
let (registry, files): (
Option<PeerRegistry>,
Vec<String>,
) = match client_context.read_safe() {
Ok(ctx) => (
ctx.peer_registry.clone(),
ctx.get_downloads()
.iter()
.filter(|d| {
d.username == username
&& matches!(
d.status,
DownloadStatus::Queued
)
})
.map(|d| d.filename.clone())
.collect(),
),
Err(e) => {
error!("[client] PeerConnected read: {}", e);
continue;
}
};
// Also flush any peer messages (e.g. search
// responses) queued while connecting.
let queued_messages = client_context
.write_safe()
.map(|mut ctx| ctx.take_peer_messages(&username))
.unwrap_or_default();
if let Some(registry) = registry {
for filename in files {
let _ =
registry.queue_upload(&username, filename);
}
for message in queued_messages {
let _ = registry.send_to_peer(
&username,
PeerMessage::SendMessage(message),
);
}
}
}
operation @ ClientOperation::IncomingSearch { .. } => {
Self::on_search(
operation,
&client_context,
&own_username,
);
}
ClientOperation::PlaceInQueueRequested {
requester_key,
filename,
} => {
let (registry, place) = match client_context.read_safe()
{
Ok(ctx) => (
ctx.peer_registry.clone(),
ctx.place_in_queue(&requester_key, &filename),
),
Err(_) => continue,
};
// Silence would leave the peer guessing; a file
// no longer queued is being served, which is
// place 0 by the same convention Nicotine+ uses.
if let Some(registry) = registry {
let _ = registry.send_to_peer(
&requester_key,
PeerMessage::SendMessage(
MessageFactory::build_place_in_queue_response(
&filename,
place.unwrap_or(0),
),
),
);
}
}
ClientOperation::PossibleParents(candidates) => {
let (dials, ops) = match client_context.write_safe() {
Ok(mut ctx) => (
ctx.leaf.consider(candidates),
ctx.operations.clone(),
),
Err(_) => continue,
};
let Some(ops) = ops else { continue };
for dial in dials {
super::distributed::spawn_link(
dial,
own_username.clone(),
ops.clone(),
);
}
}
operation @ (ClientOperation::ParentSearch { .. }
| ClientOperation::ParentBranchLevel {
..
}
| ClientOperation::ParentBranchRoot {
..
}
| ClientOperation::ParentClosed { .. }
| ClientOperation::ResetDistributed) => {
Self::on_tree(operation, &client_context);
}
ClientOperation::PrivilegedUsers(users) => {
if let Ok(mut ctx) = client_context.write_safe() {
debug!(
"[client] {} privileged users listed",
users.len()
);
ctx.set_privileged_users(users);
}
// Someone already waiting may have just been
// outranked, but a slot that is free should
// still be filled.
Self::pump_upload_queue(&client_context);
}
ClientOperation::OwnPrivileges(seconds) => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.own_privileges = Some(seconds);
}
}
ClientOperation::Recommendations {
global,
recommended,
unrecommended,
} => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.apply_recommendations(
global,
recommended,
unrecommended,
);
}
}
ClientOperation::ItemRecommendations {
item,
recommendations,
} => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.apply_item_recommendations(
item,
recommendations,
);
}
}
ClientOperation::SimilarUsers(users) => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.apply_similar_users(users);
}
}
ClientOperation::ItemSimilarUsers { item, usernames } => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.apply_item_similar_users(item, usernames);
}
}
ClientOperation::UserInterests(interests) => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.apply_user_interests(interests);
}
}
ClientOperation::PeerInfoReceived { username, info } => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.store_peer_info(username, info);
}
}
ClientOperation::FolderContents {
username,
token,
folder,
directories,
} => {
trace!(
"[client] folder {} from {} (token {}): {} dirs",
folder,
username,
token,
directories.len()
);
if let Ok(mut ctx) = client_context.write_safe() {
ctx.store_folder_contents(
username,
folder,
directories,
);
}
}
ClientOperation::ParentMinSpeed(speed) => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.set_parent_min_speed(speed);
}
Self::announce_child_capacity(&client_context);
}
ClientOperation::ParentSpeedRatio(ratio) => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.set_parent_speed_ratio(ratio);
}
Self::announce_child_capacity(&client_context);
}
ClientOperation::SessionEstablished => {
// Interests live in the server only for the session
// that set them, so a new one starts by sending ours
// again — what Nicotine+ does from its config file.
let (sender, interests) = match client_context
.read_safe()
{
Ok(ctx) => {
(ctx.server_sender.clone(), ctx.own_interests())
}
Err(_) => continue,
};
let Some(sender) = sender else { continue };
// Watch ourselves: the server pushes a user's stats
// to whoever watches them, and our own recorded
// upload speed is what the distributed child limit is
// derived from. Nicotine+ reads the same figure from
// the stats the server sends about the logged-in
// user.
let _ = sender.send(ServerMessage::SendMessage(
MessageFactory::build_watch_user(&own_username),
));
for item in &interests.likes {
let _ = sender.send(ServerMessage::SendMessage(
MessageFactory::build_add_thing_i_like(item),
));
}
for item in &interests.hates {
let _ = sender.send(ServerMessage::SendMessage(
MessageFactory::build_add_thing_i_hate(item),
));
}
}
ClientOperation::ExcludedSearchPhrases(phrases) => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.set_excluded_search_phrases(phrases);
}
}
ClientOperation::CantConnectToPeer { token } => {
// The peer we asked the server to broker gave up. The
// token is the correlation we sent, so it names the
// peer: end the wait now instead of letting every
// queued download for them sit until the timeout.
let (username, upload) =
match client_context.write_safe() {
Ok(mut ctx) => (
ctx.take_pending_connect(token),
ctx.take_brokered_upload(token),
),
Err(e) => {
error!(
"[client] CantConnectToPeer write: {}",
e
);
(None, None)
}
};
if let Some(upload) = upload
&& upload.claim()
{
Self::fail_upload(
&client_context,
upload.token,
"peer could not connect back",
);
}
if let Some(username) = username {
debug!(
"[client] {} cannot connect back (token {})",
username, token
);
Self::fail_queued_downloads(
&client_context,
&username,
);
}
}
ClientOperation::ShareListRequested { requester_key } => {
Self::reply_to_peer(
&client_context,
&requester_key,
|ctx| {
crate::message::peer::build_shared_file_list(
&ctx.shares.directories(),
)
},
);
}
ClientOperation::UserInfoRequested { requester_key } => {
Self::reply_to_peer(
&client_context,
&requester_key,
|ctx| {
crate::message::peer::build_user_info(
ctx.upload_slots as u32,
ctx.upload_queue.len() as u32,
ctx.has_free_upload_slot(),
)
},
);
}
ClientOperation::FolderContentsRequested {
requester_key,
token,
folder,
} => {
// ponytail: walks the whole share for one folder, as a
// browse does; index by directory if it ever shows up.
Self::reply_to_peer(
&client_context,
&requester_key,
|ctx| {
let dirs: Vec<_> = ctx
.shares
.directories()
.into_iter()
.filter(|dir| dir.name == folder)
.collect();
crate::message::peer::build_folder_contents(
token, &folder, &dirs,
)
},
);
}
ClientOperation::BrowseResult {
username,
directories,
} => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.store_browse_result(username, directories);
}
}
}
}
});
}
/// Ask every peer holding a queued download of ours where it sits. The
/// answers arrive as `PlaceInQueueResponse` and land on the downloads.
fn poll_queue_positions(client_context: &Arc<RwLock<ClientContext>>) {
let (registry, queued) = match client_context.read_safe() {
Ok(ctx) => (
ctx.peer_registry.clone(),
ctx.get_downloads()
.iter()
.filter(|d| matches!(d.status, DownloadStatus::Queued))
.map(|d| (d.username.clone(), d.filename.clone()))
.collect::<Vec<_>>(),
),
Err(_) => return,
};
let Some(registry) = registry else { return };
for (username, filename) in queued {
// Only over a connection we already hold: a peer we cannot reach
// is a connection problem, not a queue question.
if !registry.contains(&username) {
continue;
}
let request =
MessageFactory::build_place_in_queue_request(&filename);
let _ = registry
.send_to_peer(&username, PeerMessage::SendMessage(request));
}
}
/// Tell the server whether we would take another child right now
/// (`AcceptChildren`, code 100). Sent whenever that answer changes: the
/// server only offers us to peers while it is true.
fn announce_child_capacity(client_context: &Arc<RwLock<ClientContext>>) {
let (sender, has_room) = match client_context.write_safe() {
Ok(mut ctx) => {
// Only on a change: the server holds this as a standing
// state, and repeating it on every message we happen to
// receive would be noise.
let Some(has_room) = ctx.accept_children_change() else {
return;
};
(ctx.server_sender.clone(), has_room)
}
Err(_) => return,
};
let Some(sender) = sender else { return };
let message =
crate::message::server::MessageFactory::build_accept_children(
has_room,
);
let _ = sender.send(ServerMessage::SendMessage(message));
}
/// Answer a peer with the message `build` derives from the client state.
fn reply_to_peer(
client_context: &Arc<RwLock<ClientContext>>,
requester_key: &str,
build: impl FnOnce(&ClientContext) -> crate::message::Message,
) {
let (registry, message) = match client_context.read_safe() {
Ok(ctx) => (ctx.peer_registry.clone(), build(&ctx)),
Err(_) => return,
};
if let Some(registry) = registry {
let _ = registry
.send_to_peer(requester_key, PeerMessage::SendMessage(message));
}
}
fn sweep_stale_offers(client_context: &Arc<RwLock<ClientContext>>) {
let freed = client_context
.write_safe()
.is_ok_and(|mut ctx| ctx.expire_stale_offers(Instant::now()));
if freed {
Self::pump_upload_queue(client_context);
}
}
fn sweep_expired_connects(client_context: &Arc<RwLock<ClientContext>>) {
let expired = match client_context.write_safe() {
Ok(mut ctx) => ctx.take_expired_connects(Instant::now()),
Err(_) => return,
};
for username in expired {
Self::fail_queued_downloads(client_context, &username);
}
let expired = match client_context.write_safe() {
Ok(mut ctx) => ctx.take_expired_brokered_uploads(Instant::now()),
Err(_) => return,
};
for upload in expired.iter().filter(|u| u.claim()) {
Self::fail_upload(
client_context,
upload.token,
"peer never connected back",
);
}
}
}
@@ -0,0 +1,196 @@
//! Dispatch arms about a peer connection appearing or going away.
//!
//! Lifted out of the dispatch loop whole, so the bodies read exactly as they
//! did inside it — including `own_username`, which is re-owned on the way in
//! rather than have every line that clones it rewritten.
use super::{
Arc, CHILD_WRITE_TIMEOUT, Client, ClientContext, ClientOperation,
ConnectionType, RwLock, RwLockExt, ServerMessage, debug, error,
next_connect_token, warn,
};
impl Client {
pub(super) fn on_peers(
operation: ClientOperation,
client_context: &Arc<RwLock<ClientContext>>,
own_username: &str,
) {
let own_username = own_username.to_string();
match operation {
ClientOperation::PeerDisconnected(id, username, error) => {
// Scope the read guard: process_failed_uploads
// below acquires a write lock on the same
// RwLock, which would self-deadlock the entire
// client ops loop if this read guard were still
// held on this thread. Evict only if this exact
// actor still occupies the slot, so a replaced
// actor's shutdown can't remove its successor.
{
let context = match client_context.read_safe() {
Ok(c) => c,
Err(e) => {
error!("[client] PeerDisconnected read: {}", e);
return;
}
};
if let Some(ref registry) = context.peer_registry
&& let Some(handle) =
registry.remove_peer_if(&username, id)
{
let _ = handle.stop();
}
}
// Only an error is evidence the peer is gone. A clean
// close — our idle reaper, or a remote client tidying
// an idle socket while it waits in our queue — must
// not throw away everything that peer has queued: the
// connection comes back, the queue cannot. A departed
// peer wedging uploads shut is still covered: its
// erroring transfer fails within the socket deadlines
// and the error branch here frees its slots.
if let Some(error) = error {
warn!(
"[client] Peer {} disconnected with error: {:?}",
username, error
);
Self::process_failed_uploads(
client_context.clone(),
&username,
None,
);
Self::release_upload_slots(client_context, &username);
}
}
ClientOperation::PeerConnectFailed(
id,
username,
brokered_token,
) => {
// Direct connect failed. A dial the server asked
// us to make is answered with CantConnectToPeer
// quoting the peer's own token: that peer is
// already waiting on the server, and asking it to
// broker the same connection back only trades one
// unreachable direction for the other. Every other
// dial falls back to the broker below.
if let Some(peer_token) = brokered_token {
let server_sender = match client_context.write_safe() {
Ok(ctx) => {
if let Some(handle) = ctx
.peer_registry
.as_ref()
.and_then(|r| r.remove_peer_if(&username, id))
{
let _ = handle.stop();
}
ctx.server_sender.clone()
}
Err(e) => {
error!("[client] PeerConnectFailed write: {}", e);
return;
}
};
if let Some(sender) = server_sender {
let msg = crate::message::server::MessageFactory::build_cant_connect_to_peer(
peer_token,
&username,
);
let _ = sender.send(ServerMessage::SendMessage(msg));
}
// Nothing else is coming from this peer, so
// anything queued for it fails now rather than
// waiting out its timeout.
Self::fail_queued_downloads(client_context, &username);
return;
}
// Direct connect failed: ask the server to
// broker it. Register a correlation token, then
// send ConnectToPeer so the (firewalled) peer
// connects back to our listener quoting it.
let token = next_connect_token();
let server_sender = match client_context.write_safe() {
Ok(mut ctx) => {
// Reap the dead outbound actor so it
// stops pinning a pool worker and no
// longer shadows the brokered reconnect
// (a stale registry entry would make
// later downloads queue into a dead,
// streamless actor and hang). Identity-
// aware so a newer namesake is untouched.
if let Some(handle) = ctx
.peer_registry
.as_ref()
.and_then(|r| r.remove_peer_if(&username, id))
{
let _ = handle.stop();
}
ctx.add_pending_connect(token, username.clone());
ctx.server_sender.clone()
}
Err(e) => {
error!("[client] PeerConnectFailed write: {}", e);
return;
}
};
let Some(sender) = server_sender else {
return;
};
let msg = crate::message::server::MessageFactory::build_connect_to_peer(
token,
&username,
ConnectionType::P,
);
let _ = sender.send(ServerMessage::SendMessage(msg));
}
ClientOperation::ChildConnected { username, stream } => {
// A peer wants to hang from us. Take it on if this
// client serves children and has room, then tell it
// where our branch sits so it can report its own
// place; otherwise the socket is dropped here and the
// peer looks for another parent.
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
if !ctx.children.has_room() {
return;
}
// A child that stops reading must not wedge us: the
// relay writes under the context lock, so the write
// is bounded and a child that hits it loses its slot.
if stream.set_nodelay(true).is_err()
|| stream
.set_write_timeout(Some(CHILD_WRITE_TIMEOUT))
.is_err()
{
return;
}
if username == own_username {
return; // we cannot hang from ourselves
}
if ctx.children.accept(&username, stream) {
let branch = ctx.leaf.branch();
ctx.children.send_stance_to(
&username,
&branch.root,
branch.level,
);
debug!(
"[distributed] carrying {} children",
ctx.children.len()
);
// The server stops offering us to peers once we
// are full, and starts again when one leaves.
let full = !ctx.children.has_room();
drop(ctx);
if full {
Self::announce_child_capacity(client_context);
}
}
}
// The dispatch loop routes only the variants above to here.
_ => {}
}
}
}
@@ -0,0 +1,118 @@
//! The dispatch arm that answers a search reaching us from the network.
//!
//! Lifted out of the dispatch loop whole, so the bodies read exactly as they
//! did inside it — including `own_username`, which is re-owned on the way in
//! rather than have every line that clones it rewritten.
use super::{
Arc, Client, ClientContext, ClientOperation, Instant, PeerMessage, RwLock,
RwLockExt, ServerMessage, build_search_response, error,
};
impl Client {
pub(super) fn on_search(
operation: ClientOperation,
client_context: &Arc<RwLock<ClientContext>>,
own_username: &str,
) {
let own_username = own_username.to_string();
let ClientOperation::IncomingSearch {
username,
token,
query,
from_parent,
} = operation
else {
return;
};
{
// Every search we receive comes from the tree: from
// our parent, or from the server acting as one. That
// is what qualifies us to carry children at all —
// Nicotine+ refuses them until something feeds it —
// and passing the search down is the whole duty.
let (capacity_changed, answer) = match client_context.write_safe() {
Ok(mut ctx) => {
let was_fed = ctx.children.is_fed();
let before = ctx.children.len();
ctx.set_fed_by(true);
if !from_parent {
// A parent's search was passed down where
// it arrived; this is the server's.
ctx.children.broadcast_search(&username, token, &query);
}
// Answering costs a scan of everything we
// share, so the budget applies to searches
// from the tree. The server's own are few.
let answer =
!from_parent || ctx.leaf.admit_search(Instant::now());
// A child whose socket has gone is dropped by
// that write, which frees a slot the server
// should hear about.
(!was_fed || ctx.children.len() != before, answer)
}
Err(_) => (false, false),
};
if capacity_changed {
Self::announce_child_capacity(client_context);
}
if !answer {
return;
}
// Don't answer our own distributed search.
if username == own_username {
return;
}
let response = match client_context.read_safe() {
Ok(ctx) => build_search_response(
&ctx.shares,
&own_username,
token,
&query,
ctx.has_free_upload_slot(),
ctx.last_upload_speed,
ctx.upload_queue.len() as u32,
&ctx.excluded_search_phrases,
),
Err(e) => {
error!("[client] IncomingSearch read: {}", e);
return;
}
};
let Some(message) = response else {
return; // no matching shares
};
// Deliver to the searcher: send now if we have a
// control connection, else open one and queue.
let (connected, registry, server_sender) =
match client_context.read_safe() {
Ok(ctx) => (
ctx.peer_registry
.as_ref()
.is_some_and(|r| r.contains(&username)),
ctx.peer_registry.clone(),
ctx.server_sender.clone(),
),
Err(_) => return,
};
if connected {
if let Some(registry) = registry {
let _ = registry.send_to_peer(
&username,
PeerMessage::SendMessage(message),
);
}
} else {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.queue_peer_message(&username, message);
}
if let Some(sender) = server_sender {
let _ =
sender.send(ServerMessage::GetPeerAddress(username));
}
}
}
}
}
@@ -0,0 +1,219 @@
//! Dispatch arms that move bytes: what a peer allows, what it asks of us,
//! and the tokens tying the two together.
//!
//! Lifted out of the dispatch loop whole, so the bodies read exactly as they
//! did inside it — including `own_username`, which is re-owned on the way in
//! rather than have every line that clones it rewritten.
use super::{
Arc, Client, ClientContext, ClientOperation, Download, DownloadError,
DownloadPeer, DownloadStatus, MessageFactory, PeerMessage, RwLock,
RwLockExt, ServerMessage, debug, error, info, thread, trace,
};
impl Client {
pub(super) fn on_transfers(
operation: ClientOperation,
client_context: &Arc<RwLock<ClientContext>>,
own_username: &str,
) {
let own_username = own_username.to_string();
match operation {
ClientOperation::DownloadFromPeer(token, peer, allowed) => {
let maybe_download = match client_context.write_safe() {
Ok(mut ctx) => {
ctx.downloads.claim_for_peer(token, &peer.username)
}
Err(e) => {
error!("[client] DownloadFromPeer write: {}", e);
return;
}
};
trace!(
"[client] DownloadFromPeer token: {} peer: {:?}",
token, peer
);
let Some(download) = maybe_download else {
debug!(
"[client] transfer token {} is missing, belongs \
to another peer, or is already claimed; ignoring",
token,
);
return;
};
let client_context = client_context.clone();
thread::spawn(move || {
let download_peer = DownloadPeer::new(
download.username.clone(),
peer.host.clone(),
peer.port,
token,
allowed,
own_username,
);
match download_peer.download_file(
client_context,
Some(download.clone()),
None,
) {
Ok((download, filename)) => {
info!(
"Successfully downloaded {} bytes to {}",
download.size, filename
);
}
Err(DownloadError::Cancelled) => {}
Err(e) => {
error!(
"Failed to download file '{}' from {}:{} (token: {}) - Error: {}",
download.filename,
peer.host,
peer.port,
download.token,
e
);
}
}
});
}
ClientOperation::UpdateDownloadTokens(transfer, username) => {
let mut context = match client_context.write_safe() {
Ok(c) => c,
Err(e) => {
error!("[client] UpdateDownloadTokens write: {}", e);
return;
}
};
let download_to_update =
context.get_downloads().iter().find_map(|d| {
if d.username == username
&& d.filename == transfer.filename
{
Some((d.token, d.clone()))
} else {
None
}
});
let cancelled =
download_to_update.as_ref().is_some_and(|(_, d)| {
matches!(d.status, DownloadStatus::Cancelled)
});
if !cancelled
&& let Some((old_token, download)) = download_to_update
{
trace!(
"[client] UpdateDownloadTokens found {old_token}, transfer: {:?}",
transfer
);
context.add_download(Download {
token: transfer.token,
size: transfer.size,
..download
});
context.remove_download(old_token);
}
// Only now invite the file connection: it is
// matched by this token, which is recorded as
// of the line above. Answering any earlier
// races the peer's connection against our own
// bookkeeping.
let registry = context.peer_registry.clone();
drop(context);
if let Some(registry) = registry {
let response = if cancelled {
MessageFactory::build_transfer_denial_message(
transfer.token,
"Cancelled",
)
} else {
MessageFactory::build_transfer_response_message(
transfer,
)
};
let _ = registry.send_to_peer(
&username,
PeerMessage::SendMessage(response),
);
}
}
ClientOperation::StartUpload { token } => {
// The peer accepted our offer: resolve their
// address (from the code-9 GetPeerAddress) and
// stream the file, or queue until it resolves.
let (job_addr, downloader) = match client_context.write_safe() {
Ok(mut ctx) => {
ctx.mark_offer_answered(token);
let Some(job) = ctx.uploads.get(&token) else {
return;
};
(
ctx.peer_address(&job.downloader),
job.downloader.clone(),
)
}
Err(_) => return,
};
if let Some((host, port)) = job_addr {
Self::spawn_serve(
client_context,
&own_username,
token,
host,
port,
);
} else {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.pending_serves
.entry(downloader.clone())
.or_default()
.push(token);
}
if let Ok(ctx) = client_context.read_safe()
&& let Some(sender) = ctx.server_sender.clone()
{
let _ = sender
.send(ServerMessage::GetPeerAddress(downloader));
}
}
}
ClientOperation::QueueUpload {
requester_key,
filename,
} => {
// The peer served next may not be this one.
match client_context.write_safe() {
Ok(mut ctx) => {
let Some(file) = ctx.shares.get(&filename) else {
debug!(
"[client] QueueUpload for unknown file {}",
filename
);
return;
};
let size = file.size;
let real_path = file.real_path.clone();
ctx.enqueue_upload(
&requester_key,
&filename,
real_path,
size,
);
}
Err(e) => {
error!("[client] QueueUpload write: {}", e);
return;
}
}
Self::pump_upload_queue(client_context);
}
// The dispatch loop routes only the variants above to here.
_ => {}
}
}
}
@@ -0,0 +1,103 @@
//! Dispatch arms for our place in the distributed search tree.
//!
//! Lifted out of the dispatch loop whole, so the bodies read exactly as they
//! did inside it.
use super::{Arc, Client, ClientContext, ClientOperation, RwLock, RwLockExt};
impl Client {
pub(super) fn on_tree(
operation: ClientOperation,
client_context: &Arc<RwLock<ClientContext>>,
) {
match operation {
ClientOperation::ParentSearch {
parent,
link,
username,
token,
query,
} => {
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
if let Some(branch) = ctx.leaf.search_from(&parent, link) {
crate::client::distributed::announce_move(
&mut ctx, &branch, true,
);
}
if ctx.leaf.is_parent(&parent, link) {
// The tree's stream is not ours to throttle: every
// search goes down to our children. The budget
// below bounds only the share scan we do for our
// own reply.
ctx.children.broadcast_search(&username, token, &query);
if let Some(ops) = &ctx.operations {
let _ = ops.send(ClientOperation::IncomingSearch {
username,
token,
query,
from_parent: true,
});
}
}
}
ClientOperation::ParentBranchLevel {
parent,
link,
level,
} => {
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
if let Some(branch) =
ctx.leaf.branch_level(&parent, link, level)
{
crate::client::distributed::announce_move(
&mut ctx, &branch, true,
);
}
}
ClientOperation::ParentBranchRoot { parent, link, root } => {
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
if let Some(branch) = ctx.leaf.branch_root(&parent, link, &root)
{
crate::client::distributed::announce_move(
&mut ctx, &branch, true,
);
}
}
ClientOperation::ParentClosed { parent, link } => {
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
if ctx.leaf.closed(&parent, link) {
let branch = ctx.leaf.branch();
crate::client::distributed::announce_move(
&mut ctx, &branch, false,
);
}
}
// Also what a fresh login means: whatever tree we hung
// from belongs to the old session.
ClientOperation::ResetDistributed => {
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
ctx.leaf.reset();
// A fresh session feeds us nothing until the tree (or
// the server) starts again, so the children we were
// carrying are let go rather than left starving.
ctx.set_fed_by(false);
let branch = ctx.leaf.branch();
crate::client::distributed::announce_move(
&mut ctx, &branch, false,
);
}
// The dispatch loop routes only the variants above to here.
_ => {}
}
}
}
+308
View File
@@ -0,0 +1,308 @@
use super::{
Client, PeerMessage, Result, RoomEvent, RoomInfo, RwLockExt, ServerMessage,
SharedDirectory, SoulseekRs, UserMessage, error,
};
use crate::types::{RoomUserStats, UserInfo};
impl Client {
/// Send a private message to another user via the server.
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn send_private_message(
&self,
username: &str,
message: &str,
) -> Result<()> {
let handle = self
.server_handle
.as_ref()
.ok_or(SoulseekRs::NotConnected)?;
let msg = crate::message::server::MessageFactory::build_message_user(
username, message,
);
handle
.send(ServerMessage::SendMessage(msg))
.map_err(|_| SoulseekRs::NotConnected)?;
Ok(())
}
/// Send a raw server message via the server actor, mapping a dead channel
/// to [`SoulseekRs::NotConnected`].
pub(super) fn send_server_message(
&self,
message: crate::message::Message,
) -> Result<()> {
self.server_handle
.as_ref()
.ok_or(SoulseekRs::NotConnected)?
.send(ServerMessage::SendMessage(message))
.map_err(|_| SoulseekRs::NotConnected)?;
Ok(())
}
/// Ask the server for the list of public chat rooms. The response arrives
/// asynchronously; read it with [`Client::room_list`] or by draining
/// [`Client::take_room_events`] for a [`RoomEvent::List`].
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn request_room_list(&self) -> Result<()> {
self.send_server_message(
crate::message::server::MessageFactory::build_room_list_request(),
)
}
/// Join a public chat room. The membership list and subsequent messages
/// arrive via [`Client::take_room_events`].
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn join_room(&self, room: &str) -> Result<()> {
self.send_server_message(
crate::message::server::MessageFactory::build_join_room(
room, false,
),
)
}
/// Leave a chat room previously joined with [`Client::join_room`].
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn leave_room(&self, room: &str) -> Result<()> {
self.send_server_message(
crate::message::server::MessageFactory::build_leave_room(room),
)
}
/// Say `message` in chat room `room`. The server echoes it back as a
/// [`RoomEvent::Message`], so the UI should render from that echo rather
/// than optimistically.
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn say_in_room(&self, room: &str, message: &str) -> Result<()> {
self.send_server_message(
crate::message::server::MessageFactory::build_say_chatroom(
room, message,
),
)
}
/// The latest snapshot of the public chat-room list.
#[must_use]
pub fn room_list(&self) -> Vec<RoomInfo> {
match self.context.read_safe() {
Ok(ctx) => ctx.room_list(),
Err(e) => {
error!("[client] room_list: {}", e);
Vec::new()
}
}
}
/// Ask the server what it knows about `username`: online status and share
/// statistics.
///
/// Any previous answer for `username` is discarded first, so
/// [`Client::user_info`] returns `None` until the replies to *this*
/// request arrive — polling it cannot mistake a stale snapshot for a
/// fresh one.
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn request_user_info(&self, username: &str) -> Result<()> {
use crate::message::server::MessageFactory;
self.context.write_safe()?.invalidate_user_info(username);
self.send_server_message(MessageFactory::build_get_user_status(
username,
))?;
self.send_server_message(MessageFactory::build_get_user_stats(username))
}
/// What the server has told us about `username` since the last
/// [`Client::request_user_info`], or `None` before any reply arrives.
///
/// Presence and statistics are separate replies, so a snapshot can carry
/// one and not the other; [`UserInfo::is_complete`] reports when both
/// have landed.
#[must_use]
pub fn user_info(&self, username: &str) -> Option<UserInfo> {
match self.context.read_safe() {
Ok(ctx) => ctx.user_info(username),
Err(e) => {
error!("[client] user_info: {}", e);
None
}
}
}
/// Ask the server to watch `username` (code 5).
///
/// The server replies with that user's current status and share
/// statistics — readable with [`Client::user_info`] — and then keeps
/// pushing their status changes until [`Client::unwatch_user`]. Watching
/// a user the server does not know is answered with a "does not exist"
/// flag, and that user is dropped from [`Client::watched_users`].
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn watch_user(&self, username: &str) -> Result<()> {
use crate::message::server::MessageFactory;
// Held across the send: the reply is applied under this lock, so it
// cannot land first and have its "does not exist" undone by the add.
let mut context = self.context.write_safe()?;
self.send_server_message(MessageFactory::build_watch_user(username))?;
context.add_watched_user(username);
Ok(())
}
/// Stop watching `username` (code 6) and drop what we knew about them.
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn unwatch_user(&self, username: &str) -> Result<()> {
use crate::message::server::MessageFactory;
self.send_server_message(MessageFactory::build_unwatch_user(username))?;
self.context.write_safe()?.remove_watched_user(username);
Ok(())
}
/// Everyone this client is currently watching, sorted by name.
#[must_use]
pub fn watched_users(&self) -> Vec<String> {
match self.context.read_safe() {
Ok(ctx) => ctx.watched_users(),
Err(e) => {
error!("[client] watched_users: {}", e);
Vec::new()
}
}
}
/// Who is currently in `room`, sorted by name.
///
/// The roster is built from the membership the server sends when
/// [`Client::join_room`] succeeds and kept current by the join and leave
/// events after it, so it is populated shortly after joining rather than
/// immediately. Returns an empty list for a room this client has not
/// joined.
#[must_use]
pub fn room_members(&self, room: &str) -> Vec<String> {
match self.context.read_safe() {
Ok(ctx) => ctx.room_members(room),
Err(e) => {
error!("[client] room_members: {}", e);
Vec::new()
}
}
}
/// What the server reported about the members of `room` when we joined:
/// their status, share statistics, free upload slots and country.
///
/// Populated from the same reply that fills [`Client::room_members`], so
/// it lands shortly after joining. Unlike the roster it is not updated by
/// later join and leave events -- it is the snapshot taken at join time.
/// Returns an empty list for a room this client has not joined.
#[must_use]
pub fn room_member_stats(&self, room: &str) -> Vec<RoomUserStats> {
match self.context.read_safe() {
Ok(ctx) => ctx.room_member_stats(room),
Err(e) => {
error!("[client] room_member_stats: {}", e);
Vec::new()
}
}
}
/// Remove and return all chat-room events received since the last call.
#[must_use]
pub fn take_room_events(&self) -> Vec<RoomEvent> {
match self.context.write_safe() {
Ok(mut ctx) => ctx.take_room_events(),
Err(e) => {
error!("[client] take_room_events: {}", e);
Vec::new()
}
}
}
/// Request a peer's shared-file listing. When it arrives it can be
/// retrieved with [`Client::take_browse_result`].
///
/// # Errors
/// Returns an error if the client's context lock is poisoned.
pub fn browse_user(&self, username: &str) -> Result<()> {
let request =
crate::message::server::MessageFactory::build_get_share_file_list();
self.context.write_safe()?.mark_browse_pending(username);
self.send_to_peer_or_queue(username, request)
}
/// Send `request` to `username` over their control connection, or hold it
/// until one exists — asking the server for their address so it does.
///
/// # Errors
/// [`SoulseekRs::LockPoisoned`] if the client state is poisoned.
pub(super) fn send_to_peer_or_queue(
&self,
username: &str,
request: crate::message::Message,
) -> Result<()> {
let (connected, registry) = {
let ctx = self.context.read_safe()?;
(
ctx.peer_registry
.as_ref()
.is_some_and(|r| r.contains(username)),
ctx.peer_registry.clone(),
)
};
if connected {
if let Some(registry) = registry {
let _ = registry
.send_to_peer(username, PeerMessage::SendMessage(request));
}
} else {
// Without a server there is nobody to resolve the peer's address,
// so the request would sit in the queue forever: say so instead.
let handle = self
.server_handle
.as_ref()
.ok_or(SoulseekRs::NotConnected)?;
self.context
.write_safe()?
.queue_peer_message(username, request);
let _ = handle
.send(ServerMessage::GetPeerAddress(username.to_string()));
}
Ok(())
}
/// Remove and return a peer's shared-file listing requested via
/// [`Client::browse_user`], if it has arrived.
#[must_use]
pub fn take_browse_result(
&self,
username: &str,
) -> Option<Vec<SharedDirectory>> {
self.context
.write_safe()
.ok()
.and_then(|mut ctx| ctx.take_browse_result(username))
}
/// Remove and return all private messages received since the last call.
#[must_use]
pub fn take_private_messages(&self) -> Vec<UserMessage> {
match self.context.write_safe() {
Ok(mut ctx) => ctx.take_private_messages(),
Err(e) => {
error!("[client] take_private_messages: {}", e);
Vec::new()
}
}
}
}
+238
View File
@@ -0,0 +1,238 @@
use super::{
Arc, AtomicBool, Client, DEFAULT_WISHLIST_INTERVAL, Duration, HashMap,
Instant, Ordering, Result, RwLockExt, Search, SearchResult, ServerMessage,
SoulseekRs, info, next_search_token, sleep,
};
impl Client {
pub fn search(
&self,
query: &str,
timeout: Duration,
) -> Result<Vec<SearchResult>> {
self.search_with_cancel(query, timeout, None)
}
/// Send `query` as a wishlist search (server code 103) and return at once.
///
/// Results accumulate under `query`, so the caller reads them back with
/// [`Client::get_search_results`] after waiting however long it wants to.
/// Starting several wishes and then waiting once is the whole point: waiting
/// per wish would cost one full search window each.
///
/// The server rate-limits these to the interval it announced — see
/// [`Client::wishlist_interval`] — so a wish re-sent sooner than that comes
/// back empty.
///
/// # Errors
/// [`SoulseekRs::NotConnected`] when there is no server connection.
pub fn start_wishlist_search(&self, query: &str) -> Result<()> {
self.send_search(query, true)
}
/// How long to wait between wishlist searches: what the server announced in
/// code 104, or [`DEFAULT_WISHLIST_INTERVAL`] until it has.
#[must_use]
pub fn wishlist_interval(&self) -> Duration {
self.context
.read_safe()
.ok()
.and_then(|ctx| ctx.wishlist_interval)
.map_or(DEFAULT_WISHLIST_INTERVAL, |seconds| {
Duration::from_secs(u64::from(seconds))
})
}
pub fn search_with_cancel(
&self,
query: &str,
timeout: Duration,
cancel_flag: Option<Arc<AtomicBool>>,
) -> Result<Vec<SearchResult>> {
self.send_search(query, false)?;
Self::collect_for(timeout, cancel_flag);
Ok(self.get_search_results(query))
}
/// Register `query` and put its search on the wire. Returns as soon as the
/// message is queued; nothing has answered yet.
fn send_search(&self, query: &str, wishlist: bool) -> Result<()> {
info!("Searching for {}", query);
let token = self.register_search(query)?;
let Some(handle) = &self.server_handle else {
return Err(SoulseekRs::NotConnected);
};
let query = query.to_string();
let _ = handle.send(if wishlist {
ServerMessage::WishlistSearch { token, query }
} else {
ServerMessage::FileSearch { token, query }
});
Ok(())
}
/// Search one user's shares (server code 42) and return at once.
///
/// Results arrive from that user alone and accumulate under `query`, read
/// back with [`Client::get_search_results`] — the same place a plain
/// search's results land, since the replies are ordinary
/// `FileSearchResponse` frames.
///
/// # Errors
/// [`SoulseekRs::NotConnected`] when there is no server connection.
pub fn search_user(&self, username: &str, query: &str) -> Result<()> {
let token = self.register_search(query)?;
self.send_server_message(
crate::message::server::MessageFactory::build_user_search(
username, token, query,
),
)
}
/// Search the shares of everyone in `room` (server code 120), returning at
/// once. Results accumulate under `query` exactly as above.
///
/// # Errors
/// [`SoulseekRs::NotConnected`] when there is no server connection.
pub fn search_room(&self, room: &str, query: &str) -> Result<()> {
let token = self.register_search(query)?;
self.send_server_message(
crate::message::server::MessageFactory::build_room_search(
room, token, query,
),
)
}
/// Take a token for `query` and register it, so replies quoting that token
/// are filed under `query` when they arrive.
fn register_search(&self, query: &str) -> Result<u32> {
if self.server_handle.is_none() {
return Err(SoulseekRs::NotConnected);
}
let token = next_search_token();
self.context.write_safe()?.searches.insert(
query.to_string(),
Search {
token,
results: Vec::new(),
},
);
Ok(token)
}
/// The phrases the server excludes from the search network (code 160), as
/// last announced. Files whose path carries one are left out of the
/// replies this client sends. Empty until the server has said.
#[must_use]
pub fn excluded_search_phrases(&self) -> Vec<String> {
self.context
.read_safe()
.map(|ctx| ctx.excluded_search_phrases())
.unwrap_or_default()
}
/// Let responses accumulate for `timeout`, or until cancelled.
///
/// Peers answer a search over their own connections whenever they get round
/// to it, so there is nothing to await — the window is the whole protocol.
/// Late responders are often the better sources, which is why this runs the
/// window out rather than returning on the first hit.
pub fn collect_for(
timeout: Duration,
cancel_flag: Option<Arc<AtomicBool>>,
) {
let start = Instant::now();
while start.elapsed() < timeout {
sleep(Duration::from_millis(100));
if let Some(flag) = &cancel_flag
&& flag.load(Ordering::Relaxed)
{
info!("Search cancelled by user");
return;
}
}
}
#[must_use]
pub fn get_search_results_count(&self, search_key: &str) -> usize {
self.context
.read_safe()
.ok()
.and_then(|ctx| {
ctx.searches.get(search_key).map(|s| s.results.len())
})
.unwrap_or(0)
}
#[must_use]
pub fn get_search_results(&self, search_key: &str) -> Vec<SearchResult> {
self.context
.read_safe()
.ok()
.and_then(|ctx| {
ctx.searches.get(search_key).map(|s| s.results.clone())
})
.unwrap_or_default()
}
/// Non-blocking variant that returns None if the lock is unavailable
#[must_use]
pub fn try_get_search_results(
&self,
search_key: &str,
) -> Option<Vec<SearchResult>> {
self.context.try_read().ok().and_then(|ctx| {
ctx.searches.get(search_key).map(|s| s.results.clone())
})
}
/// Drop a search and everything it collected.
///
/// Returns whether there was one to drop. A client that stays up for days
/// would otherwise hold every result set it has ever seen, and a caller
/// that has dismissed a search has no other way to say so.
#[must_use]
pub fn forget_search(&self, search_key: &str) -> bool {
self.context
.write_safe()
.is_ok_and(|mut ctx| ctx.searches.remove(search_key).is_some())
}
#[must_use]
pub fn get_all_searches(&self) -> HashMap<String, Search> {
self.context
.read_safe()
.map(|ctx| ctx.searches.clone())
.unwrap_or_default()
}
/// Every registered search and how many files it has collected, without
/// copying the result sets.
///
/// This is the accessor to poll. [`Client::get_all_searches`] clones every
/// result of every search, which for a popular query is megabytes per
/// call — a caller that only wants to know what exists and whether
/// anything new arrived reads the counts instead.
#[must_use]
pub fn search_file_counts(&self) -> Vec<(String, usize)> {
self.context
.read_safe()
.map(|ctx| {
ctx.searches
.iter()
.map(|(query, search)| {
(
query.clone(),
search
.results
.iter()
.map(|result| result.files.len())
.sum(),
)
})
.collect()
})
.unwrap_or_default()
}
}
+542
View File
@@ -0,0 +1,542 @@
//! The social side of the protocol: room tickers (codes 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), and the odds and ends a client owes
//! the server — a keepalive ping (32), gifting privileges (123) and changing
//! our password (142).
use super::{
Client, Recommendation, Result, RoomTicker, RwLockExt, SimilarUser,
UserInterests, error,
};
use crate::message::server::MessageFactory;
impl Client {
/// Send a keepalive ping (server code 32). The server does not reply; the
/// point is that a connection with nothing to say still has traffic on it,
/// so a NAT or the server's idle reaper does not drop it.
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn ping_server(&self) -> Result<()> {
self.send_server_message(MessageFactory::build_server_ping())
}
/// Join a private room, creating it (and owning it) if the name is free.
///
/// A name already taken by someone else's private room comes back as a
/// [`crate::RoomEvent::CantCreate`] rather than a join.
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn join_private_room(&self, room: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_join_room(room, true))
}
/// Invite `username` into the private room we own (code 134).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn add_room_member(&self, room: &str, username: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_add_room_member(
room, username,
))
}
/// Remove `username` from the private room we own (code 135).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn remove_room_member(&self, room: &str, username: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_remove_room_member(
room, username,
))
}
/// Give up our own membership of a private room (code 136).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn leave_private_room(&self, room: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_cancel_room_membership(
room,
))
}
/// Give up ownership of a private room, which deletes it (code 137).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn disband_private_room(&self, room: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_cancel_room_ownership(
room,
))
}
/// Say whether we accept private-room invitations (code 141).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn set_room_invitations_enabled(&self, enabled: bool) -> Result<()> {
self.send_server_message(MessageFactory::build_enable_room_invitations(
enabled,
))
}
/// Make `username` an operator of our private room (code 143).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn add_room_operator(&self, room: &str, username: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_add_room_operator(
room, username,
))
}
/// Take operator status away from `username` (code 144).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn remove_room_operator(
&self,
room: &str,
username: &str,
) -> Result<()> {
self.send_server_message(MessageFactory::build_remove_room_operator(
room, username,
))
}
/// Give up our own operator status in `room` (code 147).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn resign_room_operatorship(&self, room: &str) -> Result<()> {
self.send_server_message(
MessageFactory::build_cancel_room_operatorship(room),
)
}
/// Who may enter the private room `room`, as the server last reported.
#[must_use]
pub fn private_room_members(&self, room: &str) -> Vec<String> {
match self.context.read_safe() {
Ok(ctx) => ctx.private_room_members(room),
Err(e) => {
error!("[client] private_room_members: {}", e);
Vec::new()
}
}
}
/// Who runs the private room `room`.
#[must_use]
pub fn private_room_operators(&self, room: &str) -> Vec<String> {
match self.context.read_safe() {
Ok(ctx) => ctx.private_room_operators(room),
Err(e) => {
error!("[client] private_room_operators: {}", e);
Vec::new()
}
}
}
/// The private rooms we belong to.
#[must_use]
pub fn private_rooms(&self) -> Vec<String> {
match self.context.read_safe() {
Ok(ctx) => ctx.private_rooms(),
Err(e) => {
error!("[client] private_rooms: {}", e);
Vec::new()
}
}
}
/// Set our online status (server code 28): away, or back to online.
///
/// Other clients see it in their `GetUserStatus` and watch replies, and
/// the server uses it when it decides who to hand a search to.
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn set_away(&self, away: bool) -> Result<()> {
let status = u32::from(!away) + 1; // away = 1, online = 2
self.send_server_message(MessageFactory::build_set_status_message(
status,
))
}
/// Ask `username` what it says about itself (peer code 15): its
/// description, upload slots and queue length — a user's profile as other
/// clients show it.
///
/// Any previous answer is dropped first, so [`Client::peer_info`] reports
/// the reply to this request rather than a stale one.
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection,
/// or [`crate::SoulseekRs::LockPoisoned`] if the client state is poisoned.
pub fn request_peer_info(&self, username: &str) -> Result<()> {
self.context.write_safe()?.invalidate_peer_info(username);
let request = crate::message::peer::build_user_info_request();
self.send_to_peer_or_queue(username, request)
}
/// What `username` last said about itself.
#[must_use]
pub fn peer_info(
&self,
username: &str,
) -> Option<crate::message::peer::PeerInfo> {
self.context
.read_safe()
.ok()
.and_then(|ctx| ctx.peer_info(username))
}
/// Ask `username` for the contents of one folder of their shares (peer
/// code 36) — what "download folder" in other clients asks for, without
/// pulling their whole listing.
///
/// The answer arrives asynchronously; take it with
/// [`Client::take_folder_contents`].
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection,
/// or [`crate::SoulseekRs::LockPoisoned`] if the client state is poisoned.
pub fn request_folder_contents(
&self,
username: &str,
folder: &str,
) -> Result<()> {
let request = crate::message::peer::build_folder_contents_request(
crate::client::next_search_token(),
folder,
);
self.send_to_peer_or_queue(username, request)
}
/// Remove and return the listing of `folder` from `username`, once their
/// answer to [`Client::request_folder_contents`] has arrived.
#[must_use]
pub fn take_folder_contents(
&self,
username: &str,
folder: &str,
) -> Option<Vec<crate::SharedDirectory>> {
self.context
.write_safe()
.ok()
.and_then(|mut ctx| ctx.take_folder_contents(username, folder))
}
/// Ask `username` where the file we queued with them sits (peer code 51).
///
/// The answer arrives asynchronously and lands on the download, readable
/// through [`Client::downloads`] as its queue position. A peer we have no
/// connection to is resolved and the request delivered once it is up, the
/// same way a browse request is.
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection,
/// or [`crate::SoulseekRs::LockPoisoned`] if the client state is poisoned.
pub fn request_place_in_queue(
&self,
username: &str,
filename: &str,
) -> Result<()> {
let request = MessageFactory::build_place_in_queue_request(filename);
self.send_to_peer_or_queue(username, request)
}
/// Set our ticker in `room` — the one-line message other members see.
/// Passing an empty `ticker` clears ours.
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn set_room_ticker(&self, room: &str, ticker: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_set_room_ticker(
room, ticker,
))
}
/// The ticker board of `room`: what the server sent on join (code 113),
/// kept current by the later add/remove events.
#[must_use]
pub fn room_tickers(&self, room: &str) -> Vec<RoomTicker> {
match self.context.read_safe() {
Ok(ctx) => ctx.room_tickers(room),
Err(e) => {
error!("[client] room_tickers: {}", e);
Vec::new()
}
}
}
/// Subscribe to the global room feed (code 150): every public room message
/// on the server arrives as a [`crate::RoomEvent::GlobalMessage`].
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn join_global_room(&self) -> Result<()> {
self.send_server_message(MessageFactory::build_join_global_room())
}
/// Stop the global room feed (code 151).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn leave_global_room(&self) -> Result<()> {
self.send_server_message(MessageFactory::build_leave_global_room())
}
/// Add an interest (code 51). Interests are what the recommendation and
/// similar-user queries below are computed from.
///
/// The server holds interests only for the session that set them, so the
/// list is kept here and sent again after every login. An interest added
/// before there is a connection is kept all the same — the error says
/// only that it could not go out yet, not that it was dropped.
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn add_interest(&self, item: &str) -> Result<()> {
let Some(item) =
self.context.write_safe()?.add_own_interest(item, true)
else {
return Ok(()); // nothing to like or unlike
};
self.send_server_message(MessageFactory::build_add_thing_i_like(&item))
}
/// Drop an interest (code 52).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn remove_interest(&self, item: &str) -> Result<()> {
let Some(item) =
self.context.write_safe()?.remove_own_interest(item, true)
else {
return Ok(()); // nothing to like or unlike
};
self.send_server_message(MessageFactory::build_remove_thing_i_like(
&item,
))
}
/// Add a dislike (code 117).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn add_dislike(&self, item: &str) -> Result<()> {
let Some(item) =
self.context.write_safe()?.add_own_interest(item, false)
else {
return Ok(()); // nothing to like or unlike
};
self.send_server_message(MessageFactory::build_add_thing_i_hate(&item))
}
/// Drop a dislike (code 118).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn remove_dislike(&self, item: &str) -> Result<()> {
let Some(item) =
self.context.write_safe()?.remove_own_interest(item, false)
else {
return Ok(()); // nothing to like or unlike
};
self.send_server_message(MessageFactory::build_remove_thing_i_hate(
&item,
))
}
/// Ask for recommendations drawn from our own interests (code 54). The
/// reply arrives asynchronously; read it with
/// [`Client::recommendations`].
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn request_recommendations(&self) -> Result<()> {
self.send_server_message(MessageFactory::build_get_recommendations())
}
/// Ask for the server-wide recommendations (code 56); read the reply with
/// [`Client::global_recommendations`].
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn request_global_recommendations(&self) -> Result<()> {
self.send_server_message(MessageFactory::build_global_recommendations())
}
/// The upload speed the server records for us, once it has reported it.
/// It is what the distributed child limit is derived from, and it only
/// becomes known after an upload has finished and been reported.
#[must_use]
pub fn own_average_speed(&self) -> Option<u32> {
self.context
.read_safe()
.ok()
.and_then(|ctx| ctx.own_average_speed())
}
/// What we ourselves like and hate, as set through this client. The
/// server forgets these when the session ends, so they are re-sent after
/// each login.
#[must_use]
pub fn own_interests(&self) -> UserInterests {
match self.context.read_safe() {
Ok(ctx) => ctx.own_interests(),
Err(e) => {
error!("[client] own_interests: {}", e);
UserInterests::default()
}
}
}
/// The last reply to [`Client::request_recommendations`] as
/// (recommended, recommended-against), or `None` until one arrives.
#[must_use]
pub fn recommendations(
&self,
) -> Option<(Vec<Recommendation>, Vec<Recommendation>)> {
self.context
.read_safe()
.ok()
.and_then(|ctx| ctx.recommendations(false))
}
/// The last reply to [`Client::request_global_recommendations`].
#[must_use]
pub fn global_recommendations(
&self,
) -> Option<(Vec<Recommendation>, Vec<Recommendation>)> {
self.context
.read_safe()
.ok()
.and_then(|ctx| ctx.recommendations(true))
}
/// Ask what else people who like `item` like (code 111).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn request_item_recommendations(&self, item: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_item_recommendations(
item,
))
}
/// The last per-item recommendations received for `item`.
#[must_use]
pub fn item_recommendations(&self, item: &str) -> Vec<Recommendation> {
match self.context.read_safe() {
Ok(ctx) => ctx.item_recommendations(item),
Err(e) => {
error!("[client] item_recommendations: {}", e);
Vec::new()
}
}
}
/// Ask which users share our interests (code 110).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn request_similar_users(&self) -> Result<()> {
self.send_server_message(MessageFactory::build_similar_users())
}
/// The last similar-user answer, weighted by how much we have in common.
#[must_use]
pub fn similar_users(&self) -> Vec<SimilarUser> {
match self.context.read_safe() {
Ok(ctx) => ctx.similar_users(),
Err(e) => {
error!("[client] similar_users: {}", e);
Vec::new()
}
}
}
/// Ask which users like `item` (code 112).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn request_item_similar_users(&self, item: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_item_similar_users(item))
}
/// The last answer to [`Client::request_item_similar_users`] for `item`.
#[must_use]
pub fn item_similar_users(&self, item: &str) -> Vec<String> {
match self.context.read_safe() {
Ok(ctx) => ctx.item_similar_users(item),
Err(e) => {
error!("[client] item_similar_users: {}", e);
Vec::new()
}
}
}
/// Ask what `username` likes and hates (code 57).
///
/// Any previous answer for `username` is dropped first, so
/// [`Client::user_interests`] reports the reply to *this* request rather
/// than a stale one.
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn request_user_interests(&self, username: &str) -> Result<()> {
self.context
.write_safe()?
.invalidate_user_interests(username);
self.send_server_message(MessageFactory::build_user_interests(username))
}
/// What the server last told us `username` likes and hates.
#[must_use]
pub fn user_interests(&self, username: &str) -> Option<UserInterests> {
self.context
.read_safe()
.ok()
.and_then(|ctx| ctx.user_interests(username))
}
/// Give `days` of our own privileges to `username` (code 123).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn give_privileges(&self, username: &str, days: u32) -> Result<()> {
self.send_server_message(MessageFactory::build_give_privileges(
username, days,
))
}
/// Change our account password (code 142).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn change_password(&self, password: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_change_password(
password,
))
}
/// Send one private message to several users at once (code 149). Each
/// recipient receives it as an ordinary private message.
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn send_private_message_to_many(
&self,
usernames: &[String],
message: &str,
) -> Result<()> {
self.send_server_message(MessageFactory::build_message_users(
usernames, message,
))
}
}
+52
View File
@@ -0,0 +1,52 @@
//! The client's context, exercised directly.
//!
//! This file is the harness the topic modules below share.
mod peers;
mod rooms;
mod search;
mod transfers;
mod users;
use super::*;
fn download(
username: &str,
filename: &str,
token: u32,
status: DownloadStatus,
sender: Sender<DownloadStatus>,
) -> Download {
Download {
username: username.to_string(),
filename: filename.to_string(),
token,
size: 100,
download_directory: "test".to_string(),
status,
sender,
queue_position: None,
metadata: DownloadMetadata::default(),
}
}
fn peer_files(count: usize) -> SearchResult {
SearchResult {
token: 1,
files: (0..count)
.map(|i| crate::types::File {
username: "bob".to_string(),
name: format!("song-{i}.mp3"),
size: 1,
attribs: std::collections::HashMap::new(),
})
.collect(),
slots: 1,
speed: 0,
username: "bob".to_string(),
}
}
use crate::types::{RoomEvent, UploadStatus};
use std::time::{Duration, Instant};
+173
View File
@@ -0,0 +1,173 @@
//! Uploads, broker connects, and the peers a disconnect may not drop.
use super::*;
#[test]
fn upload_speed_is_reported_only_while_running() {
let two_seconds_ago = Instant::now()
.checked_sub(Duration::from_secs(2))
.expect("clock supports a 2s offset");
// 1 KiB over ~2s is ~512 B/s.
let rate = upload_speed(&UploadStatus::InProgress, 1024, two_seconds_ago);
assert!((rate - 512.0).abs() < 50.0, "unexpected rate {rate}");
// A finished upload reports no rate, exactly as a finished download
// does, so the Speed column renders "-" rather than a stale figure.
for status in [
UploadStatus::Completed,
UploadStatus::Cancelled,
UploadStatus::Failed("nope".to_string()),
] {
let rate = upload_speed(&status, 1024, two_seconds_ago);
assert!(rate.abs() < f64::EPSILON, "unexpected rate {rate}");
}
// A just-started upload must not divide by a zero elapsed time.
let rate = upload_speed(&UploadStatus::InProgress, 0, Instant::now());
assert!(
rate.is_finite() && rate.abs() < f64::EPSILON,
"unexpected rate {rate}"
);
}
// A clean disconnect — the idle reaper, or a remote client tidying an idle
// socket while it waits in our queue — must keep the peer's queued uploads;
// only an error disconnect is evidence the peer is gone.
#[test]
fn a_clean_disconnect_keeps_queued_uploads_an_error_drops_them() {
let client = Client::new("test-user", "test-password");
let (ops, ops_rx) = mpsc::channel();
Client::listen_to_client_operations(
ops_rx,
client.context.clone(),
"me".to_string(),
);
client.context.write().unwrap().enqueue_upload(
"amy",
"@@share\\f.mp3",
std::path::PathBuf::from("/tmp/f.mp3"),
4096,
);
ops.send(ClientOperation::PeerDisconnected(
1,
"amy".to_string(),
None,
))
.unwrap();
// The loop is serial, so once this fence op is visible the disconnect
// before it has been handled.
ops.send(ClientOperation::OwnPrivileges(7)).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while client.own_privilege_seconds() != Some(7) {
assert!(Instant::now() < deadline, "ops loop never caught up");
thread::sleep(Duration::from_millis(10));
}
assert_eq!(
client
.context
.read()
.unwrap()
.place_in_queue("amy", "@@share\\f.mp3"),
Some(1),
"a clean disconnect must keep the queue"
);
ops.send(ClientOperation::PeerDisconnected(
1,
"amy".to_string(),
Some(crate::error::SoulseekRs::NotConnected),
))
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let place = client
.context
.read()
.unwrap()
.place_in_queue("amy", "@@share\\f.mp3");
if place.is_none() {
break;
}
assert!(
Instant::now() < deadline,
"an error disconnect must drop the queue"
);
thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn a_pending_connect_expires_after_the_broker_timeout() {
let mut context = ClientContext::new();
context.add_pending_connect(7, "ghost".to_string());
assert!(context.take_expired_connects(Instant::now()).is_empty());
let after_deadline =
Instant::now() + BROKER_CONNECT_TIMEOUT + Duration::from_secs(1);
assert_eq!(
context.take_expired_connects(after_deadline),
vec!["ghost".to_string()]
);
assert!(context.take_pending_connect(7).is_none());
}
#[test]
fn protected_peers_covers_downloads_uploads_and_pending_serves() {
let mut context = ClientContext::new();
context.add_download(download(
"downloader",
"song.mp3",
1,
DownloadStatus::Queued,
mpsc::channel().0,
));
context.add_download(download(
"done",
"old.mp3",
2,
DownloadStatus::Completed,
mpsc::channel().0,
));
context
.pending_serves
.insert("waiting".to_string(), vec![9]);
context.mark_browse_pending("browsed");
let protected = context.protected_peers();
assert!(protected.contains("downloader"));
assert!(protected.contains("waiting"));
assert!(protected.contains("browsed"));
assert!(!protected.contains("done"));
context.store_browse_result("browsed".to_string(), Vec::new());
assert!(!context.protected_peers().contains("browsed"));
}
#[test]
fn a_peer_message_nobody_could_deliver_expires() {
let mut ctx = ClientContext::for_user("me");
ctx.queue_peer_message("ghost", crate::message::Message::new());
ctx.expire_pending_peer_messages(Instant::now());
assert_eq!(ctx.take_peer_messages("ghost").len(), 1, "still fresh");
ctx.queue_peer_message("ghost", crate::message::Message::new());
ctx.expire_pending_peer_messages(Instant::now() + PENDING_PEER_TTL);
assert!(ctx.take_peer_messages("ghost").is_empty(), "expired");
}
#[test]
fn the_server_is_told_our_child_capacity_only_when_it_changes() {
// AcceptChildren is a standing state on the server, not a heartbeat.
let mut ctx = ClientContext::new();
assert_eq!(
ctx.accept_children_change(),
Some(false),
"the first answer is always worth sending"
);
assert_eq!(ctx.accept_children_change(), None, "unchanged, so silent");
}
+316
View File
@@ -0,0 +1,316 @@
//! Room rosters, ticker boards, and the private rooms the server lets us
//! see.
use super::*;
#[test]
fn the_room_roster_follows_the_joins_and_leaves_the_server_reports() {
let mut context = ClientContext::new();
// Joining hands us the current membership in one event.
context.apply_room_event(RoomEvent::Joined {
room: "lobby".to_string(),
users: vec!["bob".to_string(), "alice".to_string()],
});
assert_eq!(context.room_members("lobby"), ["alice", "bob"]);
context.apply_room_event(RoomEvent::UserJoined {
room: "lobby".to_string(),
username: "carol".to_string(),
});
context.apply_room_event(RoomEvent::UserLeft {
room: "lobby".to_string(),
username: "bob".to_string(),
});
assert_eq!(context.room_members("lobby"), ["alice", "carol"]);
// Events for other rooms do not leak into this one.
context.apply_room_event(RoomEvent::UserJoined {
room: "elsewhere".to_string(),
username: "dave".to_string(),
});
assert_eq!(context.room_members("lobby"), ["alice", "carol"]);
// A room we never joined has no roster, and leaving forgets it.
assert!(context.room_members("unknown").is_empty());
context.apply_room_event(RoomEvent::Left {
room: "lobby".to_string(),
});
assert!(context.room_members("lobby").is_empty());
}
#[test]
fn a_join_announced_before_the_member_list_does_not_start_a_roster() {
// soulfind tells a room about a joiner, the joiner included, before it
// sends them the member list. A roster started from that echo reads as a
// room holding nobody else.
let mut context = ClientContext::new();
context.apply_room_event(RoomEvent::UserJoined {
room: "lobby".to_string(),
username: "me".to_string(),
});
assert!(context.room_members("lobby").is_empty());
context.apply_room_event(RoomEvent::Joined {
room: "lobby".to_string(),
users: vec!["me".to_string(), "alice".to_string()],
});
assert_eq!(context.room_members("lobby"), ["alice", "me"]);
}
#[test]
fn a_user_joining_twice_is_listed_once() {
let mut context = ClientContext::new();
context.apply_room_event(RoomEvent::Joined {
room: "lobby".to_string(),
users: vec!["alice".to_string()],
});
context.apply_room_event(RoomEvent::UserJoined {
room: "lobby".to_string(),
username: "alice".to_string(),
});
assert_eq!(context.room_members("lobby"), ["alice"]);
}
#[test]
fn room_member_stats_are_returned_sorted_and_scoped_to_their_room() {
use crate::types::{RoomUserStats, UserStatus};
let stat = |username: &str| RoomUserStats {
username: username.to_string(),
status: UserStatus::Online,
average_speed: 1,
shared_files: 2,
shared_folders: 3,
slots_full: true,
country: None,
};
let mut context = ClientContext::new();
context.apply_room_member_stats(
"jazz".to_string(),
vec![stat("carol"), stat("alice")],
);
let names: Vec<String> = context
.room_member_stats("jazz")
.into_iter()
.map(|s| s.username)
.collect();
assert_eq!(names, vec!["alice", "carol"]);
assert!(context.room_member_stats("unjoined").is_empty());
}
#[test]
fn rejoining_replaces_the_previous_member_stats() {
use crate::types::{RoomUserStats, UserStatus};
let stat = |username: &str| RoomUserStats {
username: username.to_string(),
status: UserStatus::Online,
average_speed: 1,
shared_files: 2,
shared_folders: 3,
slots_full: false,
country: None,
};
let mut context = ClientContext::new();
context.apply_room_member_stats("jazz".to_string(), vec![stat("alice")]);
context.apply_room_member_stats("jazz".to_string(), vec![stat("bob")]);
let stats = context.room_member_stats("jazz");
assert_eq!(stats.len(), 1, "a rejoin supersedes the old snapshot");
assert_eq!(stats[0].username, "bob");
}
#[test]
fn a_new_ticker_replaces_that_users_previous_one() {
// The server treats a user's ticker as singular: a second one from the
// same user supersedes the first rather than stacking beside it.
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::Tickers {
room: "jazz".into(),
tickers: vec![
RoomTicker {
username: "alice".into(),
ticker: "first".into(),
},
RoomTicker {
username: "bob".into(),
ticker: "bobs".into(),
},
],
});
ctx.apply_room_event(RoomEvent::TickerAdded {
room: "jazz".into(),
username: "alice".into(),
ticker: "second".into(),
});
let board = ctx.room_tickers("jazz");
assert_eq!(board.len(), 2);
assert_eq!(
board.iter().find(|t| t.username == "alice").unwrap().ticker,
"second"
);
}
#[test]
fn a_removed_ticker_leaves_the_rest_of_the_board() {
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::Tickers {
room: "jazz".into(),
tickers: vec![
RoomTicker {
username: "alice".into(),
ticker: "a".into(),
},
RoomTicker {
username: "bob".into(),
ticker: "b".into(),
},
],
});
ctx.apply_room_event(RoomEvent::TickerRemoved {
room: "jazz".into(),
username: "alice".into(),
});
let board = ctx.room_tickers("jazz");
assert_eq!(board.len(), 1);
assert_eq!(board[0].username, "bob");
}
#[test]
fn a_global_message_is_queued_without_touching_room_membership() {
// The global feed carries messages from rooms we have not joined; they
// must not invent a roster for those rooms.
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::GlobalMessage {
room: "elsewhere".into(),
username: "alice".into(),
message: "hi".into(),
});
assert!(ctx.room_members("elsewhere").is_empty());
assert_eq!(ctx.take_room_events().len(), 1);
}
#[test]
fn a_private_room_roster_tracks_who_is_added_and_removed() {
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::PrivateMembers {
room: "club".into(),
users: vec!["bob".into(), "alice".into(), "bob".into()],
});
assert_eq!(
ctx.private_room_members("club"),
vec!["alice".to_string(), "bob".to_string()],
"a roster is sorted and free of repeats"
);
ctx.apply_room_event(RoomEvent::PrivateRosterChanged {
room: "club".into(),
username: "carol".into(),
members: true,
added: true,
});
ctx.apply_room_event(RoomEvent::PrivateRosterChanged {
room: "club".into(),
username: "alice".into(),
members: true,
added: false,
});
assert_eq!(
ctx.private_room_members("club"),
vec!["bob".to_string(), "carol".to_string()]
);
// Operators are a separate roster in the same room.
ctx.apply_room_event(RoomEvent::PrivateRosterChanged {
room: "club".into(),
username: "bob".into(),
members: false,
added: true,
});
assert_eq!(ctx.private_room_operators("club"), vec!["bob".to_string()]);
assert_eq!(ctx.private_rooms(), vec!["club".to_string()]);
}
#[test]
fn revoked_membership_drops_the_room_we_can_no_longer_see() {
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::PrivateMembers {
room: "club".into(),
users: vec!["alice".into()],
});
ctx.apply_room_event(RoomEvent::PrivateOperators {
room: "club".into(),
users: vec!["alice".into()],
});
ctx.apply_room_event(RoomEvent::OwnStandingChanged {
room: "club".into(),
members: true,
granted: false,
});
assert!(ctx.private_rooms().is_empty());
assert!(ctx.private_room_operators("club").is_empty());
}
#[test]
fn losing_operatorship_keeps_the_roster_we_can_still_see() {
// Demotion does not blind us: we are still a member, and the room's other
// operators are still ours to show. The server narrates our own removal
// from that roster separately (code 144).
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::PrivateMembers {
room: "club".into(),
users: vec!["alice".into()],
});
ctx.apply_room_event(RoomEvent::PrivateOperators {
room: "club".into(),
users: vec!["alice".into(), "bob".into()],
});
ctx.apply_room_event(RoomEvent::OwnStandingChanged {
room: "club".into(),
members: false,
granted: false,
});
assert_eq!(ctx.private_room_members("club"), vec!["alice".to_string()]);
assert_eq!(
ctx.private_room_operators("club"),
vec!["alice".to_string(), "bob".to_string()],
"the other operators are still there to show"
);
ctx.apply_room_event(RoomEvent::PrivateRosterChanged {
room: "club".into(),
username: "alice".into(),
members: false,
added: false,
});
assert_eq!(
ctx.private_room_operators("club"),
vec!["bob".to_string()],
"only the demotion the server narrates removes us"
);
}
#[test]
fn leaving_a_room_drops_its_ticker_board() {
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::Tickers {
room: "jazz".into(),
tickers: vec![RoomTicker {
username: "alice".into(),
ticker: "hi".into(),
}],
});
ctx.apply_room_event(RoomEvent::Left {
room: "jazz".into(),
});
assert!(
ctx.room_tickers("jazz").is_empty(),
"a board for a room we left is stale"
);
}
+167
View File
@@ -0,0 +1,167 @@
//! Answering a search against our shares, and what the client keeps of
//! the answers it gets back.
use super::*;
#[test]
fn build_search_response_matches_shares_and_echoes_token() {
let dir = std::env::temp_dir()
.join(format!("soulseek-searchresp-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("probe_xyzzy.bin"), b"data").unwrap();
let shares = Shares::scan(&dir).unwrap();
let response =
build_search_response(&shares, "me", 99, "xyzzy", true, 0, 0, &[])
.expect("a matching share yields a response");
let mut decoded =
crate::message::Message::new_with_data(response.get_buffer());
decoded.set_pointer(8);
let result = SearchResult::new_from_message(&mut decoded).unwrap();
assert_eq!(result.username, "me");
assert_eq!(result.token, 99);
assert!(result.files.iter().any(|f| f.name.contains("probe_xyzzy")));
assert!(
build_search_response(&shares, "me", 1, "nomatch", true, 0, 0, &[])
.is_none()
);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn search_file_counts_cover_every_search_without_the_results() {
// The daemon polls these counts many times a second; they must come from
// a walk of the cache, never a copy of it.
let client = Client::new("u", "p");
{
let mut context = client.context.write().unwrap();
context.searches.insert(
"aphex twin".to_string(),
Search {
token: 1,
results: vec![peer_files(2), peer_files(1)],
},
);
context.searches.insert(
"nothing yet".to_string(),
Search {
token: 2,
results: Vec::new(),
},
);
}
let mut counts = client.search_file_counts();
counts.sort();
assert_eq!(
counts,
[
("aphex twin".to_string(), 3),
("nothing yet".to_string(), 0)
]
);
}
#[test]
fn a_search_stops_collecting_once_it_has_enough_responses() {
// A popular query on the live network draws answers for minutes —
// nearly a million files and gigabytes of memory for one search.
// Surplus responders are dropped, not archived.
let mut search = Search {
token: 1,
results: Vec::new(),
};
for _ in 0..(crate::types::MAX_SEARCH_RESPONSES + 50) {
search.accept(peer_files(1));
}
assert_eq!(search.results.len(), crate::types::MAX_SEARCH_RESPONSES);
}
#[test]
fn a_flood_of_files_fills_a_search_before_the_response_cap() {
// A handful of whales with huge matching collections must not add up
// to an unbounded set just because the responses are few.
let mut search = Search {
token: 1,
results: Vec::new(),
};
for _ in 0..10 {
search.accept(peer_files(crate::types::MAX_SEARCH_FILES / 2));
}
assert_eq!(
search.results.len(),
2,
"two half-cap responses fill the search; the rest are dropped"
);
}
#[test]
fn a_file_the_server_excludes_is_left_out_of_a_reply() {
// The server's excluded phrases (code 160) police what travels the search
// network: a matching file whose path carries one must not be offered,
// and a reply with nothing left is not sent at all.
let dir = std::env::temp_dir()
.join(format!("soulseek-excluded-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Spam_Xyzzy.bin"), b"data").unwrap();
let shares = Shares::scan(&dir).unwrap();
assert!(
build_search_response(&shares, "me", 1, "xyzzy", true, 0, 0, &[])
.is_some(),
"with no exclusions the file is offered"
);
assert!(
build_search_response(
&shares,
"me",
1,
"xyzzy",
true,
0,
0,
&["spam".to_string()],
)
.is_none(),
"an excluded phrase in the path, matched case-insensitively, \
withholds the file — and with no files there is no reply"
);
assert!(
build_search_response(
&shares,
"me",
1,
"xyzzy",
true,
0,
0,
&["unrelated".to_string()],
)
.is_some(),
"an exclusion the path does not carry changes nothing"
);
// The phrases are lowercased where they arrive, so a server that sends
// one capitalised still matches a path.
let mut ctx = ClientContext::new();
ctx.set_excluded_search_phrases(vec!["SPAM".to_string()]);
assert!(
build_search_response(
&shares,
"me",
1,
"xyzzy",
true,
0,
0,
&ctx.excluded_search_phrases(),
)
.is_none(),
"a capitalised phrase from the server still withholds the file"
);
let _ = std::fs::remove_dir_all(&dir);
}
@@ -0,0 +1,329 @@
//! Downloads through the context: what is queued, paused, cancelled,
//! and what a peer's answer is allowed to start.
use super::*;
#[test]
fn test_client_context_downloads() {
let mut context = ClientContext::new();
let token = 123;
let new_token = 1234;
context.add_download(download(
"test",
"test.txt",
token,
DownloadStatus::Queued,
mpsc::channel().0,
));
assert!(context.get_download_by_token(123).is_some());
assert_eq!(context.get_download_tokens(), vec![123]);
assert_eq!(context.get_downloads().len(), 1);
if let Some(download) = context.get_download_by_token_mut(token) {
assert_eq!(download.token, token);
download.token = new_token;
}
assert!(context.get_download_by_token(new_token).is_some());
assert_eq!(context.get_download_tokens(), vec![new_token]);
context.remove_download(new_token);
assert_eq!(context.get_downloads().len(), 0);
assert!(context.get_download_by_token(1234).is_none());
}
#[test]
fn test_client_pause_and_resume_download() {
let client = Client::new("test-user", "test-password");
let (download_sender, download_receiver) = mpsc::channel();
client.context.write().unwrap().add_download(download(
"peer",
"song.mp3",
123,
DownloadStatus::InProgress {
bytes_downloaded: 25,
total_bytes: 100,
speed_bytes_per_sec: 10.0,
},
download_sender,
));
assert!(client.pause_download("peer", "song.mp3"));
assert!(matches!(
client
.context
.read()
.unwrap()
.get_download_by_token(123)
.unwrap()
.status,
DownloadStatus::Paused {
bytes_downloaded: 25,
total_bytes: 100
}
));
assert!(matches!(
download_receiver.try_recv().unwrap(),
DownloadStatus::Paused {
bytes_downloaded: 25,
total_bytes: 100
}
));
assert!(client.resume_download("peer", "song.mp3"));
assert!(matches!(
client
.context
.read()
.unwrap()
.get_download_by_token(123)
.unwrap()
.status,
DownloadStatus::InProgress {
bytes_downloaded: 25,
total_bytes: 100,
speed_bytes_per_sec: 0.0
}
));
}
#[test]
fn download_without_a_connection_resolves_failed() {
// A client that never connected has no server handle and no peer registry,
// so it cannot open a connection to the peer: the download must resolve to
// Failed rather than hang Queued forever.
let client = Client::new("test-user", "test-password");
let (_download, receiver) = client
.download(
"song.mp3".to_string(),
"peer".to_string(),
100,
"test".to_string(),
)
.expect("download() should return a handle");
assert!(matches!(
receiver.recv_timeout(Duration::from_secs(1)),
Ok(DownloadStatus::Failed(_))
));
}
#[test]
fn fail_queued_downloads_notifies_receiver_and_store() {
// When a brokered connect times out, every Queued download for the peer
// must resolve to Failed both on its channel and in the store.
let client = Client::new("u", "p");
let (sender, receiver) = mpsc::channel();
client.context.write().unwrap().add_download(download(
"peer",
"f.mp3",
7,
DownloadStatus::Queued,
sender,
));
Client::fail_queued_downloads(&client.context, "peer");
assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Failed(_))));
assert!(matches!(
client
.context
.read()
.unwrap()
.get_download_by_token(7)
.unwrap()
.status,
DownloadStatus::Failed(_)
));
}
#[test]
fn test_client_removes_only_queued_downloads() {
let client = Client::new("test-user", "test-password");
{
let mut context = client.context.write().unwrap();
context.add_download(download(
"peer",
"queued.mp3",
123,
DownloadStatus::Queued,
mpsc::channel().0,
));
context.add_download(download(
"peer",
"active.mp3",
456,
DownloadStatus::InProgress {
bytes_downloaded: 25,
total_bytes: 100,
speed_bytes_per_sec: 10.0,
},
mpsc::channel().0,
));
}
assert!(client.remove_queued_download("peer", "queued.mp3"));
assert!(!client.remove_queued_download("peer", "active.mp3"));
let context = client.context.read().unwrap();
assert!(context.get_download_by_token(123).is_none());
assert!(context.get_download_by_token(456).is_some());
}
#[test]
fn an_expired_broker_connect_fails_the_queued_downloads() {
let client = Client::new("u", "p");
let (sender, receiver) = mpsc::channel();
{
let mut ctx = client.context.write().unwrap();
ctx.add_download(download(
"ghost",
"f.mp3",
7,
DownloadStatus::Queued,
sender,
));
ctx.pending_connect_tokens
.insert(7, ("ghost".to_string(), Instant::now()));
}
let (_ops_tx, ops_rx) = mpsc::channel();
Client::listen_to_client_operations(
ops_rx,
client.context.clone(),
"u".to_string(),
);
let status = receiver.recv_timeout(Duration::from_secs(5));
assert!(
matches!(status, Ok(DownloadStatus::Failed(_))),
"the sweep must fail the queued download, got {status:?}"
);
}
#[test]
fn a_replayed_transfer_response_does_not_start_a_second_transfer() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let port = u32::from(listener.local_addr().unwrap().port());
let download_dir = std::env::temp_dir()
.join(format!("soulseek-replayed-transfer-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&download_dir);
std::fs::create_dir_all(&download_dir).unwrap();
let client = Client::new("u", "p");
let (sender, _receiver) = mpsc::channel();
let mut queued =
download("peer", "f.mp3", 9, DownloadStatus::Queued, sender);
queued.download_directory = download_dir.display().to_string();
client.context.write().unwrap().add_download(queued);
let (ops_tx, ops_rx) = mpsc::channel();
Client::listen_to_client_operations(
ops_rx,
client.context.clone(),
"u".to_string(),
);
let peer = Peer::new(
"peer".to_string(),
ConnectionType::F,
"127.0.0.1".to_string(),
port,
None,
0,
0,
0,
);
ops_tx
.send(ClientOperation::DownloadFromPeer(9, peer.clone(), true))
.unwrap();
ops_tx
.send(ClientOperation::DownloadFromPeer(9, peer, true))
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let mut connections = 0;
while Instant::now() < deadline {
match listener.accept() {
Ok(_) => {
connections += 1;
if connections == 1 {
thread::sleep(Duration::from_millis(500));
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
if connections >= 1 {
break;
}
thread::sleep(Duration::from_millis(20));
}
Err(_) => break,
}
}
assert_eq!(
connections, 1,
"a replayed TransferResponse must not dial the peer again"
);
let _ = std::fs::remove_dir_all(download_dir);
}
#[test]
fn a_cancelled_download_is_not_started_when_the_peer_allows_it() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let port = u32::from(listener.local_addr().unwrap().port());
let client = Client::new("u", "p");
let (sender, _receiver) = mpsc::channel();
client.context.write().unwrap().add_download(download(
"peer",
"f.mp3",
9,
DownloadStatus::Cancelled,
sender,
));
let (ops_tx, ops_rx) = mpsc::channel();
Client::listen_to_client_operations(
ops_rx,
client.context.clone(),
"u".to_string(),
);
let peer = Peer::new(
"peer".to_string(),
ConnectionType::F,
"127.0.0.1".to_string(),
port,
None,
0,
0,
0,
);
ops_tx
.send(ClientOperation::DownloadFromPeer(9, peer, true))
.unwrap();
thread::sleep(Duration::from_millis(500));
assert!(
matches!(listener.accept(), Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock),
"a cancelled download must not dial the peer"
);
}
#[test]
fn cancel_download_marks_the_store_and_answers_whether_it_matched() {
let client = Client::new("u", "p");
let (sender, receiver) = mpsc::channel();
client.context.write().unwrap().add_download(download(
"peer",
"f.mp3",
9,
DownloadStatus::Queued,
sender,
));
assert!(client.cancel_download("peer", "f.mp3"));
assert!(!client.cancel_download("peer", "f.mp3"));
assert!(!client.cancel_download("peer", "other.mp3"));
assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Cancelled)));
assert!(matches!(
client.get_all_downloads()[0].status,
DownloadStatus::Cancelled
));
}
+200
View File
@@ -0,0 +1,200 @@
//! What the server tells us about people: watches, interests, and the
//! two halves of a user snapshot.
use super::*;
#[test]
fn a_fresh_request_discards_the_previous_answer() {
// Without this, a poll after a second request returns the old
// snapshot immediately and the caller cannot tell stale from fresh.
let mut context = ClientContext::new();
context.apply_user_status("alice".to_string(), 2, false);
context.apply_user_stats("alice".to_string(), 10, 20, 30);
assert!(context.user_info("alice").is_some_and(|i| i.is_complete()));
context.invalidate_user_info("alice");
assert!(
context.user_info("alice").is_none(),
"a new request must not be answerable from the old reply"
);
}
#[test]
fn each_reply_fills_only_its_own_half() {
let mut context = ClientContext::new();
context.apply_user_status("bob".to_string(), 1, true);
let info = context.user_info("bob").expect("a snapshot");
assert!(!info.is_complete(), "stats have not arrived");
assert_eq!(
info.presence.map(|p| p.status),
Some(crate::types::UserStatus::Away)
);
assert!(info.stats.is_none(), "must not invent statistics");
context.apply_user_stats("bob".to_string(), 5, 6, 7);
let info = context.user_info("bob").expect("a snapshot");
assert!(info.is_complete());
assert_eq!(info.stats.map(|s| s.shared_files), Some(6));
assert_eq!(
info.presence.map(|p| p.privileged),
Some(true),
"the earlier half must survive the merge"
);
}
#[test]
fn a_watch_reply_fills_both_halves_of_the_snapshot() {
let mut context = ClientContext::new();
context.add_watched_user("alice");
context.apply_watched_user(
"alice".to_string(),
true,
Some(2),
Some(1024),
Some(20),
Some(3),
);
let info = context.user_info("alice").expect("a snapshot");
assert!(info.is_complete(), "a watch reply carries status and stats");
assert_eq!(info.stats.map(|s| s.average_speed), Some(1024));
assert_eq!(context.watched_users(), vec!["alice".to_string()]);
}
#[test]
fn a_watch_reply_keeps_a_privileged_flag_it_cannot_carry() {
// WatchUser has no privileged field, so it must not overwrite what
// GetUserStatus already told us with a fabricated `false`.
let mut context = ClientContext::new();
context.apply_user_status("bob".to_string(), 1, true);
context.apply_watched_user(
"bob".to_string(),
true,
Some(2),
Some(1),
Some(2),
Some(3),
);
let info = context.user_info("bob").expect("a snapshot");
assert_eq!(info.presence.map(|p| p.privileged), Some(true));
}
#[test]
fn watching_an_unknown_user_drops_them_from_the_list() {
// The server will never push status for a name it does not know, so
// keeping it in the watch list would show a permanently blank row.
let mut context = ClientContext::new();
context.add_watched_user("ghost");
context.apply_watched_user(
"ghost".to_string(),
false,
None,
None,
None,
None,
);
assert!(context.watched_users().is_empty());
assert!(context.user_info("ghost").is_none());
}
#[test]
fn unwatching_forgets_the_users_snapshot() {
let mut context = ClientContext::new();
context.add_watched_user("alice");
context.apply_user_status("alice".to_string(), 2, false);
context.remove_watched_user("alice");
assert!(context.watched_users().is_empty());
assert!(
context.user_info("alice").is_none(),
"a later re-watch must report a fresh answer"
);
}
#[test]
fn watched_users_are_listed_in_a_stable_order() {
let mut context = ClientContext::new();
context.add_watched_user("carol");
context.add_watched_user("alice");
context.add_watched_user("bob");
assert_eq!(context.watched_users(), vec!["alice", "bob", "carol"]);
}
// A reply queued for a searcher the server cannot place waits a minute,
// not forever: every such search would otherwise pin a map entry and a
// protected slot in the peer registry.
#[test]
fn own_and_global_recommendations_are_kept_apart() {
let mut ctx = ClientContext::new();
ctx.apply_recommendations(
false,
vec![Recommendation {
item: "jazz".into(),
rating: 2,
}],
Vec::new(),
);
ctx.apply_recommendations(
true,
vec![Recommendation {
item: "pop".into(),
rating: 9,
}],
Vec::new(),
);
assert_eq!(ctx.recommendations(false).unwrap().0[0].item, "jazz");
assert_eq!(ctx.recommendations(true).unwrap().0[0].item, "pop");
}
#[test]
fn asking_again_about_a_users_interests_drops_the_previous_answer() {
let mut ctx = ClientContext::new();
ctx.apply_user_interests(UserInterests {
username: "alice".into(),
likes: vec!["jazz".into()],
hates: Vec::new(),
});
assert!(ctx.user_interests("alice").is_some());
ctx.invalidate_user_interests("alice");
assert!(
ctx.user_interests("alice").is_none(),
"a stale answer must not be mistaken for the next one"
);
}
#[test]
fn our_own_interests_are_kept_lowercased_and_deduplicated() {
// The server matches interests case-insensitively and forgets them when
// the session ends, so they are held here in one spelling to be sent
// again next login.
let mut ctx = ClientContext::new();
assert_eq!(
ctx.add_own_interest(" Krautrock ", true).as_deref(),
Some("krautrock")
);
assert_eq!(
ctx.add_own_interest(" ", true),
None,
"an empty interest is not stored, and is not sent either"
);
ctx.add_own_interest("KRAUTROCK", true);
ctx.add_own_interest("Muzak", false);
let interests = ctx.own_interests();
assert_eq!(interests.likes, ["krautrock"]);
assert_eq!(interests.hates, ["muzak"]);
ctx.remove_own_interest("KrautRock", true);
assert!(ctx.own_interests().likes.is_empty());
assert_eq!(
ctx.own_interests().hates,
["muzak"],
"the other list stands"
);
}
+641
View File
@@ -0,0 +1,641 @@
//! The queue peers wait in for one of our upload slots.
//!
//! Soulseek's rules ask an alternative client for "respect / recognition of
//! privileges": a user who has donated is meant to be served before one who
//! has not. That only means anything if there is a queue to jump — serving
//! every request the instant it arrives treats a privileged peer exactly like
//! everybody else — so the slot cap and the ordering are one feature.
//!
//! Ordering is privileged-first, then first-come. Nothing else: a fancier
//! policy (per-user fairness, small-file-first) would be a different promise
//! than the one the rules ask for.
use super::{ClientContext, Duration, Instant, PeerRegistry, UploadJob};
use crate::types::{UploadInfo, UploadStatus};
use std::path::PathBuf;
/// Cap on remembered queued-upload states, so a caller that never drains
/// them cannot grow the list without bound.
const MAX_UPLOAD_EVENTS: usize = 256;
/// How long an offered slot waits for the peer's TransferResponse. A peer that
/// never answers, having gone away or hung, would otherwise hold the slot for
/// the life of the process; with two slots, two of them shut uploads down.
const OFFER_TIMEOUT: Duration = Duration::from_secs(30);
/// A queued upload that has just been given a slot, ready to be offered to the
/// peer that asked for it.
#[derive(Debug, Clone)]
pub struct UploadOffer {
pub requester_key: String,
pub token: u32,
pub virtual_path: String,
pub size: u64,
}
/// A file a peer has asked us for and is waiting on.
#[derive(Debug, Clone)]
pub struct QueuedUpload {
/// The asking peer's username — also the registry key of its actor.
pub downloader: String,
pub virtual_path: String,
pub real_path: PathBuf,
pub size: u64,
/// Whether the server listed this user as privileged when they asked.
pub privileged: bool,
/// Arrival order, so the tie-break among equals is first-come.
pub seq: u64,
}
/// What decides the order: donors first, then whoever asked first.
const fn rank(job: &QueuedUpload) -> (bool, u64) {
(!job.privileged, job.seq)
}
/// Indices of `queue` in the order they will be served.
#[must_use]
pub fn serve_order(queue: &[QueuedUpload]) -> Vec<usize> {
let mut order: Vec<usize> = (0..queue.len()).collect();
order.sort_by_key(|&i| rank(&queue[i]));
order
}
/// Which entry to serve next, or `None` when nobody is waiting.
#[must_use]
pub fn next_to_serve(queue: &[QueuedUpload]) -> Option<usize> {
queue
.iter()
.enumerate()
.min_by_key(|(_, job)| rank(job))
.map(|(index, _)| index)
}
/// A queue position as the protocol reports it: counting from 1.
fn as_place(zero_based: usize) -> u32 {
u32::try_from(zero_based + 1).unwrap_or(u32::MAX)
}
/// Where `filename` sits for `downloader`, counting from 1 as the protocol's
/// `PlaceInQueueResponse` does. `None` when that peer is not waiting for it,
/// which is also the answer once the file has been handed over.
#[must_use]
pub fn place_in_queue(
queue: &[QueuedUpload],
downloader: &str,
filename: &str,
) -> Option<u32> {
serve_order(queue)
.into_iter()
.position(|i| {
queue[i].downloader == downloader
&& queue[i].virtual_path == filename
})
.map(as_place)
}
/// The queue as the client drives it: taking a request, filling slots, and
/// answering where a peer sits.
impl ClientContext {
/// Take `filename` from a peer's request and put them in the queue. The
/// caller pumps afterwards; queueing and serving are separate so a request
/// for a file we do not have never reaches the queue.
pub fn enqueue_upload(
&mut self,
downloader: &str,
filename: &str,
real_path: std::path::PathBuf,
size: u64,
) {
self.upload_seq += 1;
self.upload_queue.push(QueuedUpload {
downloader: downloader.to_string(),
virtual_path: filename.to_string(),
real_path,
size,
privileged: self.privileged_users.contains(downloader),
seq: self.upload_seq,
});
}
/// Move as many queued uploads into free slots as will fit, and return the
/// offers to send. Ordering is privileged-first, then first-come.
///
/// The registry comes back with them so the caller can send after dropping
/// the context lock: a peer actor's mailbox is not something to hold it
/// across.
pub fn pump_uploads(
&mut self,
mut next_token: impl FnMut() -> u32,
) -> (Option<PeerRegistry>, Vec<UploadOffer>) {
let mut offers = Vec::new();
while self.has_free_upload_slot() {
let Some(index) = next_to_serve(&self.upload_queue) else {
break;
};
let job = self.upload_queue.remove(index);
let token = next_token();
offers.push(UploadOffer {
requester_key: job.downloader.clone(),
token,
virtual_path: job.virtual_path.clone(),
size: job.size,
});
self.uploads.insert(
token,
UploadJob {
downloader: job.downloader,
real_path: job.real_path,
virtual_path: job.virtual_path,
size: job.size,
offered: Some(Instant::now()),
},
);
}
// Whatever is still waiting had to wait. Record it, because a caller
// polling `uploads()` on an interval cannot see a peer that queued and
// was served between two polls — and with a small slot count that is
// the common case, not the rare one.
for waiting in self.queued_uploads() {
let already = self.upload_events.iter().any(|seen| {
seen.username == waiting.username
&& seen.filename == waiting.filename
});
if !already {
self.upload_events.push(waiting);
}
}
// A caller that never drains must not grow this without bound.
if self.upload_events.len() > MAX_UPLOAD_EVENTS {
let excess = self.upload_events.len() - MAX_UPLOAD_EVENTS;
self.upload_events.drain(..excess);
}
(self.peer_registry.clone(), offers)
}
/// Take the queued-upload states recorded since the last call.
pub fn take_upload_events(&mut self) -> Vec<UploadInfo> {
std::mem::take(&mut self.upload_events)
}
/// Uploads occupying a slot: offered but not yet accepted, plus those
/// actually streaming.
///
/// `active_uploads` keeps finished transfers so `uploads()` can report them,
/// so only the in-progress ones count — counting the whole table would let
/// a few completed transfers wedge the queue shut forever.
fn uploads_in_flight(&self) -> usize {
self.uploads.len()
+ self
.active_uploads
.values()
.filter(|upload| {
matches!(upload.status, UploadStatus::InProgress)
})
.count()
}
pub(super) fn has_free_upload_slot(&self) -> bool {
self.uploads_in_flight() < self.upload_slots
}
/// The peers still waiting, in the order they will be served, each carrying
/// its own 1-based place.
pub(super) fn queued_uploads(&self) -> Vec<UploadInfo> {
serve_order(&self.upload_queue)
.into_iter()
.enumerate()
.map(|(place, index)| {
let job = &self.upload_queue[index];
UploadInfo {
username: job.downloader.clone(),
filename: job.virtual_path.clone(),
size: job.size,
bytes_sent: 0,
speed_bytes_per_sec: 0.0,
status: UploadStatus::Queued(as_place(place)),
}
})
.collect()
}
/// Where `downloader`'s queued `filename` sits, counting from 1. `None`
/// once it has left the queue for a slot.
#[must_use]
pub fn place_in_queue(
&self,
downloader: &str,
filename: &str,
) -> Option<u32> {
place_in_queue(&self.upload_queue, downloader, filename)
}
/// Replace the privileged set, re-ranking anyone already waiting: the list
/// arrives at login, which can be after a peer has queued something.
pub fn set_privileged_users(&mut self, users: Vec<String>) {
self.privileged_users = users.into_iter().collect();
for job in &mut self.upload_queue {
job.privileged = self.privileged_users.contains(&job.downloader);
}
}
#[must_use]
pub fn is_privileged(&self, username: &str) -> bool {
self.privileged_users.contains(username)
}
/// Drop offers older than [`OFFER_TIMEOUT`], recording each as a failed
/// upload so an operator sees why the peer never got its file. Returns
/// whether anything was dropped, which is the caller's cue to pump again.
pub fn expire_stale_offers(&mut self, now: Instant) -> bool {
let stale: Vec<u32> = self
.uploads
.iter()
.filter(|(_, job)| {
job.offered.is_some_and(|sent| {
now.duration_since(sent) >= OFFER_TIMEOUT
})
})
.map(|(&token, _)| token)
.collect();
for token in &stale {
let Some(job) = self.uploads.remove(token) else {
continue;
};
self.upload_events.push(UploadInfo {
username: job.downloader,
filename: job.virtual_path,
size: job.size,
bytes_sent: 0,
speed_bytes_per_sec: 0.0,
status: UploadStatus::Failed(
"no answer to the transfer offer".to_string(),
),
});
}
!stale.is_empty()
}
/// The peer accepted the offer for `token`; it may still take a while to
/// resolve their address and connect, and that wait must not expire it.
pub fn mark_offer_answered(&mut self, token: u32) {
if let Some(job) = self.uploads.get_mut(&token) {
job.offered = None;
}
}
/// Forget everything `downloader` was waiting for or had been offered, and
/// report whether that freed anything.
///
/// An offer sits in `uploads` counting against the slot cap until the peer
/// accepts it. A peer that never will — because it has gone — would hold
/// that slot for the life of the process, so leaving is what releases it.
pub fn release_upload_slots(&mut self, downloader: &str) -> bool {
let before = self.upload_queue.len() + self.uploads.len();
self.upload_queue.retain(|job| job.downloader != downloader);
self.uploads.retain(|_, job| job.downloader != downloader);
before != self.upload_queue.len() + self.uploads.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn queued(user: &str, privileged: bool, seq: u64) -> QueuedUpload {
QueuedUpload {
downloader: user.to_string(),
virtual_path: format!("@@share\\{user}.mp3"),
real_path: PathBuf::from("/tmp/x.mp3"),
size: 1,
privileged,
seq,
}
}
#[test]
fn an_empty_queue_serves_nobody() {
assert_eq!(next_to_serve(&[]), None);
}
#[test]
fn equals_are_served_in_the_order_they_asked() {
let queue = [queued("amy", false, 1), queued("bob", false, 2)];
assert_eq!(next_to_serve(&queue), Some(0));
assert_eq!(serve_order(&queue), [0, 1]);
}
#[test]
fn a_privileged_peer_is_served_before_one_that_asked_first() {
let queue = [
queued("early_plain", false, 1),
queued("late_donor", true, 2),
];
assert_eq!(
next_to_serve(&queue),
Some(1),
"the donor jumps the plain user who was already waiting"
);
}
#[test]
fn privileged_peers_keep_first_come_order_among_themselves() {
let queue = [
queued("donor_late", true, 9),
queued("plain", false, 2),
queued("donor_early", true, 5),
];
let order: Vec<&str> = serve_order(&queue)
.into_iter()
.map(|i| queue[i].downloader.as_str())
.collect();
assert_eq!(order, ["donor_early", "donor_late", "plain"]);
}
#[test]
fn the_reported_place_counts_from_one() {
let queue = [queued("amy", false, 1), queued("bob", false, 2)];
assert_eq!(place_in_queue(&queue, "amy", "@@share\\amy.mp3"), Some(1));
assert_eq!(place_in_queue(&queue, "bob", "@@share\\bob.mp3"), Some(2));
}
#[test]
fn the_reported_place_reflects_being_overtaken() {
let mut queue = vec![queued("plain", false, 1)];
assert_eq!(
place_in_queue(&queue, "plain", "@@share\\plain.mp3"),
Some(1)
);
queue.push(queued("donor", true, 2));
assert_eq!(
place_in_queue(&queue, "plain", "@@share\\plain.mp3"),
Some(2),
"a peer that got overtaken must be told so, not left on 1"
);
}
#[test]
fn a_file_nobody_queued_has_no_place() {
let queue = [queued("amy", false, 1)];
assert_eq!(place_in_queue(&queue, "amy", "@@share\\other.mp3"), None);
assert_eq!(place_in_queue(&queue, "zoe", "@@share\\amy.mp3"), None);
}
}
/// Tests of the queue as the client actually drives it: what a request, a
/// privileged-user list, and a finished transfer do to a real
/// [`super::ClientContext`].
///
/// These sit here rather than in the e2e suite because a Soulseek server only
/// ever names privileged users it knows about, and the soulfind used for e2e
/// declares nobody — so the one behaviour the rules are about would otherwise
/// go untested.
#[cfg(test)]
mod context_tests {
use super::OFFER_TIMEOUT;
use crate::client::{ClientContext, DEFAULT_UPLOAD_SLOTS};
use crate::types::UploadStatus;
use std::path::PathBuf;
/// A context with `slots` upload slots and a token counter that hands out
/// 1, 2, 3… so an assertion can name a token.
fn context(slots: usize) -> (ClientContext, impl FnMut() -> u32) {
let mut ctx = ClientContext::new();
ctx.upload_slots = slots;
let mut next = 0;
(ctx, move || {
next += 1;
next
})
}
fn ask(ctx: &mut ClientContext, user: &str, file: &str) {
ctx.enqueue_upload(user, file, PathBuf::from("/tmp/x"), 4096);
}
/// Who the pump offered to, by the order it offered them.
fn offered(
ctx: &mut ClientContext,
token: &mut impl FnMut() -> u32,
) -> Vec<String> {
ctx.pump_uploads(token)
.1
.into_iter()
.map(|offer| offer.requester_key)
.collect()
}
#[test]
fn a_fresh_context_starts_with_the_default_cap() {
assert_eq!(ClientContext::new().upload_slots, DEFAULT_UPLOAD_SLOTS);
}
#[test]
fn the_pump_fills_the_slots_and_leaves_the_rest_queued() {
let (mut ctx, mut token) = context(2);
for user in ["a", "b", "c"] {
ask(&mut ctx, user, "f.mp3");
}
assert_eq!(offered(&mut ctx, &mut token), ["a", "b"]);
assert_eq!(
ctx.place_in_queue("c", "f.mp3"),
Some(1),
"the third asker waits, now at the front of the queue"
);
assert!(
offered(&mut ctx, &mut token).is_empty(),
"pumping again must not oversubscribe the slots"
);
}
#[test]
fn a_privileged_list_arriving_late_re_ranks_who_is_waiting() {
let (mut ctx, mut token) = context(1);
ask(&mut ctx, "blocker", "f.mp3");
ask(&mut ctx, "plain", "f.mp3");
ask(&mut ctx, "donor", "f.mp3");
assert_eq!(offered(&mut ctx, &mut token), ["blocker"]);
// Before the server has said anything, first-come holds.
assert_eq!(ctx.place_in_queue("plain", "f.mp3"), Some(1));
assert_eq!(ctx.place_in_queue("donor", "f.mp3"), Some(2));
ctx.set_privileged_users(vec!["donor".to_string()]);
assert!(ctx.is_privileged("donor"));
assert!(!ctx.is_privileged("plain"));
assert_eq!(
ctx.place_in_queue("donor", "f.mp3"),
Some(1),
"the donor should overtake the peer that asked first"
);
assert_eq!(
ctx.place_in_queue("plain", "f.mp3"),
Some(2),
"and the plain peer should be told they were overtaken"
);
}
#[test]
fn the_privileged_peer_is_the_one_served_next() {
let (mut ctx, mut token) = context(1);
ctx.set_privileged_users(vec!["donor".to_string()]);
// Someone else already holds the only slot: a transfer in progress is
// never preempted, so the donor's turn is the *next* one.
ask(&mut ctx, "blocker", "f.mp3");
assert_eq!(offered(&mut ctx, &mut token), ["blocker"]);
ask(&mut ctx, "plain", "f.mp3");
ask(&mut ctx, "donor", "f.mp3");
ctx.uploads.clear(); // the blocker's transfer finished
assert_eq!(
offered(&mut ctx, &mut token),
["donor"],
"the freed slot goes to the donor, not the longer-waiting peer"
);
}
#[test]
fn a_finished_transfer_frees_its_slot_for_the_next_in_line() {
let (mut ctx, mut token) = context(1);
ask(&mut ctx, "first", "f.mp3");
ask(&mut ctx, "second", "f.mp3");
assert_eq!(offered(&mut ctx, &mut token), ["first"]);
ctx.uploads.clear();
assert_eq!(offered(&mut ctx, &mut token), ["second"]);
assert_eq!(ctx.place_in_queue("second", "f.mp3"), None);
}
#[test]
fn an_offer_nobody_answers_frees_its_slot_after_the_timeout() {
let (mut ctx, mut token) = context(1);
ask(&mut ctx, "gone", "f.mp3");
ask(&mut ctx, "next", "f.mp3");
assert_eq!(offered(&mut ctx, &mut token), ["gone"]);
let now = std::time::Instant::now();
assert!(!ctx.expire_stale_offers(now), "a fresh offer stays");
assert!(
ctx.expire_stale_offers(now + OFFER_TIMEOUT),
"an unanswered offer expires"
);
assert_eq!(offered(&mut ctx, &mut token), ["next"]);
assert!(ctx.take_upload_events().iter().any(|event| {
event.username == "gone"
&& matches!(event.status, UploadStatus::Failed(_))
}));
}
#[test]
fn an_accepted_offer_waiting_on_an_address_does_not_expire() {
let (mut ctx, mut token) = context(1);
ask(&mut ctx, "slow", "f.mp3");
offered(&mut ctx, &mut token);
ctx.mark_offer_answered(1);
let later = std::time::Instant::now() + OFFER_TIMEOUT * 2;
assert!(!ctx.expire_stale_offers(later), "an answered offer stays");
assert!(ctx.uploads.contains_key(&1));
}
#[test]
fn completed_transfers_do_not_keep_occupying_slots() {
// active_uploads keeps finished transfers so `uploads()` can report
// them; counting those would wedge the queue shut for the session.
let (mut ctx, mut token) = context(1);
ask(&mut ctx, "first", "f.mp3");
offered(&mut ctx, &mut token);
let job = ctx.uploads.remove(&1).expect("the offer was recorded");
ctx.active_uploads.insert(
1,
crate::client::ActiveUpload {
username: job.downloader,
filename: job.virtual_path,
size: job.size,
bytes_sent: std::sync::Arc::new(
std::sync::atomic::AtomicU64::new(job.size),
),
cancel: std::sync::Arc::new(
std::sync::atomic::AtomicBool::new(false),
),
status: UploadStatus::Completed,
started: std::time::Instant::now(),
},
);
ask(&mut ctx, "second", "f.mp3");
assert_eq!(
offered(&mut ctx, &mut token),
["second"],
"a completed upload must not hold its slot"
);
}
#[test]
fn queued_uploads_are_reported_in_the_order_they_will_be_served() {
let (mut ctx, mut token) = context(1);
ctx.set_privileged_users(vec!["donor".to_string()]);
ask(&mut ctx, "blocker", "f.mp3");
offered(&mut ctx, &mut token);
ask(&mut ctx, "plain", "f.mp3");
ask(&mut ctx, "donor", "f.mp3");
let reported: Vec<(String, UploadStatus)> = ctx
.queued_uploads()
.into_iter()
.map(|upload| (upload.username, upload.status))
.collect();
assert_eq!(
reported,
[
("donor".to_string(), UploadStatus::Queued(1)),
("plain".to_string(), UploadStatus::Queued(2)),
]
);
}
// A peer that waits and is then served inside one poll interval must still
// be reportable as queued: `serve` samples on an interval, so a state that
// only ever existed between two samples would otherwise vanish from the
// transfer log entirely.
#[test]
fn a_queued_upload_is_recorded_even_after_it_leaves_the_queue() {
let (mut ctx, mut token) = context(1);
ask(&mut ctx, "amy", "@@share\\amy.mp3");
ask(&mut ctx, "bob", "@@share\\bob.mp3");
// One slot, so bob waits behind amy.
let _ = ctx.pump_uploads(&mut token);
assert!(
ctx.queued_uploads().iter().any(|u| u.username == "bob"),
"bob should be waiting while amy holds the only slot"
);
// Amy finishes and bob is served, so nothing is waiting any more...
ctx.uploads.clear();
ctx.active_uploads.clear();
let _ = ctx.pump_uploads(&mut token);
assert!(
ctx.queued_uploads().is_empty(),
"the queue should have drained"
);
// ...but the fact that bob waited survives for whoever asks next.
let events = ctx.take_upload_events();
assert!(
events.iter().any(|u| u.username == "bob"
&& matches!(u.status, UploadStatus::Queued(_))),
"bob's wait should still be reportable, got {events:?}"
);
assert!(
ctx.take_upload_events().is_empty(),
"draining twice must not repeat the same events"
);
}
}
+362
View File
@@ -0,0 +1,362 @@
use super::{
ActiveUpload, Arc, BrokeredUpload, Client, ClientContext, DownloadStatus,
PeerMessage, RwLock, RwLockExt, ServerMessage, collect_failed_tokens,
debug, error, next_connect_token, next_upload_token, thread,
};
use crate::message::server::MessageFactory;
use crate::peer::{ConnectionType, upload_peer};
use crate::types::UploadStatus;
use std::net::TcpStream;
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
impl Client {
/// Whether the server listed `username` as privileged. Privileged peers are
/// served before everyone else.
#[must_use]
pub fn is_privileged(&self, username: &str) -> bool {
self.context
.read_safe()
.is_ok_and(|ctx| ctx.is_privileged(username))
}
/// Ask the server how much of our own privilege time is left (code 92). The
/// answer arrives asynchronously; read it with
/// [`Client::own_privilege_seconds`].
///
/// # Errors
/// [`crate::error::SoulseekRs::NotConnected`] without a server connection.
pub fn check_privileges(&self) -> crate::error::Result<()> {
let Some(handle) = &self.server_handle else {
return Err(crate::error::SoulseekRs::NotConnected);
};
let _ = handle.send(super::ServerMessage::CheckPrivileges);
Ok(())
}
/// Seconds of our own privileges left, or `None` until the server has
/// answered a [`Client::check_privileges`].
#[must_use]
pub fn own_privilege_seconds(&self) -> Option<u32> {
self.context
.read_safe()
.ok()
.and_then(|ctx| ctx.own_privileges)
}
/// Queued-upload states recorded since the last call, for a caller that
/// samples [`Client::uploads`] and would otherwise miss a peer that queued
/// and was served between two samples.
#[must_use]
pub fn take_upload_events(&self) -> Vec<crate::types::UploadInfo> {
self.context
.write_safe()
.map(|mut ctx| ctx.take_upload_events())
.unwrap_or_default()
}
/// How many uploads run at once. Lowering it does not interrupt transfers
/// already in flight; the queue simply refills more slowly.
pub fn set_upload_slots(&self, slots: usize) {
if let Ok(mut ctx) = self.context.write_safe() {
ctx.upload_slots = slots.max(1);
}
}
/// Drop everything `username` was queued or offered, then hand their slot to
/// whoever is next.
pub(crate) fn release_upload_slots(
client_context: &Arc<RwLock<ClientContext>>,
username: &str,
) {
let freed = client_context
.write_safe()
.is_ok_and(|mut ctx| ctx.release_upload_slots(username));
if freed {
Self::pump_upload_queue(client_context);
}
}
/// Fill every free upload slot from the queue and send the offers.
///
/// Called whenever the queue or the slot count could have changed: a new
/// request, a finished transfer, or a privileged list that re-ranked who is
/// waiting.
pub(crate) fn pump_upload_queue(
client_context: &Arc<RwLock<ClientContext>>,
) {
let (registry, offers) = match client_context.write_safe() {
Ok(mut ctx) => ctx.pump_uploads(next_upload_token),
Err(e) => {
error!("[client] pump_upload_queue write: {}", e);
return;
}
};
let Some(registry) = registry else {
return;
};
for offer in offers {
let _ = registry.send_to_peer(
&offer.requester_key,
PeerMessage::ServeUpload {
token: offer.token,
filename: offer.virtual_path,
size: offer.size,
},
);
}
}
/// Consume the upload job for `token` and stream the file to `host:port`
/// on a background thread.
pub(crate) fn spawn_serve(
client_context: &Arc<RwLock<ClientContext>>,
own_username: &str,
token: u32,
host: String,
port: u32,
) {
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
let Some(job) = ctx.uploads.remove(&token) else {
return;
};
let bytes_sent = Arc::new(AtomicU64::new(0));
let cancel = Arc::new(AtomicBool::new(false));
ctx.active_uploads.insert(
token,
ActiveUpload {
username: job.downloader.clone(),
filename: job.virtual_path.clone(),
size: job.size,
bytes_sent: bytes_sent.clone(),
cancel: cancel.clone(),
status: UploadStatus::InProgress,
started: std::time::Instant::now(),
},
);
drop(ctx);
let own = own_username.to_string();
let real_path = job.real_path;
let downloader = job.downloader;
let context = client_context.clone();
thread::spawn(move || {
// Most downloaders sit behind a NAT, so dialling them is a gamble
// that costs a 20s timeout when it fails. Like the reference
// clients, ask the server to have them connect back at the same
// time; the first socket to arrive claims the transfer, and port 0
// (the server not knowing where they listen) skips the dial.
let Some((connect_token, claimed)) =
Self::broker_upload(&context, token, &real_path, &downloader)
else {
return;
};
if port == 0 {
return;
}
let stream = match upload_peer::dial(&host, port) {
Ok(stream) => stream,
Err(e) => {
debug!(
"[client] cannot dial {downloader} at {host}:{port} \
for upload {token} ({e}); waiting for them to connect"
);
return;
}
};
if claimed
.compare_exchange(
false,
true,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_err()
{
return;
}
if let Ok(mut ctx) = context.write_safe() {
ctx.take_brokered_upload(connect_token);
}
let result = upload_peer::serve_direct(
stream,
&own,
token,
&real_path,
&bytes_sent,
&cancel,
);
Self::finish_upload(&context, token, &real_path, result);
});
}
/// Park the upload under a correlation token and ask the server to have
/// `downloader` connect back to us with it. Returns the token and the claim
/// flag the dialling route races against; `None` fails the upload.
fn broker_upload(
context: &Arc<RwLock<ClientContext>>,
token: u32,
real_path: &Path,
downloader: &str,
) -> Option<(u32, Arc<AtomicBool>)> {
let connect_token = next_connect_token();
let (claimed, sender) = match context.write_safe() {
Ok(mut ctx) => (
ctx.add_brokered_upload(
connect_token,
token,
real_path.to_path_buf(),
),
ctx.server_sender.clone(),
),
Err(_) => return None,
};
let Some(sender) = sender else {
Self::fail_upload(context, token, "not connected to the server");
return None;
};
let _ = sender.send(ServerMessage::SendMessage(
MessageFactory::build_connect_to_peer(
connect_token,
downloader,
ConnectionType::F,
),
));
Some((connect_token, claimed))
}
/// The downloader of a brokered upload connected back: stream the file
/// over its socket on a background thread, unless the dial beat it.
pub(crate) fn serve_pierced(
client_context: &Arc<RwLock<ClientContext>>,
upload: BrokeredUpload,
stream: TcpStream,
) {
if !upload.claim() {
return;
}
let Some((bytes_sent, cancel)) =
client_context.write_safe().ok().and_then(|mut ctx| {
let active = ctx.active_uploads.get_mut(&upload.token)?;
active.started = std::time::Instant::now();
Some((active.bytes_sent.clone(), active.cancel.clone()))
})
else {
return;
};
let context = client_context.clone();
thread::spawn(move || {
let result = upload_peer::serve_pierced(
stream,
upload.token,
&upload.real_path,
&bytes_sent,
&cancel,
);
Self::finish_upload(
&context,
upload.token,
&upload.real_path,
result,
);
});
}
/// Mark upload `token` failed and hand its slot to the next in line.
pub(crate) fn fail_upload(
client_context: &Arc<RwLock<ClientContext>>,
token: u32,
reason: &str,
) {
if let Ok(mut ctx) = client_context.write_safe()
&& let Some(upload) = ctx.active_uploads.get_mut(&token)
{
error!("[client] upload {token} to {}: {reason}", upload.username);
upload.status = UploadStatus::Failed(reason.to_string());
}
Self::pump_upload_queue(client_context);
}
/// Record how a served upload ended, report its rate to the server and free
/// its slot.
fn finish_upload(
context: &Arc<RwLock<ClientContext>>,
token: u32,
real_path: &Path,
result: std::io::Result<u64>,
) {
let streamed = result.as_ref().ok().copied();
let status = match &result {
Ok(_) => UploadStatus::Completed,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {
UploadStatus::Cancelled
}
Err(e) => {
error!("[client] serve {}: {}", real_path.display(), e);
UploadStatus::Failed(e.to_string())
}
};
let mut report = None;
if let Ok(mut ctx) = context.write_safe()
&& let Some(upload) = ctx.active_uploads.get_mut(&token)
{
let secs = upload.started.elapsed().as_secs_f64();
upload.status = status;
if let Some(bytes) = streamed
&& secs > 0.0
{
let speed = (bytes as f64 / secs) as u32;
ctx.last_upload_speed = speed;
report = ctx.server_sender.clone().map(|s| (s, speed));
}
}
// The server folds each finished upload's rate into the average
// speed it shows other users for us.
if let Some((server, speed)) = report {
let _ = server.send(ServerMessage::SendMessage(
MessageFactory::build_send_upload_speed(speed),
));
}
// The slot this transfer held is free now, so whoever is next in
// line gets it without waiting for another request to arrive.
Self::pump_upload_queue(context);
}
pub(crate) fn process_failed_uploads(
client_context: Arc<RwLock<ClientContext>>,
username: &str,
filename: Option<&str>,
) {
let failed_tokens = match client_context.read_safe() {
Ok(context) => {
collect_failed_tokens(&context.downloads, username, filename)
}
Err(e) => {
error!("[client] process_failed_uploads read: {}", e);
return;
}
};
if failed_tokens.is_empty() {
return;
}
match client_context.write_safe() {
Ok(mut context) => {
for token in failed_tokens {
context.downloads.update_status(
token,
DownloadStatus::Failed(Some(
"The upload failed on the other side".to_string(),
)),
);
context.downloads.remove(token);
}
}
Err(e) => {
error!("[client] process_failed_uploads write: {}", e);
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
use crate::message::{Message, handlers::Handlers};
use std::sync::mpsc::Sender;
use crate::debug;
pub struct MessageDispatcher<Op> {
owner_name: String,
sender: Sender<Op>,
handlers: Handlers<Op>,
}
impl<Op> MessageDispatcher<Op> {
#[must_use]
pub const fn new(
owner_name: String,
sender: Sender<Op>,
handlers: Handlers<Op>,
) -> Self {
Self {
owner_name,
sender,
handlers,
}
}
pub fn dispatch(&self, message: &mut Message) {
let code = message.get_message_code();
if let Some(handler) = self.handlers.get_handler(code) {
message.set_pointer(8);
handler.handle(message, self.sender.clone());
} else {
debug!(
"[{}:dispatcher] No handler found for message code: {code}",
self.owner_name
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::MessageHandler;
use std::sync::mpsc::channel;
struct Echo(u32);
impl MessageHandler<u32> for Echo {
fn get_code(&self) -> u32 {
self.0
}
fn handle(&self, _: &mut Message, sender: Sender<u32>) {
let _ = sender.send(self.0);
}
}
fn framed(code: u32) -> Message {
Message::new_with_data(Message::new().write_int32(code).get_buffer())
}
#[test]
fn a_code_above_255_is_not_confused_with_its_low_byte() {
let (sender, received) = channel();
let mut handlers = Handlers::new();
handlers.register_handler(Echo(1));
handlers.register_handler(Echo(1001));
let dispatcher =
MessageDispatcher::new("test".to_string(), sender, handlers);
dispatcher.dispatch(&mut framed(257));
dispatcher.dispatch(&mut framed(1001));
assert_eq!(received.try_iter().collect::<Vec<_>>(), [1001]);
}
}
+589
View File
@@ -0,0 +1,589 @@
use crate::types::{Download, DownloadStatus};
#[derive(Default)]
pub struct DownloadStore {
downloads: Vec<Download>,
}
impl DownloadStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, download: Download) {
self.downloads.retain(|d| {
!(d.is_finished()
&& d.username == download.username
&& d.filename == download.filename)
});
self.downloads.push(download);
}
pub fn remove(&mut self, token: u32) {
self.downloads.retain(|d| d.token != token);
}
#[must_use]
pub fn get_by_token(&self, token: u32) -> Option<&Download> {
self.downloads.iter().find(|d| d.token == token)
}
pub fn get_by_token_mut(&mut self, token: u32) -> Option<&mut Download> {
self.downloads.iter_mut().find(|d| d.token == token)
}
pub fn get_by_file_mut(
&mut self,
username: &str,
filename: &str,
) -> Option<&mut Download> {
self.downloads
.iter_mut()
.find(|d| d.username == username && d.filename == filename)
}
#[must_use]
pub fn tokens(&self) -> Vec<u32> {
self.downloads.iter().map(|d| d.token).collect()
}
#[must_use]
pub const fn list(&self) -> &Vec<Download> {
&self.downloads
}
pub fn update_status(
&mut self,
token: u32,
status: DownloadStatus,
) -> bool {
let Some(download) = self.get_by_token_mut(token) else {
return false;
};
if download.status.is_terminal()
|| matches!(
(&download.status, &status),
(
DownloadStatus::Paused { .. },
DownloadStatus::InProgress { .. }
)
)
{
return false;
}
download.status = status;
true
}
/// Atomically reserve a queued download for the peer that owns it.
///
/// Every file-connection direction uses this before opening a partial, so
/// two sockets cannot both observe `Queued` and write the same target.
pub fn claim_for_peer(
&mut self,
token: u32,
username: &str,
) -> Option<Download> {
let download = self.get_by_token_mut(token)?;
if download.username != username
|| !matches!(download.status, DownloadStatus::Queued)
{
return None;
}
download.status = DownloadStatus::InProgress {
bytes_downloaded: 0,
total_bytes: download.size,
speed_bytes_per_sec: 0.0,
};
Some(download.clone())
}
pub fn cancel_by_file(
&mut self,
username: &str,
filename: &str,
) -> Option<Download> {
let download = self.get_by_file_mut(username, filename)?;
if download.is_finished() {
return None;
}
download.status = DownloadStatus::Cancelled;
let _ = download.sender.send(DownloadStatus::Cancelled);
Some(download.clone())
}
pub fn update_queue_position(
&mut self,
username: &str,
filename: &str,
position: u32,
) -> bool {
let Some(download) = self.get_by_file_mut(username, filename) else {
return false;
};
download.queue_position = Some(position);
true
}
pub fn remove_queued_by_file(
&mut self,
username: &str,
filename: &str,
) -> bool {
let Some(index) = self.downloads.iter().position(|download| {
download.username == username
&& download.filename == filename
&& matches!(download.status, DownloadStatus::Queued)
}) else {
return false;
};
self.downloads.remove(index);
true
}
/// Remove every download matching `username`/`filename` regardless of
/// status. Used before retrying a failed download so the stale entry (whose
/// md5-derived token collides with the retry's) can't shadow the fresh one.
/// Returns whether anything was removed.
pub fn remove_by_file(&mut self, username: &str, filename: &str) -> bool {
let before = self.downloads.len();
self.downloads
.retain(|d| !(d.username == username && d.filename == filename));
self.downloads.len() != before
}
pub fn pause_by_file(&mut self, username: &str, filename: &str) -> bool {
let Some(download) = self.get_by_file_mut(username, filename) else {
return false;
};
let paused_status = match &download.status {
DownloadStatus::InProgress {
bytes_downloaded,
total_bytes,
..
} => DownloadStatus::Paused {
bytes_downloaded: *bytes_downloaded,
total_bytes: *total_bytes,
},
DownloadStatus::Paused { .. } => return true,
_ => return false,
};
download.status = paused_status.clone();
let _ = download.sender.send(paused_status);
true
}
pub fn resume_by_file(&mut self, username: &str, filename: &str) -> bool {
let Some(download) = self.get_by_file_mut(username, filename) else {
return false;
};
let resumed_status = match &download.status {
DownloadStatus::Paused {
bytes_downloaded,
total_bytes,
} => DownloadStatus::InProgress {
bytes_downloaded: *bytes_downloaded,
total_bytes: *total_bytes,
speed_bytes_per_sec: 0.0,
},
DownloadStatus::InProgress { .. } => return true,
_ => return false,
};
download.status = resumed_status.clone();
let _ = download.sender.send(resumed_status);
true
}
}
/// Returns the tokens of queued downloads matching `username` (and optionally
/// a `filename`) after notifying their senders of `Failed`.
///
/// Caller is responsible for then calling `update_status` and `remove` for
/// each token, typically under a write lock.
#[must_use]
pub fn collect_failed_tokens(
store: &DownloadStore,
username: &str,
filename: Option<&str>,
) -> Vec<u32> {
store
.list()
.iter()
.filter(|d| {
d.username == username && filename.is_none_or(|f| d.filename == *f)
})
.filter(|d| matches!(d.status, DownloadStatus::Queued))
.map(|d| {
let _ = d.sender.send(DownloadStatus::Failed(Some(
"The upload failed on the other side".to_string(),
)));
d.token
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::DownloadMetadata;
use std::sync::mpsc;
fn make_download(token: u32, status: DownloadStatus) -> Download {
Download {
username: "peer".to_string(),
filename: format!("file-{token}.mp3"),
token,
size: 100,
download_directory: "test".to_string(),
status,
sender: mpsc::channel().0,
queue_position: None,
metadata: DownloadMetadata::default(),
}
}
#[test]
fn add_get_remove_roundtrip() {
let mut store = DownloadStore::new();
store.add(make_download(123, DownloadStatus::Queued));
assert!(store.get_by_token(123).is_some());
assert_eq!(store.tokens(), vec![123]);
assert_eq!(store.list().len(), 1);
store.remove(123);
assert!(store.get_by_token(123).is_none());
assert!(store.list().is_empty());
}
#[test]
fn update_queue_position_sets_field_when_match() {
let mut store = DownloadStore::new();
let mut download = make_download(1, DownloadStatus::Queued);
download.username = "peer".to_string();
download.filename = "song.mp3".to_string();
store.add(download);
assert!(store.update_queue_position("peer", "song.mp3", 42));
assert_eq!(store.get_by_token(1).unwrap().queue_position, Some(42));
assert!(!store.update_queue_position("peer", "missing.mp3", 1));
assert!(!store.update_queue_position("other", "song.mp3", 1));
}
#[test]
fn cancel_by_file_flips_an_unfinished_download_and_tells_its_channel() {
let mut store = DownloadStore::new();
let (tx, rx) = mpsc::channel();
let mut queued = make_download(1, DownloadStatus::Queued);
queued.sender = tx;
store.add(queued);
store.add(make_download(
2,
DownloadStatus::InProgress {
bytes_downloaded: 5,
total_bytes: 100,
speed_bytes_per_sec: 1.0,
},
));
store.add(make_download(
3,
DownloadStatus::Paused {
bytes_downloaded: 5,
total_bytes: 100,
},
));
store.add(make_download(4, DownloadStatus::Completed));
store.add(make_download(5, DownloadStatus::Failed(None)));
assert!(store.cancel_by_file("peer", "file-1.mp3").is_some());
assert!(matches!(rx.try_recv(), Ok(DownloadStatus::Cancelled)));
assert!(store.cancel_by_file("peer", "file-2.mp3").is_some());
assert!(store.cancel_by_file("peer", "file-3.mp3").is_some());
assert!(store.cancel_by_file("peer", "file-4.mp3").is_none());
assert!(store.cancel_by_file("peer", "file-5.mp3").is_none());
assert!(store.cancel_by_file("peer", "file-9.mp3").is_none());
for token in 1..=3 {
let download = store.get_by_token(token).unwrap();
assert!(matches!(download.status, DownloadStatus::Cancelled));
assert!(download.is_finished());
}
}
#[test]
fn a_cancelled_download_stays_cancelled() {
let mut store = DownloadStore::new();
store.add(make_download(1, DownloadStatus::Queued));
assert!(store.cancel_by_file("peer", "file-1.mp3").is_some());
assert!(!store.update_status(
1,
DownloadStatus::InProgress {
bytes_downloaded: 0,
total_bytes: 100,
speed_bytes_per_sec: 0.0,
},
));
assert!(
!store
.update_status(1, DownloadStatus::Failed(Some("late".into())))
);
assert!(matches!(
store.get_by_token(1).unwrap().status,
DownloadStatus::Cancelled
));
assert!(store.cancel_by_file("peer", "file-1.mp3").is_none());
assert!(!store.resume_by_file("peer", "file-1.mp3"));
assert!(!store.pause_by_file("peer", "file-1.mp3"));
}
#[test]
fn add_evicts_a_finished_entry_for_the_same_file() {
let mut store = DownloadStore::new();
store.add(make_download(1, DownloadStatus::Cancelled));
store.add(make_download(2, DownloadStatus::Queued));
let mut fresh = make_download(3, DownloadStatus::Queued);
fresh.filename = "file-1.mp3".to_string();
store.add(fresh);
let mut again = make_download(4, DownloadStatus::Queued);
again.filename = "file-2.mp3".to_string();
store.add(again);
assert_eq!(store.tokens(), vec![2, 3, 4], "only the dead row goes");
assert!(store.cancel_by_file("peer", "file-1.mp3").is_some());
assert!(matches!(
store.get_by_token(3).unwrap().status,
DownloadStatus::Cancelled
));
}
#[test]
fn pause_then_resume_in_progress_download() {
let mut store = DownloadStore::new();
let (tx, rx) = mpsc::channel();
let mut download = make_download(
1,
DownloadStatus::InProgress {
bytes_downloaded: 25,
total_bytes: 100,
speed_bytes_per_sec: 10.0,
},
);
download.sender = tx;
store.add(download);
assert!(store.pause_by_file("peer", "file-1.mp3"));
assert!(matches!(
store.get_by_token(1).unwrap().status,
DownloadStatus::Paused {
bytes_downloaded: 25,
total_bytes: 100
}
));
assert!(matches!(
rx.try_recv().unwrap(),
DownloadStatus::Paused {
bytes_downloaded: 25,
total_bytes: 100
}
));
assert!(store.resume_by_file("peer", "file-1.mp3"));
assert!(matches!(
store.get_by_token(1).unwrap().status,
DownloadStatus::InProgress {
bytes_downloaded: 25,
total_bytes: 100,
speed_bytes_per_sec: 0.0
}
));
}
#[test]
fn progress_cannot_implicitly_resume_a_paused_download() {
let mut store = DownloadStore::new();
store.add(make_download(
1,
DownloadStatus::Paused {
bytes_downloaded: 25,
total_bytes: 100,
},
));
assert!(!store.update_status(
1,
DownloadStatus::InProgress {
bytes_downloaded: 50,
total_bytes: 100,
speed_bytes_per_sec: 10.0,
},
));
assert!(matches!(
store.get_by_token(1).unwrap().status,
DownloadStatus::Paused {
bytes_downloaded: 25,
total_bytes: 100
}
));
}
#[test]
fn a_download_can_only_be_claimed_once_and_by_its_peer() {
let mut store = DownloadStore::new();
store.add(make_download(1, DownloadStatus::Queued));
assert!(store.claim_for_peer(1, "other").is_none());
let claimed = store.claim_for_peer(1, "peer").unwrap();
assert!(matches!(
claimed.status,
DownloadStatus::InProgress {
bytes_downloaded: 0,
total_bytes: 100,
speed_bytes_per_sec: 0.0
}
));
assert!(store.claim_for_peer(1, "peer").is_none());
}
#[test]
fn remove_queued_skips_active_downloads() {
let mut store = DownloadStore::new();
store.add(make_download(123, DownloadStatus::Queued));
store.add(make_download(
456,
DownloadStatus::InProgress {
bytes_downloaded: 25,
total_bytes: 100,
speed_bytes_per_sec: 10.0,
},
));
// Override second download's filename so they don't collide
store.get_by_token_mut(456).unwrap().filename =
"active.mp3".to_string();
store.get_by_token_mut(123).unwrap().filename =
"queued.mp3".to_string();
assert!(store.remove_queued_by_file("peer", "queued.mp3"));
assert!(!store.remove_queued_by_file("peer", "active.mp3"));
assert!(store.get_by_token(123).is_none());
assert!(store.get_by_token(456).is_some());
}
#[test]
fn remove_by_file_removes_regardless_of_status() {
let mut store = DownloadStore::new();
// A failed download (the retry case) plus a same-name duplicate that a
// token-migration could have left behind — both must go.
let mut failed = make_download(1, DownloadStatus::Failed(None));
failed.filename = "song.mp3".to_string();
store.add(failed);
let mut dup = make_download(2, DownloadStatus::Queued);
dup.filename = "song.mp3".to_string();
store.add(dup);
let mut other = make_download(3, DownloadStatus::Queued);
other.filename = "other.mp3".to_string();
store.add(other);
assert!(store.remove_by_file("peer", "song.mp3"));
assert!(store.get_by_token(1).is_none());
assert!(store.get_by_token(2).is_none());
assert!(store.get_by_token(3).is_some(), "other file untouched");
assert!(!store.remove_by_file("peer", "song.mp3"), "idempotent");
}
#[test]
fn collect_failed_tokens_collects_only_queued_downloads() {
let mut store = DownloadStore::new();
let (tx_active, rx_active) = mpsc::channel();
let (tx_paused, rx_paused) = mpsc::channel();
let (tx_completed, rx_completed) = mpsc::channel();
let (tx_queued, _rx_queued) = mpsc::channel();
let mut active = make_download(
1,
DownloadStatus::InProgress {
bytes_downloaded: 25,
total_bytes: 100,
speed_bytes_per_sec: 10.0,
},
);
active.sender = tx_active;
let mut paused = make_download(
2,
DownloadStatus::Paused {
bytes_downloaded: 25,
total_bytes: 100,
},
);
paused.sender = tx_paused;
let mut completed = make_download(3, DownloadStatus::Completed);
completed.sender = tx_completed;
let mut queued = make_download(4, DownloadStatus::Queued);
queued.sender = tx_queued;
store.add(active);
store.add(paused);
store.add(completed);
store.add(queued);
let tokens = collect_failed_tokens(&store, "peer", None);
assert_eq!(tokens, vec![4], "only the queued download may fail");
assert!(
rx_active.try_recv().is_err(),
"an in-progress transfer must not be notified of failure"
);
assert!(
rx_paused.try_recv().is_err(),
"a paused transfer must not be notified of failure"
);
assert!(
rx_completed.try_recv().is_err(),
"a completed download must not be notified of failure"
);
}
#[test]
fn collect_failed_tokens_notifies_and_lists_matching() {
let mut store = DownloadStore::new();
let (tx_match, rx_match) = mpsc::channel();
let (tx_other_user, _rx_other_user) = mpsc::channel();
let (tx_other_file, _rx_other_file) = mpsc::channel();
let mut a = make_download(1, DownloadStatus::Queued);
a.sender = tx_match;
a.username = "peer".to_string();
a.filename = "song.mp3".to_string();
let mut b = make_download(2, DownloadStatus::Queued);
b.sender = tx_other_user;
b.username = "other".to_string();
b.filename = "song.mp3".to_string();
let mut c = make_download(3, DownloadStatus::Queued);
c.sender = tx_other_file;
c.username = "peer".to_string();
c.filename = "different.mp3".to_string();
store.add(a);
store.add(b);
store.add(c);
let tokens = collect_failed_tokens(&store, "peer", Some("song.mp3"));
assert_eq!(tokens, vec![1]);
assert!(matches!(
rx_match.try_recv().unwrap(),
DownloadStatus::Failed(_)
));
}
}
+79
View File
@@ -0,0 +1,79 @@
use std::{error::Error, fmt};
/// Custom error type for the Soulseek download library
#[derive(Debug)]
pub enum SoulseekRs {
NetworkError(std::io::Error),
/// Authentication failed during login
AuthenticationFailed,
/// Error parsing messages or data
ParseError(String),
/// Operation timed out
Timeout,
/// Connection was closed unexpectedly
ConnectionClosed,
/// Invalid message format or content
InvalidMessage(String),
/// Server not connected
NotConnected,
/// Compression/decompression error
CompressionError(String),
/// A lock was poisoned by a panic in another thread
LockPoisoned,
}
impl fmt::Display for SoulseekRs {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NetworkError(err) => {
write!(f, "Network error: {err}")
}
Self::AuthenticationFailed => {
write!(f, "Authentication failed")
}
Self::ParseError(msg) => write!(f, "Parse error: {msg}"),
Self::Timeout => write!(f, "Operation timed out"),
Self::ConnectionClosed => write!(f, "Connection closed"),
Self::InvalidMessage(msg) => {
write!(f, "Invalid message: {msg}")
}
Self::NotConnected => write!(f, "Not connected to server"),
Self::CompressionError(msg) => {
write!(f, "Compression error: {msg}")
}
Self::LockPoisoned => {
write!(f, "Lock poisoned by panicking thread")
}
}
}
}
impl Error for SoulseekRs {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::NetworkError(err) => Some(err),
_ => None,
}
}
}
impl From<std::io::Error> for SoulseekRs {
fn from(err: std::io::Error) -> Self {
Self::NetworkError(err)
}
}
impl From<std::num::ParseIntError> for SoulseekRs {
fn from(err: std::num::ParseIntError) -> Self {
Self::ParseError(format!("Integer parse error: {err}"))
}
}
impl From<String> for SoulseekRs {
fn from(err: String) -> Self {
Self::CompressionError(err)
}
}
/// Result type alias for the Soulseek library
pub type Result<T> = std::result::Result<T, SoulseekRs>;
+44
View File
@@ -0,0 +1,44 @@
// The README is the crate's front page, and `cargo test --doc` compiles every
// example in it, so a signature change cannot leave the advertised usage stale.
#![doc = include_str!("../README.md")]
// The public surface is the client, its types, the wire codec and the share
// index. The actors, dispatcher, peer plumbing and download store are how the
// client is built, not what a host programs against, so they stay inside the
// crate and a change to them is not a change to the library's API.
pub(crate) mod actor;
pub mod client;
pub(crate) mod dispatcher;
pub(crate) mod download_store;
pub mod error;
pub mod message;
pub(crate) mod peer;
pub mod shares;
pub mod types;
#[macro_use]
pub mod utils;
// Prelude module for commonly used items
pub mod prelude {
pub use crate::actor::server_actor::PeerAddress;
pub use crate::types::{
ClientVersion, DownloadStatus, File, Recommendation, RoomEvent,
RoomInfo, RoomTicker, Search, SearchResult, SimilarUser, Transfer,
UploadInfo, UploadStatus, UserInfo, UserInterests, UserPresence,
UserStats, UserStatus,
};
pub use crate::{debug, error, info, trace, warn};
}
// Re-export commonly used types
pub use actor::server_actor::{PeerAddress, UserMessage};
pub use client::{CancelHandle, Client, ClientSettings};
pub use error::{Result, SoulseekRs};
pub use message::peer::{SharedDirectory, SharedFileEntry};
pub use peer::{ConnectionType, ParseConnectionTypeError};
pub use types::{
ClientVersion, DownloadStatus, File, Recommendation, RoomEvent, RoomInfo,
RoomTicker, Search, SearchResult, SessionLoss, SimilarUser, Transfer,
UploadInfo, UploadStatus, UserInfo, UserInterests, UserPresence, UserStats,
UserStatus,
};
+249
View File
@@ -0,0 +1,249 @@
//! The distributed search network's wire format.
//!
//! Frames carry a one-byte code, unlike the four-byte server and peer codes,
//! and a leaf only ever reads them: 3 Search, 4 BranchLevel, 5 BranchRoot,
//! 93 EmbeddedMessage.
use crate::message::Message;
use crate::message::server::MessageFactory;
/// A search relayed through the tree carries this in its first field; anything
/// else is a frame we do not understand and must not answer.
const SEARCH_IDENTIFIER: u32 = 49;
/// What a parent sends a leaf.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Distributed {
Search {
username: String,
token: u32,
query: String,
},
BranchLevel(i32),
BranchRoot(String),
}
/// Decode one framed distributed message (length prefix included), or `None`
/// for a ping, an unknown code or a truncated frame.
#[must_use]
pub fn parse(message: &mut Message) -> Option<Distributed> {
if message.get_size() < 5 {
return None;
}
message.set_pointer(5);
parse_at(message, message.get_init_code())
}
/// Decode the body that follows a one-byte `code`, the pointer already past
/// it. A code-93 envelope is unwrapped exactly once, as Nicotine+ does: an
/// envelope inside an envelope is nobody's search.
#[must_use]
pub fn parse_at(message: &mut Message, code: u8) -> Option<Distributed> {
let code = if code == 93 {
message.read_int8()
} else {
code
};
match code {
3 => {
if remaining(message) < 4
|| message.read_int32() != SEARCH_IDENTIFIER
{
return None;
}
let username = message.read_string();
if remaining(message) < 4 {
return None;
}
let token = message.read_int32();
let query = message.read_string();
(!username.is_empty() && !query.is_empty()).then_some(
Distributed::Search {
username,
token,
query,
},
)
}
4 => (remaining(message) >= 4)
.then(|| Distributed::BranchLevel(message.read_int32() as i32)),
5 => {
let root = message.read_string();
(!root.is_empty()).then_some(Distributed::BranchRoot(root))
}
_ => None,
}
}
const fn remaining(message: &mut Message) -> usize {
message.get_size().saturating_sub(message.get_pointer())
}
/// A distributed frame with the one-byte `code`: what a parent (or a test
/// standing in for one) sends down the tree.
fn framed(code: u8, body: impl FnOnce(&mut Message)) -> Message {
let mut message = Message::new();
message.write_int8(code);
body(&mut message);
message
}
#[must_use]
pub fn build_search(username: &str, token: u32, query: &str) -> Message {
framed(3, |m| {
m.write_int32(SEARCH_IDENTIFIER)
.write_string(username)
.write_int32(token)
.write_string(query);
})
}
#[must_use]
pub fn build_branch_level(level: i32) -> Message {
framed(4, |m| {
m.write_int32(level as u32);
})
}
/// What we tell the server about our place in the tree: the four messages
/// Nicotine+ sends together whenever it changes.
#[must_use]
pub fn stance(
root: &str,
level: u32,
has_parent: bool,
accept_children: bool,
) -> Vec<Message> {
vec![
MessageFactory::build_have_no_parent(!has_parent),
MessageFactory::build_branch_root(root),
MessageFactory::build_branch_level(level),
MessageFactory::build_accept_children(accept_children),
]
}
#[must_use]
pub fn build_branch_root(root: &str) -> Message {
framed(5, |m| {
m.write_string(root);
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_distributed_search_carries_who_asked_and_what_for() {
let frame = build_search("seeker", 77, "aphex twin");
let mut message = Message::new_with_data(frame.get_buffer());
assert_eq!(
parse(&mut message),
Some(Distributed::Search {
username: "seeker".to_string(),
token: 77,
query: "aphex twin".to_string(),
})
);
}
#[test]
fn a_search_with_the_wrong_identifier_is_dropped() {
let mut body = Message::new();
body.write_int8(3)
.write_int32(7)
.write_string("seeker")
.write_int32(1)
.write_string("q");
let mut message = Message::new_with_data(body.get_buffer());
assert_eq!(parse(&mut message), None);
}
#[test]
fn branch_level_and_root_frames_parse() {
let mut level =
Message::new_with_data(build_branch_level(2).get_buffer());
assert_eq!(parse(&mut level), Some(Distributed::BranchLevel(2)));
let mut root =
Message::new_with_data(build_branch_root("rooty").get_buffer());
assert_eq!(
parse(&mut root),
Some(Distributed::BranchRoot("rooty".to_string()))
);
}
#[test]
fn an_embedded_search_unwraps_to_the_search_itself() {
let inner = build_search("seeker", 5, "q").get_data();
let mut body = Message::new();
body.write_int8(93).write_raw_bytes(inner);
let mut message = Message::new_with_data(body.get_buffer());
assert!(matches!(
parse(&mut message),
Some(Distributed::Search { token: 5, .. })
));
}
#[test]
fn unknown_or_truncated_frames_parse_to_nothing() {
let mut ping =
Message::new_with_data(Message::new().write_int8(0).get_buffer());
assert_eq!(parse(&mut ping), None);
let mut short = Message::new_with_data(vec![1, 0, 0, 0, 3]);
assert_eq!(parse(&mut short), None);
let mut level = Message::new_with_data(vec![3, 0, 0, 0, 4, 1, 0]);
assert_eq!(parse(&mut level), None, "a level needs four bytes");
let mut root =
Message::new_with_data(build_branch_root("").get_buffer());
assert_eq!(parse(&mut root), None, "an empty root is no root");
let mut blank =
Message::new_with_data(build_search("seeker", 1, "").get_buffer());
assert_eq!(parse(&mut blank), None, "an empty query is no search");
}
// An envelope inside an envelope is unwrapped once and found empty; a
// frame that is nothing but envelopes must not recurse its way off the
// stack.
#[test]
fn a_nested_envelope_is_not_unwrapped_twice() {
let mut nested = Message::new();
nested.write_int8(93).write_raw_bytes(
Message::new()
.write_int8(93)
.write_raw_bytes(build_search("seeker", 5, "q").get_data())
.get_data(),
);
let mut message = Message::new_with_data(nested.get_buffer());
assert_eq!(parse(&mut message), None);
let mut onions = Message::new_with_data(
Message::new()
.write_raw_bytes(vec![93u8; 64 * 1024])
.get_buffer(),
);
assert_eq!(parse(&mut onions), None);
}
// The frame as the protocol documents it, byte for byte: code 3, the
// constant 49, then user, token and query.
#[test]
fn a_search_frame_matches_the_documented_layout() {
let mut bytes = vec![3u8, 49, 0, 0, 0];
bytes.extend(2u32.to_le_bytes());
bytes.extend(b"me");
bytes.extend(9u32.to_le_bytes());
bytes.extend(1u32.to_le_bytes());
bytes.extend(b"q");
let mut framed = (bytes.len() as u32).to_le_bytes().to_vec();
framed.extend(&bytes);
assert_eq!(build_search("me", 9, "q").get_buffer(), framed);
assert_eq!(
parse(&mut Message::new_with_data(framed)),
Some(Distributed::Search {
username: "me".to_string(),
token: 9,
query: "q".to_string()
})
);
}
}
+42
View File
@@ -0,0 +1,42 @@
use std::collections::HashMap;
use crate::message::Message;
use std::sync::mpsc::Sender;
pub trait MessageHandler<Op>: Send {
fn get_code(&self) -> u32;
fn handle(&self, message: &mut Message, sender: Sender<Op>);
}
pub struct Handlers<Op> {
handlers: HashMap<u32, Box<dyn MessageHandler<Op> + Send>>,
}
impl<Op> Default for Handlers<Op> {
fn default() -> Self {
Self::new()
}
}
impl<Op> Handlers<Op> {
#[must_use]
pub fn new() -> Self {
Self {
handlers: HashMap::new(),
}
}
pub fn register_handler<H>(&mut self, handler: H) -> &mut Self
where
H: 'static + MessageHandler<Op> + Send + Sync,
{
self.handlers.insert(handler.get_code(), Box::new(handler));
self
}
#[must_use]
pub fn get_handler(
&self,
code: u32,
) -> Option<&(dyn MessageHandler<Op> + Send)> {
self.handlers.get(&code).map(|v| &**v)
}
}
@@ -0,0 +1,158 @@
use std::io::{self, Read};
use std::{collections::VecDeque, net::TcpStream};
use crate::message::Message;
// Soulseek messages are length-prefixed (u32 LE size, then payload). TCP gives us
// arbitrary-sized chunks, so we accumulate into a buffer and only emit a Message
// once size + 4 bytes are available.
pub struct MessageReader {
buffer: VecDeque<u8>,
}
impl Default for MessageReader {
fn default() -> Self {
Self::new()
}
}
impl MessageReader {
#[must_use]
pub const fn new() -> Self {
Self {
buffer: VecDeque::new(),
}
}
#[cfg(test)]
#[must_use]
pub fn new_with_buffer(buffer: Vec<u8>) -> Self {
Self {
buffer: buffer.into(),
}
}
pub fn read_from_socket(
&mut self,
stream: &mut TcpStream,
) -> io::Result<()> {
let mut temp_buffer = [0; 1024]; // Temporary buffer for reading from the socket
let bytes_read = stream.read(&mut temp_buffer)?;
// On a TCP socket a zero-byte read is the other end hanging up. Passing
// that back as success left the caller polling a dead socket forever,
// which is how a server that closes cleanly went unnoticed.
if bytes_read == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"the remote end closed the connection",
));
}
// Add the read bytes to the internal buffer
self.buffer.extend(&temp_buffer[..bytes_read]);
Ok(())
}
#[must_use]
pub fn buffer_len(&self) -> usize {
self.buffer.len()
}
pub fn get_buffer(&mut self) -> Vec<u8> {
self.buffer.drain(..).collect()
}
pub fn extract_message(&mut self) -> io::Result<Option<Message>> {
let bytes_read = self.buffer.len();
if bytes_read < 4 {
return Ok(None);
}
let message_size = u32::from_le_bytes([
self.buffer[0],
self.buffer[1],
self.buffer[2],
self.buffer[3],
]) as usize;
let total_size = message_size + 4;
if bytes_read < total_size {
return Ok(None);
}
let message_buffer: Vec<u8> = self.buffer.drain(..total_size).collect();
Ok(Some(Message::new_with_data(message_buffer)))
}
}
#[cfg(test)]
mod tests {
use crate::message::MessageReader;
#[test]
fn test_extract_message() {
let buffer: Vec<u8> = [
8, 0, 0, 0, 117, 115, 101, 114, 110, 97, 109, 101, 8, 0, 0, 0, 112,
97, 115, 115, 119, 111, 114, 100, 160, 0, 0, 0, 32, 0, 0, 0, 100,
53, 49, 99, 57, 97, 55, 101, 57, 51, 53, 51, 55, 52, 54, 97, 54,
48, 50, 48, 102, 57, 54, 48, 50, 100, 52, 53, 50, 57, 50, 57, 17,
0, 0, 0,
]
.to_vec();
let mut buffered_reader = MessageReader::new_with_buffer(buffer);
let mut message = buffered_reader.extract_message().unwrap().unwrap();
assert_eq!(
message.get_data(),
vec![8, 0, 0, 0, 117, 115, 101, 114, 110, 97, 109, 101]
);
assert_eq!(message.read_string(), "username");
}
#[test]
fn test_extract_message_incomplete_message() {
let incomplete_buffer = vec![1, 2, 3];
let mut buffered_reader =
MessageReader::new_with_buffer(incomplete_buffer);
let result = buffered_reader.extract_message();
assert_eq!(None, result.unwrap());
let rest: Vec<u8> = buffered_reader
.buffer
.drain(..buffered_reader.buffer.len())
.collect();
assert!(buffered_reader.buffer.is_empty());
assert_eq!(vec![1, 2, 3], rest);
}
/// A hangup has to reach the caller as an error. Reported as success it
/// leaves the actors polling a socket that will never speak again, which is
/// how a server that closed cleanly went unnoticed.
#[test]
fn a_closed_connection_reads_as_an_error_not_as_success() {
use std::io::Write;
use std::net::{TcpListener, TcpStream};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let mut ours = TcpStream::connect(addr).unwrap();
let (mut theirs, _) = listener.accept().unwrap();
theirs.write_all(&[1, 2, 3]).unwrap();
theirs.flush().unwrap();
drop(theirs);
let mut reader = MessageReader::new();
// Whatever was sent before the close still arrives.
while reader.buffer_len() < 3 {
reader.read_from_socket(&mut ours).unwrap();
}
let error = reader
.read_from_socket(&mut ours)
.expect_err("a closed connection is not a successful read");
assert_eq!(error.kind(), std::io::ErrorKind::UnexpectedEof);
}
}
+448
View File
@@ -0,0 +1,448 @@
pub use crate::{debug, error, info, trace, warn};
pub mod distributed;
pub mod handlers;
mod message_reader;
pub mod peer;
pub mod server;
pub use handlers::{Handlers, MessageHandler};
pub use message_reader::MessageReader;
use std::str;
#[derive(Debug, PartialEq, Eq)]
#[allow(dead_code)]
pub enum MessageType {
Server,
Peer,
PeerInit,
Distributed,
}
#[derive(Debug)]
pub struct Error(String);
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for Error {}
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct Message {
data: Vec<u8>,
pointer: usize,
}
impl Default for Message {
fn default() -> Self {
Self::new()
}
}
impl Message {
#[must_use]
pub const fn new() -> Self {
Self {
data: Vec::new(),
pointer: 0,
}
}
#[must_use]
pub fn get_message_code(&self) -> u32 {
if self.data.len() < 8 {
return 0;
}
self.data[4..8].try_into().map_or(0, u32::from_le_bytes)
}
/// The one-byte code of a peer init frame (PierceFirewall 0, PeerInit 1),
/// the only messages whose code is not four bytes wide.
#[must_use]
pub fn get_init_code(&self) -> u8 {
self.data.get(4).copied().unwrap_or(0)
}
#[must_use]
pub const fn new_with_data(data: Vec<u8>) -> Self {
Self { data, pointer: 0 }
}
pub const fn set_pointer(&mut self, pointer: usize) {
self.pointer = pointer;
}
pub const fn get_pointer(&mut self) -> usize {
self.pointer
}
pub const fn get_size(&mut self) -> usize {
self.data.len()
}
#[must_use]
pub fn get_data(&self) -> Vec<u8> {
self.data.clone()
}
#[must_use]
pub fn get_slice(&self, from: usize, to: usize) -> Vec<u8> {
self.data[from..to].to_vec()
}
/// gets buffer with the message length prepended
#[must_use]
pub fn get_buffer(&self) -> Vec<u8> {
let mut b = vec![0u8; 4];
let length = self.data.len() as u32;
b[0..4].copy_from_slice(&length.to_le_bytes());
let mut combined = b;
combined.extend(&self.data);
combined
}
pub fn read_string(&mut self) -> String {
if self.pointer + 4 > self.data.len() {
self.pointer = self.data.len();
return String::new();
}
let size = u32::from_le_bytes([
self.data[self.pointer],
self.data[self.pointer + 1],
self.data[self.pointer + 2],
self.data[self.pointer + 3],
]) as usize;
self.pointer += 4;
// A truncated / malformed length field from an untrusted peer must not
// slice past the buffer end; treat it as an empty string and consume
// the rest of the message.
if self.pointer + size > self.data.len() {
self.pointer = self.data.len();
return String::new();
}
let data = &self.data[self.pointer..self.pointer + size];
self.pointer += size;
match String::from_utf8(data.to_vec()) {
Ok(s) => s,
Err(_) => data.iter().map(|&b| b as char).collect::<String>(),
}
}
pub fn read_int8(&mut self) -> u8 {
if self.pointer >= self.data.len() {
return 0;
}
let val = self.data[self.pointer];
self.pointer += 1;
val
}
#[allow(dead_code)]
pub fn read_int64(&mut self) -> u64 {
if self.pointer + 8 > self.data.len() {
return 0;
}
let val = u64::from_le_bytes([
self.data[self.pointer],
self.data[self.pointer + 1],
self.data[self.pointer + 2],
self.data[self.pointer + 3],
self.data[self.pointer + 4],
self.data[self.pointer + 5],
self.data[self.pointer + 6],
self.data[self.pointer + 7],
]);
self.pointer += 8;
val
}
pub fn read_int32(&mut self) -> u32 {
if self.pointer + 4 > self.data.len() {
return 0;
}
let val = u32::from_le_bytes([
self.data[self.pointer],
self.data[self.pointer + 1],
self.data[self.pointer + 2],
self.data[self.pointer + 3],
]);
self.pointer += 4;
val
}
pub fn read_bool(&mut self) -> bool {
if self.pointer >= self.data.len() {
return false;
}
let val = self.data[self.pointer] == 1;
self.pointer += 1;
val
}
pub fn write_string(&mut self, val: &str) -> &mut Self {
let length = val.len() as u32;
self.data.extend_from_slice(&length.to_le_bytes());
self.data.extend_from_slice(val.as_bytes());
self
}
pub fn write_int8(&mut self, value: u8) -> &mut Self {
self.data.extend_from_slice(&value.to_le_bytes());
self
}
pub fn write_int32(&mut self, value: u32) -> &mut Self {
self.data.extend_from_slice(&value.to_le_bytes());
self
}
#[allow(dead_code)]
pub fn write_int64(&mut self, value: u64) -> &mut Self {
self.data.extend_from_slice(&value.to_le_bytes());
self
}
pub fn write_raw_bytes(&mut self, value: Vec<u8>) -> &mut Self {
self.data.extend_from_slice(&value);
self
}
pub fn write_bool(&mut self, value: bool) -> &mut Self {
self.data.push(u8::from(value));
self
}
pub fn get_message_name(
&self,
msg_type: MessageType,
code: u32,
) -> Result<&str, Error> {
match msg_type {
MessageType::Server => match code {
1 => Ok("Login"),
2 => Ok("SetWaitPort"),
3 => Ok("GetPeerAddress"),
5 => Ok("WatchUser"),
6 => Ok("UnwatchUser"),
7 => Ok("GetUserStatus"),
13 => Ok("SayChatroom"),
14 => Ok("JoinRoom"),
15 => Ok("LeaveRoom"),
18 => Ok("ConnectToPeer"),
22 => Ok("MessageUser"),
23 => Ok("MessageAcked"),
26 => Ok("FileSearch"),
28 => Ok("SetStatus"),
32 => Ok("ServerPing"),
35 => Ok("SharedFoldersFiles"),
36 => Ok("GetUserStats"),
41 => Ok("Relogged"),
42 => Ok("UserSearch"),
64 => Ok("RoomList"),
69 => Ok("PrivilegedUsers"),
71 => Ok("HaveNoParent"),
83 => Ok("ParentMinSpeed"),
84 => Ok("ParentSpeedRatio"),
92 => Ok("CheckPrivileges"),
93 => Ok("EmbeddedMessage"),
100 => Ok("AcceptChildren"),
102 => Ok("PossibleParents"),
104 => Ok("WishlistInterval"),
121 => Ok("SendUploadSpeed"),
160 => Ok("ExcludedSearchPhrases"),
1001 => Ok("CantConnectToPeer"),
_ => Err(Error(format!("Unknown server message code: {code}"))),
},
MessageType::PeerInit => match code {
0 => Ok("PierceFireWall"),
1 => Ok("PeerInit"),
_ => Err(Error(format!(
"Unknown peer init message code: {code}"
))),
},
MessageType::Peer => match code {
1 => Ok("PeerInit"),
4 => Ok("GetShareFileList"),
5 => Ok("SharedFileListResponse"),
9 => Ok("FileSearchResponse"),
15 => Ok("UserInfoRequest"),
16 => Ok("UserInfoResponse"),
36 => Ok("FolderContentsRequest"),
37 => Ok("FolderContentsResponse"),
40 => Ok("TransferRequest"),
41 => Ok("TransferResponse"),
43 => Ok("QueueUpload"),
44 => Ok("PlaceInQueueResponse"),
46 => Ok("UploadFailed"),
50 => Ok("UploadDenied"),
51 => Ok("PlaceInQueueRequest"),
_ => Err(Error(format!("Unknown peer message code: {code}"))),
},
MessageType::Distributed => match code {
3 => Ok("SearchRequest"),
4 => Ok("BranchLevel"),
5 => Ok("BranchRoot"),
93 => Ok("EmbeddedMessage"),
_ => Err(Error(format!(
"Unknown distributed message code: {code}"
))),
},
}
}
}
/// Build a received message the way the dispatcher hands one to a handler:
/// 8 bytes of frame header, then the fields, with the pointer parked at the
/// start of the payload.
#[cfg(test)]
pub fn framed(build: impl FnOnce(&mut Message)) -> Message {
let mut message = Message::new();
message.write_raw_bytes(vec![0u8; 8]);
build(&mut message);
message.set_pointer(8);
message
}
#[test]
fn test_get_buffer() {
let message = Message::new_with_data(
[
26, 0, 0, 0, 219, 178, 47, 28, 11, 0, 0, 0, 116, 104, 101, 32, 119,
101, 101, 107, 101, 110, 100,
]
.to_vec(),
);
assert_eq!(
message.get_buffer(),
[
23, 0, 0, 0, 26, 0, 0, 0, 219, 178, 47, 28, 11, 0, 0, 0, 116, 104,
101, 32, 119, 101, 101, 107, 101, 110, 100,
]
.to_vec()
);
let message = Message::new_with_data(
[
1, 0, 0, 0, 20, 0, 0, 0, 105, 110, 115, 97, 110, 101, 95, 105, 110,
95, 116, 104, 101, 95, 98, 114, 97, 105, 110, 50, 8, 0, 0, 0, 49,
51, 51, 55, 53, 49, 51, 55, 160, 0, 0, 0, 32, 0, 0, 0, 50, 101,
100, 102, 53, 49, 100, 48, 51, 55, 57, 52, 51, 55, 56, 102, 56, 98,
98, 54, 51, 49, 48, 100, 52, 54, 48, 99, 50, 50, 98, 49, 17, 0, 0,
0,
]
.to_vec(),
);
assert_eq!(
message.get_buffer(),
[
84, 0, 0, 0, 1, 0, 0, 0, 20, 0, 0, 0, 105, 110, 115, 97, 110, 101,
95, 105, 110, 95, 116, 104, 101, 95, 98, 114, 97, 105, 110, 50, 8,
0, 0, 0, 49, 51, 51, 55, 53, 49, 51, 55, 160, 0, 0, 0, 32, 0, 0, 0,
50, 101, 100, 102, 53, 49, 100, 48, 51, 55, 57, 52, 51, 55, 56,
102, 56, 98, 98, 54, 51, 49, 48, 100, 52, 54, 48, 99, 50, 50, 98,
49, 17, 0, 0, 0,
]
.to_vec()
);
}
#[test]
fn test_read_string() {
let data = vec![
5, 0, 0, 0, // size = 5
72, 101, 108, 108, 111, // "Hello"
];
let mut test_data = Message::new_with_data(data);
assert_eq!(test_data.read_string(), "Hello");
}
// Robustness against malformed / truncated network input. A remote server or
// peer is untrusted and can send a message whose declared field lengths exceed
// the bytes actually present; parsing must not panic (which would unwind out of
// the actor job and permanently kill a thread-pool worker).
#[test]
fn read_string_with_oversized_length_does_not_panic() {
// size = 0xFFFFFFFF but no payload bytes follow.
let mut msg = Message::new_with_data(vec![255, 255, 255, 255]);
assert_eq!(msg.read_string(), "");
}
#[test]
fn read_string_with_truncated_payload_does_not_panic() {
// size = 10 but only 2 payload bytes present.
let mut msg = Message::new_with_data(vec![10, 0, 0, 0, 65, 66]);
assert_eq!(msg.read_string(), "");
}
#[test]
fn read_string_with_missing_length_prefix_does_not_panic() {
// Only 2 bytes: not even a full 4-byte length prefix.
let mut msg = Message::new_with_data(vec![1, 2]);
assert_eq!(msg.read_string(), "");
}
#[test]
fn get_message_code_on_short_message_does_not_panic() {
// A zero-length frame drains to exactly the 4-byte length prefix, leaving
// no room for a code after it.
let msg = Message::new_with_data(vec![0, 0, 0, 0]);
assert_eq!(msg.get_message_code(), 0);
assert_eq!(msg.get_init_code(), 0);
}
#[test]
fn an_init_frame_reads_its_one_byte_code() {
let init = Message::new_with_data(
Message::new().write_int8(1).write_string("ab").get_buffer(),
);
assert_eq!(init.get_init_code(), 1);
assert_ne!(init.get_message_code(), 1);
}
#[test]
fn read_int_primitives_are_bounds_checked() {
let mut msg = Message::new_with_data(vec![1, 2]);
assert_eq!(msg.read_int64(), 0);
let mut msg = Message::new_with_data(vec![]);
assert_eq!(msg.read_int8(), 0);
assert!(!msg.read_bool());
}
#[test]
fn test_read_string_2() {
let data = vec![
50, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 81, 170, 162, 77, 32, 0, 0, 0,
101, 102, 99, 97, 51, 52, 102, 99, 52, 99, 56, 98, 101, 56, 98, 55,
101, 102, 51, 56, 97, 102, 50, 54, 50, 52, 100, 101, 53, 52, 54, 52, 0,
];
let mut test_data = Message::new_with_data(data);
test_data.set_pointer(9);
assert_eq!(test_data.read_string(), "");
}
#[test]
fn test_read_string_invalid_utf82() {
let data = [
128, 0, 0, 0, 103, 58, 92, 100, 105, 115, 107, 52, 92, 115, 101, 109,
105, 114, 97, 109, 105, 115, 92, 99, 104, 105, 108, 108, 44, 32, 100,
117, 98, 44, 32, 100, 111, 119, 110, 98, 101, 97, 116, 44, 32, 97, 109,
98, 105, 101, 110, 116, 92, 118, 97, 114, 105, 111, 117, 115, 32, 97,
114, 116, 105, 115, 116, 115, 92, 112, 111, 116, 116, 32, 104, 101, 97,
100, 122, 32, 45, 32, 100, 111, 112, 101, 32, 115, 109, 111, 107, 105,
110, 180, 98, 101, 97, 116, 115, 32, 107, 98, 115, 32, 49, 50, 56, 32,
49, 57, 57, 54, 92, 48, 53, 32, 45, 32, 98, 108, 117, 101, 32, 116,
114, 97, 105, 110, 46, 109, 112, 51,
]
.to_vec();
let mut test_data = Message::new_with_data(data);
let str = test_data.read_string();
assert_eq!(
str,
r"g:\disk4\semiramis\chill, dub, downbeat, ambient\various artists\pott headz - dope smokin´beats kbs 128 1996\05 - blue train.mp3"
);
}
@@ -0,0 +1,153 @@
use crate::message::{Message, MessageHandler};
use crate::peer::PeerMessage;
use crate::types::SearchResult;
use crate::utils::zlib::deflate;
use std::sync::mpsc::Sender;
/// A borrowed view of one file to advertise in a search response, kept
/// independent of the shares module so the message layer stays decoupled.
pub struct FileEntry<'a> {
pub name: &'a str,
pub size: u64,
pub attribs: &'a [(u32, u32)],
}
/// Build a `FileSearchResponse` (peer code 9): the zlib-compressed reply we send
/// to a peer whose search matched our shared files. The payload is the exact
/// inverse of [`SearchResult::new_from_message`].
#[must_use]
pub fn build_file_search_response(
own_username: &str,
token: u32,
files: &[FileEntry],
slots: u8,
speed: u32,
queue_length: u32,
) -> Message {
let mut payload = Message::new();
payload
.write_string(own_username)
.write_int32(token)
.write_int32(files.len() as u32);
for file in files {
payload
.write_int8(1)
.write_string(file.name)
.write_int64(file.size)
.write_string("") // extension (decoder reads and discards)
.write_int32(file.attribs.len() as u32);
for &(code, value) in file.attribs {
payload.write_int32(code).write_int32(value);
}
}
payload
.write_int8(slots)
.write_int32(speed)
.write_int32(queue_length);
let compressed = deflate(&payload.get_data());
Message::new()
.write_int32(9)
.write_raw_bytes(compressed)
.clone()
}
pub struct FileSearchResponse;
impl MessageHandler<PeerMessage> for FileSearchResponse {
fn get_code(&self) -> u32 {
9
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
// Skip malformed search results
let Ok(file_search) = SearchResult::new_from_message(message) else {
return;
};
let _ = sender.send(PeerMessage::FileSearchResult(file_search));
}
}
#[test]
fn test_new_from_message() {
let data: Vec<u8> = [
229, 0, 0, 0, 9, 0, 0, 0, 120, 156, 99, 103, 96, 96, 72, 201, 79, 201,
76, 79, 204, 203, 213, 158, 98, 194, 4, 228, 50, 250, 3, 9, 7, 135,
162, 156, 148, 194, 188, 152, 228, 252, 220, 130, 156, 212, 146, 212,
24, 231, 196, 188, 228, 204, 252, 188, 212, 226, 152, 144, 162, 210,
226, 226, 212, 28, 93, 67, 75, 115, 75, 93, 119, 160, 144, 130, 91,
126, 145, 66, 72, 70, 170, 66, 120, 106, 106, 118, 106, 94, 138, 174,
161, 89, 82, 102, 137, 174, 137, 137, 142, 161, 119, 70, 149, 94, 90,
78, 98, 114, 203, 175, 243, 32, 163, 193, 128, 25, 100, 7, 16, 23, 0,
9, 22, 32, 237, 178, 134, 129, 129, 21, 72, 11, 128, 196, 243, 176,
217, 29, 156, 153, 151, 158, 147, 90, 12, 54, 95, 193, 216, 84, 193,
200, 192, 200, 36, 198, 45, 181, 168, 40, 53, 57, 91, 193, 37, 177, 60,
79, 71, 193, 55, 177, 44, 181, 40, 19, 200, 13, 78, 76, 42, 74, 85, 80,
83, 240, 75, 45, 7, 10, 38, 103, 100, 2, 221, 167, 139, 238, 66, 5, 13,
144, 17, 154, 96, 167, 173, 228, 215, 98, 68, 119, 218, 74, 6, 76, 167,
49, 60, 153, 202, 200, 160, 199, 128, 0, 0, 161, 99, 76, 142,
]
.to_vec();
let mut message = Message::new_with_data(data);
message.set_pointer(8);
let file_search = SearchResult::new_from_message(&mut message).unwrap();
assert_eq!(file_search.token, 882125677);
assert_eq!(file_search.files.len(), 2);
let file = &file_search.files[0];
assert_eq!(
file.name,
"@@rldqn\\complete\\Canciones\\Trussel-1979-Gone For The Weekend-16bit-44,1Khz.flac"
);
assert_eq!(file.username, "dodigan");
assert_eq!(file.size, 47184516);
}
#[test]
fn build_file_search_response_roundtrips_through_the_decoder() {
let files = [
FileEntry {
name: "music\\album\\song.mp3",
size: 47_184_516,
attribs: &[(1, 320), (4, 44100), (5, 16)],
},
FileEntry {
name: "b.flac",
size: 456,
attribs: &[],
},
];
let message = build_file_search_response("e2e_sharer", 42, &files, 1, 0, 0);
// Decode via the exact production decoder used for real peer responses:
// the dispatcher positions the pointer at 8 (past length + code).
let mut decoded = Message::new_with_data(message.get_buffer());
decoded.set_pointer(8);
let result = SearchResult::new_from_message(&mut decoded).unwrap();
assert_eq!(result.username, "e2e_sharer");
assert_eq!(result.token, 42);
assert_eq!(result.files.len(), 2);
assert_eq!(result.files[0].name, "music\\album\\song.mp3");
assert_eq!(result.files[0].size, 47_184_516);
assert_eq!(result.files[0].attribs.get(&1), Some(&320));
assert_eq!(result.files[0].attribs.get(&4), Some(&44100));
assert_eq!(result.files[1].name, "b.flac");
assert_eq!(result.files[1].size, 456);
assert!(result.files[1].attribs.is_empty());
assert_eq!(result.slots, 1);
}
#[test]
fn the_trailer_carries_slots_speed_and_queue_length() {
let message = build_file_search_response("me", 1, &[], 0, 900, 7);
let mut body = Message::new_with_data(
crate::utils::zlib::inflate(&message.get_data()[4..]).unwrap(),
);
body.read_string();
body.read_int32();
assert_eq!(body.read_int32(), 0, "no files");
assert_eq!(
(body.read_int8(), body.read_int32(), body.read_int32()),
(0, 900, 7)
);
}
@@ -0,0 +1,112 @@
//! `FolderContentsRequest` (peer code 36) and its reply (code 37): one folder
//! of a share in the listing form code 5 uses, which is what "download
//! folder" in other clients asks for.
use crate::message::peer::SharedDirectory;
#[cfg(test)]
use crate::message::peer::SharedFileEntry;
use crate::message::peer::shared_file_list::{
decompress_body, read_directories, write_directories,
};
use crate::message::{Message, MessageHandler};
use crate::peer::PeerMessage;
use crate::utils::zlib::deflate;
use std::sync::mpsc::Sender;
pub struct FolderContentsRequest;
impl MessageHandler<PeerMessage> for FolderContentsRequest {
fn get_code(&self) -> u32 {
36
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let token = message.read_int32();
let folder = message.read_string();
let _ =
sender.send(PeerMessage::FolderContentsRequested { token, folder });
}
}
/// `FolderContentsResponse` (peer code 37): the reply to a folder we asked
/// for. The token is the one we quoted, so a client with two folders in
/// flight can tell the answers apart.
pub struct FolderContentsResponseHandler;
impl MessageHandler<PeerMessage> for FolderContentsResponseHandler {
fn get_code(&self) -> u32 {
37
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let Some((token, folder, directories)) = parse_folder_contents(message)
else {
return;
};
let _ = sender.send(PeerMessage::FolderContentsReceived {
token,
folder,
directories,
});
}
}
/// Build a `FolderContentsRequest` (peer code 36).
#[must_use]
pub fn build_folder_contents_request(token: u32, folder: &str) -> Message {
Message::new()
.write_int32(36)
.write_int32(token)
.write_string(folder)
.clone()
}
/// Build a `FolderContentsResponse` (peer code 37).
#[must_use]
pub fn build_folder_contents(
token: u32,
folder: &str,
dirs: &[SharedDirectory],
) -> Message {
let mut payload = Message::new();
payload.write_int32(token).write_string(folder);
write_directories(&mut payload, dirs);
Message::new()
.write_int32(37)
.write_raw_bytes(deflate(&payload.get_data()))
.clone()
}
/// Parse a `FolderContentsResponse` positioned at its compressed blob, or
/// `None` when it does not inflate.
#[must_use]
pub fn parse_folder_contents(
message: &mut Message,
) -> Option<(u32, String, Vec<SharedDirectory>)> {
let mut body = decompress_body(message, crate::utils::zlib::MAX_INFLATED)?;
let token = body.read_int32();
let folder = body.read_string();
Some((token, folder, read_directories(&mut body)))
}
#[test]
fn a_folder_listing_round_trips() {
let dirs = vec![SharedDirectory {
name: "music\\album".to_string(),
files: vec![
SharedFileEntry {
name: "one.flac".to_string(),
size: 40,
attributes: vec![(0, 992)],
},
SharedFileEntry {
name: "two.flac".to_string(),
size: 50,
attributes: Vec::new(),
},
],
}];
let built = build_folder_contents(7, "music\\album", &dirs);
let mut message = Message::new_with_data(built.get_data());
message.set_pointer(4);
assert_eq!(
parse_folder_contents(&mut message),
Some((7, "music\\album".to_string(), dirs))
);
}
@@ -0,0 +1,17 @@
use crate::{
message::{Message, MessageHandler},
peer::PeerMessage,
};
use std::sync::mpsc::Sender;
/// A peer asking to browse our shared files (peer code 4). The client (which
/// owns the shares) builds the real SharedFileListResponse in reply.
pub struct GetShareFileList;
impl MessageHandler<PeerMessage> for GetShareFileList {
fn get_code(&self) -> u32 {
4
}
fn handle(&self, _message: &mut Message, sender: Sender<PeerMessage>) {
let _ = sender.send(PeerMessage::ShareListRequested);
}
}
+40
View File
@@ -0,0 +1,40 @@
mod file_search_response;
mod folder_contents;
mod get_share_file_list;
mod peer_init;
mod place_in_queue_request;
mod place_in_queue_response;
mod queue_upload;
mod shared_file_list;
mod transfer_request;
mod transfer_response;
mod upload_denied;
mod upload_failed;
mod user_info;
// Re-export handlers
pub use file_search_response::{
FileEntry, FileSearchResponse, build_file_search_response,
};
pub use folder_contents::{
FolderContentsRequest, FolderContentsResponseHandler,
build_folder_contents, build_folder_contents_request,
parse_folder_contents,
};
pub use get_share_file_list::GetShareFileList;
pub use peer_init::PeerInit;
pub use place_in_queue_request::PlaceInQueueRequest;
pub use place_in_queue_response::PlaceInQueueResponse;
pub use queue_upload::QueueUploadHandler;
pub use shared_file_list::{
SharedDirectory, SharedFileEntry, SharedFileListResponseHandler,
build_shared_file_list, parse_shared_file_list,
};
pub use transfer_request::TransferRequest;
pub use transfer_response::TransferResponse;
pub use upload_denied::UploadDeniedHandler;
pub use upload_failed::UploadFailedHandler;
pub use user_info::{
PeerInfo, UserInfoRequest, UserInfoResponseHandler, build_user_info,
build_user_info_request,
};
@@ -0,0 +1,28 @@
use std::sync::mpsc::Sender;
use crate::{
message::{Message, MessageHandler},
peer::PeerMessage,
trace,
};
pub struct PeerInit;
impl MessageHandler<PeerMessage> for PeerInit {
fn get_code(&self) -> u32 {
1
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
message.set_pointer(4);
let _message_code = message.read_int8();
let username = message.read_string();
let connection_type = message.read_string();
let token = message.read_int32();
trace!(
"PeerInit: username: {}, connection_type: {}, token: {}",
username, connection_type, token
);
let _ = sender.send(PeerMessage::SetUsername(username));
}
}
@@ -0,0 +1,45 @@
use crate::{
message::{Message, MessageHandler},
peer::PeerMessage,
};
use std::sync::mpsc::Sender;
/// `PlaceInQueueRequest` (peer code 51): a peer asking where the file they
/// queued sits in our upload queue.
///
/// Worth answering rather than dropping: a downloader that gets no position
/// cannot tell a long queue from a client that has forgotten about it.
pub struct PlaceInQueueRequest;
impl MessageHandler<PeerMessage> for PlaceInQueueRequest {
fn get_code(&self) -> u32 {
51
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let filename = message.read_string();
let _ = sender.send(PeerMessage::IncomingPlaceInQueueRequest(filename));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
#[test]
fn the_asked_about_filename_is_forwarded() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("@@share\\track.mp3");
});
PlaceInQueueRequest.handle(&mut message, tx);
match rx.try_recv() {
Ok(PeerMessage::IncomingPlaceInQueueRequest(filename)) => {
assert_eq!(filename, "@@share\\track.mp3");
}
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -0,0 +1,21 @@
use crate::{
message::{Message, MessageHandler},
peer::PeerMessage,
};
use std::sync::mpsc::Sender;
pub struct PlaceInQueueResponse;
impl MessageHandler<PeerMessage> for PlaceInQueueResponse {
fn get_code(&self) -> u32 {
44
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let filename = message.read_string();
let place = message.read_int32();
let _ =
sender.send(PeerMessage::PlaceInQueueResponse { filename, place });
}
}
@@ -0,0 +1,19 @@
use crate::{
message::{Message, MessageHandler},
peer::PeerMessage,
};
use std::sync::mpsc::Sender;
/// A peer asking to download one of our shared files (peer code 43).
pub struct QueueUploadHandler;
impl MessageHandler<PeerMessage> for QueueUploadHandler {
fn get_code(&self) -> u32 {
43
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let filename = message.read_string();
let _ = sender.send(PeerMessage::IncomingQueueUpload(filename));
}
}
@@ -0,0 +1,221 @@
//! `SharedFileListResponse` (peer code 5): a peer's full shared-file listing,
//! sent in reply to `GetShareFileList` (code 4). The payload is zlib-compressed
//! and groups files by their virtual directory.
use crate::message::{Message, MessageHandler};
use crate::peer::PeerMessage;
use crate::utils::zlib::{MAX_INFLATED_LISTING, deflate, inflate_limited};
use std::sync::mpsc::Sender;
/// One file in a shared directory: its basename, size and the `(code, value)`
/// audio attributes the owner advertises for it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SharedFileEntry {
pub name: String,
pub size: u64,
pub attributes: Vec<(u32, u32)>,
}
impl SharedFileEntry {
/// The value of attribute `code` (0 bitrate, 1 duration, 2 VBR, 4 sample
/// rate, 5 bit depth), if the owner advertised it.
#[must_use]
pub fn attribute(&self, code: u32) -> Option<u32> {
self.attributes
.iter()
.find(|&&(c, _)| c == code)
.map(|&(_, v)| v)
}
}
/// One shared directory and the files directly in it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SharedDirectory {
pub name: String,
pub files: Vec<SharedFileEntry>,
}
/// Receives a peer's `SharedFileListResponse` (peer code 5) when browsing them.
pub struct SharedFileListResponseHandler;
impl MessageHandler<PeerMessage> for SharedFileListResponseHandler {
fn get_code(&self) -> u32 {
5
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let directories = parse_shared_file_list(message);
let _ = sender.send(PeerMessage::ShareListReceived(directories));
}
}
/// Build a `SharedFileListResponse` (peer code 5) from the directory listing.
#[must_use]
pub fn build_shared_file_list(dirs: &[SharedDirectory]) -> Message {
let mut payload = Message::new();
write_directories(&mut payload, dirs);
payload.write_int32(0); // unknown
payload.write_int32(0); // number of private directories
let compressed = deflate(&payload.get_data());
Message::new()
.write_int32(5)
.write_raw_bytes(compressed)
.clone()
}
/// Parse the (zlib-compressed) `SharedFileListResponse` payload. `message` must
/// be positioned at the compressed blob (the dispatcher sets pointer 8).
///
/// Returns an empty listing if the payload is malformed.
#[must_use]
pub fn parse_shared_file_list(message: &mut Message) -> Vec<SharedDirectory> {
// A whole share listing is the one payload that legitimately runs large.
decompress_body(message, MAX_INFLATED_LISTING)
.map_or_else(Vec::new, |mut body| read_directories(&mut body))
}
/// Write a directory listing in the form both code 5 and code 37 carry.
pub fn write_directories(payload: &mut Message, dirs: &[SharedDirectory]) {
payload.write_int32(dirs.len() as u32);
for dir in dirs {
payload
.write_string(&dir.name)
.write_int32(dir.files.len() as u32);
for file in &dir.files {
payload
.write_int8(1)
.write_string(&file.name)
.write_int64(file.size)
.write_string("") // extension
.write_int32(file.attributes.len() as u32);
for &(code, value) in &file.attributes {
payload.write_int32(code).write_int32(value);
}
}
}
}
/// Read back what [`write_directories`] wrote, stopping early when a hostile
/// count outruns the payload so a bogus length cannot spin into a huge
/// allocation loop.
pub fn read_directories(body: &mut Message) -> Vec<SharedDirectory> {
let dir_count = body.read_int32();
let mut dirs = Vec::new();
for _ in 0..dir_count {
if body.get_pointer() >= body.get_size() {
break;
}
let name = body.read_string();
let file_count = body.read_int32();
let mut files = Vec::new();
for _ in 0..file_count {
if body.get_pointer() >= body.get_size() {
break;
}
body.read_int8(); // code
let name = body.read_string();
let size = body.read_int64();
body.read_string(); // extension
let attr_count = body.read_int32();
let mut attributes = Vec::new();
for _ in 0..attr_count {
// Each attribute is two int32s (8 bytes); read_int32 does not
// advance past the end, so bound the loop explicitly.
if body.get_pointer() + 8 > body.get_size() {
break;
}
attributes.push((body.read_int32(), body.read_int32()));
}
files.push(SharedFileEntry {
name,
size,
attributes,
});
}
dirs.push(SharedDirectory { name, files });
}
dirs
}
/// Inflate a compressed peer payload positioned at the blob, refusing one
/// that expands past `max_out` — a peer we have never met chose these bytes.
pub fn decompress_body(
message: &mut Message,
max_out: usize,
) -> Option<Message> {
let pointer = message.get_pointer();
let size = message.get_size();
inflate_limited(&message.get_slice(pointer, size), max_out)
.ok()
.map(Message::new_with_data)
}
#[test]
fn hostile_dir_count_does_not_hang() {
// A compressed body claiming ~4 billion directories with no data must
// parse to empty promptly rather than looping into an OOM.
let compressed = crate::utils::zlib::deflate(&u32::MAX.to_le_bytes());
let mut message = crate::message::framed(|m| {
m.write_raw_bytes(compressed);
});
assert!(parse_shared_file_list(&mut message).is_empty());
}
#[test]
fn a_listing_carries_each_files_attributes() {
let dirs = vec![SharedDirectory {
name: "music\\album".to_string(),
files: vec![
SharedFileEntry {
name: "one.mp3".to_string(),
size: 40,
attributes: vec![(0, 320), (1, 187)],
},
SharedFileEntry {
name: "two.flac".to_string(),
size: 50,
attributes: Vec::new(),
},
],
}];
let mut message = crate::message::framed(|m| {
m.write_raw_bytes(
build_shared_file_list(&dirs).get_data()[4..].to_vec(),
);
});
assert_eq!(parse_shared_file_list(&mut message), dirs);
}
#[test]
fn shared_file_list_roundtrips() {
let dirs = vec![
SharedDirectory {
name: "music\\album".to_string(),
files: vec![
SharedFileEntry {
name: "song one.flac".to_string(),
size: 123,
attributes: vec![(1, 300), (4, 44_100), (5, 16)],
},
SharedFileEntry {
name: "song two.flac".to_string(),
size: 456,
attributes: Vec::new(),
},
],
},
SharedDirectory {
name: "music".to_string(),
files: vec![SharedFileEntry {
name: "top.mp3".to_string(),
size: 789,
attributes: Vec::new(),
}],
},
];
let message = build_shared_file_list(&dirs);
// Decode via the same offset the dispatcher would use.
let mut decoded = Message::new_with_data(message.get_buffer());
decoded.set_pointer(8);
assert_eq!(parse_shared_file_list(&mut decoded), dirs);
}
@@ -0,0 +1,18 @@
use crate::{
message::{Message, MessageHandler},
peer::PeerMessage,
types::Transfer,
};
use std::sync::mpsc::Sender;
pub struct TransferRequest;
impl MessageHandler<PeerMessage> for TransferRequest {
fn get_code(&self) -> u32 {
40
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let transfer = Transfer::new_from_message(message);
let _ = sender.send(PeerMessage::TransferRequest(transfer));
}
}
@@ -0,0 +1,25 @@
use crate::{
message::{Message, MessageHandler},
peer::PeerMessage,
};
use std::sync::mpsc::Sender;
pub struct TransferResponse;
impl MessageHandler<PeerMessage> for TransferResponse {
fn get_code(&self) -> u32 {
41
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let token = message.read_int32();
let allowed = message.read_int8();
let reason = (allowed == 0).then(|| message.read_string());
let _ = sender.send(PeerMessage::TransferResponse {
token,
allowed: allowed == 1,
reason,
});
}
}
@@ -0,0 +1,56 @@
use crate::info;
use crate::{
message::{Message, MessageHandler},
peer::PeerMessage,
};
use std::sync::mpsc::Sender;
pub struct UploadDeniedHandler;
impl MessageHandler<PeerMessage> for UploadDeniedHandler {
fn get_code(&self) -> u32 {
50
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let filename = message.read_string();
let reason = message.read_string();
info!("Upload denied for {}: {}", filename, reason);
if reason == "Queued" {
return;
}
let _ = sender.send(PeerMessage::UploadFailed(String::new(), filename));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
#[test]
fn a_queued_reason_is_not_a_failure() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("@@share\\gone.mp3").write_string("Queued");
});
UploadDeniedHandler.handle(&mut message, tx);
assert!(rx.try_recv().is_err());
}
#[test]
fn the_denied_filename_is_forwarded() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("@@share\\gone.mp3")
.write_string("File not shared.");
});
UploadDeniedHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(PeerMessage::UploadFailed(_, filename)) => {
assert_eq!(filename, "@@share\\gone.mp3");
}
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -0,0 +1,40 @@
use crate::info;
use crate::{
message::{Message, MessageHandler},
peer::PeerMessage,
};
use std::sync::mpsc::Sender;
pub struct UploadFailedHandler;
impl MessageHandler<PeerMessage> for UploadFailedHandler {
fn get_code(&self) -> u32 {
46
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let filename = message.read_string();
info!("Upload failed for {}", filename);
let _ = sender.send(PeerMessage::UploadFailed(String::new(), filename));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
#[test]
fn the_failed_filename_is_forwarded() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("@@share\\gone.mp3");
});
UploadFailedHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(PeerMessage::UploadFailed(_, filename)) => {
assert_eq!(filename, "@@share\\gone.mp3");
}
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -0,0 +1,181 @@
//! `UserInfoRequest` (peer code 15) and its reply (code 16): what a peer says
//! about itself when another user opens its profile.
use crate::message::{Message, MessageHandler};
use crate::peer::PeerMessage;
use std::sync::mpsc::Sender;
pub struct UserInfoRequest;
impl MessageHandler<PeerMessage> for UserInfoRequest {
fn get_code(&self) -> u32 {
15
}
fn handle(&self, _message: &mut Message, sender: Sender<PeerMessage>) {
let _ = sender.send(PeerMessage::UserInfoRequested);
}
}
/// Build a `UserInfoRequest` (peer code 15, no body): ask a peer what it says
/// about itself.
#[must_use]
pub fn build_user_info_request() -> Message {
Message::new().write_int32(15).clone()
}
/// What a peer says about itself, from its `UserInfoResponse` (code 16).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PeerInfo {
pub description: String,
/// Whether a profile picture came with it, and how many bytes it was. The
/// image itself is not kept: no caller has a use for it yet, and a peer
/// can make it arbitrarily large.
pub picture_bytes: Option<usize>,
/// Uploads the peer says it has done in total.
pub total_uploads: u32,
/// How many transfers are waiting in its queue.
pub queue_size: u32,
/// Whether it has a free upload slot right now.
pub slots_free: bool,
/// Whether it would let us queue anything at all. Absent from older
/// clients, which stop the message one field early.
pub upload_allowed: Option<u32>,
}
/// `UserInfoResponse` (peer code 16): a peer's answer about itself.
pub struct UserInfoResponseHandler;
impl MessageHandler<PeerMessage> for UserInfoResponseHandler {
fn get_code(&self) -> u32 {
16
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let description = message.read_string();
let has_picture = message.read_bool();
let picture_bytes = has_picture.then(|| {
let length = message.read_int32() as usize;
let available =
message.get_size().saturating_sub(message.get_pointer());
let length = length.min(available);
let at = message.get_pointer() + length;
message.set_pointer(at);
length
});
let total_uploads = message.read_int32();
let queue_size = message.read_int32();
let slots_free = message.read_bool();
// Museek+ writes `slotsavail` as an integer, leaving three bytes of
// rubbish where the next field starts; Nicotine+ guards the read the
// same way, by only taking a field there is room for.
let upload_allowed =
(message.get_size().saturating_sub(message.get_pointer()) >= 4)
.then(|| message.read_int32());
let _ = sender.send(PeerMessage::UserInfoReceived(PeerInfo {
description,
picture_bytes,
total_uploads,
queue_size,
slots_free,
upload_allowed,
}));
}
}
/// Build a `UserInfoResponse` (peer code 16) with no description or picture.
#[must_use]
pub fn build_user_info(
upload_slots: u32,
queue_size: u32,
slots_free: bool,
) -> Message {
Message::new()
.write_int32(16)
.write_string("")
.write_bool(false)
.write_int32(upload_slots)
.write_int32(queue_size)
.write_bool(slots_free)
.clone()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
fn parse(build: impl FnOnce(&mut Message)) -> PeerInfo {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(build);
UserInfoResponseHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(PeerMessage::UserInfoReceived(info)) => info,
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn a_peers_answer_carries_its_slots_and_queue() {
let info = parse(|m| {
m.write_string("hello, I share flacs");
m.write_bool(false);
m.write_int32(42); // total uploads
m.write_int32(3); // queue size
m.write_bool(true); // a slot is free
m.write_int32(1); // uploads allowed
});
assert_eq!(info.description, "hello, I share flacs");
assert_eq!(info.picture_bytes, None);
assert_eq!(info.total_uploads, 42);
assert_eq!(info.queue_size, 3);
assert!(info.slots_free);
assert_eq!(info.upload_allowed, Some(1));
}
#[test]
fn a_picture_is_skipped_over_rather_than_kept() {
let info = parse(|m| {
m.write_string("with a picture");
m.write_bool(true);
m.write_int32(4);
m.write_raw_bytes(vec![1, 2, 3, 4]);
m.write_int32(7);
m.write_int32(0);
m.write_bool(false);
m.write_int32(0);
});
assert_eq!(info.picture_bytes, Some(4));
assert_eq!(info.total_uploads, 7, "the fields after it still line up");
}
#[test]
fn a_message_that_stops_early_still_parses() {
// Older clients omit the trailing field, and Museek+ leaves rubbish
// where it would be; neither may cost us the fields before it.
let info = parse(|m| {
m.write_string("older client");
m.write_bool(false);
m.write_int32(5);
m.write_int32(1);
m.write_bool(true);
});
assert_eq!(info.total_uploads, 5);
assert_eq!(info.queue_size, 1);
assert!(info.slots_free);
assert_eq!(info.upload_allowed, None);
}
#[test]
fn a_picture_longer_than_the_message_does_not_run_off_the_end() {
let info = parse(|m| {
m.write_string("hostile");
m.write_bool(true);
m.write_int32(u32::MAX);
m.write_raw_bytes(vec![9, 9]);
});
assert_eq!(info.picture_bytes, Some(2));
assert_eq!(info.total_uploads, 0, "nothing left to read reads as zero");
}
}
@@ -0,0 +1,30 @@
//! `AdminMessage` (server code 66): an announcement from the server to
//! everyone, surfaced as a private message from the user "server".
use crate::actor::server_actor::{ServerMessage, UserMessage};
use crate::message::{Message, MessageHandler};
use std::sync::mpsc::Sender;
use std::time::{SystemTime, UNIX_EPOCH};
pub struct AdminMessageHandler;
impl MessageHandler<ServerMessage> for AdminMessageHandler {
fn get_code(&self) -> u32 {
66
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as u32);
let announcement = UserMessage::new(
0,
now,
"server".to_string(),
message.read_string(),
false,
);
let _ =
sender.send(ServerMessage::PrivateMessageReceived(announcement));
}
}
@@ -0,0 +1,49 @@
use std::sync::mpsc::Sender;
use crate::{
actor::server_actor::ServerMessage,
message::{Message, MessageHandler},
};
/// `CantConnectToPeer` (code 1001): a brokered peer gave up reaching us.
///
/// The token is the one we quoted in our own `ConnectToPeer` (code 18), and it
/// is the only field the server relays — the username is not repeated, so the
/// token is what a client matches the pending attempt on.
///
/// Without this the download sits in "connecting" until a timeout; with it the
/// wait ends the moment the peer gives up.
pub struct CantConnectToPeerHandler;
impl MessageHandler<ServerMessage> for CantConnectToPeerHandler {
fn get_code(&self) -> u32 {
1001
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let token = message.read_int32();
let _ = sender.send(ServerMessage::CantConnectToPeer { token });
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
#[test]
fn the_token_of_the_failed_attempt_is_reported() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_int32(4242);
});
CantConnectToPeerHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::CantConnectToPeer { token }) => {
assert_eq!(token, 4242);
}
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -0,0 +1,17 @@
use crate::actor::server_actor::ServerMessage;
use crate::message::{Message, MessageHandler};
use crate::peer::Peer;
use std::sync::mpsc::Sender;
pub struct ConnectToPeerHandler;
impl MessageHandler<ServerMessage> for ConnectToPeerHandler {
fn get_code(&self) -> u32 {
18
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let Some(peer) = Peer::new_from_message(message) else {
return;
};
let _ = sender.send(ServerMessage::ConnectToPeer(peer));
}
}
@@ -0,0 +1,162 @@
//! What the server says about the distributed network: parent candidates
//! (code 102), a reset (130), and a search embedded for a branch root (93).
use crate::actor::server_actor::ServerMessage;
use crate::message::distributed::{Distributed, parse_at};
use crate::message::{Message, MessageHandler};
use std::net::Ipv4Addr;
use std::sync::mpsc::Sender;
pub struct PossibleParentsHandler;
impl MessageHandler<ServerMessage> for PossibleParentsHandler {
fn get_code(&self) -> u32 {
102
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let count = message.read_int32();
let candidates = (0..count.min(10))
.map_while(|_| entry(message))
.filter_map(|(username, host, port)| {
Some((username, host, u16::try_from(port).ok()?))
})
.collect();
let _ = sender.send(ServerMessage::PossibleParents(candidates));
}
}
/// One whole `(username, host, port)` entry, or `None` where the message
/// runs out: a truncated entry is not a parent to dial.
fn entry(message: &mut Message) -> Option<(String, String, u32)> {
let username = message.read_string();
if username.is_empty()
|| message.get_size().saturating_sub(message.get_pointer()) < 8
{
return None;
}
let host = Ipv4Addr::from(message.read_int32()).to_string();
Some((username, host, message.read_int32()))
}
pub struct ResetDistributedHandler;
impl MessageHandler<ServerMessage> for ResetDistributedHandler {
fn get_code(&self) -> u32 {
130
}
fn handle(&self, _message: &mut Message, sender: Sender<ServerMessage>) {
let _ = sender.send(ServerMessage::ResetDistributed);
}
}
/// A search the server hands us directly because we are a branch root; it is
/// answered like any other.
pub struct EmbeddedMessageHandler;
impl MessageHandler<ServerMessage> for EmbeddedMessageHandler {
fn get_code(&self) -> u32 {
93
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let code = message.read_int8();
if let Some(Distributed::Search {
username,
token,
query,
}) = parse_at(message, code)
{
let _ = sender.send(ServerMessage::FileSearchRequest {
username,
token,
query,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc::channel;
#[test]
fn possible_parents_carry_each_candidates_address() {
let (sender, received) = channel();
let mut message = crate::message::framed(|m| {
m.write_int32(2)
.write_string("alice")
.write_int32(u32::from_le_bytes([1, 0, 0, 127]))
.write_int32(2234)
.write_string("bob")
.write_int32(u32::from_le_bytes([8, 8, 8, 8]))
.write_int32(2235);
});
PossibleParentsHandler.handle(&mut message, sender);
let ServerMessage::PossibleParents(candidates) =
received.try_recv().unwrap()
else {
panic!("expected PossibleParents");
};
assert_eq!(
candidates,
vec![
("alice".to_string(), "127.0.0.1".to_string(), 2234),
("bob".to_string(), "8.8.8.8".to_string(), 2235),
]
);
}
// The count says two, the bytes hold one and a half: the whole one is
// a candidate, the stub is not. A port that is not a port is skipped too.
#[test]
fn a_truncated_or_absurd_entry_is_not_a_candidate() {
let (sender, received) = channel();
let mut message = crate::message::framed(|m| {
m.write_int32(3)
.write_string("alice")
.write_int32(u32::from_le_bytes([1, 0, 0, 127]))
.write_int32(70_000)
.write_string("bob")
.write_int32(u32::from_le_bytes([8, 8, 8, 8]))
.write_int32(2235)
.write_string("carol");
});
PossibleParentsHandler.handle(&mut message, sender);
let ServerMessage::PossibleParents(candidates) =
received.try_recv().unwrap()
else {
panic!("expected PossibleParents");
};
assert_eq!(
candidates,
vec![("bob".to_string(), "8.8.8.8".to_string(), 2235)]
);
}
#[test]
fn a_reset_and_an_embedded_search_become_client_events() {
let (sender, received) = channel();
let mut reset = crate::message::framed(|_| {});
ResetDistributedHandler.handle(&mut reset, sender.clone());
assert!(matches!(
received.try_recv().unwrap(),
ServerMessage::ResetDistributed
));
let inner = crate::message::distributed::build_search("seeker", 9, "q")
.get_data();
let mut embedded = crate::message::framed(|m| {
m.write_raw_bytes(inner);
});
EmbeddedMessageHandler.handle(&mut embedded, sender);
let ServerMessage::FileSearchRequest {
username,
token,
query,
} = received.try_recv().unwrap()
else {
panic!("expected FileSearchRequest");
};
assert_eq!(
(username.as_str(), token, query.as_str()),
("seeker", 9, "q")
);
}
}
@@ -0,0 +1,69 @@
use crate::debug;
use crate::{
actor::server_actor::ServerMessage,
message::{Message, MessageHandler},
};
use std::sync::mpsc::Sender;
pub struct ExcludedSearchPhrasesHandler;
impl MessageHandler<ServerMessage> for ExcludedSearchPhrasesHandler {
fn get_code(&self) -> u32 {
160
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let item_count = message.read_int32();
let mut exluded_phrases: Vec<String> = Vec::new();
for _ in 0..item_count {
// Guard against a hostile item_count outrunning the payload.
if message.get_pointer() + 4 > message.get_size() {
break;
}
let phrase = message.read_string();
exluded_phrases.push(phrase);
}
debug!("Excluded search phrases: {:?}", exluded_phrases);
// Kept, not just logged: the server rejects a search carrying one of
// these, so a client that forgets them spends its search allowance on
// queries that were never going to be answered.
let _ =
sender.send(ServerMessage::ExcludedSearchPhrases(exluded_phrases));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
#[test]
fn hostile_item_count_does_not_hang() {
// item_count=u32::MAX with no phrases: the guard must make this return
// promptly instead of looping ~4 billion times.
let (tx, _rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_int32(u32::MAX);
});
ExcludedSearchPhrasesHandler.handle(&mut message, tx);
}
#[test]
fn the_phrases_are_reported_not_only_logged() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_int32(2);
m.write_string("banned");
m.write_string("blocked");
});
ExcludedSearchPhrasesHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::ExcludedSearchPhrases(phrases)) => {
assert_eq!(phrases, ["banned", "blocked"]);
}
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -0,0 +1,27 @@
use crate::trace;
use std::sync::mpsc::Sender;
use crate::{
actor::server_actor::ServerMessage, message::Message,
message::handlers::MessageHandler,
};
pub struct FileSearchHandler;
impl MessageHandler<ServerMessage> for FileSearchHandler {
fn get_code(&self) -> u32 {
26
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
// The server distributes another user's search to us: [user][token][query].
let username = message.read_string();
let token = message.read_int32();
let query = message.read_string();
trace!("[server] search from {}: {} ({})", username, query, token);
let _ = sender.send(ServerMessage::FileSearchRequest {
username,
token,
query,
});
}
}
@@ -0,0 +1,35 @@
use crate::actor::server_actor::ServerMessage;
use crate::message::{Message, MessageHandler};
use std::sync::mpsc::Sender;
pub struct GetPeerAddressHandler;
impl MessageHandler<ServerMessage> for GetPeerAddressHandler {
fn get_code(&self) -> u32 {
3
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let username = message.read_string();
// Read IP address as 4 bytes
let mut ip: Vec<u8> = vec![];
for _ in 0..4 {
ip.push(message.read_int8());
}
let host = format!("{}.{}.{}.{}", ip[3], ip[2], ip[1], ip[0]);
let port = message.read_int32();
let obfuscation_type = message.read_int32();
let obfuscated_port = message.read_int32() as u16;
crate::debug!("GetPeerAddressHandler: {username:?}");
let _ = sender.send(ServerMessage::GetPeerAddressResponse {
username,
host,
port,
obfuscation_type,
obfuscated_port,
});
}
}
@@ -0,0 +1,58 @@
use std::sync::mpsc::Sender;
use crate::{
actor::server_actor::ServerMessage,
message::{Message, MessageHandler},
};
/// `GlobalRoomMessage` (code 152): a message said in some public room,
/// delivered because we subscribed to the global feed (code 150).
pub struct GlobalRoomMessageHandler;
impl MessageHandler<ServerMessage> for GlobalRoomMessageHandler {
fn get_code(&self) -> u32 {
152
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let room = message.read_string();
let username = message.read_string();
let text = message.read_string();
let _ = sender.send(ServerMessage::GlobalRoomMessageReceived {
room,
username,
message: text,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
#[test]
fn a_global_message_names_the_room_it_was_said_in() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("jazz");
m.write_string("alice");
m.write_string("anyone here?");
});
GlobalRoomMessageHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::GlobalRoomMessageReceived {
room,
username,
message,
}) => {
assert_eq!(
(room.as_str(), username.as_str(), message.as_str()),
("jazz", "alice", "anyone here?")
);
}
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -0,0 +1,346 @@
//! Interests and the queries built on them: recommendations (codes 54, 56,
//! 111), similar users (110, 112) and another user's interests (57).
//!
//! Ratings are signed on the wire: the server sends a descending list of
//! things it recommends and an ascending list of things it recommends
//! against, and the latter carry negative ratings.
use std::sync::mpsc::Sender;
use crate::{
actor::server_actor::ServerMessage,
message::{Message, MessageHandler},
types::{Recommendation, SimilarUser, UserInterests},
};
/// Read a `[count][item][rating]...` vector, stopping if the payload runs out
/// before the count does.
fn read_recommendations(message: &mut Message) -> Vec<Recommendation> {
let count = message.read_int32();
let mut items = Vec::new();
for _ in 0..count {
let item = message.read_string();
if item.is_empty() {
break;
}
#[allow(clippy::cast_possible_wrap)]
let rating = message.read_int32() as i32;
items.push(Recommendation { item, rating });
}
items
}
/// Read a `[count][string]...` vector, stopping at the end of the payload.
fn read_strings(message: &mut Message) -> Vec<String> {
let count = message.read_int32();
let mut items = Vec::new();
for _ in 0..count {
let item = message.read_string();
if item.is_empty() {
break;
}
items.push(item);
}
items
}
/// `GetRecommendations` (code 54): recommendations drawn from our own
/// interests.
pub struct RecommendationsHandler;
impl MessageHandler<ServerMessage> for RecommendationsHandler {
fn get_code(&self) -> u32 {
54
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let recommended = read_recommendations(message);
let unrecommended = read_recommendations(message);
let _ = sender.send(ServerMessage::RecommendationsReceived {
recommended,
unrecommended,
});
}
}
/// `GlobalRecommendations` (code 56): the same shape, server-wide.
pub struct GlobalRecommendationsHandler;
impl MessageHandler<ServerMessage> for GlobalRecommendationsHandler {
fn get_code(&self) -> u32 {
56
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let recommended = read_recommendations(message);
let unrecommended = read_recommendations(message);
let _ = sender.send(ServerMessage::GlobalRecommendationsReceived {
recommended,
unrecommended,
});
}
}
/// `ItemRecommendations` (code 111): what else people who like one item like.
pub struct ItemRecommendationsHandler;
impl MessageHandler<ServerMessage> for ItemRecommendationsHandler {
fn get_code(&self) -> u32 {
111
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let item = message.read_string();
let recommendations = read_recommendations(message);
let _ = sender.send(ServerMessage::ItemRecommendationsReceived {
item,
recommendations,
});
}
}
/// `SimilarUsers` (code 110): users whose interests overlap ours.
pub struct SimilarUsersHandler;
impl MessageHandler<ServerMessage> for SimilarUsersHandler {
fn get_code(&self) -> u32 {
110
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let count = message.read_int32();
let mut users = Vec::new();
for _ in 0..count {
let username = message.read_string();
if username.is_empty() {
break;
}
let weight = message.read_int32();
users.push(SimilarUser { username, weight });
}
let _ = sender.send(ServerMessage::SimilarUsersReceived { users });
}
}
/// `ItemSimilarUsers` (code 112): who likes one item. The server sends only
/// names here, so every weight reads as zero.
pub struct ItemSimilarUsersHandler;
impl MessageHandler<ServerMessage> for ItemSimilarUsersHandler {
fn get_code(&self) -> u32 {
112
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let item = message.read_string();
let usernames = read_strings(message);
let _ = sender
.send(ServerMessage::ItemSimilarUsersReceived { item, usernames });
}
}
/// `UserInterests` (code 57): what one user likes and hates.
pub struct UserInterestsHandler;
impl MessageHandler<ServerMessage> for UserInterestsHandler {
fn get_code(&self) -> u32 {
57
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let username = message.read_string();
let likes = read_strings(message);
let hates = read_strings(message);
let _ =
sender.send(ServerMessage::UserInterestsReceived(UserInterests {
username,
likes,
hates,
}));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
#[test]
fn recommendations_split_into_recommended_and_not() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_int32(1);
m.write_string("jazz");
m.write_int32(3);
m.write_int32(1);
m.write_string("polka");
#[allow(clippy::cast_sign_loss)]
m.write_int32(-2i32 as u32);
});
RecommendationsHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::RecommendationsReceived {
recommended,
unrecommended,
}) => {
assert_eq!(
recommended,
vec![Recommendation {
item: "jazz".into(),
rating: 3
}]
);
assert_eq!(
unrecommended,
vec![Recommendation {
item: "polka".into(),
rating: -2
}]
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn global_recommendations_parse_the_same_shape() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_int32(1);
m.write_string("dub");
m.write_int32(9);
m.write_int32(0);
});
GlobalRecommendationsHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::GlobalRecommendationsReceived {
recommended,
unrecommended,
}) => {
assert_eq!(recommended.len(), 1);
assert_eq!(recommended[0].item, "dub");
assert!(unrecommended.is_empty());
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn similar_users_carry_their_weight() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_int32(2);
m.write_string("alice");
m.write_int32(4);
m.write_string("bob");
m.write_int32(1);
});
SimilarUsersHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::SimilarUsersReceived { users }) => {
assert_eq!(
users,
vec![
SimilarUser {
username: "alice".into(),
weight: 4
},
SimilarUser {
username: "bob".into(),
weight: 1
},
]
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn item_similar_users_report_the_item_they_answer_for() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("jazz");
m.write_int32(1);
m.write_string("alice");
});
ItemSimilarUsersHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::ItemSimilarUsersReceived { item, usernames }) => {
assert_eq!(item, "jazz");
assert_eq!(usernames, vec!["alice".to_string()]);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn item_recommendations_report_their_item() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("jazz");
m.write_int32(1);
m.write_string("blues");
m.write_int32(2);
});
ItemRecommendationsHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::ItemRecommendationsReceived {
item,
recommendations,
}) => {
assert_eq!(item, "jazz");
assert_eq!(recommendations[0].item, "blues");
assert_eq!(recommendations[0].rating, 2);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn a_users_likes_and_hates_arrive_as_two_lists() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("alice");
m.write_int32(2);
m.write_string("jazz");
m.write_string("dub");
m.write_int32(1);
m.write_string("polka");
});
UserInterestsHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::UserInterestsReceived(interests)) => {
assert_eq!(interests.username, "alice");
assert_eq!(interests.likes, ["jazz", "dub"]);
assert_eq!(interests.hates, ["polka"]);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn a_user_with_no_interests_parses_to_empty_lists() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("ghost");
m.write_int32(0);
m.write_int32(0);
});
UserInterestsHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::UserInterestsReceived(interests)) => {
assert!(interests.likes.is_empty());
assert!(interests.hates.is_empty());
}
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -0,0 +1,309 @@
use crate::{
actor::server_actor::ServerMessage,
message::{Message, MessageHandler},
types::{RoomUserStats, UserStatus},
};
use std::sync::mpsc::Sender;
pub struct JoinRoomHandler;
impl MessageHandler<ServerMessage> for JoinRoomHandler {
fn get_code(&self) -> u32 {
14
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
// JoinRoom (code 14): room name, the member usernames, then the
// parallel per-member stat vectors.
let room = message.read_string();
let users = read_strings(message);
let stats = parse_member_stats(message, &users);
let _ = sender.send(ServerMessage::RoomJoined {
room: room.clone(),
users,
});
if !stats.is_empty() {
let _ = sender.send(ServerMessage::RoomMemberStats { room, stats });
}
}
}
/// Read a length-prefixed vector of strings, stopping early if the count
/// outruns the payload: a bogus (possibly hostile) count must not spin us
/// into an OOM allocation loop. A 4-byte floor is the smallest a
/// length-prefixed string can be.
fn read_strings(message: &mut Message) -> Vec<String> {
let count = message.read_int32();
let mut values = Vec::new();
for _ in 0..count {
if message.get_pointer() + 4 > message.get_size() {
break;
}
values.push(message.read_string());
}
values
}
/// Read a length-prefixed vector of 32-bit values, bounded the same way.
fn read_int32s(message: &mut Message) -> Vec<u32> {
let count = message.read_int32();
let mut values = Vec::new();
for _ in 0..count {
if message.get_pointer() + 4 > message.get_size() {
break;
}
values.push(message.read_int32());
}
values
}
/// Parse the per-member statistics that follow the membership list of a
/// `JoinRoom` (code 14) reply: statuses, then a record of share statistics per
/// member, then whether each member's upload slots are full, then country
/// codes.
///
/// Each vector is independently length-prefixed and a server may send fewer
/// entries than there are members (or, on an older server, omit the country
/// vector entirely), so every field is looked up positionally and defaults
/// when absent rather than shifting the whole roster.
#[must_use]
fn parse_member_stats(
message: &mut Message,
users: &[String],
) -> Vec<RoomUserStats> {
let statuses = read_int32s(message);
let stat_count = message.read_int32();
let mut shares = Vec::new();
for _ in 0..stat_count {
// A share record is 20 bytes: speed, an obsolete 64-bit upload
// number, files and folders.
if message.get_pointer() + 20 > message.get_size() {
break;
}
let average_speed = message.read_int32();
let _upload_number = message.read_int64(); // obsolete
let shared_files = message.read_int32();
let shared_folders = message.read_int32();
shares.push((average_speed, shared_files, shared_folders));
}
let slots_full = read_int32s(message);
let countries = read_strings(message);
users
.iter()
.enumerate()
.map(|(i, username)| {
let (average_speed, shared_files, shared_folders) =
shares.get(i).copied().unwrap_or((0, 0, 0));
RoomUserStats {
username: username.clone(),
status: UserStatus::from_code(
statuses.get(i).copied().unwrap_or(0),
),
average_speed,
shared_files,
shared_folders,
slots_full: slots_full.get(i).copied().unwrap_or(0) != 0,
country: countries.get(i).cloned().filter(|c| !c.is_empty()),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
#[test]
fn hostile_user_count_does_not_hang() {
// room="" then user_count=u32::MAX with no usernames: must return
// promptly instead of looping ~4 billion times.
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("").write_int32(u32::MAX);
});
JoinRoomHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::RoomJoined { users, .. }) => {
assert!(users.is_empty());
}
other => panic!("unexpected: {other:?}"),
}
}
/// A full JoinRoom payload: room, members, then the four parallel stat
/// vectors the server sends for them.
fn join_payload() -> Message {
framed(|m| {
m.write_string("nicotine")
.write_int32(2)
.write_string("alice")
.write_string("bob")
// statuses
.write_int32(2)
.write_int32(2)
.write_int32(1)
// share records
.write_int32(2)
.write_int32(1024)
.write_int64(0)
.write_int32(4321)
.write_int32(77)
.write_int32(50)
.write_int64(0)
.write_int32(10)
.write_int32(2)
// slots full
.write_int32(2)
.write_int32(1)
.write_int32(0)
// countries
.write_int32(2)
.write_string("NL")
.write_string("");
})
}
#[test]
fn member_stats_are_parsed_alongside_the_roster() {
let (tx, rx) = std::sync::mpsc::channel();
JoinRoomHandler.handle(&mut join_payload(), tx);
assert!(matches!(
rx.try_recv(),
Ok(ServerMessage::RoomJoined { .. })
));
match rx.try_recv() {
Ok(ServerMessage::RoomMemberStats { room, stats }) => {
assert_eq!(room, "nicotine");
assert_eq!(
stats,
vec![
RoomUserStats {
username: "alice".to_string(),
status: UserStatus::Online,
average_speed: 1024,
shared_files: 4321,
shared_folders: 77,
slots_full: true,
country: Some("NL".to_string()),
},
RoomUserStats {
username: "bob".to_string(),
status: UserStatus::Away,
average_speed: 50,
shared_files: 10,
shared_folders: 2,
slots_full: false,
country: None,
},
]
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn a_server_that_omits_the_stat_vectors_still_yields_a_roster() {
// Nothing after the membership list: every stat reads as a default
// rather than shifting the roster or dropping members.
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("jazz").write_int32(1).write_string("alice");
});
JoinRoomHandler.handle(&mut message, tx);
assert!(matches!(
rx.try_recv(),
Ok(ServerMessage::RoomJoined { .. })
));
match rx.try_recv() {
Ok(ServerMessage::RoomMemberStats { stats, .. }) => {
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].username, "alice");
assert_eq!(stats[0].average_speed, 0);
assert_eq!(stats[0].country, None);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn short_stat_vectors_do_not_shift_the_roster() {
// Stats for the first member only: the second must still be reported,
// with defaults, rather than inheriting the first member's figures.
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("jazz")
.write_int32(2)
.write_string("alice")
.write_string("bob")
.write_int32(1)
.write_int32(2)
.write_int32(1)
.write_int32(1024)
.write_int64(0)
.write_int32(9)
.write_int32(1)
.write_int32(0)
.write_int32(0);
});
JoinRoomHandler.handle(&mut message, tx);
let _ = rx.try_recv();
match rx.try_recv() {
Ok(ServerMessage::RoomMemberStats { stats, .. }) => {
assert_eq!(stats.len(), 2);
assert_eq!(stats[0].shared_files, 9);
assert_eq!(stats[1].username, "bob");
assert_eq!(stats[1].shared_files, 0);
assert_eq!(stats[1].status, UserStatus::Offline);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn hostile_stat_counts_do_not_hang_or_overallocate() {
// Vectors claiming ~4 billion entries in a tiny frame must return
// promptly, bounded by the payload.
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("jazz")
.write_int32(1)
.write_string("alice")
.write_int32(u32::MAX);
});
JoinRoomHandler.handle(&mut message, tx);
assert!(matches!(
rx.try_recv(),
Ok(ServerMessage::RoomJoined { .. })
));
}
#[test]
fn forwards_room_and_member_list() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("nicotine")
.write_int32(2)
.write_string("alice")
.write_string("bob");
});
JoinRoomHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::RoomJoined { room, users }) => {
assert_eq!(room, "nicotine");
assert_eq!(users, vec!["alice", "bob"]);
}
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -0,0 +1,38 @@
use crate::{
actor::server_actor::ServerMessage,
message::{Message, MessageHandler},
};
use std::sync::mpsc::Sender;
pub struct LeaveRoomHandler;
impl MessageHandler<ServerMessage> for LeaveRoomHandler {
fn get_code(&self) -> u32 {
15
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let room = message.read_string();
let _ = sender.send(ServerMessage::RoomLeft { room });
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
#[test]
fn forwards_left_room() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("jazz");
});
LeaveRoomHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::RoomLeft { room }) => assert_eq!(room, "jazz"),
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -0,0 +1,29 @@
use crate::{
actor::server_actor::ServerMessage, debug, info, message::Message,
};
use std::sync::mpsc::Sender;
use crate::message::MessageHandler;
pub struct LoginHandler;
impl MessageHandler<ServerMessage> for LoginHandler {
fn get_code(&self) -> u32 {
1
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let response = message.read_int8();
if response != 1 {
let _ = sender.send(ServerMessage::LoginStatus(false));
return;
}
info!("Login successful");
let greeting = message.read_string();
debug!("Server greeting: {:?}", greeting);
let _ = sender.send(ServerMessage::LoginStatus(true));
}
}
@@ -0,0 +1,950 @@
use crate::{
message::Message,
peer::ConnectionType,
types::{ClientVersion, Transfer},
utils::md5::md5,
};
pub struct MessageFactory;
impl MessageFactory {
#[must_use]
pub fn build_get_peer_address(username: &str) -> Message {
let mut message = Message::new();
message.write_int32(3);
message.write_string(username);
message
}
#[must_use]
pub fn build_login_message(
username: &str,
password: &str,
version: ClientVersion,
) -> Message {
let hash = md5([username, password].join("").as_str());
let mut message = Message::new();
message
.write_int32(1)
.write_string(username)
.write_string(password)
.write_int32(version.major)
.write_string(&hash)
.write_int32(version.minor)
.clone()
}
#[must_use]
pub fn build_shared_folders_message(
folder_count: u32,
file_count: u32,
) -> Message {
Message::new()
.write_int32(35)
.write_int32(folder_count)
.write_int32(file_count)
.clone()
}
#[must_use]
pub fn build_file_search_message(token: u32, query: &str) -> Message {
Message::new()
.write_int32(26)
.write_int32(token)
.write_string(query)
.clone()
}
/// Build a private message (server code 22) to send to another user.
#[must_use]
pub fn build_message_user(username: &str, message: &str) -> Message {
Message::new()
.write_int32(22)
.write_string(username)
.write_string(message)
.clone()
}
/// Acknowledge a received private message (server code 23) so the server
/// stops re-delivering it.
#[must_use]
pub fn build_message_acked(id: u32) -> Message {
Message::new().write_int32(23).write_int32(id).clone()
}
/// Ask the server (code 18) to broker a connection to a firewalled peer:
/// the server tells that peer to connect back to us, quoting `token`.
#[must_use]
pub fn build_connect_to_peer(
token: u32,
username: &str,
connection_type: ConnectionType,
) -> Message {
Message::new()
.write_int32(18)
.write_int32(token)
.write_string(username)
.write_string(&connection_type.to_string())
.clone()
}
/// Ask the server (code 92) how much of our own privilege time is left.
#[must_use]
pub fn build_check_privileges() -> Message {
Message::new().write_int32(92).clone()
}
/// Tell a peer where their queued file sits (peer code 44). Place counts
/// from 1.
#[must_use]
pub fn build_place_in_queue_response(
filename: &str,
place: u32,
) -> Message {
Message::new()
.write_int32(44)
.write_string(filename)
.write_int32(place)
.clone()
}
/// A wishlist search (server code 103). Same shape as a plain `FileSearch`,
/// but the server rate-limits it to the interval it announced in code 104
/// and does not count it against the normal search allowance.
#[must_use]
pub fn build_wishlist_search(token: u32, query: &str) -> Message {
Message::new()
.write_int32(103)
.write_int32(token)
.write_string(query)
.clone()
}
#[must_use]
pub fn build_set_status_message(status_code: u32) -> Message {
Message::new()
.write_int32(28)
.write_int32(status_code)
.clone()
}
#[must_use]
/// Whether we lack a parent in the distributed network (code 71); the
/// server offers PossibleParents while we say so.
pub fn build_have_no_parent(no_parent: bool) -> Message {
Message::new().write_int32(71).write_bool(no_parent).clone()
}
/// The root of the branch we hang from (code 127): ourselves while we
/// have no parent.
#[must_use]
pub fn build_branch_root(username: &str) -> Message {
Message::new()
.write_int32(127)
.write_string(username)
.clone()
}
/// How deep in the tree we sit (code 126): 0 without a parent.
#[must_use]
pub fn build_branch_level(level: u32) -> Message {
Message::new().write_int32(126).write_int32(level).clone()
}
/// Whether we take children in the distributed network (code 100).
#[must_use]
pub fn build_accept_children(accept: bool) -> Message {
Message::new().write_int32(100).write_bool(accept).clone()
}
#[must_use]
pub fn build_set_wait_port_message(port: u16) -> Message {
Message::new()
.write_int32(2)
.write_int32(port.into())
.clone()
}
/// Ask the server (code 7) for a user's online status.
#[must_use]
pub fn build_get_user_status(username: &str) -> Message {
Message::new().write_int32(7).write_string(username).clone()
}
/// Tell the server (code 121) what a finished upload averaged, in bytes
/// per second; it folds that into the speed other users see for us.
#[must_use]
pub fn build_send_upload_speed(bytes_per_second: u32) -> Message {
Message::new()
.write_int32(121)
.write_int32(bytes_per_second)
.clone()
}
/// Ask the server (code 36) for a user's share statistics.
#[must_use]
pub fn build_get_user_stats(username: &str) -> Message {
Message::new()
.write_int32(36)
.write_string(username)
.clone()
}
/// Watch `username` (server code 5): the server replies with their
/// current status and share statistics, then pushes every later status
/// change as a `GetUserStatus` (code 7) until we unwatch them.
#[must_use]
pub fn build_watch_user(username: &str) -> Message {
Message::new().write_int32(5).write_string(username).clone()
}
/// Stop watching `username` (server code 6). The server sends no reply.
#[must_use]
pub fn build_unwatch_user(username: &str) -> Message {
Message::new().write_int32(6).write_string(username).clone()
}
/// Ask the server (code 64) for the list of public chat rooms.
#[must_use]
pub fn build_room_list_request() -> Message {
Message::new().write_int32(64).clone()
}
/// Join a chat room (server code 14). `private` requests a private room.
#[must_use]
pub fn build_join_room(room: &str, private: bool) -> Message {
Message::new()
.write_int32(14)
.write_string(room)
.write_int32(u32::from(private))
.clone()
}
/// Leave a chat room (server code 15).
#[must_use]
pub fn build_leave_room(room: &str) -> Message {
Message::new().write_int32(15).write_string(room).clone()
}
/// Say `message` in chat room `room` (server code 13).
#[must_use]
pub fn build_say_chatroom(room: &str, message: &str) -> Message {
Message::new()
.write_int32(13)
.write_string(room)
.write_string(message)
.clone()
}
/// Ask a peer for their shared-file listing (peer code 4, no body).
#[must_use]
pub fn build_get_share_file_list() -> Message {
Message::new().write_int32(4).clone()
}
#[must_use]
pub fn build_queue_upload_message(filename: &str) -> Message {
Message::new()
.write_int32(43)
.write_string(filename)
.clone()
}
#[must_use]
pub fn build_transfer_request_message(
filename: &str,
token: u32,
) -> Message {
Message::new()
.write_int32(40) // code
.write_int32(0) // direction
.write_int32(token)
.write_string(filename)
.clone()
}
/// A TransferRequest (peer code 40) initiating an *upload*: we offer a file
/// to a peer who queued it, quoting our transfer token and its size.
#[must_use]
pub fn build_upload_transfer_request(
filename: &str,
token: u32,
size: u64,
) -> Message {
Message::new()
.write_int32(40)
.write_int32(1) // direction: upload
.write_int32(token)
.write_string(filename)
.write_int64(size)
.clone()
}
#[must_use]
pub fn build_transfer_response_message(transfer: Transfer) -> Message {
Message::new()
.write_int32(41)
.write_int32(transfer.token)
.write_bool(true)
.clone()
}
#[must_use]
pub fn build_transfer_denial_message(token: u32, reason: &str) -> Message {
Message::new()
.write_int32(41)
.write_int32(token)
.write_bool(false)
.write_string(reason)
.clone()
}
#[must_use]
pub fn build_pierce_firewall_message(token: u32) -> Message {
Message::new()
.write_int8(0) // PierceFirewall message code
.write_int32(token)
.clone()
}
/// Ask a peer where the file we queued with them sits (peer code 51).
#[must_use]
pub fn build_place_in_queue_request(filename: &str) -> Message {
Message::new()
.write_int32(51)
.write_string(filename)
.clone()
}
/// Invite `username` into the private room we own (server code 134).
#[must_use]
pub fn build_add_room_member(room: &str, username: &str) -> Message {
Message::new()
.write_int32(134)
.write_string(room)
.write_string(username)
.clone()
}
/// Remove `username` from the private room we own (server code 135).
#[must_use]
pub fn build_remove_room_member(room: &str, username: &str) -> Message {
Message::new()
.write_int32(135)
.write_string(room)
.write_string(username)
.clone()
}
/// Give up our own membership of a private room (server code 136).
#[must_use]
pub fn build_cancel_room_membership(room: &str) -> Message {
Message::new().write_int32(136).write_string(room).clone()
}
/// Give up ownership of a private room, which deletes it (code 137).
#[must_use]
pub fn build_cancel_room_ownership(room: &str) -> Message {
Message::new().write_int32(137).write_string(room).clone()
}
/// Say whether we accept private-room invitations (server code 141).
#[must_use]
pub fn build_enable_room_invitations(enabled: bool) -> Message {
Message::new()
.write_int32(141)
.write_int8(u8::from(enabled))
.clone()
}
/// Make `username` an operator of our private room (server code 143).
#[must_use]
pub fn build_add_room_operator(room: &str, username: &str) -> Message {
Message::new()
.write_int32(143)
.write_string(room)
.write_string(username)
.clone()
}
/// Take operator status away from `username` (server code 144).
#[must_use]
pub fn build_remove_room_operator(room: &str, username: &str) -> Message {
Message::new()
.write_int32(144)
.write_string(room)
.write_string(username)
.clone()
}
/// Give up our own operator status in a room (server code 147).
#[must_use]
pub fn build_cancel_room_operatorship(room: &str) -> Message {
Message::new().write_int32(147).write_string(room).clone()
}
/// A keepalive ping (server code 32, no body). The server sends no reply;
/// its only job is to keep a quiet connection from being reaped by a NAT
/// or the server's own idle timeout, the way other clients ping.
#[must_use]
pub fn build_server_ping() -> Message {
Message::new().write_int32(32).clone()
}
/// Search one user's shares (server code 42). Replies come back over a
/// peer connection as ordinary `FileSearchResponse` frames, so the token
/// is drawn from the same space as a plain `FileSearch`.
#[must_use]
pub fn build_user_search(
username: &str,
token: u32,
query: &str,
) -> Message {
Message::new()
.write_int32(42)
.write_string(username)
.write_int32(token)
.write_string(query)
.clone()
}
/// Search the shares of everyone in `room` (server code 120).
#[must_use]
pub fn build_room_search(room: &str, token: u32, query: &str) -> Message {
Message::new()
.write_int32(120)
.write_string(room)
.write_int32(token)
.write_string(query)
.clone()
}
/// Tell the server (code 1001) that the peer it asked us to connect to,
/// quoting `token`, could not be reached. The server relays that to the
/// peer so it stops waiting on a connection that is never coming.
#[must_use]
pub fn build_cant_connect_to_peer(token: u32, username: &str) -> Message {
Message::new()
.write_int32(1001)
.write_int32(token)
.write_string(username)
.clone()
}
/// Set our ticker (the scrolling one-line message) in `room`, server code
/// 116. An empty ticker removes ours.
#[must_use]
pub fn build_set_room_ticker(room: &str, ticker: &str) -> Message {
Message::new()
.write_int32(116)
.write_string(room)
.write_string(ticker)
.clone()
}
/// Subscribe to the global room feed (server code 150): every public room
/// message on the server, tagged with the room it came from.
#[must_use]
pub fn build_join_global_room() -> Message {
Message::new().write_int32(150).clone()
}
/// Stop the global room feed (server code 151).
#[must_use]
pub fn build_leave_global_room() -> Message {
Message::new().write_int32(151).clone()
}
/// Add an interest (server code 51). Interests drive the recommendation
/// and similar-user queries below.
#[must_use]
pub fn build_add_thing_i_like(item: &str) -> Message {
Message::new().write_int32(51).write_string(item).clone()
}
/// Drop an interest (server code 52).
#[must_use]
pub fn build_remove_thing_i_like(item: &str) -> Message {
Message::new().write_int32(52).write_string(item).clone()
}
/// Add a dislike (server code 117).
#[must_use]
pub fn build_add_thing_i_hate(item: &str) -> Message {
Message::new().write_int32(117).write_string(item).clone()
}
/// Drop a dislike (server code 118).
#[must_use]
pub fn build_remove_thing_i_hate(item: &str) -> Message {
Message::new().write_int32(118).write_string(item).clone()
}
/// Ask for recommendations based on our own interests (server code 54).
#[must_use]
pub fn build_get_recommendations() -> Message {
Message::new().write_int32(54).clone()
}
/// Ask for the server-wide recommendations (server code 56).
#[must_use]
pub fn build_global_recommendations() -> Message {
Message::new().write_int32(56).clone()
}
/// Ask what `username` likes and hates (server code 57).
#[must_use]
pub fn build_user_interests(username: &str) -> Message {
Message::new()
.write_int32(57)
.write_string(username)
.clone()
}
/// Ask for users whose interests overlap ours (server code 110).
#[must_use]
pub fn build_similar_users() -> Message {
Message::new().write_int32(110).clone()
}
/// Ask what else people who like `item` like (server code 111).
#[must_use]
pub fn build_item_recommendations(item: &str) -> Message {
Message::new().write_int32(111).write_string(item).clone()
}
/// Ask which users like `item` (server code 112).
#[must_use]
pub fn build_item_similar_users(item: &str) -> Message {
Message::new().write_int32(112).write_string(item).clone()
}
/// Hand `days` of our own privileges to `username` (server code 123).
#[must_use]
pub fn build_give_privileges(username: &str, days: u32) -> Message {
Message::new()
.write_int32(123)
.write_string(username)
.write_int32(days)
.clone()
}
/// Change our account password (server code 142). The server echoes the
/// accepted password back under the same code.
#[must_use]
pub fn build_change_password(password: &str) -> Message {
Message::new()
.write_int32(142)
.write_string(password)
.clone()
}
/// Send one private message to several users at once (server code 149).
#[must_use]
pub fn build_message_users(usernames: &[String], message: &str) -> Message {
let mut msg = Message::new();
msg.write_int32(149);
msg.write_int32(usernames.len() as u32);
for username in usernames {
msg.write_string(username);
}
msg.write_string(message);
msg
}
#[must_use]
pub fn build_peer_init_message(
own_username: &str,
connection_type: ConnectionType,
token: u32,
) -> Message {
Message::new()
.write_int8(1)
.write_string(own_username)
.write_string(&connection_type.to_string())
.write_int32(token)
.clone()
}
}
#[test]
fn test_build_get_user_status() {
let message = MessageFactory::build_get_user_status("bob");
let expect: Vec<u8> = [7, 0, 0, 0, 3, 0, 0, 0, b'b', b'o', b'b'].to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_get_user_stats() {
let message = MessageFactory::build_get_user_stats("bob");
let expect: Vec<u8> = [36, 0, 0, 0, 3, 0, 0, 0, b'b', b'o', b'b'].to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_login_message() {
let message = MessageFactory::build_login_message(
"insane_in_the_brain2",
"13375137",
ClientVersion::default(),
);
let expect: Vec<u8> = [
1, 0, 0, 0, 20, 0, 0, 0, 105, 110, 115, 97, 110, 101, 95, 105, 110, 95,
116, 104, 101, 95, 98, 114, 97, 105, 110, 50, 8, 0, 0, 0, 49, 51, 51,
55, 53, 49, 51, 55, 176, 0, 0, 0, 32, 0, 0, 0, 50, 101, 100, 102, 53,
49, 100, 48, 51, 55, 57, 52, 51, 55, 56, 102, 56, 98, 98, 54, 51, 49,
48, 100, 52, 54, 48, 99, 50, 50, 98, 49, 1, 0, 0, 0,
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_upload_transfer_request() {
use crate::types::Transfer;
let message =
MessageFactory::build_upload_transfer_request("song.mp3", 555, 4096);
// Decode via the production Transfer parser (dispatcher starts at offset 8).
let mut decoded = Message::new_with_data(message.get_buffer());
decoded.set_pointer(8);
let transfer = Transfer::new_from_message(&mut decoded);
assert_eq!(transfer.direction, 1); // upload
assert_eq!(transfer.token, 555);
assert_eq!(transfer.filename, "song.mp3");
assert_eq!(transfer.size, 4096);
}
#[test]
fn a_transfer_denial_carries_the_token_and_the_reason() {
let message =
MessageFactory::build_transfer_denial_message(555, "Cancelled");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 41);
decoded.set_pointer(8);
assert_eq!(decoded.read_int32(), 555);
assert!(!decoded.read_bool());
assert_eq!(decoded.read_string(), "Cancelled");
}
#[test]
fn test_build_peer_init_message() {
use crate::peer::ConnectionType;
let message =
MessageFactory::build_peer_init_message("bob", ConnectionType::P, 7);
// [1][len=3]"bob"[len=1]"P"[token=7] — no length prefix in get_data()
let expect: Vec<u8> = [
1, // PeerInit code (int8)
3, 0, 0, 0, 98, 111, 98, // username "bob"
1, 0, 0, 0, 80, // connection type "P"
7, 0, 0, 0, // token
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_message_user() {
let message = MessageFactory::build_message_user("bob", "hi");
let expect: Vec<u8> = [
22, 0, 0, 0, // code
3, 0, 0, 0, 98, 111, 98, // username "bob"
2, 0, 0, 0, 104, 105, // message "hi"
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_message_acked() {
let message = MessageFactory::build_message_acked(7);
let expect: Vec<u8> = [23, 0, 0, 0, 7, 0, 0, 0].to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_join_room() {
let message = MessageFactory::build_join_room("nicotine", false);
let expect: Vec<u8> = [
14, 0, 0, 0, // code
8, 0, 0, 0, 110, 105, 99, 111, 116, 105, 110, 101, // "nicotine"
0, 0, 0, 0, // private = 0
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_leave_room() {
let message = MessageFactory::build_leave_room("nicotine");
let expect: Vec<u8> = [
15, 0, 0, 0, // code
8, 0, 0, 0, 110, 105, 99, 111, 116, 105, 110, 101, // "nicotine"
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_say_chatroom() {
let message = MessageFactory::build_say_chatroom("room", "hi");
let expect: Vec<u8> = [
13, 0, 0, 0, // code
4, 0, 0, 0, 114, 111, 111, 109, // "room"
2, 0, 0, 0, 104, 105, // "hi"
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_room_list_request() {
let message = MessageFactory::build_room_list_request();
assert_eq!(vec![64, 0, 0, 0], message.get_data());
}
#[test]
fn test_build_check_privileges() {
assert_eq!(
vec![92, 0, 0, 0],
MessageFactory::build_check_privileges().get_data()
);
}
#[test]
fn test_build_place_in_queue_response() {
let message = MessageFactory::build_place_in_queue_response("song.mp3", 3);
let expect: Vec<u8> = [
44, 0, 0, 0, // code
8, 0, 0, 0, 115, 111, 110, 103, 46, 109, 112, 51, // "song.mp3"
3, 0, 0, 0, // place
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_wishlist_search() {
let message = MessageFactory::build_wishlist_search(12, "trance wax");
let expect: Vec<u8> = [
103, 0, 0, 0, 12, 0, 0, 0, 10, 0, 0, 0, 116, 114, 97, 110, 99, 101, 32,
119, 97, 120,
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_file_search_message() {
let message = MessageFactory::build_file_search_message(12, "trance wax");
let expect: Vec<u8> = [
26, 0, 0, 0, 12, 0, 0, 0, 10, 0, 0, 0, 116, 114, 97, 110, 99, 101, 32,
119, 97, 120,
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_watch_user() {
let message = MessageFactory::build_watch_user("bob");
let expect: Vec<u8> = [5, 0, 0, 0, 3, 0, 0, 0, b'b', b'o', b'b'].to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_unwatch_user() {
let message = MessageFactory::build_unwatch_user("bob");
let expect: Vec<u8> = [6, 0, 0, 0, 3, 0, 0, 0, b'b', b'o', b'b'].to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn an_upload_speed_report_carries_the_rate() {
let message = MessageFactory::build_send_upload_speed(900);
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 121);
decoded.set_pointer(8);
assert_eq!(decoded.read_int32(), 900);
}
#[test]
fn a_place_in_queue_request_names_the_file() {
let message = MessageFactory::build_place_in_queue_request("a\\b.mp3");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 51);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "a\\b.mp3");
}
#[test]
fn a_ping_is_just_its_code() {
let message = MessageFactory::build_server_ping();
assert_eq!(message.get_data(), [32, 0, 0, 0]);
}
#[test]
fn a_user_search_names_the_user_before_its_token() {
let message = MessageFactory::build_user_search("bob", 99, "jazz");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 42);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "bob");
assert_eq!(decoded.read_int32(), 99);
assert_eq!(decoded.read_string(), "jazz");
}
#[test]
fn a_room_search_names_the_room_before_its_token() {
let message = MessageFactory::build_room_search("nicotine", 7, "dub");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 120);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "nicotine");
assert_eq!(decoded.read_int32(), 7);
assert_eq!(decoded.read_string(), "dub");
}
#[test]
fn a_cant_connect_carries_the_token_then_the_user() {
let message = MessageFactory::build_cant_connect_to_peer(4242, "bob");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 1001);
decoded.set_pointer(8);
assert_eq!(decoded.read_int32(), 4242);
assert_eq!(decoded.read_string(), "bob");
}
#[test]
fn a_room_ticker_carries_its_room_and_text() {
let message = MessageFactory::build_set_room_ticker("jazz", "hello");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 116);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "jazz");
assert_eq!(decoded.read_string(), "hello");
}
#[test]
fn the_global_room_is_joined_and_left_by_code_alone() {
assert_eq!(
MessageFactory::build_join_global_room().get_data(),
[150, 0, 0, 0]
);
assert_eq!(
MessageFactory::build_leave_global_room().get_data(),
[151, 0, 0, 0]
);
}
#[test]
fn each_interest_message_uses_its_own_code() {
for (message, code) in [
(MessageFactory::build_add_thing_i_like("jazz"), 51),
(MessageFactory::build_remove_thing_i_like("jazz"), 52),
(MessageFactory::build_add_thing_i_hate("polka"), 117),
(MessageFactory::build_remove_thing_i_hate("polka"), 118),
(MessageFactory::build_user_interests("bob"), 57),
(MessageFactory::build_item_recommendations("jazz"), 111),
(MessageFactory::build_item_similar_users("jazz"), 112),
] {
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), code);
decoded.set_pointer(8);
assert!(!decoded.read_string().is_empty());
}
}
#[test]
fn the_bodyless_queries_are_just_their_codes() {
assert_eq!(
MessageFactory::build_get_recommendations().get_data(),
[54, 0, 0, 0]
);
assert_eq!(
MessageFactory::build_global_recommendations().get_data(),
[56, 0, 0, 0]
);
assert_eq!(
MessageFactory::build_similar_users().get_data(),
[110, 0, 0, 0]
);
}
#[test]
fn a_privilege_gift_carries_the_recipient_and_days() {
let message = MessageFactory::build_give_privileges("bob", 3);
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 123);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "bob");
assert_eq!(decoded.read_int32(), 3);
}
#[test]
fn a_password_change_carries_the_new_password() {
let message = MessageFactory::build_change_password("hunter2");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 142);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "hunter2");
}
#[test]
fn a_multi_user_message_counts_its_recipients_first() {
let recipients = vec!["alice".to_string(), "bob".to_string()];
let message = MessageFactory::build_message_users(&recipients, "hi all");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 149);
decoded.set_pointer(8);
assert_eq!(decoded.read_int32(), 2);
assert_eq!(decoded.read_string(), "alice");
assert_eq!(decoded.read_string(), "bob");
assert_eq!(decoded.read_string(), "hi all");
}
#[test]
fn the_private_room_roster_messages_carry_room_then_user() {
for (message, code) in [
(MessageFactory::build_add_room_member("club", "bob"), 134),
(MessageFactory::build_remove_room_member("club", "bob"), 135),
(MessageFactory::build_add_room_operator("club", "bob"), 143),
(
MessageFactory::build_remove_room_operator("club", "bob"),
144,
),
] {
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), code);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "club");
assert_eq!(decoded.read_string(), "bob");
}
}
#[test]
fn giving_up_a_room_standing_names_only_the_room() {
for (message, code) in [
(MessageFactory::build_cancel_room_membership("club"), 136),
(MessageFactory::build_cancel_room_ownership("club"), 137),
(MessageFactory::build_cancel_room_operatorship("club"), 147),
] {
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), code);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "club");
}
}
#[test]
fn room_invitations_are_toggled_by_a_single_byte() {
let message = MessageFactory::build_enable_room_invitations(true);
assert_eq!(message.get_data(), [141, 0, 0, 0, 1]);
let message = MessageFactory::build_enable_room_invitations(false);
assert_eq!(message.get_data(), [141, 0, 0, 0, 0]);
}
#[test]
fn a_private_join_sets_the_private_flag() {
let message = MessageFactory::build_join_room("club", true);
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 14);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "club");
assert_eq!(decoded.read_int32(), 1);
}
@@ -0,0 +1,100 @@
use crate::actor::server_actor::{ServerMessage, UserMessage};
use crate::info;
use crate::message::server::MessageFactory;
use crate::message::{Message, MessageHandler};
use std::sync::mpsc::Sender;
pub struct MessageUser;
impl MessageHandler<ServerMessage> for MessageUser {
fn get_code(&self) -> u32 {
22
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let id = message.read_int32();
let timestamp = message.read_int32();
let username = message.read_string();
let message_content = message.read_string();
let new_message = message.read_bool();
let user_message = UserMessage::new(
id,
timestamp,
username,
message_content,
new_message,
);
info!("[MessageUser] User message received:{:?}", user_message);
// Acknowledge every message, not only the ones flagged new. The flag
// marks a message delivered as it was sent; one the server stored
// while we were offline arrives with it false, and those are exactly
// the messages the server keeps re-sending at every login until they
// are acknowledged.
let _ = sender.send(ServerMessage::SendMessage(
MessageFactory::build_message_acked(id),
));
// Surface the message to the client so it can be read via the API.
let _ =
sender.send(ServerMessage::PrivateMessageReceived(user_message));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
fn deliver(new_message: bool) -> Vec<ServerMessage> {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_int32(7);
m.write_int32(1_700_000_000);
m.write_string("alice");
m.write_string("hello");
m.write_int8(u8::from(new_message));
});
MessageUser.handle(&mut message, tx);
rx.try_iter().collect()
}
fn acked_id(messages: &[ServerMessage]) -> Option<u32> {
messages.iter().find_map(|message| match message {
ServerMessage::SendMessage(m) => {
let mut decoded = Message::new_with_data(m.get_buffer());
(decoded.get_message_code() == 23).then(|| {
decoded.set_pointer(8);
decoded.read_int32()
})
}
_ => None,
})
}
#[test]
fn a_freshly_delivered_message_is_acknowledged() {
let sent = deliver(true);
assert_eq!(acked_id(&sent), Some(7));
}
#[test]
fn a_message_stored_while_we_were_offline_is_acknowledged_too() {
// Stored messages arrive with new_message false; without an ack the
// server re-delivers them at every login, forever.
let sent = deliver(false);
assert_eq!(acked_id(&sent), Some(7));
}
#[test]
fn the_message_itself_is_surfaced_to_the_client() {
let sent = deliver(false);
assert!(sent.iter().any(|message| matches!(
message,
ServerMessage::PrivateMessageReceived(m)
if m.message() == "hello" && m.username() == "alice"
)));
}
}

Some files were not shown because too many files have changed in this diff Show More