diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 005f468..4a5c9ee 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -24,6 +24,7 @@ jobs: timeout-minutes: 5 outputs: sha: ${{ steps.commit.outputs.sha }} + version: ${{ steps.commit.outputs.version }} steps: # workflow_dispatch has a branch selector, but a Nightly run always means # develop. Resolve it once so every target builds the identical commit @@ -36,13 +37,22 @@ jobs: - name: Record source commit id: commit shell: bash - run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + run: | + set -euo pipefail + sha=$(git rev-parse HEAD) + base=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2) + stamp=$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y%m%d%H%M) + [[ "$base" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$stamp" =~ ^[0-9]{12}$ ]] + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "version=$base+git$stamp.${sha:0:7}" >> "$GITHUB_OUTPUT" binaries: name: ${{ matrix.target }} needs: source runs-on: ${{ matrix.os }} timeout-minutes: 45 + env: + SOULSEEK_RS_VERSION: ${{ needs.source.outputs.version }} strategy: fail-fast: false matrix: @@ -95,6 +105,12 @@ jobs: dry-run: true dry-run-intended: true + - name: Build .deb and .rpm + if: endsWith(matrix.target, '-musl') + env: + TARGET: ${{ matrix.target }} + run: sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "$SOULSEEK_RS_VERSION" "$TARGET" . soulseek-rs-nightly + - name: Store packaged binary uses: actions/upload-artifact@v4 with: @@ -102,6 +118,8 @@ jobs: path: | ${{ steps.package.outputs.tar || steps.package.outputs.zip }} ${{ steps.package.outputs.sha256 }} + soulseek-rs-nightly-*.deb + soulseek-rs-nightly-*.rpm if-no-files-found: error retention-days: 1 @@ -137,6 +155,10 @@ jobs: soulseek-rs-nightly-aarch64-apple-darwin.sha256 soulseek-rs-nightly-x86_64-pc-windows-msvc.sha256 soulseek-rs-nightly-aarch64-pc-windows-msvc.sha256 + soulseek-rs-nightly-amd64.deb + soulseek-rs-nightly-arm64.deb + soulseek-rs-nightly-x86_64.rpm + soulseek-rs-nightly-aarch64.rpm ) printf '%s\n' "${expected[@]}" | sort > "$RUNNER_TEMP/expected-assets" find dist -maxdepth 1 -type f -exec basename {} \; | sort > "$RUNNER_TEMP/actual-assets" @@ -219,9 +241,12 @@ jobs: smoke-test: name: Install published nightly - needs: publish + needs: [source, publish] runs-on: ubuntu-latest timeout-minutes: 5 + env: + VERSION: ${{ needs.source.outputs.version }} + DOWNLOAD: https://github.com/${{ github.repository }}/releases/download/nightly steps: - uses: actions/checkout@v4 with: @@ -233,7 +258,7 @@ jobs: SOULSEEK_RS_INSTALL_DIR: ${{ runner.temp }}/curl-install run: | sh website/public/install.sh --nightly - "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version + "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version | grep -F "$VERSION" - name: Install with wget and no curl on PATH env: @@ -246,4 +271,21 @@ jobs: ln -s "$(command -v "$tool")" "$RUNNER_TEMP/wget-path/$tool" done PATH="$RUNNER_TEMP/wget-path" /bin/sh website/public/install.sh --nightly - "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version + "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version | grep -F "$VERSION" + + - name: Install the .deb + run: | + curl -fsSLO "$DOWNLOAD/soulseek-rs-nightly-amd64.deb" + sudo apt-get install -y ./soulseek-rs-nightly-amd64.deb + soulseek-rs --version | grep -F "$VERSION" + dpkg -L soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + dpkg -L soulseek-rs | grep -Fx /usr/share/zsh/vendor-completions/_soulseek-rs + + - name: Install the .rpm + run: | + docker run --rm fedora:latest sh -ec " + dnf install -y '$DOWNLOAD/soulseek-rs-nightly-x86_64.rpm' + soulseek-rs --version | grep -F '$VERSION' + rpm -ql soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + rpm -ql soulseek-rs | grep -Fx /usr/share/zsh/site-functions/_soulseek-rs + " diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml new file mode 100644 index 0000000..bb1b304 --- /dev/null +++ b/.github/workflows/packaging.yml @@ -0,0 +1,87 @@ +name: Packaging + +on: + pull_request: + paths: + - 'flake.nix' + - 'flake.lock' + - 'Cargo.lock' + - 'Cargo.toml' + - 'packaging/**' + - 'scripts/package-linux.sh' + - 'soulseek-rs/src/cli.rs' + - 'soulseek-rs/src/run.rs' + - 'soulseek-rs/src/commands/**' + - '.github/workflows/packaging.yml' + +permissions: + contents: read + +concurrency: + group: packaging-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + VERSION: 0.0.0+git202601010000.0000000 + +jobs: + packages: + name: .deb and .rpm + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-musl + + - uses: Swatinem/rust-cache@v2 + + - name: Install musl toolchain + run: sudo apt-get update && sudo apt-get install -y musl-tools + + - name: Build the packages + env: + SOULSEEK_RS_VERSION: ${{ env.VERSION }} + TARGET: x86_64-unknown-linux-musl + run: | + cargo build --locked --release --target "$TARGET" --package soulseek-rs + sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "$VERSION" "$TARGET" . soulseek-rs-nightly + + - name: Install the .deb + run: | + sudo apt-get install -y ./soulseek-rs-nightly-amd64.deb + soulseek-rs --version | grep -F "$VERSION" + dpkg -L soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + dpkg -L soulseek-rs | grep -Fx /usr/share/zsh/vendor-completions/_soulseek-rs + + - name: Install the .rpm + run: | + docker run --rm -v "$PWD:/packages:ro" fedora:latest sh -ec " + dnf install -y /packages/soulseek-rs-nightly-x86_64.rpm + soulseek-rs --version | grep -F '$VERSION' + rpm -ql soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + rpm -ql soulseek-rs | grep -Fx /usr/share/zsh/site-functions/_soulseek-rs + " + + flake: + name: Nix flake + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: cachix/install-nix-action@v31 + + - name: Build and run + run: | + nix build --print-build-logs + ./result/bin/soulseek-rs --version | grep -E '\+git[0-9]{12}\.[0-9a-f]{7}$' + test -s result/share/man/man1/soulseek-rs.1.gz + test -s result/share/fish/vendor_completions.d/soulseek-rs.fish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac7f993..a7bbc65 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -200,6 +200,16 @@ jobs: ref: refs/tags/${{ needs.release.outputs.tag }} token: ${{ secrets.GITHUB_TOKEN }} + - name: Build and upload .deb and .rpm + if: endsWith(matrix.target, '-musl') && hashFiles('scripts/package-linux.sh') != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.release.outputs.tag }} + TARGET: ${{ matrix.target }} + run: | + sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "${TAG#v}" "$TARGET" dist + gh release upload "$TAG" dist/* --clobber + # The tap regenerates its formula from whatever `gh release view` calls latest, # so it only needs a nudge once every archive and .sha256 is on the release — # hence `needs: binaries` rather than firing alongside it. The tap also polls diff --git a/Cargo.lock b/Cargo.lock index e316a04..d57a443 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -476,9 +476,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.2" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -486,9 +486,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -508,14 +508,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.2", ] [[package]] @@ -524,6 +524,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clap_mangen" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "color-eyre" version = "0.6.5" @@ -2251,6 +2261,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -2550,6 +2566,7 @@ dependencies = [ "chrono", "clap", "clap_complete", + "clap_mangen", "color-eyre", "directories", "dotenv", diff --git a/Cargo.toml b/Cargo.toml index 310dc01..47a88b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ resolver = "2" version = "19.0.0" authors = ["Michel de Graaf"] edition = "2024" +rust-version = "1.91" license = "MIT" repository = "https://github.com/michel/soulseek-rs" homepage = "https://re-invention.nl/soulseek-rs/" diff --git a/README.md b/README.md index db9eb9d..bf7808b 100644 --- a/README.md +++ b/README.md @@ -107,17 +107,66 @@ numbered release. A failed download, checksum, or startup check leaves an existing binary untouched. Windows archives for both channels are on the [releases page](https://github.com/michel/soulseek-rs/releases). -The other stable routes need no installer script: +Or install it with a package manager. Stable follows the latest release: ```bash -brew install michel/tap/soulseek-rs # macOS and Linux -cargo install soulseek-rs # or from crates.io +# Debian, Ubuntu, Mint (on ARM: arm64 for amd64) +curl -fsSLO https://github.com/michel/soulseek-rs/releases/latest/download/soulseek-rs-amd64.deb +sudo apt install ./soulseek-rs-amd64.deb + +# Fedora, RHEL (on ARM: aarch64 for x86_64) +sudo dnf install https://github.com/michel/soulseek-rs/releases/latest/download/soulseek-rs-x86_64.rpm + +# Nix +nix run github:michel/soulseek-rs/master + +# Homebrew, on macOS and Linux +brew install michel/tap/soulseek-rs + +# cargo-binstall fetches the release binary and compiles nothing +cargo binstall soulseek-rs + +# From source: Rust 1.91 or newer. Debian 13 and Ubuntu 24.04 default to an older one +cargo install --locked soulseek-rs ``` +Nightly is the tip of `develop`. The `.deb` and `.rpm` are downloads; Nix, +Homebrew and cargo build it from source: + +```bash +curl -fsSLO https://github.com/michel/soulseek-rs/releases/download/nightly/soulseek-rs-nightly-amd64.deb +sudo apt install ./soulseek-rs-nightly-amd64.deb + +sudo dnf install https://github.com/michel/soulseek-rs/releases/download/nightly/soulseek-rs-nightly-x86_64.rpm + +nix run github:michel/soulseek-rs/develop + +brew install --HEAD michel/tap/soulseek-rs + +cargo install --locked --git https://github.com/michel/soulseek-rs --branch develop soulseek-rs +``` + +A `.deb` or `.rpm` does not update itself. Install the newer file over it. A +nightly sorts above the release it follows, so going back to stable is a +downgrade. In a script apt refuses one without `--allow-downgrades`, while dnf +goes ahead. Both carry the man page and completions for bash, zsh and fish. +A release download prints the plain number, `19.0.0`. Any Nix build, and a +nightly download, reads `19.0.0+git202609141144.deb846b`: the last release, the +commit's time in UTC, and the commit. A Homebrew `--HEAD` build reports +`HEAD-deb846b`. A `cargo install` from git sets no version, so it prints +`19.0.0` too. + From source, `cargo build --release` leaves the binary at `target/release/soulseek-rs`. To build your own client on the protocol library, run `cargo add soulseek-rs-lib`. +To package it for a distribution: a musl build is static, a glibc build links +only libc, libm and libgcc_s, +`soulseek-rs completions print ` and `soulseek-rs man` write the +completion scripts and the man page to stdout, and a build with +`SOULSEEK_RS_VERSION` set reports that instead of the crate version. The nfpm +recipe behind the `.deb` and `.rpm` is in [`packaging/`](packaging). + ## Usage Run it with no subcommand for the interactive TUI: @@ -332,6 +381,8 @@ soulseek-rs config path|list|get|set soulseek-rs portmap # test automatic port mapping soulseek-rs skills install|uninstall|list # teach a coding agent this CLI soulseek-rs completions install|uninstall # tab completion for bash/zsh/fish +soulseek-rs completions print # the script on stdout, for packaging +soulseek-rs man # the man page on stdout ``` A script usually starts with `whoami`, which answers "are these credentials @@ -343,9 +394,9 @@ download folder. `--no-daemon` tests this machine's own login instead. `shares list` reads the config file either way, while `shares status` and `shares reindex` ask the session and report what peers can see. -Twenty need no credentials: `config path|list|get|set`, +Twenty-two need no credentials: `config path|list|get|set`, `shares list|add|remove`, `wish add|remove|list`, `portmap`, -`skills install|uninstall|list`, `completions install|uninstall`, +`skills install|uninstall|list`, `completions install|uninstall|print`, `man`, `account logout` and `daemon token|status|stop`. Most never touch the network; `daemon status` and `daemon stop` talk to a daemon that is already logged in, so a machine with no account of its own can still ask whether one is running. `shares status` and diff --git a/RELEASE.md b/RELEASE.md index b6356d0..941db0f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -28,6 +28,11 @@ Releases are automated by [release-plz](https://release-plz.dev). You do not run | `soulseek-rs-vX.Y.Z-x86_64-pc-windows-msvc.zip` | | | `soulseek-rs-vX.Y.Z-aarch64-pc-windows-msvc.zip` | | + - packages the two Linux binaries as `soulseek-rs-amd64.deb`, `soulseek-rs-arm64.deb`, + `soulseek-rs-x86_64.rpm` and `soulseek-rs-aarch64.rpm`, with the man page and the + bash, zsh and fish completions. The names carry no version, so + `releases/latest/download/soulseek-rs-amd64.deb` is a link that keeps working, + - rewrites `Formula/soulseek-rs.rb` in the `michel/homebrew-tap` repository so `brew install michel/tap/soulseek-rs` picks up the new macOS and Linux archives, - merges `master` back into `develop`, so the branch the work happens on carries the @@ -44,9 +49,15 @@ conflict. `.github/workflows/nightly.yml` builds the tip of `develop` at 02:17 UTC every day. It resolves the commit once, builds the same six targets as a numbered -release, verifies all twelve archives and checksum files as one set, then -replaces the mutable `nightly` prerelease and tag. The published release is -smoke-tested through both the curl and wget installer paths. +release, packages the two Linux ones as `soulseek-rs-nightly-*.deb` and `.rpm`, +verifies all sixteen files as one set, then replaces the mutable `nightly` +prerelease and tag. Every nightly binary reports `X.Y.Z+git.` +instead of the crate version. The published release is smoke-tested through the +curl and wget installer paths, and by installing the `.deb` and the `.rpm`. + +Never enable GitHub's Immutable Releases on this repository: nightly replaces +its assets and moves its tag, and a tag name burnt by that setting cannot be +reused. Use **Actions → Nightly → Run workflow** to force a build. The workflow always checks out `develop`, regardless of the ref selected in the dispatch form. The diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d9c0837 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1789785513, + "narHash": "sha256-B44WL6h0XoLjJ41bUPJk0X5SDinLCII//6EcBLXKiJ0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "20b1ddd1aa5ace70c9468305030aa4f9ef79671b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..c954c07 --- /dev/null +++ b/flake.nix @@ -0,0 +1,51 @@ +{ + description = "Soulseek client for the terminal, with a scriptable CLI and daemon"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = + { self, nixpkgs }: + let + inherit (nixpkgs) lib; + version = (lib.importTOML ./Cargo.toml).workspace.package.version; + systems = [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + ]; + in + { + packages = lib.genAttrs systems (system: { + default = nixpkgs.legacyPackages.${system}.callPackage ( + { rustPlatform, installShellFiles }: + rustPlatform.buildRustPackage { + pname = "soulseek-rs"; + inherit version; + src = self; + cargoLock.lockFile = ./Cargo.lock; + cargoBuildFlags = [ + "--package" + "soulseek-rs" + ]; + doCheck = false; + nativeBuildInputs = [ installShellFiles ]; + env.SOULSEEK_RS_VERSION = "${version}+git${lib.substring 0 12 self.lastModifiedDate}.${self.shortRev or "dirty"}"; + postInstall = '' + installShellCompletion --cmd soulseek-rs \ + --bash <($out/bin/soulseek-rs completions print bash) \ + --zsh <($out/bin/soulseek-rs completions print zsh) \ + --fish <($out/bin/soulseek-rs completions print fish) + $out/bin/soulseek-rs man > soulseek-rs.1 + installManPage soulseek-rs.1 + ''; + meta = { + description = "Soulseek client for the terminal, with a scriptable CLI and daemon"; + homepage = "https://re-invention.nl/soulseek-rs/"; + license = lib.licenses.mit; + mainProgram = "soulseek-rs"; + }; + } + ) { }; + }); + }; +} diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..1493db5 --- /dev/null +++ b/packaging/nfpm.yaml @@ -0,0 +1,55 @@ +name: soulseek-rs +arch: ${PKG_ARCH} +platform: linux +version: ${PKG_VERSION} +version_schema: none +release: "1" +section: net +priority: optional +maintainer: Michel de Graaf +description: | + Soulseek client for the terminal + A TUI, a scriptable command line and a daemon for the Soulseek + peer-to-peer network. +vendor: Michel de Graaf +homepage: https://re-invention.nl/soulseek-rs/ +license: MIT +suggests: + - gnome-keyring +contents: + - src: target/nfpm/soulseek-rs + dst: /usr/bin/soulseek-rs + file_info: + mode: 0755 + - src: target/nfpm/soulseek-rs.1.gz + dst: /usr/share/man/man1/soulseek-rs.1.gz + - src: target/nfpm/soulseek-rs.bash + dst: /usr/share/bash-completion/completions/soulseek-rs + - src: target/nfpm/soulseek-rs.fish + dst: /usr/share/fish/vendor_completions.d/soulseek-rs.fish + - src: target/nfpm/_soulseek-rs + dst: /usr/share/zsh/vendor-completions/_soulseek-rs + packager: deb + - src: target/nfpm/_soulseek-rs + dst: /usr/share/zsh/site-functions/_soulseek-rs + packager: rpm + - dst: /usr/share/doc/soulseek-rs + type: dir + packager: rpm + - dst: /usr/share/licenses/soulseek-rs + type: dir + packager: rpm + - src: README.md + dst: /usr/share/doc/soulseek-rs/README.md + packager: deb + - src: README.md + dst: /usr/share/doc/soulseek-rs/README.md + packager: rpm + type: doc + - src: LICENSE + dst: /usr/share/doc/soulseek-rs/copyright + packager: deb + - src: LICENSE + dst: /usr/share/licenses/soulseek-rs/LICENSE + packager: rpm + type: license diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh new file mode 100755 index 0000000..8216098 --- /dev/null +++ b/scripts/package-linux.sh @@ -0,0 +1,54 @@ +#!/bin/sh +set -eu +umask 022 + +[ $# -ge 4 ] || { + echo "usage: $0 []" >&2 + exit 2 +} + +NFPM_VERSION=2.47.0 + +binary=$1 +PKG_VERSION=$2 +target=$3 +out=$4 +package=${5:-soulseek-rs} + +case "$target" in + x86_64-*) PKG_ARCH=amd64 rpm_arch=x86_64 ;; + aarch64-*) PKG_ARCH=arm64 rpm_arch=aarch64 ;; + *) echo "no package architecture for $target" >&2; exit 1 ;; +esac + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) nfpm_asset=Linux_x86_64 nfpm_sha256=0660ca602b2d2d2ae4781a06c692b3eeb9d437ffea05b831d76e41f4a3188783 ;; + Linux-aarch64) nfpm_asset=Linux_arm64 nfpm_sha256=1c0f5f2999b9a974bfb04fdb0cc3306096de530ac5dbb25d739cc5f5219c919c ;; + *) echo "no pinned nfpm build for this machine" >&2; exit 1 ;; +esac + +root=$(cd "$(dirname "$0")/.." && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +stage=$root/target/nfpm +rm -rf "$stage" +mkdir -p "$stage" "$out" +out=$(cd "$out" && pwd) + +cp "$binary" "$stage/soulseek-rs" +"$binary" completions print bash > "$stage/soulseek-rs.bash" +"$binary" completions print zsh > "$stage/_soulseek-rs" +"$binary" completions print fish > "$stage/soulseek-rs.fish" +"$binary" man > "$stage/soulseek-rs.1" +gzip -9n "$stage/soulseek-rs.1" + +archive=nfpm_${NFPM_VERSION}_$nfpm_asset.tar.gz +curl -fsSL -o "$work/$archive" \ + "https://github.com/goreleaser/nfpm/releases/download/v$NFPM_VERSION/$archive" +echo "$nfpm_sha256 $work/$archive" | sha256sum -c - >/dev/null +tar -xzf "$work/$archive" -C "$work" nfpm + +export PKG_ARCH PKG_VERSION +cd "$root" +"$work/nfpm" package --config packaging/nfpm.yaml --packager deb --target "$out/$package-$PKG_ARCH.deb" +"$work/nfpm" package --config packaging/nfpm.yaml --packager rpm --target "$out/$package-$rpm_arch.rpm" diff --git a/soulseek-rs-lib/Cargo.toml b/soulseek-rs-lib/Cargo.toml index ebc7d14..659c074 100644 --- a/soulseek-rs-lib/Cargo.toml +++ b/soulseek-rs-lib/Cargo.toml @@ -3,6 +3,7 @@ name = "soulseek-rs-lib" version.workspace = true authors.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index 150de3e..658387f 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -1,5 +1,5 @@ use crate::actor::{Actor, ActorHandle, ConnectionState}; -use crate::client::ClientOperation; +use crate::client::{CancelHandle, ClientOperation}; use crate::dispatcher::MessageDispatcher; use crate::message::server::AdminMessageHandler; use crate::message::server::CantConnectToPeerHandler; @@ -288,6 +288,7 @@ pub struct ServerActor { shared_folder_count: u32, shared_file_count: u32, session: SessionWatch, + cancel: CancelHandle, } /// The messages a client sends right after a successful login: its shared-file @@ -340,6 +341,7 @@ impl ServerActor { shared_folder_count, shared_file_count, session: SessionWatch::default(), + cancel: CancelHandle::new(), } } @@ -348,6 +350,10 @@ impl ServerActor { self.session = session; } + pub fn set_cancel(&mut self, cancel: CancelHandle) { + self.cancel = cancel; + } + fn initiate_connection(&mut self) -> bool { let stream = (self.address.get_host(), self.address.get_port()) .to_socket_addrs() @@ -563,6 +569,10 @@ impl ServerActor { } fn send_message(&mut self, message: Message) { + let cancel = self.cancel.clone(); + let Some(_live) = cancel.live() else { + return; + }; let Some(stream) = self.stream.as_mut() else { error!("[server] Cannot send message: stream is None"); return; diff --git a/soulseek-rs-lib/src/actor/server_actor/tests.rs b/soulseek-rs-lib/src/actor/server_actor/tests.rs index 0809d99..064ba7e 100644 --- a/soulseek-rs-lib/src/actor/server_actor/tests.rs +++ b/soulseek-rs-lib/src/actor/server_actor/tests.rs @@ -188,6 +188,44 @@ fn a_login_with_nobody_listening_is_refused_at_once() { ); } +#[test] +fn a_cancelled_actor_never_writes_the_login_it_queued() { + use std::io::Read; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let client = TcpStream::connect(addr).unwrap(); + let (mut server_side, _) = listener.accept().unwrap(); + client.set_nonblocking(true).unwrap(); + + let cancel = CancelHandle::new(); + let mut actor = parked_actor(addr.port()); + actor.set_cancel(cancel.clone()); + actor.stream = Some(client); + actor.connection_state = ConnectionState::Connecting { + since: Instant::now(), + }; + let (response, _verdict) = std::sync::mpsc::channel(); + actor.handle_login( + "u".into(), + "p".into(), + ClientVersion::default(), + response, + ); + assert_eq!(actor.queued_messages.len(), 1); + + cancel.cancel(); + actor.tick(); + assert!(matches!(actor.connection_state, ConnectionState::Connected)); + actor.on_stop(); + + server_side + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut written = Vec::new(); + assert_eq!(server_side.read_to_end(&mut written).unwrap(), 0); +} + #[test] fn a_successful_login_marks_the_session_live_again() { let mut actor = parked_actor(1); diff --git a/soulseek-rs-lib/src/client/cancel.rs b/soulseek-rs-lib/src/client/cancel.rs new file mode 100644 index 0000000..ba8ebbe --- /dev/null +++ b/soulseek-rs-lib/src/client/cancel.rs @@ -0,0 +1,24 @@ +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +#[derive(Clone)] +pub struct CancelHandle(Arc>); + +impl CancelHandle { + pub(crate) fn new() -> Self { + Self(Arc::new(Mutex::new(false))) + } + + pub fn cancel(&self) { + *self.0.lock().unwrap_or_else(PoisonError::into_inner) = true; + } + + #[must_use] + pub fn is_cancelled(&self) -> bool { + *self.0.lock().unwrap_or_else(PoisonError::into_inner) + } + + pub(crate) fn live(&self) -> Option> { + let guard = self.0.lock().unwrap_or_else(PoisonError::into_inner); + (!*guard).then_some(guard) + } +} diff --git a/soulseek-rs-lib/src/client/connection.rs b/soulseek-rs-lib/src/client/connection.rs index 01bffb7..cf0bb09 100644 --- a/soulseek-rs-lib/src/client/connection.rs +++ b/soulseek-rs-lib/src/client/connection.rs @@ -5,6 +5,11 @@ use super::{ TcpStream, error, info, mpsc, thread, trace, warn, }; use std::sync::atomic::Ordering; +use std::sync::mpsc::RecvTimeoutError; +use std::time::{Duration, Instant}; + +const LISTENER_RELEASE_TIMEOUT: Duration = Duration::from_secs(2); +const LOGIN_POLL_INTERVAL: Duration = Duration::from_millis(100); /// Ceiling on the wait for a login verdict. Generous enough for a slow server /// and a retrying connect, short enough that an unattended caller ends. @@ -87,6 +92,10 @@ impl Client { ctx.shares = shares; ctx.shared_directories.clone_from(&self.shared_directories); + if self.cancel.is_cancelled() { + return Err(SoulseekRs::NotConnected); + } + // Bind before logging in: the port we advertise has to be the port we // hold, and a bind that fails outright is the caller's to see rather // than a panic on a thread nobody is watching. @@ -108,6 +117,7 @@ impl Client { shared_file_count, ); server_actor.set_session_watch(self.session.clone()); + server_actor.set_cancel(self.cancel.clone()); self.server_handle = Some(ctx.actor_system.spawn_with_handle( server_actor, @@ -121,6 +131,8 @@ impl Client { let context = self.context.clone(); let own_username = self.username.clone(); let stopped = self.listener_stopped.clone(); + let released = Arc::new(AtomicBool::new(false)); + self.listener_released = released.clone(); thread::spawn(move || { Listen::serve( @@ -130,6 +142,8 @@ impl Client { own_username, stopped, ); + drop(listener); + released.store(true, Ordering::Relaxed); }); } @@ -151,7 +165,8 @@ impl Client { /// collide with our own ghost. /// /// Called automatically when the client is dropped. The peer listener's - /// bound port, if one was opened, is released with the process. + /// bound port, if one was opened, is free again when this returns, unless + /// the listener cannot be woken within two seconds. pub fn disconnect(&mut self) { if let Some(handle) = self.server_handle.take() { let _ = handle.stop(); @@ -173,8 +188,15 @@ impl Client { // this the port stays bound for the life of the process, and the next // client falls back to an ephemeral port nobody was told about. self.listener_stopped.store(true, Ordering::Relaxed); - if let Some(port) = self.bound_port.take() { - let _ = TcpStream::connect(("127.0.0.1", port)); + if let Some(port) = self.bound_port.take() + && TcpStream::connect(("127.0.0.1", port)).is_ok() + { + let deadline = Instant::now() + LISTENER_RELEASE_TIMEOUT; + while !self.listener_released.load(Ordering::Relaxed) + && Instant::now() < deadline + { + thread::sleep(Duration::from_millis(5)); + } } } @@ -187,7 +209,9 @@ impl Client { /// control. pub fn login(&self) -> Result { info!("Logging in as {}", self.username); - if let Some(handle) = &self.server_handle { + if let Some(handle) = &self.server_handle + && !self.cancel.is_cancelled() + { let (tx, rx) = std::sync::mpsc::channel(); let _ = handle.send(ServerMessage::Login { username: self.username.clone(), @@ -196,9 +220,19 @@ impl Client { response: tx, }); - match rx.recv_timeout(LOGIN_RESPONSE_TIMEOUT) { - Ok(result) => result, - Err(_) => Err(SoulseekRs::Timeout), + let deadline = Instant::now() + LOGIN_RESPONSE_TIMEOUT; + loop { + match rx.recv_timeout(LOGIN_POLL_INTERVAL) { + Ok(result) => return result, + Err(RecvTimeoutError::Timeout) + if self.cancel.is_cancelled() => + { + return Err(SoulseekRs::NotConnected); + } + Err(RecvTimeoutError::Timeout) + if Instant::now() < deadline => {} + Err(_) => return Err(SoulseekRs::Timeout), + } } } else { Err(SoulseekRs::NotConnected) diff --git a/soulseek-rs-lib/src/client/context.rs b/soulseek-rs-lib/src/client/context.rs index b51a58a..ff98041 100644 --- a/soulseek-rs-lib/src/client/context.rs +++ b/soulseek-rs-lib/src/client/context.rs @@ -42,6 +42,7 @@ impl ClientContext { private_messages: Vec::new(), pending_connect_tokens: HashMap::new(), max_peers: Arc::new(AtomicUsize::new(DEFAULT_MAX_PEERS)), + download_speed_limit: Arc::default(), shares: Arc::new(Shares::empty()), shared_directories: Vec::new(), peer_addresses: HashMap::new(), @@ -100,10 +101,12 @@ impl ClientContext { // the whole board again on the next join. self.room_tickers.remove(room); } + // Only a roster the server has sent in full is kept current: the + // joiner hears of their own arrival before the member list. RoomEvent::UserJoined { room, username } => { - let members = - self.room_members.entry(room.clone()).or_default(); - if let Err(at) = members.binary_search(username) { + if let Some(members) = self.room_members.get_mut(room) + && let Err(at) = members.binary_search(username) + { members.insert(at, username.clone()); } } diff --git a/soulseek-rs-lib/src/client/downloads.rs b/soulseek-rs-lib/src/client/downloads.rs index 28c8177..e47db5d 100644 --- a/soulseek-rs-lib/src/client/downloads.rs +++ b/soulseek-rs-lib/src/client/downloads.rs @@ -14,6 +14,12 @@ impl Client { .unwrap_or_default() } + pub fn set_download_speed_limit(&self, bytes_per_second: u64) { + if let Ok(ctx) = self.context.read_safe() { + ctx.download_speed_limit.set(bytes_per_second); + } + } + #[must_use] pub fn pause_download(&self, username: &str, filename: &str) -> bool { match self.context.write_safe() { diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index 21d7dfd..6ce58e5 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -9,6 +9,7 @@ use crate::types::{ SimilarUser, UserInfo, UserInterests, UserPresence, UserStats, UserStatus, }; use crate::utils::logger; +use crate::utils::rate_limit::RateLimit; use crate::{ Transfer, actor::{ActorSystem, peer_registry::PeerRegistry}, @@ -486,6 +487,7 @@ pub struct ClientContext { /// a token we sent in a ConnectToPeer to the peer we expect back. pending_connect_tokens: HashMap, max_peers: Arc, + pub(crate) download_speed_limit: Arc, /// Files we share with peers (read-only after connect). pub shares: Arc, /// The directories the current share index was built from. @@ -646,6 +648,8 @@ pub struct Client { /// Tells the peer listener to stop, so a disconnected client releases the /// port it bound. listener_stopped: Arc, + listener_released: Arc, + cancel: CancelHandle, } impl Drop for Client { @@ -680,6 +684,8 @@ impl Client { server_handle: None, session: SessionWatch::default(), listener_stopped: Arc::new(AtomicBool::new(false)), + listener_released: Arc::new(AtomicBool::new(false)), + cancel: CancelHandle::new(), } } @@ -720,6 +726,11 @@ impl Client { self.session.loss() } + #[must_use] + pub fn cancel_handle(&self) -> CancelHandle { + self.cancel.clone() + } + /// The directories whose files are currently shared with other peers. #[must_use] pub fn shared_directories(&self) -> Vec { @@ -845,6 +856,7 @@ impl Client { } } +mod cancel; mod children; mod connection; mod context; @@ -857,5 +869,7 @@ mod social; mod upload_queue; mod uploads; +pub use cancel::CancelHandle; + #[cfg(test)] mod tests; diff --git a/soulseek-rs-lib/src/client/rooms.rs b/soulseek-rs-lib/src/client/rooms.rs index d11da0c..671ff53 100644 --- a/soulseek-rs-lib/src/client/rooms.rs +++ b/soulseek-rs-lib/src/client/rooms.rs @@ -150,8 +150,11 @@ impl Client { /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. pub fn watch_user(&self, username: &str) -> Result<()> { use crate::message::server::MessageFactory; + // Held across the send: the reply is applied under this lock, so it + // cannot land first and have its "does not exist" undone by the add. + let mut context = self.context.write_safe()?; self.send_server_message(MessageFactory::build_watch_user(username))?; - self.context.write_safe()?.add_watched_user(username); + context.add_watched_user(username); Ok(()) } diff --git a/soulseek-rs-lib/src/client/tests/rooms.rs b/soulseek-rs-lib/src/client/tests/rooms.rs index 0af6784..d504839 100644 --- a/soulseek-rs-lib/src/client/tests/rooms.rs +++ b/soulseek-rs-lib/src/client/tests/rooms.rs @@ -39,6 +39,25 @@ fn the_room_roster_follows_the_joins_and_leaves_the_server_reports() { assert!(context.room_members("lobby").is_empty()); } +#[test] +fn a_join_announced_before_the_member_list_does_not_start_a_roster() { + // soulfind tells a room about a joiner, the joiner included, before it + // sends them the member list. A roster started from that echo reads as a + // room holding nobody else. + let mut context = ClientContext::new(); + context.apply_room_event(RoomEvent::UserJoined { + room: "lobby".to_string(), + username: "me".to_string(), + }); + assert!(context.room_members("lobby").is_empty()); + + context.apply_room_event(RoomEvent::Joined { + room: "lobby".to_string(), + users: vec!["me".to_string(), "alice".to_string()], + }); + assert_eq!(context.room_members("lobby"), ["alice", "me"]); +} + #[test] fn a_user_joining_twice_is_listed_once() { let mut context = ClientContext::new(); diff --git a/soulseek-rs-lib/src/lib.rs b/soulseek-rs-lib/src/lib.rs index a174fbd..45d3263 100644 --- a/soulseek-rs-lib/src/lib.rs +++ b/soulseek-rs-lib/src/lib.rs @@ -32,7 +32,7 @@ pub mod prelude { // Re-export commonly used types pub use actor::server_actor::{PeerAddress, UserMessage}; -pub use client::{Client, ClientSettings}; +pub use client::{CancelHandle, Client, ClientSettings}; pub use error::{Result, SoulseekRs}; pub use message::peer::{SharedDirectory, SharedFileEntry}; pub use peer::{ConnectionType, ParseConnectionTypeError}; diff --git a/soulseek-rs-lib/src/peer/download_peer.rs b/soulseek-rs-lib/src/peer/download_peer.rs index 034e54f..fd2e231 100644 --- a/soulseek-rs-lib/src/peer/download_peer.rs +++ b/soulseek-rs-lib/src/peer/download_peer.rs @@ -363,6 +363,11 @@ impl DownloadPeer { stream .set_read_timeout(Some(CANCEL_POLL)) .map_err(DownloadError::ConnectionFailed)?; + let speed_limit = client_context + .read() + .map_err(|_| DownloadError::LockPoisoned)? + .download_speed_limit + .clone(); let mut last_data = Instant::now(); loop { @@ -384,7 +389,10 @@ impl DownloadPeer { break; } - match stream.read(&mut read_buffer) { + let allowance = speed_limit.take(READ_BUFFER_SIZE); + let read = stream.read(&mut read_buffer[..allowance]); + speed_limit.give_back(allowance - read.as_ref().map_or(0, |&n| n)); + match read { Ok(0) => { trace!( "[download_peer:{}] connection closed by peer", diff --git a/soulseek-rs-lib/src/utils/mod.rs b/soulseek-rs-lib/src/utils/mod.rs index e32ce7c..b7ea3bd 100644 --- a/soulseek-rs-lib/src/utils/mod.rs +++ b/soulseek-rs-lib/src/utils/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod keepalive; pub mod lock; pub mod md5; pub mod path; +pub(crate) mod rate_limit; pub(crate) mod semaphore; pub mod zlib; diff --git a/soulseek-rs-lib/src/utils/rate_limit.rs b/soulseek-rs-lib/src/utils/rate_limit.rs new file mode 100644 index 0000000..a5a261a --- /dev/null +++ b/soulseek-rs-lib/src/utils/rate_limit.rs @@ -0,0 +1,136 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, PoisonError}; +use std::time::{Duration, Instant}; + +const TICK: Duration = Duration::from_millis(100); +const TICKS_PER_SECOND: u64 = 1000 / TICK.as_millis() as u64; + +#[derive(Default)] +pub struct RateLimit { + bytes_per_second: AtomicU64, + bucket: Mutex, +} + +#[derive(Default)] +struct Bucket { + tokens: u64, + refilled: Option, +} + +impl RateLimit { + pub fn set(&self, bytes_per_second: u64) { + self.bytes_per_second + .store(bytes_per_second, Ordering::Relaxed); + } + + pub fn take(&self, want: usize) -> usize { + loop { + let rate = self.bytes_per_second.load(Ordering::Relaxed); + if rate == 0 { + return want; + } + let mut bucket = + self.bucket.lock().unwrap_or_else(PoisonError::into_inner); + let now = Instant::now(); + let since = bucket.refilled.map_or(TICK, |at| now - at); + if since >= TICK { + bucket.tokens = (rate / TICKS_PER_SECOND).max(1); + bucket.refilled = Some(now); + } + if bucket.tokens > 0 { + let grant = bucket.tokens.min(want as u64); + bucket.tokens -= grant; + return grant as usize; + } + drop(bucket); + std::thread::sleep(TICK.saturating_sub(since)); + } + } + + pub fn give_back(&self, unused: usize) { + if unused == 0 || self.bytes_per_second.load(Ordering::Relaxed) == 0 { + return; + } + self.bucket + .lock() + .unwrap_or_else(PoisonError::into_inner) + .tokens += unused as u64; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_limit_grants_everything_at_once() { + let limit = RateLimit::default(); + let started = Instant::now(); + assert_eq!(limit.take(1 << 20), 1 << 20); + assert!(started.elapsed() < TICK); + } + + #[test] + fn a_limit_paces_what_is_taken() { + let limit = RateLimit::default(); + limit.set(20_000); + let started = Instant::now(); + let mut taken = 0; + while taken < 6_000 { + let grant = limit.take(4096); + assert!((1..=4096).contains(&grant), "granted {grant}"); + taken += grant; + } + let elapsed = started.elapsed(); + assert!( + (2 * TICK..10 * TICK).contains(&elapsed), + "{taken} bytes at 20 kB/s took {elapsed:?}" + ); + } + + #[test] + fn concurrent_takers_share_one_budget() { + let limit = RateLimit::default(); + limit.set(20_000); + let started = Instant::now(); + std::thread::scope(|s| { + for _ in 0..3 { + s.spawn(|| { + let mut taken = 0; + while taken < 2_000 { + taken += limit.take(4096); + } + }); + } + }); + assert!( + started.elapsed() >= 2 * TICK, + "6 kB at 20 kB/s across three takers took {:?}", + started.elapsed() + ); + } + + #[test] + fn unused_allowance_goes_back_to_the_budget() { + let limit = RateLimit::default(); + limit.set(20_000); + let started = Instant::now(); + let grant = limit.take(4096); + limit.give_back(grant); + assert_eq!(limit.take(4096), grant); + assert!(started.elapsed() < TICK); + } + + #[test] + fn lifting_a_limit_frees_a_take_that_is_waiting() { + let limit = RateLimit::default(); + limit.set(100); + assert_eq!(limit.take(1000), 10); + std::thread::scope(|s| { + let waiting = s.spawn(|| limit.take(1000)); + std::thread::sleep(Duration::from_millis(10)); + limit.set(0); + assert_eq!(waiting.join().unwrap(), 1000); + }); + } +} diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 5727b75..0e45c9f 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -76,38 +76,51 @@ impl TestServer { } /// Spawn a local soulfind on an ephemeral port with a throwaway database. + /// + /// soulfind exits if its port is taken between `free_port()` handing it + /// out and soulfind binding it, so a child that dies is retried on a + /// fresh port rather than read as a missing server. fn spawn(gate: std::sync::MutexGuard<'static, ()>) -> Option { let bin = soulfind_binary()?; - let port = free_port()?; - let db = std::env::temp_dir().join(format!("soulfind-e2e-{port}.db")); - let _ = std::fs::remove_file(&db); + for _ in 0..3 { + let port = free_port()?; + let db = + std::env::temp_dir().join(format!("soulfind-e2e-{port}.db")); + let _ = std::fs::remove_file(&db); - let mut child = Command::new(&bin) - .arg("-p") - .arg(port.to_string()) - .arg("-d") - .arg(&db) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .ok()?; + let mut child = Command::new(&bin) + .arg("-p") + .arg(port.to_string()) + .arg("-d") + .arg(&db) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok()?; - if wait_until_listening("127.0.0.1", port, Duration::from_secs(5)) - .is_none() - { - // Server never came up (e.g. a toolchain/SQLite issue); skip. + // A child that has exited is never taken as up: soulfind exits + // when its port is gone, and whoever took it may answer instead. + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline + && child.try_wait().ok().flatten().is_none() + { + if TcpStream::connect(("127.0.0.1", port)).is_ok() { + return Some(Self { + host: "127.0.0.1".to_string(), + port, + child: Some(child), + db: Some(db), + _gate: gate, + }); + } + std::thread::sleep(Duration::from_millis(100)); + } + // Never came up (e.g. a toolchain/SQLite issue) or died. let _ = child.kill(); let _ = child.wait(); - return None; + let _ = std::fs::remove_file(&db); } - - Some(Self { - host: "127.0.0.1".to_string(), - port, - child: Some(child), - db: Some(db), - _gate: gate, - }) + None } fn settings(&self, username: &str, password: &str) -> ClientSettings { @@ -327,8 +340,15 @@ fn a_chat_room_message_is_delivered_between_users() { alice.join_room(room).expect("alice joins room"); bob.join_room(room).expect("bob joins room"); - // Give both joins time to register on the server before speaking. - std::thread::sleep(Duration::from_millis(500)); + // Each is in once the server has sent them the member list; a line said + // before bob is in never reaches him. + let deadline = Instant::now() + Duration::from_secs(5); + while alice.room_members(room).is_empty() + || bob.room_members(room).is_empty() + { + assert!(Instant::now() < deadline, "both should get into the room"); + std::thread::sleep(Duration::from_millis(50)); + } let _ = alice.take_room_events(); let _ = bob.take_room_events(); @@ -526,7 +546,14 @@ fn open_control_and_await_queue( let mut p = connect_retry(&cfg.listen_addr, Duration::from_secs(5))?; p.set_read_timeout(Some(Duration::from_secs(10)))?; p.write_all(&peer_init_bytes(&cfg.peer_username, "P", 0))?; + // The client answers a user-info request through the peer it registered + // for this connection, so the answer means a download queues on it + // rather than dialling a peer the server has never heard of. + p.write_all( + &soulseek_rs::message::peer::build_user_info_request().get_buffer(), + )?; p.flush()?; + expect_code(&mut p, 16, Duration::from_secs(10))?; let _ = cfg.ready.send(()); loop { @@ -860,10 +887,6 @@ fn file_downloads_over_p_and_f( .recv_timeout(Duration::from_secs(5)) .expect("mock uploader P connection"); - // The listener registers an incoming peer under its plain username; give - // that registration a moment to complete before queuing the download. - std::thread::sleep(Duration::from_millis(1500)); - let (_download, status_rx) = client .download( filename.to_string(), @@ -913,6 +936,82 @@ fn a_download_completes_when_the_f_connection_beats_token_registration() { ); } +#[test] +fn a_download_speed_limit_paces_a_transfer_until_it_is_lifted() { + let server = server_or_skip!(); + let listen_port = free_port().expect("free listen port"); + let mut client = Client::with_settings(server.listening_settings( + "e2e_cappedpeer_dl", + "pw", + listen_port, + )); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + client.set_download_speed_limit(64 * 1024); + + let filename = "capped.mp3"; + let content: Vec = + (0..1024 * 1024u32).map(|i| (i % 251) as u8).collect(); + let download_dir = unique_download_dir(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let cfg = MockUpload { + listen_addr: format!("127.0.0.1:{listen_port}"), + peer_username: "e2e_cappedpeer".to_string(), + filename: filename.to_string(), + content: content.clone(), + token: 434_343, + ready: ready_tx, + token_delay: Duration::ZERO, + }; + let uploader = std::thread::spawn(move || { + if let Err(e) = run_mock_uploader(&cfg) { + eprintln!("[mock uploader] {e}"); + } + }); + ready_rx + .recv_timeout(Duration::from_secs(5)) + .expect("mock uploader P connection"); + + let started = Instant::now(); + let (_download, status_rx) = client + .download( + filename.to_string(), + "e2e_cappedpeer".to_string(), + content.len() as u64, + download_dir.display().to_string(), + ) + .expect("start download"); + let in_progress = || { + client + .get_all_downloads() + .iter() + .any(|d| matches!(d.status, DownloadStatus::InProgress { .. })) + }; + assert!(wait_for(in_progress), "the download should start"); + std::thread::sleep(Duration::from_secs(1)); + assert!( + in_progress(), + "1 MiB at 64 KiB/s must still be arriving a second in" + ); + + client.set_download_speed_limit(0); + assert!( + wait_for_completion(&client, &status_rx, Duration::from_secs(20)), + "the download should reach Completed" + ); + let elapsed = started.elapsed(); + let _ = uploader.join(); + assert!( + elapsed < Duration::from_secs(8), + "capped for the whole file this takes 16s; lifted it took {elapsed:?}" + ); + assert_eq!( + std::fs::read(download_dir.join(filename)).expect("downloaded file"), + content + ); + let _ = std::fs::remove_dir_all(&download_dir); +} + fn cancel_mid_transfer(peer_username: &str, stall_after: Option) { let server = server_or_skip!(); @@ -943,7 +1042,6 @@ fn cancel_mid_transfer(peer_username: &str, stall_after: Option) { ready_rx .recv_timeout(Duration::from_secs(5)) .expect("mock uploader P connection"); - std::thread::sleep(Duration::from_millis(1500)); let (_download, status_rx) = client .download( @@ -1043,7 +1141,6 @@ fn a_cancelled_queued_download_declines_the_peers_offer() { ready_rx .recv_timeout(Duration::from_secs(5)) .expect("mock uploader P connection"); - std::thread::sleep(Duration::from_millis(1500)); let download_dir = unique_download_dir(); let (_download, status_rx) = client @@ -1117,7 +1214,6 @@ fn refusal_fails_the_download_quickly( ready_rx .recv_timeout(Duration::from_secs(5)) .expect("mock uploader P connection"); - std::thread::sleep(Duration::from_millis(1500)); let download_dir = unique_download_dir(); let (_download, status_rx) = client @@ -1217,7 +1313,6 @@ fn an_interrupted_download_resumes_from_its_partial_file() { ready_rx .recv_timeout(Duration::from_secs(5)) .expect("mock uploader P connection"); - std::thread::sleep(Duration::from_millis(1500)); let (_download, status_rx) = client .download( @@ -2936,9 +3031,9 @@ fn a_joined_room_reports_its_members_statistics() { assert!(sharer.login().expect("sharer login")); sharer.join_room(room).expect("sharer joins room"); - // Let the server register the sharer's membership and share counts, so - // the observer's own join reply describes a room that already has them. - std::thread::sleep(Duration::from_millis(750)); + // The sharer's membership and share counts have to land first, so the + // observer's own join reply describes a room that already has them. + fence(&sharer, "e2e_stats_observer"); let mut observer = Client::with_settings(server.settings("e2e_stats_observer", "pw")); @@ -3019,9 +3114,9 @@ fn watching_a_user_returns_their_status_and_share_counts() { watcher.connect().expect("watcher connect"); assert!(watcher.login().expect("watcher login")); - // Let the subject's share counts reach the server before watching, so - // the reply describes a user it already knows the statistics for. - std::thread::sleep(Duration::from_millis(750)); + // The subject's share counts have to reach the server before watching, + // so the reply describes a user it already knows the statistics for. + fence(&subject, "e2e_watch_watcher"); watcher .watch_user("e2e_watch_subject") @@ -3352,7 +3447,6 @@ fn download_progress_reports_the_rate_actually_received() { ready_rx .recv_timeout(Duration::from_secs(5)) .expect("mock uploader P connection"); - std::thread::sleep(Duration::from_millis(1500)); let (_download, status_rx) = client .download( @@ -3796,6 +3890,18 @@ fn await_room_event( None } +/// Wait until the server has handled everything `client` has sent, including +/// requests it never answers: soulfind takes a connection's messages in order, +/// so the answer to a question about `other` asked now comes after them. +fn fence(client: &Client, other: &str) { + client.request_user_info(other).expect("ask about a user"); + let deadline = Instant::now() + Duration::from_secs(5); + while client.user_info(other).is_none() { + assert!(Instant::now() < deadline, "the server should answer"); + std::thread::sleep(Duration::from_millis(50)); + } +} + #[test] fn a_ticker_set_in_a_room_reaches_the_other_members() { use soulseek_rs::types::RoomEvent; @@ -3812,7 +3918,12 @@ fn a_ticker_set_in_a_room_reaches_the_other_members() { alice.join_room(room).expect("alice joins"); bob.join_room(room).expect("bob joins"); - std::thread::sleep(Duration::from_millis(500)); + // A ticker reaches only who is in the room when it is set. + let deadline = Instant::now() + Duration::from_secs(5); + while bob.room_members(room).is_empty() { + assert!(Instant::now() < deadline, "bob should get into the room"); + std::thread::sleep(Duration::from_millis(50)); + } let _ = bob.take_room_events(); alice @@ -3856,11 +3967,15 @@ fn a_client_joining_later_receives_the_whole_ticker_board() { alice.connect().expect("alice connect"); assert!(alice.login().expect("alice login")); alice.join_room(room).expect("alice joins"); - std::thread::sleep(Duration::from_millis(300)); alice .set_room_ticker(room, "standing message") .expect("set ticker"); - std::thread::sleep(Duration::from_millis(500)); + // The room, alice included, is told once the board holds it. + await_room_event(&alice, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::TickerAdded { room: r, .. } if r == room) + .then_some(()) + }) + .expect("alice should see her ticker go up"); // Carol joins afterwards and must be handed the board that already exists. let mut carol = @@ -3901,7 +4016,7 @@ fn the_global_room_feed_carries_a_room_we_never_joined() { watcher.join_global_room().expect("join global room"); talker.join_room(room).expect("talker joins a room"); - std::thread::sleep(Duration::from_millis(500)); + fence(&watcher, "e2e_global_talk"); let _ = watcher.take_room_events(); let body = "spoken where nobody is watching"; @@ -3925,7 +4040,7 @@ fn the_global_room_feed_carries_a_room_we_never_joined() { // And leaving the feed stops it: the next message must not arrive. watcher.leave_global_room().expect("leave global room"); - std::thread::sleep(Duration::from_millis(500)); + fence(&watcher, "e2e_global_talk"); let _ = watcher.take_room_events(); talker .say_in_room(room, "after leaving") @@ -3991,7 +4106,16 @@ fn a_room_search_is_answered_by_the_rooms_members() { ); sharer.join_room(room).expect("sharer joins"); searcher.join_room(room).expect("searcher joins"); - std::thread::sleep(Duration::from_millis(750)); + // A room search goes to whoever is in the room when it is sent. + let deadline = Instant::now() + Duration::from_secs(5); + while !searcher + .room_members(room) + .iter() + .any(|member| member == "e2e_rs_sharer") + { + assert!(Instant::now() < deadline, "both should get into the room"); + std::thread::sleep(Duration::from_millis(50)); + } let query = "relic"; searcher.search_room(room, query).expect("room search"); @@ -4028,7 +4152,7 @@ fn shared_interests_make_two_users_similar() { alice .add_dislike("e2e_interest_muzak") .expect("alice dislikes something"); - std::thread::sleep(Duration::from_millis(500)); + fence(&bob, "e2e_like_alice"); // Who else likes this item (code 112). let deadline = Instant::now() + Duration::from_secs(5); @@ -4122,7 +4246,7 @@ fn recommendations_are_returned_for_our_interests() { alice.add_interest(shared).expect("alice likes shared"); bob.add_interest(shared).expect("bob likes shared"); bob.add_interest(other).expect("bob likes other"); - std::thread::sleep(Duration::from_millis(500)); + fence(&bob, "e2e_rec_alice"); let deadline = Instant::now() + Duration::from_secs(8); let mut recommended = Vec::new(); @@ -4256,7 +4380,23 @@ fn a_changed_password_is_what_the_next_login_needs() { client .change_password("second-pw") .expect("change password"); - std::thread::sleep(Duration::from_millis(500)); + // soulfind stores the new password once it has hashed it, and forgets the + // change if our session closes first. The session stays until a login + // with the new password shows it landed; a refused one leaves it alone. + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let mut fresh = + Client::with_settings(server.settings(user, "second-pw")); + fresh.connect().expect("connect with the new password"); + if matches!(fresh.login(), Ok(true)) { + break; + } + assert!( + Instant::now() < deadline, + "the new password must be accepted" + ); + std::thread::sleep(Duration::from_millis(100)); + } drop(client); let mut stale = Client::with_settings(server.settings(user, "first-pw")); @@ -4265,14 +4405,6 @@ fn a_changed_password_is_what_the_next_login_needs() { !matches!(stale.login(), Ok(true)), "the old password must stop working" ); - drop(stale); - - let mut fresh = Client::with_settings(server.settings(user, "second-pw")); - fresh.connect().expect("connect with the new password"); - assert!( - fresh.login().expect("login with the new password"), - "the new password must be accepted" - ); } #[test] @@ -4436,6 +4568,11 @@ fn phrases_the_server_excludes_are_kept_for_our_replies() { // network (code 160). Nicotine+ applies them to the files it offers in a // search reply, not to the searches it sends, and so do we — this pins // that the announced list arrives and is kept for that use. + // It binds listeners, so it waits for the server tests: a port handed + // to a soulfind that is still starting is otherwise up for grabs. + let _gate = SERVER_GATE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let mut phrases = Message::new(); phrases .write_int32(160) @@ -4604,7 +4741,8 @@ fn a_private_room_is_owned_granted_and_revoked() { owner .join_private_room(room) .expect("owner creates the room"); - std::thread::sleep(Duration::from_millis(750)); + // An invite reaches only a user already accepting invitations. + fence(&guest, "e2e_priv_owner"); // The guest is invited, and hears about it (code 139). owner @@ -4723,7 +4861,7 @@ fn a_private_room_someone_else_owns_cannot_be_taken() { owner .join_private_room(room) .expect("owner creates the room"); - std::thread::sleep(Duration::from_millis(750)); + fence(&owner, "e2e_taken_outsider"); // An outsider asking for the same name must be refused (code 1003), not // handed the room. @@ -4776,7 +4914,7 @@ fn an_acknowledged_offline_message_is_not_delivered_twice() { sender .send_private_message("e2e_ack_recipient", body) .expect("send to an offline user"); - std::thread::sleep(Duration::from_millis(500)); + fence(&sender, "e2e_ack_recipient"); let received_once = |label: &str| -> bool { let mut client = @@ -4794,7 +4932,9 @@ fn an_acknowledged_offline_message_is_not_delivered_twice() { std::thread::sleep(Duration::from_millis(100)); } } - std::thread::sleep(Duration::from_millis(500)); + // The acknowledgement goes out ahead of the message it answers, so + // once the server has handled what followed, it has the ack too. + fence(&client, "e2e_ack_sender"); got }; @@ -5348,7 +5488,7 @@ fn a_member_can_give_up_a_private_room_and_an_owner_can_disband_it() { owner .join_private_room(room) .expect("owner creates the room"); - std::thread::sleep(Duration::from_millis(750)); + fence(&guest, "e2e_leave_owner"); owner .add_room_member(room, "e2e_leave_guest") .expect("owner invites the guest"); @@ -5454,14 +5594,42 @@ fn privileges_can_be_handed_to_another_user() { } #[test] -fn a_disconnected_client_gives_its_listener_port_back() { - // A client that goes out of scope must release the port it bound: the - // next one has to be able to advertise the port it was configured with, - // not fall back to an ephemeral one nobody was told about. No server is - // needed — binding the listener happens in connect(), before the dial. +fn a_dropped_client_has_released_its_listener_port_when_drop_returns() { + let _gate = SERVER_GATE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let port = free_port().expect("a port to hold"); - let settings = |port: u16| ClientSettings { - username: "e2e_port_release".to_string(), + + for round in 0..25 { + let mut client = Client::with_settings(ClientSettings { + username: "e2e_port_release_now".to_string(), + password: "pw".to_string(), + server_address: PeerAddress::new("127.0.0.1".to_string(), 1), + enable_listen: true, + listen_port: port, + shared_directories: Vec::new(), + accept_children: false, + version: ClientVersion::default(), + }); + client.connect().expect("bind the listener"); + assert_eq!(client.listen_port(), Some(port), "round {round}"); + drop(client); + + assert!( + std::net::TcpListener::bind(("0.0.0.0", port)).is_ok(), + "round {round}: the port is still held once drop has returned" + ); + } +} + +#[test] +fn a_cancelled_client_never_binds_its_port() { + let _gate = SERVER_GATE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let port = free_port().expect("a port to hold"); + let mut client = Client::with_settings(ClientSettings { + username: "e2e_cancelled_bind".to_string(), password: "pw".to_string(), server_address: PeerAddress::new("127.0.0.1".to_string(), 1), enable_listen: true, @@ -5469,30 +5637,58 @@ fn a_disconnected_client_gives_its_listener_port_back() { shared_directories: Vec::new(), accept_children: false, version: ClientVersion::default(), - }; + }); - let mut first = Client::with_settings(settings(port)); - first.connect().expect("bind the listener"); - assert_eq!(first.listen_port(), Some(port)); - drop(first); + client.cancel_handle().cancel(); - // The listener thread notices the stop the moment it is woken, which the - // disconnect does by dialling it; give it a beat to unwind. - let deadline = Instant::now() + Duration::from_secs(5); - let mut second_port = None; - while Instant::now() < deadline && second_port != Some(port) { - let mut second = Client::with_settings(settings(port)); - second.connect().expect("bind again"); - second_port = second.listen_port(); - if second_port == Some(port) { - break; - } - drop(second); - std::thread::sleep(Duration::from_millis(200)); - } + assert!(client.connect().is_err()); + assert_eq!(client.listen_port(), None); + assert!(std::net::TcpListener::bind(("0.0.0.0", port)).is_ok()); +} + +#[test] +fn a_cancel_ends_a_login_waiting_for_its_verdict_and_nothing_follows_it() { + let server = + std::net::TcpListener::bind("127.0.0.1:0").expect("a silent server"); + let mut client = Client::with_settings(ClientSettings { + username: "e2e_cancelled_login".to_string(), + password: "pw".to_string(), + server_address: PeerAddress::new( + "127.0.0.1".to_string(), + server.local_addr().expect("server address").port(), + ), + enable_listen: false, + listen_port: 0, + shared_directories: Vec::new(), + accept_children: false, + version: ClientVersion::default(), + }); + client.connect().expect("start the session"); + let (mut conn, _) = server.accept().expect("the client dials the server"); + conn.set_read_timeout(Some(Duration::from_secs(5))) + .expect("read timeout"); + let cancel = client.cancel_handle(); + + std::thread::scope(|scope| { + let login = scope.spawn(|| client.login()); + let frame = + read_framed(&mut conn).expect("the login reaches the server"); + assert_eq!(frame.get_message_code(), 1); + + let cancelled_at = Instant::now(); + cancel.cancel(); + assert!(login.join().expect("the login thread").is_err()); + assert!(cancelled_at.elapsed() < Duration::from_secs(5)); + }); + client + .connect_peer("e2e_nobody") + .expect("the actor takes a request"); + drop(client); + + let mut rest = Vec::new(); assert_eq!( - second_port, - Some(port), - "the port should be free again once the first client is dropped" + conn.read_to_end(&mut rest) + .expect("the server sees the session end"), + 0 ); } diff --git a/soulseek-rs-lib/tests/resume_e2e.rs b/soulseek-rs-lib/tests/resume_e2e.rs index 7271c25..89ef307 100644 --- a/soulseek-rs-lib/tests/resume_e2e.rs +++ b/soulseek-rs-lib/tests/resume_e2e.rs @@ -379,18 +379,21 @@ fn a_live_download_pauses_and_resumes_on_the_same_connection() { matches!(status, DownloadStatus::Paused { .. }) }); - // Give a read already in flight one timeout cycle to observe the pause. - std::thread::sleep(Duration::from_millis(1_200)); + // The pause is checked before every read, so once it lands only the read + // already in flight can finish: one 64 KiB buffer at most. Queue more + // than that, which a transfer ignoring the pause would take all of. let stable_len = fs::metadata(&part_path).expect("paused partial").len(); - let queued_while_paused = 8 * 1024; + let in_flight = 64 * 1024; + let queued_while_paused = in_flight + 8 * 1024; peer.write_all(&content[first_chunk..first_chunk + queued_while_paused]) .expect("queue bytes while paused"); peer.flush().expect("flush bytes while paused"); std::thread::sleep(Duration::from_millis(300)); - assert_eq!( - fs::metadata(&part_path).expect("paused partial").len(), - stable_len, - "a paused transfer must stop consuming its socket" + let paused_len = fs::metadata(&part_path).expect("paused partial").len(); + assert!( + paused_len <= stable_len + in_flight as u64, + "a paused transfer must stop consuming its socket: {paused_len} \ + bytes after pausing at {stable_len}" ); assert!(subject.client.resume_download(&control.username, filename)); diff --git a/soulseek-rs/Cargo.toml b/soulseek-rs/Cargo.toml index 1465749..f40af90 100644 --- a/soulseek-rs/Cargo.toml +++ b/soulseek-rs/Cargo.toml @@ -3,6 +3,7 @@ name = "soulseek-rs" version.workspace = true authors.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true @@ -29,6 +30,7 @@ soulseek-rs-lib = { version = "19.0.0", path = "../soulseek-rs-lib" } clap = { version = "4.6.2", features = ["derive", "color", "wrap_help", "env"] } clap_complete = "4.6.7" +clap_mangen = "0.3.3" ratatui = "0.30.2" unicode-width = "0.2" color-eyre = "0.6.5" diff --git a/soulseek-rs/src/SKILL.md b/soulseek-rs/src/SKILL.md index 2dc21fc..387db6d 100644 --- a/soulseek-rs/src/SKILL.md +++ b/soulseek-rs/src/SKILL.md @@ -236,6 +236,9 @@ this skill file into the local agent directories, or remove it: `agent`, completion script where bash, zsh, or fish looks for it, or take it back again: `shell`, `path`, `action`. For a human at a prompt, not for you. +**`completions print `** and **`man`** — the completion script or the +man page on stdout, raw rather than records. For whoever builds a package. + ## Idioms ```bash diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs index 8c5efd0..37b3270 100644 --- a/soulseek-rs/src/cli.rs +++ b/soulseek-rs/src/cli.rs @@ -34,11 +34,16 @@ Exit codes: 0 success 2 usage/config 3 connect or login 4 no results 5 timed out 6 transfer failed 7 session lost 1 unexpected error"; +pub const VERSION: &str = match option_env!("SOULSEEK_RS_VERSION") { + Some(version) => version, + None => env!("CARGO_PKG_VERSION"), +}; + #[derive(Parser, Debug, Default)] #[command( name = "soulseek-rs", author, - version, + version = VERSION, about = "Soulseek client in Rust 🦀", long_about = "Soulseek client in Rust.\n\nRun without a subcommand for the \ interactive TUI, or use a subcommand for a scriptable \ @@ -244,6 +249,9 @@ pub enum Commands { /// Tab completion for bash, zsh, and fish #[command(subcommand)] Completions(CompletionsCommand), + + /// Print the man page to stdout, for packaging + Man, } #[derive(Args, Debug, Default)] @@ -310,6 +318,12 @@ pub enum CompletionsCommand { /// Remove the completion script again Uninstall(CompletionsArgs), + + /// Print the completion script to stdout, for packaging + Print { + #[arg(value_enum)] + shell: Shell, + }, } #[derive(Args, Debug)] diff --git a/soulseek-rs/src/commands/completions.rs b/soulseek-rs/src/commands/completions.rs index d6ca9ef..cd5582c 100644 --- a/soulseek-rs/src/commands/completions.rs +++ b/soulseek-rs/src/commands/completions.rs @@ -8,10 +8,10 @@ use super::{unwritable, xdg}; use crate::cli::{Cli, CompletionsArgs, CompletionsCommand, Shell}; -use crate::output::{CliError, CliResult, CompletionRecord, Out}; +use crate::output::{CliError, CliResult, CompletionRecord, Exit, Out}; use clap::CommandFactory; use directories::UserDirs; -use std::io::Write; +use std::io::{ErrorKind, Write}; use std::path::{Path, PathBuf}; /// The command being completed, and the file name the scripts are written as. @@ -65,6 +65,27 @@ pub fn run(out: &Out, command: &CompletionsCommand) -> CliResult { match command { CompletionsCommand::Install(args) => install(out, &targets(args)?), CompletionsCommand::Uninstall(args) => uninstall(out, &targets(args)?), + CompletionsCommand::Print { shell } => print(&script(*shell)), + } +} + +pub fn man() -> CliResult { + let mut page = Vec::new(); + clap_mangen::Man::new(Cli::command().about("Soulseek client in Rust")) + .render(&mut page) + .expect("a Vec accepts every write"); + print(&page) +} + +fn print(bytes: &[u8]) -> CliResult { + match std::io::stdout().write_all(bytes) { + Err(error) if error.kind() != ErrorKind::BrokenPipe => { + Err(CliError::new( + Exit::Failure, + format!("cannot write stdout: {error}"), + )) + } + _ => Ok(()), } } diff --git a/soulseek-rs/src/commands/mod.rs b/soulseek-rs/src/commands/mod.rs index c5a009e..65192e9 100644 --- a/soulseek-rs/src/commands/mod.rs +++ b/soulseek-rs/src/commands/mod.rs @@ -330,7 +330,8 @@ fn dispatch(ctx: &Ctx, command: Commands) -> CliResult { | Commands::Config(_) | Commands::Shares(_) | Commands::Skills(_) - | Commands::Completions(_) => { + | Commands::Completions(_) + | Commands::Man => { Err(CliError::usage("handled without a session; see main::run")) } } diff --git a/soulseek-rs/src/daemon/server/mod.rs b/soulseek-rs/src/daemon/server/mod.rs index ca7fe3c..201a6d0 100644 --- a/soulseek-rs/src/daemon/server/mod.rs +++ b/soulseek-rs/src/daemon/server/mod.rs @@ -522,7 +522,7 @@ impl Daemon { fn identify(&self) -> AuthResult { AuthResult { protocol: PROTOCOL_VERSION, - daemon_version: env!("CARGO_PKG_VERSION").to_string(), + daemon_version: crate::cli::VERSION.to_string(), username: self.session.username(), } } @@ -532,7 +532,7 @@ impl Daemon { DaemonStatus { username: self.session.username(), server: self.server.clone(), - daemon_version: env!("CARGO_PKG_VERSION").to_string(), + daemon_version: crate::cli::VERSION.to_string(), protocol: PROTOCOL_VERSION, listen_port: self.session.listen_port(), shared_folders: folders, diff --git a/soulseek-rs/src/port_mapping/mod.rs b/soulseek-rs/src/port_mapping/mod.rs index 4787084..f8e67b6 100644 --- a/soulseek-rs/src/port_mapping/mod.rs +++ b/soulseek-rs/src/port_mapping/mod.rs @@ -418,7 +418,18 @@ mod tests { thread::spawn(move || { let mut buf = [0u8; 64]; // Answer many requests (initial map + cleanup unmap + any renews). - while let Ok((n, from)) = socket.recv_from(&mut buf) { + loop { + let (n, from) = match socket.recv_from(&mut buf) { + Ok(received) => received, + // Windows reports a reply that met an already-closed + // client socket on the next receive; the gateway is fine. + Err(e) + if e.kind() == std::io::ErrorKind::ConnectionReset => + { + continue; + } + Err(_) => break, + }; let request = buf[..n].to_vec(); let internal = u16::from_be_bytes([request[4], request[5]]); let lifetime = u32::from_be_bytes([ @@ -465,20 +476,21 @@ mod tests { ); // Dropping the mapper must remove the mapping (a lifetime-0 request). + // A map the gateway was slow to answer is sent again, and that resend + // can arrive first. drop(mapper); - let unmap_req = requests - .recv_timeout(StdDuration::from_secs(3)) - .expect("dropping the mapper should send an unmap request"); - assert_eq!( + let unmapped = std::iter::from_fn(|| { + requests.recv_timeout(StdDuration::from_secs(3)).ok() + }) + .any(|request| { u32::from_be_bytes([ - unmap_req[8], - unmap_req[9], - unmap_req[10], - unmap_req[11] - ]), - 0, - "cleanup should request lifetime 0" - ); + request[8], + request[9], + request[10], + request[11], + ]) == 0 + }); + assert!(unmapped, "cleanup should request lifetime 0"); } /// A minimal in-process UPnP IGD: an HTTP server that answers every SOAP diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs index b0e8b58..0d653b3 100644 --- a/soulseek-rs/src/run.rs +++ b/soulseek-rs/src/run.rs @@ -35,6 +35,14 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { } } + match &cli.command { + Some(Commands::Completions(command)) => { + return crate::commands::completions::run(out, command); + } + Some(Commands::Man) => return crate::commands::completions::man(), + _ => {} + } + let config_path = config_path(&cli); let file_config = match &config_path { Some(path) => crate::persist::config::FileConfig::load(path) @@ -84,9 +92,6 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { resolved.username.as_deref(), ); } - Commands::Completions(ref command) => { - return crate::commands::completions::run(out, command); - } Commands::Config(ConfigCommand::Path) => { return crate::commands::settings::config_path(out, &store); } @@ -502,14 +507,17 @@ fn run_default_tui( // Best-effort: make ourselves reachable behind a home router so // firewalled peers can connect back. Mapped only once the listener is // up, and for the port it really bound. Kept alive for the session. - let _port_mapper = outcome - .client - .listen_port() - .map(crate::port_mapping::PortMapper::spawn); + let bound_port = outcome.client.listen_port(); + let _port_mapper = + bound_port.map(crate::port_mapping::PortMapper::spawn); let store = crate::persist::paths::state_dir() .map(crate::persist::state::StateStore::new); + let listener_fallback = bound_port + .filter(|&bound| listen_port != 0 && bound != listen_port) + .map(|bound| (listen_port, bound)); + let exit = launch_main_tui( terminal, Arc::new(outcome.client), @@ -517,6 +525,7 @@ fn run_default_tui( Duration::from_secs(resolved.search_timeout), store, config_path.clone(), + listener_fallback, ) .map_err(|e| CliError::new(Exit::Failure, e.to_string()))?; @@ -592,6 +601,7 @@ fn run_attached_tui( Duration::from_secs(resolved.search_timeout), None, config_path, + None, ) .map(|_| ()) .map_err(|e| CliError::new(Exit::Failure, e.to_string())) diff --git a/soulseek-rs/src/ui/downloads.rs b/soulseek-rs/src/ui/downloads.rs index 0747f76..24eec71 100644 --- a/soulseek-rs/src/ui/downloads.rs +++ b/soulseek-rs/src/ui/downloads.rs @@ -1,3 +1,4 @@ +use crate::cli::VERSION; use crate::models::DownloadEntry; use crate::ui::{ BYTES_PER_MB, accent_style, dimmed_style, error_style, @@ -12,8 +13,7 @@ use ratatui::{ widgets::Paragraph, }; use soulseek_rs::DownloadStatus; - -const VERSION: &str = env!("CARGO_PKG_VERSION"); +use std::fmt::Write as _; /// Bytes transferred, bytes expected and combined speed across the downloads /// that are actually running — or `None` when none are, so the caller can hide @@ -54,6 +54,7 @@ pub fn render_download_stats( downloads: &[DownloadEntry], active_count: usize, daemon: Option<&str>, + listener_fallback: Option<(u16, u16)>, ) { let completed = downloads .iter() @@ -79,10 +80,14 @@ pub fn render_download_stats( let active = active_progress(downloads); - let title = match daemon { + let mut title = match daemon { Some(endpoint) => format!("Status · daemon {endpoint}"), None => "Status".to_string(), }; + if let Some((configured, bound)) = listener_fallback { + let _ = + write!(title, " · port {configured} in use, listening on {bound}"); + } let block = pane_block(false).title(plain_title(&title, false)); let inner_area = block.inner(area); diff --git a/soulseek-rs/src/ui/login.rs b/soulseek-rs/src/ui/login.rs index 4bf2334..6376ba0 100644 --- a/soulseek-rs/src/ui/login.rs +++ b/soulseek-rs/src/ui/login.rs @@ -6,21 +6,26 @@ //! ([`run_login_flow`]) drives it against a real terminal and client. use crate::ui::{ - GLYPH_CURSOR, accent_style, dimmed_style, error_style, info_style, mask, - pane_block, primary_style, title_style, warning_style, + GLYPH_CURSOR, accent_style, dimmed_style, error_style, get_spinner_char, + info_style, mask, pane_block, primary_style, title_style, warning_style, }; use color_eyre::Result; use ratatui::{ DefaultTerminal, Frame, - crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, poll}, + crossterm::event::{ + self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, poll, + }, layout::{Constraint, Flex, Layout, Rect}, style::Modifier, text::{Line, Span}, widgets::{Clear, Paragraph}, }; -use soulseek_rs::{Client, ClientSettings}; +use soulseek_rs::{CancelHandle, Client, ClientSettings, SoulseekRs}; use std::sync::mpsc::{Receiver, channel}; -use std::time::Duration; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +const RESTART_FLOOR: Duration = Duration::from_secs(1); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LoginField { @@ -36,15 +41,17 @@ pub enum LoginPhase { Connecting, /// The last attempt failed; the message is shown until the next key. Failed(String), + Rejected(String), } /// What the caller should do after feeding a key to the form. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LoginAction { None, - /// Both fields are filled and the user pressed Enter. + /// Both fields are filled and the user pressed Enter or r. Submit, - /// User pressed Esc — abort the whole program. + Stop, + /// User pressed Esc, q or Ctrl-C — abort the whole program. Cancel, } @@ -72,9 +79,33 @@ impl LoginForm { } pub fn handle_key(&mut self, key: KeyEvent) -> LoginAction { + if key.code == KeyCode::Char('c') + && key.modifiers.contains(KeyModifiers::CONTROL) + { + return LoginAction::Cancel; + } + match self.phase { - LoginPhase::Connecting => return LoginAction::None, + LoginPhase::Connecting => { + return match key.code { + KeyCode::Enter | KeyCode::Char('r') => LoginAction::Submit, + KeyCode::Esc => { + self.phase = LoginPhase::Editing; + LoginAction::Stop + } + KeyCode::Char('q') => LoginAction::Cancel, + _ => LoginAction::None, + }; + } LoginPhase::Failed(_) => { + self.phase = LoginPhase::Editing; + return match key.code { + KeyCode::Enter | KeyCode::Char('r') => self.submit(), + KeyCode::Esc => LoginAction::Cancel, + _ => LoginAction::None, + }; + } + LoginPhase::Rejected(_) => { // Any key acknowledges the error; the password was wrong (or // the name is taken), so make the user retype it. self.phase = LoginPhase::Editing; @@ -97,20 +128,7 @@ impl LoginForm { }; LoginAction::None } - KeyCode::Enter => { - if !self.username.is_empty() && !self.password.is_empty() { - self.phase = LoginPhase::Connecting; - LoginAction::Submit - } else { - // Move to the first empty field instead of submitting. - self.focused = if self.username.is_empty() { - LoginField::Username - } else { - LoginField::Password - }; - LoginAction::None - } - } + KeyCode::Enter => self.submit(), KeyCode::Backspace => { self.focused_field_mut().pop(); LoginAction::None @@ -123,6 +141,20 @@ impl LoginForm { } } + fn submit(&mut self) -> LoginAction { + if !self.username.is_empty() && !self.password.is_empty() { + self.phase = LoginPhase::Connecting; + return LoginAction::Submit; + } + // Move to the first empty field instead of submitting. + self.focused = if self.username.is_empty() { + LoginField::Username + } else { + LoginField::Password + }; + LoginAction::None + } + const fn focused_field_mut(&mut self) -> &mut String { match self.focused { LoginField::Username => &mut self.username, @@ -142,7 +174,7 @@ pub struct LoginOutcome { } /// Drive the login screen until a login succeeds (`Some`) or the user -/// cancels with Esc (`None`). When both credentials are already known an +/// quits (`None`). When both credentials are already known an /// attempt starts immediately and the form is only shown on failure. pub fn run_login_flow( terminal: &mut DefaultTerminal, @@ -150,27 +182,44 @@ pub fn run_login_flow( initial_username: Option, initial_password: Option, ) -> Result> { - let mut form = LoginForm::new(initial_username); - let mut entered_via_form = false; - let mut attempt: Option>> = - match initial_password { - Some(password) if !form.username.is_empty() => { - form.password = password; - form.phase = LoginPhase::Connecting; - Some(spawn_attempt(make_settings( - form.username.clone(), - form.password.clone(), - ))) - } - _ => None, - }; + let start = |form: &LoginForm, previous: Option>| { + let mut client = Client::with_settings(make_settings( + form.username.clone(), + form.password.clone(), + )); + let cancel = client.cancel_handle(); + Attempt::spawn(previous, cancel, move || { + let outcome = client + .connect() + .map_err(|e| { + LoginPhase::Failed(format!("Failed to connect: {e}")) + }) + .and_then(|()| verdict(client.login())); + outcome.map(|()| client) + }) + }; + let mut form = LoginForm::new(initial_username.clone()); + let mut attempt = match &initial_password { + Some(password) if !form.username.is_empty() => { + form.password.clone_from(password); + form.phase = LoginPhase::Connecting; + Some(start(&form, None)) + } + _ => None, + }; + let mut started = Instant::now(); loop { - terminal.draw(|frame| render(frame, &form))?; - - if let Some(rx) = &attempt { - match rx.try_recv() { + if let Some(current) = &attempt { + let connecting = form.phase == LoginPhase::Connecting; + match current.result.try_recv() { + Ok(_) | Err(std::sync::mpsc::TryRecvError::Disconnected) + if !connecting => {} Ok(Ok(client)) => { + let entered_via_form = initial_username.as_deref() + != Some(form.username.as_str()) + || initial_password.as_deref() + != Some(form.password.as_str()); return Ok(Some(LoginOutcome { client, username: form.username, @@ -178,31 +227,36 @@ pub fn run_login_flow( entered_via_form, })); } - Ok(Err(message)) => { - form.phase = LoginPhase::Failed(message); - attempt = None; - } + Ok(Err(failure)) => form.phase = failure, Err(std::sync::mpsc::TryRecvError::Empty) => {} Err(std::sync::mpsc::TryRecvError::Disconnected) => { form.phase = LoginPhase::Failed("Connection thread died".into()); - attempt = None; } } } + let spinner = + get_spinner_char(started.elapsed().subsec_millis() as usize / 100); + terminal.draw(|frame| render(frame, &form, spinner))?; + if poll(Duration::from_millis(100))? && let Event::Key(key) = event::read()? && key.kind == KeyEventKind::Press { + let restarting = form.phase == LoginPhase::Connecting; match form.handle_key(key) { LoginAction::Cancel => return Ok(None), + LoginAction::Submit + if restarting && started.elapsed() < RESTART_FLOOR => {} LoginAction::Submit => { - entered_via_form = true; - attempt = Some(spawn_attempt(make_settings( - form.username.clone(), - form.password.clone(), - ))); + attempt = Some(start(&form, attempt.take())); + started = Instant::now(); + } + LoginAction::Stop => { + if let Some(current) = &attempt { + current.cancel.cancel(); + } } LoginAction::None => {} } @@ -210,28 +264,58 @@ pub fn run_login_flow( } } -/// Connect and log in on a background thread so the UI stays responsive. -fn spawn_attempt(settings: ClientSettings) -> Receiver> { - let (tx, rx) = channel(); - std::thread::spawn(move || { - let mut client = Client::with_settings(settings); - let result = client - .connect() - .map_err(|e| format!("Failed to connect: {e}")) - .and_then(|()| match client.login() { - Ok(true) => Ok(()), - Ok(false) => Err( - "Login rejected: wrong password, or the username is taken" - .to_string(), - ), - Err(e) => Err(format!("Login failed: {e}")), - }); - let _ = tx.send(result.map(|()| client)); - }); - rx +/// One login attempt, run on a background thread so the UI stays responsive. +struct Attempt { + cancel: CancelHandle, + thread: JoinHandle<()>, + result: Receiver>, } -fn render(frame: &mut Frame, form: &LoginForm) { +impl Attempt { + fn spawn( + previous: Option, + cancel: CancelHandle, + work: impl FnOnce() -> Result + Send + 'static, + ) -> Self { + if let Some(previous) = &previous { + previous.cancel.cancel(); + } + let (tx, result) = channel(); + let own = cancel.clone(); + let thread = std::thread::spawn(move || { + if let Some(previous) = previous { + let _ = previous.thread.join(); + } + if own.is_cancelled() { + return; + } + let outcome = work(); + if !own.is_cancelled() { + let _ = tx.send(outcome); + } + }); + Self { + cancel, + thread, + result, + } + } +} + +fn verdict(login: soulseek_rs::Result) -> Result<(), LoginPhase> { + match login { + Ok(true) => Ok(()), + Ok(false) | Err(SoulseekRs::AuthenticationFailed) => { + Err(LoginPhase::Rejected( + "Login rejected: wrong password, or the username is taken" + .to_string(), + )) + } + Err(e) => Err(LoginPhase::Failed(format!("Login failed: {e}"))), + } +} + +fn render(frame: &mut Frame, form: &LoginForm, spinner: &str) { let area = centered(frame.area(), 52, 12); frame.render_widget(Clear, area); @@ -290,21 +374,32 @@ fn render(frame: &mut Frame, form: &LoginForm) { let status = match &form.phase { LoginPhase::Editing => Paragraph::new(""), LoginPhase::Connecting => Paragraph::new(Line::from(Span::styled( - "Connecting…", + format!("{spinner} Connecting…"), warning_style(), ))), - LoginPhase::Failed(message) => Paragraph::new(Line::from( - Span::styled(message.clone(), error_style()), - )) - .wrap(ratatui::widgets::Wrap { trim: true }), + LoginPhase::Failed(message) | LoginPhase::Rejected(message) => { + Paragraph::new(Line::from(Span::styled( + message.clone(), + error_style(), + ))) + .wrap(ratatui::widgets::Wrap { trim: true }) + } }; frame.render_widget(status, rows[3]); + let keys = match form.phase { + LoginPhase::Connecting => { + "Enter/r: restart · Esc: stop · q/Ctrl-C: quit" + } + LoginPhase::Failed(_) => "Enter/r: retry · Esc: quit", + LoginPhase::Editing | LoginPhase::Rejected(_) => { + "Tab: switch · Enter: log in · Esc: quit" + } + }; frame.render_widget( - Paragraph::new( - "New usernames are registered automatically.\n\ - Tab: switch · Enter: log in · Esc: quit", - ) + Paragraph::new(format!( + "New usernames are registered automatically.\n{keys}" + )) .style(dimmed_style()), rows[5], ); @@ -323,7 +418,6 @@ fn centered(area: Rect, width: u16, height: u16) -> Rect { #[cfg(test)] mod tests { use super::*; - use ratatui::crossterm::event::KeyModifiers; fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) @@ -335,6 +429,13 @@ mod tests { } } + fn connecting() -> LoginForm { + let mut form = LoginForm::new(Some("alice".into())); + type_str(&mut form, "pw"); + assert_eq!(form.handle_key(key(KeyCode::Enter)), LoginAction::Submit); + form + } + #[test] fn prefilled_username_focuses_password() { let form = LoginForm::new(Some("alice".into())); @@ -402,11 +503,93 @@ mod tests { } #[test] - fn key_after_failure_returns_to_editing_and_clears_password() { - let mut form = LoginForm::new(Some("alice".into())); - type_str(&mut form, "wrong"); - form.handle_key(key(KeyCode::Enter)); - form.phase = LoginPhase::Failed("INVALIDPASS".into()); + fn ctrl_c_quits_from_every_phase_without_typing() { + let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + for phase in [ + LoginPhase::Editing, + LoginPhase::Connecting, + LoginPhase::Failed("unreachable".into()), + LoginPhase::Rejected("INVALIDPASS".into()), + ] { + let mut form = LoginForm::new(None); + form.phase = phase.clone(); + assert_eq!( + form.handle_key(ctrl_c), + LoginAction::Cancel, + "{phase:?}" + ); + assert_eq!(form.username, "", "{phase:?}"); + } + } + + #[test] + fn typing_is_ignored_while_connecting() { + let mut form = connecting(); + let action = form.handle_key(key(KeyCode::Char('x'))); + assert_eq!(action, LoginAction::None); + assert_eq!(form.password, "pw"); + assert_eq!(form.phase, LoginPhase::Connecting); + } + + #[test] + fn escape_while_connecting_stops_and_keeps_the_credentials() { + let mut form = connecting(); + assert_eq!(form.handle_key(key(KeyCode::Esc)), LoginAction::Stop); + assert_eq!(form.phase, LoginPhase::Editing); + assert_eq!(form.password, "pw"); + assert_eq!(form.handle_key(key(KeyCode::Enter)), LoginAction::Submit); + } + + #[test] + fn enter_or_r_while_connecting_restarts() { + for code in [KeyCode::Enter, KeyCode::Char('r')] { + let mut form = connecting(); + assert_eq!(form.handle_key(key(code)), LoginAction::Submit); + assert_eq!(form.phase, LoginPhase::Connecting); + assert_eq!(form.password, "pw"); + } + } + + #[test] + fn q_while_connecting_quits() { + let mut form = connecting(); + assert_eq!( + form.handle_key(key(KeyCode::Char('q'))), + LoginAction::Cancel + ); + } + + #[test] + fn enter_or_r_after_a_failed_connection_retries_with_the_same_password() { + for code in [KeyCode::Enter, KeyCode::Char('r')] { + let mut form = connecting(); + form.phase = LoginPhase::Failed("Login failed: timed out".into()); + assert_eq!(form.handle_key(key(code)), LoginAction::Submit); + assert_eq!(form.phase, LoginPhase::Connecting); + assert_eq!(form.password, "pw"); + } + } + + #[test] + fn other_key_after_a_failed_connection_edits_with_the_password_kept() { + let mut form = connecting(); + form.phase = LoginPhase::Failed("Login failed: timed out".into()); + assert_eq!(form.handle_key(key(KeyCode::Char('x'))), LoginAction::None); + assert_eq!(form.phase, LoginPhase::Editing); + assert_eq!(form.password, "pw"); + } + + #[test] + fn escape_after_a_failed_connection_quits() { + let mut form = connecting(); + form.phase = LoginPhase::Failed("Login failed: timed out".into()); + assert_eq!(form.handle_key(key(KeyCode::Esc)), LoginAction::Cancel); + } + + #[test] + fn key_after_rejection_returns_to_editing_and_clears_password() { + let mut form = connecting(); + form.phase = LoginPhase::Rejected("INVALIDPASS".into()); let action = form.handle_key(key(KeyCode::Char('x'))); assert_eq!(action, LoginAction::None); assert_eq!(form.phase, LoginPhase::Editing); @@ -414,13 +597,116 @@ mod tests { } #[test] - fn keys_ignored_while_connecting() { - let mut form = LoginForm::new(Some("alice".into())); - type_str(&mut form, "pw"); - form.handle_key(key(KeyCode::Enter)); - assert_eq!(form.phase, LoginPhase::Connecting); - let action = form.handle_key(key(KeyCode::Char('x'))); - assert_eq!(action, LoginAction::None); - assert_eq!(form.password, "pw"); + fn a_wrong_password_is_rejected_and_an_unanswered_login_failed() { + assert_eq!(verdict(Ok(true)), Ok(())); + for rejected in [Ok(false), Err(SoulseekRs::AuthenticationFailed)] { + assert!(matches!(verdict(rejected), Err(LoginPhase::Rejected(_)))); + } + for failed in [Err(SoulseekRs::Timeout), Err(SoulseekRs::NotConnected)] + { + assert!(matches!(verdict(failed), Err(LoginPhase::Failed(_)))); + } + } + + struct Held(&'static str, std::sync::mpsc::Sender); + + impl Drop for Held { + fn drop(&mut self) { + let _ = self.1.send(format!("released {}", self.0)); + } + } + + fn cancel_handle() -> CancelHandle { + Client::new("attempt", "pw").cancel_handle() + } + + fn next(seen: &Receiver) -> String { + seen.recv_timeout(Duration::from_secs(5)) + .expect("an attempt event") + } + + #[test] + fn a_restart_builds_nothing_until_the_attempt_it_replaces_has_let_go() { + let (events, seen) = channel(); + let first_events = events.clone(); + let first = Attempt::spawn(None, cancel_handle(), move || { + let _ = first_events.send("started first".to_string()); + std::thread::sleep(Duration::from_millis(100)); + Ok(Held("first", first_events)) + }); + assert_eq!(next(&seen), "started first"); + + let _second = Attempt::spawn(Some(first), cancel_handle(), move || { + let _ = events.send("started second".to_string()); + Err(LoginPhase::Failed("second".into())) + }); + + assert_eq!(next(&seen), "released first"); + assert_eq!(next(&seen), "started second"); + } + + #[test] + fn a_finished_attempt_is_let_go_before_its_replacement_starts() { + let (events, seen) = channel(); + let first_events = events.clone(); + let first = Attempt::spawn(None, cancel_handle(), move || { + Ok(Held("first", first_events)) + }); + let deadline = Instant::now() + Duration::from_secs(5); + while !first.thread.is_finished() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + assert!(first.thread.is_finished()); + + let _second = Attempt::spawn(Some(first), cancel_handle(), move || { + let _ = events.send("started second".to_string()); + Err(LoginPhase::Failed("second".into())) + }); + + assert_eq!(next(&seen), "released first"); + assert_eq!(next(&seen), "started second"); + } + + #[test] + fn an_attempt_superseded_before_it_began_never_runs() { + let (events, seen) = channel(); + let (release, gate) = channel::<()>(); + let first_events = events.clone(); + let first = Attempt::spawn(None, cancel_handle(), move || { + let _ = first_events.send("started first".to_string()); + let _ = gate.recv(); + Err::(LoginPhase::Failed("first".into())) + }); + assert_eq!(next(&seen), "started first"); + + let second_events = events.clone(); + let second = Attempt::spawn(Some(first), cancel_handle(), move || { + let _ = second_events.send("started second".to_string()); + Err(LoginPhase::Failed("second".into())) + }); + let _third = Attempt::spawn(Some(second), cancel_handle(), move || { + let _ = events.send("started third".to_string()); + Err(LoginPhase::Failed("third".into())) + }); + release.send(()).expect("release the first attempt"); + + assert_eq!(next(&seen), "started third"); + } + + #[test] + fn a_result_that_lands_after_a_stop_is_let_go_on_the_attempts_thread() { + let (events, seen) = channel(); + let (release, gate) = channel::<()>(); + let attempt = Attempt::spawn(None, cancel_handle(), move || { + let _ = events.send("started late".to_string()); + let _ = gate.recv(); + Ok(Held("late", events)) + }); + assert_eq!(next(&seen), "started late"); + + attempt.cancel.cancel(); + release.send(()).expect("release the attempt"); + + assert_eq!(next(&seen), "released late"); } } diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 3658f4c..cf5d31e 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -33,6 +33,7 @@ pub struct MainTui { saved_snapshot: Snapshot, /// Which divider the left mouse button is holding, if any. resize_drag: Option, + listener_fallback: Option<(u16, u16)>, } impl MainTui { @@ -53,6 +54,7 @@ impl MainTui { config_path, saved_snapshot: Snapshot::default(), resize_drag: None, + listener_fallback: None, }; tui.restore_persisted_state(); tui @@ -257,9 +259,11 @@ pub fn launch_main_tui( search_timeout: Duration, store: Option, config_path: Option, + listener_fallback: Option<(u16, u16)>, ) -> Result { - let tui = + let mut tui = MainTui::new(client, download_dir, search_timeout, store, config_path); + tui.listener_fallback = listener_fallback; tui.run(terminal) } diff --git a/soulseek-rs/src/ui/main_tui/render/mod.rs b/soulseek-rs/src/ui/main_tui/render/mod.rs index bf16054..98cd750 100644 --- a/soulseek-rs/src/ui/main_tui/render/mod.rs +++ b/soulseek-rs/src/ui/main_tui/render/mod.rs @@ -40,6 +40,7 @@ impl MainTui { &self.state.downloads, self.state.active_downloads_count, daemon.as_deref(), + self.listener_fallback, ); self.render_content(frame, main_chunks[1]); diff --git a/soulseek-rs/src/ui/main_tui/tests/layout.rs b/soulseek-rs/src/ui/main_tui/tests/layout.rs index 8381637..1ea058f 100644 --- a/soulseek-rs/src/ui/main_tui/tests/layout.rs +++ b/soulseek-rs/src/ui/main_tui/tests/layout.rs @@ -21,6 +21,19 @@ fn the_results_pane_has_the_whole_width_to_itself() { assert!(searches < downloads && downloads < info, "{row}"); } +#[test] +fn a_listener_that_fell_back_says_so_in_the_status_bar() { + let mut tui = furnished_tui(); + tui.listener_fallback = Some((2234, 50123)); + + let screen = screen_of(&mut tui); + + assert!( + screen.contains("port 2234 in use, listening on 50123"), + "{screen}" + ); +} + #[test] fn tab_and_shift_tab_walk_the_panes_in_legend_order() { let mut tui = furnished_tui(); diff --git a/soulseek-rs/src/ui/panes/results_pane.rs b/soulseek-rs/src/ui/panes/results_pane.rs index 1b71f59..e111655 100644 --- a/soulseek-rs/src/ui/panes/results_pane.rs +++ b/soulseek-rs/src/ui/panes/results_pane.rs @@ -1,4 +1,5 @@ use super::name_scroll::{column_width, end_offset, scroll_text}; +use crate::cli::VERSION; use crate::models::FileDisplayData; use crate::ui::{ BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes, @@ -13,8 +14,6 @@ use ratatui::{ }; use std::collections::HashSet; -const VERSION: &str = env!("CARGO_PKG_VERSION"); - // The file name and its folder share whatever width the fixed columns leave, // the name getting the larger part: it is what a search is after, and a // folder's tail is a scroll away. diff --git a/soulseek-rs/tests/cli_e2e.rs b/soulseek-rs/tests/cli_e2e.rs index 040e663..6a44531 100644 --- a/soulseek-rs/tests/cli_e2e.rs +++ b/soulseek-rs/tests/cli_e2e.rs @@ -412,6 +412,28 @@ fn installing_completions_is_repeatable_and_reversible() { assert_eq!(action(&again), "absent"); } +#[test] +fn a_package_build_reads_completions_and_the_man_page_from_stdout() { + let dir = Scratch::new("package-build"); + let config = dir.path().join("config.toml"); + std::fs::write(&config, "not = [toml").expect("a config file"); + let broken = config.display().to_string(); + + for (shell, marker) in [ + ("bash", "complete -F"), + ("zsh", "#compdef soulseek-rs"), + ("fish", "complete -c soulseek-rs"), + ] { + let output = run(&["--config", &broken, "completions", "print", shell]); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + assert!(stdout(&output).contains(marker), "{shell} script"); + } + + let man = run(&["--config", &broken, "man"]); + assert_eq!(code(&man), EXIT_OK, "stderr: {}", stderr(&man)); + assert!(stdout(&man).contains(".TH soulseek-rs 1")); +} + // --- wishlist, the half that only touches the config file ------------------ #[test] @@ -884,21 +906,28 @@ fn split_session_flags(args: &[&str]) -> (Vec, Vec) { /// /// `--no-config` is dropped for the obvious reason, and `--quiet` with it: /// these commands report the schedule the *server* chose on stderr, which is -/// the only place a test can see it. +/// the only place a test can see it. Session flags go to the daemon, as in +/// [`cli`]. fn cli_with_config( server: &TestServer, user: &str, config: &Path, args: &[&str], ) -> Output { + let (session_flags, command_args) = match mode() { + Mode::Local => { + (Vec::new(), args.iter().map(|a| (*a).to_string()).collect()) + } + Mode::Daemon => split_session_flags(args), + }; let mut all: Vec = server - .args(user) + .args_with(user, &session_flags) .into_iter() .filter(|arg| arg != "--no-config" && arg != "--quiet") .collect(); all.push("--config".to_string()); all.push(config.to_str().expect("utf-8 path").to_string()); - all.extend(args.iter().map(|a| (*a).to_string())); + all.extend(command_args); let refs: Vec<&str> = all.iter().map(String::as_str).collect(); run(&refs) } @@ -1888,8 +1917,19 @@ fn room_listen_streams_what_is_said_in_the_room() { .spawn() .expect("the binary should run"); - // Give the listener time to join before saying anything. - std::thread::sleep(Duration::from_secs(3)); + // The room announces the listener once the server has it in; a line + // said before that never reaches it. + let deadline = Instant::now() + Duration::from_secs(30); + while !speaker.take_room_events().iter().any(|event| { + matches!( + event, + soulseek_rs::types::RoomEvent::UserJoined { room, username } + if room == "cli_e2e_lobby_b" && username == "cli_e2e_room_reader" + ) + }) { + assert!(Instant::now() < deadline, "the listener never joined"); + std::thread::sleep(Duration::from_millis(50)); + } speaker .say_in_room("cli_e2e_lobby_b", "streamed line") .expect("say"); @@ -1958,6 +1998,11 @@ fn a_private_message_reaches_its_recipient() { fn message_read_streams_incoming_private_messages() { let server = server_or_skip!(); let sender = server.client("cli_e2e_pm_sender", Vec::new()); + // soulfind drops a message to a name it has never seen but keeps one for + // a known user until they log in, so however long the reader takes to + // get online, the line below reaches it. Registered before the reader's + // own session exists: a later login as them would take its place. + drop(server.client("cli_e2e_pm_reader", Vec::new())); settle(); let mut args = server.args("cli_e2e_pm_reader"); @@ -2418,7 +2463,12 @@ fn room_users_lists_who_is_in_the_room() { let server = server_or_skip!(); let resident = server.client("cli_e2e_resident", Vec::new()); resident.join_room("cli_e2e_roster").expect("join"); - settle(); + // The member list reaches the resident once the server has them in. + let deadline = Instant::now() + Duration::from_secs(10); + while resident.room_members("cli_e2e_roster").is_empty() { + assert!(Instant::now() < deadline, "the resident never got in"); + std::thread::sleep(Duration::from_millis(50)); + } let output = cli( &server, diff --git a/soulseek-rs/tests/common/mod.rs b/soulseek-rs/tests/common/mod.rs index d942a9e..82232af 100644 --- a/soulseek-rs/tests/common/mod.rs +++ b/soulseek-rs/tests/common/mod.rs @@ -60,12 +60,16 @@ pub struct Soulfind { } impl Soulfind { + /// soulfind exits if its port is taken between `free_port()` handing it + /// out and soulfind binding it, so a start that fails gets a fresh port. pub fn start() -> Option { - let port = free_port()?; - let db = std::env::temp_dir().join(format!("soulfind-{port}.db")); - let _ = std::fs::remove_file(&db); - let child = Self::spawn(port, &db)?; - Some(Self { child, db, port }) + (0..3).find_map(|_| { + let port = free_port()?; + let db = std::env::temp_dir().join(format!("soulfind-{port}.db")); + let _ = std::fs::remove_file(&db); + let child = Self::spawn(port, &db)?; + Some(Self { child, db, port }) + }) } fn spawn(port: u16, db: &Path) -> Option { @@ -78,8 +82,8 @@ impl Soulfind { let deadline = Instant::now() + Duration::from_secs(10); while Instant::now() < deadline { - // Whoever answers on the port has to be this child: one that - // lost the port and died would otherwise pass as up. + // A child that has exited is never taken as up: soulfind exits + // when its port is gone, and whoever took it may answer instead. if child.try_wait().ok().flatten().is_some() { return None; } diff --git a/website/src/lib/links.ts b/website/src/lib/links.ts index fdb4f37..54385ed 100644 --- a/website/src/lib/links.ts +++ b/website/src/lib/links.ts @@ -29,3 +29,7 @@ export const INSTALL_CMD = `curl -fsSL ${SITE_URL}install.sh | sh` export const NIGHTLY_INSTALL_CMD = 'curl -fsSL https://raw.githubusercontent.com/michel/soulseek-rs/develop/website/public/install.sh | sh -s -- --nightly' + +export const STABLE_DOWNLOAD = `${LINKS.releases}/latest/download` + +export const NIGHTLY_DOWNLOAD = `${LINKS.releases}/download/nightly` diff --git a/website/src/pages/install-routes.tsx b/website/src/pages/install-routes.tsx new file mode 100644 index 0000000..2aa1ffc --- /dev/null +++ b/website/src/pages/install-routes.tsx @@ -0,0 +1,218 @@ +import { ExtLink } from '@/components/ui/ext-link' +import { Code } from '@/components/ui/inline-code' +import { OsIcon, type OsName } from '@/components/ui/os-icon' +import { Terminal, type TermLine } from '@/components/ui/terminal' +import { LINKS, NIGHTLY_DOWNLOAD, STABLE_DOWNLOAD } from '@/lib/links' + +interface InstallRoute { + id: string + os: OsName + best: string + lines: readonly TermLine[] + alt: React.ReactNode +} + +// aislop-ignore-file code-quality/duplicate-block -- data tables: the INSTALLS and LINUX_ROUTES records repeat the field names their interfaces require. The rule matches punctuation, not logic. +const INSTALLS: readonly InstallRoute[] = [ + { + id: 'macos', + os: 'macOS', + best: 'Homebrew', + lines: [ + { t: 'cmd', text: 'brew install michel/tap/soulseek-rs' }, + { t: 'cmd', text: 'soulseek-rs' }, + ], + alt: ( + <> + A prebuilt binary for Apple silicon and Intel, no Rust toolchain needed. With a + toolchain, cargo install --locked soulseek-rs builds from crates.io instead. + + ), + }, + { + id: 'linux', + os: 'Linux', + best: 'your package manager', + lines: [ + { t: 'cm', text: '# Debian and Ubuntu; the others are below' }, + { t: 'cmd', text: `curl -fsSLO ${STABLE_DOWNLOAD}/soulseek-rs-amd64.deb` }, + { t: 'cmd', text: 'sudo apt install ./soulseek-rs-amd64.deb' }, + { t: 'cmd', text: 'soulseek-rs' }, + ], + alt: ( + <> + Fedora, Nix, Homebrew and cargo are{' '} + + listed below + + , each with a nightly. Without any of them, the{' '} + releases page has static musl archives for x86-64 + and arm64 that run on any distribution. + + ), + }, + { + id: 'windows', + os: 'Windows', + best: 'cargo or a prebuilt .exe', + lines: [ + { t: 'cmd', text: 'cargo install --locked soulseek-rs' }, + { t: 'cmd', text: 'soulseek-rs.exe' }, + ], + alt: ( + <> + Cargo needs the MSVC build tools. To skip both, take the{' '} + pc-windows-msvc zip from the{' '} + releases page, unpack it, and put{' '} + soulseek-rs.exe on your PATH. + + ), + }, +] + +interface LinuxRoute { + id: string + name: string + stable: readonly TermLine[] + nightly: readonly TermLine[] + note: React.ReactNode +} + +const LINUX_ROUTES: readonly LinuxRoute[] = [ + { + id: 'deb', + name: 'Debian, Ubuntu, Mint', + stable: [ + { t: 'cmd', text: `curl -fsSLO ${STABLE_DOWNLOAD}/soulseek-rs-amd64.deb` }, + { t: 'cmd', text: 'sudo apt install ./soulseek-rs-amd64.deb' }, + ], + nightly: [ + { t: 'cmd', text: `curl -fsSLO ${NIGHTLY_DOWNLOAD}/soulseek-rs-nightly-amd64.deb` }, + { t: 'cmd', text: 'sudo apt install ./soulseek-rs-nightly-amd64.deb' }, + ], + note: ( + <> + On ARM, arm64 takes the place of amd64. There is no apt + repository behind this, so an update means installing the newer file over the old one. + A nightly sorts above the release it follows, so going back to stable is a downgrade, + and apt asks before doing it. + + ), + }, + { + id: 'rpm', + name: 'Fedora, RHEL', + stable: [{ t: 'cmd', text: `sudo dnf install ${STABLE_DOWNLOAD}/soulseek-rs-x86_64.rpm` }], + nightly: [ + { t: 'cmd', text: `sudo dnf install ${NIGHTLY_DOWNLOAD}/soulseek-rs-nightly-x86_64.rpm` }, + ], + note: ( + <> + On ARM, aarch64 takes the place of x86_64. Run the same + command again to update. dnf fetches the file each time, so a newer nightly at the same + address is picked up. An older file downgrades without asking. + + ), + }, + { + id: 'nix', + name: 'Nix', + stable: [{ t: 'cmd', text: 'nix run github:michel/soulseek-rs/master' }], + nightly: [{ t: 'cmd', text: 'nix run github:michel/soulseek-rs/develop' }], + note: ( + <> + Both build from source. With no branch at all, Nix takes the repository’s + default one, and here that is develop. + + ), + }, + { + id: 'brew', + name: 'Homebrew', + stable: [{ t: 'cmd', text: 'brew install michel/tap/soulseek-rs' }], + nightly: [{ t: 'cmd', text: 'brew install --HEAD michel/tap/soulseek-rs' }], + note: ( + <> + Homebrew installs under /home/linuxbrew, outside the image that a system + update replaces on Bazzite and Silverblue. --HEAD compiles{' '} + develop with Homebrew’s own Rust. + + ), + }, + { + id: 'cargo', + name: 'cargo', + stable: [{ t: 'cmd', text: 'cargo install --locked soulseek-rs' }], + nightly: [ + { + t: 'cmd', + text: 'cargo install --locked --git https://github.com/michel/soulseek-rs --branch develop soulseek-rs', + }, + ], + note: ( + <> + Needs Rust 1.91 or newer. Debian 13 and Ubuntu 24.04 default to an older one, so take + the toolchain from rustup.rs.{' '} + cargo binstall soulseek-rs fetches the release binary and compiles nothing. + + ), + }, +] + +export const InstallCards = () => ( +
+ {INSTALLS.map((route) => ( +
+
+ +

+ {route.os} +

+
+ + {route.best} + + +

+ {route.alt} +

+
+ ))} +
+) + +export const LinuxRoutes = () => ( +
+

+ Linux, by distribution +

+

+ Stable is the latest release, or for Nix the master branch it was cut + from. Nightly is the tip of develop, rebuilt every day. A release download + prints the plain number, 19.0.0. Any Nix build, and a nightly download, + reads 19.0.0+git202609141144.deb846b: the last release, the + commit’s time in UTC, and the commit. A Homebrew --HEAD build + reports HEAD-deb846b, and a cargo install from git sets no + version, so it prints 19.0.0 too. The .deb and .rpm + carry the man page and completions for bash, zsh and fish. +

+ {LINUX_ROUTES.map((route) => ( +
+ + {route.name} + +
+ + +
+

+ {route.note} +

+
+ ))} +
+) diff --git a/website/src/pages/install.tsx b/website/src/pages/install.tsx index f17cf70..9e03fb0 100644 --- a/website/src/pages/install.tsx +++ b/website/src/pages/install.tsx @@ -3,10 +3,10 @@ import { FeatureCard } from '@/components/ui/feature-card' import { ExtLink } from '@/components/ui/ext-link' import { Code } from '@/components/ui/inline-code' import { Cols, PageHead, Prose, Section, SectionHead } from '@/components/ui/layout' -import { OsIcon, type OsName } from '@/components/ui/os-icon' import { Callout } from '@/components/ui/panel' import { Terminal, type TermLine } from '@/components/ui/terminal' import { INSTALL_CMD, LINKS, NIGHTLY_INSTALL_CMD, SITE_URL } from '@/lib/links' +import { InstallCards, LinuxRoutes } from '@/pages/install-routes' const INSTALL_SCRIPT_LINES: readonly TermLine[] = [ { t: 'cm', text: '# latest stable release' }, @@ -36,66 +36,6 @@ const LIB_LINES: TermLine[] = LIB_SRC.split('\n').map((text) => ({ text, })) -interface InstallRoute { - id: string - os: OsName - best: string - lines: readonly TermLine[] - alt: React.ReactNode -} - -// aislop-ignore-file code-quality/duplicate-block -- three findings here, all data tables: the INSTALLS records repeat the field names InstallRoute requires, and the two uninstall `lines` arrays repeat the `{ t, text }` shape around different commands. The rule matches punctuation, not logic. -const INSTALLS: readonly InstallRoute[] = [ - { - id: 'macos', - os: 'macOS', - best: 'Homebrew', - lines: [ - { t: 'cmd', text: 'brew install michel/tap/soulseek-rs' }, - { t: 'cmd', text: 'soulseek-rs' }, - ], - alt: ( - <> - A prebuilt binary for Apple silicon and Intel, no Rust toolchain needed. With a - toolchain, cargo install soulseek-rs builds from crates.io instead. - - ), - }, - { - id: 'linux', - os: 'Linux', - best: 'cargo', - lines: [ - { t: 'cmd', text: 'cargo install soulseek-rs' }, - { t: 'cmd', text: 'soulseek-rs' }, - ], - alt: ( - <> - Homebrew on Linux works the same as on macOS. Without either, the{' '} - releases page has static musl archives for x86-64 and - arm64 that run on any distribution. - - ), - }, - { - id: 'windows', - os: 'Windows', - best: 'cargo or a prebuilt .exe', - lines: [ - { t: 'cmd', text: 'cargo install soulseek-rs' }, - { t: 'cmd', text: 'soulseek-rs.exe' }, - ], - alt: ( - <> - Cargo needs the MSVC build tools. To skip both, take the{' '} - pc-windows-msvc zip from the{' '} - releases page, unpack it, and put{' '} - soulseek-rs.exe on your PATH. - - ), - }, -] - interface PlatformNote { id: string name: string @@ -291,32 +231,12 @@ const Steps = () => ( place.

- Or pick a route per platform. Cargo needs a Rust toolchain from{' '} - rustup.rs; the other routes don’t. + Or pick a route per platform. Cargo needs Rust 1.91 or newer from{' '} + rustup.rs. Nix and Homebrew{' '} + --HEAD also build from source, but bring their own Rust.

-
- {INSTALLS.map((route) => ( -
-
- -

- {route.os} -

-
- - {route.best} - - -

- {route.alt} -

-
- ))} -
+ +

Every route puts the same binary on your PATH. Run soulseek-rs in a terminal and the TUI opens, in a script or a pipe it wants a subcommand @@ -522,11 +442,13 @@ const Config = () => ( ) +// aislop-ignore-file code-quality/duplicate-block -- the two uninstall `lines` arrays repeat the `{ t, text }` shape around different commands. The rule matches punctuation, not logic. const Uninstall = () => (

Three things exist on disk: the binary, the config, and the state, plus the completion - scripts and the agent skill if you asked for those. Nothing else, unless you point{' '} + scripts and the agent skill if you asked for those. A .deb or .rpm also brings a man page + and its own completions, and takes them back when removed. Nothing else, unless you point{' '} --log-file somewhere. No telemetry to opt out of. @@ -539,6 +461,8 @@ const Uninstall = () => ( { t: 'cm', text: '# whichever way you installed it' }, { t: 'cmd', text: 'brew uninstall soulseek-rs' }, { t: 'cmd', text: 'cargo uninstall soulseek-rs' }, + { t: 'cmd', text: 'sudo apt remove soulseek-rs' }, + { t: 'cmd', text: 'sudo dnf remove soulseek-rs' }, { t: 'cm', text: '# script install without brew: the binary itself' }, { t: 'cmd', text: 'rm -f /usr/local/bin/soulseek-rs ~/.local/bin/soulseek-rs' }, { t: 'cm', text: '# brew only: drop the tap it came from' }, @@ -581,7 +505,7 @@ const Uninstall = () => ( export const Install = () => ( <> - One command with the install script, Homebrew or cargo. Build from source if + One command with the install script, your package manager, Homebrew or cargo. Build from source if you’d rather. Or depend on the library and write your own client.