A busy room's log scrolls past and its member list runs to a thousand
names, with nothing to narrow either. In a room / now filters the log
to lines mentioning the text and u filters the member list, the same
way the room list and the results filter: type, Enter keeps it, Esc
clears it before it leaves the room. The inbox takes / over the open
conversation. The member keys, b and m among them, act on the narrowed
list, and the titles say what is filtered and how many made it.
A room's chat log was wrapped to the pane width from the first line on
every frame, so a busy room lagged more the longer it stayed open: in
the harness 30,000 lines cost 16.6ms a frame. The wrapped rows now
live with the log and a draw only wraps the lines that arrived since
the last one, starting over when the width changes. The same cache
serves the private-message box, and the room list draws only its
visible page like the other tables. The 30,000-line room now draws in
0.4ms.
A browsed share only moved a row at a time, so a folder of a few
hundred files stood between the user and the next folder. J and K now
jump between folder rows, H and L close or open every folder, and /
filters the whole tree by path the way the results and the room list
filter: type to narrow, Enter keeps it, Esc clears it, then hides. A
folder under a filter shows the paths that match and d downloads just
those. The keys popup gains a browse section and the bar lists them.
Moving through a browsed share felt laggy for the same reason the
results did: the popup re-flattened the whole expanded tree on every
frame, cloning every row's name and path, then built a table row for
each, and every keypress flattened it twice more. A heavy sharer's
tree runs to tens of thousands of rows once a few folders are open.
The flattened rows now live on the browse state and are rebuilt only
when a folder opens or closes, and the popup draws the visible page
alone. In the harness a 30,000-row tree went from 11ms to 0.35ms a
frame and a keypress from 2.7ms to under a microsecond.
The downloads pane had the same shape (every transfer became a row
each frame, and a select-all can queue ten thousand), so it takes the
same page window, which now lives beside page_of for every pane to
share.
Hiding panes with w and zooming with z had no way back short of
pressing each hidden pane's number and Esc. W shows every pane and
leaves zoom in one go, and the shortcut bar offers it only while there
is a layout to reset.
C has cleared every search for a while, and the keys popup says so, but
the pane's own shortcut bar never mentioned it, so it read as if d on
each search were the only way. The bar now lists it next to d → remove.
Scrolling a long results list was laggy: every frame built a table row
for every result, and ratatui then drew the forty-odd that fit. At the
10,000-file cap that cost 25-32ms a frame in release (69ms in debug), in
attached and standalone mode alike, so a held key stuttered.
The pane now slices to the page ratatui would show, by its own rule:
stay where the last frame left off, pulled along only as far as keeps
the selection in view. The table gets a page-local state and the page
start is written back into the real one. The page is counted with the
same page_of that PgUp/PgDn use, so paging and drawing agree, and a
selection past the end lands on the last row as ratatui put it. Idle
frames drop to 1.5ms and a keypress to 0.6ms at the same size, measured
live in both modes.
ratatui has no lazy rows of its own: the lazy_rows proposal was not
merged and moved to the experimental ratatui-table crate, so windowing
in the caller is the recommended approach today.
The README's recording still showed the whole path in one column. Every
results line in it, the rows a popup half covers and the ones the
recording caught mid-paint included, is redrawn from what the pane now
renders at that width, with the same names, sizes and scroll position.
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.