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.
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
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 brokered third-party browse e2e failed deterministically on CI. The test
bound its listener to 127.0.0.1 only, but the server brokers back the address
it observed for that client - the host's routable IP (172.17.0.2 under Docker,
10.x on a CI runner). The sharer's dial was refused, it fell back to
re-brokering, and the accept never arrived. The sibling third-party browse
tests already bind 0.0.0.0.
disconnect_with_error logged only "disconnect" and dropped the io::Error on the
floor, which is what made this invisible in the logs; it now logs the cause.
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.
serve_file streams in chunks, updating a shared byte counter and
aborting with Interrupted when a cancel flag is set. Each served upload
is registered (peer, virtual path, size, live progress, status) and
exposed via Client::uploads(); Client::cancel_upload flags a matching
in-progress transfer. e2e: the download test now also asserts the
uploader recorded a completed upload with the right peer and byte
count.
Client::set_shared_directories rescans the given roots into a fresh
in-memory index, swaps it for peers, and re-announces the folder/file
counts to the server. The current directory list moves into the client
context so the running set is queryable. e2e-verified: a browser sees a
directory that was added after login.
Shares::scan_many merges several roots into one index, suffixing
duplicate root names so virtual paths cannot collide, and skipping
unreadable roots instead of failing. ClientSettings now takes
shared_directories: Vec<String>; config gains a shared_dirs list
(combined with the single shared_dir and --shared-dir, deduped), with an
explicitly empty value still disabling sharing and the
share-what-you-download default unchanged.
Adds a lib target (soulseek_rs_tui) exposing cli/models/persist so
integration tests can drive the persistence pipeline: state and config
survive a simulated restart through the real files, with the incomplete
download flagged for auto-resume. The lib e2e suite gains a
register-then-relogin test against soulfind proving the stored-credential
flow's server assumptions. cargo clippy --all-targets and cargo fmt are
clean.
Add an e2e that forces the server-brokered (firewalled) browse path —
the path that matters on the real network. The sharer does NOT listen,
so the browser's direct dial fails and the server must broker the peer
connecting back to the browser's listener. This exercises
PeerConnectFailed -> ConnectToPeer -> inbound PierceFirewall ->
PeerConnected flush, which the localhost direct-dial test never hits.
Also soften user-facing download failure reasons from protocol jargon
("Peer …") to plain language ("The user went offline", etc.).
Implement the Soulseek chat-room server messages against soulfind's
wire format:
- MessageFactory builders: room-list request (64), join (14),
leave (15), say (13).
- Handlers parsing the server side: RoomList (64) names+counts,
JoinRoom (14) membership, SayChatroom (13), UserJoinedRoom (16),
UserLeftRoom (17), LeaveRoom (15).
- Surface everything to the client as a drained RoomEvent stream plus
a room-list snapshot; new Client API: request_room_list, join_room,
leave_room, say_in_room, room_list, take_room_events.
Unit tests cover each builder and handler; server-gated e2e tests
exercise message delivery and room listing against soulfind.
Carry a human-readable reason on DownloadStatus::Failed so it flows
through the existing status channel to the TUI. The download Info pane
now shows an Error line for a failed download (connect failure, peer
disconnect, truncated transfer, peer-reported upload failure, or the
underlying transfer error). Failures without a known reason render as
before.
Add Client::browse_user (sends GetShareFileList, code 4, connecting first if
needed via the same queue-and-flush path as search replies) and
take_browse_result. A new SharedFileListResponseHandler parses the peer's
code-5 reply into SharedDirectory entries, forwarded as
ClientOperation::BrowseResult and stored per user. Verified end to end: a
second real client browses a sharer and receives its file listing.
End-to-end proof of the whole sharing stack with no mock peer: a sharer indexes
a temp file, the leecher's search is distributed by soulfind, the sharer replies
with a FileSearchResponse, and the leecher downloads the real bytes that the
sharer serves over a peer-to-peer transfer. Asserts the search result and that
the downloaded bytes match the source. Server-gated (SOULSEEK_E2E_REQUIRED).
Implement the FIREWALLED connection mode: when a direct outbound connection to
a peer fails (peer unreachable), fall back to asking the server to broker it.
- Add MessageFactory::build_connect_to_peer (server code 18).
- A pre-established outbound failure now emits ClientOperation::PeerConnectFailed
instead of a normal disconnect; the client registers a correlation token and
sends ConnectToPeer so the peer connects back.
- The listener accepts the peer's PierceFirewall (peer code 0) connect-back,
matches the pending token, registers it as the P control connection, and
fires PeerConnected to flush the queued download.
Fix a latent aliasing bug this surfaced: a peer actor could emit two terminal
notifications (a connect-failure, then a disconnect when later replaced and
stopped), and the second removed the *newer* actor registered under the same
username, killing the brokered connection mid-transfer. An actor now reports a
terminal outcome at most once (disconnect_reported guard).
Verified end to end against soulfind: the mock advertises an unlistened port so
the direct connect is refused, the server brokers the connection, the mock
pierces back, and the file transfers — bytes match. A companion test
(soulfind_brokers_connect_to_peer_between_users) pins the server behaviour the
mode relies on.
Implement the DIRECT connection-initiation mode so a download can target a
peer the client is not already connected to:
- Fix the outbound peer handshake. on_connection_established sent
build_watch_user() (a server WatchUser message, wrongly framed with
get_data()) for outbound connections; it now sends a proper length-prefixed
PeerInit (peer code 1) with our own username. own_username is plumbed in
via PeerRegistry -> PeerActor; inbound peers (outbound=false) are unchanged.
- download() now auto-connects: if there is no control connection to the peer
it sends GetPeerAddress and defers the QueueUpload until the outbound stream
handshakes, signalled by a new ClientOperation::PeerConnected. A never-
connected client still resolves the download to Failed (unit test).
- Add Client::connect_peer(username) to open/pre-warm a control connection.
Verified end to end against soulfind: a_file_downloads_from_a_peer_via_direct_connection
logs a mock peer into the server, advertises its port via SetWaitPort, and the
client resolves the address, dials it, and downloads the file — bytes match.
The F-serve leg is shared with the passive-download mock.
A peer actor multiplexes socket reads with its mailbox on one thread: tick()
reads the socket while outgoing messages (e.g. a queued QueueUpload that starts
a download) are delivered through the mailbox between ticks. Inbound
connections accepted by the listener were left in blocking mode, so once such a
peer went idle tick() parked inside read() and never drained the mailbox — a
download queued against that peer never sent its QueueUpload and hung. Outbound
connections already set non-blocking; do the same for inbound P connections.
Covered by a new server-optional e2e test that downloads a file end to end from
an in-process mock peer over real P (control) and F (transfer) connections,
asserting the bytes written match the source. The mock speaks the library's own
public Message wire format, so no dependencies are added.
Wire private messaging through the client:
- MessageFactory::build_message_user (code 22) and build_message_acked
(code 23);
- the incoming MessageUser handler now acknowledges freshly delivered
messages and forwards them to the client instead of only logging them;
- ClientContext buffers received UserMessages;
- Client::send_private_message and Client::take_private_messages expose the
feature, and UserMessage (with accessors) is re-exported from the crate.
Verified end to end against soulfind: a_private_message_is_delivered_between_users
logs in two clients and asserts one receives the other's message.
Cover implemented, server-verifiable paths that were previously untested:
- assert an issued search is registered in client state, not just that
results are empty;
- a_search_is_forwarded_to_a_connected_peer: a second client exercises the
incoming FileSearch handler (the server distributes searches to it), and we
prove that session survives by round-tripping its own search afterwards;
- login_succeeds_with_listener_enabled: drives the post-login handshake with
the peer listener on (SetWaitPort), which every other e2e test skips.
Peer downloads / file sharing remain out of scope here: they need a second
peer that actually serves files, which the client does not implement yet.
Extend the existing CI workflow with an ubuntu-only "End-to-end (soulfind)"
job that builds soulfind from source (LDC + `dub build :server`), points
SOULFIND_BIN at it, and runs the e2e suite with SOULSEEK_E2E_REQUIRED=1.
Also widen the clippy job to the whole workspace.
The e2e harness gains SOULSEEK_E2E_REQUIRED: when set, a missing/unstartable
server is a hard failure instead of a silent skip, so CI genuinely exercises
the client end-to-end. Add e2e coverage for two existing behaviours a single
server can verify: wrong-password rejection and concurrent logins.
Add a --shared-dir / SOULSEEK_SHARED_DIR option and a SOULSEEK_DOWNLOAD_DIR
env for the download directory. The shared directory is resolved and
validated up front (tilde-expanded, must exist and be a directory); a
misconfigured value is a warning, not a fatal error.
The library gains an optional shared_directory on ClientSettings/Client
with a getter, keeping soulseek-rs-lib dependency-free. Resolution lives
in a new soulseek-rs/src/directories.rs with unit tests.
Adds soulseek-rs-lib/tests/e2e.rs, which drives the client's public API
(connect, login, search) against a real Soulseek server. The harness is
server-optional so cargo test stays green everywhere: it uses
SOULSEEK_TEST_SERVER if set, otherwise spawns a soulfind binary (SOULFIND_BIN
or a soulfind/bin/soulfind checkout in a parent dir) on an ephemeral port with
a throwaway database, and skips with a notice when no server is available.
Uses only std and the library's public API — no new dependencies. Documents how
to run it (including the macOS soulfind SQLite workaround) in the README.