The path splitter is a one-line stdlib call with a single caller, so it
lives beside that caller as a private fn, without its own tests. The pane
tests assert the two columns in one screen fragment each, the end-offset
test keeps only the two cases the existing name tests do not cover, and
the key-handling test that repeated one of them goes. The website mock
splits on the backslash the same way its Info pane already does.
The results list held the whole shared path in one column, so a search
was read through its folder: every row began with the same peer prefix
and the file name, the part being searched for, sat off the right edge.
Split the column at the last separator, the file name first with the
larger share of the width and the folder dimmed beside it, so names line
up and read at a glance.
The one scroll offset moves both columns together, and $ runs to the end
of whichever overflows further. The website's mock of the pane follows.
Suggested in #55.
A candidate that accepted the dial and then said nothing kept its slot
for the session, and ten of those blocked every later batch; a link now
closes after a minute of silence. A parent that flaps its level made us
tell the server four messages per flap; moves are now told at most once
a second, the latest one winning. A parent that reports level zero is
its own root from then on, whatever it said before. The wait for a
searcher's control connection runs from the last message queued for it,
not the first. A successful login is pinned to reset the leaf.
Review of the leaf found the seams a stranger on the other end of a D
connection could pull at, and a few that a slow one would.
- A code-93 envelope is unwrapped once, as Nicotine+ does; a frame made
of nothing but envelopes used to recurse once per byte.
- A branch level is validated before it is used: negative levels leave
the candidate unadoptable and the largest one no longer overflows.
- The link reads through the crate's MessageReader, so a frame split
across a read timeout is no longer torn or mistaken for a closed link.
- Every event a link raises carries its link number, so a stale thread
cannot adopt, close or move a newer link to the same user.
- Later branch facts from the parent move our own place and are told to
the server; a fresh login resets the leaf, whose stance is now the one
place the four announcement messages come from.
- Candidates from a later batch join the ones in flight instead of
cancelling them, capped at ten; searches from the parent are budgeted
at fifty a second; a reply queued for a searcher the server cannot
place expires after a minute.
- Truncated frames and half-read PossibleParents entries are dropped
instead of yielding an empty root, a level of zero or a phantom
candidate.
The e2e now has a searcher of its own, so the answer must go to whoever
asked and not to the parent that relayed it.
The server hands every client a list of possible parents and expects it to
hang from one so searches also travel peer to peer. Until now we told the
server we were a lone root and left it at that, which the network tolerates
but does not favour.
A leaf now dials each PossibleParents candidate over a D connection, adopts
the first that passes a search down after stating its branch, announces the
new branch root and level to the server, and answers those searches the same
way as ones from the server. Losing the parent or a ResetDistributed puts it
back to a parentless stance. The leaf takes no children.
The Leaf state machine is pure so its rules are unit-tested without sockets;
the e2e test drives a real client from a mock parent, since soulfind never
hands out parents.
The distributed search network carries searches from parent to child
over connections whose frames have a one-byte code; the library had no
reader for them. This adds the codec for Search, BranchLevel,
BranchRoot and the embedded envelope, with the identifier check that
keeps malformed frames from being answered, and completes the post-login
stance Nicotine+ sends: HaveNoParent, BranchRoot (ourselves),
BranchLevel 0 and AcceptChildren false, in place of HaveNoParent alone.
Every release since 14 has been a major, because the actor mailboxes,
the share index's struct and the CLI's command enum were all exhaustive
public API: adding a peer message, a scanned field or a subcommand read
as a breaking change to the library. The actors, dispatcher, peer
plumbing and download store are how the client is built, not what a host
programs against, so they move inside the crate; PeerMessage, SharedFile
and the CLI command enums are non-exhaustive; ConnectionType is
re-exported at the root for the one thing hosts used it for. A one-time
break so that the next additions are not.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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`.
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.
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.
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.
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.
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.
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.