Commit Graph
551 Commits
Author SHA1 Message Date
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