Commit Graph
24 Commits
Author SHA1 Message Date
Michel de Graaf 77f36b1e98 fix(actor): leave Disconnected state behind after every disconnect
A failed outbound dial left PeerActor in Connecting, so
check_connection_status re-fired the 20-second timeout error on every
100ms tick until the actor was stopped. ServerActor had the same bug:
its disconnect paths only cleared state when already Connected.

Reset connection_state to Disconnected in all four disconnect paths.

Fixes #31
2026-08-13 12:21:14 +02:00
Michel de Graaf 0bbb559f68 fix: survive busy sessions without exhausting file descriptors
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
2026-08-09 22:34:36 +02:00
Michel de Graaf f1b667f7f9 refactor: cut what a whole-repo audit proved dead, padded, or hand-rolled
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.
2026-07-28 12:52:18 +02:00
Michel de Graaf 5b4ef30f08 feat: lift the client's concurrency ceiling from ~15 peers to 1024
The client had never been driven at scale. A stress harness that mixes
searches, downloads, uploads and browses against a local soulfind found four
ceilings, three of which lose work silently rather than merely being slow.

Peers were capped at one per core. ActorSystem ran each actor's whole lifetime
as one job on a fixed-size ThreadPool, and actor loops block until stopped, so
the pool acted as a semaphore: past the cap an actor never started, never
connected and never errored, and uploads stopped working entirely. The pool had
exactly one consumer and every job it received was non-terminating, so it is
gone; actors get their own thread.

TransferResponse raced the bookkeeping it depends on. It was sent straight back
to the peer while UpdateDownloadTokens was still queued for the client loop —
but the response is what invites the peer's file connection, and that connection
is matched by token. Under load the client loop lost, the file connection
matched nothing, and the download sat on Queued forever. The response now goes
out after the token is recorded.

Download tokens were md5(filename) truncated to 20 bits, and the store deletes
every entry matching a token, so two downloads sharing one destroyed each other
— which fetching the same filename from two peers does every time. They now
come from a counter.

Port 0 from GetPeerAddress means the server does not know where a user listens.
It was cached and dialled, and the upload waiting on it was dropped. It is no
longer cached or served against; the connect attempt stays, because its failure
is what makes an unreachable peer fall back to server brokering.

