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