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().
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.
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
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.
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.
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.
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.
Seven region audits, every finding adversarially verified against the
whole workspace before being applied; 80 landed and the two that failed
in application were refuted honestly (ParseError has a from_str_radix
producer the audit missed; NatPmpError's Display is the only reader of
its diagnostic payloads).
The bigger cuts:
- The NewPeer/PierceFireWall cluster: an operation variant nothing ever
sent, its 77-line arm, a protocol-wrong Client::pierce_firewall that
wrote a peer-init frame down the server socket, and the struct only
they used. The live paths in peer/listen.rs are untouched.
- The ops loop reads `for operation in reader` instead of hand-rolling
recv/break, de-indenting the whole dispatch body.
- Dead lib surface: Message::print_hex and four unused accessors,
UploadFailed, registry and actor getters, build_watch_user,
strip_ansi_codes, delegating shims on ClientContext. Removing public
items makes this a semver major, which the release pipeline enforces.
- Never-used flexibility: the state-migration framework that never held
a migration, border-type helpers that all answered Rounded, the TUI's
max_concurrent_downloads field nothing read.
- Convergence: six poll loops into poll_until, three wrap-around
navigation blocks into cycle, hit tests into Rect::contains, char
walkers into floor/ceil_char_boundary, five Download literals into
one helper, the suites' shared scaffolding into tests/common.
Deliberately not taken: swapping the hand-rolled zlib inflate and MD5
for crates (they replace wire-facing machinery whose tolerance of
real-world peer quirks no local test exercises) and pulling chrono into
the lib for a log timestamp. Recorded for their own decision.
Net -1002 lines. Both e2e modes stay green: 94 CLI tests each way, 16
protocol, 33 lib, unit suites, clippy pedantic clean, aislop 100/100
with the four scanner false positives suppressed inline where they
fire.
The full-surface audit found the settings story half-true: `config set
download_dir` and the TUI's settings form edited a file a running daemon
never rereads, while the attached TUI displayed the laptop's path and
discarded the edit entirely.
- `download.set_dir`: a session-wide move, published in the contract;
the per-transfer destination stays refused. `daemon.status` reports
it and the next transfer lands there.
- `config set download_dir` and `config set shared_dirs` push to a
live daemon the way `shares add` does, through one shared helper;
the socket probe is paid only by the keys a daemon owns.
- The attached TUI seeds its settings form from the daemon's status,
pushes both folders over the socket as typed, reports a partial
refusal as exactly that, and creates no client-side directories. It
reads the daemon token from the resolved config — with the token only
in config.toml it could not attach over TCP — and settings
persistence honours --config instead of hard-coding the default path.
- `search.wishlist` records a start time, so an attached window
renders it as running rather than instantly finished.
The rest is the coverage the audit demanded: CLI-level token auth
(wrong, missing, and valid over TCP), `daemon status|stop` with
nothing running, --sort, --limit, --pick first, --password-cmd,
--log-file, --upload-slots, the config-named download folder in both
modes, and headless settings-form tests attached and local. Every
guard was checked by disabling what it protects. Both e2e modes run
green: 94 CLI tests each way, 16 protocol, 285 unit, 34 lib.
Leading with `get` was the wrong trade. It ranks on free slot, bitrate and peer
speed, which are facts about the transfer and say nothing about whether the
file is the track that was asked for. On a setlist of remixes, dubs and white
labels it does not miss, it succeeds with the wrong one: exit 0, a file on
disk, and nobody finds out until it plays. A miss can be retried; a confident
wrong answer cannot. The previous revision optimised the measurable cost and
paid for it in the unmeasurable one.
The token problem it was solving is real, but the answer is to narrow before
reading rather than not to look. A search writes its records to a file; search
flags and jq cut three hundred down to eight without any of them entering
context; the eight are read and judged. `get` keeps a scoped mention for when
the user has named nothing in particular.
Adds what "the right one" means, since that is now the load-bearing step: fold
both sides, require every distinctive word, reject live/radio edit/snippet/dj
mix, prefer lossless, and above all treat the version as the thing you match on
rather than the thing you dropped. The ladder drops "(Kalabrese Remix)" to find
anything at all, so the match must then require "kalabrese" or it takes the
original and reports success.
Checked against agents given only this file on a remix-heavy setlist: 2/2
refuse `get`, narrow before reading, require the remixer's name in the chosen
path, and report a track as unavailable rather than silently taking the
original when only that exists.
Feedback from a real 32-track run: 70 agents, 2.48M tokens, 18.7 minutes, most
of it avoidable. Four things, three adopted as reported and one corrected.
The skill mandated a handoff it gave no mechanism for. Subagents had to report
a pick, while the rules said remote paths are backslash-separated, may contain
anything, and must be passed back byte-identical. A path does not survive a
subagent's return, a coordinator's JSON and a child's argv, so every agent
re-derived a workaround from prose. The idiom is now written down: append every
rung to one ndjson file, select the pick by line number, feed it to `download
--stdin`. The bytes never leave the file, and a subagent reports a file and a
line rather than a path.
`get` is now the default and subagents the exception. It searches, ranks and
downloads in one command, so results never enter any context, which is where
the tokens went. The section says when to escalate: a named mix, an ambiguous
title, four dead rungs, or several runs landing on the same peer.
Browsing triggers on any artist appearing more than once, not only on a whole
album. In the reported set that was 14 of 32 tracks across six artists.
The one correction: "rung 2 empty means rungs 3 and 4 are ceremony" is not
safe, because rung 3 folds diacritics and punctuation and that is often exactly
why rung 2 found nothing. The general rules are that a rung producing the same
string as the last is not a rung, and that no results at all means the tokens
are wrong rather than too many, so jump to the widest rung instead of stepping.
Streaming picks into transfers is untouched; the report singled it out as
working.
Checked against agents given only this file on the same shape of set: both lead
with `get`, spawn 3 and 6 subagents rather than 32, keep result sets out of
context, browse the repeated artists, and hand paths over as a file and a line
number.
The clearance step read as a barrier. "Only you can see every pick" invited
collecting all of them before releasing any, so a forty track set moved no
bytes until the last search finished, and one white label nobody is sharing
held up thirty-nine tracks that people are.
Nothing about the safety needed a barrier. Answering a single pick takes two
running counts, transfers to that peer and transfers overall, so picks are now
cleared as they arrive and the first file transfers while the last track is
still on its second rung. Says so explicitly, because the previous wording did
not have to be read as batching but was.
Also points a track that survives all four rungs at the wishlist rather than
just reporting it missing: the server re-runs those on its own schedule, which
is the right home for something nobody is sharing right now.
Checked against agents given only this file, on a forty track set with
unfindable tracks in it: 2/2 start transferring before the searches finish, and
both say a missing track holds up nothing but its own subagent.
Twenty transfers, twenty subagents, ten searches on the wire. The transfer
number is not invented: it is the client's own default, and the reasoning is
already in config.rs, that a Soulseek transfer is paced by the peer sending it
so a fast link is filled by many transfers rather than faster ones. Twenty
costs twenty sockets.
Two numbers deliberately did not scale with the rest, and the table now says
why:
- Three from one peer, up from two. This is the one that gets you banned. A
client serves ten upload slots by default, so three is a visible minority of
one person's capacity rather than a monopoly on it. Twenty transfers is fine;
twenty transfers from two people is not.
- Ten searches, not twenty. The server rate-limits searches and it is the
daemon's single connection making all of them. A subagent spends most of its
life waiting on a transfer, so twenty subagents behind ten search slots costs
nothing.
Four rungs is unchanged and now says it is not a concurrency limit at all.
Checked against agents given only this file, including a batch where one peer
has every track: both read the raised numbers correctly and both hold at three
from that peer, letting their queue feed the rest rather than opening
twenty-five transfers on one machine.
Soulseek matches a query against the filenames peers happen to have, and the
name a user gives is usually more specific than any of them: a mix suffix that
is not in the file, a diacritic the uploader typed as ASCII, an ampersand where
the file has a space. Searched verbatim that is exit 4 for a track dozens of
people share.
Adds a four-rung ladder (as written, without the version suffix, folded to
ASCII without featured artists, then the rarest two or three words), stopping
at the first rung that returns anything, and then matching results back against
what was actually asked for, since widening finds candidates but does not
choose. Reconciled with the rate-limit rule: a wider query is a different
query, repeating one is not.
Also fixes an inconsistency the concurrency numbers had grown: the subagent
route capped transfers per peer but never overall, while the batch route said
-c 5, so the same skill gave two answers. There is now one table covering
subagents, searches on the wire, transfers, transfers per peer, and rungs per
track. Five searches against ten subagents is deliberate, because the server
rate-limits searches harder than peers rate-limit uploads.
Checked against agents given only this file: 2/2 climb the ladder rather than
reporting failure on the first empty search, stop at four rungs, and verify the
result is the track that was asked for.
The batch guidance optimised for parallelism and judgement and was nearly
silent on what actually gets you blocked on Soulseek, which is the thing an
agent driving this unattended is most likely to walk into.
Rewritten around the risk, in the order it bites:
- Sharing nothing is the commonest reason to be banned, so `shares status` is
now a precondition: if files is 0, say so before downloading anything.
- Queueing many files at once from one person reads as abuse to them. Subagents
now report their pick and wait, because only the main agent can see every
pick and resolve two subagents landing on the same peer. The batch form gets
the matching rule: interleave the lines so consecutive ones name different
users, since the workers take them in order.
- Searches are rate-limited by the server, so never re-run a query; pick again
from the results already held.
- A failed transfer means take the next candidate, not retry the same peer.
Also adds the strategy for an album, which fresh agents reading the previous
draft worked out for themselves and which is both faster and safer: search
once, browse whoever has most of it, and only search per-track for the
remainder.
Checked against agents given only this file: 2/2 verify shares first,
coordinate peers, and refuse both the retry loop and the repeat search.
Two TUIs on one daemon behaved as two separate windows that happened to share
a login: each showed only the transfers it had queued and the searches it had
run, and neither said it was attached to anything.
The status bar now names the daemon, and both lists are views of the session:
transfers and searches appear in every window, dismissing one dismisses it
everywhere, and a window opened later starts from what is already running
rather than blank. message read likewise starts from the conversation the
daemon collected instead of only what arrives while it runs.
Search state needed the daemon to say when a query went out. The window a
search collects for belongs to whichever client started it, so without that
every other window showed a running search as finished the moment it appeared
— which is what it did. The summary carries a file count too, so a window has
the right number before it has pulled the results across.
Adds Client::forget_search, which the library had no way to do: a resident
client otherwise holds every result set it has ever seen, and a window that
dismissed a search had no way to say so.
A review of the first cut found five defects, all fixed here and all covered:
syncing ran locally too, where it cost a full copy of every result set per
frame and deleted the completed-download history restored from disk; a
transfer could be listed twice by racing its own registration; clearing all
searches did not reach the session, so every row came back; a search dismissed
elsewhere never left; and a wishlist search reported as finished the instant
it went out.
A search fans out to hundreds of peers, each holding a socket. A shell hands
its children a soft limit of 256 open files on macOS, which one busy search
blows straight through; a one-shot command never noticed because it exited
before it mattered, but a resident daemon hits it and then cannot accept peers
or control connections. From a client that looks like 'cannot reach the
daemon: Connection closed', intermittently, for no visible reason.
The daemon now raises its own soft limit to 8192 at startup, before anything
opens a socket, and says so plainly when the hard limit will not allow it.
macOS rejects an unlimited hard limit for open files, so both fields are named.
Both accept loops also slept zero on failure. Out of descriptors does not clear
by the next instruction, so one exhausted moment became a spinning core and
thousands of identical log lines — which is what the report looked like. They
now back off.
spawn_connection used thread::spawn, which panics when a thread cannot be
created. That panic unwinds through the accept loop, so on a client already
running a thread per peer one exhausted moment would end control for the life
of the process — every later connection accepted and immediately closed, which
is exactly what 'cannot reach the daemon: Connection closed' looks like from
the other side.
Builder::spawn instead: refusing the one connection, with a reason in the log.
Starting the daemon printed 'ready' and nothing else, so the token and the
next step were both somewhere else. It now prints what other commands can do
from here, and when --bind is given, the token itself with the two config
commands a remote client needs. All on stderr, so stdout stays empty and
daemon still composes in a pipeline.
Adds the guarantee daemon mode is for as a test: eight clients connected at
once are each answered correctly, none has its replies crossed with another's,
and the daemon still counts all eight. Plus 250 connect-and-drop cycles, well
past the connection cap, to catch a per-connection resource that is not
released — the shape a daemon that works for a while and then refuses
everybody would take.
A connection the daemon drops before it can be served now logs why at warning
level. A client only ever sees 'the connection closed', so that line is the
only account of the reason.
A remote daemon could only be named with --daemon and --daemon-token on every
invocation, which made the one workflow daemon mode is most useful for — a
headless download box driven from a laptop — a matter of retyping a 64-character
token. Both are now config keys, so it is two 'config set' commands once and
plain commands thereafter.
Fixes a bug that setup exposed: the up-front daemon status probe read the token
from the command line only, so with the token in the config file it failed
silently and every command that names a path or a server fell back to this
machine's defaults — whoami claiming downloads land in the laptop's home
directory when they land on the box. The regression test was checked by
reintroducing the bug.
Documents the download-box workflow in plain terms across the README, the
website and the agent skill, and says plainly that the schema is open: OpenRPC,
served by the daemon itself, so an Android, iOS or web remote is something
anyone can build without asking.
The skill buried daemon mode two-thirds down as a scaling trick, and still
offered --username "$SOULSEEK_USERNAME-$RANDOM" as the way to run commands in
parallel. That is worse than underselling it: the server allows one session
per account, so a per-run name means registering a throwaway account on a
public server for every search — account churn on infrastructure other people
maintain and share.
Starting a daemon is now the first instruction in the file, with the reason
stated, and the random-username idiom is named as the anti-pattern rather than
offered as an alternative. Exit 7's advice changes accordingly: it means
something else logged in as you, and the fix is a daemon, not a new name.
Three agents given only this file and a parallel-work task all started a
daemon and none invented a username.
The startup snippet also had a shell precedence bug: in
`daemon status || daemon &`, the & backgrounds the whole list, so it returned
instantly and the next command raced the login it was meant to use. Replaced
with a bounded wait, checked against a live server.
shares add wrote the config file and stopped there, so with a daemon running
the folder stayed invisible to the network until the daemon restarted — the
command reported success and nothing had happened. Locally that is the right
meaning, because there is no session yet; a daemon is already the next run.
Both commands now push the new list over shares.set. The e2e test was checked
against a disabled push to confirm it detects the bug.
Only 25 of the 83 CLI e2e tests actually reached a daemon: the helpers that
build a command bypassed the one place that routed. Routing at
TestServer::args instead brings that to 39 and, more importantly, puts the
streaming tests through a daemon — event.room and event.message delivery to a
remote client's mirror had no end-to-end coverage at all.
Adds what the command surface structurally cannot reach:
- Two clients against one daemon at once, which is the reason the hub exists
and was proven nowhere in CI.
- tests/daemon_protocol_e2e.rs, speaking the socket by hand: TCP token
required and socket token not, pre-auth refusal, error ids, notification
silence, the line cap, the handshake timeout, and that a call shaped by the
published contract is accepted.
- daemon status/stop/token as commands.
- A TUI unit test for conversation seeding, the part of the attach path that
had actually been broken.
The protocol suite caught a regression in yesterday's hardening on its first
run: the socket was shut down before the writer flushed a failed handshake's
refusal, so a client with a wrong token saw the connection vanish instead of
being told why.
A review pass over the diff found seventeen confirmed defects. The worst was
the contract itself: every method's parameters were published as a single
descriptor named `params`, so a client generated from openrpc.json sent
{"params":{"params":{…}}} and every call failed -32602. Parameters are now
published per field, with a test asserting the published names are the keys
the daemon actually deserializes.
Security and lifecycle:
- download.start no longer lets a caller name a directory, which was an
arbitrary write on the daemon's host.
- The Unix socket is bound under a temporary name and renamed into place; it
was briefly connectable by any local user between bind and chmod, and on
that transport connecting is authenticating.
- Pre-auth reads are capped at 1 MiB with a 30s handshake timeout, and
connections are capped, so an unauthenticated peer cannot exhaust memory or
threads.
- A read error no longer returns past the hub unsubscribe, which leaked a
subscriber and two threads per occurrence.
- A client whose event queue fills is now actually disconnected. It used to be
dropped from the fan-out and left connected, working on but blind to every
event after — the documentation already promised otherwise.
- A client whose daemon vanishes mid-transfer is told at once instead of
waiting out its full timeout and reporting a misleading "gave up".
Contract accuracy: errors carry an explicit null id (a missing one reads as a
notification and gets dropped), a second auth answers with the published
shape, and nullable fields are sent rather than omitted so a generated client
sees one shape.
Also splits proto.rs and server.rs, whose concerns had diverged, and applies
the review's simplifications.
SOULSEEK_E2E_MODE=daemon makes the existing e2e harness serve every session
from a daemon instead of the command's own login, so the same 83 tests assert
the same records and exit codes both ways. CI runs the suite twice.
Two defects it found:
serve judged whether anything was shared from this run's configuration
rather than from the session, so routed to a daemon it would start with
nothing to serve and report success.
The TUI showed an empty conversation when attached, because the daemon's
message history was never asked for. It is now a SessionApi method, empty
locally, which puts the difference behind the seam.
main.rs is now a thin entry point over the library rather than a second copy
of every module, so the program compiles once instead of twice.
Session::open attaches to a daemon when one is reachable and logs in
otherwise, so every existing command works unchanged against either. A local
socket is found automatically, which is what makes an agent's invocation need
no flags at all; --no-daemon opts out.
Commands now read identity, shares, server, and download directory from the
session rather than from this run's config: routed to a daemon those differ,
and the session is the one actually on the network. Without that, whoami
named the wrong server and shares reindex would have imposed the client
machine's folders on the daemon.
The daemon drains the library's destructive take_* accessors once and fans
events out per connection, which is what lets more than one client share a
session. A Unix socket at 0600 is the local transport and its permissions
are the authentication; TCP is opt-in and needs the generated token. Rooms,
transfers, and the conversation are daemon-owned and restored on restart.
Methods and events are enums built by one macro, so every consumer matches
exhaustively: adding a member is a compile error until the dispatcher, the
published schema, and the remote client all handle it. docs/openrpc.json is
derived from the DTOs rather than hand-written, and a test fails the build
when the checked-in copy drifts.
Commands and the TUI now hold an Arc<dyn SessionApi> instead of an
Arc<Client>. The trait is implemented on Client itself, so the local path
is the same client behind one vtable and no wrapper type exists to keep in
sync. Daemon mode plugs a remote implementation into the same handle.
`serve` samples `uploads()` every 500ms and emits what changed, so an upload
state shorter than that window never reached the log. Nothing surfaced this
while every upload sat on its slot for a 500ms linger, which was exactly one
poll; with the linger gone a peer can queue and be served between two samples,
and "it had to wait" — the one thing the queue exists to record — silently
disappeared.
The pump now keeps whatever it left waiting, and `serve` drains that alongside
the snapshot, so the fact survives regardless of when the next sample lands.
The list is deduplicated per peer and file and capped, so a caller that never
drains cannot grow it without bound.
This is what made cli_e2e::serve_reports_a_queued_upload_once_the_slots_are_full
fail on CI while passing locally: the assertion was real, and it was timing that
decided whether the client could answer it.
The slot cap merged from master defaults to 2 uploads, and the CLI capped
downloads at 5. Those are the numbers you pick when your own bandwidth is the
scarce resource, but a Soulseek transfer is paced by the peer on the other end
— usually a few hundred KiB/s — so what fills a modern link is many transfers
at once, not faster ones. Upload slots go to 10, downloads to 20. The queue
itself stays: privileged users being able to jump it is a condition of being a
tolerated third-party client.
serve_file also slept half a second after the last byte so the downloader could
drain before the socket closed. With a cap that is time no other peer can use,
and 64 uploads over 10 slots paid it six times over. A half-close says "that is
the whole file" and TCP still delivers everything written before the FIN.
Measured with 64 waiting peers: 2 slots 24.2s, 8 slots 6.2s, 32 slots 3.1s; at
10 slots the linger alone was 5.4s to 3.05s.
Three findings were never real. parse_request is 34 lines, not 426: the
`'{'` char literal in it reads as an opening brace to the line counter, which
then runs to EOF — 610 + 426 lands on line 1035 of a 1034-line file. The
duplicate-block hits on home.tsx and install.tsx match the punctuation between
entries of a data table, not logic; the records repeat the field names their
type demands.
Each is silenced at its own site with the reason, so the rule keeps working
everywhere else. Nothing is switched off in config.yml — a `rules:` entry there
would blind the rule across the project. `ci.failBelow: 100` makes the baseline
enforceable: the check exits 1 on a single new warning.
Running several one-shot commands at once returned "no results" for queries that
had plenty. Four defects, each of which made a dead session look like an empty
network:
- The peer listener bound its port inside a detached thread and panicked on
EADDRINUSE, unwinding that thread alone. The process carried on with no
listener, so no peer could deliver a search response. `Listen::bind` now takes
the socket before login and falls back to an ephemeral port when the
configured one is held.
- SetWaitPort advertised the configured port whether or not it was bound, so
peers dialled a sibling process that discarded responses to tickets it never
issued. The port the listener really holds is what gets advertised, mapped
through UPnP, and reported by `whoami` and `serve`.
- A zero-byte read is EOF on a TCP socket, but MessageReader reported it as
success, so a server that closed cleanly left the actor polling a dead socket.
It is now an error, and Relogged (code 41) has a handler. The peer actor
classifies that error: a control connection that was established and then
closed is a clean disconnect, because peers drop idle sockets while the bytes
move over their own connection, and reporting an error there fails every
download queued with that peer including the one streaming. A connection that
never established keeps the error path, which is what the server-brokered
fallback reads to know a peer is unreachable.
- A session the server cut off - usually because another process logged in under
the same name - exited 4, documented as "it worked and found nothing". Callers
concluded the file was unavailable. Sessions now record why they ended and
those verdicts become exit 7.
The server allows one session per account, so concurrency still needs a distinct
--username per run; SKILL.md documents that. The listener port needs no such
care any more.
Six e2e tests against soulfind cover it, each of which fails without the fix: a
taken listen port, a displaced session, three clients searching at once under
one configured port, the two CLI equivalents, and a peer that hangs up its
control connection while a transfer is still to come.
BREAKING CHANGE: `Listen::start` is replaced by `Listen::bind` and
`Listen::serve`, so the caller owns the socket and can read the port it actually
bound. `Client::listen_port` and `Client::session_loss` are new.
Closes the last two gaps in Soulseek's list of features an alternative client
must implement to be tolerated, and adds the result filters Nicotine+ has had
all along.
Wishlist: server code 103 is sent and code 104's announced interval is kept
rather than logged and discarded, so the re-search timer runs off the schedule
the server set. `wish add|remove|list|run` plus `serve --wishlist`; wishes
persist in config.toml as a TOML array so a comma inside a query survives.
`wish run` prints search-shaped records, so it pipes into `download --stdin`.
Privilege recognition: the code-69 user list is parsed instead of counted and
dropped, and code 92 answers `whoami` as `privilege_seconds`. Uploads now go
through a queue with a slot cap (`--upload-slots`, default 2) that serves
privileged users first — the cap is what makes the ordering mean anything, so
they are one change. `PlaceInQueueRequest` (peer code 51) is answered, where it
previously had no handler at all, and a departed peer's slot is released so a
stalled peer cannot wedge uploads shut.
Search filters: `--exclude`, `--extension`, `--min-size`, `--max-size` beside
the existing `--min-bitrate` and `--free-slots`, in one flattened arg group
shared by `search`, `get` and `wish run`.
Also serialises the library e2e suite behind the same gate `cli_e2e` already
uses: the new queue tests hold peer sockets open on purpose, which starved
concurrently-spawned soulfinds and failed unrelated download tests with
`login: Timeout`.
BREAKING CHANGE: `UploadStatus` gains a `Queued(u32)` variant, so an exhaustive
match on it no longer compiles, and `cli::Commands` gains `Wish`.
* test: prove the documented examples against the binary
The README, the website and the shipped SKILL.md all promise specific
records, keys and exit codes, and nothing was checking that the binary
still agrees. Cover the claims that were unverified: the eleven
credential-free commands, the one-day bound on every wait, the config
directory relocation, `serve` refusing to run without a listener, the
four action commands that answer with an exit code alone, the search
filters, `--password-stdin`, `room listen`'s join records, and every
`--json` field the docs name for search, browse, whoami, user, shares,
room, download, serve and portmap.
The library README's examples now compile as doctests: lib.rs includes
it as the crate docs and the fences are `no_run`, so a signature change
cannot leave the advertised usage stale.
Two things were wrong. SKILL.md listed `queued` as a `serve` upload
status, which `upload_record` never emits — an agent branching on it
would wait for a state that never arrives. And the library README still
pointed at the 7.x lib.
* test: cover the completions commands the docs now advertise
Downloads buffered the whole file in memory and wrote it out only on
success, so an interrupted transfer left nothing behind and always
started over. The START_DOWNLOAD offset was hardcoded to zero on the
download side and discarded on the upload side, so neither half of the
protocol's resume support was wired up.
Bytes now stream into `<file>.part` as they arrive and it is renamed
only once the file is whole. A new attempt sends the partial file's
length as the START_DOWNLOAD offset, and `serve_file` seeks to the
offset a peer asks for rather than replaying from the start. A `.part`
at or past the expected size is treated as stale and restarted.
Partial files are kept out of the share index, since the download
directory is shared by default.
`completions install` generates the script from the same clap definition
the binary parses with, so it cannot drift from `--help`, and writes it
where each shell already looks. fish scans its own completions directory
unprompted; bash and zsh get one marked `source` line appended to their rc
file, because nothing puts a per-user directory on their search path by
default. `completions uninstall` takes back exactly those lines and leaves
the rest of the file alone.
The Login message hardcoded SoulseekQt's major version 157, so the
server could not tell soulseek-rs apart from the official client. The
protocol document now reserves a unique major version per project, so
claim 176 and send it via a new ClientVersion on ClientSettings.
Clients and bots built on the library keep major 176 and pick their own
minor version (default 1); the reference CLI/TUI logs in with minor 100.
Refs #12
The cursor row set a foreground, and ratatui only skips a cell's colour
when that field is None — so selecting a row repainted its gold sizes,
blue usernames and status glyphs into one flat cream. Table cursors now
tint the background only, over a warm oxide wash instead of neutral.
Browse files, their sizes and the settings popup rendered in the raw
terminal foreground; they take the palette now, and free slots read
green when a peer has one.
`render_shortcuts` was 144 lines of "which shortcuts apply" wrapped around 40
lines of drawing; the decision moves into a `shortcuts` method beside the
`rooms_shortcuts` helper that already worked this way. Three identically
shaped comments over the popup guards collapse into one that carries the part
worth knowing — an open popup owns the keyboard, so the order matters. The
state tests trade bare `unwrap()` for `expect()` so a failure names the step.
A `cargo install` leaves a binary and nothing else, so there is no file on
disk for a coding agent to read. `skills install` writes an embedded SKILL.md
into every agent found on the machine — Claude Code and opencode today, any
other via --dir — and installing over an older copy is what updating means.
The skill carries what --help cannot: the JSON keys of every record, what each
exit code means, and the rule that an agent always passes --json. A unit test
walks the clap command tree and holds SKILL.md to a documented entry for every
command, so a new subcommand fails the build until it is written up.
An agent driving the binary could search, fetch and chat, but it could not be
a peer: nothing kept a session online to serve files, nothing reported an
upload, shares could only be set at startup, and there was no way to ask
whether a peer was worth queueing to or to check credentials without doing
something else.
New commands:
serve [--follow] stay online sharing; one record per upload
whoami credentials, connection and what we offer
user <NAME> a peer's presence and share counts
room users <ROOM> the member roster
shares list|add|remove|status|reindex
config path|list|get|set
`config` and `shares list|add|remove` need no account, so they run before the
credential check — asking a script to log in for a `config get` would be a
poor trade. `serve` is the only place uploads are visible: they live in the
process serving them, so a one-shot `uploads list` could print nothing but an
empty list.
Library additions, all request/poll pairs matching the existing conventions:
- Client::room_members, kept current from the join and leave events
- Client::request_user_info / user_info, backed by new handlers for
GetUserStatus (7) and GetUserStats (36) — codes the dispatcher previously
logged as unhandled. The wire format is taken from soulfind's own source
and pinned by an e2e test that asserts a peer's real share count.
- UserInfo carries presence and stats as Options, so a reply that never
arrived cannot be read as "offline" or "shares nothing"
- request_user_info clears the previous answer, so the documented poll loop
observes this request rather than the last one
- ServerMessage and ClientOperation are now #[non_exhaustive]: every protocol
message the client learns adds a variant, and that should not break callers
- the crate root and prelude re-export every type a Client method returns
Also pins the output contract with tests: under --json, stdout carries only
newline-delimited JSON across fifteen command shapes while progress still
goes to stderr, and a failing command writes nothing to stdout at all.
The one-shot half of the binary could not finish its headline task without a
human at the keyboard: `search` opened a full-screen picker, panicked with
exit 101 when there was no terminal, and no command could fetch a known file
at all. Everything exited 0 whatever happened, wrote emoji prose to stdout,
and ignored --download-dir, SOULSEEK_DOWNLOAD_DIR and config.toml.
The commands are now built for scripts and agents:
search download get browse room list|say|listen message send|read
stdout carries records only (tab-separated, or NDJSON under --json), stderr
carries progress, and the exit code carries the verdict: 2 usage, 3 connect,
4 no results, 5 timed out, 6 transfer failed. `get` does search, rank and
fetch in one step, and `search --json | jq ... | download --stdin` composes.
Global flags parse in any position and every one has a SOULSEEK_ variable and
a config.toml key resolved through a single precedence chain.
Defects found and fixed along the way, each covered by a test:
- a download with an unknown size was finalized against zero bytes and saved
as an empty file; the size now comes from the peer's share listing
- transfers are keyed by a hash of the remote path, so the same path from two
peers shadowed itself and two files sharing a basename overwrote each
other; both collisions are now dropped with a warning
- `message send` and `room say` slept 500ms and claimed success; they now
wait for a server reply queued behind the message before reporting
- Client::login waited on an unbounded channel and never returned when the
server went quiet
Deleted the TUI picker the old `search` drove (download_selector,
MultiDownloadProgress, SearchConfig): 1,573 lines out, 926 in. The
interactive TUI is otherwise untouched.
BREAKING CHANGE: `rooms` and `chat` become `room list|say|listen` and
`message send|read`; `--disable-listener` becomes `--no-listener`; the
unprefixed DISABLE_LISTENER, LISTENER_PORT, LOG_FILE and
MAX_CONCURRENT_DOWNLOADS variables are now SOULSEEK_-prefixed; `search` no
longer opens a picker or downloads anything.
Peer layer:
- Stream peer messages with a partial-write queue instead of write_all, so a
large SharedFileListResponse over the non-blocking socket is no longer
truncated (third-party clients like SoulseekQt can now browse our shares).
- Bound the peer-init handshake with a read timeout and handle each accepted
connection on its own thread, so one stalled peer can't wedge the listener.
- e2e: third-party direct/brokered/large-share browse + stalled-peer coverage.
TUI:
- Uploads share the downloads table columns (real Speed) and the info pane now
renders details for a selected upload.
- Download progress bar counts only in-progress transfers and hides when idle.
- Always-visible unread badge (private messages + rooms) in the shortcuts bar,
self-drawn blink so it works under tmux.
- Persist the unread private-message count across restarts.
Replace the ANSI Light* palette with the design system's tokens
(tokens/colors.css): oxide accent, phosphor focus, tape/signal/alarm
semantics, paper text on dust chrome. Only foregrounds and the row
highlight are painted so the user's terminal background shows through.
Route every bordered pane through a single pane_block() helper carrying
one column of horizontal padding, and give legends a consistent
[n] Title form with the tag in signal. The command bar now derives its
cursor position from the block's inner rect instead of a hardcoded
border offset.
Also collapses the download-status glyph match that was duplicated
across three render sites into download_status_glyph().
Press 'o' for a settings popup: edit the download folder, add/remove
share paths (applied live via a rescan and persisted to config.toml),
and force a re-index with 'r' for files changed on disk. The state
machine is unit-tested; feedback shows the shared file/folder counts.
The downloads pane is now Downloads/Uploads: uploads being served are
listed after downloads with an up-arrow marker, live progress, and
status; 'x' cancels an in-progress upload. Client::shared_counts exposes
the index size for the status line.