The serving process built its arguments by hand, so in daemon mode
--shared-dir stayed on the attached run, where shares belong to the
daemon and the flag is ignored. The daemon fell back to sharing its
default folder - which on a developer machine contains the probe files
earlier buggy runs deposited, so the test only passed by finding
served.bin in leftovers. args_with routes the share to the process
whose session does the sharing in either mode.
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.
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.
The OpenRPC document is the machine-readable contract; daemon-protocol.md
covers what a schema cannot say — framing, the auth handshake, and that
events are pushed, unreplayable, and will disconnect a client that stops
reading.
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
`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
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.