`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.
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.
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.
- 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.