Commit Graph
434 Commits
Author SHA1 Message Date
Michel de Graaf 93bfa053e9 Merge pull request #82 from michel/lib/listing-attributes
feat(lib): carry audio attributes in browse and folder listings
2026-09-08 02:06:27 +02:00
Michel de Graaf 2c15e0600e Merge pull request #81 from michel/lib/audio-attributes
feat(lib): read audio attributes from shared files at scan time
2026-09-08 02:06:22 +02:00
Michel de Graaf f6423a2ed6 feat(lib): carry audio attributes in browse and folder listings
A browse listed each file as a name and a size, so a searcher who saw a
bitrate in a search reply lost it the moment they opened the sharer's
files. Listings (codes 5 and 37) now carry each file's attributes the
way Nicotine+ sends them, as a SharedFileEntry with name, size and
attributes in place of the (name, size) pair; the CLI's browse record
gains bitrate and duration, and the daemon protocol moves to version 2
for the new listing shape.
2026-09-08 01:44:49 +02:00
Michel de Graaf 5a20d69056 fix(lib): count the ID3 tag once and honour CRC frames when probing an MP3
The tag's length was subtracted twice from the audio length when the
tag fit in the first read, and not at all when it did not, so durations
came out short or long by the tag; a frame with the protection bit
clear carries a two-byte CRC before its side info, which put the Xing
block two bytes off; and a Xing block without a frame count threw the
whole file's attributes away instead of falling back to the frame
header. Each has a test now.
2026-09-08 01:44:19 +02:00
Michel de Graaf a58aedf479 feat(lib): read audio attributes from shared files at scan time
Every search reply we sent carried no bitrate or duration, so our files
failed every bitrate filter in other clients and sorted last in
SoulseekQt, whatever their quality. The share scan now reads each MP3's
first frame header (and Xing block for variable bitrate) and each FLAC's
STREAMINFO, advertising bitrate, duration, VBR, sample rate and bit
depth as far as the format gives them; other files stay bare. Only the
file's head is read, no crate is needed, and a file that does not parse
is shared without attributes rather than skipped.
2026-09-08 01:44:19 +02:00
Michel de Graaf d28e33802f Merge pull request #86 from michel/ci/stress-serve-browses-while-offering
fix(lib): keep a replaced peer connection open for the reply in flight
2026-09-08 01:43:42 +02:00
Michel de Graaf bcf71dabec fix(lib): parse what a peer sent before acting on its close
Draining several buffers a tick meant a reply and the close after it
could land in one call, and the close was acted on first: the reply sat
unparsed in the reader while the actor disconnected. Only the first read
of a drain reports an error now; the close is seen again on the next
tick, after the reply has been handled.
2026-09-08 01:23:19 +02:00
Michel de Graaf 2ab7902d1f fix(lib): retire a dialling actor at once and drain the socket while retired
A Retire that reached an actor still dialling was queued behind the
connection and, if that dial then failed, never applied: nothing in the
registry pointed at the actor any more, so its thread lived on. Nothing
has gone out on a dial in progress, so retiring one closes it on the
spot.

A retired actor reads only from its tick, and one socket buffer a tick
is 10 KiB a second: a real share listing would outlast the grace. Every
actor now drains up to 64 buffers a tick.
2026-09-08 01:00:21 +02:00
Michel de Graaf 2d4a6fdd51 refactor(lib): move the peer actor's tests to their own file 2026-09-08 00:36:03 +02:00
Michel de Graaf 3f1494a4f9 fix(stress): serve a browse that lands while an offer awaits its answer
The mock seeder drained every message but the TransferResponse while it
waited for the client to allow an upload, so a browse arriving in that
window was silently dropped and counted against the client.
2026-09-08 00:35:11 +02:00
Michel de Graaf ea168605b5 fix(lib): keep a replaced peer connection open for the reply in flight
Two connections to the same peer race whenever both sides dial at once:
we open one to send a browse, the peer opens one to deliver a search
reply. The registry keeps one actor per username, and until now stopped
the older one on the spot, which closed the socket carrying our browse
before the listing could arrive. Under load the stress gate lost up to
two thirds of its browses that way.

A replaced actor is now retired instead: it sends nothing new, keeps
reading for thirty seconds so a reply already on the wire still reaches
the client, and then stops itself. The peer closing a retired connection
is expected and reported as a clean close, so it cannot fail transfers
running on the connection that took over.
2026-09-08 00:35:11 +02:00
Michel de Graaf ee8d285505 Merge pull request #79 from michel/lib/offer-expiry
fix(lib): let an unanswered upload offer expire
2026-09-07 23:51:01 +02:00
Michel de Graaf 431080699d fix(lib): let an unanswered upload offer expire
An upload offer counted against the slot cap until the peer accepted
it, and a peer that never answered, having gone away or hung, held that
slot for the life of the process; with two slots by default, two such
peers shut uploads down until a restart. An offer nobody answers within
thirty seconds is now dropped, recorded as a failed upload so the
operator sees why, and the slot goes to the next in line.
2026-09-07 23:30:33 +02:00
Michel de Graaf 2d774c779f Merge pull request #80 from michel/ci/stress-gate
ci: gate pull requests on the stress benchmark losing no work
2026-09-07 23:30:02 +02:00
Michel de Graaf 43058d295b ci: gate pull requests on the stress benchmark losing no work
Every ceiling this project has hit only appeared under a mixed load, and
three of the four lost work silently while single-feature e2e stayed
green, yet the benchmark only ever ran by hand on one laptop. With
STRESS_REQUIRE_FUNCTIONAL=1 the stress example exits 1 when a download,
upload, search or browse was lost; throughput never fails it because it
measures the machine, not the client. CI now runs a quarter-size profile
against soulfind on every pull request.
2026-09-07 23:07:26 +02:00
Michel de Graaf 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