Commit Graph
6 Commits
Author SHA1 Message Date
Michel de Graaf 3c5a3dd648 test(portmap): find the unmap among resent maps
The NAT-PMP client resends a map the gateway has not answered within
250 ms, and the mock gateway records every request, so a slow mock put a
resent map where the test expected the unmap and failed it. The test now
looks for the lifetime-0 request among whatever arrives after the drop.

On Windows the mock's answer to that resend meets an already-closed
socket, and the next receive reports the bounce as a connection reset,
which ended the mock's loop before the unmap. It now carries on past it.
2026-09-10 14:36:32 +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 be6581ee6c feat(cli)!: replace the one-shot commands with a scriptable surface
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.
2026-07-25 01:30:48 +02:00
michel 2d5fc26db7 test(net): e2e-test the UPnP path + add a portmap diagnostic
Address the gap that the port mapper's UPnP path and gateway discovery
had no automated coverage (only NAT-PMP did):

- UPnP e2e: split map_via_gateway out of discovery and drive it through
  igd-next against an in-process mock IGD (a std HTTP server returning a
  canned SOAP envelope). Verifies the real add_port SOAP round-trip and
  that we request the exact external/internal port + LAN client.
- default_gateway parsing is now pure, testable functions with unit
  tests for the macOS and Linux formats (including the 0.0.0.0 skip).
- New 'portmap' CLI subcommand (needs no login): synchronously probes
  UPnP/NAT-PMP against the real router and reports whether the port was
  opened + the external address, then removes the test mapping — so a
  user can verify reachability on their own network.

9 port_mapping tests now cover the NAT-PMP protocol, the UPnP SOAP
round-trip, gateway parsing, and the map/renew/cleanup lifecycle.
2026-07-19 21:46:46 +02:00
michel 84c571767a fix(net): harden port mapper from adversarial review
- Shutdown no longer freezes: Drop signalled stop but then blocked on a
  thread join, and nothing observed stop during the blocking UPnP/NAT-PMP
  discovery (up to ~9s). Drop now waits on a done-channel with a 1s cap
  and detaches otherwise, so quitting is always prompt; the finite lease
  cleans up if we detach mid-discovery. Regression-tested against an
  unresponsive gateway.
- Linux default_gateway() no longer returns 0.0.0.0 for a gateway-less
  (directly-connected) default route — it keeps scanning, matching the
  macOS branch, instead of firing pointless NAT-PMP at the unspecified
  address.
- UPnP now retries AddPortMapping with a permanent (lease 0) mapping when
  the router rejects a timed lease (IGDs that only support permanent
  leases), and renews/removes with the accepted lease.
2026-07-19 21:26:51 +02:00
michel 5ba2f9e181 feat(net): auto-map the listen port via UPnP-IGD and NAT-PMP
Behind a home router the client is unreachable for incoming peer
connections, so browsing/downloading firewalled peers (and the
server-brokered connect-back) times out. Add a best-effort port mapper
to the binary crate that, when listening is enabled, makes us reachable:

- NAT-PMP (RFC 6886) hand-rolled on std::net::UdpSocket, zero deps, with
  an injectable gateway address so it is fully testable.
- UPnP-IGD via the blocking igd-next crate as the primary path.
- A background PortMapper that discovers off the main thread (never
  blocks startup), renews at half the lease, and removes the mapping on
  drop. Any failure is non-fatal and logged.
- Maps the EXACT listen port only (we advertise it verbatim via
  SetWaitPort, so a different external port would be unreachable) and
  warns the user to forward manually otherwise.

Wired into the TUI, search, and CLI browse (the P2P modes); chat/rooms/
messaging are server-mediated and skip it. e2e-tested against an
in-process mock NAT-PMP gateway: the map request bytes, response parsing,
result-code/timeout handling, and the full spawn -> map -> drop -> unmap
service lifecycle.
2026-07-19 21:10:15 +02:00