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