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
- 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.