Mixed profile at 1024 peers: 512/512 downloads, 256/256 uploads, 400-850 MiB/s
in ~7s, against 71/96 downloads at 7.9 MiB/s and a 60s timeout before.
2026-07-26 17:01:32 +02:00
Michel de Graaf e8a48c6b06 fix!: make concurrent invocations work instead of silently seeing nothing (#22)
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.
2026-07-26 14:52:31 +02:00
Michel de Graaf 9b87bd6a0f feat!: wishlist, upload-queue privilege recognition, and search filters (#20)
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`.
2026-07-26 14:20:07 +02:00
Michel de Graaf 54feb3bc5a fix(peer): log disconnect cause; bind e2e browse listener on all interfaces
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.
2026-07-24 22:21:06 +02:00
Michel de Graaf be5dc6c064 fix(peer): serve full browse listings + harden the listener; TUI transfer/UX work
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.
2026-07-24 21:18:20 +02:00
Michel de Graaf 2918b0ce75 refactor: resolve all lint findings
- remove 34 trivial restating comments (auto-fix)
- replace all 20 production .unwrap() calls with graceful handling:
  ?-propagation via LockPoisoned, unwrap_or_default on infallible
  conversions, let _ = on actor-channel sends, and a downloads_sender()
  helper in the TUI that also dedupes three channel-init blocks
- collapse nested if into match guard (clippy::collapsible_match)
- split client.rs (2275 lines) into a client/ module: connection,
  search, downloads, uploads, rooms, operations
- split ui/main_tui.rs (2027 lines) into a main_tui/ module: render,
  input, browse, rooms, downloads, search
- extract oversized functions: peer_actor/server_actor handle_message
  into per-message helpers, main() into init_logging/run_default_tui,
  download_selector and main_tui render into per-section helpers

Mechanical only, zero behavior change. Verified: clippy clean
(pedantic+nursery), fmt clean, 143 unit tests + 16 e2e tests against
soulfind pass, lint clean.
2026-07-20 10:53:57 +02:00
michel f3218475dc feat: browse another user's shared files
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.
2026-07-19 18:56:19 +02:00
michel f3b24f34c0 feat(shares): reply to browse requests with a SharedFileListResponse (code 5)
GetShareFileList (peer code 4) previously replied with a malformed message (a
server SharedFolders stats message over a peer connection). It now emits
ShareListRequested, and the client builds a real SharedFileListResponse
(code 5) from its shares grouped by directory. Adds a std-only code-5
encoder/decoder (build_shared_file_list / parse_shared_file_list) with a
roundtrip test, and Shares::directories() to group files. The decoder is the
foundation for the Browse feature.
2026-07-19 18:51:55 +02:00
michel 393f69e478 feat(shares): serve queued files to peers over F connections
Implement the upload path so a peer can download our shared files:
- fix the mis-coded PlaceInQueueResponse (43 -> 44) and add a real QueueUpload
  handler (code 43) -> PeerMessage::IncomingQueueUpload;
- the peer actor forwards it as ClientOperation::QueueUpload; the client looks
  the file up in its shares, mints an upload token (high half of the token
  space, disjoint from md5 download tokens), and offers it via ServeUpload ->
  build_upload_transfer_request (peer code 40, direction=upload);
- a TransferResponse for one of our serving tokens is handled by a surgical
  early-return that emits StartUpload, leaving the download path untouched;
- StartUpload resolves the downloader's address (GetPeerAddress, cached) and
  streams the file via a new peer::upload_peer::serve_file over an F connection.

Tested: build_upload_transfer_request decodes via the production Transfer
parser; serve_file streams a file correctly over a loopback F connection.
2026-07-19 18:46:06 +02:00
michel 3908dceb2f fix: identity-aware peer eviction so a replaced actor can't evict its successor
When a healthy peer actor was replaced under the same username (both sides
connecting at once, or two overlapping downloads to the same not-yet-connected
peer), its graceful shutdown still emitted PeerDisconnected, and the client
evicted by username — removing and stopping the NEWER actor now occupying the
slot, tearing down the live connection and stalling that peer's downloads.

Give each peer actor a unique id (assigned by the registry) and echo it in
PeerDisconnected/PeerConnectFailed. The client now evicts via
remove_peer_if(username, id), which removes the entry only when the reporting
actor still owns the slot, so a stale actor's shutdown is a no-op. The brokered
timeout no longer removes an actor by username either, avoiding the same
aliasing. Add a registry unit test for the identity guard.
2026-07-19 17:54:50 +02:00
michel a186c9d07d fix: pierce the firewall on server-brokered reverse connects
When the server asks us to connect to a peer (a ConnectToPeer we receive,
carrying the server's correlation token), we dial that peer and must announce
ourselves with a PierceFirewall quoting the token — not a PeerInit — so the
remote peer can correlate its own indirect request (search-result / upload
delivery). The outbound handshake now sends PierceFirewall when the peer
carries a server token and PeerInit only for direct GetPeerAddress dials
(token None). Direct and firewalled e2e paths are unchanged.
2026-07-19 17:48:50 +02:00
michel b82b03a143 feat: firewalled peer connections via server-brokered connect
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.
2026-07-19 17:26:52 +02:00
michel e4093b1ea8 feat: direct peer connections — download() connects to unconnected peers
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.
2026-07-19 17:12:17 +02:00
michel e72c8a5c07 style: crank clippy to pedantic+nursery and fix all lints
Enable the full clippy all + pedantic + nursery groups as workspace warnings,
with a small documented allow-list for the pedantic/nursery lints this project
intentionally does not follow (rustdoc-style requirements, deliberate numeric
casts in the protocol/UI layers, and a few noisy nursery lints). Also enable
the rust unused_lifetimes and unsafe_op_in_unsafe_fn lints.

Fixed every resulting warning (527 -> 0) mechanically per clippy's suggestions:
inline format args, const fns, item ordering, let-else, redundant clones,
map_or, cast_lossless, Eq derives, associated-fn conversions for methods that
never used self, and removing unnecessary Result wrappers whose call sites could
never propagate an error. No runtime behavior changed; cargo fmt applied.

cargo clippy --workspace --all-targets: 0 warnings. All tests pass.
2026-07-19 00:09:17 +02:00
michel d06aa2d3de style: apply cargo fmt 2026-04-26 17:28:43 +02:00
michel 574833bf61 feat: add download info pane to TUI
Splits the downloads area 60/40 with a new info pane that adapts to the
selected download's status: header (filename, user, size, status, save
path, bitrate, length) plus queued-specific fields (queue position, peer
slots, peer upload speed) or live progress/speed/ETA when transferring.

Plumbs queue position via a new ClientOperation::PlaceInQueueUpdate so
PlaceInQueueResponse messages from peers reach DownloadStore, and threads
search-time metadata (bitrate, length, peer slots/speed) onto Download
through a new download_with_metadata entry point.
2026-04-26 17:19:26 +02:00
michel 2722552e35 refactor: replace lock unwraps with poison-aware helpers
Add SoulseekRs::LockPoisoned variant and a small RwLockExt/MutexExt
helper module exposing read_safe/write_safe/lock_safe that map
PoisonError to that variant. Walk every non-test lock acquisition
across client, peer/server actors, peer registry, listener and the
thread pool: propagate via ? where the function returns Result, log
and degrade to a sensible default (return early, false, None, empty)
where it does not. Client::connect now returns Result so the CLI can
surface the failure instead of panicking.
2026-04-26 12:32:46 +02:00
Michel de Graaf dfe6f532fa chore: formatting 2026-01-04 20:37:03 +01:00
mayor 16c8a4bde9 chore: update to Rust 2024 edition 2026-01-04 20:08:16 +01:00
Michel de Graaf cafa40a09d chore: refactor to support multi search 2025-11-13 10:28:16 +01:00
Michel de Graaf 7bf2748e1f feat: seperate into lib and cli packages 2025-11-11 21:55:53 +01:00