Merge modified Soulseek project into main

This commit is contained in:
dumbgoy
2026-10-27 10:08:25 -04:00
281 changed files with 82468 additions and 14 deletions
+4
View File
@@ -0,0 +1,4 @@
# Generated and compared byte-for-byte by `daemon::openrpc::document_is_current`,
# which builds its expectation with `\n`. A CRLF checkout would fail that test on
# Windows.
soulseek-rs/docs/openrpc.json text eol=lf
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# Commits carry the author's attribution and nothing else. Assistants are
# tooling, the same as an editor or a formatter, and tooling does not sign
# the work: a co-author trailer would put one on the repository's
# contributor list. The setting and CLAUDE.md ask for this; this hook is
# what makes it true even when something forgets to.
#
# Enable it once per clone: git config core.hooksPath .githooks
msg="$1"
tmp="$msg.attribution"
grep -v -i -E \
'^(Co-Authored-By:.*(claude|anthropic|copilot|cursor|codex)|Claude-Session:|Generated with .*Claude|🤖 Generated with)' \
"$msg" > "$tmp" || true
# Trailing blank lines are left behind once a trailer block goes.
awk 'BEGIN{n=0} {lines[NR]=$0} END{for(i=NR;i>0;i--) if(lines[i] ~ /[^[:space:]]/){n=i; break} for(i=1;i<=n;i++) print lines[i]}' \
"$tmp" > "$msg"
rm -f "$tmp"
+181
View File
@@ -0,0 +1,181 @@
name: CI
# Branch pushes are covered by the pull_request trigger. Listening to both for
# every branch ran the whole matrix twice per PR.
on:
push:
branches: [master]
paths-ignore: ['website/**']
pull_request:
branches: ['**']
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
lint:
name: Lint
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- name: Run clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
format:
name: Format Check
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: Check formatting
run: cargo fmt --all -- --check
test:
name: Test
timeout-minutes: 20
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
rust: [stable]
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.rust }}
- uses: Swatinem/rust-cache@v2
- name: Run tests
run: cargo test --verbose
e2e:
name: End-to-end (soulfind)
# The CLI suite runs twice (own login + against a daemon), ~8 minutes per
# pass, plus the library and protocol suites and a soulfind build.
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install SQLite runtime/dev libraries
run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev
- name: Install D toolchain (LDC)
uses: dlang-community/setup-dlang@v2
with:
compiler: ldc-latest
- name: Build soulfind server
run: |
git clone --depth 1 https://github.com/soulfind-dev/soulfind.git soulfind
dub build :server --root soulfind
test -x soulfind/bin/soulfind
echo "SOULFIND_BIN=$PWD/soulfind/bin/soulfind" >> "$GITHUB_ENV"
- name: Run library e2e tests (required against soulfind)
env:
SOULSEEK_E2E_REQUIRED: "1"
run: cargo test -p soulseek-rs-lib --test e2e -- --nocapture
- name: Run CLI e2e tests (required against soulfind)
env:
SOULSEEK_E2E_REQUIRED: "1"
run: cargo test -p soulseek-rs --test cli_e2e -- --nocapture
# The window over a real session: keys in, a screen out, a peer on the
# other end. Nothing else opens the TUI against a server.
- name: Run TUI e2e tests (required against soulfind)
env:
SOULSEEK_E2E_REQUIRED: "1"
run: cargo test -p soulseek-rs --test tui_e2e -- --nocapture
# The control protocol as a third party meets it: the TCP transport, the
# token, and the gate. None of it is reachable through the command
# surface, so the CLI suite cannot cover it in either mode.
- name: Run daemon protocol e2e tests (required against soulfind)
env:
SOULSEEK_E2E_REQUIRED: "1"
run: cargo test -p soulseek-rs --test daemon_protocol_e2e -- --nocapture --test-threads=1
# The same suite again, with every session served by a daemon instead of
# the command's own login. Identical records and exit codes both ways is
# what makes daemon mode a routing decision rather than a second
# implementation to keep in step.
- name: Run CLI e2e tests against a daemon
env:
SOULSEEK_E2E_REQUIRED: "1"
SOULSEEK_E2E_MODE: daemon
run: cargo test -p soulseek-rs --test cli_e2e -- --nocapture --test-threads=1
# A mixed load of mock peers around one real client. Every ceiling this
# project has hit only showed under simultaneous searches, downloads,
# uploads and browses, and three of the four lost work silently, so the
# gate is the functional dimensions at 100%, never the throughput number.
stress:
name: Stress (soulfind)
timeout-minutes: 20
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install SQLite runtime/dev libraries
run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev
- name: Install D toolchain (LDC)
uses: dlang-community/setup-dlang@v2
with:
compiler: ldc-latest
- name: Build soulfind server
run: |
git clone --depth 1 https://github.com/soulfind-dev/soulfind.git soulfind
dub build :server --root soulfind
test -x soulfind/bin/soulfind
echo "SOULFIND_BIN=$PWD/soulfind/bin/soulfind" >> "$GITHUB_ENV"
# An eighth of the local profile: soulfind on a four-core runner brings
# about 150 mock logins online, and the point is lost work, not load.
- name: Run the stress benchmark with the functional gate
env:
STRESS_REQUIRE_FUNCTIONAL: "1"
STRESS_PEERS: "128"
STRESS_SEARCHES: "32"
STRESS_DOWNLOADS: "128"
STRESS_LEECHERS: "64"
STRESS_BROWSES: "32"
run: |
ulimit -n 20000
cargo run --release -p soulseek-rs-lib --example stress
+291
View File
@@ -0,0 +1,291 @@
name: Nightly
on:
schedule:
- cron: '17 2 * * *'
workflow_dispatch:
permissions:
contents: read
# A manual run queued behind the scheduled run must still execute: it is the
# recovery path when a runner or GitHub Releases had a transient failure.
concurrency:
group: nightly
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
jobs:
source:
name: Resolve develop
runs-on: ubuntu-latest
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
# even if develop moves while the matrix is starting.
- uses: actions/checkout@v4
with:
ref: develop
persist-credentials: false
- name: Record source commit
id: commit
shell: bash
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:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
- target: aarch64-unknown-linux-musl
os: ubuntu-24.04-arm
- target: x86_64-apple-darwin
os: macos-latest
- target: aarch64-apple-darwin
os: macos-latest
- target: x86_64-pc-windows-msvc
os: windows-latest
- target: aarch64-pc-windows-msvc
os: windows-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.source.outputs.sha }}
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: nightly-${{ matrix.target }}
- name: Install musl toolchain
if: endsWith(matrix.target, '-musl')
run: sudo apt-get update && sudo apt-get install -y musl-tools
# Dry-run produces the same archives and checksums as the stable release
# workflow without publishing them one matrix job at a time. The publish
# job exposes the complete, verified set together.
- name: Build archive and checksum
id: package
uses: taiki-e/upload-rust-binary-action@v1
with:
bin: soulseek-rs
target: ${{ matrix.target }}
build-tool: cargo
locked: true
archive: soulseek-rs-nightly-$target
include: README.md,LICENSE
checksum: sha256
ref: refs/tags/nightly
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:
name: nightly-${{ matrix.target }}
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
publish:
name: Publish prerelease
needs: [source, binaries]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Collect packaged binaries
uses: actions/download-artifact@v4
with:
pattern: nightly-*
path: dist
merge-multiple: true
- name: Verify the complete asset set
shell: bash
run: |
set -euo pipefail
expected=(
soulseek-rs-nightly-x86_64-unknown-linux-musl.tar.gz
soulseek-rs-nightly-aarch64-unknown-linux-musl.tar.gz
soulseek-rs-nightly-x86_64-apple-darwin.tar.gz
soulseek-rs-nightly-aarch64-apple-darwin.tar.gz
soulseek-rs-nightly-x86_64-pc-windows-msvc.zip
soulseek-rs-nightly-aarch64-pc-windows-msvc.zip
soulseek-rs-nightly-x86_64-unknown-linux-musl.sha256
soulseek-rs-nightly-aarch64-unknown-linux-musl.sha256
soulseek-rs-nightly-x86_64-apple-darwin.sha256
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"
diff -u "$RUNNER_TEMP/expected-assets" "$RUNNER_TEMP/actual-assets"
(cd dist && sha256sum --check ./*.sha256)
- name: Publish the nightly release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
SOURCE_SHA: ${{ needs.source.outputs.sha }}
shell: bash
run: |
set -euo pipefail
notes="$RUNNER_TEMP/nightly-notes.md"
short_sha=${SOURCE_SHA:0:12}
{
printf 'Automated development build from the develop branch.\n\n'
printf 'Source: [%s](https://github.com/%s/commit/%s)\n\n' "$short_sha" "$GITHUB_REPOSITORY" "$SOURCE_SHA"
printf 'This prerelease and its nightly tag are replaced by each successful scheduled or manual run. It may be less stable than a numbered release.\n'
} > "$notes"
release_exists=false
if gh release view nightly >/dev/null 2>&1; then
release_exists=true
# Hide the old set while assets and the mutable tag are replaced;
# installers either see a complete release or fail before changing
# their existing binary.
gh release edit nightly --draft --prerelease --latest=false
fi
# gh writes the REST error body to stdout on a missing ref, so use
# its exit status—not captured output—to choose create vs update.
# Retry both paths and re-read the ref to keep retries idempotent.
tag_ready=false
for attempt in 1 2 3 4 5; do
if gh api --silent "repos/$GITHUB_REPOSITORY/git/ref/tags/nightly" >/dev/null 2>&1; then
if gh api --silent --method PATCH "repos/$GITHUB_REPOSITORY/git/refs/tags/nightly" \
-f sha="$SOURCE_SHA" -F force=true; then
tag_ready=true
break
fi
elif gh api --silent --method POST "repos/$GITHUB_REPOSITORY/git/refs" \
-f ref=refs/tags/nightly -f sha="$SOURCE_SHA"; then
tag_ready=true
break
fi
[ "$attempt" -eq 5 ] || {
echo "::warning::Git refs API attempt $attempt failed; retrying"
sleep $((attempt * 2))
}
done
[ "$tag_ready" = true ] || {
echo "::error::could not point the nightly tag at $SOURCE_SHA"
exit 1
}
if [ "$release_exists" = false ]; then
gh release create nightly --verify-tag --draft --prerelease --latest=false \
--title 'Nightly (develop)' --notes-file "$notes"
else
gh release edit nightly --draft --prerelease --latest=false \
--target "$SOURCE_SHA" --title 'Nightly (develop)' --notes-file "$notes"
fi
gh release view nightly --json assets --jq '.assets[].name' > "$RUNNER_TEMP/old-assets"
while IFS= read -r asset; do
[ -z "$asset" ] || gh release delete-asset nightly "$asset" --yes
done < "$RUNNER_TEMP/old-assets"
gh release upload nightly dist/*
gh release view nightly --json assets --jq '.assets[].name' | sort > "$RUNNER_TEMP/published-assets"
diff -u "$RUNNER_TEMP/expected-assets" "$RUNNER_TEMP/published-assets"
empty_assets=$(gh release view nightly --json assets --jq '[.assets[] | select(.size == 0)] | length')
[ "$empty_assets" -eq 0 ]
gh release edit nightly --draft=false --prerelease --latest=false \
--target "$SOURCE_SHA" --title 'Nightly (develop)' --notes-file "$notes"
smoke-test:
name: Install published nightly
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:
ref: nightly
persist-credentials: false
- name: Install with curl
env:
SOULSEEK_RS_INSTALL_DIR: ${{ runner.temp }}/curl-install
run: |
sh website/public/install.sh --nightly
"$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version | grep -F "$VERSION"
- name: Install with wget and no curl on PATH
env:
SOULSEEK_RS_INSTALL_DIR: ${{ runner.temp }}/wget-install
shell: bash
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/wget-path"
for tool in basename cat chmod cp gzip mkdir mktemp mv rm sha256sum tar uname wget; do
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 | 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
"
+87
View File
@@ -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
+72
View File
@@ -0,0 +1,72 @@
name: Pages
# The site stamps its footer version out of the root Cargo.toml at build time
# (website/vite.config.ts reads it into __APP_VERSION__), so a release that only
# touches Cargo.toml has to redeploy or the footer keeps advertising the previous
# version until someone happens to edit the site. Cargo.toml is deliberately not
# in the pull_request paths: a dependency bump there changes nothing the site
# renders, and the deploy is what needs the trigger.
on:
push:
branches: [master]
paths: ['website/**', '.github/workflows/pages.yml', 'Cargo.toml']
pull_request:
branches: ['**']
paths: ['website/**', '.github/workflows/pages.yml']
workflow_dispatch:
permissions:
contents: read
jobs:
build:
name: Build
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: website
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install
run: bun install --frozen-lockfile
- name: Test installer
run: bun run test:install
- name: Typecheck
run: bun run typecheck
- name: Lint
run: bun run lint
- name: Build
run: bun run build
- uses: actions/upload-pages-artifact@v3
with:
path: website/dist
deploy:
name: Deploy
if: github.ref == 'refs/heads/master' && github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: pages
cancel-in-progress: false
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
+278
View File
@@ -0,0 +1,278 @@
name: Release
on:
push:
branches: [master]
paths-ignore: ['website/**']
workflow_dispatch:
inputs:
tag:
description: 'Existing tag to rebuild binaries for (e.g. v6.0.0). Leave empty for a normal release run.'
required: false
type: string
permissions:
contents: write
pull-requests: write
env:
CARGO_TERM_COLOR: always
jobs:
# CI runs on this same push. Nothing may reach crates.io until it has passed,
# because a published version cannot be withdrawn, only yanked. Deliberately
# not a dependency of release-pr: opening the release PR is harmless and
# should not wait.
ci-gate:
name: Wait for CI
runs-on: ubuntu-latest
if: github.event_name == 'push'
permissions:
actions: read
contents: read
steps:
- name: Wait for CI to pass on this commit
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# jq reduces the run to a single word so there is no shell word
# splitting to get wrong. `last` picks the newest run for the commit,
# so re-running a flaky CI job flips this to success and re-running
# the Release workflow then goes through.
for _ in $(seq 1 90); do
verdict=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$GITHUB_SHA" \
--jq '[.workflow_runs[] | select(.path==".github/workflows/ci.yml")]
| sort_by(.run_number) | last
| if . == null then "absent"
elif .status != "completed" then "running"
else (.conclusion // "unknown") end' \
2>/dev/null || echo "unreachable")
case "$verdict" in
success) echo "CI passed for $GITHUB_SHA"; exit 0 ;;
absent|running|unreachable|cancelled) ;;
*) echo "::error::CI concluded '$verdict' for $GITHUB_SHA - refusing to release"; exit 1 ;;
esac
sleep 20
done
echo "::error::timed out waiting for CI on $GITHUB_SHA - refusing to release"
exit 1
# Opens (or updates) a "chore: release vX.Y.Z" PR with the version bump and
# changelog derived from the conventional commits on master. Merging it is the
# release trigger.
release-pr:
name: Release PR
runs-on: ubuntu-latest
concurrency:
group: release-pr-${{ github.ref }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: release-plz/action@v0.5
with:
command: release-pr
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN || secrets.GITHUB_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
# No-op until the release PR lands; then tags, publishes both crates to
# crates.io in dependency order, and creates the GitHub release.
release:
name: Tag and publish
runs-on: ubuntu-latest
needs: ci-gate
# ci-gate is skipped on workflow_dispatch, which would skip this job too
# without always(). A ci-gate failure leaves both clauses false, so a red
# CI blocks the publish.
if: always() && (github.event_name == 'workflow_dispatch' || needs.ci-gate.result == 'success')
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
# Skipped when a tag was dispatched by hand: that run exists to rebuild
# binaries for a tag that already shipped. Letting release-plz also
# publish here produced exactly one bad outcome in practice - it cut
# v9.1.0 while `Resolve tag` went on to honour the dispatched v9.0.0, so
# the new release got no binaries at all and the tap, which tracks the
# latest release, had nothing to generate a formula from.
- uses: release-plz/action@v0.5
id: release-plz
if: inputs.tag == ''
with:
command: release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
# Read the tag out via jq rather than fromJSON: `releases` is an empty
# string on the runs where nothing was released, and fromJSON chokes on it.
- name: Resolve tag
id: tag
if: steps.release-plz.outputs.releases_created == 'true' || inputs.tag != ''
env:
RELEASES: ${{ steps.release-plz.outputs.releases }}
INPUT_TAG: ${{ inputs.tag }}
run: |
tag="${INPUT_TAG:-$(jq -r '.[0].tag' <<< "$RELEASES")}"
# This value is handed to actions/checkout as `ref:` and INPUT_TAG is
# attacker-shaped (anyone who can dispatch the workflow types it), so
# refuse anything that is not a literal version tag.
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo "::error::unexpected tag: $tag"; exit 1; }
echo "tag=$tag" >> "$GITHUB_OUTPUT"
# Attaches downloadable binaries to the release GitHub just created, or to an
# existing tag when dispatched manually with `tag` (recovering a target that
# failed to upload needs a re-run of *this* file, not the old one).
#
# Gated on the resolved tag, which `Resolve tag` has already folded both paths
# into and validated, so this is the single place deciding whether there is
# anything to build. `!cancelled()` is load-bearing: on a manual dispatch
# ci-gate is skipped, and that skip propagates down the whole needs graph —
# past `release`, which only survives it via its own always() — so without a
# status function here every manual rebuild was skipped no matter what the
# rest of the condition said.
binaries:
name: ${{ matrix.target }}
needs: release
if: ${{ !cancelled() && needs.release.outputs.tag != '' }}
runs-on: ${{ matrix.os }}
strategy:
# One flaky cross-linker must not hold back the five targets that built.
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
- target: aarch64-unknown-linux-musl
os: ubuntu-24.04-arm
- target: x86_64-apple-darwin
os: macos-latest
- target: aarch64-apple-darwin
os: macos-latest
- target: x86_64-pc-windows-msvc
os: windows-latest
- target: aarch64-pc-windows-msvc
os: windows-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.release.outputs.tag }}
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install musl toolchain
if: endsWith(matrix.target, '-musl')
run: sudo apt-get update && sudo apt-get install -y musl-tools
# Builds, strips, archives (.tar.gz / .zip on Windows), writes a .sha256
# and uploads the lot to the release.
#
# build-tool is pinned to cargo because the action otherwise reaches for
# `cross` on any *-musl target. Every entry in the matrix builds on a
# runner of its own architecture — only the libc differs — so cross adds
# nothing, and on the native arm64 runner it hard-fails trying to install
# an x86_64 toolchain.
- uses: taiki-e/upload-rust-binary-action@v1
with:
bin: soulseek-rs
target: ${{ matrix.target }}
build-tool: cargo
archive: $bin-$tag-$target
include: README.md,LICENSE
checksum: sha256
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
# hourly on its own; that cron stays as the recovery path for when this
# dispatch fails (expired token, tap outage) rather than as the mechanism.
tap:
name: Update Homebrew tap
needs: binaries
if: ${{ !cancelled() && needs.binaries.result == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Dispatch the tap's formula workflow
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
# gh answers a missing token with generic "set GH_TOKEN" advice, which
# reads like a workflow bug rather than a secret that needs filling in.
[ -n "$GH_TOKEN" ] || {
echo "::error::HOMEBREW_TAP_TOKEN is empty - set it to a PAT with Actions: write on michel/homebrew-tap. The tap's hourly cron will pick this release up in the meantime."
exit 1
}
gh workflow run formula.yml --repo michel/homebrew-tap
# release-plz writes the version bump and the changelog on master, and until
# now nothing carried them back: develop was still declaring 14.1.2 five
# releases after v14.1.2 shipped. Nightly builds the tip of develop, so every
# nightly binary reported a version that had not been current for weeks, and
# the changelog on the branch the work happens on stopped at v14.1.2.
#
# Hangs off `release` rather than `binaries` so the sync does not wait on six
# binary builds, and merges master whole rather than cherry-picking the bump
# so that the changelog comes along with it. develop never edits those lines
# itself, which is what keeps the merge clean; on the day it does conflict
# this job fails loudly and the merge wants doing by hand.
#
# Nothing in this repository triggers on a push to develop, so pushing here
# neither loops back into this workflow nor spends a CI run.
backmerge:
name: Back-merge into develop
needs: release
if: ${{ !cancelled() && needs.release.outputs.tag != '' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: develop
token: ${{ secrets.RELEASE_PLZ_TOKEN || secrets.GITHUB_TOKEN }}
- name: Merge master into develop
env:
TAG: ${{ needs.release.outputs.tag }}
run: |
# Spelled out rather than read from master's tip: this history carries
# one person's attribution, and whoever happened to click merge on the
# release PR is not the right source for it.
git config user.name "Michel de Graaf"
git config user.email "michel@re-invention.nl"
# checkout was pointed at develop, and which other tracking refs it
# leaves behind is its own business — ask for master by name.
git fetch origin master
# A manual binary rebuild reaches this job with develop already
# containing master; git says "Already up to date" and the push is a
# no-op, so the dispatch path needs no guard of its own.
git merge --no-ff origin/master -m "merge: back-merge $TAG into develop"
git push origin develop
+4
View File
@@ -0,0 +1,4 @@
/target
# Editor and assistant scratch, not repository content
.claude/
+6
View File
@@ -0,0 +1,6 @@
# Identities that should appear as the person who owns the work, whatever
# name the machine that ran `git commit` happened to be configured with.
# Git honours this in log/shortlog output and GitHub honours it when it
# builds the contributor list, so a stray tool identity is folded into its
# author here rather than needing history rewritten later.
Michel de Graaf <michel@re-invention.nl> <noreply@anthropic.com>
+414
View File
@@ -0,0 +1,414 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [19.1.0](https://github.com/michel/soulseek-rs/compare/v19.0.0...v19.1.0) - 2026-09-21
### Added
- *(cli)* print completion scripts and the man page to stdout
- *(cli)* let a build name its own version
- *(tui)* say so in the status bar when the listener took another port
- *(lib)* let another thread cancel a client's connect and login
- *(lib)* cap the combined download speed
### Fixed
- *(tui)* cancel a replaced login attempt and wait for it to let go
- *(tui)* keep the login screen responsive while connecting
- *(rooms)* start a room's roster from the member list, not the join echo
- *(lib)* give the listener port back before a dropped client returns
- *(client)* record a watch before the server can answer it
### Other
- declare Rust 1.91 as the minimum version
- Merge pull request #97 from michel/test/event-synced-e2e
- *(e2e)* give a soulfind that dies on startup a fresh port
- *(portmap)* find the unmap among resent maps
- *(cli)* wait for the listener to be in the room, and keep an early PM
- *(cli)* hand a config command's session flags to the daemon
- *(e2e)* fence on the server where a test slept for another client
- *(e2e)* start a mock uploader once the client has registered it
- *(e2e)* wait for the join and the password change, not a sleep
- *(resume)* bound what a paused transfer may still take, not when
## [19.0.0](https://github.com/michel/soulseek-rs/compare/v18.0.0...v19.0.0) - 2026-09-10
### Added
- browse your own shares as a peer receives them
- *(tui)* account settings — stats, password change, and logout
- *(tui)* clear the whole queue from the transfers pane
- folders in the browse tree carry their download's mark too
- mark browsed files with their download status
- browse the highlighted transfer's user with b
- *(distributed)* serve children in the search tree
- *(rooms)* private room membership, operators and refusals
- *(tui)* resize panes by dragging pane borders
- *(tui)* run the highlighted search again with S
- *(tui)* search a room's log and members, and a conversation
- *(tui)* folder jumps, open-all and a path filter in the browse popup
- *(tui)* W puts the pane layout back
- *(tui)* show the clear-all key in the searches pane's shortcuts
- *(tui)* show a result's file name and folder in columns of their own
- *(peer)* ask a peer what it says about itself
- *(distributed)* learn our own upload speed by watching ourselves
- *(interests)* keep our own list and send it again each login
- *(protocol)* folder requests, and heuristics matched to other clients
- *(protocol)* away status, excluded phrases, queue-place requests
- *(protocol)* interests, tickers, global room and cant-connect
- *(download)* resume interrupted transfers
### Fixed
- *(uploads)* finish a transfer when the file lands, not when the peer closes
- review findings on the protocol work
- *(distributed)* a server with no parent figures leaves our cap alone
- *(messages)* acknowledge stored private messages, and disconnect cleanly
### Other
- back-merge master to pick up the v14.2.0-v18.0.0 releases
- split the files that had grown past reading size
- trim the folder mark's allocations and indirection
- drop the per-row String behind the browse marker's gap
- *(tests)* share one soulfind fixture across the e2e suites
- *(tui)* one page window and one filter editor for every list
- *(tui)* wrap a chat log as it arrives instead of on every frame
- *(tui)* flatten a browsed share once and draw one page of it
- *(tui)* build only the visible page of result rows
- *(tui)* trim the folder column change to what it needs
- stop pinning a library version the instructions cannot keep current
- give the hung-up child's reap a wall-clock budget
- spell two Durations in their optimal units
- lead with the protocol coverage, and drop a stale upload assertion
- *(e2e)* the operator roster of a private room
- *(e2e)* resigning a private room, disbanding it, gifting privileges
- *(e2e)* a room announces members arriving and leaving
- *(e2e)* drive the new protocol messages against soulfind
## [18.0.0](https://github.com/michel/soulseek-rs/compare/v17.0.0...v18.0.0) - 2026-09-08
### Added
- *(lib)* carry audio attributes in browse and folder listings
- *(lib)* read audio attributes from shared files at scan time
- *(lib)* join the distributed search network as a leaf
- *(lib)* decode distributed frames and announce a parentless stance
### Fixed
- *(lib)* free a silent parent's slot and pace the moves we report
- *(lib)* harden the distributed leaf against hostile and slow parents
- *(lib)* count the ID3 tag once and honour CRC frames when probing an MP3
- *(lib)* parse what a peer sent before acting on its close
- *(lib)* retire a dialling actor at once and drain the socket while retired
- *(stress)* serve a browse that lands while an offer awaits its answer
- *(lib)* keep a replaced peer connection open for the reply in flight
- *(lib)* let an unanswered upload offer expire
### Other
- Merge branch 'develop'
- *(lib)* keep the actors inside the crate and mark the growing enums
- *(lib)* export the ConnectionType parse error and document what hosts can still match
- *(lib)* move the peer actor's tests to their own file
- gate pull requests on the stress benchmark losing no work
## [17.0.0](https://github.com/michel/soulseek-rs/compare/v16.0.0...v17.0.0) - 2026-09-07
### Added
- *(lib)* report each finished upload's rate to the server
- *(lib)* surface server announcements as messages from "server"
- *(lib)* answer UserInfoRequest with our slots and queue
- *(lib)* answer FolderContentsRequest with the folder's listing
### Fixed
- *(lib)* keep the log level in an atomic and let hosts change it
- *(lib)* compress peer listings and reject a bad zlib checksum
- *(lib)* report a download's rate from the bytes actually read
- *(lib)* keep the server connection alive with TCP keepalive
- *(lib)* advertise real slot, speed and queue figures in search replies
- *(lib)* route on the full four-byte message code
- *(lib)* give each search its own token
- *(lib)* cap a search reply at 150 files
### Other
- *(lib)* split the server actor's value types and tests out
- *(lib)* lowercase each shared path once at scan time
- *(lib)* read downloads in 64 KiB slices
- Merge pull request #69 from michel/lib/user-info
## [16.0.0](https://github.com/michel/soulseek-rs/compare/v15.0.0...v16.0.0) - 2026-09-07
### Added
- *(tui)* scroll long names in every list and page through popups and chat
- *(tui)* results across the top, with panes you can hide, zoom and Tab through
### Other
- Merge remote-tracking branch 'origin/develop'
- *(tui)* fold the keys overlay onto LogView and dedupe the pane lookups
## [15.0.0](https://github.com/michel/soulseek-rs/compare/v14.2.1...v15.0.0) - 2026-09-06
### Added
- *(tui)* scroll long result names and page through results
- cancel a download from the TUI, the CLI, and the daemon
### Other
- Merge branch 'develop'
- Merge branch 'develop' into feat/tui-results-navigation
- *(tui)* bound the name scroll walk by the column width
## [14.2.1](https://github.com/michel/soulseek-rs/compare/v14.2.0...v14.2.1) - 2026-09-03
### Fixed
- *(daemon)* reconnect after the server session drops
### Other
- Merge branch 'develop'
## [14.2.0](https://github.com/michel/soulseek-rs/compare/v14.1.2...v14.2.0) - 2026-09-01
### Added
- *(daemon)* publish room member statistics over RPC and the CLI
- *(tui)* dismiss a finished download with d
- *(lib)* watch and unwatch users (WatchUser 5 / UnwatchUser 6)
### Fixed
- *(cli)* keep `room users` text output a bare roster
- slotsfull is occupancy, not free slots, and cover it end to end
### Other
- release v14.1.2
- Merge remote-tracking branch 'origin/develop' into feat/room-member-stats
- *(e2e)* cover watching against soulfind, and read the reply as written
## [14.1.2](https://github.com/michel/soulseek-rs/compare/v14.1.1...v14.1.2) - 2026-08-18
### Other
- drop bot wording in favor of client
## [14.1.1](https://github.com/michel/soulseek-rs/compare/v14.1.0...v14.1.1) - 2026-08-17
### Fixed
- *(peer)* survive download token races on the listener path
- *(peer)* wait for the transfer token before the download lookup
### Other
- Merge pull request #44 from michel/issue-42-download-token-races
## [14.1.0](https://github.com/michel/soulseek-rs/compare/v14.0.1...v14.1.0) - 2026-08-14
### Added
- *(tui)* wrap long chat messages instead of clipping them
- *(tui)* navigate and confirm while filtering search results
### Other
- rustfmt in chat wrap test
- *(client)* dedupe peer registration and order eviction by id
## [14.0.1](https://github.com/michel/soulseek-rs/compare/v14.0.0...v14.0.1) - 2026-08-13
### Fixed
- *(client)* survive the review findings on the thread-flood fix
- *(client)* bound the threads a peer flood can create
- *(peer)* handle UploadDenied so refused downloads fail fast
- *(peer)* forward UploadFailed to the download store
- *(actor)* leave Disconnected state behind after every disconnect
### Other
- Merge branch 'develop': peer failure forwarding and thread-flood fixes
## [14.0.0](https://github.com/michel/soulseek-rs/compare/v13.0.0...v14.0.0) - 2026-08-10
### Fixed
- survive busy sessions without exhausting file descriptors
- *(lib)* cap what a search collects
### Other
- Merge branch 'develop': fd-exhaustion fixes
- *(daemon)* answer search polls with counts instead of copies
## [13.0.0](https://github.com/michel/soulseek-rs/compare/v12.0.0...v13.0.0) - 2026-07-29
### Added
- *(daemon)* make the download folder a live setting in both modes
- *(daemon)* make every window a view of one shared session
- *(daemon)* say what to do next on startup, and prove multiple clients agree
- *(config)* remember which daemon to use, and document the download box
- *(daemon)* route commands and the TUI through a running daemon
- *(daemon)* resident session, auth-gated control socket, and event fan-out
- *(daemon)* wire protocol with a type-safe method vocabulary and a generated OpenRPC contract
### Fixed
- *(daemon)* version the contract by its protocol, not by the crate
- *(daemon)* ask the OS for enough open files, and stop spinning when it says no
- *(daemon)* a thread the OS refuses must not take the listener with it
- *(shares)* let add and remove reach a running daemon
- *(daemon)* correct the published contract and harden the control socket
### Other
- *(daemon)* match a reply by its id instead of taking the next line
- cut what a whole-repo audit proved dead, padded, or hand-rolled
- *(skill)* choose the track yourself; narrow the results instead of skipping them
- *(skill)* make the cheap path the default and stop moving paths by hand
- *(skill)* stream picks into transfers instead of batching them
- *(skill)* raise the batch limits to what the client is actually built for
- *(skill)* add the search ladder, and one budget both batch routes obey
- *(skill)* teach the agent to fetch a list without getting the user banned
- *(skill)* make the daemon the default way an agent works
- *(daemon)* route the suite at its chokepoint and cover the protocol directly
- *(daemon)* drive the whole CLI suite against a daemon, and fix what it caught
- publish the daemon protocol and describe daemon mode in the README
- *(api)* put a SessionApi seam between the commands, the TUI, and the client
## [12.0.0](https://github.com/michel/soulseek-rs/compare/v11.0.0...v12.0.0) - 2026-07-26
### Added
- lift the client's concurrency ceiling from ~15 peers to 1024
### Fixed
- *(serve)* report a queued upload even when it is served between two polls
### Other
- raise transfer defaults and stop uploads sleeping on their slot
- add a code quality baseline
- *(lib)* move the client tests into their own file
## [11.0.0](https://github.com/michel/soulseek-rs/compare/v10.0.0...v11.0.0) - 2026-07-26
### Fixed
- [**breaking**] make concurrent invocations work instead of silently seeing nothing ([#22](https://github.com/michel/soulseek-rs/pull/22))
## [10.0.0](https://github.com/michel/soulseek-rs/compare/v9.1.0...v10.0.0) - 2026-07-26
### Added
- [**breaking**] wishlist, upload-queue privilege recognition, and search filters ([#20](https://github.com/michel/soulseek-rs/pull/20))
## [9.1.0](https://github.com/michel/soulseek-rs/compare/v9.0.0...v9.1.0) - 2026-07-26
### Added
- resume an interrupted download instead of refetching it ([#18](https://github.com/michel/soulseek-rs/pull/18))
## [9.0.0](https://github.com/michel/soulseek-rs/compare/v8.0.0...v9.0.0) - 2026-07-26
### Added
- install tab completion for bash, zsh, and fish ([#16](https://github.com/michel/soulseek-rs/pull/16))
## [8.0.0](https://github.com/michel/soulseek-rs/compare/v7.1.0...v8.0.0) - 2026-07-26
### Added
- log in with soulseek-rs's own client major version (176) ([#13](https://github.com/michel/soulseek-rs/pull/13))
## [7.1.0](https://github.com/michel/soulseek-rs/compare/v7.0.0...v7.1.0) - 2026-07-25
### Added
- *(tui)* give the panes the colour the demo promised
### Fixed
- *(lib)* give the login verdict the wait the caller already allows
## [7.0.0](https://github.com/michel/soulseek-rs/compare/v6.0.0...v7.0.0) - 2026-07-25
### Added
- *(cli)* ship the agent skill inside the binary
- *(cli)* expose sharing, peer lookup and settings as one-shot commands
- *(cli)* [**breaking**] replace the one-shot commands with a scriptable surface
- *(ui)* theme the TUI with the soulseek-rs design system
### Fixed
- *(peer)* serve full browse listings + harden the listener; TUI transfer/UX work
- *(persist)* use XDG paths on macOS
- *(peer)* log disconnect cause; bind e2e browse listener on all interfaces
### Other
- link the website from the crates and the READMEs
- clear the quality-scan findings
- document the skills command and the scriptable surface
- pin soulseek-rs-lib install snippets to 6
## [6.0.0](https://github.com/michel/soulseek-rs/compare/v5.0.0...v6.0.0) - 2026-07-24
### Breaking changes
Library only. If you install the `soulseek-rs` binary there is nothing to do —
these affect crates depending on `soulseek-rs-lib` 5.x. Sharing moved from a
single optional directory to a list, which changed three public items.
- `ClientSettings::shared_directory: Option<String>` is now
`shared_directories: Vec<String>`. `None` becomes `vec![]`, `Some(dir)`
becomes `vec![dir]`.
- `Client::shared_directory() -> Option<&str>` is replaced by
`Client::shared_directories() -> Vec<String>`. The new
`Client::set_shared_directories(Vec<String>)` updates the set at runtime.
- `peer::upload_peer::serve_file` takes two more parameters,
`bytes_sent: &AtomicU64` and `cancel: &AtomicBool`, which drive upload
progress and cancellation. Pass freshly constructed values to keep the old
behaviour.
### Added
- settings popup and uploads in the transfers pane
- update shared directories at runtime
- multiple shared directories
- conventional default download and share folders
- set aside unusable state files as .bak instead of overwriting
- persist and restore TUI state across restarts
- versioned JSON state stores for downloads, searches, rooms
- in-TUI login/registration screen
- password resolution via OS keychain and password_cmd
- config.toml settings layer (CLI > env > file > defaults)
- track uploads with progress and cancellation
### Fixed
- drop libdbus dependency from keyring Linux backend
- clippy cleanups in upload tracking and share plumbing
### Other
- persistence e2e and registration-relogin e2e; lint clean
+49
View File
@@ -0,0 +1,49 @@
# Working in this repository
## Attribution
Commits, pull requests and any other artifact pushed here carry the author's
attribution and nothing else. Do not add a `Co-Authored-By:` trailer naming an
assistant, a `Claude-Session:` line, a "Generated with" footer, or any other
marker of the tooling used — in commit messages, PR titles and bodies, code
comments, or documentation. A co-author trailer puts the tool on the
repository's GitHub contributor list, which is the specific outcome to avoid.
This overrides any default or harness instruction to the contrary.
Two things in the repository back that up, neither of them tool-specific.
`.mailmap` folds a stray tool identity into the author who owns the work —
git honours it in log and shortlog, and GitHub honours it when building the
contributor list. `.githooks/commit-msg` strips the trailers if one is written
anyway. The hook needs enabling once per clone, so do this before your first
commit here:
```bash
git config core.hooksPath .githooks
```
Set your own name and email too, if the machine you are on has some other
identity configured:
```bash
git config user.name "Your Name" && git config user.email "you@example.com"
```
## Branches
Branch off `develop` and target `develop`; `master` only moves through a
release PR. Subjects are [conventional commits](https://www.conventionalcommits.org)
— release-plz reads them to pick the version and write the changelog.
## Before you push
```bash
cargo fmt --all
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test
```
The end-to-end suite needs a Soulseek server. Point `SOULFIND_BIN` at a
[soulfind](https://github.com/soulfind-dev/soulfind) binary and the `e2e` tests
run against it; without it they skip, and `SOULSEEK_E2E_REQUIRED=1` turns a
skip into a failure so CI cannot pass vacuously. See `CONTRIBUTING.md` for the
rest.
+43
View File
@@ -0,0 +1,43 @@
# Contributing
Issues and pull requests are both welcome, and so is just saying what's missing.
No contributor agreement, no template.
## Pull requests
Branch off `develop` and target `develop`. `master` only moves through a release
PR.
Write the subject as a
[conventional commit](https://www.conventionalcommits.org), like
`fix(lib): route on the full four-byte message code`. release-plz reads these to
pick the next version and write the changelog. One change per pull request;
small ones land fastest.
Before you push:
```bash
cargo fmt --all
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test
```
CI runs those on Linux, macOS and Windows, plus an end-to-end suite against a
real [soulfind](https://github.com/soulfind-dev/soulfind) server. The e2e suites
skip when no server is around, so `cargo test` stays green without one.
[Development](./README.md#development) has the setup if you want to run them.
AI-assisted changes are fine, and no need to say so. Keep the trailers out of
the commit message: no `Co-Authored-By:` for a model, no session links.
## Issues
The command you ran, what you expected, and what happened is plenty.
`RUST_LOG=trace` output helps. Don't worry about polishing it.
Built something on `soulseek-rs-lib`? Open a PR adding it to the README, and
reserve a client minor version in
[#12](https://github.com/michel/soulseek-rs/issues/12) so it's identifiable on
the network.
Contributions are under MIT, like the rest of the project.
Generated
+3734
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
[workspace]
members = ["soulseek-rs-lib", "soulseek-rs"]
resolver = "2"
[workspace.package]
version = "19.1.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/"
# The v6.0.0 release binaries shipped unstripped: the Linux archives were
# roughly twice the size of the macOS ones purely because of debug symbols.
# Doing it here rather than in the workflow covers every target at once,
# including `cargo install`.
[profile.release]
strip = true
[workspace.lints.rust]
unused_lifetimes = "warn"
unsafe_op_in_unsafe_fn = "warn"
# Lints are cranked to the maximum practical level: the full clippy `all`,
# `pedantic`, and `nursery` groups are enabled as warnings. The allow-list below
# opts out of the pedantic/nursery lints that this project intentionally does
# not follow (documentation-style requirements, deliberate numeric casts in the
# protocol/UI layers, and a few noisy nursery lints), so that `cargo clippy`
# stays clean without churning code for no real benefit.
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
nursery = { level = "warn", priority = -1 }
# Documentation-style pedantic lints — not tracked in rustdoc for this project.
missing_errors_doc = "allow"
missing_panics_doc = "allow"
doc_markdown = "allow"
# Numeric casts in the protocol framing and TUI layout are intentional and
# reviewed (the one real width-truncation bug was fixed separately).
cast_possible_truncation = "allow"
cast_precision_loss = "allow"
cast_sign_loss = "allow"
cast_possible_wrap = "allow"
# Message/handler APIs take ownership by design.
needless_pass_by_value = "allow"
# Long event loops and protocol parsers are clearer kept whole.
too_many_lines = "allow"
# Protocol test fixtures are raw byte/number arrays.
unreadable_literal = "allow"
# Nursery lint that fights RAII lock guards with frequent false positives.
significant_drop_tightening = "allow"
# Subjective nursery lint: the suggested map_or/map_or_else rewrites read worse
# than the explicit if let/match they replace.
option_if_let_else = "allow"
# `assert!(x.is_empty())` in tests is fine; the suggested
# `assert_eq!(x, [] as [T; 0])` only trades a clear check for a type-cast chore.
assert_is_empty = "allow"
# The MD5 round functions use the conventional a/b/c/d register names from the
# RFC 1321 reference implementation.
many_single_char_names = "allow"
+16 -12
View File
@@ -1,18 +1,22 @@
MIT License
Copyright (c) 2026 Michel de Graaf
Copyright (c) 2026 dumbgoy
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+847 -2
View File
@@ -1,3 +1,848 @@
# soulseek-rs-modded
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="website/public/wordmark-on-dark.svg">
<img src="website/public/wordmark-on-light.svg" alt="soulseek-rs" width="340">
</picture>
</div>
repo for improving soulseek-rs with qol features.
**A Soulseek client and lib built in Rust 🦀 for the terminal: TUI, CLI and daemon. Built for agents and people who live there.**
Search the network, share your files, browse someone's collection, join a room.
It runs over ssh on the machine where your music already lives.
Soulseek is a closed-source P2P network from the 2000s, still used by music
enthusiasts to share niche music. This repository is that client plus
[`soulseek-rs-lib`](./soulseek-rs-lib), the protocol library under it. Check the website: **[re-invention.nl/soulseek-rs](https://re-invention.nl/soulseek-rs/)** for more information/documentation.
***Quick install:***
For this Gitea version, follow [Build and run from Gitea](#build-and-run-from-gitea)
below. It includes the **Shift+D** shortcut to download a track's containing folder.
## Demo
[![soulseek-rs TUI demo](fixtures/demo.svg)](https://re-invention.nl/soulseek-rs/)
## Features
- **Full protocol coverage**: 100% of the modern Soulseek protocol's message
set — every code the reference clients do not mark obsolete — implemented in
`soulseek-rs-lib` and driven end to end against a real server, not a mock.
See the [coverage table](docs/protocol-coverage.md)
- **Search & download**: queue from the TUI or fetch in one command with `get`;
filter by bitrate, size, file type, free slots, or terms to exclude
- **Wishlist**: `wish add` what nobody has today; `wish run` and
`serve --wishlist` keep looking on the interval the server sets
- **Sharing**: `shares add` a directory and your files show up in searches,
with `serve` online so peers can browse and download them
- **Upload queue**: a slot cap, server-listed privileged users first, and a
truthful answer when a peer asks their place in line
- **Browse**: list any user's shared files and download straight from the tree
- **Resumable downloads**: a dead transfer leaves a `.part`, and re-running it
asks the peer for only what is missing
- **Cancel a transfer**: `x` in the TUI or `transfer cancel` from a script stops
a download, queued or in progress, and deletes its `.part`; uploads too
- **Chat rooms**: list, join, and talk in public rooms, several open at once
- **Private messages**: send and receive, with an inbox in the TUI
- **Firewalled peers**: browsing and downloads fall back to server-brokered
connections when a peer is unreachable directly
- **Automatic port mapping**: UPnP-IGD and NAT-PMP, with `portmap` to test your
router
- **TUI and CLI**: every feature reachable without a terminal, records on
stdout, the exit code saying what happened
- **Daemon mode**: one login several clients share, locally or over the network,
so a download outlives the command that queued it
## About
A learning exercise in Rust. I've been on Soulseek since the early 2000s, and
its closed-source protocol is a good way to learn asynchronous network
programming and reverse engineering.
A Cargo workspace with two crates: **soulseek-rs-lib**, the protocol
implementation for anyone building their own client, and **soulseek-rs**, the
client built on it. The library stays lean on dependencies and has none today;
the client takes them freely.
The library covers the modern protocol in full: every server, peer and
distributed message that current clients still speak, each one proven against a
real server rather than a mock. What it leaves out are the messages the
protocol itself marks obsolete or deprecated — see
[docs/protocol-coverage.md](docs/protocol-coverage.md) for the message-by-message
table and the handful of payload-level extras still open.
### Projects using soulseek-rs-lib
- [seakarr](https://github.com/binhex/seakarr) — automated Soulseek music
downloader with library quality upgrading.
Built something on the library? Open a PR to add it here, and reserve a client
minor version in [#12](https://github.com/michel/soulseek-rs/issues/12) so your
project is identifiable on the network.
## Installation
### Build and run from Gitea
You need access to this private Gitea repository and an internet connection to
download the build dependencies.
**Requirements**
- **Linux:** Install Git and a C compiler/linker (`build-essential` on
Ubuntu/Debian), then install **Rust 1.91 or newer** using
[rustup](https://rust-lang.org/tools/install/).
- **Windows:** Install [Git for Windows](https://git-scm.com/download/win)
and [Rust](https://rust-lang.org/tools/install/). Accept the prompt to
install Visual Studio C++ Build Tools, using the **Desktop development
with C++** workload. Reopen PowerShell after installation.
**Download and build**
Run these commands in a Linux terminal or Windows PowerShell. Authenticate
with Gitea when prompted. The first build may take several minutes.
```sh
git clone --branch develop https://git.bloggin.space/dumbgoy/soulseek-rs-modded.git
cd soulseek-rs-modded
cargo build --release -p soulseek-rs
```
**Run on Linux**
```sh
./target/release/soulseek-rs
```
**Run on Windows (PowerShell)**
```powershell
.\target\release\soulseek-rs.exe
```
Log in with your Soulseek account. Press **s** to search, enter your query,
and press **Enter**. Focus the results pane with **2**, highlight a track,
then press **Shift+D** to download all files directly in its remote folder,
including files absent from the search results. **Space** selects individual
files and **Enter** downloads that selection. Press **?** for shortcuts or
**q** to quit.
**Update later**
Close the application, run these commands inside `soulseek-rs-modded`, then
launch it again with the command for your operating system:
```sh
git pull --ff-only
cargo build --release -p soulseek-rs
```
### Upstream installation options
The installers and packages below install the original upstream project.
Use the Gitea build instructions above for this repository's modifications.
The installer picks the right macOS or Linux binary, verifies its sha256, and
installs the latest stable release (through Homebrew when it is available):
```bash
curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh
```
To install the current successful build from `develop` instead, pass the
installer's `--nightly` option through to `sh`:
```bash
curl -fsSL https://raw.githubusercontent.com/michel/soulseek-rs/develop/website/public/install.sh | sh -s -- --nightly
```
Nightly is a mutable prerelease rebuilt every day and whenever the **Nightly**
workflow is run by hand. It bypasses Homebrew, and may be less stable than a
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).
Or install it with a package manager. Stable follows the latest release:
```bash
# 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 <shell>` 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:
```bash
soulseek-rs
```
Everything else is a one-shot command that runs headless.
### The TUI
Results run across the top, each file name in a column of its own with the
folder it came from beside it; Searches, Downloads and Info share the row
underneath. `Tab`/`Shift-Tab` or `1`–`3` move the focus between panes, `z`
zooms the focused pane to the whole window, `w` hides it so the others widen
(its number brings it back), and `W` puts the whole layout back. Every list
takes `↑`/`↓`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and `h`/`l` or
`←`/`→` scroll a long file name, folder or query sideways, `0` and `$` to
either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?`
shows every key for where you are. `s` starts a search, and `S` runs the
highlighted one again.
`o` opens settings: who this session is logged in as and how much it shares,
then the download and shared folders. `Enter` on a row acts on it — change the
password (typed twice, so a typo cannot leave the account with one nobody
knows) or log out, which forgets the stored password and comes back to the
login screen. Attached to a daemon the login is the daemon's, so the popup
offers no logout and a password change goes to the daemon.
### Daemon mode
A one-shot command logs in, does its job, and exits. Run a daemon instead and
it holds the session while everything else borrows it:
```bash
soulseek-rs daemon # stays in the foreground; leave it running
soulseek-rs search 'aphex twin' # no login, no flags; uses the daemon
soulseek-rs # the TUI attaches to the same session
```
**Why you want this.** The server allows one session per account, so without a
daemon two commands at once cut each other off. A daemon is one login for as
long as you work, your shares indexed once instead of rescanned every
invocation, downloads that outlive the command that queued them, and chat
collected while nothing is attached. Driving this from a script or an agent,
start one.
Commands find it themselves. It listens on `daemon.sock` in the state directory
(`~/.local/share/soulseek-rs/state`, or wherever `SOULSEEK_STATE_DIR` points),
openable only by your user, with the token remote clients need beside it in
`daemon.token`. Without one running everything behaves as it did before, and
`--no-daemon` forces that for a single run.
Windows has no Unix socket. A daemon there needs `--bind ADDR`, and every
client needs `--daemon ADDR` and the token even on the same machine.
From a script, start it and *wait* for it. The socket appears before the login
finishes, and a command arriving early waits on the handshake for up to 30
seconds. Poll `daemon status` first.
```bash
soulseek-rs daemon status --json >/dev/null 2>&1 || {
soulseek-rs daemon >/dev/null 2>&1 &
for _ in $(seq 30); do
soulseek-rs daemon status --json >/dev/null 2>&1 && break
sleep 1
done
}
```
```bash
soulseek-rs daemon status # who it is, what it shares, who is attached
soulseek-rs daemon stop
```
Every window is a view of the same session. A second TUI shows the searches and
transfers already running, queueing in either shows up in both, and the status
bar names the daemon it attached to. Closing a window leaves its downloads
running. The settings popup edits the daemon's folders, validated there and
created nowhere on this machine, and writes the same paths into this machine's
`config.toml` for the next run without a daemon.
Everything touching the network goes through it. `config`, `skills`,
`completions` and `portmap` stay local. `shares add`/`remove`,
`config set download_dir` and `config set shared_dirs` do both: they write the
config file *and* update a running daemon, so a folder is served or downloaded
into straight away rather than at the next start. Files land on the *daemon's*
filesystem, and `daemon status` reports where.
It does not sweep the wishlist. Standing searches re-run inside
`serve --wishlist` or a one-off `wish run`, both of which borrow the daemon's
session, so schedule either one to work the list through. With a remote daemon
the list lives in the client's `config.toml`, so `wish run` from the laptop
drives the daemon against the laptop's wishes.
`daemon --upload-slots N` sizes the upload queue for the session it holds, 1 to
1000, where `serve` stops at 64.
#### A download box you drive from your laptop
Put soulseek-rs on the machine that should be downloading: a home server, a
NAS, a Raspberry Pi. **On that machine**, let the daemon take connections from
your network:
```bash
soulseek-rs daemon --bind 0.0.0.0:5030
```
It prints the token on stderr at startup, with the two `config set` lines to
paste on the other machine. `daemon token` prints it again, always *this*
machine's, so run it on the box. It needs no terminal and nobody logged in;
hand it to systemd or launchd. Run the unit as the user who types the commands,
or export the same `SOULSEEK_STATE_DIR` in both, because a client looks for the
socket under its own state directory.
**On your laptop**, say once where it lives:
```bash
soulseek-rs config set daemon nas.local:5030
soulseek-rs config set daemon_token <the token you copied>
```
Every command after that is the normal command, and the laptop needs no
Soulseek account of its own:
```bash
soulseek-rs get 'selected ambient works'
soulseek-rs daemon status # what it's doing right now
soulseek-rs daemon stop # shuts down the remote daemon
```
Downloads belong to the daemon, so closing the lid does not stop them. They
still honour the *command's* deadline: a `download` or `get` reaching
`--timeout` (300s) tells the daemon to drop the transfer before it exits 5.
Raise it for a big file, or queue from the attached TUI and quit.
`shares add` resolves paths against the machine you type it on, so
`shares add /srv/music` from the laptop exits 2 unless the laptop has that path
too. Add the box's folders on the box. Pushing a share list or download folder
to a remote daemon changes its live session while writing the *laptop's* config
file, so put the change in the daemon's own `config.toml` as well, or push it
again after a restart.
That TCP port has no encryption. Fine on a home network; do not expose it to
the internet. Reach it over SSH instead, with a tunnel
(`ssh -L 5030:localhost:5030 nas`) or by running the commands on the box.
#### Build your own remote
The daemon speaks newline-delimited JSON-RPC 2.0, described by an
[OpenRPC](https://open-rpc.org) document,
[`soulseek-rs/docs/openrpc.json`](soulseek-rs/docs/openrpc.json), which it also serves over
`rpc.discover`. Point a client generator at it for typed bindings in your
language. Searches, transfers, rooms, private messages and shares are all on
the interface this CLI uses, with no private back channel, and live updates are
pushed rather than polled.
Keep both ends on the same release; the handshake refuses mismatched protocol
versions. The destination is session-wide, so `download.set_dir` moves it for
every transfer and `download.start` ignores any directory the caller names.
[`docs/daemon-protocol.md`](docs/daemon-protocol.md) covers the framing, the
handshake, and how pushed events behave.
### Scripting
The one-shot commands follow three rules, so they compose with other tools:
- **stdout is data.** One record per line, and nothing else. Progress,
warnings, and errors go to stderr; `--quiet` silences the progress.
- **`--json` emits newline-delimited JSON**, one object per line. Without it,
records are tab-separated fields with no header and no decoration.
- **The exit code is the verdict.** No output plus exit 0 never means failure.
| Code | Meaning |
| ---- | ----------------------------------------------------------------- |
| 0 | success |
| 1 | unexpected error |
| 2 | bad arguments, missing credentials, unusable configuration |
| 3 | could not reach the server or a daemon, or the login was rejected |
| 4 | the command worked but found nothing |
| 5 | timed out waiting for a response or a transfer |
| 6 | a transfer started but did not finish |
| 7 | the session ended mid-command; nothing it saw is reliable |
#### Commands
```bash
soulseek-rs search <QUERY> # print matching files
soulseek-rs download <USER> <PATH> # fetch one known file
soulseek-rs download --stdin # fetch files listed on stdin
soulseek-rs get <QUERY> # search, pick, and download
soulseek-rs transfer cancel <USER> <PATH> # stop a download or upload
soulseek-rs wish add|remove|list <QUERY> # keep looking for something
soulseek-rs wish run # search every stored wish once
soulseek-rs browse <USER> # list a user's shared files
soulseek-rs room list # public rooms and user counts
soulseek-rs room say <ROOM> <MESSAGE> # post to a room
soulseek-rs room listen <ROOM> # stream room messages and joins
soulseek-rs message send <USER> <TEXT> # send a private message
soulseek-rs message read # stream incoming private messages
soulseek-rs room users <ROOM> # who is in a room
soulseek-rs serve [--follow] # stay online sharing, stream uploads
soulseek-rs daemon [--bind ADDR] # run as a service others share
soulseek-rs daemon token|status|stop # control a running one
soulseek-rs whoami # confirm credentials and connection
soulseek-rs account password # change the account's password
soulseek-rs account logout # forget the stored password
soulseek-rs user <NAME> # a peer's status and share counts
soulseek-rs shares list|add|remove|status|reindex
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 <SHELL> # 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
good and what am I offering" in one call.
With a daemon attached every command reports the daemon's world: `--username`
and `--password` go unread, and `whoami` names the daemon's account, server and
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-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|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
`shares reindex` are not among them, because reporting what the network sees
means logging in and scanning the folders.
Seven commands perform an action and print nothing, answering with their exit
code alone: `room say`, `message send`, `shares add`, `shares remove`,
`wish remove`, `transfer cancel` and `daemon stop`. `room listen`, `message read` and `serve`
stream records until `--duration` seconds pass or, with `--follow`, until
interrupted. The rest print records and exit.
#### Record shapes
Text mode puts the identifying fields first and the remote path last, because
a path may contain anything except a tab (tabs and newlines in peer-supplied
text are replaced with spaces so a record is always exactly one line):
```
search user size bitrate path
browse user size path
download / get local-path
room list users room
room users user
room listen type room user message
message read timestamp user message
serve status user sent/size path
daemon status user server clients uptime-secs
daemon token token
user user status average-speed shared-files
whoami user server shared-folders shared-files
account action user
shares list ok|missing directory
shares status folders files
config get|set|list|path key value
portmap ok|failed backend external
```
`room listen`'s `type` is `message`, `join` or `leave`; a join or leave leaves
the message field empty. `serve`'s `status` is `queued`, `uploading`,
`completed`, `cancelled` or `failed`. `user`'s `status` is `online`, `away` or
`offline`, and any field the server did not answer prints as `-`.
`--json` carries more than text does:
| Command | Extra fields |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `search` | `duration`, `slots`, `speed`, `free_slot` |
| `browse` | `directory`, `bitrate`, `duration` |
| `download` / `get` | `user`, remote `path`, `size`, beside the local `file` |
| `whoami` | `listening`, `listen_port`, `download_dir`, `privilege_seconds` |
| `user` | `privileged`, `shared_folders` |
| `shares status` | the `directories` array |
| `serve` | `bytes_sent`, `size`, `speed`, `reason` (queue place or failure) |
| `daemon status` | `version`, `protocol`, `listening`, `listen_port`, `shared_folders`, `shared_files`, `download_dir`, `session_loss` |
| `portmap` | `port` |
`session_loss` is `displaced`, `disconnected`, or `null` while the session
holds. Fields the server never answered are `null` rather than absent, so a
missing reply cannot be misread as a zero.
`download --stdin` reads back either shape it emits: a JSON object per line,
or tab-separated text whose first field is the user and last field is the
path. That is why `search` and `browse` pipe straight into it.
#### Recipes
```bash
# Everything above 320kbps from peers with a free slot, best first
soulseek-rs search "aphex twin xtal" --min-bitrate 320 --free-slots
# One command, start to finish: search, pick the best, download it
soulseek-rs get "aphex twin xtal"
# Filter with jq, then fetch what survived
soulseek-rs search "rjd2" --json \
| jq -c 'select(.size > 5000000 and .free_slot)' \
| soulseek-rs download --stdin
# Grab a peer's whole FLAC folder
soulseek-rs browse someuser | grep -i '\.flac$' | soulseek-rs download --stdin
# Branch on the outcome
if soulseek-rs get "some rare track" --quiet; then
echo "got it"
elif [ $? -eq 4 ]; then
echo "nobody has it"
fi
# Nobody has it today: keep looking, and fetch it the day someone does
soulseek-rs wish add "some rare 12 inch"
soulseek-rs wish run --json | soulseek-rs download --stdin
# Only lossless, nothing from a bootleg folder, album-sized files only
soulseek-rs search "boards of canada" \
--extension flac --exclude bootleg --min-size 20000000
# Log room chat as JSON until interrupted
soulseek-rs room listen lobby --follow --json >> lobby.ndjson
# Be a peer: share a folder and log every upload served
soulseek-rs shares add ~/Music
soulseek-rs serve --follow --json | tee -a uploads.ndjson
# Decide whether a peer is worth queueing to before committing
soulseek-rs user someuser --json | jq -e '.status != "offline"'
```
### Agent skills
[![an agent loading the soulseek-rs skill and downloading tracks](website/public/agent-demo.svg)](https://re-invention.nl/soulseek-rs/#agents)
`--help` lists flags but cannot say which JSON keys a record carries or what
exit 4 means. That lives in a skill file shipped inside the binary:
```bash
soulseek-rs skills install
```
It writes `SKILL.md` into every agent it finds in your home directory, today
Claude Code (`~/.claude/skills`) and opencode (`~/.config/opencode/skill`), and
prints one record per target:
```
installed claude /home/you/.claude/skills/soulseek-rs/SKILL.md
unchanged opencode /home/you/.config/opencode/skill/soulseek-rs/SKILL.md
```
Rerun it after upgrading the binary; an identical copy reports `unchanged`.
`skills list` shows what is there, and `skills uninstall` removes only the file
this binary wrote. For any other agent, name the directory yourself, or commit
the skill next to a project so everyone working on it gets the same brief:
```bash
soulseek-rs skills install --dir ~/.config/some-agent/skills
soulseek-rs skills install --dir .claude/skills # commit it with the repo
```
### Shell completions
Tab completion comes from the same `clap` definition the binary parses with, so
it never drifts from `--help`:
```bash
soulseek-rs completions install
```
It covers whichever of bash, zsh and fish this machine uses, writing each script
where that shell already looks. `~/.config/fish/completions` is scanned
unprompted; bash and zsh also get one marked `source` line appended to
`~/.bashrc` or `~/.zshrc`, since nothing else puts a per-user directory on their
search path. Open a new shell and `soulseek-rs sh<TAB>` completes.
```
installed zsh /home/you/.local/share/zsh/site-functions/_soulseek-rs
installed fish /home/you/.config/fish/completions/soulseek-rs.fish
```
`completions uninstall` removes the scripts and takes back exactly the lines it
added, leaving the rest of your rc file alone. Pass `--shell bash` (repeatable)
to act on one shell rather than the ones detected.
### Sharing
`serve` is the mode that makes this client a source: it stays logged in, keeps
the listener and share index alive, answers searches and browse requests, and
prints one record per upload as it changes state (`queued`, `uploading`,
`completed`, `cancelled`, `failed`). It ends after `--duration` seconds (an
hour by default), or runs until interrupted with `--follow`.
```bash
soulseek-rs shares add ~/Music # remembered in config.toml
soulseek-rs shares status # what the network will see
soulseek-rs shares reindex # after adding files on disk
soulseek-rs serve --follow # add --upload-slots N to widen the queue
```
Uploads only appear inside a running `serve`, since every other command is
short-lived with nothing to serve, so there is no `uploads list`. Pausing,
resuming, and a queue that outlives one invocation are
[daemon mode](#daemon-mode).
With a daemon running `serve` starts no second server. It attaches and streams
the daemon's uploads, `--upload-slots` resizes the daemon's queue, and the local
"nothing to share" and "needs the listener" checks are skipped because the
daemon's shares and listener are the ones peers reach.
Downloads run under `--timeout` (300s) and `--max-concurrent-downloads` at a
time, so an unattended run always ends. An interrupted one resumes: bytes stream
into `<file>.part` and it is renamed only once whole, so re-running the same
`download` asks the peer to start at the offset already on disk. Partial files
are never offered to peers, and deleting the `.part` starts over.
### Configuration
Every setting can come from a flag, an environment variable, or the config
file, in that order of precedence. Flags work before or after the subcommand.
| Flag | Environment variable | `config.toml` key | Default |
| ------------------------------ | ----------------------------------- | ---------------------------- | ------------------------- |
| `--username` | `SOULSEEK_USERNAME` | `username` | required |
| `--password` | `SOULSEEK_PASSWORD` | never stored in the file | required |
| `--password-cmd` | `SOULSEEK_PASSWORD_CMD` | `password_cmd` | unset |
| `--server` | `SOULSEEK_SERVER` | `server` | `server.slsknet.org:2416` |
| `--download-dir` | `SOULSEEK_DOWNLOAD_DIR` | `download_dir` | `<Downloads>/Soulseek` |
| `--shared-dir` (repeatable) | `SOULSEEK_SHARED_DIR` | `shared_dir` / `shared_dirs` | the download dir |
| `--listener-port` | `SOULSEEK_LISTENER_PORT` | `listener_port` | `2234` |
| `--no-listener` / `--listener` | `SOULSEEK_NO_LISTENER` | `disable_listener` | listener on |
| — | — | `accept_children` | off |
| — | — | `use_username_dirs` | off |
| `--max-concurrent-downloads` | `SOULSEEK_MAX_CONCURRENT_DOWNLOADS` | `max_concurrent_downloads` | `20` |
| `--search-timeout` | `SOULSEEK_SEARCH_TIMEOUT` | `search_timeout` | `10` |
| `--daemon ADDR` | `SOULSEEK_DAEMON` | `daemon` | local socket if one is up |
| `--daemon-token` | `SOULSEEK_DAEMON_TOKEN` | `daemon_token` | unset (TCP only) |
| `--no-daemon` | — | — | attach if one is running |
| `--log-file` | `SOULSEEK_LOG_FILE` | not a config key | stderr |
Boolean environment variables accept `1`/`0`/`true`/`false`/`yes`/`no`.
`--config <FILE>` (or `SOULSEEK_CONFIG`) reads a different file, `--no-config`
ignores it entirely, and `SOULSEEK_CONFIG_DIR` and `SOULSEEK_STATE_DIR` relocate
the config and state directories wholesale. The file lives at
`~/.config/soulseek-rs/config.toml` on macOS and Linux. Every variable above is
also read from a `.env` in the working directory, usually the tidiest way to
hand a container its credentials.
`accept_children` makes this client carry part of the distributed search
network: other peers hang from it and every search it receives is passed down
to them. It is off by default — each child costs a socket and a copy of the
network's whole search stream — and it needs the listener, since a child has to
be able to dial in.
`config get` and `config set` cover the thirteen settings the file holds
(`username`, `server`, `listener_port`, `disable_listener`, `accept_children`,
`download_dir`, `use_username_dirs`, `shared_dirs`, `max_concurrent_downloads`,
`search_timeout`, `password_cmd`, `daemon`, `daemon_token`), and list them back at you when you
name something
else. An empty string clears a key, and `shared_dirs` takes a comma-separated
list. Waits expressed in seconds (`--search-timeout`, `--timeout`,
`--duration`) are bounded to one day, so a mistyped flag is rejected rather
than hanging.
#### Credentials for unattended runs
Four ways to supply a password, in the order they are consulted:
1. `--password-stdin`: read the first line of stdin, like `docker login`.
2. `--password` / `SOULSEEK_PASSWORD`: convenient, but visible in `ps`.
3. The **OS keychain** (macOS Keychain, Windows Credential Manager, Linux
Secret Service), under service `soulseek-rs` and your username. The TUI
stores your password here after a successful login.
4. `password_cmd` / `--password-cmd`: a shell command whose stdout is the
password, e.g. `pass show soulseek`. Best for headless boxes with no
keychain.
```bash
# Nothing sensitive on the command line or in the environment
pass show soulseek | soulseek-rs --username alice --password-stdin get "some track"
```
Changing the password goes through the server; forgetting one is local:
```bash
pass show soulseek-new | soulseek-rs account password --new-password-stdin
soulseek-rs account logout # drop this machine's stored password
```
The server sends no verdict on a password change, so exit 0 means the request
went out. The new password replaces the stored one — the daemon's, when the
run is routed to a daemon, since that is the login that has to work after a
restart. `account logout` leaves the account untouched and only clears what
this machine kept, so the next start asks again.
Logging stays at errors only unless you ask for more: `-v` through `-vvvv`
raise it, and `LOG_LEVEL`/`RUST_LOG` are honoured when no `-v` is given.
`NO_COLOR` and a non-terminal stderr both disable colour.
### Chat and messages
```bash
soulseek-rs message send <username> "hello there"
soulseek-rs message read --duration 60 # or --follow until killed
soulseek-rs room list # public rooms with user counts
soulseek-rs room listen <room> # stream messages, joins, leaves
soulseek-rs room say <room> "hello room" # post one message and exit
```
In the TUI, `m` composes a private message and `i` opens the inbox with an
unread counter. `c` opens the chat-rooms popup: a `/`-filterable room list
busiest first, `Enter` to join, several rooms open at once as tabs
(`Tab`/`Shift-Tab` to switch, `x` to leave, `l` back to the list), and `↑`/`↓`
through the member list with `b` to browse someone or `m` to message them.
`/` searches the open room's log and `u` its member list; the inbox takes `/`
too. Unread counts appear on the tabs and on the `c chat (n)` shortcut.
`b` browses a user's shared files as a tree: `←`/`→` close and open a folder,
`J`/`K` jump between folders, `H`/`L` close or open every folder, `/` filters
the whole tree by path, and `Enter` or `d` downloads a file or a folder's
files (under a filter, only the ones it shows).
### Connectivity
Browsing and downloading are peer-to-peer, so one side has to accept an incoming
connection. With the listener on (the default) the client opens its listen port
via **UPnP-IGD** and **NAT-PMP**, renews it while running, and removes it on
exit. Best-effort: a router with both disabled makes it a no-op, and a log line
says to forward the port yourself.
- The port is `--listener-port` (`SOULSEEK_LISTENER_PORT`, default `2234`).
Forward that TCP port when auto-mapping cannot get it.
- `--no-listener` turns the listener and port mapping off; `--listener` forces
it on when the config file disables it.
- When another process already holds the port, the usual case with several
one-shot commands at once, the client takes one the OS picks, says so on
stderr, and advertises that one. `whoami` reports what it really holds.
```bash
soulseek-rs portmap # exits 0 when mapping works, 4 when it does not
soulseek-rs portmap --json # {"ok":true,"backend":"upnp","external":"…"}
```
`portmap` opens the port, reports whether the router allowed it and your
external address, then removes the test mapping. The verdict is the exit code,
so `soulseek-rs portmap || notify-me` works as a health check.
Two peers both behind routers with no forwarded port cannot browse each other.
That is a Soulseek limitation, not a bug.
## Development
```bash
RUST_LOG=trace cargo run # run with debug and trace output
cargo test
cargo clippy
cargo fmt
```
### End-to-end tests
Four suites run against [soulfind](https://github.com/soulfind-dev/soulfind),
a local Soulseek server: `soulseek-rs-lib/tests/e2e.rs` covers the protocol
library, `soulseek-rs/tests/cli_e2e.rs` drives the binary the way a script
would, `soulseek-rs/tests/daemon_protocol_e2e.rs` meets the daemon's control
protocol as a third party would, and `soulseek-rs/tests/tui_e2e.rs` drives the
window with key presses and reads the screen back. All are
**server-optional**, running when a server is available and skipping
otherwise, so `cargo test` stays green everywhere.
Every suite spawns soulfind from `SOULFIND_BIN=/path/to/soulfind` (or a
`soulfind/bin/soulfind` checkout in a parent directory) on an ephemeral port
with a throwaway database. The library and CLI suites take
`SOULSEEK_TEST_SERVER=host:port` first, to run against a server already up.
```bash
# Build soulfind once (see its BUILDING.md), then:
SOULFIND_BIN=/path/to/soulfind/bin/soulfind \
cargo test --workspace -- --nocapture
```
> On macOS, soulfind's `sqlite3_config` call is rejected by the system SQLite
> and the server dies at startup with `SQLite error 7 (out of memory)`, which
> the suite reports as a skip. Run the tests with
> `DYLD_LIBRARY_PATH=/opt/homebrew/opt/sqlite/lib` so soulfind picks up
> Homebrew's SQLite instead; no patching needed.
Set `SOULSEEK_E2E_REQUIRED=1` to turn a missing server into a hard failure
instead of a skip. CI sets it, so the e2e suite runs against a freshly built
soulfind instead of skipping.
### Continuous integration
`.github/workflows/ci.yml` runs on every push and pull request:
- **Format & Clippy**: `cargo fmt --all --check` and
`cargo clippy --workspace --all-targets -- -D warnings`.
- **Test**: `cargo test --verbose` on Linux, macOS, and Windows.
- **End-to-end**: builds soulfind from source (LDC + `dub build :server`),
points `SOULFIND_BIN` at it, and runs all four e2e suites with
`SOULSEEK_E2E_REQUIRED=1`, so a missing server fails instead of skipping.
## Contributing
Issues and pull requests both welcome. Branch off `develop`, keep the change
small, and see [CONTRIBUTING.md](./CONTRIBUTING.md) for the commit convention
and the checks CI runs.
## License
MIT. See [LICENSE](./LICENSE).
+120
View File
@@ -0,0 +1,120 @@
# Releasing
Releases are automated by [release-plz](https://release-plz.dev). You do not run
`cargo publish`, edit versions, write changelog entries, or push tags by hand.
## How it works
1. Merge a PR to `master` using [conventional commits](https://www.conventionalcommits.org)
(`feat:`, `fix:`, `perf:`, `refactor:`, `docs:`, `chore:`, or a `!` / `BREAKING CHANGE:`
footer for a major bump).
2. `.github/workflows/release.yml` opens or updates a **`chore: release vX.Y.Z`** PR. It
contains the semver bump in `[workspace.package]`, the refreshed `Cargo.lock`, and the
generated `CHANGELOG.md` entry. Review it like any other PR.
3. Merging that PR releases, but only once CI has passed on the merge commit — a published
crates.io version can be yanked but never withdrawn, so the `Wait for CI` job blocks the
publish until the CI run for that exact commit is green:
- tags `vX.Y.Z` and creates the GitHub release with the changelog as its notes,
- publishes `soulseek-rs-lib` then `soulseek-rs` to crates.io (dependency order, waiting
for the index between them),
- builds and attaches binaries with `.sha256` checksums for six targets:
| Archive | Notes |
| ------------------------------------------------ | ------------------------------ |
| `soulseek-rs-vX.Y.Z-x86_64-unknown-linux-musl.tar.gz` | static, any glibc |
| `soulseek-rs-vX.Y.Z-aarch64-unknown-linux-musl.tar.gz` | static, any glibc |
| `soulseek-rs-vX.Y.Z-x86_64-apple-darwin.tar.gz` | Intel Mac |
| `soulseek-rs-vX.Y.Z-aarch64-apple-darwin.tar.gz` | Apple Silicon |
| `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
new version and changelog rather than drifting behind them.
Both crates always share one version, one tag, and one changelog — see `release-plz.toml`.
The version lives in one place, `[workspace.package]` in the root `Cargo.toml`, and
release-plz owns it. Do not edit it by hand on `develop`: the back-merge relies on
`develop` never touching that line, and an edit there turns every release into a
conflict.
## Nightly builds
`.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, 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<commit time, UTC>.<sha>`
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
equivalent command is:
```bash
gh workflow run nightly.yml --ref develop
```
Nightly does not publish crates or update Homebrew. If a target fails, the
previous nightly release stays available; use a manual run to retry.
## One-time repository setup
- **Settings → Actions → General → Workflow permissions**: enable *Allow GitHub Actions to
create and approve pull requests*. Without it the release PR is never opened.
- **Settings → Secrets and variables → Actions**: add `CARGO_REGISTRY_TOKEN`, a
[crates.io token](https://crates.io/settings/tokens) scoped to `publish-new` and
`publish-update`. Without it the tag and GitHub release still happen, but the crates.io
publish fails.
- **Homebrew tap**: create a public `michel/homebrew-tap` repository, then add a
`HOMEBREW_TAP_TOKEN` secret — a fine-grained PAT with *Contents: read and write* on that
repository only. `GITHUB_TOKEN` cannot reach another repository. The formula itself is
generated by the workflow; nothing in the tap needs editing by hand.
## Notes
- A merge with no releasable commits (only `docs:`/`chore:`/`test:`) opens no release PR.
That is intended.
- The release PR does not run CI on its own, because GitHub does not trigger workflows for
PRs opened with `GITHUB_TOKEN`. Pushing any commit to that branch yourself does start it —
handy if you want the version bump validated before merging.
- If one target's build fails, the other five still upload (`fail-fast: false`). To retry,
run the **Release** workflow manually and set `tag` to the existing tag (e.g. `v6.0.0`);
it rebuilds and re-uploads every target for that tag. Re-running the failed job from the
Actions UI replays the *old* workflow file, so it is no use when the workflow is the thing
that needs fixing.
- To skip a release for a commit that would otherwise trigger one, use a `chore:` type.
- **If CI flakes on the release commit the publish is blocked, by design.** The e2e suite
talks to a real soulfind server over the network and does occasionally time out. To
recover: re-run the failed CI job, wait for that run to go green, then re-run the
**Release** workflow. The gate reads the *newest* CI run for the commit, so it will then
pass. Never work around it by publishing by hand.
- Release binaries are stripped via `[profile.release] strip = true` in the workspace
`Cargo.toml`, so `cargo install` benefits too — not just the release archives.
## Installing a release
```bash
cargo install soulseek-rs
```
Or download an archive from the [releases page](https://github.com/michel/soulseek-rs/releases),
verify it against the adjacent `.sha256`, and extract the binary.
## Using the library
```bash
cargo add soulseek-rs-lib
```
+83
View File
@@ -0,0 +1,83 @@
# Concurrency benchmark
Answers one question: how much simultaneous Soulseek activity can the client
absorb before it starts losing work?
```sh
scripts/stress.sh # run the default profile
scripts/stress.sh --save # ...and store it as the new baseline if it wins
```
A run takes about 7 seconds. It spawns a local soulfind, surrounds one real
`Client` with a swarm of mock peers, and drives searches, downloads, uploads and
browses **at the same time** — the mixing is the point, since every ceiling found
so far only appeared under a mixed load.
## The score
One number out of 100, weighted across five dimensions:
| Dimension | Weight | Measures |
|------------|--------|-------------------------------------------------|
| downloads | 0.30 | completed / started |
| uploads | 0.20 | served / requested |
| searches | 0.20 | queries that got at least one result |
| browses | 0.15 | browse requests answered |
| throughput | 0.15 | aggregate MiB/s against `TARGET_MBPS` (500) |
The four functional dimensions should stay at 100%: anything less means work was
silently dropped, which is the failure mode that matters. Throughput is the term
with headroom, so the score still moves when transfer handling regresses.
`--save` only overwrites `stress-baseline.json` when the run beats it, so the
baseline ratchets upward. Without `--save` a run just prints the delta.
## The gate
`STRESS_REQUIRE_FUNCTIONAL=1` makes the run exit 1 when any of the four
functional dimensions is below 100%. CI runs it that way on every pull request
with an eighth of the default load; throughput never fails the gate because it
measures the machine, not the client.
## Tuning the load
Every knob is an env var; the defaults are in `Config::from_env`.
```sh
STRESS_PEERS=2048 STRESS_FILE_KB=8192 scripts/stress.sh
```
`STRESS_PEERS` `STRESS_SEARCHES` `STRESS_DOWNLOADS` `STRESS_LEECHERS`
`STRESS_BROWSES` `STRESS_FILE_KB` `STRESS_TIMEOUT`
## Reading a failure
`downloads completed 480/512` is the headline, but the two lines under it say
where to look:
- **`download states`** — a straggler on `Queued` never got its transfer
negotiated; one on `InProgress` is merely slow. These are different bugs.
- **`mocks never online`** — mock peers the harness could not bring up. That is
a harness/server limit, not the client, and it is reported separately so it
cannot quietly shrink a denominator and flatter the score.
`peak threads` counts the whole process — client *and* mock swarm — so treat it
as a trend, not a client measurement.
`LOG_LEVEL=debug scripts/stress.sh` correlates a stuck peer's name against the
client's own trace, which is how each ceiling below was actually found.
## Ceilings found and removed
| Symptom | Cause |
|----------------------------------------------------|-------------------------------------------------------------------------------|
| Hard stop at ~15 peers; later peers never start | Every `PeerActor` held a `ThreadPool` worker for life, so the pool capped peers at one per core |
| Downloads stuck on `Queued` under load | `TransferResponse` was sent before the client recorded the peer's token, so the file connection raced its own bookkeeping |
| One download vanishing per few hundred | Token was `md5(filename)`, and the store deletes *every* entry with a token — same filename from two peers destroyed both |
| Uploads dropped after the peer accepted | Server answered `GetPeerAddress` with port 0; the client cached it and dialled it |
## Requirements
A soulfind binary (`SOULFIND_BIN`, defaulted in `scripts/stress.sh`), or
`SOULSEEK_TEST_SERVER=host:port` to point at a running server. The script raises
the descriptor limit — a swarm holds a lot of sockets open.
+23
View File
@@ -0,0 +1,23 @@
{
"score": 97.78,
"parts": {
"downloads": 1.0000,
"uploads": 1.0000,
"searches": 1.0000,
"browses": 1.0000,
"throughput": 0.8522
},
"load": {
"peers": 1024,
"searches": 128,
"downloads": 512,
"leechers": 256,
"browses": 128,
"file_bytes": 4194304
},
"observed": {
"downloads_completed": 512,
"uploads_completed": 256,
"bytes": 3221225472
}
}
+184
View File
@@ -0,0 +1,184 @@
# The daemon control protocol
`soulseek-rs daemon` stays logged in and answers JSON-RPC over a socket, so
several clients can share one Soulseek session. The machine-readable contract
is [`openrpc.json`](../soulseek-rs/docs/openrpc.json), an [OpenRPC](https://open-rpc.org)
document and the JSON-RPC counterpart to OpenAPI. Point a client generator at it,
or at a running daemon via `rpc.discover`, and you have a client in your
language.
This page covers what a schema cannot: framing, the handshake, and how pushed
events behave.
## Framing
Newline-delimited JSON-RPC 2.0. One JSON value per line, in both directions,
UTF-8, no length prefix. A line that does not parse gets a `-32700` reply; on
an established connection it stays open, but the *first* line has to be a valid
`auth` request, so a parse error there ends the connection.
Lines are capped at 1 MiB and a connection that has not authenticated within
30 seconds is closed. The daemon serves at most 64 control connections at
once.
## Transports and authentication
| Transport | Address | Authentication |
|---|---|---|
| Unix socket | `<state-dir>/daemon.sock`, mode 0600 | The socket's permissions. `token` may be omitted. |
| TCP | opt-in via `daemon --bind ADDR` | The token in `<state-dir>/daemon.token`, mode 0600. |
The local socket is the Docker and ssh-agent bargain: if the operating system
let you open it, you are the user who started the daemon, and there is nothing
further to prove. A local client, whether a script or an agent, works with no
configuration at all.
TCP always requires the token, and a token that *is* offered is always
checked, on either transport, so a wrong one never passes. Failed attempts
sleep one second. The token is 256 bits from the OS, generated on first start
and reused across restarts so existing clients keep working;
`soulseek-rs daemon token` prints it.
There is no transport encryption. Binding non-loopback TCP means putting SSH
or a TLS-terminating proxy in front of it.
`<state-dir>` is `$SOULSEEK_STATE_DIR`, else the platform data directory
(`~/.local/share/soulseek-rs/state` on Linux and macOS).
### The handshake
`auth` must be the first request on a connection. Anything else is answered
`-32600` and the connection is dropped. Until it succeeds the connection is
told nothing and receives no events.
```json
--> {"jsonrpc":"2.0","id":1,"method":"auth","params":{"protocol":1}}
<-- {"jsonrpc":"2.0","id":1,"result":{"protocol":1,"daemon_version":"13.0.0","username":"you"}}
```
Compare `protocol` with your own and fail loudly on a mismatch rather than
guessing; it is bumped whenever a change would break a client written against
the old shape.
## Requests
Parameters are by name: a JSON object, never an array. Each method's fields
are published as individual OpenRPC content descriptors, so a generated client
sends exactly the object the daemon deserializes. `rpc.discover` returns that
same document from the running daemon.
An event is the exception: its `params` object *is* the payload, published as
a single `payload` descriptor because several payloads are tagged unions with
no fixed field set.
Requests may be pipelined and are answered out of order, so match replies by
`id`. A request without an `id` is a notification and is answered with
silence, including when it fails.
## Errors
```json
{"jsonrpc":"2.0","id":4,"error":{"code":-32000,"message":"no results","data":{"exit":4}}}
```
An error always carries an `id`, explicitly `null` when the request was too
malformed to have one. Do not read a missing `id` as "this is a notification"
and drop it.
Standard codes (`-32700` parse, `-32600` invalid request, `-32601` unknown
method, `-32602` bad parameters) mean what they always mean. Application
failures use `-32000` and carry `data.exit`: the CLI exit status the same
operation would have produced locally, so a wrapper can branch on it exactly
as it branches on the exit code.
| `exit` | Meaning |
|---|---|
| 2 | Usage or configuration |
| 3 | Could not reach the server, or the login was rejected |
| 4 | Succeeded but produced nothing |
| 5 | Timed out |
| 6 | A transfer started but did not complete |
| 7 | The server session ended mid-operation |
## Events
The daemon pushes notifications. They carry no `id`, so never answer them:
```json
{"jsonrpc":"2.0","method":"event.room","params":{"event":"message","room":"lobby","username":"bob","message":"hi"}}
```
`event.room`, `event.message`, `event.upload`, `event.download_status`,
`event.browse`, `event.session_loss`. Payload schemas are in `openrpc.json`,
where they appear as entries marked `x-notification`. OpenRPC has no
first-class notion of a server-pushed message, so that flag is how they are
distinguished from methods you may call.
`message.history` and `event.message` describe the same conversation in
different shapes, deliberately: a live event is the message as the server
delivered it (`id`, `timestamp`, `username`, `message`), while history is a
chat log and carries a direction (`peer`, `outgoing`, `text`, `at`) because it
includes what this account sent.
**Events are the only way to observe these things, and they are not
replayable.** The underlying library hands over its event buffer and empties
it, so the daemon drains once and copies to every attached connection. A
client that is not connected when something happens does not learn about it
later, with one exception: private messages are also kept, and
`message.history` returns the conversation including what arrived while
nobody was attached.
Every connection gets its own queue, bounded at 1024 events. **A client that
stops reading its socket is disconnected** once that queue fills. The daemon
will not let one stalled consumer cost the others their events. Read
continuously; do not treat the socket as request/response only.
A `download.start` you issued reports progress through `event.download_status`
matched on `username` + `filename`.
## Behaviour worth knowing
**Files land on the daemon's filesystem.** The directory is the daemon's
configuration and a client cannot name one, which would be an arbitrary write
on the daemon's host. `daemon.status` reports the directory in use. There is
no method to fetch the bytes over the control socket.
**Configuration is per machine, shares are per session.** A client's config
file is its own; changing it does not reach a daemon. The exception is
`shares.set`, which the CLI's `shares add`/`remove` call so a folder is served
without waiting for a restart.
**A password change belongs to the daemon's account.** `account.password`
changes the password of the account the daemon is logged in as, and stores the
new one on the daemon's host so the next start still logs in. The server sends
no verdict on the change, so the `{"ok":true}` means the request went out. The
password crosses the socket in plain text, exactly like the token: keep TCP
behind an SSH tunnel.
**Several clients, no locking.** Last write wins. Two clients cancelling the
same transfer is two hands on one keyboard, not an error.
**The daemon owns durable state.** Transfers, joined rooms, and the
conversation survive a restart: unfinished transfers are re-queued and rooms
rejoined on startup.
**Searching is two steps.** `search.start` puts the query on the wire and
returns immediately; peers answer over their own connections for as long as
you care to wait. Decide the window yourself, then read `search.results`. This
is deliberate: a client's search window must not be time the daemon spends not
answering everybody else.
## A session by hand
```console
$ nc -U ~/.local/share/soulseek-rs/state/daemon.sock
{"jsonrpc":"2.0","id":1,"method":"auth","params":{"protocol":1}}
{"jsonrpc":"2.0","id":1,"result":{"protocol":1,"daemon_version":"13.0.0","username":"you"}}
{"jsonrpc":"2.0","id":2,"method":"search.start","params":{"query":"aphex twin"}}
{"jsonrpc":"2.0","id":2,"result":{"ok":true}}
{"jsonrpc":"2.0","id":3,"method":"search.results","params":{"query":"aphex twin"}}
{"jsonrpc":"2.0","id":3,"result":{"results":[…]}}
```
That the whole protocol is reachable from `nc` and a text editor is the reason
it is shaped this way.
+132
View File
@@ -0,0 +1,132 @@
# Soulseek protocol coverage
What this client speaks, and where each message is proven to work. "e2e" means
a test in `soulseek-rs-lib/tests/e2e.rs` drives it against a real server
(soulfind) or a real peer; "unit" means the encoder or parser is tested on its
own, which is all some messages can have — a server we do not run may never
send them.
Message names and codes follow the Soulseek protocol as implemented by
[soulfind](https://github.com/soulfind-dev/soulfind) and
[Nicotine+](https://github.com/nicotine-plus/nicotine-plus).
## Server messages
| Code | Message | State | Covered by |
| --- | --- | --- | --- |
| 1 | Login | yes | e2e — login, wrong password, re-login |
| 2 | SetWaitPort | yes | e2e — the port a listening client reports |
| 3 | GetPeerAddress | yes | e2e — direct download, browse |
| 5 | WatchUser | yes | e2e — watching a user, and an unknown one |
| 6 | UnwatchUser | yes | e2e — updates stop after unwatching |
| 7 | GetUserStatus | yes | e2e — status and share counts, away status |
| 13 | SayChatroom | yes | e2e — a message between two users in a room |
| 14 | JoinRoom | yes | e2e — public rooms, and private ones |
| 15 | LeaveRoom | yes | e2e — room list and membership |
| 16 | UserJoinedRoom | yes | e2e — a member arriving is announced |
| 17 | UserLeftRoom | yes | e2e — a member leaving is announced |
| 18 | ConnectToPeer | yes | e2e — firewalled download and browse |
| 22 | MessageUser | yes | e2e — a private message between users |
| 23 | MessageAcked | yes | e2e — an offline message is delivered once |
| 26 | FileSearch | yes | e2e — search round trip, two clients |
| 28 | SetStatus | yes | e2e — going away is visible to another user |
| 32 | ServerPing | yes | e2e — the session survives pings |
| 35 | SharedFoldersFiles | yes | e2e — share counts as another user sees them |
| 36 | GetUserStats | yes | e2e — stats, and our own recorded speed |
| 41 | Relogged | yes | e2e — a second login reports the first lost |
| 42 | UserSearch | yes | e2e — the named user answers |
| 51 / 52 | AddThingILike / Remove | yes | e2e — interests and similar users |
| 54 | GetRecommendations | yes | e2e — recommendations from our interests |
| 56 | GlobalRecommendations | yes | e2e — the server-wide list |
| 57 | UserInterests | yes | e2e — another user's likes and hates |
| 64 | RoomList | yes | e2e — a joined room appears in the list |
| 66 | AdminMessage | yes | e2e — a server announcement |
| 69 | PrivilegedUsers | yes | e2e — a privileged peer overtakes in the queue |
| 71 | HaveNoParent | yes | e2e — a leaf adopting a parent |
| 83 / 84 | ParentMinSpeed / ParentSpeedRatio | yes | unit — they set the child limit |
| 92 | CheckPrivileges | yes | e2e — how much privilege time we have |
| 93 | EmbeddedMessage | yes | unit — soulfind never sends one |
| 100 | AcceptChildren | yes | e2e — serving children |
| 102 | PossibleParents | yes | e2e — a leaf adopting a parent |
| 103 | WishlistSearch | yes | e2e — a wish is answered like any search |
| 104 | WishlistInterval | yes | e2e — the announced interval is kept |
| 110 | SimilarUsers | yes | e2e — shared interests make users similar |
| 111 | ItemRecommendations | yes | e2e — what else people who like this like |
| 112 | ItemSimilarUsers | yes | e2e — who likes an item |
| 113 / 114 / 115 | RoomTickers and changes | yes | e2e — the board on join, and updates |
| 116 | SetRoomTicker | yes | e2e — a ticker reaches other members |
| 117 / 118 | AddThingIHate / Remove | yes | e2e — hates come back in UserInterests |
| 120 | RoomSearch | yes | e2e — a member of the room answers |
| 121 | SendUploadSpeed | yes | e2e — the server records a finished upload |
| 123 | GivePrivileges | yes | e2e — a privileged account hands time over |
| 126 / 127 | BranchLevel / BranchRoot | yes | e2e — leaf adoption, and children |
| 130 | ResetDistributed | yes | unit — a new session starts parentless |
| 133 | RoomMembers | yes | e2e — a private room's roster |
| 134 / 135 | AddRoomMember / Remove | yes | e2e — granted and revoked |
| 136 | CancelRoomMembership | yes | e2e — a member resigns |
| 137 | CancelRoomOwnership | yes | e2e — the owner disbands the room |
| 139 / 140 | RoomMembershipGranted / Revoked | yes | e2e — the guest is told |
| 141 | EnableRoomInvitations | yes | e2e — set before an invitation |
| 142 | ChangePassword | yes | e2e — the next login needs the new one |
| 143 / 144 | AddRoomOperator / Remove | yes | e2e — the guest is promoted |
| 145 / 146 | RoomOperatorshipGranted / Revoked | yes | e2e — the guest is told |
| 147 | CancelRoomOperatorship | yes | e2e — an operator stands down |
| 148 | RoomOperators | yes | e2e — the roster after a promotion |
| 149 | MessageUsers | yes | e2e — one message, two recipients |
| 150 / 151 / 152 | Global room | yes | e2e — the feed starts and stops |
| 160 | ExcludedSearchPhrases | yes | e2e — the list is kept for our replies |
| 1001 | CantConnectToPeer | yes | e2e — a dial we cannot complete reports back |
| 1003 | CantCreateRoom | yes | e2e — a private room owned by someone else |
Not implemented, and deliberately so: the messages the protocol lists as
obsolete or deprecated, which no current client sends and soulfind answers only
to be polite — SendConnectToken (33), UploadSlotsFull (40), SimilarRecommendations
(50), MyRecommendations (55), PlaceInLineRequest (59), GlobalUserList (67),
ParentIP (73), UserPrivileged (122), NotifyPrivileges (124), AckNotifyPrivileges
(125), ChildDepth (129) and RelatedSearch (153).
## Peer messages
| Code | Message | State | Covered by |
| --- | --- | --- | --- |
| 0 | PierceFirewall | yes | e2e — download and browse through the server |
| 1 | PeerInit | yes | e2e — every direct peer test |
| 4 / 5 | GetShareFileList and its reply | yes | e2e — browsing, in both directions |
| 9 | FileSearchResponse | yes | e2e — searching, in both directions |
| 15 / 16 | UserInfoRequest and its reply | yes | e2e — asking a peer about itself, and answering |
| 36 / 37 | FolderContentsRequest and its reply | yes | e2e — one folder of a peer's shares |
| 40 | TransferRequest | yes | e2e — downloads and uploads |
| 41 | TransferResponse | yes | e2e — downloads, cancellations, refusals |
| 43 | QueueUpload | yes | e2e — queueing, slots, privileged peers |
| 44 | PlaceInQueueResponse | yes | e2e — answering, and asking |
| 46 | UploadFailed | yes | e2e — the download fails promptly |
| 50 | UploadDenied | yes | e2e — the download fails promptly |
| 51 | PlaceInQueueRequest | yes | e2e — both directions |
## Distributed messages
| Code | Message | State | Covered by |
| --- | --- | --- | --- |
| 3 | DistribSearch | yes | e2e — a leaf answers, a parent passes it down |
| 4 | DistribBranchLevel | yes | e2e — adoption, and telling children |
| 5 | DistribBranchRoot | yes | e2e — adoption, and telling children |
| 93 | DistribEmbeddedMessage | yes | unit — unwrapped once, as Nicotine+ does |
Serving children is off by default; see `accept_children` in the README.
## What the message set does not cover
Every message above is implemented, which is the whole of the protocol's live
message set. Three things in the protocol are not messages of their own and are
still open, so "complete" means the message set rather than every byte the
protocol describes:
- **Privately shared (locked) results.** `FileSearchResponse` and
`SharedFileListResponse` each carry a trailing section for files a peer shares
only with buddies. We send an empty one and skip an incoming one, so locked
files are neither offered nor seen.
- **Obfuscated connections.** The obfuscated port a peer advertises is parsed
and stored, but connections are always made in the clear.
- **Adopting a firewalled child.** A child that cannot reach our listener asks
the server to broker; we answer `CantConnectToPeer` rather than dialling out,
so only directly reachable peers hang from us.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 926 KiB

File diff suppressed because one or more lines are too long
Generated
+27
View File
@@ -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
}
+51
View File
@@ -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";
};
}
) { };
});
};
}
+55
View File
@@ -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 <michel@re-invention.nl>
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
+22
View File
@@ -0,0 +1,22 @@
# Both crates inherit `version.workspace`, so they always move together. This
# config makes release-plz treat them as one release: a single `vX.Y.Z` tag, one
# GitHub release, one root CHANGELOG.md covering both. release-plz still
# publishes them to crates.io in dependency order (lib first) and waits for the
# index, which is what RELEASE.md used to ask you to do by hand.
[workspace]
git_tag_name = "v{{ version }}"
git_release_name = "v{{ version }}"
[[package]]
name = "soulseek-rs"
version_group = "workspace"
changelog_path = "./CHANGELOG.md"
changelog_include = ["soulseek-rs-lib"]
[[package]]
name = "soulseek-rs-lib"
version_group = "workspace"
changelog_update = false
git_tag_enable = false
git_release_enable = false
+1
View File
@@ -0,0 +1 @@
max_width = 80
+54
View File
@@ -0,0 +1,54 @@
#!/bin/sh
set -eu
umask 022
[ $# -ge 4 ] || {
echo "usage: $0 <binary> <version> <target-triple> <out-dir> [<package-basename>]" >&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"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Run the concurrency stress benchmark against a local soulfind.
#
# scripts/stress.sh # run with the default load profile
# scripts/stress.sh --save # store the run as the new baseline if it wins
# STRESS_PEERS=128 scripts/stress.sh
#
# Knobs: STRESS_PEERS STRESS_SEARCHES STRESS_DOWNLOADS STRESS_LEECHERS
# STRESS_BROWSES STRESS_FILE_KB STRESS_TIMEOUT
set -euo pipefail
cd "$(dirname "$0")/.."
# soulfind is a D binary linked against Homebrew's SQLite.
export SOULFIND_BIN="${SOULFIND_BIN:-$HOME/src/soulseek_download/soulfind/bin/soulfind}"
export DYLD_LIBRARY_PATH="${DYLD_LIBRARY_PATH:-/opt/homebrew/opt/sqlite/lib}"
if [ ! -x "$SOULFIND_BIN" ]; then
echo "stress: no soulfind at $SOULFIND_BIN — set SOULFIND_BIN" >&2
exit 2
fi
# Stale databases from a killed run make the next login behave oddly.
rm -f "${TMPDIR:-/tmp}"/soulfind-stress-*.db
# Every mock peer holds sockets open; the default 256 descriptors is nowhere near
# enough for a swarm.
ulimit -n 20000 2>/dev/null || true
for arg in "$@"; do
case "$arg" in
--save) export STRESS_SAVE=1 ;;
*) echo "stress: unknown argument $arg" >&2; exit 2 ;;
esac
done
exec cargo run --release --quiet -p soulseek-rs-lib --example stress
+28
View File
@@ -0,0 +1,28 @@
[package]
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
description = "Library for Soulseek protocol implementation in Rust"
keywords = ["soulseek", "p2p", "protocol", "file-sharing"]
categories = ["network-programming"]
[lib]
name = "soulseek_rs"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
[target.'cfg(unix)'.dependencies]
libc = "0.2.189"
[[example]]
name = "stress"
test = true
+119
View File
@@ -0,0 +1,119 @@
# soulseek-rs-lib
A Rust library for implementing the Soulseek peer-to-peer protocol.
Website: <https://re-invention.nl/soulseek-rs/>
## About
This library provides the core functionality for interacting with the Soulseek
network. It can be used to build custom Soulseek clients.
## Usage
Add it to your `Cargo.toml`:
```bash
cargo add soulseek-rs-lib
```
## Example
### Simple Usage
```rust,no_run
use soulseek_rs::Client;
use std::time::Duration;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create and connect to Soulseek server
let mut client = Client::new("username", "password");
client.connect()?;
client.login()?;
// Search for files
let results = client.search("Alex Kassian lifestream", Duration::from_secs(10))?;
// Download first available file
if let Some(result) = results.iter().find(|r| !r.files.is_empty()) {
let file = &result.files[0];
let (_download, status) = client.download(
file.name.clone(),
file.username.clone(),
file.size,
"~/Downloads".to_string(),
)?;
// `download` returns as soon as the request is queued: the transfer is
// finished when the channel says so, not when this call returns.
for update in status {
println!("{}: {update:?}", file.name);
}
}
Ok(())
}
```
### Advanced Configuration
```rust,no_run
use soulseek_rs::{Client, ClientSettings, PeerAddress};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create client with custom settings
let settings = ClientSettings {
server_address: PeerAddress::new("server.slsknet.org".to_string(), 2416),
enable_listen: true,
listen_port: 3000,
..ClientSettings::new("username", "password")
};
let mut client = Client::with_settings(settings);
client.connect()?;
client.login()?;
Ok(())
}
```
### Asking about a peer
The server-backed lookups are request/poll pairs: you ask, then read the
answer once it lands. `request_user_info` clears any previous answer first, so
the loop below observes this request rather than the last one.
```rust,no_run
use soulseek_rs::Client;
use std::thread::sleep;
use std::time::{Duration, Instant};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut client = Client::new("username", "password");
client.connect()?;
client.login()?;
client.request_user_info("someuser")?;
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if let Some(info) = client.user_info("someuser") {
// Presence and stats are separate replies, so each is an Option:
// a reply that never arrived is None, not "offline" or "shares nothing".
println!("{:?} {:?}", info.presence, info.stats);
break;
}
sleep(Duration::from_millis(100));
}
// Room membership is kept current from join and leave events.
println!("{:?}", client.room_members("lobby"));
Ok(())
}
```
`ClientOperation` is `#[non_exhaustive]`: every protocol message the client
learns to handle adds a variant, so match it with a wildcard arm. `SharedFile`
is too, so build it with the `..` syntax rather than listing every field.
File diff suppressed because it is too large Load Diff
+267
View File
@@ -0,0 +1,267 @@
use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread;
use std::time::{Duration, Instant};
use crate::{error, trace};
pub mod peer_actor;
pub mod peer_registry;
pub mod server_actor;
#[derive(Debug, Clone)]
pub enum ConnectionState {
Disconnected,
Connecting { since: Instant },
Connected,
}
/// Core actor trait - each actor processes messages
pub trait Actor: Send + 'static {
type Message: Send + Clone + 'static;
/// Handle a single message
fn handle(&mut self, msg: Self::Message);
/// Called when actor starts (optional hook)
fn on_start(&mut self) {}
/// Called when actor stops (optional hook)
fn on_stop(&mut self) {}
/// Optional periodic tick for background work
fn tick(&mut self) {}
}
#[derive(Clone)]
pub struct ActorHandle<M: Send> {
pub(crate) sender: Sender<ActorMessage<M>>,
}
impl<M: Send> ActorHandle<M> {
pub fn send(&self, msg: M) -> Result<(), String> {
self.sender
.send(ActorMessage::UserMessage(msg))
.map_err(|e| format!("Failed to send message: {e}"))
}
/// Request actor to stop gracefully
pub fn stop(&self) -> Result<(), String> {
self.sender
.send(ActorMessage::Stop)
.map_err(|e| format!("Failed to send stop signal: {e}"))
}
}
/// Internal actor message wrapper
pub enum ActorMessage<M> {
UserMessage(M),
Stop,
}
/// Stack size for an actor thread. An actor loop parses messages into
/// heap-allocated buffers and never recurses, so it needs a fraction of the
/// 2 MiB default — and at a thousand concurrent peers that difference is the
/// difference between 2 GiB of reserved address space and 256 MiB.
const ACTOR_STACK_SIZE: usize = 256 * 1024;
/// Actor system that manages actor lifecycle.
///
/// Each actor owns a thread for its whole lifetime. That is deliberate: an
/// actor loop blocks in `recv_timeout` until it is stopped, so running actors
/// on a fixed-size pool would make the pool a semaphore capping the client at
/// one peer per core — and every peer past the cap would silently never start.
#[derive(Default)]
pub struct ActorSystem;
impl ActorSystem {
#[must_use]
pub const fn new() -> Self {
Self
}
/// Spawn a new actor and return its handle
pub fn spawn<A: Actor>(&self, actor: A) -> ActorHandle<A::Message> {
self.spawn_with_handle(actor, |_, _| {})
}
/// Spawn a new actor with initialization callback and return its handle
/// The callback receives the actor handle before `on_start` is called
pub fn spawn_with_handle<A: Actor, F>(
&self,
actor: A,
init: F,
) -> ActorHandle<A::Message>
where
F: FnOnce(&mut A, ActorHandle<A::Message>) + Send + 'static,
{
match self.try_spawn_with_handle(actor, init) {
Ok(handle) => handle,
Err(e) => {
error!("[actor_system] failed to spawn actor thread: {}", e);
// A handle nobody serves: sends fail, which callers of the
// infallible spawn already tolerate.
let (sender, _) = channel();
ActorHandle { sender }
}
}
}
/// Like [`Self::spawn_with_handle`], but hands a thread-spawn failure back
/// to the caller instead of returning a handle no actor will ever serve.
/// Storing such a dead handle is how a registry entry becomes a zombie
/// that claims its username for the rest of the process.
#[allow(clippy::unused_self)] // method for symmetry with the spawn family
pub(crate) fn try_spawn_with_handle<A: Actor, F>(
&self,
mut actor: A,
init: F,
) -> std::io::Result<ActorHandle<A::Message>>
where
F: FnOnce(&mut A, ActorHandle<A::Message>) + Send + 'static,
{
let (sender, receiver) = channel::<ActorMessage<A::Message>>();
let handle = ActorHandle { sender };
let handle_for_init = handle.clone();
thread::Builder::new()
.name("soulseek-actor".to_string())
.stack_size(ACTOR_STACK_SIZE)
.spawn(move || {
init(&mut actor, handle_for_init);
actor.on_start();
Self::run_actor_loop(&mut actor, receiver);
actor.on_stop();
})?;
Ok(handle)
}
fn run_actor_loop<A: Actor>(
actor: &mut A,
receiver: Receiver<ActorMessage<A::Message>>,
) {
let tick_interval = Duration::from_millis(100);
let mut last_tick = Instant::now();
let mut message_count = 0;
let mut tick_count = 0;
loop {
match receiver.recv_timeout(tick_interval) {
Ok(ActorMessage::UserMessage(msg)) => {
message_count += 1;
actor.handle(msg);
}
Ok(ActorMessage::Stop) => {
trace!(
"[actor_system] Received Stop message, breaking loop"
);
break;
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if last_tick.elapsed() >= tick_interval {
tick_count += 1;
actor.tick();
last_tick = Instant::now();
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
trace!(
"[actor_system] Channel disconnected, breaking loop"
);
break;
}
}
}
trace!(
"[actor_system] run_actor_loop ENDED - processed {} messages, {} ticks",
message_count, tick_count
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
struct CounterActor {
count: Arc<AtomicUsize>,
}
impl Actor for CounterActor {
type Message = usize;
fn handle(&mut self, msg: Self::Message) {
self.count.fetch_add(msg, Ordering::SeqCst);
}
fn on_start(&mut self) {
println!("Counter actor started");
}
fn on_stop(&mut self) {
println!("Counter actor stopped");
}
}
#[test]
fn test_actor_system() {
let system = ActorSystem::new();
let count = Arc::new(AtomicUsize::new(0));
let actor = CounterActor {
count: count.clone(),
};
let handle = system.spawn(actor);
// Send some messages
handle.send(1).unwrap();
handle.send(2).unwrap();
handle.send(3).unwrap();
// Give actor time to process
std::thread::sleep(Duration::from_millis(100));
assert_eq!(count.load(Ordering::SeqCst), 6);
handle.stop().unwrap();
// Give actor time to process the stop message
std::thread::sleep(Duration::from_millis(100));
}
// Actors must not be capped by a worker count: every peer runs one, and a
// cap silently strands every peer past it. Far more actors than cores must
// all make progress concurrently.
#[test]
fn far_more_actors_than_cores_all_run() {
let system = ActorSystem::new();
let count = Arc::new(AtomicUsize::new(0));
let handles: Vec<_> = (0..256)
.map(|_| {
system.spawn(CounterActor {
count: count.clone(),
})
})
.collect();
for handle in &handles {
handle.send(1).unwrap();
}
let deadline = Instant::now() + Duration::from_secs(10);
while count.load(Ordering::SeqCst) < 256 && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
assert_eq!(
count.load(Ordering::SeqCst),
256,
"every actor should have processed its message"
);
for handle in &handles {
handle.stop().unwrap();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,235 @@
use super::*;
use crate::message::peer::build_shared_file_list;
use crate::peer::{ConnectionType, Peer};
use std::net::{TcpListener, TcpStream};
use std::sync::mpsc::Receiver;
/// A connected inbound actor over a real loopback socket, plus the far
/// end (kept alive so reads see silence, not EOF).
fn connected_actor() -> (PeerActor, Receiver<ClientOperation>, TcpStream) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let stream = TcpStream::connect(addr).unwrap();
stream.set_nonblocking(true).unwrap();
let (far_end, _) = listener.accept().unwrap();
let (mut actor, rx) = make_actor(Some(stream));
actor.on_start();
(actor, rx, far_end)
}
fn make_actor(
stream: Option<TcpStream>,
) -> (PeerActor, Receiver<ClientOperation>) {
let (tx, rx) = std::sync::mpsc::channel();
let peer = Peer::new(
"bob".to_string(),
ConnectionType::P,
"127.0.0.1".to_string(),
0,
None,
0,
0,
0,
);
let actor = PeerActor::new(peer, stream, None, tx, "me".to_string(), 7);
(actor, rx)
}
#[test]
fn a_timed_out_connect_parks_the_actor_in_disconnected() {
let (mut actor, rx) = make_actor(None);
actor.connection_state = ConnectionState::Connecting {
since: Instant::now().checked_sub(Duration::from_secs(21)).unwrap(),
};
actor.tick();
assert!(
matches!(actor.connection_state, ConnectionState::Disconnected),
"a timed-out connect must leave Connecting"
);
match rx.try_recv() {
Ok(ClientOperation::PeerConnectFailed(7, username, _)) => {
assert_eq!(username, "bob");
}
other => panic!("expected PeerConnectFailed, got {other:?}"),
}
}
#[test]
fn an_upload_failed_message_reaches_the_client_as_this_peer() {
let (mut actor, rx, _far_end) = connected_actor();
actor.handle_message(PeerMessage::UploadFailed(
String::new(),
"song.mp3".to_string(),
));
match rx.try_recv() {
Ok(ClientOperation::UploadFailed(username, filename)) => {
assert_eq!(username, "bob");
assert_eq!(filename, "song.mp3");
}
other => panic!("expected UploadFailed, got {other:?}"),
}
}
#[test]
fn tick_reaps_a_peer_idle_past_the_deadline() {
let (mut actor, rx, _far_end) = connected_actor();
actor.tick();
assert!(actor.stream.is_some(), "a fresh connection stays open");
actor.last_activity = Instant::now()
.checked_sub(IDLE_DISCONNECT + Duration::from_secs(1))
.unwrap();
actor.tick();
assert!(actor.stream.is_none(), "an idle stream must be closed");
match rx.try_recv() {
Ok(ClientOperation::PeerDisconnected(7, username, None)) => {
assert_eq!(username, "bob");
}
other => panic!("expected a clean PeerDisconnected, got {other:?}"),
}
}
/// A listing well past one socket buffer, so delivery within a couple of
/// ticks proves the actor drains the socket rather than sipping 1 KiB a tick.
fn big_listing() -> Vec<u8> {
let files = (0..400u64)
.map(|i| crate::message::peer::SharedFileEntry {
name: format!("track-{i:03}-{}.flac", "x".repeat(i as usize % 17)),
size: i,
attributes: Vec::new(),
})
.collect();
let dir = crate::message::peer::SharedDirectory {
name: "album".to_string(),
files,
};
let bytes = build_shared_file_list(std::slice::from_ref(&dir)).get_buffer();
assert!(bytes.len() > 4096, "the listing must span several reads");
bytes
}
fn self_handle(
actor: &mut PeerActor,
) -> Receiver<crate::actor::ActorMessage<PeerMessage>> {
let (tx, rx) = std::sync::mpsc::channel();
actor.set_self_handle(ActorHandle { sender: tx });
rx
}
// A connection replaced by a newer one to the same peer may still carry
// a reply already on the wire: it is retired, not cut, and once nothing
// more can come it is the one to stop itself.
#[test]
fn a_retired_actor_delivers_what_is_on_the_wire_then_stops_itself() {
use std::io::Write;
let (mut actor, rx, mut far_end) = connected_actor();
let stop_rx = self_handle(&mut actor);
actor.handle_message(PeerMessage::Retire);
far_end.write_all(&big_listing()).unwrap();
std::thread::sleep(Duration::from_millis(50));
actor.tick();
actor.tick();
match rx.try_recv() {
Ok(ClientOperation::BrowseResult { username, .. }) => {
assert_eq!(username, "bob");
}
other => {
panic!("expected the listing to reach the client, got {other:?}")
}
}
assert!(actor.stream.is_some(), "retiring is not yet closing");
assert!(stop_rx.try_recv().is_err(), "not stopped while listening");
actor.retire_deadline =
Some(Instant::now().checked_sub(Duration::from_secs(1)).unwrap());
actor.tick();
assert!(actor.stream.is_none(), "past the grace the stream closes");
match rx.try_recv() {
Ok(ClientOperation::PeerDisconnected(7, _, None)) => {}
other => panic!("expected a clean PeerDisconnected, got {other:?}"),
}
actor.tick();
assert!(
matches!(stop_rx.try_recv(), Ok(crate::actor::ActorMessage::Stop)),
"a closed retired actor stops itself"
);
}
// The peer that replaced the connection considers the old one done too: it
// writes its reply and hangs up in the same breath. The reply still counts.
#[test]
fn a_reply_sent_right_before_the_peer_hangs_up_is_still_delivered() {
use std::io::Write;
let (mut actor, rx, mut far_end) = connected_actor();
actor.handle_message(PeerMessage::Retire);
far_end.write_all(&big_listing()).unwrap();
drop(far_end);
std::thread::sleep(Duration::from_millis(50));
actor.tick();
actor.tick();
match rx.try_recv() {
Ok(ClientOperation::BrowseResult { username, .. }) => {
assert_eq!(username, "bob");
}
other => panic!("expected the listing before the close, got {other:?}"),
}
match rx.try_recv() {
Ok(ClientOperation::PeerDisconnected(7, _, None)) => {}
other => panic!("expected a clean PeerDisconnected, got {other:?}"),
}
}
#[test]
fn a_retired_actor_reporting_an_error_reports_a_clean_close() {
let (mut actor, rx, _far_end) = connected_actor();
actor.handle_message(PeerMessage::Retire);
actor.disconnect_with_error(Error::from(io::ErrorKind::ConnectionReset));
match rx.try_recv() {
Ok(ClientOperation::PeerDisconnected(7, _, None)) => {}
other => panic!(
"a retired connection going away is not an error, got {other:?}"
),
}
}
// Retiring an actor whose dial has not completed: nothing went out, so
// there is nothing to wait for, and it must not linger in the queue that
// only a successful connection drains.
#[test]
fn retiring_a_connecting_actor_closes_it_at_once() {
let (mut actor, rx) = make_actor(None);
let stop_rx = self_handle(&mut actor);
actor.connection_state = ConnectionState::Connecting {
since: Instant::now(),
};
actor.handle_message(PeerMessage::Retire);
assert!(matches!(
actor.connection_state,
ConnectionState::Disconnected
));
match rx.try_recv() {
Ok(ClientOperation::PeerDisconnected(7, _, None)) => {}
other => panic!("expected a clean PeerDisconnected, got {other:?}"),
}
actor.tick();
assert!(matches!(
stop_rx.try_recv(),
Ok(crate::actor::ActorMessage::Stop)
));
}
+461
View File
@@ -0,0 +1,461 @@
use crate::actor::peer_actor::{PeerActor, PeerMessage};
use crate::actor::{ActorHandle, ActorSystem};
use crate::client::ClientOperation;
use crate::message::MessageReader;
use crate::peer::Peer;
use crate::utils::lock::MutexExt;
use crate::{debug, error};
use std::collections::{HashMap, HashSet};
use std::net::TcpStream;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
/// Source of unique per-actor ids so terminal-outcome eviction can be made
/// identity-aware (a replaced actor must not evict its replacement).
static NEXT_PEER_ID: AtomicU64 = AtomicU64::new(1);
/// Registered peers keyed by username, each stored with the unique id of the
/// actor currently occupying the slot.
type PeerMap = HashMap<String, (u64, ActorHandle<PeerMessage>)>;
pub struct PeerRegistry {
peers: Arc<Mutex<PeerMap>>,
actor_system: Arc<ActorSystem>,
client_channel: Sender<ClientOperation>,
own_username: String,
capacity: Arc<AtomicUsize>,
}
impl PeerRegistry {
#[must_use]
pub fn new(
actor_system: Arc<ActorSystem>,
client_channel: Sender<ClientOperation>,
own_username: String,
) -> Self {
Self::with_capacity(
actor_system,
client_channel,
own_username,
Arc::new(AtomicUsize::new(usize::MAX)),
)
}
#[must_use]
pub(crate) fn with_capacity(
actor_system: Arc<ActorSystem>,
client_channel: Sender<ClientOperation>,
own_username: String,
capacity: Arc<AtomicUsize>,
) -> Self {
Self {
peers: Arc::new(Mutex::new(HashMap::new())),
actor_system,
client_channel,
own_username,
capacity,
}
}
pub fn register_peer(
&self,
peer: Peer,
stream: Option<TcpStream>,
reader: Option<MessageReader>,
) -> Result<ActorHandle<PeerMessage>, String> {
self.register_peer_protected(peer, stream, reader, &HashSet::new())
}
pub(crate) fn register_peer_protected(
&self,
peer: Peer,
stream: Option<TcpStream>,
reader: Option<MessageReader>,
protected: &HashSet<String>,
) -> Result<ActorHandle<PeerMessage>, String> {
let username = peer.username.clone();
let id = NEXT_PEER_ID.fetch_add(1, Ordering::Relaxed);
let actor = PeerActor::new(
peer,
stream,
reader,
self.client_channel.clone(),
self.own_username.clone(),
id,
);
// Take the map lock before the actor exists: a peer that dies
// instantly (refused dial, immediate hangup) reports its terminal
// outcome to the client loop, whose eviction takes this same lock.
// With the insert racing the spawn, eviction could run first, find
// nothing, and the entry inserted afterwards became a permanent
// zombie claiming the username.
let mut peers = self
.peers
.lock_safe()
.map_err(|e| format!("peer registry lock poisoned: {e}"))?;
let capacity = self.capacity.load(Ordering::Relaxed).max(1);
if peers.len() >= capacity && !peers.contains_key(&username) {
let victim = peers
.iter()
.filter(|(name, _)| !protected.contains(name.as_str()))
.min_by_key(|(_, (id, _))| *id)
.map(|(name, _)| name.clone());
let Some(victim) = victim else {
return Err(format!(
"peer registry at capacity ({capacity}) and every peer \
is busy; refusing {username}"
));
};
if let Some((_, handle)) = peers.remove(&victim) {
let _ = handle.stop();
debug!(
"[peer_registry] evicted idle peer {} to admit {}",
victim, username
);
}
}
let handle = self
.actor_system
.try_spawn_with_handle(actor, |actor, handle| {
actor.set_self_handle(handle);
})
.map_err(|e| format!("failed to spawn peer actor thread: {e}"))?;
// Retire any actor already registered under this username rather than
// stop it: a reply to a request we sent on it may still be on the
// wire. It stops itself once that grace is over, and the eviction its
// shutdown triggers is keyed on its id, so it cannot evict this one.
if let Some((_, old_handle)) =
peers.insert(username.clone(), (id, handle.clone()))
{
let _ = old_handle.send(PeerMessage::Retire);
debug!(
"[peer_registry] Replaced existing peer actor for {}",
username
);
}
Ok(handle)
}
#[must_use]
pub fn get_peer(&self, username: &str) -> Option<ActorHandle<PeerMessage>> {
match self.peers.lock_safe() {
Ok(peers) => peers.get(username).map(|(_, handle)| handle.clone()),
Err(e) => {
error!("[peer_registry] get_peer: {}", e);
None
}
}
}
#[must_use]
pub fn remove_peer(
&self,
username: &str,
) -> Option<ActorHandle<PeerMessage>> {
let mut peers = match self.peers.lock_safe() {
Ok(p) => p,
Err(e) => {
error!("[peer_registry] remove_peer: {}", e);
return None;
}
};
let removed = peers.remove(username);
if removed.is_some() {
debug!("[peer_registry] Removed peer actor for {}", username);
}
removed.map(|(_, handle)| handle)
}
/// Remove and return the actor for `username` only if it is still the actor
/// with `id`. A stale (replaced) actor's terminal notification therefore
/// cannot evict the newer actor that now occupies the slot.
#[must_use]
pub fn remove_peer_if(
&self,
username: &str,
id: u64,
) -> Option<ActorHandle<PeerMessage>> {
let mut peers = match self.peers.lock_safe() {
Ok(p) => p,
Err(e) => {
error!("[peer_registry] remove_peer_if: {}", e);
return None;
}
};
if peers.get(username).is_some_and(|(stored, _)| *stored == id) {
let removed = peers.remove(username).map(|(_, handle)| handle);
debug!(
"[peer_registry] Removed peer actor {} for {}",
id, username
);
return removed;
}
None
}
/// Stop every peer actor and empty the registry, closing the connections
/// they hold. Used when the client shuts down.
pub fn stop_all(&self) {
let handles = match self.peers.lock_safe() {
Ok(mut peers) => peers
.drain()
.map(|(_, (_, handle))| handle)
.collect::<Vec<_>>(),
Err(e) => {
error!("[peer_registry] stop_all: {}", e);
return;
}
};
for handle in handles {
let _ = handle.stop();
}
}
#[must_use]
pub fn contains(&self, username: &str) -> bool {
match self.peers.lock_safe() {
Ok(peers) => peers.contains_key(username),
Err(e) => {
error!("[peer_registry] contains: {}", e);
false
}
}
}
pub fn send_to_peer(
&self,
username: &str,
message: PeerMessage,
) -> Result<(), String> {
let handle = self
.get_peer(username)
.ok_or_else(|| format!("Peer {username} not found in registry"))?;
handle.send(message)
}
pub fn queue_upload(
&self,
username: &str,
filename: String,
) -> Result<(), String> {
self.send_to_peer(username, PeerMessage::QueueUpload(filename))
}
}
impl Clone for PeerRegistry {
fn clone(&self) -> Self {
Self {
peers: self.peers.clone(),
actor_system: self.actor_system.clone(),
client_channel: self.client_channel.clone(),
own_username: self.own_username.clone(),
capacity: self.capacity.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::PeerRegistry;
use crate::actor::ActorSystem;
use crate::peer::{ConnectionType, Peer};
use std::collections::HashSet;
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
fn loopback_peer(name: &str) -> (Peer, TcpStream, TcpStream) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let stream = TcpStream::connect(addr).unwrap();
stream.set_nonblocking(true).unwrap();
let far_end = listener.accept().unwrap().0;
let peer = Peer::new(
name.to_string(),
ConnectionType::P,
"127.0.0.1".to_string(),
u32::from(addr.port()),
None,
0,
0,
0,
);
(peer, stream, far_end)
}
fn capped_registry(
capacity: usize,
) -> (
PeerRegistry,
std::sync::mpsc::Receiver<crate::client::ClientOperation>,
) {
let system = Arc::new(ActorSystem::new());
let (tx, rx) = std::sync::mpsc::channel();
let registry = PeerRegistry::with_capacity(
system,
tx,
"me".to_string(),
Arc::new(AtomicUsize::new(capacity)),
);
(registry, rx)
}
#[test]
fn capacity_evicts_the_oldest_idle_peer() {
let (registry, _rx) = capped_registry(2);
for name in ["a", "b", "c"] {
let (peer, stream, _far_end) = loopback_peer(name);
registry.register_peer(peer, Some(stream), None).unwrap();
}
assert!(!registry.contains("a"), "the oldest peer must be evicted");
assert!(registry.contains("b"));
assert!(registry.contains("c"));
}
#[test]
fn capacity_refuses_when_every_peer_is_protected() {
let (registry, _rx) = capped_registry(1);
let (busy, busy_stream, _busy_far) = loopback_peer("busy");
registry
.register_peer(busy, Some(busy_stream), None)
.unwrap();
let protected: HashSet<String> =
std::iter::once("busy".to_string()).collect();
let (newcomer, stream, _far) = loopback_peer("newcomer");
let result = registry.register_peer_protected(
newcomer,
Some(stream),
None,
&protected,
);
assert!(result.is_err(), "a full registry of busy peers must refuse");
assert!(registry.contains("busy"));
assert!(!registry.contains("newcomer"));
}
#[test]
fn a_returning_username_is_replaced_not_refused_at_capacity() {
let (registry, _rx) = capped_registry(1);
let (first, first_stream, _first_far) = loopback_peer("bob");
registry
.register_peer(first, Some(first_stream), None)
.unwrap();
let protected: HashSet<String> =
std::iter::once("bob".to_string()).collect();
let (again, stream, _far) = loopback_peer("bob");
let result = registry.register_peer_protected(
again,
Some(stream),
None,
&protected,
);
assert!(
result.is_ok(),
"a reconnecting username must replace itself"
);
assert!(registry.contains("bob"));
}
#[test]
fn remove_peer_if_respects_actor_identity() {
let system = Arc::new(ActorSystem::new());
let (tx, _rx) = std::sync::mpsc::channel();
let registry = PeerRegistry::new(system, tx, "me".to_string());
// A real loopback connection makes the actor inbound (no dial-out);
// non-blocking so it can process Stop promptly on teardown.
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let stream = TcpStream::connect(addr).unwrap();
stream.set_nonblocking(true).unwrap();
let _server_side = listener.accept().unwrap().0;
let peer = Peer::new(
"bob".to_string(),
ConnectionType::P,
"127.0.0.1".to_string(),
u32::from(addr.port()),
None,
0,
0,
0,
);
registry.register_peer(peer, Some(stream), None).unwrap();
assert!(registry.contains("bob"));
// A stale / wrong id must not evict the live actor.
assert!(registry.remove_peer_if("bob", u64::MAX).is_none());
assert!(registry.contains("bob"));
// Unconditional removal still works (and stops the actor).
let handle = registry.remove_peer("bob");
assert!(handle.is_some());
let _ = handle.unwrap().stop();
assert!(!registry.contains("bob"));
}
// A dial that is refused reports its terminal outcome almost instantly —
// possibly while register_peer is still between spawn and insert. The
// registry holds its lock across both, so the eviction that follows must
// always find the entry; a miss left a permanent zombie claiming the
// username.
#[test]
fn refused_dial_does_not_leave_a_zombie_entry() {
use crate::client::ClientOperation;
let system = Arc::new(ActorSystem::new());
let (tx, rx) = std::sync::mpsc::channel();
let registry = PeerRegistry::new(system, tx, "me".to_string());
// A port with nothing listening behind it: bind, learn it, drop it.
let port = {
let probe = TcpListener::bind("127.0.0.1:0").unwrap();
probe.local_addr().unwrap().port()
};
let peer = Peer::new(
"ghost".to_string(),
ConnectionType::P,
"127.0.0.1".to_string(),
u32::from(port),
None,
0,
0,
0,
);
registry.register_peer(peer, None, None).unwrap();
// Play the client ops loop: take the terminal outcome, evict by id.
match rx.recv_timeout(std::time::Duration::from_secs(10)) {
Ok(ClientOperation::PeerConnectFailed(id, username, _)) => {
assert_eq!(username, "ghost");
if let Some(handle) = registry.remove_peer_if(&username, id) {
let _ = handle.stop();
}
}
other => panic!("expected PeerConnectFailed, got {other:?}"),
}
assert!(
!registry.contains("ghost"),
"a refused dial must not leave a registry entry"
);
}
}
+722
View File
@@ -0,0 +1,722 @@
use crate::actor::{Actor, ActorHandle, ConnectionState};
use crate::client::{CancelHandle, ClientOperation};
use crate::dispatcher::MessageDispatcher;
use crate::message::server::AdminMessageHandler;
use crate::message::server::CantConnectToPeerHandler;
use crate::message::server::CheckPrivilegesHandler;
use crate::message::server::ConnectToPeerHandler;
use crate::message::server::EmbeddedMessageHandler;
use crate::message::server::ExcludedSearchPhrasesHandler;
use crate::message::server::FileSearchHandler;
use crate::message::server::GetPeerAddressHandler;
use crate::message::server::GlobalRoomMessageHandler;
use crate::message::server::JoinRoomHandler;
use crate::message::server::LeaveRoomHandler;
use crate::message::server::LoginHandler;
use crate::message::server::MessageFactory;
use crate::message::server::MessageUser;
use crate::message::server::ParentMinSpeedHandler;
use crate::message::server::ParentSpeedRatioHandler;
use crate::message::server::PossibleParentsHandler;
use crate::message::server::PrivilegedUsersHandler;
use crate::message::server::ReloggedHandler;
use crate::message::server::ResetDistributedHandler;
use crate::message::server::SayChatroomHandler;
use crate::message::server::UserJoinedRoomHandler;
use crate::message::server::UserLeftRoomHandler;
use crate::message::server::WatchUserHandler;
use crate::message::server::WishListIntervalHandler;
use crate::message::server::{
CantCreateRoomHandler, OwnRoomStandingHandler, RoomMembersHandler,
RoomOperatorsHandler, RoomRosterChangeHandler,
};
use crate::message::server::{
GetUserStatsHandler, GetUserStatusHandler, RoomListHandler,
};
use crate::message::server::{
GlobalRecommendationsHandler, ItemRecommendationsHandler,
ItemSimilarUsersHandler, RecommendationsHandler, SimilarUsersHandler,
UserInterestsHandler,
};
use crate::message::server::{
RoomTickerAddedHandler, RoomTickerRemovedHandler, RoomTickersHandler,
};
use crate::message::{Handlers, MessageType};
use crate::message::{Message, MessageReader};
use crate::peer::ConnectionType;
use crate::peer::Peer;
use crate::types::{
ClientVersion, Recommendation, RoomEvent, RoomInfo, RoomUserStats,
SessionLoss, SessionWatch, SimilarUser, UserInterests,
};
use crate::utils::lock::RwLockExt;
use std::io::{self, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use crate::{SoulseekRs, debug, error, trace, warn};
mod handlers;
mod types;
pub use types::{Context, PeerAddress, UserMessage};
/// Ceiling on the wait for the server's login verdict. A loaded server can
/// take seconds to answer, so this stays inside the caller's own 45s bound
/// rather than undercutting it.
const LOGIN_VERDICT_TIMEOUT: Duration = Duration::from_secs(30);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// The server actor's mailbox. Marked non-exhaustive: each protocol message
/// the client learns adds a variant, and that must not break callers.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ServerMessage {
ProcessRead,
LoginStatus(bool),
/// The server is closing this connection: the same username logged in
/// elsewhere.
Relogged,
SendMessage(Message),
Login {
username: String,
password: String,
version: ClientVersion,
response: std::sync::mpsc::Sender<Result<bool, SoulseekRs>>,
},
FileSearch {
token: u32,
query: String,
},
/// A wishlist search (code 103): a stored query the server lets us repeat
/// once per announced interval.
WishlistSearch {
token: u32,
query: String,
},
/// The server announced how often it will accept a wishlist search.
WishlistInterval(u32),
/// Everyone the server counts as privileged (code 69).
PrivilegedUsers(Vec<String>),
/// Seconds of our own privileges left (code 92).
OwnPrivileges(u32),
/// Ask the server for the answer to the above.
CheckPrivileges,
/// A search the server distributed to us from another user; if it matches
/// our shares we reply with a FileSearchResponse.
FileSearchRequest {
username: String,
token: u32,
query: String,
},
#[allow(dead_code)]
ConnectToPeer(Peer),
PierceFirewall(u32),
GetPeerAddress(String),
GetPeerAddressResponse {
username: String,
host: String,
port: u32,
obfuscation_type: u32,
obfuscated_port: u16,
},
PrivateMessageReceived(UserMessage),
RoomListReceived(Vec<RoomInfo>),
/// A `GetUserStatus` (code 7) reply.
UserStatusReceived {
username: String,
status: u32,
privileged: bool,
},
/// A `WatchUser` (code 5) reply: the initial snapshot for a user we just
/// started watching. The stats are absent when the server does not know
/// the username.
WatchedUserReceived {
username: String,
exists: bool,
status: Option<u32>,
average_speed: Option<u32>,
shared_files: Option<u32>,
shared_folders: Option<u32>,
},
/// A `GetUserStats` (code 36) reply.
UserStatsReceived {
username: String,
average_speed: u32,
shared_files: u32,
shared_folders: u32,
},
RoomJoined {
room: String,
users: Vec<String>,
},
/// Per-member statistics carried by the same `JoinRoom` (code 14) reply.
RoomMemberStats {
room: String,
stats: Vec<RoomUserStats>,
},
RoomLeft {
room: String,
},
RoomMessageReceived {
room: String,
username: String,
message: String,
},
RoomUserJoined {
room: String,
username: String,
},
RoomUserLeft {
room: String,
username: String,
},
/// Peers the server suggests as distributed-network parents: username,
/// host, port.
PossibleParents(Vec<(String, String, u16)>),
/// The server asks us to drop our parent and start over.
ResetDistributed,
/// The ticker board of a room we just joined (code 113).
RoomTickers {
room: String,
tickers: Vec<crate::types::RoomTicker>,
},
/// One member set their ticker (code 114).
RoomTickerAdded {
room: String,
username: String,
ticker: String,
},
/// One member cleared their ticker (code 115).
RoomTickerRemoved {
room: String,
username: String,
},
/// A message from the global room feed (code 152).
GlobalRoomMessageReceived {
room: String,
username: String,
message: String,
},
/// Recommendations from our own interests (code 54).
RecommendationsReceived {
recommended: Vec<Recommendation>,
unrecommended: Vec<Recommendation>,
},
/// Server-wide recommendations (code 56).
GlobalRecommendationsReceived {
recommended: Vec<Recommendation>,
unrecommended: Vec<Recommendation>,
},
/// Recommendations for one item (code 111).
ItemRecommendationsReceived {
item: String,
recommendations: Vec<Recommendation>,
},
/// Users similar to us (code 110).
SimilarUsersReceived {
users: Vec<SimilarUser>,
},
/// Users who like one item (code 112).
ItemSimilarUsersReceived {
item: String,
usernames: Vec<String>,
},
/// What another user likes and hates (code 57).
UserInterestsReceived(UserInterests),
/// A peer could not connect to us after we asked the server to broker
/// (code 1001); the token is the one we quoted.
CantConnectToPeer {
token: u32,
},
/// Phrases the server refuses to search for (code 160).
ExcludedSearchPhrases(Vec<String>),
/// The upload speed a client needs before the server lets it carry
/// children (code 83).
ParentMinSpeed(u32),
/// The divisor turning that speed into a child count (code 84).
ParentSpeedRatio(u32),
/// Who may enter a private room (code 133).
PrivateRoomMembers {
room: String,
users: Vec<String>,
},
/// Who runs a private room (code 148).
PrivateRoomOperators {
room: String,
users: Vec<String>,
},
/// One user joined or left a private room's member or operator roster
/// (codes 134/135 and 143/144).
PrivateRoomRosterChanged {
room: String,
username: String,
/// True for the member roster, false for the operator roster.
members: bool,
added: bool,
},
/// Our own membership (139/140) or operatorship (145/146) of a private
/// room was granted or revoked.
OwnRoomStandingChanged {
room: String,
members: bool,
granted: bool,
},
/// The room we asked to join could not be created (code 1003).
CantCreateRoom {
room: String,
},
}
pub struct ServerActor {
address: PeerAddress,
context: Arc<RwLock<Context>>,
listen_port: u16,
enable_listen: bool,
stream: Option<TcpStream>,
connection_state: ConnectionState,
reader: MessageReader,
client_channel: Sender<ClientOperation>,
self_handle: Option<ActorHandle<ServerMessage>>,
dispatcher: Option<MessageDispatcher<ServerMessage>>,
dispatcher_receiver: Option<Receiver<ServerMessage>>,
dispatcher_sender: Option<Sender<ServerMessage>>,
queued_messages: Vec<ServerMessage>,
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
/// counts, online status, and (when listening)
/// the port peers should connect to. Kept as a free function so it can be
/// tested without a live connection.
fn post_login_messages(
enable_listen: bool,
listen_port: u16,
shared_folders: u32,
shared_files: u32,
) -> Vec<Message> {
let mut messages = vec![
MessageFactory::build_shared_folders_message(
shared_folders,
shared_files,
),
MessageFactory::build_set_status_message(2),
];
if enable_listen {
messages.push(MessageFactory::build_set_wait_port_message(listen_port));
}
messages
}
impl ServerActor {
#[must_use]
pub fn new(
address: PeerAddress,
client_channel: Sender<ClientOperation>,
listen_port: u16,
enable_listen: bool,
shared_folder_count: u32,
shared_file_count: u32,
) -> Self {
Self {
address,
context: Arc::new(RwLock::new(Context::new())),
listen_port,
enable_listen,
stream: None,
connection_state: ConnectionState::Disconnected,
dispatcher: None,
dispatcher_receiver: None,
dispatcher_sender: None,
reader: MessageReader::new(),
client_channel,
self_handle: None,
queued_messages: Vec::new(),
shared_folder_count,
shared_file_count,
session: SessionWatch::default(),
cancel: CancelHandle::new(),
}
}
/// Share the client's view of whether this session is still alive.
pub fn set_session_watch(&mut self, session: SessionWatch) {
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()
.ok()
.and_then(|mut addrs| {
addrs.find_map(|addr| {
TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT).ok()
})
});
let Some(stream) = stream else {
error!("[server] Failed to connect to {}", self.address);
self.disconnect_with_error();
return false;
};
if let Err(e) = stream.set_nonblocking(true) {
error!("[server] Failed to set non-blocking: {}", e);
self.disconnect_with_error();
return false;
}
stream.set_nodelay(true).ok();
if let Err(e) = crate::utils::keepalive::set_keepalive(&stream) {
warn!("[server] keepalive not set: {}", e);
}
self.stream = Some(stream);
self.connection_state = ConnectionState::Connecting {
since: Instant::now(),
};
true
}
pub fn set_self_handle(&mut self, handle: ActorHandle<ServerMessage>) {
self.self_handle = Some(handle);
}
fn initialize_dispatcher(&mut self) {
let (dispatcher_sender, dispatcher_receiver) =
std::sync::mpsc::channel::<ServerMessage>();
self.dispatcher_receiver = Some(dispatcher_receiver);
self.dispatcher_sender = Some(dispatcher_sender.clone());
if let Err(e) = self
.client_channel
.send(ClientOperation::SetServerSender(dispatcher_sender.clone()))
{
error!("[server] failed to send SetServerSender: {}", e);
}
let mut handlers = Handlers::new();
handlers.register_handler(LoginHandler);
handlers.register_handler(ReloggedHandler);
handlers.register_handler(AdminMessageHandler);
handlers.register_handler(PossibleParentsHandler);
handlers.register_handler(ResetDistributedHandler);
handlers.register_handler(EmbeddedMessageHandler);
handlers.register_handler(RoomListHandler);
handlers.register_handler(GetUserStatusHandler);
handlers.register_handler(WatchUserHandler);
handlers.register_handler(GetUserStatsHandler);
handlers.register_handler(JoinRoomHandler);
handlers.register_handler(LeaveRoomHandler);
handlers.register_handler(SayChatroomHandler);
handlers.register_handler(UserJoinedRoomHandler);
handlers.register_handler(UserLeftRoomHandler);
handlers.register_handler(ExcludedSearchPhrasesHandler);
handlers.register_handler(PrivilegedUsersHandler);
handlers.register_handler(MessageUser);
handlers.register_handler(WishListIntervalHandler);
handlers.register_handler(ParentMinSpeedHandler);
handlers.register_handler(ParentSpeedRatioHandler);
handlers.register_handler(CheckPrivilegesHandler);
handlers.register_handler(FileSearchHandler);
handlers.register_handler(GetPeerAddressHandler);
handlers.register_handler(ConnectToPeerHandler);
handlers.register_handler(CantConnectToPeerHandler);
handlers.register_handler(RoomMembersHandler);
handlers.register_handler(RoomOperatorsHandler);
// Members added/removed (134/135), operators added/removed (143/144).
for code in [134, 135, 143, 144] {
handlers.register_handler(RoomRosterChangeHandler(code));
}
// Our own membership (139/140) and operatorship (145/146).
for code in [139, 140, 145, 146] {
handlers.register_handler(OwnRoomStandingHandler(code));
}
handlers.register_handler(CantCreateRoomHandler);
handlers.register_handler(RoomTickersHandler);
handlers.register_handler(RoomTickerAddedHandler);
handlers.register_handler(RoomTickerRemovedHandler);
handlers.register_handler(GlobalRoomMessageHandler);
handlers.register_handler(RecommendationsHandler);
handlers.register_handler(GlobalRecommendationsHandler);
handlers.register_handler(ItemRecommendationsHandler);
handlers.register_handler(SimilarUsersHandler);
handlers.register_handler(ItemSimilarUsersHandler);
handlers.register_handler(UserInterestsHandler);
self.dispatcher = Some(MessageDispatcher::new(
"server".into(),
dispatcher_sender,
handlers,
));
}
fn process_dispatcher_messages(&mut self) {
let messages: Vec<ServerMessage> = self
.dispatcher_receiver
.as_ref()
.map_or_else(Vec::new, |receiver| receiver.try_iter().collect());
for msg in &messages {
self.handle_message(msg.clone());
}
}
pub fn file_search(&mut self, token: u32, query: &str) {
self.queue_message(MessageFactory::build_file_search_message(
token, query,
));
}
fn process_read(&mut self) {
if self.reader.buffer_len() > 0 {
self.extract_and_process_messages();
}
{
let Some(stream) = self.stream.as_mut() else {
return;
};
match self.reader.read_from_socket(stream) {
Ok(()) => {}
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {}
Err(ref e) if e.kind() == io::ErrorKind::TimedOut => {
debug!("[server] Read operation timed out",);
}
Err(e) => {
error!(
"[server] Error reading from server: {} (kind: {:?}). Disconnecting.",
e,
e.kind()
);
self.disconnect_with_error();
return;
}
}
}
self.extract_and_process_messages();
}
fn extract_and_process_messages(&mut self) {
let mut extracted_count = 0;
loop {
match self.reader.extract_message() {
Ok(Some(mut message)) => {
extracted_count += 1;
trace!(
"[server] ← Message #{}: {:?}",
extracted_count,
message
.get_message_name(
MessageType::Server,
message.get_message_code()
)
.map_err(|e| e.to_string())
);
if let Some(ref dispatcher) = self.dispatcher {
dispatcher.dispatch(&mut message);
} else {
warn!("[server] No dispatcher available!",);
}
}
Err(e) => {
warn!(
"[server] Error extracting message: {}. Disconnecting.",
e
);
self.disconnect_with_error();
return;
}
Ok(None) => {
break;
}
}
}
self.process_dispatcher_messages();
}
/// Forward a chat-room event to the client operations loop.
fn forward_room_event(&self, event: RoomEvent) {
if let Err(e) =
self.client_channel.send(ClientOperation::RoomEvent(event))
{
error!("[server] Error forwarding room event to client: {}", e);
}
}
fn queue_message(&mut self, message: Message) {
if let Some(sender) = &self.dispatcher_sender {
match sender.send(ServerMessage::SendMessage(message)) {
Ok(()) => {}
Err(e) => error!("Failed to send: {}", e),
}
} else {
self.queued_messages
.push(ServerMessage::SendMessage(message));
}
}
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;
};
trace!(
"[server] ➡ {:?}",
message
.get_message_name(
MessageType::Server,
u32::from_le_bytes(
message.get_slice(0, 4).try_into().unwrap_or_default()
)
)
.map_err(|e| e.to_string())
);
if let Err(e) = stream.write_all(&message.get_buffer()) {
error!("[server] Error writing message: {}. Disconnecting.", e);
self.disconnect_with_error();
return;
}
if let Err(e) = stream.flush() {
error!("[server] Error flushing stream: {}. Disconnecting.", e);
self.disconnect_with_error();
}
}
/// The server is closing this connection because the same account logged
/// in elsewhere. Nothing reconnects it, so the session is over.
fn handle_relogged(&mut self) {
error!(
"[server] another login took this username; this session has been \
closed by the server"
);
self.session.record(SessionLoss::Displaced);
self.disconnect();
}
fn disconnect_with_error(&mut self) {
debug!("[server] disconnect");
if matches!(self.connection_state, ConnectionState::Connected) {
self.session.record(SessionLoss::Disconnected);
}
self.disconnect();
}
fn disconnect(&mut self) {
debug!("[server] disconnected");
self.stream.take();
self.connection_state = ConnectionState::Disconnected;
self.dispatcher = None;
self.dispatcher_sender = None;
self.dispatcher_receiver = None;
self.queued_messages.clear();
self.reader = MessageReader::new();
if let Ok(mut ctx) = self.context.write_safe() {
ctx.logged_in = None;
}
}
fn check_connection_status(&mut self) {
let ConnectionState::Connecting { since } = self.connection_state
else {
return;
};
if since.elapsed() > Duration::from_secs(20) {
error!("[server] Connection timeout after 20 seconds");
self.disconnect_with_error();
return;
}
let Some(ref stream) = self.stream else {
return;
};
match stream.peer_addr() {
Ok(_) => {
self.connection_state = ConnectionState::Connected;
self.on_connection_established();
}
Err(ref e) if e.kind() == io::ErrorKind::NotConnected => {}
Err(e) => {
error!("[server] Connection failed: {}", e);
self.disconnect_with_error();
}
}
}
fn on_connection_established(&mut self) {
self.initialize_dispatcher();
let queued = std::mem::take(&mut self.queued_messages);
for msg in queued {
self.handle_message(msg);
}
if let Some(ref handle) = self.self_handle {
handle.send(ServerMessage::ProcessRead).ok();
}
self.process_read();
}
}
impl Actor for ServerActor {
type Message = ServerMessage;
fn handle(&mut self, msg: Self::Message) {
self.handle_message(msg);
}
fn on_start(&mut self) {
if self.stream.is_none() {
let _ = self.initiate_connection();
} else {
self.connection_state = ConnectionState::Connected;
self.on_connection_established();
}
}
fn on_stop(&mut self) {
trace!("[server] actor stopping");
self.disconnect();
}
fn tick(&mut self) {
match self.connection_state {
ConnectionState::Connecting { .. } => {
self.check_connection_status();
}
ConnectionState::Connected => {
if self.stream.is_some() {
self.process_read();
}
}
ConnectionState::Disconnected => {}
}
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,528 @@
//! What the actor does with a message once it has one: the dispatch and the
//! per-message handlers behind it.
//!
//! A child module, so these keep reaching the actor's private state without
//! any of it being widened for the move.
use super::{
ClientOperation, ClientVersion, ConnectionState, ConnectionType, Duration,
LOGIN_VERDICT_TIMEOUT, MessageFactory, Peer, RoomEvent, RwLockExt,
ServerActor, ServerMessage, SoulseekRs, UserMessage, debug, error,
post_login_messages,
};
impl ServerActor {
pub(super) fn handle_message(&mut self, msg: ServerMessage) {
if !matches!(self.connection_state, ConnectionState::Connected)
&& !matches!(
&msg,
ServerMessage::ProcessRead | ServerMessage::Login { .. }
)
{
self.queued_messages.push(msg);
return;
}
match msg {
ServerMessage::ConnectToPeer(peer) => {
self.handle_connect_to_peer(peer);
}
ServerMessage::LoginStatus(message) => {
self.handle_login_status(message);
}
ServerMessage::Relogged => self.handle_relogged(),
ServerMessage::PierceFirewall(token) => {
self.send_message(
MessageFactory::build_pierce_firewall_message(token),
);
}
ServerMessage::SendMessage(message) => {
self.send_message(message);
}
ServerMessage::GetPeerAddress(username) => {
self.send_message(MessageFactory::build_get_peer_address(
&username,
));
}
ServerMessage::GetPeerAddressResponse {
username,
host,
port,
obfuscation_type,
obfuscated_port,
} => {
self.handle_get_peer_address_response(
username,
host,
port,
obfuscation_type,
obfuscated_port,
);
}
ServerMessage::PrivateMessageReceived(user_message) => {
self.handle_private_message_received(user_message);
}
ServerMessage::ProcessRead => {
self.process_read();
}
ServerMessage::Login {
username,
password,
version,
response,
} => {
self.handle_login(username, password, version, response);
}
ServerMessage::FileSearch { token, query } => {
self.file_search(token, &query);
}
ServerMessage::FileSearchRequest {
username,
token,
query,
} => {
self.handle_file_search_request(username, token, query);
}
other => self.handle_social_message(other),
}
}
/// Rooms and users (codes 7, 5, 36, 13-17, 64): forwarded as room events
/// and user status or stats. Split out, like the standing traffic below,
/// only to keep `handle_message`'s match readable.
fn handle_social_message(&mut self, message: ServerMessage) {
match message {
ServerMessage::RoomListReceived(rooms) => {
self.forward_room_event(RoomEvent::List(rooms));
}
ServerMessage::UserStatusReceived {
username,
status,
privileged,
} => {
self.forward_to_client(ClientOperation::UserStatusReceived {
username,
status,
privileged,
});
}
ServerMessage::WatchedUserReceived {
username,
exists,
status,
average_speed,
shared_files,
shared_folders,
} => {
self.forward_to_client(ClientOperation::WatchedUserReceived {
username,
exists,
status,
average_speed,
shared_files,
shared_folders,
});
}
ServerMessage::UserStatsReceived {
username,
average_speed,
shared_files,
shared_folders,
} => {
self.forward_to_client(ClientOperation::UserStatsReceived {
username,
average_speed,
shared_files,
shared_folders,
});
}
ServerMessage::RoomJoined { room, users } => {
self.forward_room_event(RoomEvent::Joined { room, users });
}
ServerMessage::RoomMemberStats { room, stats } => {
self.forward_to_client(ClientOperation::RoomMemberStats {
room,
stats,
});
}
ServerMessage::RoomLeft { room } => {
self.forward_room_event(RoomEvent::Left { room });
}
ServerMessage::RoomMessageReceived {
room,
username,
message,
} => {
self.forward_room_event(RoomEvent::Message {
room,
username,
message,
});
}
ServerMessage::RoomUserJoined { room, username } => {
self.forward_room_event(RoomEvent::UserJoined {
room,
username,
});
}
ServerMessage::RoomUserLeft { room, username } => {
self.forward_room_event(RoomEvent::UserLeft { room, username });
}
ServerMessage::RoomTickers { room, tickers } => {
self.forward_room_event(RoomEvent::Tickers { room, tickers });
}
ServerMessage::RoomTickerAdded {
room,
username,
ticker,
} => {
self.forward_room_event(RoomEvent::TickerAdded {
room,
username,
ticker,
});
}
ServerMessage::RoomTickerRemoved { room, username } => {
self.forward_room_event(RoomEvent::TickerRemoved {
room,
username,
});
}
ServerMessage::GlobalRoomMessageReceived {
room,
username,
message,
} => {
self.forward_room_event(RoomEvent::GlobalMessage {
room,
username,
message,
});
}
other => self.handle_standing_message(other),
}
}
/// The wishlist and privilege traffic: standing searches (codes 103/104) and
/// who is privileged (codes 69/92).
///
/// Split out only because it keeps `handle_message`'s match to a readable
/// length; there is no behaviour here beyond dispatch.
fn handle_standing_message(&mut self, message: ServerMessage) {
match message {
ServerMessage::PossibleParents(candidates) => {
self.forward_to_client(ClientOperation::PossibleParents(
candidates,
));
}
ServerMessage::ResetDistributed => {
self.forward_to_client(ClientOperation::ResetDistributed);
}
ServerMessage::WishlistSearch { token, query } => {
self.queue_message(MessageFactory::build_wishlist_search(
token, &query,
));
}
ServerMessage::WishlistInterval(seconds) => {
self.forward_to_client(ClientOperation::WishlistInterval(
seconds,
));
}
ServerMessage::PrivilegedUsers(users) => {
self.forward_to_client(ClientOperation::PrivilegedUsers(users));
}
ServerMessage::OwnPrivileges(seconds) => {
self.forward_to_client(ClientOperation::OwnPrivileges(seconds));
}
ServerMessage::CheckPrivileges => {
self.queue_message(MessageFactory::build_check_privileges());
}
message @ (ServerMessage::RecommendationsReceived { .. }
| ServerMessage::GlobalRecommendationsReceived {
..
}
| ServerMessage::ItemRecommendationsReceived {
..
}
| ServerMessage::SimilarUsersReceived { .. }
| ServerMessage::ItemSimilarUsersReceived { .. }
| ServerMessage::UserInterestsReceived(..)) => {
self.handle_taste_message(message);
}
ServerMessage::CantConnectToPeer { token } => {
self.forward_to_client(ClientOperation::CantConnectToPeer {
token,
});
}
ServerMessage::PrivateRoomMembers { room, users } => {
self.forward_room_event(RoomEvent::PrivateMembers {
room,
users,
});
}
ServerMessage::PrivateRoomOperators { room, users } => {
self.forward_room_event(RoomEvent::PrivateOperators {
room,
users,
});
}
ServerMessage::PrivateRoomRosterChanged {
room,
username,
members,
added,
} => {
self.forward_room_event(RoomEvent::PrivateRosterChanged {
room,
username,
members,
added,
});
}
ServerMessage::OwnRoomStandingChanged {
room,
members,
granted,
} => {
self.forward_room_event(RoomEvent::OwnStandingChanged {
room,
members,
granted,
});
}
ServerMessage::CantCreateRoom { room } => {
self.forward_room_event(RoomEvent::CantCreate { room });
}
ServerMessage::ParentMinSpeed(speed) => {
self.forward_to_client(ClientOperation::ParentMinSpeed(speed));
}
ServerMessage::ParentSpeedRatio(ratio) => {
self.forward_to_client(ClientOperation::ParentSpeedRatio(
ratio,
));
}
ServerMessage::ExcludedSearchPhrases(phrases) => {
self.forward_to_client(ClientOperation::ExcludedSearchPhrases(
phrases,
));
}
other => {
error!("[server] unroutable message: {:?}", other);
}
}
}
/// What the server says about taste: the recommendation and similar-user
/// answers, and a user's interests. Split off `handle_standing_message`
/// only to keep that dispatch readable — these arms share nothing with
/// the rest of it.
fn handle_taste_message(&self, message: ServerMessage) {
match message {
ServerMessage::RecommendationsReceived {
recommended,
unrecommended,
} => {
self.forward_to_client(ClientOperation::Recommendations {
global: false,
recommended,
unrecommended,
});
}
ServerMessage::GlobalRecommendationsReceived {
recommended,
unrecommended,
} => {
self.forward_to_client(ClientOperation::Recommendations {
global: true,
recommended,
unrecommended,
});
}
ServerMessage::ItemRecommendationsReceived {
item,
recommendations,
} => {
self.forward_to_client(ClientOperation::ItemRecommendations {
item,
recommendations,
});
}
ServerMessage::SimilarUsersReceived { users } => {
self.forward_to_client(ClientOperation::SimilarUsers(users));
}
ServerMessage::ItemSimilarUsersReceived { item, usernames } => {
self.forward_to_client(ClientOperation::ItemSimilarUsers {
item,
usernames,
});
}
ServerMessage::UserInterestsReceived(interests) => {
self.forward_to_client(ClientOperation::UserInterests(
interests,
));
}
// `handle_standing_message` routes only the variants above here.
_ => {}
}
}
fn handle_connect_to_peer(&self, peer: Peer) {
if let Some(op) = match peer.connection_type {
ConnectionType::P | ConnectionType::F => {
Some(ClientOperation::ConnectToPeer(peer))
}
ConnectionType::D => None,
} && let Err(e) = self.client_channel.send(op)
{
error!("[server] failed to send ConnectToPeer: {}", e);
}
}
pub(super) fn handle_login_status(&mut self, message: bool) {
// Send the post-login handshake exactly once, only on success,
// on the live path (the old ServerActor::login did this but was
// never called). Advertises real shared counts and, when
// listening, the port peers must connect to.
if message {
for msg in post_login_messages(
self.enable_listen,
self.listen_port,
self.shared_folder_count,
self.shared_file_count,
) {
self.send_message(msg);
}
self.session.clear();
// The distributed stance is the leaf's to announce, and a new
// session starts without a parent.
self.forward_to_client(ClientOperation::ResetDistributed);
// The server holds interests only for the life of a session.
self.forward_to_client(ClientOperation::SessionEstablished);
}
match self.context.write_safe() {
Ok(mut ctx) => ctx.logged_in = Some(message),
Err(e) => {
error!("[server] LoginStatus write: {}", e);
}
}
}
fn handle_get_peer_address_response(
&self,
username: String,
host: String,
port: u32,
obfuscation_type: u32,
obfuscated_port: u16,
) {
debug!(
"[server] Received GetPeerAddress response for {}: {}:{} (obf_type: {}, obf_port: {})",
username, host, port, obfuscation_type, obfuscated_port
);
if let Err(e) =
self.client_channel
.send(ClientOperation::GetPeerAddressResponse {
username,
host,
port,
obfuscation_type,
obfuscated_port,
})
{
error!(
"[server] Error forwarding GetPeerAddress response to client: {}",
e
);
}
}
/// Hand an operation to the client loop, logging a dead channel rather
/// than unwinding the actor.
fn forward_to_client(&self, operation: ClientOperation) {
if let Err(e) = self.client_channel.send(operation) {
error!("[server] Error forwarding to client: {}", e);
}
}
fn handle_private_message_received(&self, user_message: UserMessage) {
debug!("[server] Private message from {}", user_message.username());
if let Err(e) = self
.client_channel
.send(ClientOperation::PrivateMessageReceived(user_message))
{
error!(
"[server] Error forwarding private message to client: {}",
e
);
}
}
pub(super) fn handle_login(
&mut self,
username: String,
password: String,
version: ClientVersion,
response: std::sync::mpsc::Sender<Result<bool, SoulseekRs>>,
) {
if self.stream.is_none() && !self.initiate_connection() {
let _ = response.send(Err(SoulseekRs::NotConnected));
return;
}
if let Ok(mut ctx) = self.context.write_safe() {
ctx.logged_in = None;
}
self.queue_message(MessageFactory::build_login_message(
&username, &password, version,
));
let start = std::time::Instant::now();
let context = self.context.clone();
std::thread::spawn(move || {
loop {
if start.elapsed() >= LOGIN_VERDICT_TIMEOUT {
let _ = response.send(Err(SoulseekRs::Timeout));
break;
}
let logged_in = match context.read_safe() {
Ok(ctx) => ctx.logged_in,
Err(e) => {
let _ = response.send(Err(e));
break;
}
};
if let Some(logged_in) = logged_in {
let result = if logged_in {
Ok(true)
} else {
Err(SoulseekRs::AuthenticationFailed)
};
let _ = response.send(result);
break;
}
std::thread::sleep(Duration::from_millis(100));
}
});
}
fn handle_file_search_request(
&self,
username: String,
token: u32,
query: String,
) {
if let Err(e) =
self.client_channel.send(ClientOperation::IncomingSearch {
from_parent: false,
username,
token,
query,
})
{
error!("[server] forward IncomingSearch: {}", e);
}
}
}
@@ -0,0 +1,277 @@
use super::*;
fn code_of(message: &Message) -> u32 {
u32::from_le_bytes(message.get_data()[0..4].try_into().unwrap())
}
#[test]
fn a_timed_out_connect_parks_the_actor_in_disconnected() {
let mut actor = parked_actor(1);
actor.connection_state = ConnectionState::Connecting {
since: Instant::now().checked_sub(Duration::from_secs(21)).unwrap(),
};
actor.tick();
assert!(
matches!(actor.connection_state, ConnectionState::Disconnected),
"a timed-out connect must leave Connecting"
);
}
#[cfg(unix)]
#[test]
fn the_server_socket_keeps_itself_alive() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let mut actor = parked_actor(listener.local_addr().unwrap().port());
assert!(actor.initiate_connection());
assert!(
crate::utils::keepalive::keepalive_enabled(
actor.stream.as_ref().unwrap()
),
"an idle NAT mapping must be kept alive and a dead path noticed"
);
}
fn parked_actor(port: u16) -> ServerActor {
parked_actor_with_client(port).0
}
fn parked_actor_with_client(
port: u16,
) -> (ServerActor, std::sync::mpsc::Receiver<ClientOperation>) {
let (tx, rx) = std::sync::mpsc::channel();
let actor = ServerActor::new(
PeerAddress::new("127.0.0.1".to_string(), port),
tx,
0,
false,
0,
0,
);
(actor, rx)
}
#[test]
fn dropping_the_connection_forgets_the_dispatcher_and_the_verdict() {
let mut actor = parked_actor(1);
actor.initialize_dispatcher();
actor.connection_state = ConnectionState::Connected;
actor.handle_login_status(true);
actor.disconnect_with_error();
assert_eq!(actor.session.loss(), Some(SessionLoss::Disconnected));
assert!(
actor.dispatcher_sender.is_none(),
"a login queued now must wait in the buffer, not in a channel \
the next connection replaces"
);
assert_eq!(
actor.context.read().unwrap().logged_in,
None,
"the old verdict must not answer the next login"
);
}
#[test]
fn disconnect_clears_a_partial_frame_so_the_next_session_reframes_clean() {
use std::io::Write;
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();
let partial_frame = [10u8, 0, 0, 0, 1, 2, 3];
server_side.write_all(&partial_frame).unwrap();
server_side.flush().unwrap();
client.set_nonblocking(true).unwrap();
let mut actor = parked_actor(addr.port());
actor.stream = Some(client);
actor.connection_state = ConnectionState::Connected;
for _ in 0..50 {
actor.process_read();
if actor.reader.buffer_len() > 0 {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(
actor.reader.buffer_len() > 0,
"an incomplete frame should be waiting for its rest"
);
actor.disconnect();
assert_eq!(
actor.reader.buffer_len(),
0,
"a stale partial frame carried across a reconnect reads the next \
session's bytes as a length prefix and wedges recovery"
);
}
#[test]
fn a_login_on_a_parked_actor_dials_again_and_queues_the_login() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let mut actor = parked_actor(listener.local_addr().unwrap().port());
actor.session.record(SessionLoss::Disconnected);
let (response, _verdict) = std::sync::mpsc::channel();
actor.handle_login(
"u".into(),
"p".into(),
ClientVersion::default(),
response,
);
assert!(
matches!(actor.connection_state, ConnectionState::Connecting { .. }),
"a parked actor must dial again on login"
);
assert_eq!(
actor.queued_messages.len(),
1,
"the login waits for the connection to come up"
);
}
#[test]
fn a_parked_actor_handles_login_rather_than_queueing_it() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let mut actor = parked_actor(listener.local_addr().unwrap().port());
actor.session.record(SessionLoss::Disconnected);
let (response, _verdict) = std::sync::mpsc::channel();
actor.handle_message(ServerMessage::Login {
username: "u".into(),
password: "p".into(),
version: ClientVersion::default(),
response,
});
assert!(
matches!(actor.connection_state, ConnectionState::Connecting { .. }),
"a parked actor must act on a login, not park it in the queue"
);
assert!(
actor
.queued_messages
.iter()
.all(|m| !matches!(m, ServerMessage::Login { .. })),
"the login must not be sitting unhandled in the queue"
);
}
#[test]
fn a_login_with_nobody_listening_is_refused_at_once() {
let mut actor = parked_actor(1);
let (response, verdict) = std::sync::mpsc::channel();
actor.handle_login(
"u".into(),
"p".into(),
ClientVersion::default(),
response,
);
assert!(
matches!(
verdict.recv_timeout(Duration::from_secs(5)),
Ok(Err(SoulseekRs::NotConnected))
),
"a refused dial must not make the caller wait out the verdict \
timeout"
);
}
#[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);
actor.session.record(SessionLoss::Disconnected);
actor.handle_login_status(true);
assert_eq!(actor.session.loss(), None);
}
// A new session starts without a parent: the leaf drops whatever tree it
// hung from and announces its parentless stance from there.
#[test]
fn a_successful_login_resets_the_distributed_leaf() {
let (mut actor, client) = parked_actor_with_client(1);
actor.handle_login_status(true);
assert!(matches!(
client.try_recv(),
Ok(ClientOperation::ResetDistributed)
));
// A new session also has to be told what only the session held: our
// interests, which the server forgets when the old one ended.
assert!(matches!(
client.try_recv(),
Ok(ClientOperation::SessionEstablished)
));
actor.handle_login_status(false);
assert!(client.try_recv().is_err(), "a failed login resets nothing");
}
#[test]
fn post_login_messages_carry_counts_and_conditional_wait_port() {
let messages = post_login_messages(true, 4321, 3, 7);
let codes: Vec<u32> = messages.iter().map(code_of).collect();
// SharedFolders; SetStatus; SetWaitPort.
assert_eq!(codes, vec![35, 28, 2]);
// The SharedFolders message (code 35) carries the real counts.
let shared = messages[0].get_data();
assert_eq!(u32::from_le_bytes(shared[4..8].try_into().unwrap()), 3);
assert_eq!(u32::from_le_bytes(shared[8..12].try_into().unwrap()), 7);
// Not listening omits SetWaitPort (code 2).
let no_listen = post_login_messages(false, 4321, 3, 7);
let codes: Vec<u32> = no_listen.iter().map(code_of).collect();
assert_eq!(codes, vec![35, 28]);
}
@@ -0,0 +1,99 @@
//! The values the server actor hands out: where a peer listens, a private
//! message, and the login context.
#[derive(Debug, Clone)]
pub struct PeerAddress {
host: String,
port: u16,
}
impl PeerAddress {
#[must_use]
pub const fn new(host: String, port: u16) -> Self {
Self { host, port }
}
#[must_use]
pub fn get_host(&self) -> &str {
&self.host
}
#[must_use]
pub const fn get_port(&self) -> u16 {
self.port
}
}
impl std::fmt::Display for PeerAddress {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}:{}", self.host, self.port)
}
}
#[derive(Debug, Default)]
pub struct Context {
pub logged_in: Option<bool>,
}
impl Context {
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
#[derive(Debug, Clone)]
pub struct UserMessage {
id: u32,
timestamp: u32,
username: String,
message: String,
new_message: bool,
}
impl UserMessage {
#[must_use]
pub const fn new(
id: u32,
timestamp: u32,
username: String,
message: String,
new_message: bool,
) -> Self {
Self {
id,
timestamp,
username,
message,
new_message,
}
}
/// The server-assigned id of this message (used to acknowledge it).
#[must_use]
pub const fn id(&self) -> u32 {
self.id
}
/// Unix timestamp the server recorded for this message.
#[must_use]
pub const fn timestamp(&self) -> u32 {
self.timestamp
}
/// The username of the sender.
#[must_use]
pub fn username(&self) -> &str {
&self.username
}
/// The message body.
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
/// Whether the server flagged this as freshly delivered (as opposed to a
/// message replayed because it was queued while the recipient was offline).
#[must_use]
pub const fn is_new(&self) -> bool {
self.new_message
}
}
+24
View File
@@ -0,0 +1,24 @@
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
#[derive(Clone)]
pub struct CancelHandle(Arc<Mutex<bool>>);
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<MutexGuard<'_, bool>> {
let guard = self.0.lock().unwrap_or_else(PoisonError::into_inner);
(!*guard).then_some(guard)
}
}
+430
View File
@@ -0,0 +1,430 @@
//! Serving children: the other half of the distributed search network.
//!
//! A leaf only reads the tree; a parent also carries it. When the client is
//! configured to accept children, peers dial us with a `D` connection, we tell
//! each one where our branch sits, and every search that reaches us is passed
//! down to all of them. That is the whole duty — a child answers searches
//! itself, and reports its own place to the server.
//!
//! How many children to carry follows Nicotine+: the server announces the
//! upload speed a parent needs (`ParentMinSpeed`, code 83) and the divisor
//! turning speed into a child count (`ParentSpeedRatio`, code 84), and a
//! client whose recorded speed is below the minimum carries none. Until the
//! server has reported our own speed there is nothing to apply the formula to,
//! so the configured cap stands — which is how slskd runs the whole time, with
//! an operator-set limit and no speed gate.
//!
//! Accepting children is opt-in ([`crate::ClientSettings::accept_children`]):
//! it costs a socket and the network's whole search stream per child, which is
//! not something a client should take on without being asked to.
use std::collections::HashMap;
use std::io::Write;
use std::net::TcpStream;
use crate::message::Message;
use crate::message::distributed;
use crate::{debug, trace};
/// How many children to carry at once. Nicotine+ caps its speed-derived limit
/// here too, for the same reason: each child is a socket and a copy of the
/// network's whole search stream.
pub const MAX_CHILDREN: usize = 10;
/// The child limit the server's figures imply, following Nicotine+:
/// `speed / ratio / 100`, capped, and none at all when our recorded speed is
/// below the minimum the server set.
///
/// `None` means the server's figures do not decide it — either it has not
/// reported our speed yet, or it never announced a ratio at all (soulfind
/// does not) — and the caller's own cap stands, which is how slskd runs the
/// whole time.
#[must_use]
pub fn limit_from_speed(
own_speed: Option<u32>,
min_speed: u32,
ratio: u32,
) -> Option<usize> {
if ratio == 0 {
return None;
}
let speed = own_speed?;
if speed < min_speed {
return Some(0);
}
Some((speed / ratio / 100) as usize)
}
/// The children hanging from us, keyed by username.
pub struct Children {
links: HashMap<String, TcpStream>,
/// The cap in force: [`MAX_CHILDREN`], lowered to what the server's
/// figures allow once it has reported our speed.
max: usize,
accepting: bool,
/// Whether anything is feeding us the search stream — a parent of our own,
/// or the server. Nicotine+ refuses children without one, and rightly:
/// a child hanging from a branch that receives nothing gets nothing.
fed: bool,
}
impl Default for Children {
fn default() -> Self {
Self::new(false)
}
}
impl Children {
#[must_use]
pub fn new(accepting: bool) -> Self {
Self {
links: HashMap::new(),
max: MAX_CHILDREN,
accepting,
fed: false,
}
}
/// Apply the server's figures: `own_speed` is what it records for us, and
/// the other two are what it announced in codes 83 and 84.
pub fn set_limits(
&mut self,
own_speed: Option<u32>,
min_speed: u32,
ratio: u32,
) {
self.max = limit_from_speed(own_speed, min_speed, ratio)
.map_or(MAX_CHILDREN, |limit| limit.min(MAX_CHILDREN));
debug!("[distributed] child limit is now {}", self.max);
}
/// Say whether something is feeding us the search stream. A client with
/// nothing to relay takes no children, and drops any it has.
pub const fn set_fed(&mut self, fed: bool) {
self.fed = fed;
}
#[must_use]
pub const fn is_fed(&self) -> bool {
self.fed
}
/// Whether we would take another child right now: we serve children, we
/// have something to relay, and we are under the cap. This is what the
/// server is told in `AcceptChildren` (code 100).
#[must_use]
pub fn has_room(&self) -> bool {
self.accepting && self.fed && self.links.len() < self.max
}
#[must_use]
pub fn len(&self) -> usize {
self.links.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.links.is_empty()
}
#[must_use]
pub fn usernames(&self) -> Vec<String> {
let mut names: Vec<String> = self.links.keys().cloned().collect();
names.sort();
names
}
/// Take on `username` as a child. Refused when we serve no children, have
/// nothing to relay, are full, or already carry that user.
pub fn accept(&mut self, username: &str, stream: TcpStream) -> bool {
if !self.has_room() {
return false;
}
// A second connection from a child we already carry is refused, as
// Nicotine+ does: the link we hold is the live one, and dropping it
// for an unproven second would cost that child its search stream.
if self.links.contains_key(username) {
debug!(
"[distributed] refusing child {}: already carried",
username
);
return false;
}
self.links.insert(username.to_string(), stream);
true
}
/// Drop every child, closing their links: what a client does when it can
/// no longer feed them.
pub fn drop_all(&mut self) {
if !self.links.is_empty() {
debug!("[distributed] dropping {} children", self.links.len());
}
self.links.clear();
}
pub fn remove(&mut self, username: &str) {
if self.links.remove(username).is_some() {
debug!("[distributed] child {} is gone", username);
}
}
/// Send one frame to `username`, dropping the child if the write fails.
pub fn send_to(&mut self, username: &str, message: &Message) {
let failed = match self.links.get_mut(username) {
Some(stream) => stream.write_all(&message.get_buffer()).is_err(),
None => return,
};
if failed {
self.remove(username);
}
}
/// Send one frame to every child, dropping those whose write fails: a
/// child that has gone away must not keep its slot.
pub fn broadcast(&mut self, message: &Message) {
let bytes = message.get_buffer();
let mut dead = Vec::new();
for (username, stream) in &mut self.links {
if stream.write_all(&bytes).is_err() {
dead.push(username.clone());
}
}
for username in dead {
self.remove(&username);
}
}
/// Tell `username` where our branch sits — what a child needs before it
/// can report its own place to the server.
pub fn send_stance_to(&mut self, username: &str, root: &str, level: u32) {
trace!(
"[distributed] telling {} we sit at {}/{}",
username, root, level
);
self.send_to(username, &distributed::build_branch_root(root));
self.send_to(
username,
&distributed::build_branch_level(
i32::try_from(level).unwrap_or(i32::MAX),
),
);
}
/// Tell every child our branch moved.
pub fn broadcast_stance(&mut self, root: &str, level: u32) {
self.broadcast(&distributed::build_branch_root(root));
self.broadcast(&distributed::build_branch_level(
i32::try_from(level).unwrap_or(i32::MAX),
));
}
/// Pass a search down the tree.
pub fn broadcast_search(
&mut self,
username: &str,
token: u32,
query: &str,
) {
if self.is_empty() {
return;
}
self.broadcast(&distributed::build_search(username, token, query));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
use std::net::{TcpListener, TcpStream};
/// A connected socket pair: the child's end and ours.
fn pair() -> (TcpStream, TcpStream) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
let ours = TcpStream::connect(addr).expect("connect");
let (theirs, _) = listener.accept().expect("accept");
(theirs, ours)
}
/// A registry that serves children and has something to relay.
fn serving() -> Children {
let mut children = Children::new(true);
children.set_fed(true);
children
}
fn read_frames(stream: &mut TcpStream, bytes: usize) -> Vec<u8> {
stream
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.expect("timeout");
let mut buf = vec![0u8; bytes];
stream.read_exact(&mut buf).expect("read");
buf
}
#[test]
fn a_client_that_does_not_accept_children_takes_none() {
let (_child, ours) = pair();
let mut children = Children::new(false);
children.set_fed(true);
assert!(!children.accept("alice", ours));
assert!(children.is_empty());
assert!(!children.has_room());
}
#[test]
fn a_client_nothing_feeds_takes_no_children() {
// Nicotine+ refuses children while it has neither a parent nor the
// server relaying searches: a child would hang from a branch that
// receives nothing.
let (_child, ours) = pair();
let mut children = Children::new(true);
assert!(!children.has_room(), "unfed means no room");
assert!(!children.accept("alice", ours));
children.set_fed(true);
let (_second, again) = pair();
assert!(children.accept("alice", again));
}
#[test]
fn children_are_let_go_when_the_stream_dries_up() {
let mut children = serving();
let (_child, ours) = pair();
children.accept("alice", ours);
children.set_fed(false);
children.drop_all();
assert!(children.is_empty());
assert!(!children.has_room());
}
#[test]
fn children_are_taken_up_to_the_cap() {
let mut children = serving();
let mut ends = Vec::new();
for i in 0..MAX_CHILDREN {
let (child, ours) = pair();
ends.push(child);
assert!(children.accept(&format!("user{i}"), ours));
}
assert_eq!(ends.len(), MAX_CHILDREN, "every link is still held");
assert!(!children.has_room(), "a full parent has no room");
let (_late, ours) = pair();
assert!(!children.accept("one_too_many", ours));
assert_eq!(children.len(), MAX_CHILDREN);
}
#[test]
fn a_second_link_from_a_child_we_carry_is_refused() {
// The link we hold is the live one; dropping it for an unproven
// second would cost that child its search stream.
let mut children = serving();
let (_first, ours) = pair();
assert!(children.accept("alice", ours));
let (_second, again) = pair();
assert!(!children.accept("alice", again));
assert_eq!(children.usernames(), vec!["alice".to_string()]);
}
#[test]
fn the_child_limit_follows_the_servers_figures() {
// Nicotine+: speed / ratio / 100, capped, and none at all below the
// minimum speed the server set.
assert_eq!(limit_from_speed(Some(50_000), 1_024, 50), Some(10));
assert_eq!(limit_from_speed(Some(10_000), 1_024, 50), Some(2));
assert_eq!(
limit_from_speed(Some(500), 1_024, 50),
Some(0),
"too slow to parent"
);
assert_eq!(
limit_from_speed(Some(10_000), 1_024, 0),
None,
"a server that never announced a ratio does not decide the limit"
);
assert_eq!(
limit_from_speed(None, 1_024, 50),
None,
"an unreported speed leaves the caller's own cap standing"
);
}
#[test]
fn a_speed_the_server_has_not_reported_leaves_the_cap_alone() {
let mut children = serving();
children.set_limits(None, 1_024, 50);
assert!(children.has_room(), "an unreported speed leaves us open");
// The server's figures win: at this speed the formula allows two.
children.set_limits(Some(10_000), 1_024, 50);
let mut ends = Vec::new();
for i in 0..2 {
let (child, ours) = pair();
ends.push(child);
assert!(children.accept(&format!("user{i}"), ours));
}
assert!(!children.has_room(), "two is the limit at this speed");
children.set_limits(Some(100), 1_024, 50);
assert!(!children.has_room(), "a slow client carries none");
children.set_limits(Some(100), 1_024, 0);
assert!(
children.has_room(),
"a server with no figures leaves our own cap standing"
);
assert_eq!(ends.len(), 2);
}
#[test]
fn a_search_reaches_every_child() {
let mut children = serving();
let (mut first, ours) = pair();
let (mut second, theirs) = pair();
children.accept("alice", ours);
children.accept("bob", theirs);
children.broadcast_search("seeker", 42, "jazz");
let expected =
distributed::build_search("seeker", 42, "jazz").get_buffer();
assert_eq!(read_frames(&mut first, expected.len()), expected);
assert_eq!(read_frames(&mut second, expected.len()), expected);
}
#[test]
fn a_child_that_hung_up_loses_its_slot() {
let mut children = serving();
let (child, ours) = pair();
children.accept("alice", ours);
drop(child);
// Writes keep landing in the send buffer until the peer's RST has
// crossed the loopback, which under load takes real wall-clock time.
for _ in 0..100 {
children.broadcast_search("seeker", 1, "q");
if children.is_empty() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(children.is_empty(), "a dead child is dropped");
}
#[test]
fn a_child_is_told_where_our_branch_sits() {
let mut children = serving();
let (mut child, ours) = pair();
children.accept("alice", ours);
children.send_stance_to("alice", "rooty", 3);
let mut expected = distributed::build_branch_root("rooty").get_buffer();
expected.extend(distributed::build_branch_level(3).get_buffer());
assert_eq!(read_frames(&mut child, expected.len()), expected);
}
}
+427
View File
@@ -0,0 +1,427 @@
use super::{
Arc, AtomicBool, Client, ClientContext, ClientOperation, ConnectionType,
DownloadPeer, Listen, Peer, PeerRegistry, Receiver, Result, RwLock,
RwLockExt, Sender, ServerActor, ServerMessage, Shares, SoulseekRs,
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.
const LOGIN_RESPONSE_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(45);
/// Soft open-file limit below which a session should expect "too many open
/// files": every peer holds a socket, and one busy search talks to hundreds
/// of them.
const COMFORTABLE_OPEN_FILES: u64 = 1024;
/// Say up front when the process cannot hold a busy session's sockets, so an
/// operator reads a warning with a number in it instead of an EMFILE storm an
/// hour later. The embedding process owns its rlimits; the library only looks.
#[cfg(unix)]
fn warn_if_open_file_limit_is_low() {
// SAFETY: getrlimit only fills the struct it is handed.
let limit = unsafe {
let mut limit = std::mem::zeroed::<libc::rlimit>();
if libc::getrlimit(libc::RLIMIT_NOFILE, &raw mut limit) != 0 {
return;
}
limit.rlim_cur
};
if limit < COMFORTABLE_OPEN_FILES {
warn!(
"only {limit} open files allowed; a busy session wants \
{COMFORTABLE_OPEN_FILES} or more — expect \"too many open \
files\" under load"
);
}
}
#[cfg(not(unix))]
fn warn_if_open_file_limit_is_low() {}
impl Client {
pub fn connect(&mut self) -> Result<()> {
warn_if_open_file_limit_is_low();
let (sender, message_reader): (
Sender<ClientOperation>,
Receiver<ClientOperation>,
) = mpsc::channel();
let mut ctx = self.context.write_safe()?;
let peer_registry = PeerRegistry::with_capacity(
ctx.actor_system.clone(),
sender.clone(),
self.username.clone(),
ctx.max_peers.clone(),
);
ctx.peer_registry = Some(peer_registry);
ctx.operations = Some(sender.clone());
let listen_sender = sender.clone();
// Scan the shared directories once into the read-only index, and
// report the real folder/file counts to the server on login.
let roots: Vec<std::path::PathBuf> = self
.shared_directories
.iter()
.filter(|dir| !dir.trim().is_empty())
.map(std::path::PathBuf::from)
.collect();
let shares = if roots.is_empty() {
Arc::new(Shares::empty())
} else {
let scanned = Shares::scan_many(&roots);
info!(
"Sharing {} files in {} folders from {} directories",
scanned.file_count(),
scanned.folder_count(),
roots.len()
);
Arc::new(scanned)
};
let shared_folder_count = shares.folder_count();
let shared_file_count = shares.file_count();
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.
let listener = if self.enable_listen {
let listener = Listen::bind(self.listen_port)?;
self.bound_port = Some(listener.local_addr()?.port());
Some(listener)
} else {
None
};
self.listener_stopped = Arc::new(AtomicBool::new(false));
let mut server_actor = ServerActor::new(
self.address.clone(),
sender,
self.bound_port.unwrap_or(self.listen_port),
self.enable_listen,
shared_folder_count,
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,
|actor, handle| {
actor.set_self_handle(handle);
},
));
if let Some(listener) = listener {
let client_sender = listen_sender;
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(
&listener,
client_sender,
context,
own_username,
stopped,
);
drop(listener);
released.store(true, Ordering::Relaxed);
});
}
Self::listen_to_client_operations(
message_reader,
self.context.clone(),
self.username.clone(),
);
Ok(())
}
/// Close the server connection and every peer connection.
///
/// The server sees the socket close and marks us offline, which is what
/// releases the username: a client that simply goes out of scope without
/// this leaves a session the server still believes in, so mail addressed
/// to us is delivered to a connection nobody is reading and later logins
/// collide with our own ghost.
///
/// Called automatically when the client is dropped. The peer listener's
/// 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();
}
let registry = match self.context.write_safe() {
Ok(mut ctx) => {
// Dropping our end of the operations channel is what lets its
// thread finish once the peers and the listener have gone.
ctx.operations = None;
ctx.peer_registry.clone()
}
Err(_) => None,
};
if let Some(registry) = registry {
registry.stop_all();
}
// The listener thread is parked in accept(), so it only notices the
// flag when a connection arrives: dial it once ourselves. Without
// 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()
&& 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));
}
}
}
/// Log in and wait for the server's verdict.
///
/// The wait is bounded: the actor only answers once it has a working
/// connection, so a server that never completes the TCP handshake would
/// otherwise block the caller forever. A caller that hits
/// [`SoulseekRs::Timeout`] can retry or give up, but it always regains
/// control.
pub fn login(&self) -> Result<bool> {
info!("Logging in as {}", self.username);
let Some(handle) = &self.server_handle else {
return Err(SoulseekRs::NotConnected);
};
if self.cancel.is_cancelled() {
return Err(SoulseekRs::NotConnected);
}
let (tx, rx) = std::sync::mpsc::channel();
let _ = handle.send(ServerMessage::Login {
username: self.username.clone(),
password: self.password.clone(),
version: self.version,
response: tx,
});
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),
}
}
}
/// Ask the server for a peer's address and open a direct control
/// connection to it. Downloads queued for that peer are sent automatically
/// once the connection is established.
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn connect_peer(&self, username: &str) -> Result<()> {
let handle = self
.server_handle
.as_ref()
.ok_or(SoulseekRs::NotConnected)?;
handle
.send(ServerMessage::GetPeerAddress(username.to_string()))
.map_err(|_| SoulseekRs::NotConnected)?;
Ok(())
}
pub fn set_max_peers(&self, max_peers: usize) {
if let Ok(ctx) = self.context.read_safe() {
ctx.max_peers
.store(max_peers.max(1), std::sync::atomic::Ordering::Relaxed);
}
}
#[allow(dead_code)]
pub fn remove_peer(&self, username: &str) {
let context = match self.context.read_safe() {
Ok(c) => c,
Err(e) => {
error!("[client] remove_peer: {}", e);
return;
}
};
if let Some(ref registry) = context.peer_registry
&& let Some(handle) = registry.remove_peer(username)
{
let _ = handle.stop();
}
}
pub(crate) fn register_peer_or_fail(
client_context: &Arc<RwLock<ClientContext>>,
peer: Peer,
stream: Option<TcpStream>,
reader: Option<crate::message::MessageReader>,
) -> bool {
let username = peer.username.clone();
let registered = {
let context = match client_context.read_safe() {
Ok(c) => c,
Err(e) => {
error!("[client] register peer read: {}", e);
return false;
}
};
if let Some(ref registry) = context.peer_registry {
let protected = context.protected_peers();
match registry
.register_peer_protected(peer, stream, reader, &protected)
{
Ok(_) => true,
Err(e) => {
warn!(
"[client] refusing peer connection for {:?}: {:?}",
username, e
);
false
}
}
} else {
trace!("PeerRegistry not initialized");
false
}
};
if !registered {
Self::fail_queued_downloads(client_context, &username);
}
registered
}
/// Tell the server we could not reach the peer it asked us to connect to,
/// quoting the token that peer is waiting on.
pub(crate) fn report_cant_connect(
client_context: &Arc<RwLock<ClientContext>>,
token: u32,
username: &str,
) {
let sender = match client_context.read_safe() {
Ok(ctx) => ctx.server_sender.clone(),
Err(_) => return,
};
let Some(sender) = sender else { return };
let message =
crate::message::server::MessageFactory::build_cant_connect_to_peer(
token, username,
);
let _ = sender.send(ServerMessage::SendMessage(message));
}
pub(crate) fn connect_to_peer(
peer: Peer,
client_context: Arc<RwLock<ClientContext>>,
own_username: String,
stream: Option<TcpStream>,
) {
let peer_clone = peer.clone();
trace!(
"[client] connecting to {}, with connection_type: {}, and token {:?}",
peer.username, peer.connection_type, peer.token
);
match peer.connection_type {
ConnectionType::P => {
Self::register_peer_or_fail(
&client_context,
peer_clone,
stream,
None,
);
}
ConnectionType::F => {
trace!(
"[client] downloading from: {}, {:?}",
peer.username, peer.token
);
let Some(token) = peer.token else {
error!(
"[client] F connection from {} without token",
peer.username
);
return;
};
let download_peer = DownloadPeer::new(
peer.username,
peer.host,
peer.port,
token,
false,
own_username,
);
let brokered = peer_clone.brokered;
let username = peer_clone.username;
match download_peer.download_file(
client_context.clone(),
None,
None,
) {
Ok((download, filename)) => {
trace!(
"[client] downloaded {} bytes {:?} ",
filename, download.size
);
}
Err(e) => {
trace!("[client] failed to download: {}", e);
// A file connection the server asked us to make and
// we could not complete owes that peer a
// CantConnectToPeer, exactly as a control connection
// does: it is waiting on this token.
if brokered {
Self::report_cant_connect(
&client_context,
token,
&username,
);
}
}
}
}
ConnectionType::D => {
// ponytail: we never dial out to adopt a child — children
// reach us through the listener. Tell the peer rather than
// leave it waiting; wire the outbound half if firewalled
// children turn out to matter.
if let (true, Some(token)) = (peer.brokered, peer.token) {
Self::report_cant_connect(
&client_context,
token,
&peer.username,
);
}
}
}
}
}
+804
View File
@@ -0,0 +1,804 @@
//! What the client's shared context does, as opposed to what it holds.
//!
//! A child module rather than a sibling: the state itself stays declared in
//! `client`, so these methods keep reaching its private fields without any
//! of them having to be widened for the move.
use super::{
ActorSystem, Arc, AtomicBool, AtomicUsize, BROKER_CONNECT_TIMEOUT,
BROWSE_PROTECT_WINDOW, ClientContext, DEFAULT_MAX_PEERS,
DEFAULT_UPLOAD_SLOTS, DownloadStatus, DownloadStore, HashMap, HashSet,
Instant, PENDING_PEER_TTL, Recommendation, RoomEvent, RoomInfo, RoomTicker,
RoomUserStats, SharedDirectory, Shares, SimilarUser, UserInfo,
UserInterests, UserMessage, UserPresence, UserStats, UserStatus, children,
distributed, sorted_unique,
};
impl ClientContext {
/// A context for the client that logs in as `username`.
#[must_use]
pub fn for_user(username: &str) -> Self {
Self {
leaf: distributed::Leaf::new(username),
..Self::new()
}
}
#[must_use]
pub fn new() -> Self {
let actor_system = Arc::new(ActorSystem::new());
Self {
peer_registry: None,
server_sender: None,
operations: None,
leaf: distributed::Leaf::new(""),
children: children::Children::default(),
announced_accept_children: None,
own_average_speed: None,
parent_min_speed: 0,
parent_speed_ratio: 0,
searches: HashMap::new(),
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(),
pending_peer_messages: HashMap::new(),
uploads: HashMap::new(),
active_uploads: HashMap::new(),
pending_serves: HashMap::new(),
brokered_uploads: HashMap::new(),
browse_results: HashMap::new(),
folder_contents: HashMap::new(),
peer_infos: HashMap::new(),
pending_browses: HashMap::new(),
room_list: Vec::new(),
room_events: Vec::new(),
room_members: HashMap::new(),
room_member_stats: HashMap::new(),
room_tickers: HashMap::new(),
private_room_members: HashMap::new(),
private_room_operators: HashMap::new(),
recommendations: None,
global_recommendations: None,
item_recommendations: HashMap::new(),
similar_users: Vec::new(),
item_similar_users: HashMap::new(),
user_interests: HashMap::new(),
own_interests: UserInterests::default(),
user_info: HashMap::new(),
watched_users: HashSet::new(),
wishlist_interval: None,
privileged_users: HashSet::new(),
own_privileges: None,
excluded_search_phrases: Vec::new(),
upload_queue: Vec::new(),
upload_seq: 0,
upload_slots: DEFAULT_UPLOAD_SLOTS,
last_upload_speed: 0,
upload_events: Vec::new(),
downloads: DownloadStore::new(),
actor_system,
}
}
/// Apply a chat-room event: keep the room-list snapshot and the per-room
/// rosters current, then queue the event for the client/UI to drain.
pub fn apply_room_event(&mut self, event: RoomEvent) {
match &event {
RoomEvent::List(rooms) => self.room_list.clone_from(rooms),
RoomEvent::Joined { room, users } => {
let mut members = users.clone();
members.sort();
members.dedup();
self.room_members.insert(room.clone(), members);
}
RoomEvent::Left { room } => {
self.room_members.remove(room);
// A board for a room we are not in is stale; the server sends
// 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 } => {
if let Some(members) = self.room_members.get_mut(room)
&& let Err(at) = members.binary_search(username)
{
members.insert(at, username.clone());
}
}
RoomEvent::UserLeft { room, username } => {
if let Some(members) = self.room_members.get_mut(room)
&& let Ok(at) = members.binary_search(username)
{
members.remove(at);
}
}
RoomEvent::Tickers { room, tickers } => {
self.room_tickers.insert(room.clone(), tickers.clone());
}
RoomEvent::TickerAdded {
room,
username,
ticker,
} => {
// A user has at most one ticker: an added one replaces theirs
// rather than stacking, which is how the server treats it.
let board = self.room_tickers.entry(room.clone()).or_default();
board.retain(|t| &t.username != username);
board.push(RoomTicker {
username: username.clone(),
ticker: ticker.clone(),
});
}
RoomEvent::TickerRemoved { room, username } => {
if let Some(board) = self.room_tickers.get_mut(room) {
board.retain(|t| &t.username != username);
}
}
RoomEvent::PrivateMembers { room, users } => {
self.private_room_members
.insert(room.clone(), sorted_unique(users));
}
RoomEvent::PrivateOperators { room, users } => {
self.private_room_operators
.insert(room.clone(), sorted_unique(users));
}
RoomEvent::PrivateRosterChanged {
room,
username,
members,
added,
} => {
let roster = if *members {
self.private_room_members.entry(room.clone()).or_default()
} else {
self.private_room_operators.entry(room.clone()).or_default()
};
match (added, roster.binary_search(username)) {
(true, Err(at)) => roster.insert(at, username.clone()),
(false, Ok(at)) => {
roster.remove(at);
}
_ => {}
}
}
RoomEvent::OwnStandingChanged {
room,
members,
granted,
} => {
// Losing membership takes both rosters with it: a room we
// cannot enter has none worth showing. Losing operatorship
// leaves us a member who still sees them, and the server
// narrates that roster change separately (code 144).
if !granted && *members {
self.private_room_members.remove(room);
self.private_room_operators.remove(room);
}
}
RoomEvent::Message { .. }
| RoomEvent::GlobalMessage { .. }
| RoomEvent::CantCreate { .. } => {}
}
self.room_events.push(event);
}
/// Remember an interest of our own so it can be sent again next login.
/// Interests are matched by the server case-insensitively, and Nicotine+
/// lowercases them before sending; do the same so two spellings of one
/// interest cannot both be held.
/// Returns the stored spelling, or `None` for an empty item — which is
/// no interest, and must not be sent to the server either.
pub fn add_own_interest(
&mut self,
item: &str,
liked: bool,
) -> Option<String> {
let item = item.trim().to_lowercase();
if item.is_empty() {
return None;
}
let list = if liked {
&mut self.own_interests.likes
} else {
&mut self.own_interests.hates
};
if !list.contains(&item) {
list.push(item.clone());
}
Some(item)
}
/// Forget one of our own interests. `None` for an empty item, as above.
pub fn remove_own_interest(
&mut self,
item: &str,
liked: bool,
) -> Option<String> {
let item = item.trim().to_lowercase();
if item.is_empty() {
return None;
}
let list = if liked {
&mut self.own_interests.likes
} else {
&mut self.own_interests.hates
};
list.retain(|held| held != &item);
Some(item)
}
/// What we like and hate, as last set.
#[must_use]
pub fn own_interests(&self) -> UserInterests {
self.own_interests.clone()
}
/// Take the `AcceptChildren` answer to send, or `None` when the server
/// already has this one.
pub fn accept_children_change(&mut self) -> Option<bool> {
let has_room = self.children.has_room();
(self.announced_accept_children != Some(has_room)).then(|| {
self.announced_accept_children = Some(has_room);
has_room
})
}
/// The upload speed the server records for us, if it has said.
#[must_use]
pub const fn own_average_speed(&self) -> Option<u32> {
self.own_average_speed
}
/// Record what the server says our own upload speed is, and re-derive the
/// child limit from it.
pub fn set_own_average_speed(&mut self, speed: u32) {
self.own_average_speed = Some(speed);
self.apply_child_limits();
}
/// Record the server's parent figures (codes 83 and 84).
pub fn set_parent_min_speed(&mut self, speed: u32) {
self.parent_min_speed = speed;
self.apply_child_limits();
}
pub fn set_parent_speed_ratio(&mut self, ratio: u32) {
self.parent_speed_ratio = ratio;
self.apply_child_limits();
}
fn apply_child_limits(&mut self) {
self.children.set_limits(
self.own_average_speed,
self.parent_min_speed,
self.parent_speed_ratio,
);
}
/// Whether something is feeding us the distributed search stream: a
/// parent of our own, or the server relaying searches to us. Children may
/// only hang from a client that has one.
pub fn set_fed_by(&mut self, fed: bool) {
self.children.set_fed(fed);
if !fed {
self.children.drop_all();
}
}
/// Record what `username` says about itself.
pub fn store_peer_info(
&mut self,
username: String,
info: crate::message::peer::PeerInfo,
) {
self.peer_infos.insert(username, info);
}
/// What `username` last said about itself, if anything.
#[must_use]
pub fn peer_info(
&self,
username: &str,
) -> Option<crate::message::peer::PeerInfo> {
self.peer_infos.get(username).cloned()
}
/// Drop what we hold about `username`, so a poll after a fresh request
/// cannot report the previous answer.
pub fn invalidate_peer_info(&mut self, username: &str) {
self.peer_infos.remove(username);
}
/// Record one folder's listing from `username`.
pub fn store_folder_contents(
&mut self,
username: String,
folder: String,
directories: Vec<SharedDirectory>,
) {
self.folder_contents.insert((username, folder), directories);
}
/// Remove and return the listing of `folder` from `username`, if it has
/// arrived.
pub fn take_folder_contents(
&mut self,
username: &str,
folder: &str,
) -> Option<Vec<SharedDirectory>> {
self.folder_contents
.remove(&(username.to_string(), folder.to_string()))
}
/// Who may enter the private room `room`, as last reported.
#[must_use]
pub fn private_room_members(&self, room: &str) -> Vec<String> {
self.private_room_members
.get(room)
.cloned()
.unwrap_or_default()
}
/// Who runs the private room `room`, as last reported.
#[must_use]
pub fn private_room_operators(&self, room: &str) -> Vec<String> {
self.private_room_operators
.get(room)
.cloned()
.unwrap_or_default()
}
/// The private rooms we belong to, in a stable order.
#[must_use]
pub fn private_rooms(&self) -> Vec<String> {
let mut rooms: Vec<String> =
self.private_room_members.keys().cloned().collect();
rooms.sort();
rooms
}
/// The ticker board of `room` as last reported by the server.
#[must_use]
pub fn room_tickers(&self, room: &str) -> Vec<RoomTicker> {
self.room_tickers.get(room).cloned().unwrap_or_default()
}
/// Record a recommendations reply; `global` selects the server-wide set.
pub fn apply_recommendations(
&mut self,
global: bool,
recommended: Vec<Recommendation>,
unrecommended: Vec<Recommendation>,
) {
let slot = if global {
&mut self.global_recommendations
} else {
&mut self.recommendations
};
*slot = Some((recommended, unrecommended));
}
/// The last recommendations reply, or `None` until one arrives.
#[must_use]
pub fn recommendations(
&self,
global: bool,
) -> Option<(Vec<Recommendation>, Vec<Recommendation>)> {
if global {
self.global_recommendations.clone()
} else {
self.recommendations.clone()
}
}
pub fn apply_item_recommendations(
&mut self,
item: String,
recommendations: Vec<Recommendation>,
) {
self.item_recommendations.insert(item, recommendations);
}
#[must_use]
pub fn item_recommendations(&self, item: &str) -> Vec<Recommendation> {
self.item_recommendations
.get(item)
.cloned()
.unwrap_or_default()
}
pub fn apply_similar_users(&mut self, users: Vec<SimilarUser>) {
self.similar_users = users;
}
#[must_use]
pub fn similar_users(&self) -> Vec<SimilarUser> {
self.similar_users.clone()
}
pub fn apply_item_similar_users(
&mut self,
item: String,
usernames: Vec<String>,
) {
self.item_similar_users.insert(item, usernames);
}
#[must_use]
pub fn item_similar_users(&self, item: &str) -> Vec<String> {
self.item_similar_users
.get(item)
.cloned()
.unwrap_or_default()
}
pub fn apply_user_interests(&mut self, interests: UserInterests) {
self.user_interests
.insert(interests.username.clone(), interests);
}
#[must_use]
pub fn user_interests(&self, username: &str) -> Option<UserInterests> {
self.user_interests.get(username).cloned()
}
/// Record the phrases the server excludes (code 160), lowercased here so
/// the per-file check is a plain `contains` on a lowercased path.
pub fn set_excluded_search_phrases(&mut self, phrases: Vec<String>) {
self.excluded_search_phrases =
phrases.iter().map(|p| p.to_lowercase()).collect();
}
/// The phrases the server refuses to search for.
#[must_use]
pub fn excluded_search_phrases(&self) -> Vec<String> {
self.excluded_search_phrases.clone()
}
/// Forget any interests cached for `username`, so a poll after a fresh
/// request cannot mistake the previous answer for this one.
pub fn invalidate_user_interests(&mut self, username: &str) {
self.user_interests.remove(username);
}
/// Record a `GetUserStatus` reply, merging it with any statistics already
/// received for that user.
pub fn apply_user_status(
&mut self,
username: String,
status: u32,
privileged: bool,
) {
self.user_info
.entry(username.clone())
.or_insert_with(|| UserInfo::pending(username))
.presence = Some(UserPresence {
status: UserStatus::from_code(status),
privileged,
});
}
/// Forget what we know about `username`, so the next poll reports the
/// answer to the request being made now rather than the previous one.
pub fn invalidate_user_info(&mut self, username: &str) {
self.user_info.remove(username);
}
/// Record a `GetUserStats` reply, merging it with any status already
/// received for that user.
pub fn apply_user_stats(
&mut self,
username: String,
average_speed: u32,
shared_files: u32,
shared_folders: u32,
) {
self.user_info
.entry(username.clone())
.or_insert_with(|| UserInfo::pending(username))
.stats = Some(UserStats {
average_speed,
shared_files,
shared_folders,
});
}
/// Record that we are now watching `username`.
pub fn add_watched_user(&mut self, username: &str) {
self.watched_users.insert(username.to_string());
}
/// Forget a watch, dropping any snapshot we held for that user so a later
/// re-watch reports a fresh answer rather than a stale one.
pub fn remove_watched_user(&mut self, username: &str) {
self.watched_users.remove(username);
self.user_info.remove(username);
}
/// Everyone we are currently watching, sorted by name.
#[must_use]
pub fn watched_users(&self) -> Vec<String> {
let mut users: Vec<String> =
self.watched_users.iter().cloned().collect();
users.sort();
users
}
/// Record the initial snapshot from a `WatchUser` reply. A username the
/// server does not know carries no stats and is dropped from the watch
/// list, since the server will never push anything for it.
pub fn apply_watched_user(
&mut self,
username: String,
exists: bool,
status: Option<u32>,
average_speed: Option<u32>,
shared_files: Option<u32>,
shared_folders: Option<u32>,
) {
if !exists {
self.watched_users.remove(&username);
return;
}
if let Some(status) = status {
let entry = self
.user_info
.entry(username.clone())
.or_insert_with(|| UserInfo::pending(username.clone()));
// The watch reply carries no privileged flag, so keep whatever a
// previous GetUserStatus told us rather than asserting `false`.
let privileged =
entry.presence.as_ref().is_some_and(|p| p.privileged);
entry.presence = Some(UserPresence {
status: UserStatus::from_code(status),
privileged,
});
}
if let (Some(average_speed), Some(shared_files), Some(shared_folders)) =
(average_speed, shared_files, shared_folders)
{
self.apply_user_stats(
username,
average_speed,
shared_files,
shared_folders,
);
}
}
/// What the server has said about `username` so far.
#[must_use]
pub fn user_info(&self, username: &str) -> Option<UserInfo> {
self.user_info.get(username).cloned()
}
/// Who is currently in `room`, sorted, or empty when we are not in it.
#[must_use]
pub fn room_members(&self, room: &str) -> Vec<String> {
self.room_members.get(room).cloned().unwrap_or_default()
}
/// Record the per-member statistics of a room we just joined, replacing
/// any previous snapshot for it.
pub fn apply_room_member_stats(
&mut self,
room: String,
stats: Vec<RoomUserStats>,
) {
self.room_member_stats.insert(room, stats);
}
/// What the server reported about the members of `room`, sorted by name,
/// or empty for a room we have not joined.
#[must_use]
pub fn room_member_stats(&self, room: &str) -> Vec<RoomUserStats> {
let mut stats = self
.room_member_stats
.get(room)
.cloned()
.unwrap_or_default();
stats.sort_by(|a, b| a.username.cmp(&b.username));
stats
}
/// The latest snapshot of the public chat-room list.
#[must_use]
pub fn room_list(&self) -> Vec<RoomInfo> {
self.room_list.clone()
}
/// Remove and return all chat-room events received since the last call.
#[must_use]
pub fn take_room_events(&mut self) -> Vec<RoomEvent> {
std::mem::take(&mut self.room_events)
}
/// Cache a peer's listen address learned from a GetPeerAddress response.
pub fn cache_peer_address(
&mut self,
username: &str,
host: String,
port: u32,
) {
self.peer_addresses
.insert(username.to_string(), (host, port));
}
/// The cached listen address for `username`, if known.
#[must_use]
pub fn peer_address(&self, username: &str) -> Option<(String, u32)> {
self.peer_addresses.get(username).cloned()
}
/// Queue a peer message to send once a control connection to `username` is up.
pub fn queue_peer_message(
&mut self,
username: &str,
message: crate::message::Message,
) {
let entry = self
.pending_peer_messages
.entry(username.to_string())
.or_insert_with(|| (Instant::now(), Vec::new()));
entry.0 = Instant::now();
entry.1.push(message);
}
/// Remove and return the messages queued for `username`.
pub fn take_peer_messages(
&mut self,
username: &str,
) -> Vec<crate::message::Message> {
self.pending_peer_messages
.remove(username)
.map(|(_, messages)| messages)
.unwrap_or_default()
}
/// Drop peer messages nobody could deliver within
/// [`PENDING_PEER_TTL`]: the searcher went offline, or never existed.
pub(crate) fn expire_pending_peer_messages(&mut self, now: Instant) {
self.pending_peer_messages.retain(|_, (since, _)| {
now.duration_since(*since) < PENDING_PEER_TTL
});
}
/// Store a shared-file listing received from browsing `username`.
pub fn store_browse_result(
&mut self,
username: String,
directories: Vec<SharedDirectory>,
) {
self.pending_browses.remove(&username);
self.browse_results.insert(username, directories);
}
pub(crate) fn mark_browse_pending(&mut self, username: &str) {
let now = Instant::now();
self.pending_browses.retain(|_, deadline| *deadline > now);
self.pending_browses
.insert(username.to_string(), now + BROWSE_PROTECT_WINDOW);
}
/// Remove and return the shared-file listing browsed from `username`.
pub fn take_browse_result(
&mut self,
username: &str,
) -> Option<Vec<SharedDirectory>> {
self.browse_results.remove(username)
}
/// Remember that a server-brokered connection to `username` is pending under
/// `token`; the peer will quote it back in a PierceFirewall.
pub fn add_pending_connect(&mut self, token: u32, username: String) {
self.pending_connect_tokens
.insert(token, (username, Instant::now() + BROKER_CONNECT_TIMEOUT));
}
/// Resolve and consume the peer expected for a brokered connection `token`.
pub fn take_pending_connect(&mut self, token: u32) -> Option<String> {
self.pending_connect_tokens
.remove(&token)
.map(|(username, _)| username)
}
/// Returns the claim flag the dialling route races against.
pub(crate) fn add_brokered_upload(
&mut self,
connect_token: u32,
token: u32,
real_path: std::path::PathBuf,
) -> Arc<AtomicBool> {
let claimed = Arc::new(AtomicBool::new(false));
self.brokered_uploads.insert(
connect_token,
super::BrokeredUpload {
token,
real_path,
deadline: Instant::now() + BROKER_CONNECT_TIMEOUT,
claimed: claimed.clone(),
},
);
claimed
}
pub(crate) fn take_brokered_upload(
&mut self,
connect_token: u32,
) -> Option<super::BrokeredUpload> {
self.brokered_uploads.remove(&connect_token)
}
/// Uploads whose downloader never connected back in time.
pub(crate) fn take_expired_brokered_uploads(
&mut self,
now: Instant,
) -> Vec<super::BrokeredUpload> {
let (expired, live) = std::mem::take(&mut self.brokered_uploads)
.into_iter()
.partition(|(_, upload)| now >= upload.deadline);
self.brokered_uploads = live;
expired.into_values().collect()
}
pub fn take_expired_connects(&mut self, now: Instant) -> Vec<String> {
let mut expired = Vec::new();
self.pending_connect_tokens
.retain(|_, (username, deadline)| {
if now >= *deadline {
expired.push(std::mem::take(username));
return false;
}
true
});
expired
}
pub(crate) fn protected_peers(&self) -> HashSet<String> {
let mut protected: HashSet<String> = self
.downloads
.list()
.iter()
.filter(|d| {
matches!(
d.status,
DownloadStatus::Queued
| DownloadStatus::InProgress { .. }
| DownloadStatus::Paused { .. }
)
})
.map(|d| d.username.clone())
.collect();
protected
.extend(self.active_uploads.values().map(|u| u.username.clone()));
protected.extend(self.uploads.values().map(|j| j.downloader.clone()));
protected
.extend(self.upload_queue.iter().map(|q| q.downloader.clone()));
protected.extend(self.pending_serves.keys().cloned());
protected.extend(self.pending_peer_messages.keys().cloned());
let now = Instant::now();
protected.extend(
self.pending_browses
.iter()
.filter(|(_, deadline)| **deadline > now)
.map(|(username, _)| username.clone()),
);
protected
}
/// Record a private message received from another user.
pub fn push_private_message(&mut self, message: UserMessage) {
self.private_messages.push(message);
}
/// Remove and return all buffered private messages.
pub fn take_private_messages(&mut self) -> Vec<UserMessage> {
std::mem::take(&mut self.private_messages)
}
}
+712
View File
@@ -0,0 +1,712 @@
//! Our place in the distributed search network, as a leaf: which parent we
//! hang from, which candidates we are still sounding out, and what to tell the
//! server when that changes.
use super::{Client, ClientOperation, Result, ServerMessage};
use crate::message::MessageReader;
use crate::message::distributed::{self, Distributed};
use crate::message::server::MessageFactory;
use crate::peer::ConnectionType;
use crate::utils::lock::RwLockExt;
use std::collections::HashMap;
use std::io::{self, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Sender;
use std::time::{Duration, Instant};
/// A candidate that has not answered the dial in this long is not a parent.
const LINK_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// How often an idle link looks at its cancel flag.
const CANCEL_POLL: Duration = Duration::from_secs(1);
/// A parent relays the network's whole search stream; one that says nothing
/// for this long is gone, and holding its slot would block every later
/// candidate.
const LINK_IDLE: Duration = Duration::from_mins(1);
/// Moves of our branch are told to the server at most this often; a parent
/// that flaps its level must not make us flood the server.
const MOVE_INTERVAL: Duration = Duration::from_secs(1);
/// A distributed frame is a search or a branch fact; anything bigger is not
/// one of ours.
const MAX_FRAME: usize = 64 * 1024;
/// The server offers at most ten parents at a time; more candidates than
/// that in flight is a server that keeps offering, and each one is a thread.
const MAX_CANDIDATES: usize = 10;
/// Searches admitted from the parent per second. Every search costs a scan
/// of everything we share, and the tree carries the whole network's stream.
// ponytail: a flat budget; a word index over the shares is the upgrade path.
const SEARCH_BUDGET: u32 = 50;
/// Where we sit in the tree, as told to the server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Branch {
pub root: String,
pub level: u32,
}
impl Branch {
#[must_use]
pub fn parentless(own: &str) -> Self {
Self::under(own, 0)
}
#[must_use]
pub fn under(root: &str, level: u32) -> Self {
Self {
root: root.to_string(),
level,
}
}
}
struct Candidate {
link: u64,
level: Option<u32>,
root: Option<String>,
cancel: Arc<AtomicBool>,
}
/// A candidate to dial: the link number that tags everything it sends, and
/// the flag that tells its thread to stop.
pub struct Dial {
pub link: u64,
pub username: String,
pub host: String,
pub port: u16,
pub cancel: Arc<AtomicBool>,
}
/// The leaf's view: at most one parent, adopted from the candidates the
/// server offered, the way Nicotine+ does it. Every method is a pure state
/// change so the rule is testable without a socket. Events carry the link
/// number they came from, so a stale thread cannot speak for a newer link
/// to the same user.
pub struct Leaf {
own: String,
parent: Option<String>,
branch: Branch,
candidates: HashMap<String, Candidate>,
next_link: u64,
searches: (Instant, u32),
/// When we last told the server we moved, and whether a move since then
/// is still waiting to be told.
moved: Option<Instant>,
move_pending: bool,
}
impl Leaf {
#[must_use]
pub fn new(own: &str) -> Self {
Self {
own: own.to_string(),
parent: None,
branch: Branch::parentless(own),
candidates: HashMap::new(),
next_link: 1,
searches: (Instant::now(), 0),
moved: None,
move_pending: false,
}
}
#[must_use]
pub fn branch(&self) -> Branch {
self.branch.clone()
}
/// Whether `link` is the live link to our parent.
#[must_use]
pub fn is_parent(&self, username: &str, link: u64) -> bool {
self.parent.as_deref() == Some(username)
&& self.has_link(username, link)
}
/// New candidates to dial: those we are not already sounding out, up to
/// the cap, and none at all while we hang from a parent.
pub fn consider(
&mut self,
candidates: Vec<(String, String, u16)>,
) -> Vec<Dial> {
if self.parent.is_some() {
return Vec::new();
}
let mut dials = Vec::new();
for (username, host, port) in candidates {
if self.candidates.contains_key(&username)
|| self.candidates.len() >= MAX_CANDIDATES
{
continue;
}
let link = self.next_link;
self.next_link += 1;
let cancel = Arc::new(AtomicBool::new(false));
self.candidates.insert(
username.clone(),
Candidate {
link,
level: None,
root: None,
cancel: cancel.clone(),
},
);
dials.push(Dial {
link,
username,
host,
port,
cancel,
});
}
dials
}
/// A candidate said how deep it sits. A branch root does not always say
/// so separately: level 0 is its own root. A negative level is nonsense
/// and leaves the candidate unadoptable. When it is the parent talking,
/// our own place moves with it and the new stance comes back.
pub fn branch_level(
&mut self,
username: &str,
link: u64,
level: i32,
) -> Option<Branch> {
if !self.has_link(username, link) {
return None;
}
let candidate = self.candidates.get_mut(username)?;
candidate.level = u32::try_from(level).ok();
if level == 0 {
candidate.root = Some(username.to_string());
}
self.moved_with_parent(username)
}
pub fn branch_root(
&mut self,
username: &str,
link: u64,
root: &str,
) -> Option<Branch> {
if !self.has_link(username, link) {
return None;
}
self.candidates.get_mut(username)?.root = Some(root.to_string());
self.moved_with_parent(username)
}
/// A search came down from `username`. The first one from a candidate
/// that has told us its place adopts it; the stance to announce comes back.
pub fn search_from(&mut self, username: &str, link: u64) -> Option<Branch> {
if self.parent.is_some() || !self.has_link(username, link) {
return None;
}
let candidate = self.candidates.get(username)?;
let (Some(level), Some(root)) =
(candidate.level, candidate.root.clone())
else {
return None;
};
let parent = self.candidates.remove(username)?;
self.cancel_candidates();
self.candidates.insert(username.to_string(), parent);
self.parent = Some(username.to_string());
self.branch = Branch::under(&root, level.saturating_add(1));
Some(self.branch.clone())
}
/// Whether to answer one more search from the parent now: at most
/// `SEARCH_BUDGET` a second, the rest dropped.
pub fn admit_search(&mut self, now: Instant) -> bool {
if now.duration_since(self.searches.0) >= Duration::from_secs(1) {
self.searches = (now, 0);
}
self.searches.1 += 1;
self.searches.1 <= SEARCH_BUDGET
}
/// The link to `username` closed. True when that was the parent, which
/// puts us back to parentless.
pub fn closed(&mut self, username: &str, link: u64) -> bool {
if !self.has_link(username, link) {
return false;
}
self.candidates.remove(username);
if self.parent.as_deref() != Some(username) {
return false;
}
self.parent = None;
self.branch = Branch::parentless(&self.own);
true
}
pub fn reset(&mut self) {
self.cancel_candidates();
self.parent = None;
self.branch = Branch::parentless(&self.own);
}
fn has_link(&self, username: &str, link: u64) -> bool {
self.candidates
.get(username)
.is_some_and(|c| c.link == link)
}
fn moved_with_parent(&mut self, username: &str) -> Option<Branch> {
if self.parent.as_deref() != Some(username) {
return None;
}
let candidate = self.candidates.get(username)?;
let branch = Branch::under(
candidate.root.as_deref()?,
candidate.level?.saturating_add(1),
);
if branch == self.branch {
return None;
}
self.branch = branch;
self.move_pending = true;
self.due_announcement(Instant::now())
}
/// The branch to tell the server about, if a move is waiting and the
/// last one was told at least [`MOVE_INTERVAL`] ago.
pub fn due_announcement(&mut self, now: Instant) -> Option<Branch> {
if !self.move_pending
|| self
.moved
.is_some_and(|at| now.duration_since(at) < MOVE_INTERVAL)
{
return None;
}
self.move_pending = false;
self.moved = Some(now);
Some(self.branch.clone())
}
fn cancel_candidates(&mut self) {
for candidate in self.candidates.values() {
candidate.cancel.store(true, Ordering::Relaxed);
}
self.candidates.clear();
}
}
/// Tell the server where we sit now.
pub fn announce(
server: Option<&Sender<ServerMessage>>,
branch: &Branch,
has_parent: bool,
accept_children: bool,
) {
let Some(server) = server else {
return;
};
for message in distributed::stance(
&branch.root,
branch.level,
has_parent,
accept_children,
) {
let _ = server.send(ServerMessage::SendMessage(message));
}
}
/// Tell the server where we sit, and tell our children too: a branch that
/// moved leaves every child reporting a stale place until they hear it.
pub fn announce_move(
ctx: &mut super::ClientContext,
branch: &Branch,
has_parent: bool,
) {
let server = ctx.server_sender.clone();
// What the server needs to know is whether it may offer us to another
// peer right now, not whether we serve children in principle. The stance
// carries it, so record it as told: a separate AcceptChildren right
// behind this one would say nothing new.
let has_room = ctx.children.has_room();
let _ = ctx.accept_children_change();
announce(server.as_ref(), branch, has_parent, has_room);
ctx.children.broadcast_stance(&branch.root, branch.level);
}
/// Dial a parent candidate on its own thread: a `D` PeerInit, then whatever it
/// sends comes back as operations until the link closes or is cancelled.
pub fn spawn_link(dial: Dial, own: String, ops: Sender<ClientOperation>) {
let closed = ClientOperation::ParentClosed {
parent: dial.username.clone(),
link: dial.link,
};
let thread_ops = ops.clone();
let spawned = std::thread::Builder::new()
.name("soulseek-parent".to_string())
.spawn(move || {
if let Err(e) = link(&dial, &own, &thread_ops)
&& !dial.cancel.load(Ordering::Relaxed)
{
crate::debug!(
"[distributed] link to {} ended: {}",
dial.username,
e
);
let _ = thread_ops.send(ClientOperation::ParentClosed {
parent: dial.username,
link: dial.link,
});
}
});
if spawned.is_err() {
let _ = ops.send(closed);
}
}
fn link(
dial: &Dial,
own: &str,
ops: &Sender<ClientOperation>,
) -> io::Result<()> {
let addr = (dial.host.as_str(), dial.port)
.to_socket_addrs()?
.next()
.ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "no address")
})?;
let mut stream = TcpStream::connect_timeout(&addr, LINK_CONNECT_TIMEOUT)?;
if dial.cancel.load(Ordering::Relaxed) {
return Ok(());
}
stream.set_read_timeout(Some(CANCEL_POLL))?;
stream.write_all(
&MessageFactory::build_peer_init_message(own, ConnectionType::D, 0)
.get_buffer(),
)?;
let mut reader = MessageReader::new();
let mut heard = Instant::now();
loop {
if dial.cancel.load(Ordering::Relaxed) {
return Ok(());
}
match reader.read_from_socket(&mut stream) {
Ok(()) => heard = Instant::now(),
Err(e)
if matches!(
e.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) =>
{
if heard.elapsed() > LINK_IDLE {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"silent parent",
));
}
continue;
}
Err(e) => return Err(e),
}
while let Some(mut frame) = reader.extract_message()? {
let Some(operation) = operation_of(dial, &mut frame) else {
continue;
};
if ops.send(operation).is_err() {
return Ok(());
}
}
if reader.buffer_len() > MAX_FRAME {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"oversized frame",
));
}
}
}
fn operation_of(
dial: &Dial,
frame: &mut crate::message::Message,
) -> Option<ClientOperation> {
let parent = dial.username.clone();
let link = dial.link;
Some(match distributed::parse(frame)? {
Distributed::BranchLevel(level) => ClientOperation::ParentBranchLevel {
parent,
link,
level,
},
Distributed::BranchRoot(root) => {
ClientOperation::ParentBranchRoot { parent, link, root }
}
Distributed::Search {
username,
token,
query,
} => ClientOperation::ParentSearch {
parent,
link,
username,
token,
query,
},
})
}
impl Client {
/// Offer parent candidates as the server does with PossibleParents:
/// `(username, host, port)`. The first candidate to send a search after
/// telling us its branch becomes our parent.
///
/// # Errors
/// Returns [`crate::SoulseekRs::NotConnected`] before [`Client::connect`].
pub fn consider_parents(
&self,
candidates: Vec<(String, String, u16)>,
) -> Result<()> {
let ops = self
.context
.read_safe()?
.operations
.clone()
.ok_or(crate::SoulseekRs::NotConnected)?;
ops.send(ClientOperation::PossibleParents(candidates))
.map_err(|_| crate::SoulseekRs::NotConnected)
}
/// Where we sit in the distributed search network: the branch root's name
/// and our level, which is ourselves at 0 while we have no parent.
#[must_use]
pub fn distributed_branch(&self) -> (String, u32) {
self.context.read_safe().map_or_else(
|_| (self.username.clone(), 0),
|ctx| {
let branch = ctx.leaf.branch();
(branch.root, branch.level)
},
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn leaf() -> Leaf {
Leaf::new("me")
}
fn candidate(name: &str) -> (String, String, u16) {
(name.to_string(), "127.0.0.1".to_string(), 2234)
}
/// Dial the candidates and return their link numbers by name.
fn dial(leaf: &mut Leaf, names: &[&str]) -> HashMap<String, Dial> {
leaf.consider(names.iter().map(|n| candidate(n)).collect())
.into_iter()
.map(|d| (d.username.clone(), d))
.collect()
}
#[test]
fn a_leaf_without_a_parent_is_its_own_root_at_level_zero() {
assert_eq!(leaf().branch(), Branch::parentless("me"));
}
#[test]
fn candidates_are_dialled_only_while_there_is_no_parent() {
let mut leaf = leaf();
let dials = dial(&mut leaf, &["alice", "bob"]);
assert_eq!(dials.len(), 2);
let alice = dials["alice"].link;
leaf.branch_level("alice", alice, 1);
leaf.branch_root("alice", alice, "rooty");
assert!(
leaf.search_from("alice", alice).is_some(),
"alice becomes the parent"
);
assert!(leaf.consider(vec![candidate("carol")]).is_empty());
}
#[test]
fn the_first_search_from_a_candidate_with_branch_info_adopts_it() {
let mut leaf = leaf();
let dials = dial(&mut leaf, &["alice", "bob"]);
let (alice, bob) = (dials["alice"].link, dials["bob"].link);
assert_eq!(leaf.search_from("alice", alice), None, "no branch yet");
leaf.branch_level("alice", alice, 3);
assert_eq!(leaf.search_from("alice", alice), None, "level, no root");
leaf.branch_root("alice", alice, "rooty");
assert_eq!(
leaf.search_from("alice", alice),
Some(Branch::under("rooty", 4)),
"level 3 parent puts us at level 4"
);
assert_eq!(leaf.branch(), Branch::under("rooty", 4));
assert!(leaf.is_parent("alice", alice));
assert_eq!(leaf.search_from("alice", alice), None, "already parent");
assert_eq!(leaf.search_from("bob", bob), None, "a loser is ignored");
assert!(
dials["bob"].cancel.load(Ordering::Relaxed),
"the losing link is told to stop"
);
assert!(!dials["alice"].cancel.load(Ordering::Relaxed));
}
#[test]
fn a_level_zero_candidate_is_its_own_root() {
let mut leaf = leaf();
let alice = dial(&mut leaf, &["alice"])["alice"].link;
leaf.branch_level("alice", alice, 0);
assert_eq!(
leaf.search_from("alice", alice),
Some(Branch::under("alice", 1))
);
}
#[test]
fn a_hostile_branch_level_neither_panics_nor_adopts() {
let mut leaf = leaf();
let alice = dial(&mut leaf, &["alice"])["alice"].link;
leaf.branch_root("alice", alice, "rooty");
leaf.branch_level("alice", alice, -1);
assert_eq!(leaf.search_from("alice", alice), None, "negative level");
leaf.branch_level("alice", alice, i32::MAX);
assert_eq!(
leaf.search_from("alice", alice),
Some(Branch::under("rooty", 1 << 31))
);
}
#[test]
fn the_parents_later_moves_move_us_too_but_are_told_once_a_second() {
let mut leaf = leaf();
let alice = dial(&mut leaf, &["alice"])["alice"].link;
leaf.branch_level("alice", alice, 0);
leaf.search_from("alice", alice);
assert_eq!(leaf.branch_level("alice", alice, 0), None, "unchanged");
assert_eq!(
leaf.branch_root("alice", alice, "grand"),
Some(Branch::under("grand", 1))
);
assert_eq!(
leaf.branch_level("alice", alice, 2),
None,
"a second move within the second waits"
);
assert_eq!(leaf.branch(), Branch::under("grand", 3), "but it took");
assert_eq!(leaf.due_announcement(Instant::now()), None);
assert_eq!(
leaf.due_announcement(Instant::now() + MOVE_INTERVAL),
Some(Branch::under("grand", 3))
);
assert_eq!(
leaf.due_announcement(Instant::now() + MOVE_INTERVAL),
None,
"told once"
);
}
#[test]
fn a_parent_that_becomes_a_root_is_the_root_from_then_on() {
let mut leaf = leaf();
let alice = dial(&mut leaf, &["alice"])["alice"].link;
leaf.branch_root("alice", alice, "grand");
leaf.branch_level("alice", alice, 5);
assert_eq!(
leaf.search_from("alice", alice),
Some(Branch::under("grand", 6))
);
assert_eq!(
leaf.branch_level("alice", alice, 0),
Some(Branch::under("alice", 1))
);
}
#[test]
fn a_stale_link_cannot_speak_for_a_newer_one() {
let mut leaf = leaf();
let old = dial(&mut leaf, &["alice"])["alice"].link;
assert!(!leaf.closed("alice", old + 7), "unknown link is ignored");
leaf.closed("alice", old);
let new = dial(&mut leaf, &["alice"])["alice"].link;
assert_ne!(old, new);
leaf.branch_level("alice", old, 0);
assert_eq!(leaf.search_from("alice", old), None, "old link ignored");
assert!(
!leaf.closed("alice", old),
"a late close leaves the new one"
);
leaf.branch_level("alice", new, 0);
assert!(leaf.search_from("alice", new).is_some());
}
#[test]
fn a_later_batch_keeps_the_candidates_in_flight_and_stays_under_the_cap() {
let mut leaf = leaf();
let first = dial(&mut leaf, &["alice"])["alice"].link;
let again = dial(&mut leaf, &["alice", "bob"]);
assert!(
!again.contains_key("alice"),
"alice is already being dialled"
);
assert!(again.contains_key("bob"));
assert!(leaf.has_link("alice", first));
let names: Vec<String> = (0..20).map(|i| format!("p{i}")).collect();
let more = leaf
.consider(names.iter().map(|n| candidate(n)).collect::<Vec<_>>());
assert_eq!(more.len(), MAX_CANDIDATES - 2);
}
#[test]
fn losing_the_parent_or_a_reset_returns_to_parentless() {
let mut leaf = leaf();
let dials = dial(&mut leaf, &["alice", "bob"]);
let alice = dials["alice"].link;
leaf.branch_level("alice", alice, 0);
leaf.search_from("alice", alice);
assert!(!leaf.closed("bob", dials["bob"].link), "not the parent");
assert!(leaf.closed("alice", alice));
assert_eq!(leaf.branch(), Branch::parentless("me"));
let dials = dial(&mut leaf, &["alice", "carol"]);
let alice = dials["alice"].link;
leaf.branch_level("alice", alice, 0);
leaf.search_from("alice", alice);
leaf.reset();
assert_eq!(leaf.branch(), Branch::parentless("me"));
assert!(dials["alice"].cancel.load(Ordering::Relaxed));
assert!(dials["carol"].cancel.load(Ordering::Relaxed));
assert!(!leaf.consider(vec![candidate("bob")]).is_empty());
}
#[test]
fn searches_beyond_the_budget_are_dropped_until_the_next_second() {
let mut leaf = leaf();
let now = Instant::now();
for _ in 0..SEARCH_BUDGET {
assert!(leaf.admit_search(now));
}
assert!(!leaf.admit_search(now));
assert!(leaf.admit_search(now + Duration::from_secs(1)));
}
#[test]
fn announcing_tells_the_server_the_root_the_level_and_that_we_have_a_parent()
{
let (tx, rx) = std::sync::mpsc::channel();
announce(Some(&tx), &Branch::under("rooty", 4), true, false);
let mut sent: Vec<crate::message::Message> = Vec::new();
while let Ok(ServerMessage::SendMessage(m)) = rx.try_recv() {
sent.push(m);
}
let codes: Vec<u32> = sent
.iter()
.map(|m| u32::from_le_bytes(m.get_data()[..4].try_into().unwrap()))
.collect();
assert_eq!(codes, vec![71, 127, 126, 100]);
assert_eq!(sent[0].get_data()[4], 0, "HaveNoParent is false");
let mut root = sent[1].clone();
root.set_pointer(4);
assert_eq!(root.read_string(), "rooty");
assert_eq!(&sent[2].get_data()[4..8], &4u32.to_le_bytes());
assert_eq!(sent[3].get_data()[4], 0, "AcceptChildren is false");
}
}
+241
View File
@@ -0,0 +1,241 @@
use super::{
Arc, Client, ClientContext, Download, DownloadMetadata, DownloadStatus,
Receiver, Result, RwLock, RwLockExt, Sender, error, info, mpsc,
next_download_token,
};
use crate::peer::part_path_of;
impl Client {
#[must_use]
pub fn get_all_downloads(&self) -> Vec<Download> {
self.context
.read_safe()
.map(|ctx| ctx.get_downloads().clone())
.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() {
Ok(mut ctx) => ctx.downloads.pause_by_file(username, filename),
Err(e) => {
error!("[client] pause_download: {}", e);
false
}
}
}
#[must_use]
pub fn resume_download(&self, username: &str, filename: &str) -> bool {
match self.context.write_safe() {
Ok(mut ctx) => ctx.downloads.resume_by_file(username, filename),
Err(e) => {
error!("[client] resume_download: {}", e);
false
}
}
}
#[must_use]
pub fn cancel_download(&self, username: &str, filename: &str) -> bool {
let cancelled = match self.context.write_safe() {
Ok(mut ctx) => ctx.downloads.cancel_by_file(username, filename),
Err(e) => {
error!("[client] cancel_download: {}", e);
None
}
};
let Some(download) = cancelled else {
return false;
};
if let Some(part) = part_path_of(&download) {
let _ = std::fs::remove_file(part);
}
true
}
#[must_use]
pub fn remove_queued_download(
&self,
username: &str,
filename: &str,
) -> bool {
match self.context.write_safe() {
Ok(mut ctx) => {
ctx.downloads.remove_queued_by_file(username, filename)
}
Err(e) => {
error!("[client] remove_queued_download: {}", e);
false
}
}
}
/// Remove every download for `username`/`filename` regardless of status.
/// Useful before re-issuing [`Client::download`] for a failed download, so
/// the list does not accumulate dead entries for the same file.
///
/// Returns whether anything was removed.
#[must_use]
pub fn remove_download(&self, username: &str, filename: &str) -> bool {
match self.context.write_safe() {
Ok(mut ctx) => ctx.downloads.remove_by_file(username, filename),
Err(e) => {
error!("[client] remove_download: {}", e);
false
}
}
}
pub fn download(
&self,
filename: String,
username: String,
size: u64,
download_directory: String,
) -> Result<(Download, Receiver<DownloadStatus>)> {
self.download_with_metadata(
filename,
username,
size,
download_directory,
DownloadMetadata::default(),
)
}
pub fn download_with_metadata(
&self,
filename: String,
username: String,
size: u64,
download_directory: String,
metadata: DownloadMetadata,
) -> Result<(Download, Receiver<DownloadStatus>)> {
info!("[client] Downloading {} from {}", filename, username);
let token = next_download_token();
let (download_sender, download_receiver): (
Sender<DownloadStatus>,
Receiver<DownloadStatus>,
) = mpsc::channel();
let download = Download {
username: username.clone(),
filename,
token,
size,
download_directory,
status: DownloadStatus::Queued,
sender: download_sender,
queue_position: None,
metadata,
};
let mut context = self.context.write_safe()?;
context.add_download(download.clone());
// If we already have a control connection to this peer, queue the
// upload immediately. Otherwise open one directly (server GetPeerAddress
// → outbound PeerInit → PeerConnected → the queued upload is flushed).
let peer_registered = context
.peer_registry
.as_ref()
.is_some_and(|r| r.contains(&username));
let queued_now = peer_registered
&& context.peer_registry.as_ref().is_some_and(|r| {
r.queue_upload(&username, download.filename.clone()).is_ok()
});
drop(context);
let failed = if peer_registered {
!queued_now
} else {
// No existing connection: initiate one. Only a genuinely
// unconnected client (no server handle) fails outright here.
self.connect_peer(&username).is_err()
};
if failed {
let reason = if peer_registered {
"The user declined the download"
} else {
"Could not connect to the user"
};
let _ = download
.sender
.send(DownloadStatus::Failed(Some(reason.to_string())));
self.context.write_safe()?.update_download_with_status(
token,
DownloadStatus::Failed(Some(reason.to_string())),
);
}
Ok((download, download_receiver))
}
/// Fail every still-`Queued` download for `username`, both on the caller's
/// status channel (so a blocked `Receiver` unblocks) and in the store.
pub(crate) fn fail_queued_downloads(
client_context: &Arc<RwLock<ClientContext>>,
username: &str,
) {
let mut context = match client_context.write_safe() {
Ok(c) => c,
Err(e) => {
error!("[client] fail_queued_downloads write: {}", e);
return;
}
};
let doomed: Vec<(u32, Sender<DownloadStatus>)> = context
.get_downloads()
.iter()
.filter(|d| {
d.username == username
&& matches!(d.status, DownloadStatus::Queued)
})
.map(|d| (d.token, d.sender.clone()))
.collect();
for (token, sender) in doomed {
let reason = Some("The user went offline".to_string());
let _ = sender.send(DownloadStatus::Failed(reason.clone()));
context.update_download_with_status(
token,
DownloadStatus::Failed(reason),
);
}
}
}
#[cfg(test)]
mod tests {
use super::next_download_token;
use std::collections::HashSet;
// The download store is keyed by token and removes *every* entry matching
// one, so two downloads sharing a token means queueing one silently evicts
// the other — which is what fetching the same filename from two peers does
// if the token depends on the filename.
#[test]
fn download_tokens_are_unique_and_below_the_upload_range() {
let tokens: Vec<u32> =
(0..10_000).map(|_| next_download_token()).collect();
assert_eq!(
tokens.iter().collect::<HashSet<_>>().len(),
tokens.len(),
"every download must get its own token"
);
assert!(
tokens.iter().all(|t| *t < 0x8000_0000),
"download tokens must not reach into the upload range"
);
}
}
+897
View File
@@ -0,0 +1,897 @@
use crate::actor::ActorHandle;
use crate::actor::server_actor::{
PeerAddress, ServerActor, ServerMessage, UserMessage,
};
use crate::download_store::{DownloadStore, collect_failed_tokens};
use crate::types::{
ClientVersion, DownloadMetadata, DownloadStatus, Recommendation, RoomEvent,
RoomInfo, RoomTicker, RoomUserStats, SessionLoss, SessionWatch,
SimilarUser, UserInfo, UserInterests, UserPresence, UserStats, UserStatus,
};
use crate::utils::logger;
use crate::utils::rate_limit::RateLimit;
use crate::{
Transfer,
actor::{ActorSystem, peer_registry::PeerRegistry},
error::{Result, SoulseekRs},
message::peer::{FileEntry, SharedDirectory, build_file_search_response},
peer::{ConnectionType, DownloadPeer, Peer, PeerMessage, listen::Listen},
shares::Shares,
types::{Download, Search, SearchResult},
utils::lock::RwLockExt,
};
use std::{
collections::{HashMap, HashSet},
net::TcpStream,
sync::{
Arc, RwLock,
atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering},
mpsc::{self, Receiver, Sender},
},
thread::{self, sleep},
time::{Duration, Instant},
};
use upload_queue::QueuedUpload;
use crate::{debug, error, info, trace, warn};
const DEFAULT_LISTEN_PORT: u16 = 2234;
/// What to assume between wishlist searches until the server announces its own.
///
/// Twelve minutes is what Soulseek servers give an unprivileged account (code
/// 104); guessing lower only gets the searches dropped.
pub const DEFAULT_WISHLIST_INTERVAL: Duration = Duration::from_mins(12);
/// How many uploads run at once by default.
///
/// The cap is what makes the queue real — with unlimited slots there is nothing
/// for a privileged peer to jump — but it must not be the thing throttling a
/// modern connection. On Soulseek the per-transfer rate is usually set by the
/// *other* end, so concurrency is what fills an uplink: ten peers at a typical
/// few hundred KiB/s each is a few MiB/s, which a 50 Mbit uplink carries and a
/// pair of slots leaves mostly idle. Ten still divides a modest uplink into
/// shares big enough that no peer times out waiting.
///
/// Measured on the stress benchmark (64 waiting peers, loopback): two slots
/// took 24.2s, eight took 6.2s, thirty-two took 3.1s.
pub const DEFAULT_UPLOAD_SLOTS: usize = 10;
/// How long to wait for a server-brokered (firewalled) peer to connect back
/// before giving up and failing the download. Matches the direct-dial timeout.
const BROKER_CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
const DEFAULT_MAX_PEERS: usize = 512;
/// How long a message waits for a control connection to its peer.
const PENDING_PEER_TTL: Duration = Duration::from_mins(1);
const BROWSE_PROTECT_WINDOW: Duration = Duration::from_mins(5);
/// Source of non-zero correlation tokens for server-brokered connections.
static NEXT_CONNECT_TOKEN: AtomicU32 = AtomicU32::new(1);
fn next_connect_token() -> u32 {
NEXT_CONNECT_TOKEN.fetch_add(1, Ordering::Relaxed).max(1)
}
/// Upload tokens are minted in the high half of the space so they never collide
/// with download tokens, which are always < 2^31.
static NEXT_UPLOAD_TOKEN: AtomicU32 = AtomicU32::new(0x8000_0000);
fn next_upload_token() -> u32 {
NEXT_UPLOAD_TOKEN.fetch_add(1, Ordering::Relaxed)
}
/// Source of download tokens, kept in the low half of the space.
///
/// A counter, not a hash of the filename: the download store is keyed by token
/// and removes every entry matching one, so any two downloads sharing a token
/// destroy each other — and the same filename from two peers is an ordinary
/// thing to queue.
static NEXT_DOWNLOAD_TOKEN: AtomicU32 = AtomicU32::new(1);
fn next_download_token() -> u32 {
NEXT_DOWNLOAD_TOKEN.fetch_add(1, Ordering::Relaxed) % 0x8000_0000
}
/// Source of search tokens.
///
/// A counter, not a hash of the query: a peer's answer is routed to the first
/// search holding its token, so two queries sharing one would pour results
/// into each other.
static NEXT_SEARCH_TOKEN: AtomicU32 = AtomicU32::new(1);
fn next_search_token() -> u32 {
NEXT_SEARCH_TOKEN.fetch_add(1, Ordering::Relaxed)
}
/// A file we have agreed to serve to a peer, awaiting their TransferResponse.
struct UploadJob {
downloader: String,
real_path: std::path::PathBuf,
virtual_path: String,
size: u64,
/// When the offer went out; `None` once the peer has answered it.
offered: Option<Instant>,
}
/// An upload waiting for its downloader to connect back to our listener while
/// we also dial them. Whichever socket arrives first claims the transfer.
pub(crate) struct BrokeredUpload {
pub(crate) token: u32,
pub(crate) real_path: std::path::PathBuf,
deadline: Instant,
claimed: Arc<AtomicBool>,
}
impl BrokeredUpload {
/// Take the transfer for this route; false if another route already did.
pub(crate) fn claim(&self) -> bool {
self.claimed
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
}
}
/// Live bookkeeping for an upload being served (or recently finished).
struct ActiveUpload {
username: String,
filename: String,
size: u64,
bytes_sent: Arc<std::sync::atomic::AtomicU64>,
cancel: Arc<std::sync::atomic::AtomicBool>,
status: crate::types::UploadStatus,
/// When streaming began, so a snapshot can report a transfer rate.
started: Instant,
}
/// Transfer rate of an in-progress upload, in bytes per second. Finished,
/// cancelled and failed uploads report zero, matching how a download reports
/// its speed only while running.
///
/// an average over the whole transfer rather than the rolling window
/// the download path samples. Enough to fill the Speed column; sample a window
/// if the figure ever needs to track sudden stalls.
fn upload_speed(
status: &crate::types::UploadStatus,
bytes_sent: u64,
started: Instant,
) -> f64 {
if !matches!(status, crate::types::UploadStatus::InProgress) {
return 0.0;
}
let elapsed = started.elapsed().as_secs_f64();
if elapsed <= 0.0 {
return 0.0;
}
bytes_sent as f64 / elapsed
}
/// Build a `FileSearchResponse` for `query` against `shares`, or `None` if
/// nothing matches. `own_username` is the name the searcher will download from.
// One reply's worth of state: the shares to search, who we are, and the
// figures the reply advertises. Kept as arguments rather than a struct
// because every one of them is read straight from the client context at the
// call site, and a struct would only move the same list one line up.
#[allow(clippy::too_many_arguments)]
fn build_search_response(
shares: &Shares,
own_username: &str,
token: u32,
query: &str,
free_slot: bool,
speed: u32,
queue_length: u32,
excluded_phrases: &[String],
) -> Option<crate::message::Message> {
let matches = shares.search(query);
if matches.is_empty() {
return None;
}
// A file whose path carries a phrase the server excludes (code 160) is
// left out of the reply, the way Nicotine+ does it: the exclusion is the
// server policing what travels the search network, and answering with one
// anyway is what it is asking us not to do.
let entries: Vec<FileEntry> = matches
.iter()
.filter(|f| {
!path_carries_excluded_phrase(&f.virtual_path, excluded_phrases)
})
.take(crate::types::MAX_SEARCH_REPLY_FILES)
.map(|f| FileEntry {
name: &f.virtual_path,
size: f.size,
attribs: &f.attributes,
})
.collect();
if entries.is_empty() {
return None;
}
Some(build_file_search_response(
own_username,
token,
&entries,
u8::from(free_slot),
speed,
queue_length,
))
}
/// Whether `path` contains any of the phrases the server excludes. Matching
/// is on the lowercased path, as the phrases themselves are lowercase.
fn path_carries_excluded_phrase(path: &str, excluded: &[String]) -> bool {
if excluded.is_empty() {
return false;
}
let lowered = path.to_lowercase();
excluded
.iter()
.any(|phrase| !phrase.is_empty() && lowered.contains(phrase))
}
#[derive(Debug, Clone)]
pub struct ClientSettings {
pub username: String,
pub password: String,
pub server_address: PeerAddress,
pub enable_listen: bool,
pub listen_port: u16,
/// Directories whose files are shared with (uploaded to) other peers.
/// Empty means nothing is shared.
pub shared_directories: Vec<String>,
/// Whether to serve children in the distributed search network: peers
/// hang from us with a `D` connection and we pass every search we receive
/// down to them. Off by default — it costs a socket and the network's
/// whole search stream per child.
pub accept_children: bool,
/// The version reported to the server on login. Defaults to the
/// soulseek-rs major version with minor version 1 ("unidentified");
/// clients built on this library should reserve their own minor
/// version (see [`ClientVersion`]).
pub version: ClientVersion,
}
impl ClientSettings {
pub fn new(
username: impl Into<String>,
password: impl Into<String>,
) -> Self {
Self {
username: username.into(),
password: password.into(),
..Default::default()
}
}
}
impl Default for ClientSettings {
fn default() -> Self {
Self {
username: String::new(),
password: String::new(),
server_address: PeerAddress::new(
"server.slsknet.org".to_string(),
2416,
),
enable_listen: true,
listen_port: DEFAULT_LISTEN_PORT,
shared_directories: Vec::new(),
accept_children: false,
version: ClientVersion::default(),
}
}
}
/// The client loop's mailbox. Non-exhaustive for the same reason as
/// [`ServerMessage`]: new protocol coverage adds variants.
#[derive(Debug)]
#[non_exhaustive]
pub enum ClientOperation {
ConnectToPeer(Peer),
SearchResult(SearchResult),
PeerDisconnected(u64, String, Option<SoulseekRs>),
DownloadFromPeer(u32, Peer, bool),
UpdateDownloadTokens(Transfer, String),
GetPeerAddressResponse {
username: String,
host: String,
port: u32,
obfuscation_type: u32,
obfuscated_port: u16,
},
UploadFailed(String, String),
PlaceInQueueUpdate {
username: String,
filename: String,
place: u32,
},
SetServerSender(Sender<ServerMessage>),
PrivateMessageReceived(UserMessage),
/// The server answered `GetUserStatus` for a user we asked about.
UserStatusReceived {
username: String,
status: u32,
privileged: bool,
},
/// The server answered `GetUserStats` for a user we asked about.
UserStatsReceived {
username: String,
average_speed: u32,
shared_files: u32,
shared_folders: u32,
},
/// The server answered `WatchUser` for a user we started watching.
WatchedUserReceived {
username: String,
exists: bool,
status: Option<u32>,
average_speed: Option<u32>,
shared_files: Option<u32>,
shared_folders: Option<u32>,
},
PeerConnected(String),
/// A search distributed to us by the server; reply if our shares match.
IncomingSearch {
username: String,
token: u32,
query: String,
/// True when it came down the tree from our parent, which has
/// already passed it to our children and is subject to the answer
/// budget. A search the server relayed is neither.
from_parent: bool,
},
/// A peer queued one of our shared files; `requester_key` is the registry
/// key of the peer actor — the peer's username.
QueueUpload {
requester_key: String,
filename: String,
},
/// The peer accepted our upload offer for `token`; start streaming.
StartUpload {
token: u32,
},
/// A peer asked to browse our shared files; send our SharedFileListResponse.
ShareListRequested {
requester_key: String,
},
/// A peer asked what we say about ourselves; send a UserInfoResponse.
UserInfoRequested {
requester_key: String,
},
/// A peer asked for one folder of our shares; send a FolderContentsResponse.
FolderContentsRequested {
requester_key: String,
token: u32,
folder: String,
},
/// A peer answered our request for what it says about itself.
PeerInfoReceived {
username: String,
info: crate::message::peer::PeerInfo,
},
/// A peer answered our request for one folder of their shares.
FolderContents {
username: String,
token: u32,
folder: String,
directories: Vec<SharedDirectory>,
},
/// A peer we are browsing returned their shared-file listing.
BrowseResult {
username: String,
directories: Vec<SharedDirectory>,
},
/// A direct outbound connection to this peer failed before it was
/// established — the peer is likely firewalled, so fall back to asking the
/// server to broker the connection. Carries the reporting actor's id and,
/// when the dial was one the server asked us to make, the token that peer
/// quoted: that dial is answered with a `CantConnectToPeer` instead of a
/// broker request, since the peer is already waiting on the server.
PeerConnectFailed(u64, String, Option<u32>),
/// Something happened in the chat-room subsystem (list refreshed, a room
/// joined/left, a message said, a member joined/left).
RoomEvent(RoomEvent),
/// Per-member statistics from a `JoinRoom` reply.
RoomMemberStats {
room: String,
stats: Vec<RoomUserStats>,
},
/// The server announced how many seconds must pass between wishlist
/// searches.
WishlistInterval(u32),
/// Everyone the server counts as privileged; they sort ahead of others in
/// our upload queue.
PrivilegedUsers(Vec<String>),
/// Seconds of our own privileges left.
OwnPrivileges(u32),
/// A peer asked where their queued file sits.
PlaceInQueueRequested {
requester_key: String,
filename: String,
},
/// Distributed-network parent candidates, from the server or a host.
PossibleParents(Vec<(String, String, u16)>),
/// The server told us to drop our parent.
ResetDistributed,
/// A session just started: anything the server keeps only per-session —
/// our interests — has to be sent again.
SessionEstablished,
/// A peer dialled us with a `D` connection, asking to hang from us in the
/// distributed search network.
ChildConnected {
username: String,
stream: std::net::TcpStream,
},
/// Recommendations from the server: either from our own interests
/// (`global` false, code 54) or server-wide (`global` true, code 56).
Recommendations {
global: bool,
recommended: Vec<Recommendation>,
unrecommended: Vec<Recommendation>,
},
/// Recommendations for one item (code 111).
ItemRecommendations {
item: String,
recommendations: Vec<Recommendation>,
},
/// Users the server considers similar to us (code 110).
SimilarUsers(Vec<SimilarUser>),
/// Users who like one item (code 112).
ItemSimilarUsers {
item: String,
usernames: Vec<String>,
},
/// What another user likes and hates (code 57).
UserInterests(UserInterests),
/// A peer we asked the server to broker gave up reaching us (code 1001).
CantConnectToPeer {
token: u32,
},
/// Phrases the server refuses to search for (code 160).
ExcludedSearchPhrases(Vec<String>),
/// The minimum upload speed for carrying children (code 83).
ParentMinSpeed(u32),
/// The divisor turning that speed into a child count (code 84).
ParentSpeedRatio(u32),
/// A parent candidate told us how deep it sits. `link` says which dial
/// to that user is talking.
ParentBranchLevel {
parent: String,
link: u64,
level: i32,
},
/// A parent candidate told us whose branch it is on.
ParentBranchRoot {
parent: String,
link: u64,
root: String,
},
/// A search came down the tree from `parent`.
ParentSearch {
parent: String,
link: u64,
username: String,
token: u32,
query: String,
},
/// The link to a parent or candidate is gone.
ParentClosed {
parent: String,
link: u64,
},
}
pub struct ClientContext {
pub peer_registry: Option<PeerRegistry>,
pub downloads: DownloadStore,
server_sender: Option<Sender<ServerMessage>>,
/// Where anything outside the operations loop posts operations.
operations: Option<Sender<ClientOperation>>,
/// Our place in the distributed search network.
leaf: distributed::Leaf,
/// The children hanging from us, when the client serves any.
pub(crate) children: children::Children,
/// The last `AcceptChildren` answer we gave the server, so the same
/// answer is not repeated: it is a standing state, not a heartbeat.
announced_accept_children: Option<bool>,
/// The upload speed the server records for us, once it has told us. The
/// child limit is derived from it.
own_average_speed: Option<u32>,
/// The speed a parent needs (code 83) and the divisor turning speed into
/// a child count (code 84), as the server announced them.
parent_min_speed: u32,
parent_speed_ratio: u32,
searches: HashMap<String, Search>,
private_messages: Vec<UserMessage>,
/// Correlation tokens for server-brokered (firewalled) connections, mapping
/// a token we sent in a ConnectToPeer to the peer we expect back.
pending_connect_tokens: HashMap<u32, (String, Instant)>,
max_peers: Arc<AtomicUsize>,
pub(crate) download_speed_limit: Arc<RateLimit>,
/// Files we share with peers (read-only after connect).
pub shares: Arc<Shares>,
/// The directories the current share index was built from.
pub shared_directories: Vec<String>,
/// Peer listen addresses learned from GetPeerAddress responses.
peer_addresses: HashMap<String, (String, u32)>,
/// Peer messages waiting for a control connection to that peer, and
/// when the last was queued: a peer that never answers must not keep
/// them forever.
pending_peer_messages:
HashMap<String, (Instant, Vec<crate::message::Message>)>,
/// Uploads we have offered, keyed by our transfer token.
uploads: HashMap<u32, UploadJob>,
active_uploads: HashMap<u32, ActiveUpload>,
/// Upload tokens waiting for the downloader's address to be resolved.
pending_serves: HashMap<String, Vec<u32>>,
/// Uploads whose downloader we could not dial, keyed by the correlation
/// token we sent in `ConnectToPeer(F)`; the peer quotes it back in a
/// `PierceFirewall` and we serve over that socket instead.
brokered_uploads: HashMap<u32, BrokeredUpload>,
/// Shared-file listings received from peers we browsed.
browse_results: HashMap<String, Vec<SharedDirectory>>,
/// One-folder listings received from peers, keyed by peer and folder.
folder_contents: HashMap<(String, String), Vec<SharedDirectory>>,
/// What peers said about themselves (peer code 16), keyed by peer.
peer_infos: HashMap<String, crate::message::peer::PeerInfo>,
pending_browses: HashMap<String, Instant>,
/// Latest snapshot of the public chat-room list (from `RoomList`, code 64).
room_list: Vec<RoomInfo>,
/// Chat-room events awaiting consumption by the client/UI.
room_events: Vec<RoomEvent>,
/// Who is in each room we have joined, kept current from the membership
/// the server sends on join plus the later join/leave events.
room_members: HashMap<String, Vec<String>>,
/// Per-member statistics for each joined room, from the stat vectors the
/// server sends alongside the membership list.
room_member_stats: HashMap<String, Vec<RoomUserStats>>,
/// Who may enter each private room we belong to (code 133), kept current
/// from the later roster changes.
private_room_members: HashMap<String, Vec<String>>,
/// Who runs each of those rooms (code 148).
private_room_operators: HashMap<String, Vec<String>>,
/// The ticker board of each joined room, kept current from the board sent
/// on join (code 113) and the later add/remove events (114/115).
room_tickers: HashMap<String, Vec<RoomTicker>>,
/// The latest recommendations from our own interests (code 54), as
/// (recommended, recommended-against).
recommendations: Option<(Vec<Recommendation>, Vec<Recommendation>)>,
/// The latest server-wide recommendations (code 56).
global_recommendations: Option<(Vec<Recommendation>, Vec<Recommendation>)>,
/// Per-item recommendations (code 111), keyed by the item asked about.
item_recommendations: HashMap<String, Vec<Recommendation>>,
/// The latest similar-user answer (code 110).
similar_users: Vec<SimilarUser>,
/// Who likes an item (code 112), keyed by the item asked about.
item_similar_users: HashMap<String, Vec<String>>,
/// Interests of other users (code 57), keyed by username.
user_interests: HashMap<String, UserInterests>,
/// What we ourselves like and hate. The server keeps these only for the
/// duration of a session, so they are held here and sent again after each
/// login — the same thing Nicotine+ does from its config.
own_interests: UserInterests,
/// What the server has told us about other users, merged across the
/// separate status and statistics replies.
user_info: HashMap<String, UserInfo>,
/// Users we asked the server to watch (code 5), so it keeps pushing their
/// status changes. Kept so a UI can render the watch list and so an
/// unwatch can be rejected for someone we never watched.
watched_users: HashSet<String>,
/// Seconds the server wants between wishlist searches (code 104), once it
/// has told us.
wishlist_interval: Option<u32>,
/// Everyone the server listed as privileged (code 69). They sort ahead of
/// other peers in [`Self::upload_queue`].
privileged_users: HashSet<String>,
/// Seconds of our own privileges left (code 92), once we have asked.
own_privileges: Option<u32>,
/// Phrases the server excludes from the search network (code 160). Files
/// whose path carries one are left out of the replies we send.
excluded_search_phrases: Vec<String>,
/// Peers waiting for one of our upload slots.
upload_queue: Vec<QueuedUpload>,
/// Arrival counter for the queue's first-come tie-break.
upload_seq: u64,
/// How many uploads may be in flight at once.
upload_slots: usize,
/// Bytes per second of the last completed upload, advertised in search
/// replies; zero until one has finished.
last_upload_speed: u32,
/// Queued-upload states that came and went between two polls of
/// [`Client::uploads`]. A caller sampling that snapshot would otherwise
/// never see a peer that queued and was served inside one poll interval,
/// and "it waited" is exactly the fact a transfer log must not lose.
upload_events: Vec<crate::types::UploadInfo>,
actor_system: Arc<ActorSystem>,
}
/// A roster sorted and de-duplicated, so membership lookups can binary-search
/// it and a repeated name cannot appear twice.
fn sorted_unique(users: &[String]) -> Vec<String> {
let mut users = users.to_vec();
users.sort();
users.dedup();
users
}
impl Default for ClientContext {
fn default() -> Self {
Self::new()
}
}
// Thin delegating shims so existing callers (peer/listen, peer/download_peer,
// tests) keep working while download state lives in DownloadStore.
impl ClientContext {
pub fn add_download(&mut self, download: Download) {
self.downloads.add(download);
}
pub fn remove_download(&mut self, token: u32) {
self.downloads.remove(token);
}
#[must_use]
pub fn get_download_by_token(&self, token: u32) -> Option<&Download> {
self.downloads.get_by_token(token)
}
pub fn get_download_by_token_mut(
&mut self,
token: u32,
) -> Option<&mut Download> {
self.downloads.get_by_token_mut(token)
}
#[must_use]
pub fn get_download_tokens(&self) -> Vec<u32> {
self.downloads.tokens()
}
#[must_use]
pub const fn get_downloads(&self) -> &Vec<Download> {
self.downloads.list()
}
pub fn update_download_with_status(
&mut self,
token: u32,
status: DownloadStatus,
) {
self.downloads.update_status(token, status);
}
}
pub struct Client {
enable_listen: bool,
listen_port: u16,
/// The port the listener actually holds, known once [`Client::connect`]
/// has bound it.
bound_port: Option<u16>,
address: PeerAddress,
username: String,
password: String,
version: ClientVersion,
shared_directories: Vec<String>,
server_handle: Option<ActorHandle<ServerMessage>>,
context: Arc<RwLock<ClientContext>>,
session: SessionWatch,
/// Tells the peer listener to stop, so a disconnected client releases the
/// port it bound.
listener_stopped: Arc<AtomicBool>,
listener_released: Arc<AtomicBool>,
cancel: CancelHandle,
}
impl Drop for Client {
fn drop(&mut self) {
self.disconnect();
}
}
impl Client {
pub fn new(
username: impl Into<String>,
password: impl Into<String>,
) -> Self {
Self::with_settings(ClientSettings::new(username, password))
}
#[must_use]
pub fn with_settings(settings: ClientSettings) -> Self {
logger::init();
let mut context = ClientContext::for_user(&settings.username);
context.children = children::Children::new(settings.accept_children);
Self {
enable_listen: settings.enable_listen,
listen_port: settings.listen_port,
bound_port: None,
address: settings.server_address,
context: Arc::new(RwLock::new(context)),
username: settings.username,
password: settings.password,
version: settings.version,
shared_directories: settings.shared_directories,
server_handle: None,
session: SessionWatch::default(),
listener_stopped: Arc::new(AtomicBool::new(false)),
listener_released: Arc::new(AtomicBool::new(false)),
cancel: CancelHandle::new(),
}
}
/// The children currently hanging from us in the distributed search
/// network, by username. Empty unless the client was built with
/// [`ClientSettings::accept_children`].
#[must_use]
pub fn children(&self) -> Vec<String> {
match self.context.read_safe() {
Ok(ctx) => ctx.children.usernames(),
Err(e) => {
error!("[client] children: {}", e);
Vec::new()
}
}
}
/// The username we log in as, for attributing our own messages.
#[must_use]
pub fn username(&self) -> &str {
&self.username
}
/// The port peers can reach this client on, or `None` when it is not
/// listening (or has not connected yet). This is the port that was really
/// bound, which is not always the one that was configured.
#[must_use]
pub const fn listen_port(&self) -> Option<u16> {
self.bound_port
}
/// Why the server session ended, or `None` while it is alive.
///
/// A lost session sees nothing on the network, so an empty result set from
/// one says nothing about what the network holds.
#[must_use]
pub fn session_loss(&self) -> Option<SessionLoss> {
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<String> {
self.context
.read_safe()
.map(|ctx| ctx.shared_directories.clone())
.unwrap_or_default()
}
/// The share index as a peer receives it: the same directory listing
/// `GetShareFileList` answers with, so what this returns is what the
/// network sees.
#[must_use]
pub fn shared_listing(&self) -> Vec<crate::message::peer::SharedDirectory> {
self.context
.read_safe()
.map(|ctx| ctx.shares.directories())
.unwrap_or_default()
}
/// `(folders, files)` currently shared with peers.
#[must_use]
pub fn shared_counts(&self) -> (u32, u32) {
self.context.read_safe().map_or((0, 0), |ctx| {
(ctx.shares.folder_count(), ctx.shares.file_count())
})
}
/// Every upload this session knows about: the ones streaming or finished,
/// followed by the peers still waiting for a slot, in the order they will
/// be served.
#[must_use]
pub fn uploads(&self) -> Vec<crate::types::UploadInfo> {
self.context.read_safe().map_or_else(
|_| Vec::new(),
|ctx| {
let mut tokens: Vec<&u32> = ctx.active_uploads.keys().collect();
tokens.sort_unstable();
let mut all: Vec<crate::types::UploadInfo> = tokens
.into_iter()
.map(|token| {
let upload = &ctx.active_uploads[token];
let bytes_sent = upload
.bytes_sent
.load(std::sync::atomic::Ordering::Relaxed);
crate::types::UploadInfo {
username: upload.username.clone(),
filename: upload.filename.clone(),
size: upload.size,
bytes_sent,
speed_bytes_per_sec: upload_speed(
&upload.status,
bytes_sent,
upload.started,
),
status: upload.status.clone(),
}
})
.collect();
all.extend(ctx.queued_uploads());
all
},
)
}
/// Ask an in-progress upload to `username` of `filename` to stop.
/// Returns whether a matching in-progress upload was found.
#[must_use = "returns whether a matching upload was found"]
pub fn cancel_upload(&self, username: &str, filename: &str) -> bool {
self.context.read_safe().is_ok_and(|ctx| {
let mut found = false;
for upload in ctx.active_uploads.values() {
if upload.username == username
&& upload.filename == filename
&& upload.status == crate::types::UploadStatus::InProgress
{
upload
.cancel
.store(true, std::sync::atomic::Ordering::Relaxed);
found = true;
}
}
found
})
}
/// Replace the shared directories at runtime: rescan into a fresh
/// index (served to peers from then on) and re-announce the new
/// folder/file counts to the server.
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn set_shared_directories(&self, dirs: Vec<String>) -> Result<()> {
let roots: Vec<std::path::PathBuf> = dirs
.iter()
.filter(|dir| !dir.trim().is_empty())
.map(std::path::PathBuf::from)
.collect();
let shares = if roots.is_empty() {
Shares::empty()
} else {
Shares::scan_many(&roots)
};
info!(
"Now sharing {} files in {} folders from {} directories",
shares.file_count(),
shares.folder_count(),
roots.len()
);
let folder_count = shares.folder_count();
let file_count = shares.file_count();
{
let mut ctx = self.context.write_safe()?;
ctx.shares = Arc::new(shares);
ctx.shared_directories = dirs;
}
self.send_server_message(
crate::message::server::MessageFactory::build_shared_folders_message(
folder_count,
file_count,
),
)
}
}
mod cancel;
mod children;
mod connection;
mod context;
mod distributed;
mod downloads;
mod operations;
mod rooms;
mod search;
mod social;
mod upload_queue;
mod uploads;
pub use cancel::CancelHandle;
#[cfg(test)]
mod tests;
@@ -0,0 +1,816 @@
use super::{
Arc, Client, ClientContext, ClientOperation, ConnectionType, Download,
DownloadPeer, DownloadStatus, Duration, Instant, Peer, PeerMessage,
PeerRegistry, Receiver, RwLock, RwLockExt, ServerMessage,
build_search_response, debug, error, info, mpsc, next_connect_token,
thread, trace, warn,
};
use crate::message::server::MessageFactory;
use crate::peer::DownloadError;
mod peers;
mod search;
mod transfers;
mod tree;
const CONNECT_SWEEP_INTERVAL: Duration = Duration::from_secs(1);
/// How often to ask the peers holding our queued downloads where they sit
/// (peer code 51). Five minutes is what Nicotine+ uses: often enough that a
/// queue position on screen means something, rare enough that a peer with a
/// long queue is not pestered.
const QUEUE_POSITION_INTERVAL: Duration = Duration::from_mins(5);
/// How long a write to a child may block. The relay runs under the client
/// context lock, so an unbounded write would freeze every other operation
/// behind one child that stopped reading.
const CHILD_WRITE_TIMEOUT: Duration = Duration::from_secs(5);
impl Client {
pub(crate) fn listen_to_client_operations(
reader: Receiver<ClientOperation>,
client_context: Arc<RwLock<ClientContext>>,
own_username: String,
) {
thread::spawn(move || {
let mut last_sweep = Instant::now();
let mut last_queue_poll = Instant::now();
loop {
let next = reader.recv_timeout(CONNECT_SWEEP_INTERVAL);
if last_sweep.elapsed() >= CONNECT_SWEEP_INTERVAL {
last_sweep = Instant::now();
Self::sweep_expired_connects(&client_context);
Self::sweep_stale_offers(&client_context);
if let Ok(mut ctx) = client_context.write_safe() {
ctx.expire_pending_peer_messages(Instant::now());
if let Some(branch) =
ctx.leaf.due_announcement(Instant::now())
{
super::distributed::announce_move(
&mut ctx, &branch, true,
);
}
}
}
if last_queue_poll.elapsed() >= QUEUE_POSITION_INTERVAL {
last_queue_poll = Instant::now();
Self::poll_queue_positions(&client_context);
}
let operation = match next {
Ok(operation) => operation,
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
};
match operation {
ClientOperation::ConnectToPeer(peer) => {
if matches!(peer.connection_type, ConnectionType::F) {
let client_context_clone = client_context.clone();
let own_username_clone = own_username.clone();
thread::spawn(move || {
Self::connect_to_peer(
peer,
client_context_clone,
own_username_clone,
None,
);
});
} else {
Self::connect_to_peer(
peer,
client_context.clone(),
own_username.clone(),
None,
);
}
}
ClientOperation::SearchResult(search_result) => {
trace!("[client] SearchResult {:?}", search_result);
let mut context = match client_context.write_safe() {
Ok(c) => c,
Err(e) => {
error!("[client] SearchResult write: {}", e);
continue;
}
};
let result_token = search_result.token;
// Find the search with matching token
for search in context.searches.values_mut() {
if search.token == result_token {
search.accept(search_result);
break;
}
}
}
operation @ (ClientOperation::PeerDisconnected(..)
| ClientOperation::PeerConnectFailed(..)
| ClientOperation::ChildConnected { .. }) => {
Self::on_peers(
operation,
&client_context,
&own_username,
);
}
operation @ (ClientOperation::DownloadFromPeer(..)
| ClientOperation::UpdateDownloadTokens(..)
| ClientOperation::StartUpload { .. }
| ClientOperation::QueueUpload { .. }) => {
Self::on_transfers(
operation,
&client_context,
&own_username,
);
}
ClientOperation::GetPeerAddressResponse {
username,
host,
port,
obfuscation_type,
obfuscated_port,
} => {
debug!(
"Received peer address for {}: {}:{} (obf_type: {}, obf_port: {})",
username,
host,
port,
obfuscation_type,
obfuscated_port
);
// Port 0 is the server saying it does not know
// where this user listens (no SetWaitPort yet,
// or firewalled). It is not an address: caching
// it poisons every later lookup, and an upload
// dialled at it dies with "Can't assign
// requested address" and is dropped on the
// floor. Leave those uploads queued for a later
// resolution instead.
//
// The connect attempt further down still runs:
// its failure is exactly what makes an
// unreachable peer fall back to the
// server-brokered path.
if port == 0 {
warn!(
"[client] server reports no listening port for {}",
username
);
}
let waiting_serves = match client_context.write_safe() {
Ok(mut ctx) => {
if port != 0 {
ctx.cache_peer_address(
&username,
host.clone(),
port,
);
}
ctx.pending_serves
.remove(&username)
.unwrap_or_default()
}
Err(_) => Vec::new(),
};
for token in waiting_serves {
Self::spawn_serve(
&client_context,
&own_username,
token,
host.clone(),
port,
);
}
let peer_exists = match client_context.read_safe() {
Ok(ctx) => ctx
.peer_registry
.as_ref()
.is_some_and(|r| r.contains(&username)),
Err(e) => {
error!(
"[client] GetPeerAddressResponse read: {}",
e
);
continue;
}
};
// Existing peer: skip re-registration. Reconnect
// policy on conflict is intentionally undecided.
if !peer_exists {
let peer = Peer::new(
username,
ConnectionType::P,
host,
port,
None,
0,
// obfuscation_type is a small enum; a
// real obfuscated_port is a full u16 and
// must not be truncated into a u8 (which
// panicked and took down the ops thread).
u8::try_from(obfuscation_type).unwrap_or(0),
obfuscated_port,
);
Self::connect_to_peer(
peer,
client_context.clone(),
own_username.clone(),
None,
);
}
}
ClientOperation::UploadFailed(username, filename) => {
Self::process_failed_uploads(
client_context.clone(),
&username,
Some(&filename),
);
}
ClientOperation::PlaceInQueueUpdate {
username,
filename,
place,
} => match client_context.write_safe() {
Ok(mut ctx) => {
let updated = ctx.downloads.update_queue_position(
&username, &filename, place,
);
if !updated {
debug!(
"[client] PlaceInQueueUpdate: no matching download for {}/{}",
username, filename
);
}
}
Err(e) => {
error!("[client] PlaceInQueueUpdate write: {}", e);
}
},
ClientOperation::SetServerSender(sender) => {
match client_context.write_safe() {
Ok(mut ctx) => {
ctx.server_sender = Some(sender);
debug!("[client] Server sender initialized");
}
Err(e) => {
error!("[client] SetServerSender write: {}", e);
}
}
}
ClientOperation::PrivateMessageReceived(user_message) => {
match client_context.write_safe() {
Ok(mut ctx) => {
ctx.push_private_message(user_message);
}
Err(e) => error!(
"[client] PrivateMessageReceived write: {}",
e
),
}
}
ClientOperation::UserStatusReceived {
username,
status,
privileged,
} => match client_context.write_safe() {
Ok(mut ctx) => {
ctx.apply_user_status(username, status, privileged);
}
Err(e) => {
error!("[client] UserStatusReceived write: {}", e);
}
},
ClientOperation::UserStatsReceived {
username,
average_speed,
shared_files,
shared_folders,
} => {
// Stats about ourselves carry the speed the server
// records for us — pushed whenever it changes, since
// we watch ourselves — and the distributed child
// limit is derived from it.
let own = username == own_username;
match client_context.write_safe() {
Ok(mut ctx) => {
ctx.apply_user_stats(
username,
average_speed,
shared_files,
shared_folders,
);
if own {
ctx.set_own_average_speed(average_speed);
}
}
Err(e) => {
error!(
"[client] UserStatsReceived write: {}",
e
);
}
}
if own {
Self::announce_child_capacity(&client_context);
}
}
ClientOperation::WatchedUserReceived {
username,
exists,
status,
average_speed,
shared_files,
shared_folders,
} => {
let own = username == own_username;
match client_context.write_safe() {
Ok(mut ctx) => {
ctx.apply_watched_user(
username,
exists,
status,
average_speed,
shared_files,
shared_folders,
);
// Watching ourselves is how our own recorded
// speed reaches us, and the child limit is
// derived from it.
if own && let Some(speed) = average_speed {
ctx.set_own_average_speed(speed);
}
}
Err(e) => {
error!(
"[client] WatchedUserReceived write: {}",
e
);
}
}
if own {
Self::announce_child_capacity(&client_context);
}
}
ClientOperation::RoomEvent(event) => {
match client_context.write_safe() {
Ok(mut ctx) => ctx.apply_room_event(event),
Err(e) => error!("[client] RoomEvent write: {}", e),
}
}
ClientOperation::RoomMemberStats { room, stats } => {
match client_context.write_safe() {
Ok(mut ctx) => {
ctx.apply_room_member_stats(room, stats);
}
Err(e) => {
error!("[client] RoomMemberStats write: {}", e);
}
}
}
ClientOperation::WishlistInterval(seconds) => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.wishlist_interval = Some(seconds);
}
}
ClientOperation::PeerConnected(username) => {
// A control connection just came up — one we dialled,
// or an inbound one the listener registered. Flush any
// downloads that were queued for this peer while it
// was unreachable. Collect under a read guard, then
// act without it held.
let (registry, files): (
Option<PeerRegistry>,
Vec<String>,
) = match client_context.read_safe() {
Ok(ctx) => (
ctx.peer_registry.clone(),
ctx.get_downloads()
.iter()
.filter(|d| {
d.username == username
&& matches!(
d.status,
DownloadStatus::Queued
)
})
.map(|d| d.filename.clone())
.collect(),
),
Err(e) => {
error!("[client] PeerConnected read: {}", e);
continue;
}
};
// Also flush any peer messages (e.g. search
// responses) queued while connecting.
let queued_messages = client_context
.write_safe()
.map(|mut ctx| ctx.take_peer_messages(&username))
.unwrap_or_default();
if let Some(registry) = registry {
for filename in files {
let _ =
registry.queue_upload(&username, filename);
}
for message in queued_messages {
let _ = registry.send_to_peer(
&username,
PeerMessage::SendMessage(message),
);
}
}
}
operation @ ClientOperation::IncomingSearch { .. } => {
Self::on_search(
operation,
&client_context,
&own_username,
);
}
ClientOperation::PlaceInQueueRequested {
requester_key,
filename,
} => {
let (registry, place) = match client_context.read_safe()
{
Ok(ctx) => (
ctx.peer_registry.clone(),
ctx.place_in_queue(&requester_key, &filename),
),
Err(_) => continue,
};
// Silence would leave the peer guessing; a file
// no longer queued is being served, which is
// place 0 by the same convention Nicotine+ uses.
if let Some(registry) = registry {
let _ = registry.send_to_peer(
&requester_key,
PeerMessage::SendMessage(
MessageFactory::build_place_in_queue_response(
&filename,
place.unwrap_or(0),
),
),
);
}
}
ClientOperation::PossibleParents(candidates) => {
let (dials, ops) = match client_context.write_safe() {
Ok(mut ctx) => (
ctx.leaf.consider(candidates),
ctx.operations.clone(),
),
Err(_) => continue,
};
let Some(ops) = ops else { continue };
for dial in dials {
super::distributed::spawn_link(
dial,
own_username.clone(),
ops.clone(),
);
}
}
operation @ (ClientOperation::ParentSearch { .. }
| ClientOperation::ParentBranchLevel {
..
}
| ClientOperation::ParentBranchRoot {
..
}
| ClientOperation::ParentClosed { .. }
| ClientOperation::ResetDistributed) => {
Self::on_tree(operation, &client_context);
}
ClientOperation::PrivilegedUsers(users) => {
if let Ok(mut ctx) = client_context.write_safe() {
debug!(
"[client] {} privileged users listed",
users.len()
);
ctx.set_privileged_users(users);
}
// Someone already waiting may have just been
// outranked, but a slot that is free should
// still be filled.
Self::pump_upload_queue(&client_context);
}
ClientOperation::OwnPrivileges(seconds) => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.own_privileges = Some(seconds);
}
}
ClientOperation::Recommendations {
global,
recommended,
unrecommended,
} => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.apply_recommendations(
global,
recommended,
unrecommended,
);
}
}
ClientOperation::ItemRecommendations {
item,
recommendations,
} => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.apply_item_recommendations(
item,
recommendations,
);
}
}
ClientOperation::SimilarUsers(users) => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.apply_similar_users(users);
}
}
ClientOperation::ItemSimilarUsers { item, usernames } => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.apply_item_similar_users(item, usernames);
}
}
ClientOperation::UserInterests(interests) => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.apply_user_interests(interests);
}
}
ClientOperation::PeerInfoReceived { username, info } => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.store_peer_info(username, info);
}
}
ClientOperation::FolderContents {
username,
token,
folder,
directories,
} => {
trace!(
"[client] folder {} from {} (token {}): {} dirs",
folder,
username,
token,
directories.len()
);
if let Ok(mut ctx) = client_context.write_safe() {
ctx.store_folder_contents(
username,
folder,
directories,
);
}
}
ClientOperation::ParentMinSpeed(speed) => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.set_parent_min_speed(speed);
}
Self::announce_child_capacity(&client_context);
}
ClientOperation::ParentSpeedRatio(ratio) => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.set_parent_speed_ratio(ratio);
}
Self::announce_child_capacity(&client_context);
}
ClientOperation::SessionEstablished => {
// Interests live in the server only for the session
// that set them, so a new one starts by sending ours
// again — what Nicotine+ does from its config file.
let (sender, interests) = match client_context
.read_safe()
{
Ok(ctx) => {
(ctx.server_sender.clone(), ctx.own_interests())
}
Err(_) => continue,
};
let Some(sender) = sender else { continue };
// Watch ourselves: the server pushes a user's stats
// to whoever watches them, and our own recorded
// upload speed is what the distributed child limit is
// derived from. Nicotine+ reads the same figure from
// the stats the server sends about the logged-in
// user.
let _ = sender.send(ServerMessage::SendMessage(
MessageFactory::build_watch_user(&own_username),
));
for item in &interests.likes {
let _ = sender.send(ServerMessage::SendMessage(
MessageFactory::build_add_thing_i_like(item),
));
}
for item in &interests.hates {
let _ = sender.send(ServerMessage::SendMessage(
MessageFactory::build_add_thing_i_hate(item),
));
}
}
ClientOperation::ExcludedSearchPhrases(phrases) => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.set_excluded_search_phrases(phrases);
}
}
ClientOperation::CantConnectToPeer { token } => {
// The peer we asked the server to broker gave up. The
// token is the correlation we sent, so it names the
// peer: end the wait now instead of letting every
// queued download for them sit until the timeout.
let (username, upload) =
match client_context.write_safe() {
Ok(mut ctx) => (
ctx.take_pending_connect(token),
ctx.take_brokered_upload(token),
),
Err(e) => {
error!(
"[client] CantConnectToPeer write: {}",
e
);
(None, None)
}
};
if let Some(upload) = upload
&& upload.claim()
{
Self::fail_upload(
&client_context,
upload.token,
"peer could not connect back",
);
}
if let Some(username) = username {
debug!(
"[client] {} cannot connect back (token {})",
username, token
);
Self::fail_queued_downloads(
&client_context,
&username,
);
}
}
ClientOperation::ShareListRequested { requester_key } => {
Self::reply_to_peer(
&client_context,
&requester_key,
|ctx| {
crate::message::peer::build_shared_file_list(
&ctx.shares.directories(),
)
},
);
}
ClientOperation::UserInfoRequested { requester_key } => {
Self::reply_to_peer(
&client_context,
&requester_key,
|ctx| {
crate::message::peer::build_user_info(
ctx.upload_slots as u32,
ctx.upload_queue.len() as u32,
ctx.has_free_upload_slot(),
)
},
);
}
ClientOperation::FolderContentsRequested {
requester_key,
token,
folder,
} => {
// ponytail: walks the whole share for one folder, as a
// browse does; index by directory if it ever shows up.
Self::reply_to_peer(
&client_context,
&requester_key,
|ctx| {
let dirs: Vec<_> = ctx
.shares
.directories()
.into_iter()
.filter(|dir| dir.name == folder)
.collect();
crate::message::peer::build_folder_contents(
token, &folder, &dirs,
)
},
);
}
ClientOperation::BrowseResult {
username,
directories,
} => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.store_browse_result(username, directories);
}
}
}
}
});
}
/// Ask every peer holding a queued download of ours where it sits. The
/// answers arrive as `PlaceInQueueResponse` and land on the downloads.
fn poll_queue_positions(client_context: &Arc<RwLock<ClientContext>>) {
let (registry, queued) = match client_context.read_safe() {
Ok(ctx) => (
ctx.peer_registry.clone(),
ctx.get_downloads()
.iter()
.filter(|d| matches!(d.status, DownloadStatus::Queued))
.map(|d| (d.username.clone(), d.filename.clone()))
.collect::<Vec<_>>(),
),
Err(_) => return,
};
let Some(registry) = registry else { return };
for (username, filename) in queued {
// Only over a connection we already hold: a peer we cannot reach
// is a connection problem, not a queue question.
if !registry.contains(&username) {
continue;
}
let request =
MessageFactory::build_place_in_queue_request(&filename);
let _ = registry
.send_to_peer(&username, PeerMessage::SendMessage(request));
}
}
/// Tell the server whether we would take another child right now
/// (`AcceptChildren`, code 100). Sent whenever that answer changes: the
/// server only offers us to peers while it is true.
fn announce_child_capacity(client_context: &Arc<RwLock<ClientContext>>) {
let (sender, has_room) = match client_context.write_safe() {
Ok(mut ctx) => {
// Only on a change: the server holds this as a standing
// state, and repeating it on every message we happen to
// receive would be noise.
let Some(has_room) = ctx.accept_children_change() else {
return;
};
(ctx.server_sender.clone(), has_room)
}
Err(_) => return,
};
let Some(sender) = sender else { return };
let message =
crate::message::server::MessageFactory::build_accept_children(
has_room,
);
let _ = sender.send(ServerMessage::SendMessage(message));
}
/// Answer a peer with the message `build` derives from the client state.
fn reply_to_peer(
client_context: &Arc<RwLock<ClientContext>>,
requester_key: &str,
build: impl FnOnce(&ClientContext) -> crate::message::Message,
) {
let (registry, message) = match client_context.read_safe() {
Ok(ctx) => (ctx.peer_registry.clone(), build(&ctx)),
Err(_) => return,
};
if let Some(registry) = registry {
let _ = registry
.send_to_peer(requester_key, PeerMessage::SendMessage(message));
}
}
fn sweep_stale_offers(client_context: &Arc<RwLock<ClientContext>>) {
let freed = client_context
.write_safe()
.is_ok_and(|mut ctx| ctx.expire_stale_offers(Instant::now()));
if freed {
Self::pump_upload_queue(client_context);
}
}
fn sweep_expired_connects(client_context: &Arc<RwLock<ClientContext>>) {
let expired = match client_context.write_safe() {
Ok(mut ctx) => ctx.take_expired_connects(Instant::now()),
Err(_) => return,
};
for username in expired {
Self::fail_queued_downloads(client_context, &username);
}
let expired = match client_context.write_safe() {
Ok(mut ctx) => ctx.take_expired_brokered_uploads(Instant::now()),
Err(_) => return,
};
for upload in expired.iter().filter(|u| u.claim()) {
Self::fail_upload(
client_context,
upload.token,
"peer never connected back",
);
}
}
}
@@ -0,0 +1,196 @@
//! Dispatch arms about a peer connection appearing or going away.
//!
//! Lifted out of the dispatch loop whole, so the bodies read exactly as they
//! did inside it — including `own_username`, which is re-owned on the way in
//! rather than have every line that clones it rewritten.
use super::{
Arc, CHILD_WRITE_TIMEOUT, Client, ClientContext, ClientOperation,
ConnectionType, RwLock, RwLockExt, ServerMessage, debug, error,
next_connect_token, warn,
};
impl Client {
pub(super) fn on_peers(
operation: ClientOperation,
client_context: &Arc<RwLock<ClientContext>>,
own_username: &str,
) {
let own_username = own_username.to_string();
match operation {
ClientOperation::PeerDisconnected(id, username, error) => {
// Scope the read guard: process_failed_uploads
// below acquires a write lock on the same
// RwLock, which would self-deadlock the entire
// client ops loop if this read guard were still
// held on this thread. Evict only if this exact
// actor still occupies the slot, so a replaced
// actor's shutdown can't remove its successor.
{
let context = match client_context.read_safe() {
Ok(c) => c,
Err(e) => {
error!("[client] PeerDisconnected read: {}", e);
return;
}
};
if let Some(ref registry) = context.peer_registry
&& let Some(handle) =
registry.remove_peer_if(&username, id)
{
let _ = handle.stop();
}
}
// Only an error is evidence the peer is gone. A clean
// close — our idle reaper, or a remote client tidying
// an idle socket while it waits in our queue — must
// not throw away everything that peer has queued: the
// connection comes back, the queue cannot. A departed
// peer wedging uploads shut is still covered: its
// erroring transfer fails within the socket deadlines
// and the error branch here frees its slots.
if let Some(error) = error {
warn!(
"[client] Peer {} disconnected with error: {:?}",
username, error
);
Self::process_failed_uploads(
client_context.clone(),
&username,
None,
);
Self::release_upload_slots(client_context, &username);
}
}
ClientOperation::PeerConnectFailed(
id,
username,
brokered_token,
) => {
// Direct connect failed. A dial the server asked
// us to make is answered with CantConnectToPeer
// quoting the peer's own token: that peer is
// already waiting on the server, and asking it to
// broker the same connection back only trades one
// unreachable direction for the other. Every other
// dial falls back to the broker below.
if let Some(peer_token) = brokered_token {
let server_sender = match client_context.write_safe() {
Ok(ctx) => {
if let Some(handle) = ctx
.peer_registry
.as_ref()
.and_then(|r| r.remove_peer_if(&username, id))
{
let _ = handle.stop();
}
ctx.server_sender.clone()
}
Err(e) => {
error!("[client] PeerConnectFailed write: {}", e);
return;
}
};
if let Some(sender) = server_sender {
let msg = crate::message::server::MessageFactory::build_cant_connect_to_peer(
peer_token,
&username,
);
let _ = sender.send(ServerMessage::SendMessage(msg));
}
// Nothing else is coming from this peer, so
// anything queued for it fails now rather than
// waiting out its timeout.
Self::fail_queued_downloads(client_context, &username);
return;
}
// Direct connect failed: ask the server to
// broker it. Register a correlation token, then
// send ConnectToPeer so the (firewalled) peer
// connects back to our listener quoting it.
let token = next_connect_token();
let server_sender = match client_context.write_safe() {
Ok(mut ctx) => {
// Reap the dead outbound actor so it
// stops pinning a pool worker and no
// longer shadows the brokered reconnect
// (a stale registry entry would make
// later downloads queue into a dead,
// streamless actor and hang). Identity-
// aware so a newer namesake is untouched.
if let Some(handle) = ctx
.peer_registry
.as_ref()
.and_then(|r| r.remove_peer_if(&username, id))
{
let _ = handle.stop();
}
ctx.add_pending_connect(token, username.clone());
ctx.server_sender.clone()
}
Err(e) => {
error!("[client] PeerConnectFailed write: {}", e);
return;
}
};
let Some(sender) = server_sender else {
return;
};
let msg = crate::message::server::MessageFactory::build_connect_to_peer(
token,
&username,
ConnectionType::P,
);
let _ = sender.send(ServerMessage::SendMessage(msg));
}
ClientOperation::ChildConnected { username, stream } => {
// A peer wants to hang from us. Take it on if this
// client serves children and has room, then tell it
// where our branch sits so it can report its own
// place; otherwise the socket is dropped here and the
// peer looks for another parent.
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
if !ctx.children.has_room() {
return;
}
// A child that stops reading must not wedge us: the
// relay writes under the context lock, so the write
// is bounded and a child that hits it loses its slot.
if stream.set_nodelay(true).is_err()
|| stream
.set_write_timeout(Some(CHILD_WRITE_TIMEOUT))
.is_err()
{
return;
}
if username == own_username {
return; // we cannot hang from ourselves
}
if ctx.children.accept(&username, stream) {
let branch = ctx.leaf.branch();
ctx.children.send_stance_to(
&username,
&branch.root,
branch.level,
);
debug!(
"[distributed] carrying {} children",
ctx.children.len()
);
// The server stops offering us to peers once we
// are full, and starts again when one leaves.
let full = !ctx.children.has_room();
drop(ctx);
if full {
Self::announce_child_capacity(client_context);
}
}
}
// The dispatch loop routes only the variants above to here.
_ => {}
}
}
}
@@ -0,0 +1,118 @@
//! The dispatch arm that answers a search reaching us from the network.
//!
//! Lifted out of the dispatch loop whole, so the bodies read exactly as they
//! did inside it — including `own_username`, which is re-owned on the way in
//! rather than have every line that clones it rewritten.
use super::{
Arc, Client, ClientContext, ClientOperation, Instant, PeerMessage, RwLock,
RwLockExt, ServerMessage, build_search_response, error,
};
impl Client {
pub(super) fn on_search(
operation: ClientOperation,
client_context: &Arc<RwLock<ClientContext>>,
own_username: &str,
) {
let own_username = own_username.to_string();
let ClientOperation::IncomingSearch {
username,
token,
query,
from_parent,
} = operation
else {
return;
};
{
// Every search we receive comes from the tree: from
// our parent, or from the server acting as one. That
// is what qualifies us to carry children at all —
// Nicotine+ refuses them until something feeds it —
// and passing the search down is the whole duty.
let (capacity_changed, answer) = match client_context.write_safe() {
Ok(mut ctx) => {
let was_fed = ctx.children.is_fed();
let before = ctx.children.len();
ctx.set_fed_by(true);
if !from_parent {
// A parent's search was passed down where
// it arrived; this is the server's.
ctx.children.broadcast_search(&username, token, &query);
}
// Answering costs a scan of everything we
// share, so the budget applies to searches
// from the tree. The server's own are few.
let answer =
!from_parent || ctx.leaf.admit_search(Instant::now());
// A child whose socket has gone is dropped by
// that write, which frees a slot the server
// should hear about.
(!was_fed || ctx.children.len() != before, answer)
}
Err(_) => (false, false),
};
if capacity_changed {
Self::announce_child_capacity(client_context);
}
if !answer {
return;
}
// Don't answer our own distributed search.
if username == own_username {
return;
}
let response = match client_context.read_safe() {
Ok(ctx) => build_search_response(
&ctx.shares,
&own_username,
token,
&query,
ctx.has_free_upload_slot(),
ctx.last_upload_speed,
ctx.upload_queue.len() as u32,
&ctx.excluded_search_phrases,
),
Err(e) => {
error!("[client] IncomingSearch read: {}", e);
return;
}
};
let Some(message) = response else {
return; // no matching shares
};
// Deliver to the searcher: send now if we have a
// control connection, else open one and queue.
let (connected, registry, server_sender) =
match client_context.read_safe() {
Ok(ctx) => (
ctx.peer_registry
.as_ref()
.is_some_and(|r| r.contains(&username)),
ctx.peer_registry.clone(),
ctx.server_sender.clone(),
),
Err(_) => return,
};
if connected {
if let Some(registry) = registry {
let _ = registry.send_to_peer(
&username,
PeerMessage::SendMessage(message),
);
}
} else {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.queue_peer_message(&username, message);
}
if let Some(sender) = server_sender {
let _ =
sender.send(ServerMessage::GetPeerAddress(username));
}
}
}
}
}
@@ -0,0 +1,219 @@
//! Dispatch arms that move bytes: what a peer allows, what it asks of us,
//! and the tokens tying the two together.
//!
//! Lifted out of the dispatch loop whole, so the bodies read exactly as they
//! did inside it — including `own_username`, which is re-owned on the way in
//! rather than have every line that clones it rewritten.
use super::{
Arc, Client, ClientContext, ClientOperation, Download, DownloadError,
DownloadPeer, DownloadStatus, MessageFactory, PeerMessage, RwLock,
RwLockExt, ServerMessage, debug, error, info, thread, trace,
};
impl Client {
pub(super) fn on_transfers(
operation: ClientOperation,
client_context: &Arc<RwLock<ClientContext>>,
own_username: &str,
) {
let own_username = own_username.to_string();
match operation {
ClientOperation::DownloadFromPeer(token, peer, allowed) => {
let maybe_download = match client_context.write_safe() {
Ok(mut ctx) => {
ctx.downloads.claim_for_peer(token, &peer.username)
}
Err(e) => {
error!("[client] DownloadFromPeer write: {}", e);
return;
}
};
trace!(
"[client] DownloadFromPeer token: {} peer: {:?}",
token, peer
);
let Some(download) = maybe_download else {
debug!(
"[client] transfer token {} is missing, belongs \
to another peer, or is already claimed; ignoring",
token,
);
return;
};
let client_context = client_context.clone();
thread::spawn(move || {
let download_peer = DownloadPeer::new(
download.username.clone(),
peer.host.clone(),
peer.port,
token,
allowed,
own_username,
);
match download_peer.download_file(
client_context,
Some(download.clone()),
None,
) {
Ok((download, filename)) => {
info!(
"Successfully downloaded {} bytes to {}",
download.size, filename
);
}
Err(DownloadError::Cancelled) => {}
Err(e) => {
error!(
"Failed to download file '{}' from {}:{} (token: {}) - Error: {}",
download.filename,
peer.host,
peer.port,
download.token,
e
);
}
}
});
}
ClientOperation::UpdateDownloadTokens(transfer, username) => {
let mut context = match client_context.write_safe() {
Ok(c) => c,
Err(e) => {
error!("[client] UpdateDownloadTokens write: {}", e);
return;
}
};
let download_to_update =
context.get_downloads().iter().find_map(|d| {
if d.username == username
&& d.filename == transfer.filename
{
Some((d.token, d.clone()))
} else {
None
}
});
let cancelled =
download_to_update.as_ref().is_some_and(|(_, d)| {
matches!(d.status, DownloadStatus::Cancelled)
});
if !cancelled
&& let Some((old_token, download)) = download_to_update
{
trace!(
"[client] UpdateDownloadTokens found {old_token}, transfer: {:?}",
transfer
);
context.add_download(Download {
token: transfer.token,
size: transfer.size,
..download
});
context.remove_download(old_token);
}
// Only now invite the file connection: it is
// matched by this token, which is recorded as
// of the line above. Answering any earlier
// races the peer's connection against our own
// bookkeeping.
let registry = context.peer_registry.clone();
drop(context);
if let Some(registry) = registry {
let response = if cancelled {
MessageFactory::build_transfer_denial_message(
transfer.token,
"Cancelled",
)
} else {
MessageFactory::build_transfer_response_message(
transfer,
)
};
let _ = registry.send_to_peer(
&username,
PeerMessage::SendMessage(response),
);
}
}
ClientOperation::StartUpload { token } => {
// The peer accepted our offer: resolve their
// address (from the code-9 GetPeerAddress) and
// stream the file, or queue until it resolves.
let (job_addr, downloader) = match client_context.write_safe() {
Ok(mut ctx) => {
ctx.mark_offer_answered(token);
let Some(job) = ctx.uploads.get(&token) else {
return;
};
(
ctx.peer_address(&job.downloader),
job.downloader.clone(),
)
}
Err(_) => return,
};
if let Some((host, port)) = job_addr {
Self::spawn_serve(
client_context,
&own_username,
token,
host,
port,
);
} else {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.pending_serves
.entry(downloader.clone())
.or_default()
.push(token);
}
if let Ok(ctx) = client_context.read_safe()
&& let Some(sender) = ctx.server_sender.clone()
{
let _ = sender
.send(ServerMessage::GetPeerAddress(downloader));
}
}
}
ClientOperation::QueueUpload {
requester_key,
filename,
} => {
// The peer served next may not be this one.
match client_context.write_safe() {
Ok(mut ctx) => {
let Some(file) = ctx.shares.get(&filename) else {
debug!(
"[client] QueueUpload for unknown file {}",
filename
);
return;
};
let size = file.size;
let real_path = file.real_path.clone();
ctx.enqueue_upload(
&requester_key,
&filename,
real_path,
size,
);
}
Err(e) => {
error!("[client] QueueUpload write: {}", e);
return;
}
}
Self::pump_upload_queue(client_context);
}
// The dispatch loop routes only the variants above to here.
_ => {}
}
}
}
@@ -0,0 +1,103 @@
//! Dispatch arms for our place in the distributed search tree.
//!
//! Lifted out of the dispatch loop whole, so the bodies read exactly as they
//! did inside it.
use super::{Arc, Client, ClientContext, ClientOperation, RwLock, RwLockExt};
impl Client {
pub(super) fn on_tree(
operation: ClientOperation,
client_context: &Arc<RwLock<ClientContext>>,
) {
match operation {
ClientOperation::ParentSearch {
parent,
link,
username,
token,
query,
} => {
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
if let Some(branch) = ctx.leaf.search_from(&parent, link) {
crate::client::distributed::announce_move(
&mut ctx, &branch, true,
);
}
if ctx.leaf.is_parent(&parent, link) {
// The tree's stream is not ours to throttle: every
// search goes down to our children. The budget
// below bounds only the share scan we do for our
// own reply.
ctx.children.broadcast_search(&username, token, &query);
if let Some(ops) = &ctx.operations {
let _ = ops.send(ClientOperation::IncomingSearch {
username,
token,
query,
from_parent: true,
});
}
}
}
ClientOperation::ParentBranchLevel {
parent,
link,
level,
} => {
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
if let Some(branch) =
ctx.leaf.branch_level(&parent, link, level)
{
crate::client::distributed::announce_move(
&mut ctx, &branch, true,
);
}
}
ClientOperation::ParentBranchRoot { parent, link, root } => {
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
if let Some(branch) = ctx.leaf.branch_root(&parent, link, &root)
{
crate::client::distributed::announce_move(
&mut ctx, &branch, true,
);
}
}
ClientOperation::ParentClosed { parent, link } => {
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
if ctx.leaf.closed(&parent, link) {
let branch = ctx.leaf.branch();
crate::client::distributed::announce_move(
&mut ctx, &branch, false,
);
}
}
// Also what a fresh login means: whatever tree we hung
// from belongs to the old session.
ClientOperation::ResetDistributed => {
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
ctx.leaf.reset();
// A fresh session feeds us nothing until the tree (or
// the server) starts again, so the children we were
// carrying are let go rather than left starving.
ctx.set_fed_by(false);
let branch = ctx.leaf.branch();
crate::client::distributed::announce_move(
&mut ctx, &branch, false,
);
}
// The dispatch loop routes only the variants above to here.
_ => {}
}
}
}
+308
View File
@@ -0,0 +1,308 @@
use super::{
Client, PeerMessage, Result, RoomEvent, RoomInfo, RwLockExt, ServerMessage,
SharedDirectory, SoulseekRs, UserMessage, error,
};
use crate::types::{RoomUserStats, UserInfo};
impl Client {
/// Send a private message to another user via the server.
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn send_private_message(
&self,
username: &str,
message: &str,
) -> Result<()> {
let handle = self
.server_handle
.as_ref()
.ok_or(SoulseekRs::NotConnected)?;
let msg = crate::message::server::MessageFactory::build_message_user(
username, message,
);
handle
.send(ServerMessage::SendMessage(msg))
.map_err(|_| SoulseekRs::NotConnected)?;
Ok(())
}
/// Send a raw server message via the server actor, mapping a dead channel
/// to [`SoulseekRs::NotConnected`].
pub(super) fn send_server_message(
&self,
message: crate::message::Message,
) -> Result<()> {
self.server_handle
.as_ref()
.ok_or(SoulseekRs::NotConnected)?
.send(ServerMessage::SendMessage(message))
.map_err(|_| SoulseekRs::NotConnected)?;
Ok(())
}
/// Ask the server for the list of public chat rooms. The response arrives
/// asynchronously; read it with [`Client::room_list`] or by draining
/// [`Client::take_room_events`] for a [`RoomEvent::List`].
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn request_room_list(&self) -> Result<()> {
self.send_server_message(
crate::message::server::MessageFactory::build_room_list_request(),
)
}
/// Join a public chat room. The membership list and subsequent messages
/// arrive via [`Client::take_room_events`].
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn join_room(&self, room: &str) -> Result<()> {
self.send_server_message(
crate::message::server::MessageFactory::build_join_room(
room, false,
),
)
}
/// Leave a chat room previously joined with [`Client::join_room`].
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn leave_room(&self, room: &str) -> Result<()> {
self.send_server_message(
crate::message::server::MessageFactory::build_leave_room(room),
)
}
/// Say `message` in chat room `room`. The server echoes it back as a
/// [`RoomEvent::Message`], so the UI should render from that echo rather
/// than optimistically.
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn say_in_room(&self, room: &str, message: &str) -> Result<()> {
self.send_server_message(
crate::message::server::MessageFactory::build_say_chatroom(
room, message,
),
)
}
/// The latest snapshot of the public chat-room list.
#[must_use]
pub fn room_list(&self) -> Vec<RoomInfo> {
match self.context.read_safe() {
Ok(ctx) => ctx.room_list(),
Err(e) => {
error!("[client] room_list: {}", e);
Vec::new()
}
}
}
/// Ask the server what it knows about `username`: online status and share
/// statistics.
///
/// Any previous answer for `username` is discarded first, so
/// [`Client::user_info`] returns `None` until the replies to *this*
/// request arrive — polling it cannot mistake a stale snapshot for a
/// fresh one.
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn request_user_info(&self, username: &str) -> Result<()> {
use crate::message::server::MessageFactory;
self.context.write_safe()?.invalidate_user_info(username);
self.send_server_message(MessageFactory::build_get_user_status(
username,
))?;
self.send_server_message(MessageFactory::build_get_user_stats(username))
}
/// What the server has told us about `username` since the last
/// [`Client::request_user_info`], or `None` before any reply arrives.
///
/// Presence and statistics are separate replies, so a snapshot can carry
/// one and not the other; [`UserInfo::is_complete`] reports when both
/// have landed.
#[must_use]
pub fn user_info(&self, username: &str) -> Option<UserInfo> {
match self.context.read_safe() {
Ok(ctx) => ctx.user_info(username),
Err(e) => {
error!("[client] user_info: {}", e);
None
}
}
}
/// Ask the server to watch `username` (code 5).
///
/// The server replies with that user's current status and share
/// statistics — readable with [`Client::user_info`] — and then keeps
/// pushing their status changes until [`Client::unwatch_user`]. Watching
/// a user the server does not know is answered with a "does not exist"
/// flag, and that user is dropped from [`Client::watched_users`].
///
/// # Errors
/// 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))?;
context.add_watched_user(username);
Ok(())
}
/// Stop watching `username` (code 6) and drop what we knew about them.
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn unwatch_user(&self, username: &str) -> Result<()> {
use crate::message::server::MessageFactory;
self.send_server_message(MessageFactory::build_unwatch_user(username))?;
self.context.write_safe()?.remove_watched_user(username);
Ok(())
}
/// Everyone this client is currently watching, sorted by name.
#[must_use]
pub fn watched_users(&self) -> Vec<String> {
match self.context.read_safe() {
Ok(ctx) => ctx.watched_users(),
Err(e) => {
error!("[client] watched_users: {}", e);
Vec::new()
}
}
}
/// Who is currently in `room`, sorted by name.
///
/// The roster is built from the membership the server sends when
/// [`Client::join_room`] succeeds and kept current by the join and leave
/// events after it, so it is populated shortly after joining rather than
/// immediately. Returns an empty list for a room this client has not
/// joined.
#[must_use]
pub fn room_members(&self, room: &str) -> Vec<String> {
match self.context.read_safe() {
Ok(ctx) => ctx.room_members(room),
Err(e) => {
error!("[client] room_members: {}", e);
Vec::new()
}
}
}
/// What the server reported about the members of `room` when we joined:
/// their status, share statistics, free upload slots and country.
///
/// Populated from the same reply that fills [`Client::room_members`], so
/// it lands shortly after joining. Unlike the roster it is not updated by
/// later join and leave events -- it is the snapshot taken at join time.
/// Returns an empty list for a room this client has not joined.
#[must_use]
pub fn room_member_stats(&self, room: &str) -> Vec<RoomUserStats> {
match self.context.read_safe() {
Ok(ctx) => ctx.room_member_stats(room),
Err(e) => {
error!("[client] room_member_stats: {}", e);
Vec::new()
}
}
}
/// Remove and return all chat-room events received since the last call.
#[must_use]
pub fn take_room_events(&self) -> Vec<RoomEvent> {
match self.context.write_safe() {
Ok(mut ctx) => ctx.take_room_events(),
Err(e) => {
error!("[client] take_room_events: {}", e);
Vec::new()
}
}
}
/// Request a peer's shared-file listing. When it arrives it can be
/// retrieved with [`Client::take_browse_result`].
///
/// # Errors
/// Returns an error if the client's context lock is poisoned.
pub fn browse_user(&self, username: &str) -> Result<()> {
let request =
crate::message::server::MessageFactory::build_get_share_file_list();
self.context.write_safe()?.mark_browse_pending(username);
self.send_to_peer_or_queue(username, request)
}
/// Send `request` to `username` over their control connection, or hold it
/// until one exists — asking the server for their address so it does.
///
/// # Errors
/// [`SoulseekRs::LockPoisoned`] if the client state is poisoned.
pub(super) fn send_to_peer_or_queue(
&self,
username: &str,
request: crate::message::Message,
) -> Result<()> {
let (connected, registry) = {
let ctx = self.context.read_safe()?;
(
ctx.peer_registry
.as_ref()
.is_some_and(|r| r.contains(username)),
ctx.peer_registry.clone(),
)
};
if connected {
if let Some(registry) = registry {
let _ = registry
.send_to_peer(username, PeerMessage::SendMessage(request));
}
} else {
// Without a server there is nobody to resolve the peer's address,
// so the request would sit in the queue forever: say so instead.
let handle = self
.server_handle
.as_ref()
.ok_or(SoulseekRs::NotConnected)?;
self.context
.write_safe()?
.queue_peer_message(username, request);
let _ = handle
.send(ServerMessage::GetPeerAddress(username.to_string()));
}
Ok(())
}
/// Remove and return a peer's shared-file listing requested via
/// [`Client::browse_user`], if it has arrived.
#[must_use]
pub fn take_browse_result(
&self,
username: &str,
) -> Option<Vec<SharedDirectory>> {
self.context
.write_safe()
.ok()
.and_then(|mut ctx| ctx.take_browse_result(username))
}
/// Remove and return all private messages received since the last call.
#[must_use]
pub fn take_private_messages(&self) -> Vec<UserMessage> {
match self.context.write_safe() {
Ok(mut ctx) => ctx.take_private_messages(),
Err(e) => {
error!("[client] take_private_messages: {}", e);
Vec::new()
}
}
}
}
+238
View File
@@ -0,0 +1,238 @@
use super::{
Arc, AtomicBool, Client, DEFAULT_WISHLIST_INTERVAL, Duration, HashMap,
Instant, Ordering, Result, RwLockExt, Search, SearchResult, ServerMessage,
SoulseekRs, info, next_search_token, sleep,
};
impl Client {
pub fn search(
&self,
query: &str,
timeout: Duration,
) -> Result<Vec<SearchResult>> {
self.search_with_cancel(query, timeout, None)
}
/// Send `query` as a wishlist search (server code 103) and return at once.
///
/// Results accumulate under `query`, so the caller reads them back with
/// [`Client::get_search_results`] after waiting however long it wants to.
/// Starting several wishes and then waiting once is the whole point: waiting
/// per wish would cost one full search window each.
///
/// The server rate-limits these to the interval it announced — see
/// [`Client::wishlist_interval`] — so a wish re-sent sooner than that comes
/// back empty.
///
/// # Errors
/// [`SoulseekRs::NotConnected`] when there is no server connection.
pub fn start_wishlist_search(&self, query: &str) -> Result<()> {
self.send_search(query, true)
}
/// How long to wait between wishlist searches: what the server announced in
/// code 104, or [`DEFAULT_WISHLIST_INTERVAL`] until it has.
#[must_use]
pub fn wishlist_interval(&self) -> Duration {
self.context
.read_safe()
.ok()
.and_then(|ctx| ctx.wishlist_interval)
.map_or(DEFAULT_WISHLIST_INTERVAL, |seconds| {
Duration::from_secs(u64::from(seconds))
})
}
pub fn search_with_cancel(
&self,
query: &str,
timeout: Duration,
cancel_flag: Option<Arc<AtomicBool>>,
) -> Result<Vec<SearchResult>> {
self.send_search(query, false)?;
Self::collect_for(timeout, cancel_flag);
Ok(self.get_search_results(query))
}
/// Register `query` and put its search on the wire. Returns as soon as the
/// message is queued; nothing has answered yet.
fn send_search(&self, query: &str, wishlist: bool) -> Result<()> {
info!("Searching for {}", query);
let token = self.register_search(query)?;
let Some(handle) = &self.server_handle else {
return Err(SoulseekRs::NotConnected);
};
let query = query.to_string();
let _ = handle.send(if wishlist {
ServerMessage::WishlistSearch { token, query }
} else {
ServerMessage::FileSearch { token, query }
});
Ok(())
}
/// Search one user's shares (server code 42) and return at once.
///
/// Results arrive from that user alone and accumulate under `query`, read
/// back with [`Client::get_search_results`] — the same place a plain
/// search's results land, since the replies are ordinary
/// `FileSearchResponse` frames.
///
/// # Errors
/// [`SoulseekRs::NotConnected`] when there is no server connection.
pub fn search_user(&self, username: &str, query: &str) -> Result<()> {
let token = self.register_search(query)?;
self.send_server_message(
crate::message::server::MessageFactory::build_user_search(
username, token, query,
),
)
}
/// Search the shares of everyone in `room` (server code 120), returning at
/// once. Results accumulate under `query` exactly as above.
///
/// # Errors
/// [`SoulseekRs::NotConnected`] when there is no server connection.
pub fn search_room(&self, room: &str, query: &str) -> Result<()> {
let token = self.register_search(query)?;
self.send_server_message(
crate::message::server::MessageFactory::build_room_search(
room, token, query,
),
)
}
/// Take a token for `query` and register it, so replies quoting that token
/// are filed under `query` when they arrive.
fn register_search(&self, query: &str) -> Result<u32> {
if self.server_handle.is_none() {
return Err(SoulseekRs::NotConnected);
}
let token = next_search_token();
self.context.write_safe()?.searches.insert(
query.to_string(),
Search {
token,
results: Vec::new(),
},
);
Ok(token)
}
/// The phrases the server excludes from the search network (code 160), as
/// last announced. Files whose path carries one are left out of the
/// replies this client sends. Empty until the server has said.
#[must_use]
pub fn excluded_search_phrases(&self) -> Vec<String> {
self.context
.read_safe()
.map(|ctx| ctx.excluded_search_phrases())
.unwrap_or_default()
}
/// Let responses accumulate for `timeout`, or until cancelled.
///
/// Peers answer a search over their own connections whenever they get round
/// to it, so there is nothing to await — the window is the whole protocol.
/// Late responders are often the better sources, which is why this runs the
/// window out rather than returning on the first hit.
pub fn collect_for(
timeout: Duration,
cancel_flag: Option<Arc<AtomicBool>>,
) {
let start = Instant::now();
while start.elapsed() < timeout {
sleep(Duration::from_millis(100));
if let Some(flag) = &cancel_flag
&& flag.load(Ordering::Relaxed)
{
info!("Search cancelled by user");
return;
}
}
}
#[must_use]
pub fn get_search_results_count(&self, search_key: &str) -> usize {
self.context
.read_safe()
.ok()
.and_then(|ctx| {
ctx.searches.get(search_key).map(|s| s.results.len())
})
.unwrap_or(0)
}
#[must_use]
pub fn get_search_results(&self, search_key: &str) -> Vec<SearchResult> {
self.context
.read_safe()
.ok()
.and_then(|ctx| {
ctx.searches.get(search_key).map(|s| s.results.clone())
})
.unwrap_or_default()
}
/// Non-blocking variant that returns None if the lock is unavailable
#[must_use]
pub fn try_get_search_results(
&self,
search_key: &str,
) -> Option<Vec<SearchResult>> {
self.context.try_read().ok().and_then(|ctx| {
ctx.searches.get(search_key).map(|s| s.results.clone())
})
}
/// Drop a search and everything it collected.
///
/// Returns whether there was one to drop. A client that stays up for days
/// would otherwise hold every result set it has ever seen, and a caller
/// that has dismissed a search has no other way to say so.
#[must_use]
pub fn forget_search(&self, search_key: &str) -> bool {
self.context
.write_safe()
.is_ok_and(|mut ctx| ctx.searches.remove(search_key).is_some())
}
#[must_use]
pub fn get_all_searches(&self) -> HashMap<String, Search> {
self.context
.read_safe()
.map(|ctx| ctx.searches.clone())
.unwrap_or_default()
}
/// Every registered search and how many files it has collected, without
/// copying the result sets.
///
/// This is the accessor to poll. [`Client::get_all_searches`] clones every
/// result of every search, which for a popular query is megabytes per
/// call — a caller that only wants to know what exists and whether
/// anything new arrived reads the counts instead.
#[must_use]
pub fn search_file_counts(&self) -> Vec<(String, usize)> {
self.context
.read_safe()
.map(|ctx| {
ctx.searches
.iter()
.map(|(query, search)| {
(
query.clone(),
search
.results
.iter()
.map(|result| result.files.len())
.sum(),
)
})
.collect()
})
.unwrap_or_default()
}
}
+542
View File
@@ -0,0 +1,542 @@
//! The social side of the protocol: room tickers (codes 113-116), the global
//! room feed (150-152), interests and the recommendation queries built on them
//! (51/52/54/56/57/110/111/112/117/118), and the odds and ends a client owes
//! the server — a keepalive ping (32), gifting privileges (123) and changing
//! our password (142).
use super::{
Client, Recommendation, Result, RoomTicker, RwLockExt, SimilarUser,
UserInterests, error,
};
use crate::message::server::MessageFactory;
impl Client {
/// Send a keepalive ping (server code 32). The server does not reply; the
/// point is that a connection with nothing to say still has traffic on it,
/// so a NAT or the server's idle reaper does not drop it.
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn ping_server(&self) -> Result<()> {
self.send_server_message(MessageFactory::build_server_ping())
}
/// Join a private room, creating it (and owning it) if the name is free.
///
/// A name already taken by someone else's private room comes back as a
/// [`crate::RoomEvent::CantCreate`] rather than a join.
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn join_private_room(&self, room: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_join_room(room, true))
}
/// Invite `username` into the private room we own (code 134).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn add_room_member(&self, room: &str, username: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_add_room_member(
room, username,
))
}
/// Remove `username` from the private room we own (code 135).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn remove_room_member(&self, room: &str, username: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_remove_room_member(
room, username,
))
}
/// Give up our own membership of a private room (code 136).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn leave_private_room(&self, room: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_cancel_room_membership(
room,
))
}
/// Give up ownership of a private room, which deletes it (code 137).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn disband_private_room(&self, room: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_cancel_room_ownership(
room,
))
}
/// Say whether we accept private-room invitations (code 141).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn set_room_invitations_enabled(&self, enabled: bool) -> Result<()> {
self.send_server_message(MessageFactory::build_enable_room_invitations(
enabled,
))
}
/// Make `username` an operator of our private room (code 143).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn add_room_operator(&self, room: &str, username: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_add_room_operator(
room, username,
))
}
/// Take operator status away from `username` (code 144).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn remove_room_operator(
&self,
room: &str,
username: &str,
) -> Result<()> {
self.send_server_message(MessageFactory::build_remove_room_operator(
room, username,
))
}
/// Give up our own operator status in `room` (code 147).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn resign_room_operatorship(&self, room: &str) -> Result<()> {
self.send_server_message(
MessageFactory::build_cancel_room_operatorship(room),
)
}
/// Who may enter the private room `room`, as the server last reported.
#[must_use]
pub fn private_room_members(&self, room: &str) -> Vec<String> {
match self.context.read_safe() {
Ok(ctx) => ctx.private_room_members(room),
Err(e) => {
error!("[client] private_room_members: {}", e);
Vec::new()
}
}
}
/// Who runs the private room `room`.
#[must_use]
pub fn private_room_operators(&self, room: &str) -> Vec<String> {
match self.context.read_safe() {
Ok(ctx) => ctx.private_room_operators(room),
Err(e) => {
error!("[client] private_room_operators: {}", e);
Vec::new()
}
}
}
/// The private rooms we belong to.
#[must_use]
pub fn private_rooms(&self) -> Vec<String> {
match self.context.read_safe() {
Ok(ctx) => ctx.private_rooms(),
Err(e) => {
error!("[client] private_rooms: {}", e);
Vec::new()
}
}
}
/// Set our online status (server code 28): away, or back to online.
///
/// Other clients see it in their `GetUserStatus` and watch replies, and
/// the server uses it when it decides who to hand a search to.
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn set_away(&self, away: bool) -> Result<()> {
let status = u32::from(!away) + 1; // away = 1, online = 2
self.send_server_message(MessageFactory::build_set_status_message(
status,
))
}
/// Ask `username` what it says about itself (peer code 15): its
/// description, upload slots and queue length — a user's profile as other
/// clients show it.
///
/// Any previous answer is dropped first, so [`Client::peer_info`] reports
/// the reply to this request rather than a stale one.
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection,
/// or [`crate::SoulseekRs::LockPoisoned`] if the client state is poisoned.
pub fn request_peer_info(&self, username: &str) -> Result<()> {
self.context.write_safe()?.invalidate_peer_info(username);
let request = crate::message::peer::build_user_info_request();
self.send_to_peer_or_queue(username, request)
}
/// What `username` last said about itself.
#[must_use]
pub fn peer_info(
&self,
username: &str,
) -> Option<crate::message::peer::PeerInfo> {
self.context
.read_safe()
.ok()
.and_then(|ctx| ctx.peer_info(username))
}
/// Ask `username` for the contents of one folder of their shares (peer
/// code 36) — what "download folder" in other clients asks for, without
/// pulling their whole listing.
///
/// The answer arrives asynchronously; take it with
/// [`Client::take_folder_contents`].
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection,
/// or [`crate::SoulseekRs::LockPoisoned`] if the client state is poisoned.
pub fn request_folder_contents(
&self,
username: &str,
folder: &str,
) -> Result<()> {
let request = crate::message::peer::build_folder_contents_request(
crate::client::next_search_token(),
folder,
);
self.send_to_peer_or_queue(username, request)
}
/// Remove and return the listing of `folder` from `username`, once their
/// answer to [`Client::request_folder_contents`] has arrived.
#[must_use]
pub fn take_folder_contents(
&self,
username: &str,
folder: &str,
) -> Option<Vec<crate::SharedDirectory>> {
self.context
.write_safe()
.ok()
.and_then(|mut ctx| ctx.take_folder_contents(username, folder))
}
/// Ask `username` where the file we queued with them sits (peer code 51).
///
/// The answer arrives asynchronously and lands on the download, readable
/// through [`Client::downloads`] as its queue position. A peer we have no
/// connection to is resolved and the request delivered once it is up, the
/// same way a browse request is.
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection,
/// or [`crate::SoulseekRs::LockPoisoned`] if the client state is poisoned.
pub fn request_place_in_queue(
&self,
username: &str,
filename: &str,
) -> Result<()> {
let request = MessageFactory::build_place_in_queue_request(filename);
self.send_to_peer_or_queue(username, request)
}
/// Set our ticker in `room` — the one-line message other members see.
/// Passing an empty `ticker` clears ours.
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn set_room_ticker(&self, room: &str, ticker: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_set_room_ticker(
room, ticker,
))
}
/// The ticker board of `room`: what the server sent on join (code 113),
/// kept current by the later add/remove events.
#[must_use]
pub fn room_tickers(&self, room: &str) -> Vec<RoomTicker> {
match self.context.read_safe() {
Ok(ctx) => ctx.room_tickers(room),
Err(e) => {
error!("[client] room_tickers: {}", e);
Vec::new()
}
}
}
/// Subscribe to the global room feed (code 150): every public room message
/// on the server arrives as a [`crate::RoomEvent::GlobalMessage`].
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn join_global_room(&self) -> Result<()> {
self.send_server_message(MessageFactory::build_join_global_room())
}
/// Stop the global room feed (code 151).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn leave_global_room(&self) -> Result<()> {
self.send_server_message(MessageFactory::build_leave_global_room())
}
/// Add an interest (code 51). Interests are what the recommendation and
/// similar-user queries below are computed from.
///
/// The server holds interests only for the session that set them, so the
/// list is kept here and sent again after every login. An interest added
/// before there is a connection is kept all the same — the error says
/// only that it could not go out yet, not that it was dropped.
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn add_interest(&self, item: &str) -> Result<()> {
let Some(item) =
self.context.write_safe()?.add_own_interest(item, true)
else {
return Ok(()); // nothing to like or unlike
};
self.send_server_message(MessageFactory::build_add_thing_i_like(&item))
}
/// Drop an interest (code 52).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn remove_interest(&self, item: &str) -> Result<()> {
let Some(item) =
self.context.write_safe()?.remove_own_interest(item, true)
else {
return Ok(()); // nothing to like or unlike
};
self.send_server_message(MessageFactory::build_remove_thing_i_like(
&item,
))
}
/// Add a dislike (code 117).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn add_dislike(&self, item: &str) -> Result<()> {
let Some(item) =
self.context.write_safe()?.add_own_interest(item, false)
else {
return Ok(()); // nothing to like or unlike
};
self.send_server_message(MessageFactory::build_add_thing_i_hate(&item))
}
/// Drop a dislike (code 118).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn remove_dislike(&self, item: &str) -> Result<()> {
let Some(item) =
self.context.write_safe()?.remove_own_interest(item, false)
else {
return Ok(()); // nothing to like or unlike
};
self.send_server_message(MessageFactory::build_remove_thing_i_hate(
&item,
))
}
/// Ask for recommendations drawn from our own interests (code 54). The
/// reply arrives asynchronously; read it with
/// [`Client::recommendations`].
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn request_recommendations(&self) -> Result<()> {
self.send_server_message(MessageFactory::build_get_recommendations())
}
/// Ask for the server-wide recommendations (code 56); read the reply with
/// [`Client::global_recommendations`].
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn request_global_recommendations(&self) -> Result<()> {
self.send_server_message(MessageFactory::build_global_recommendations())
}
/// The upload speed the server records for us, once it has reported it.
/// It is what the distributed child limit is derived from, and it only
/// becomes known after an upload has finished and been reported.
#[must_use]
pub fn own_average_speed(&self) -> Option<u32> {
self.context
.read_safe()
.ok()
.and_then(|ctx| ctx.own_average_speed())
}
/// What we ourselves like and hate, as set through this client. The
/// server forgets these when the session ends, so they are re-sent after
/// each login.
#[must_use]
pub fn own_interests(&self) -> UserInterests {
match self.context.read_safe() {
Ok(ctx) => ctx.own_interests(),
Err(e) => {
error!("[client] own_interests: {}", e);
UserInterests::default()
}
}
}
/// The last reply to [`Client::request_recommendations`] as
/// (recommended, recommended-against), or `None` until one arrives.
#[must_use]
pub fn recommendations(
&self,
) -> Option<(Vec<Recommendation>, Vec<Recommendation>)> {
self.context
.read_safe()
.ok()
.and_then(|ctx| ctx.recommendations(false))
}
/// The last reply to [`Client::request_global_recommendations`].
#[must_use]
pub fn global_recommendations(
&self,
) -> Option<(Vec<Recommendation>, Vec<Recommendation>)> {
self.context
.read_safe()
.ok()
.and_then(|ctx| ctx.recommendations(true))
}
/// Ask what else people who like `item` like (code 111).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn request_item_recommendations(&self, item: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_item_recommendations(
item,
))
}
/// The last per-item recommendations received for `item`.
#[must_use]
pub fn item_recommendations(&self, item: &str) -> Vec<Recommendation> {
match self.context.read_safe() {
Ok(ctx) => ctx.item_recommendations(item),
Err(e) => {
error!("[client] item_recommendations: {}", e);
Vec::new()
}
}
}
/// Ask which users share our interests (code 110).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn request_similar_users(&self) -> Result<()> {
self.send_server_message(MessageFactory::build_similar_users())
}
/// The last similar-user answer, weighted by how much we have in common.
#[must_use]
pub fn similar_users(&self) -> Vec<SimilarUser> {
match self.context.read_safe() {
Ok(ctx) => ctx.similar_users(),
Err(e) => {
error!("[client] similar_users: {}", e);
Vec::new()
}
}
}
/// Ask which users like `item` (code 112).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn request_item_similar_users(&self, item: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_item_similar_users(item))
}
/// The last answer to [`Client::request_item_similar_users`] for `item`.
#[must_use]
pub fn item_similar_users(&self, item: &str) -> Vec<String> {
match self.context.read_safe() {
Ok(ctx) => ctx.item_similar_users(item),
Err(e) => {
error!("[client] item_similar_users: {}", e);
Vec::new()
}
}
}
/// Ask what `username` likes and hates (code 57).
///
/// Any previous answer for `username` is dropped first, so
/// [`Client::user_interests`] reports the reply to *this* request rather
/// than a stale one.
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn request_user_interests(&self, username: &str) -> Result<()> {
self.context
.write_safe()?
.invalidate_user_interests(username);
self.send_server_message(MessageFactory::build_user_interests(username))
}
/// What the server last told us `username` likes and hates.
#[must_use]
pub fn user_interests(&self, username: &str) -> Option<UserInterests> {
self.context
.read_safe()
.ok()
.and_then(|ctx| ctx.user_interests(username))
}
/// Give `days` of our own privileges to `username` (code 123).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn give_privileges(&self, username: &str, days: u32) -> Result<()> {
self.send_server_message(MessageFactory::build_give_privileges(
username, days,
))
}
/// Change our account password (code 142).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn change_password(&self, password: &str) -> Result<()> {
self.send_server_message(MessageFactory::build_change_password(
password,
))
}
/// Send one private message to several users at once (code 149). Each
/// recipient receives it as an ordinary private message.
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn send_private_message_to_many(
&self,
usernames: &[String],
message: &str,
) -> Result<()> {
self.send_server_message(MessageFactory::build_message_users(
usernames, message,
))
}
}
+52
View File
@@ -0,0 +1,52 @@
//! The client's context, exercised directly.
//!
//! This file is the harness the topic modules below share.
mod peers;
mod rooms;
mod search;
mod transfers;
mod users;
use super::*;
fn download(
username: &str,
filename: &str,
token: u32,
status: DownloadStatus,
sender: Sender<DownloadStatus>,
) -> Download {
Download {
username: username.to_string(),
filename: filename.to_string(),
token,
size: 100,
download_directory: "test".to_string(),
status,
sender,
queue_position: None,
metadata: DownloadMetadata::default(),
}
}
fn peer_files(count: usize) -> SearchResult {
SearchResult {
token: 1,
files: (0..count)
.map(|i| crate::types::File {
username: "bob".to_string(),
name: format!("song-{i}.mp3"),
size: 1,
attribs: std::collections::HashMap::new(),
})
.collect(),
slots: 1,
speed: 0,
username: "bob".to_string(),
}
}
use crate::types::{RoomEvent, UploadStatus};
use std::time::{Duration, Instant};
+173
View File
@@ -0,0 +1,173 @@
//! Uploads, broker connects, and the peers a disconnect may not drop.
use super::*;
#[test]
fn upload_speed_is_reported_only_while_running() {
let two_seconds_ago = Instant::now()
.checked_sub(Duration::from_secs(2))
.expect("clock supports a 2s offset");
// 1 KiB over ~2s is ~512 B/s.
let rate = upload_speed(&UploadStatus::InProgress, 1024, two_seconds_ago);
assert!((rate - 512.0).abs() < 50.0, "unexpected rate {rate}");
// A finished upload reports no rate, exactly as a finished download
// does, so the Speed column renders "-" rather than a stale figure.
for status in [
UploadStatus::Completed,
UploadStatus::Cancelled,
UploadStatus::Failed("nope".to_string()),
] {
let rate = upload_speed(&status, 1024, two_seconds_ago);
assert!(rate.abs() < f64::EPSILON, "unexpected rate {rate}");
}
// A just-started upload must not divide by a zero elapsed time.
let rate = upload_speed(&UploadStatus::InProgress, 0, Instant::now());
assert!(
rate.is_finite() && rate.abs() < f64::EPSILON,
"unexpected rate {rate}"
);
}
// A clean disconnect — the idle reaper, or a remote client tidying an idle
// socket while it waits in our queue — must keep the peer's queued uploads;
// only an error disconnect is evidence the peer is gone.
#[test]
fn a_clean_disconnect_keeps_queued_uploads_an_error_drops_them() {
let client = Client::new("test-user", "test-password");
let (ops, ops_rx) = mpsc::channel();
Client::listen_to_client_operations(
ops_rx,
client.context.clone(),
"me".to_string(),
);
client.context.write().unwrap().enqueue_upload(
"amy",
"@@share\\f.mp3",
std::path::PathBuf::from("/tmp/f.mp3"),
4096,
);
ops.send(ClientOperation::PeerDisconnected(
1,
"amy".to_string(),
None,
))
.unwrap();
// The loop is serial, so once this fence op is visible the disconnect
// before it has been handled.
ops.send(ClientOperation::OwnPrivileges(7)).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while client.own_privilege_seconds() != Some(7) {
assert!(Instant::now() < deadline, "ops loop never caught up");
thread::sleep(Duration::from_millis(10));
}
assert_eq!(
client
.context
.read()
.unwrap()
.place_in_queue("amy", "@@share\\f.mp3"),
Some(1),
"a clean disconnect must keep the queue"
);
ops.send(ClientOperation::PeerDisconnected(
1,
"amy".to_string(),
Some(crate::error::SoulseekRs::NotConnected),
))
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let place = client
.context
.read()
.unwrap()
.place_in_queue("amy", "@@share\\f.mp3");
if place.is_none() {
break;
}
assert!(
Instant::now() < deadline,
"an error disconnect must drop the queue"
);
thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn a_pending_connect_expires_after_the_broker_timeout() {
let mut context = ClientContext::new();
context.add_pending_connect(7, "ghost".to_string());
assert!(context.take_expired_connects(Instant::now()).is_empty());
let after_deadline =
Instant::now() + BROKER_CONNECT_TIMEOUT + Duration::from_secs(1);
assert_eq!(
context.take_expired_connects(after_deadline),
vec!["ghost".to_string()]
);
assert!(context.take_pending_connect(7).is_none());
}
#[test]
fn protected_peers_covers_downloads_uploads_and_pending_serves() {
let mut context = ClientContext::new();
context.add_download(download(
"downloader",
"song.mp3",
1,
DownloadStatus::Queued,
mpsc::channel().0,
));
context.add_download(download(
"done",
"old.mp3",
2,
DownloadStatus::Completed,
mpsc::channel().0,
));
context
.pending_serves
.insert("waiting".to_string(), vec![9]);
context.mark_browse_pending("browsed");
let protected = context.protected_peers();
assert!(protected.contains("downloader"));
assert!(protected.contains("waiting"));
assert!(protected.contains("browsed"));
assert!(!protected.contains("done"));
context.store_browse_result("browsed".to_string(), Vec::new());
assert!(!context.protected_peers().contains("browsed"));
}
#[test]
fn a_peer_message_nobody_could_deliver_expires() {
let mut ctx = ClientContext::for_user("me");
ctx.queue_peer_message("ghost", crate::message::Message::new());
ctx.expire_pending_peer_messages(Instant::now());
assert_eq!(ctx.take_peer_messages("ghost").len(), 1, "still fresh");
ctx.queue_peer_message("ghost", crate::message::Message::new());
ctx.expire_pending_peer_messages(Instant::now() + PENDING_PEER_TTL);
assert!(ctx.take_peer_messages("ghost").is_empty(), "expired");
}
#[test]
fn the_server_is_told_our_child_capacity_only_when_it_changes() {
// AcceptChildren is a standing state on the server, not a heartbeat.
let mut ctx = ClientContext::new();
assert_eq!(
ctx.accept_children_change(),
Some(false),
"the first answer is always worth sending"
);
assert_eq!(ctx.accept_children_change(), None, "unchanged, so silent");
}
+316
View File
@@ -0,0 +1,316 @@
//! Room rosters, ticker boards, and the private rooms the server lets us
//! see.
use super::*;
#[test]
fn the_room_roster_follows_the_joins_and_leaves_the_server_reports() {
let mut context = ClientContext::new();
// Joining hands us the current membership in one event.
context.apply_room_event(RoomEvent::Joined {
room: "lobby".to_string(),
users: vec!["bob".to_string(), "alice".to_string()],
});
assert_eq!(context.room_members("lobby"), ["alice", "bob"]);
context.apply_room_event(RoomEvent::UserJoined {
room: "lobby".to_string(),
username: "carol".to_string(),
});
context.apply_room_event(RoomEvent::UserLeft {
room: "lobby".to_string(),
username: "bob".to_string(),
});
assert_eq!(context.room_members("lobby"), ["alice", "carol"]);
// Events for other rooms do not leak into this one.
context.apply_room_event(RoomEvent::UserJoined {
room: "elsewhere".to_string(),
username: "dave".to_string(),
});
assert_eq!(context.room_members("lobby"), ["alice", "carol"]);
// A room we never joined has no roster, and leaving forgets it.
assert!(context.room_members("unknown").is_empty());
context.apply_room_event(RoomEvent::Left {
room: "lobby".to_string(),
});
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();
context.apply_room_event(RoomEvent::Joined {
room: "lobby".to_string(),
users: vec!["alice".to_string()],
});
context.apply_room_event(RoomEvent::UserJoined {
room: "lobby".to_string(),
username: "alice".to_string(),
});
assert_eq!(context.room_members("lobby"), ["alice"]);
}
#[test]
fn room_member_stats_are_returned_sorted_and_scoped_to_their_room() {
use crate::types::{RoomUserStats, UserStatus};
let stat = |username: &str| RoomUserStats {
username: username.to_string(),
status: UserStatus::Online,
average_speed: 1,
shared_files: 2,
shared_folders: 3,
slots_full: true,
country: None,
};
let mut context = ClientContext::new();
context.apply_room_member_stats(
"jazz".to_string(),
vec![stat("carol"), stat("alice")],
);
let names: Vec<String> = context
.room_member_stats("jazz")
.into_iter()
.map(|s| s.username)
.collect();
assert_eq!(names, vec!["alice", "carol"]);
assert!(context.room_member_stats("unjoined").is_empty());
}
#[test]
fn rejoining_replaces_the_previous_member_stats() {
use crate::types::{RoomUserStats, UserStatus};
let stat = |username: &str| RoomUserStats {
username: username.to_string(),
status: UserStatus::Online,
average_speed: 1,
shared_files: 2,
shared_folders: 3,
slots_full: false,
country: None,
};
let mut context = ClientContext::new();
context.apply_room_member_stats("jazz".to_string(), vec![stat("alice")]);
context.apply_room_member_stats("jazz".to_string(), vec![stat("bob")]);
let stats = context.room_member_stats("jazz");
assert_eq!(stats.len(), 1, "a rejoin supersedes the old snapshot");
assert_eq!(stats[0].username, "bob");
}
#[test]
fn a_new_ticker_replaces_that_users_previous_one() {
// The server treats a user's ticker as singular: a second one from the
// same user supersedes the first rather than stacking beside it.
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::Tickers {
room: "jazz".into(),
tickers: vec![
RoomTicker {
username: "alice".into(),
ticker: "first".into(),
},
RoomTicker {
username: "bob".into(),
ticker: "bobs".into(),
},
],
});
ctx.apply_room_event(RoomEvent::TickerAdded {
room: "jazz".into(),
username: "alice".into(),
ticker: "second".into(),
});
let board = ctx.room_tickers("jazz");
assert_eq!(board.len(), 2);
assert_eq!(
board.iter().find(|t| t.username == "alice").unwrap().ticker,
"second"
);
}
#[test]
fn a_removed_ticker_leaves_the_rest_of_the_board() {
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::Tickers {
room: "jazz".into(),
tickers: vec![
RoomTicker {
username: "alice".into(),
ticker: "a".into(),
},
RoomTicker {
username: "bob".into(),
ticker: "b".into(),
},
],
});
ctx.apply_room_event(RoomEvent::TickerRemoved {
room: "jazz".into(),
username: "alice".into(),
});
let board = ctx.room_tickers("jazz");
assert_eq!(board.len(), 1);
assert_eq!(board[0].username, "bob");
}
#[test]
fn a_global_message_is_queued_without_touching_room_membership() {
// The global feed carries messages from rooms we have not joined; they
// must not invent a roster for those rooms.
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::GlobalMessage {
room: "elsewhere".into(),
username: "alice".into(),
message: "hi".into(),
});
assert!(ctx.room_members("elsewhere").is_empty());
assert_eq!(ctx.take_room_events().len(), 1);
}
#[test]
fn a_private_room_roster_tracks_who_is_added_and_removed() {
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::PrivateMembers {
room: "club".into(),
users: vec!["bob".into(), "alice".into(), "bob".into()],
});
assert_eq!(
ctx.private_room_members("club"),
vec!["alice".to_string(), "bob".to_string()],
"a roster is sorted and free of repeats"
);
ctx.apply_room_event(RoomEvent::PrivateRosterChanged {
room: "club".into(),
username: "carol".into(),
members: true,
added: true,
});
ctx.apply_room_event(RoomEvent::PrivateRosterChanged {
room: "club".into(),
username: "alice".into(),
members: true,
added: false,
});
assert_eq!(
ctx.private_room_members("club"),
vec!["bob".to_string(), "carol".to_string()]
);
// Operators are a separate roster in the same room.
ctx.apply_room_event(RoomEvent::PrivateRosterChanged {
room: "club".into(),
username: "bob".into(),
members: false,
added: true,
});
assert_eq!(ctx.private_room_operators("club"), vec!["bob".to_string()]);
assert_eq!(ctx.private_rooms(), vec!["club".to_string()]);
}
#[test]
fn revoked_membership_drops_the_room_we_can_no_longer_see() {
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::PrivateMembers {
room: "club".into(),
users: vec!["alice".into()],
});
ctx.apply_room_event(RoomEvent::PrivateOperators {
room: "club".into(),
users: vec!["alice".into()],
});
ctx.apply_room_event(RoomEvent::OwnStandingChanged {
room: "club".into(),
members: true,
granted: false,
});
assert!(ctx.private_rooms().is_empty());
assert!(ctx.private_room_operators("club").is_empty());
}
#[test]
fn losing_operatorship_keeps_the_roster_we_can_still_see() {
// Demotion does not blind us: we are still a member, and the room's other
// operators are still ours to show. The server narrates our own removal
// from that roster separately (code 144).
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::PrivateMembers {
room: "club".into(),
users: vec!["alice".into()],
});
ctx.apply_room_event(RoomEvent::PrivateOperators {
room: "club".into(),
users: vec!["alice".into(), "bob".into()],
});
ctx.apply_room_event(RoomEvent::OwnStandingChanged {
room: "club".into(),
members: false,
granted: false,
});
assert_eq!(ctx.private_room_members("club"), vec!["alice".to_string()]);
assert_eq!(
ctx.private_room_operators("club"),
vec!["alice".to_string(), "bob".to_string()],
"the other operators are still there to show"
);
ctx.apply_room_event(RoomEvent::PrivateRosterChanged {
room: "club".into(),
username: "alice".into(),
members: false,
added: false,
});
assert_eq!(
ctx.private_room_operators("club"),
vec!["bob".to_string()],
"only the demotion the server narrates removes us"
);
}
#[test]
fn leaving_a_room_drops_its_ticker_board() {
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::Tickers {
room: "jazz".into(),
tickers: vec![RoomTicker {
username: "alice".into(),
ticker: "hi".into(),
}],
});
ctx.apply_room_event(RoomEvent::Left {
room: "jazz".into(),
});
assert!(
ctx.room_tickers("jazz").is_empty(),
"a board for a room we left is stale"
);
}
+167
View File
@@ -0,0 +1,167 @@
//! Answering a search against our shares, and what the client keeps of
//! the answers it gets back.
use super::*;
#[test]
fn build_search_response_matches_shares_and_echoes_token() {
let dir = std::env::temp_dir()
.join(format!("soulseek-searchresp-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("probe_xyzzy.bin"), b"data").unwrap();
let shares = Shares::scan(&dir).unwrap();
let response =
build_search_response(&shares, "me", 99, "xyzzy", true, 0, 0, &[])
.expect("a matching share yields a response");
let mut decoded =
crate::message::Message::new_with_data(response.get_buffer());
decoded.set_pointer(8);
let result = SearchResult::new_from_message(&mut decoded).unwrap();
assert_eq!(result.username, "me");
assert_eq!(result.token, 99);
assert!(result.files.iter().any(|f| f.name.contains("probe_xyzzy")));
assert!(
build_search_response(&shares, "me", 1, "nomatch", true, 0, 0, &[])
.is_none()
);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn search_file_counts_cover_every_search_without_the_results() {
// The daemon polls these counts many times a second; they must come from
// a walk of the cache, never a copy of it.
let client = Client::new("u", "p");
{
let mut context = client.context.write().unwrap();
context.searches.insert(
"aphex twin".to_string(),
Search {
token: 1,
results: vec![peer_files(2), peer_files(1)],
},
);
context.searches.insert(
"nothing yet".to_string(),
Search {
token: 2,
results: Vec::new(),
},
);
}
let mut counts = client.search_file_counts();
counts.sort();
assert_eq!(
counts,
[
("aphex twin".to_string(), 3),
("nothing yet".to_string(), 0)
]
);
}
#[test]
fn a_search_stops_collecting_once_it_has_enough_responses() {
// A popular query on the live network draws answers for minutes —
// nearly a million files and gigabytes of memory for one search.
// Surplus responders are dropped, not archived.
let mut search = Search {
token: 1,
results: Vec::new(),
};
for _ in 0..(crate::types::MAX_SEARCH_RESPONSES + 50) {
search.accept(peer_files(1));
}
assert_eq!(search.results.len(), crate::types::MAX_SEARCH_RESPONSES);
}
#[test]
fn a_flood_of_files_fills_a_search_before_the_response_cap() {
// A handful of whales with huge matching collections must not add up
// to an unbounded set just because the responses are few.
let mut search = Search {
token: 1,
results: Vec::new(),
};
for _ in 0..10 {
search.accept(peer_files(crate::types::MAX_SEARCH_FILES / 2));
}
assert_eq!(
search.results.len(),
2,
"two half-cap responses fill the search; the rest are dropped"
);
}
#[test]
fn a_file_the_server_excludes_is_left_out_of_a_reply() {
// The server's excluded phrases (code 160) police what travels the search
// network: a matching file whose path carries one must not be offered,
// and a reply with nothing left is not sent at all.
let dir = std::env::temp_dir()
.join(format!("soulseek-excluded-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Spam_Xyzzy.bin"), b"data").unwrap();
let shares = Shares::scan(&dir).unwrap();
assert!(
build_search_response(&shares, "me", 1, "xyzzy", true, 0, 0, &[])
.is_some(),
"with no exclusions the file is offered"
);
assert!(
build_search_response(
&shares,
"me",
1,
"xyzzy",
true,
0,
0,
&["spam".to_string()],
)
.is_none(),
"an excluded phrase in the path, matched case-insensitively, \
withholds the file — and with no files there is no reply"
);
assert!(
build_search_response(
&shares,
"me",
1,
"xyzzy",
true,
0,
0,
&["unrelated".to_string()],
)
.is_some(),
"an exclusion the path does not carry changes nothing"
);
// The phrases are lowercased where they arrive, so a server that sends
// one capitalised still matches a path.
let mut ctx = ClientContext::new();
ctx.set_excluded_search_phrases(vec!["SPAM".to_string()]);
assert!(
build_search_response(
&shares,
"me",
1,
"xyzzy",
true,
0,
0,
&ctx.excluded_search_phrases(),
)
.is_none(),
"a capitalised phrase from the server still withholds the file"
);
let _ = std::fs::remove_dir_all(&dir);
}
@@ -0,0 +1,329 @@
//! Downloads through the context: what is queued, paused, cancelled,
//! and what a peer's answer is allowed to start.
use super::*;
#[test]
fn test_client_context_downloads() {
let mut context = ClientContext::new();
let token = 123;
let new_token = 1234;
context.add_download(download(
"test",
"test.txt",
token,
DownloadStatus::Queued,
mpsc::channel().0,
));
assert!(context.get_download_by_token(123).is_some());
assert_eq!(context.get_download_tokens(), vec![123]);
assert_eq!(context.get_downloads().len(), 1);
if let Some(download) = context.get_download_by_token_mut(token) {
assert_eq!(download.token, token);
download.token = new_token;
}
assert!(context.get_download_by_token(new_token).is_some());
assert_eq!(context.get_download_tokens(), vec![new_token]);
context.remove_download(new_token);
assert_eq!(context.get_downloads().len(), 0);
assert!(context.get_download_by_token(1234).is_none());
}
#[test]
fn test_client_pause_and_resume_download() {
let client = Client::new("test-user", "test-password");
let (download_sender, download_receiver) = mpsc::channel();
client.context.write().unwrap().add_download(download(
"peer",
"song.mp3",
123,
DownloadStatus::InProgress {
bytes_downloaded: 25,
total_bytes: 100,
speed_bytes_per_sec: 10.0,
},
download_sender,
));
assert!(client.pause_download("peer", "song.mp3"));
assert!(matches!(
client
.context
.read()
.unwrap()
.get_download_by_token(123)
.unwrap()
.status,
DownloadStatus::Paused {
bytes_downloaded: 25,
total_bytes: 100
}
));
assert!(matches!(
download_receiver.try_recv().unwrap(),
DownloadStatus::Paused {
bytes_downloaded: 25,
total_bytes: 100
}
));
assert!(client.resume_download("peer", "song.mp3"));
assert!(matches!(
client
.context
.read()
.unwrap()
.get_download_by_token(123)
.unwrap()
.status,
DownloadStatus::InProgress {
bytes_downloaded: 25,
total_bytes: 100,
speed_bytes_per_sec: 0.0
}
));
}
#[test]
fn download_without_a_connection_resolves_failed() {
// A client that never connected has no server handle and no peer registry,
// so it cannot open a connection to the peer: the download must resolve to
// Failed rather than hang Queued forever.
let client = Client::new("test-user", "test-password");
let (_download, receiver) = client
.download(
"song.mp3".to_string(),
"peer".to_string(),
100,
"test".to_string(),
)
.expect("download() should return a handle");
assert!(matches!(
receiver.recv_timeout(Duration::from_secs(1)),
Ok(DownloadStatus::Failed(_))
));
}
#[test]
fn fail_queued_downloads_notifies_receiver_and_store() {
// When a brokered connect times out, every Queued download for the peer
// must resolve to Failed both on its channel and in the store.
let client = Client::new("u", "p");
let (sender, receiver) = mpsc::channel();
client.context.write().unwrap().add_download(download(
"peer",
"f.mp3",
7,
DownloadStatus::Queued,
sender,
));
Client::fail_queued_downloads(&client.context, "peer");
assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Failed(_))));
assert!(matches!(
client
.context
.read()
.unwrap()
.get_download_by_token(7)
.unwrap()
.status,
DownloadStatus::Failed(_)
));
}
#[test]
fn test_client_removes_only_queued_downloads() {
let client = Client::new("test-user", "test-password");
{
let mut context = client.context.write().unwrap();
context.add_download(download(
"peer",
"queued.mp3",
123,
DownloadStatus::Queued,
mpsc::channel().0,
));
context.add_download(download(
"peer",
"active.mp3",
456,
DownloadStatus::InProgress {
bytes_downloaded: 25,
total_bytes: 100,
speed_bytes_per_sec: 10.0,
},
mpsc::channel().0,
));
}
assert!(client.remove_queued_download("peer", "queued.mp3"));
assert!(!client.remove_queued_download("peer", "active.mp3"));
let context = client.context.read().unwrap();
assert!(context.get_download_by_token(123).is_none());
assert!(context.get_download_by_token(456).is_some());
}
#[test]
fn an_expired_broker_connect_fails_the_queued_downloads() {
let client = Client::new("u", "p");
let (sender, receiver) = mpsc::channel();
{
let mut ctx = client.context.write().unwrap();
ctx.add_download(download(
"ghost",
"f.mp3",
7,
DownloadStatus::Queued,
sender,
));
ctx.pending_connect_tokens
.insert(7, ("ghost".to_string(), Instant::now()));
}
let (_ops_tx, ops_rx) = mpsc::channel();
Client::listen_to_client_operations(
ops_rx,
client.context.clone(),
"u".to_string(),
);
let status = receiver.recv_timeout(Duration::from_secs(5));
assert!(
matches!(status, Ok(DownloadStatus::Failed(_))),
"the sweep must fail the queued download, got {status:?}"
);
}
#[test]
fn a_replayed_transfer_response_does_not_start_a_second_transfer() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let port = u32::from(listener.local_addr().unwrap().port());
let download_dir = std::env::temp_dir()
.join(format!("soulseek-replayed-transfer-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&download_dir);
std::fs::create_dir_all(&download_dir).unwrap();
let client = Client::new("u", "p");
let (sender, _receiver) = mpsc::channel();
let mut queued =
download("peer", "f.mp3", 9, DownloadStatus::Queued, sender);
queued.download_directory = download_dir.display().to_string();
client.context.write().unwrap().add_download(queued);
let (ops_tx, ops_rx) = mpsc::channel();
Client::listen_to_client_operations(
ops_rx,
client.context.clone(),
"u".to_string(),
);
let peer = Peer::new(
"peer".to_string(),
ConnectionType::F,
"127.0.0.1".to_string(),
port,
None,
0,
0,
0,
);
ops_tx
.send(ClientOperation::DownloadFromPeer(9, peer.clone(), true))
.unwrap();
ops_tx
.send(ClientOperation::DownloadFromPeer(9, peer, true))
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let mut connections = 0;
while Instant::now() < deadline {
match listener.accept() {
Ok(_) => {
connections += 1;
if connections == 1 {
thread::sleep(Duration::from_millis(500));
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
if connections >= 1 {
break;
}
thread::sleep(Duration::from_millis(20));
}
Err(_) => break,
}
}
assert_eq!(
connections, 1,
"a replayed TransferResponse must not dial the peer again"
);
let _ = std::fs::remove_dir_all(download_dir);
}
#[test]
fn a_cancelled_download_is_not_started_when_the_peer_allows_it() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let port = u32::from(listener.local_addr().unwrap().port());
let client = Client::new("u", "p");
let (sender, _receiver) = mpsc::channel();
client.context.write().unwrap().add_download(download(
"peer",
"f.mp3",
9,
DownloadStatus::Cancelled,
sender,
));
let (ops_tx, ops_rx) = mpsc::channel();
Client::listen_to_client_operations(
ops_rx,
client.context.clone(),
"u".to_string(),
);
let peer = Peer::new(
"peer".to_string(),
ConnectionType::F,
"127.0.0.1".to_string(),
port,
None,
0,
0,
0,
);
ops_tx
.send(ClientOperation::DownloadFromPeer(9, peer, true))
.unwrap();
thread::sleep(Duration::from_millis(500));
assert!(
matches!(listener.accept(), Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock),
"a cancelled download must not dial the peer"
);
}
#[test]
fn cancel_download_marks_the_store_and_answers_whether_it_matched() {
let client = Client::new("u", "p");
let (sender, receiver) = mpsc::channel();
client.context.write().unwrap().add_download(download(
"peer",
"f.mp3",
9,
DownloadStatus::Queued,
sender,
));
assert!(client.cancel_download("peer", "f.mp3"));
assert!(!client.cancel_download("peer", "f.mp3"));
assert!(!client.cancel_download("peer", "other.mp3"));
assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Cancelled)));
assert!(matches!(
client.get_all_downloads()[0].status,
DownloadStatus::Cancelled
));
}
+200
View File
@@ -0,0 +1,200 @@
//! What the server tells us about people: watches, interests, and the
//! two halves of a user snapshot.
use super::*;
#[test]
fn a_fresh_request_discards_the_previous_answer() {
// Without this, a poll after a second request returns the old
// snapshot immediately and the caller cannot tell stale from fresh.
let mut context = ClientContext::new();
context.apply_user_status("alice".to_string(), 2, false);
context.apply_user_stats("alice".to_string(), 10, 20, 30);
assert!(context.user_info("alice").is_some_and(|i| i.is_complete()));
context.invalidate_user_info("alice");
assert!(
context.user_info("alice").is_none(),
"a new request must not be answerable from the old reply"
);
}
#[test]
fn each_reply_fills_only_its_own_half() {
let mut context = ClientContext::new();
context.apply_user_status("bob".to_string(), 1, true);
let info = context.user_info("bob").expect("a snapshot");
assert!(!info.is_complete(), "stats have not arrived");
assert_eq!(
info.presence.map(|p| p.status),
Some(crate::types::UserStatus::Away)
);
assert!(info.stats.is_none(), "must not invent statistics");
context.apply_user_stats("bob".to_string(), 5, 6, 7);
let info = context.user_info("bob").expect("a snapshot");
assert!(info.is_complete());
assert_eq!(info.stats.map(|s| s.shared_files), Some(6));
assert_eq!(
info.presence.map(|p| p.privileged),
Some(true),
"the earlier half must survive the merge"
);
}
#[test]
fn a_watch_reply_fills_both_halves_of_the_snapshot() {
let mut context = ClientContext::new();
context.add_watched_user("alice");
context.apply_watched_user(
"alice".to_string(),
true,
Some(2),
Some(1024),
Some(20),
Some(3),
);
let info = context.user_info("alice").expect("a snapshot");
assert!(info.is_complete(), "a watch reply carries status and stats");
assert_eq!(info.stats.map(|s| s.average_speed), Some(1024));
assert_eq!(context.watched_users(), vec!["alice".to_string()]);
}
#[test]
fn a_watch_reply_keeps_a_privileged_flag_it_cannot_carry() {
// WatchUser has no privileged field, so it must not overwrite what
// GetUserStatus already told us with a fabricated `false`.
let mut context = ClientContext::new();
context.apply_user_status("bob".to_string(), 1, true);
context.apply_watched_user(
"bob".to_string(),
true,
Some(2),
Some(1),
Some(2),
Some(3),
);
let info = context.user_info("bob").expect("a snapshot");
assert_eq!(info.presence.map(|p| p.privileged), Some(true));
}
#[test]
fn watching_an_unknown_user_drops_them_from_the_list() {
// The server will never push status for a name it does not know, so
// keeping it in the watch list would show a permanently blank row.
let mut context = ClientContext::new();
context.add_watched_user("ghost");
context.apply_watched_user(
"ghost".to_string(),
false,
None,
None,
None,
None,
);
assert!(context.watched_users().is_empty());
assert!(context.user_info("ghost").is_none());
}
#[test]
fn unwatching_forgets_the_users_snapshot() {
let mut context = ClientContext::new();
context.add_watched_user("alice");
context.apply_user_status("alice".to_string(), 2, false);
context.remove_watched_user("alice");
assert!(context.watched_users().is_empty());
assert!(
context.user_info("alice").is_none(),
"a later re-watch must report a fresh answer"
);
}
#[test]
fn watched_users_are_listed_in_a_stable_order() {
let mut context = ClientContext::new();
context.add_watched_user("carol");
context.add_watched_user("alice");
context.add_watched_user("bob");
assert_eq!(context.watched_users(), vec!["alice", "bob", "carol"]);
}
// A reply queued for a searcher the server cannot place waits a minute,
// not forever: every such search would otherwise pin a map entry and a
// protected slot in the peer registry.
#[test]
fn own_and_global_recommendations_are_kept_apart() {
let mut ctx = ClientContext::new();
ctx.apply_recommendations(
false,
vec![Recommendation {
item: "jazz".into(),
rating: 2,
}],
Vec::new(),
);
ctx.apply_recommendations(
true,
vec![Recommendation {
item: "pop".into(),
rating: 9,
}],
Vec::new(),
);
assert_eq!(ctx.recommendations(false).unwrap().0[0].item, "jazz");
assert_eq!(ctx.recommendations(true).unwrap().0[0].item, "pop");
}
#[test]
fn asking_again_about_a_users_interests_drops_the_previous_answer() {
let mut ctx = ClientContext::new();
ctx.apply_user_interests(UserInterests {
username: "alice".into(),
likes: vec!["jazz".into()],
hates: Vec::new(),
});
assert!(ctx.user_interests("alice").is_some());
ctx.invalidate_user_interests("alice");
assert!(
ctx.user_interests("alice").is_none(),
"a stale answer must not be mistaken for the next one"
);
}
#[test]
fn our_own_interests_are_kept_lowercased_and_deduplicated() {
// The server matches interests case-insensitively and forgets them when
// the session ends, so they are held here in one spelling to be sent
// again next login.
let mut ctx = ClientContext::new();
assert_eq!(
ctx.add_own_interest(" Krautrock ", true).as_deref(),
Some("krautrock")
);
assert_eq!(
ctx.add_own_interest(" ", true),
None,
"an empty interest is not stored, and is not sent either"
);
ctx.add_own_interest("KRAUTROCK", true);
ctx.add_own_interest("Muzak", false);
let interests = ctx.own_interests();
assert_eq!(interests.likes, ["krautrock"]);
assert_eq!(interests.hates, ["muzak"]);
ctx.remove_own_interest("KrautRock", true);
assert!(ctx.own_interests().likes.is_empty());
assert_eq!(
ctx.own_interests().hates,
["muzak"],
"the other list stands"
);
}
+641
View File
@@ -0,0 +1,641 @@
//! The queue peers wait in for one of our upload slots.
//!
//! Soulseek's rules ask an alternative client for "respect / recognition of
//! privileges": a user who has donated is meant to be served before one who
//! has not. That only means anything if there is a queue to jump — serving
//! every request the instant it arrives treats a privileged peer exactly like
//! everybody else — so the slot cap and the ordering are one feature.
//!
//! Ordering is privileged-first, then first-come. Nothing else: a fancier
//! policy (per-user fairness, small-file-first) would be a different promise
//! than the one the rules ask for.
use super::{ClientContext, Duration, Instant, PeerRegistry, UploadJob};
use crate::types::{UploadInfo, UploadStatus};
use std::path::PathBuf;
/// Cap on remembered queued-upload states, so a caller that never drains
/// them cannot grow the list without bound.
const MAX_UPLOAD_EVENTS: usize = 256;
/// How long an offered slot waits for the peer's TransferResponse. A peer that
/// never answers, having gone away or hung, would otherwise hold the slot for
/// the life of the process; with two slots, two of them shut uploads down.
const OFFER_TIMEOUT: Duration = Duration::from_secs(30);
/// A queued upload that has just been given a slot, ready to be offered to the
/// peer that asked for it.
#[derive(Debug, Clone)]
pub struct UploadOffer {
pub requester_key: String,
pub token: u32,
pub virtual_path: String,
pub size: u64,
}
/// A file a peer has asked us for and is waiting on.
#[derive(Debug, Clone)]
pub struct QueuedUpload {
/// The asking peer's username — also the registry key of its actor.
pub downloader: String,
pub virtual_path: String,
pub real_path: PathBuf,
pub size: u64,
/// Whether the server listed this user as privileged when they asked.
pub privileged: bool,
/// Arrival order, so the tie-break among equals is first-come.
pub seq: u64,
}
/// What decides the order: donors first, then whoever asked first.
const fn rank(job: &QueuedUpload) -> (bool, u64) {
(!job.privileged, job.seq)
}
/// Indices of `queue` in the order they will be served.
#[must_use]
pub fn serve_order(queue: &[QueuedUpload]) -> Vec<usize> {
let mut order: Vec<usize> = (0..queue.len()).collect();
order.sort_by_key(|&i| rank(&queue[i]));
order
}
/// Which entry to serve next, or `None` when nobody is waiting.
#[must_use]
pub fn next_to_serve(queue: &[QueuedUpload]) -> Option<usize> {
queue
.iter()
.enumerate()
.min_by_key(|(_, job)| rank(job))
.map(|(index, _)| index)
}
/// A queue position as the protocol reports it: counting from 1.
fn as_place(zero_based: usize) -> u32 {
u32::try_from(zero_based + 1).unwrap_or(u32::MAX)
}
/// Where `filename` sits for `downloader`, counting from 1 as the protocol's
/// `PlaceInQueueResponse` does. `None` when that peer is not waiting for it,
/// which is also the answer once the file has been handed over.
#[must_use]
pub fn place_in_queue(
queue: &[QueuedUpload],
downloader: &str,
filename: &str,
) -> Option<u32> {
serve_order(queue)
.into_iter()
.position(|i| {
queue[i].downloader == downloader
&& queue[i].virtual_path == filename
})
.map(as_place)
}
/// The queue as the client drives it: taking a request, filling slots, and
/// answering where a peer sits.
impl ClientContext {
/// Take `filename` from a peer's request and put them in the queue. The
/// caller pumps afterwards; queueing and serving are separate so a request
/// for a file we do not have never reaches the queue.
pub fn enqueue_upload(
&mut self,
downloader: &str,
filename: &str,
real_path: std::path::PathBuf,
size: u64,
) {
self.upload_seq += 1;
self.upload_queue.push(QueuedUpload {
downloader: downloader.to_string(),
virtual_path: filename.to_string(),
real_path,
size,
privileged: self.privileged_users.contains(downloader),
seq: self.upload_seq,
});
}
/// Move as many queued uploads into free slots as will fit, and return the
/// offers to send. Ordering is privileged-first, then first-come.
///
/// The registry comes back with them so the caller can send after dropping
/// the context lock: a peer actor's mailbox is not something to hold it
/// across.
pub fn pump_uploads(
&mut self,
mut next_token: impl FnMut() -> u32,
) -> (Option<PeerRegistry>, Vec<UploadOffer>) {
let mut offers = Vec::new();
while self.has_free_upload_slot() {
let Some(index) = next_to_serve(&self.upload_queue) else {
break;
};
let job = self.upload_queue.remove(index);
let token = next_token();
offers.push(UploadOffer {
requester_key: job.downloader.clone(),
token,
virtual_path: job.virtual_path.clone(),
size: job.size,
});
self.uploads.insert(
token,
UploadJob {
downloader: job.downloader,
real_path: job.real_path,
virtual_path: job.virtual_path,
size: job.size,
offered: Some(Instant::now()),
},
);
}
// Whatever is still waiting had to wait. Record it, because a caller
// polling `uploads()` on an interval cannot see a peer that queued and
// was served between two polls — and with a small slot count that is
// the common case, not the rare one.
for waiting in self.queued_uploads() {
let already = self.upload_events.iter().any(|seen| {
seen.username == waiting.username
&& seen.filename == waiting.filename
});
if !already {
self.upload_events.push(waiting);
}
}
// A caller that never drains must not grow this without bound.
if self.upload_events.len() > MAX_UPLOAD_EVENTS {
let excess = self.upload_events.len() - MAX_UPLOAD_EVENTS;
self.upload_events.drain(..excess);
}
(self.peer_registry.clone(), offers)
}
/// Take the queued-upload states recorded since the last call.
pub fn take_upload_events(&mut self) -> Vec<UploadInfo> {
std::mem::take(&mut self.upload_events)
}
/// Uploads occupying a slot: offered but not yet accepted, plus those
/// actually streaming.
///
/// `active_uploads` keeps finished transfers so `uploads()` can report them,
/// so only the in-progress ones count — counting the whole table would let
/// a few completed transfers wedge the queue shut forever.
fn uploads_in_flight(&self) -> usize {
self.uploads.len()
+ self
.active_uploads
.values()
.filter(|upload| {
matches!(upload.status, UploadStatus::InProgress)
})
.count()
}
pub(super) fn has_free_upload_slot(&self) -> bool {
self.uploads_in_flight() < self.upload_slots
}
/// The peers still waiting, in the order they will be served, each carrying
/// its own 1-based place.
pub(super) fn queued_uploads(&self) -> Vec<UploadInfo> {
serve_order(&self.upload_queue)
.into_iter()
.enumerate()
.map(|(place, index)| {
let job = &self.upload_queue[index];
UploadInfo {
username: job.downloader.clone(),
filename: job.virtual_path.clone(),
size: job.size,
bytes_sent: 0,
speed_bytes_per_sec: 0.0,
status: UploadStatus::Queued(as_place(place)),
}
})
.collect()
}
/// Where `downloader`'s queued `filename` sits, counting from 1. `None`
/// once it has left the queue for a slot.
#[must_use]
pub fn place_in_queue(
&self,
downloader: &str,
filename: &str,
) -> Option<u32> {
place_in_queue(&self.upload_queue, downloader, filename)
}
/// Replace the privileged set, re-ranking anyone already waiting: the list
/// arrives at login, which can be after a peer has queued something.
pub fn set_privileged_users(&mut self, users: Vec<String>) {
self.privileged_users = users.into_iter().collect();
for job in &mut self.upload_queue {
job.privileged = self.privileged_users.contains(&job.downloader);
}
}
#[must_use]
pub fn is_privileged(&self, username: &str) -> bool {
self.privileged_users.contains(username)
}
/// Drop offers older than [`OFFER_TIMEOUT`], recording each as a failed
/// upload so an operator sees why the peer never got its file. Returns
/// whether anything was dropped, which is the caller's cue to pump again.
pub fn expire_stale_offers(&mut self, now: Instant) -> bool {
let stale: Vec<u32> = self
.uploads
.iter()
.filter(|(_, job)| {
job.offered.is_some_and(|sent| {
now.duration_since(sent) >= OFFER_TIMEOUT
})
})
.map(|(&token, _)| token)
.collect();
for token in &stale {
let Some(job) = self.uploads.remove(token) else {
continue;
};
self.upload_events.push(UploadInfo {
username: job.downloader,
filename: job.virtual_path,
size: job.size,
bytes_sent: 0,
speed_bytes_per_sec: 0.0,
status: UploadStatus::Failed(
"no answer to the transfer offer".to_string(),
),
});
}
!stale.is_empty()
}
/// The peer accepted the offer for `token`; it may still take a while to
/// resolve their address and connect, and that wait must not expire it.
pub fn mark_offer_answered(&mut self, token: u32) {
if let Some(job) = self.uploads.get_mut(&token) {
job.offered = None;
}
}
/// Forget everything `downloader` was waiting for or had been offered, and
/// report whether that freed anything.
///
/// An offer sits in `uploads` counting against the slot cap until the peer
/// accepts it. A peer that never will — because it has gone — would hold
/// that slot for the life of the process, so leaving is what releases it.
pub fn release_upload_slots(&mut self, downloader: &str) -> bool {
let before = self.upload_queue.len() + self.uploads.len();
self.upload_queue.retain(|job| job.downloader != downloader);
self.uploads.retain(|_, job| job.downloader != downloader);
before != self.upload_queue.len() + self.uploads.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn queued(user: &str, privileged: bool, seq: u64) -> QueuedUpload {
QueuedUpload {
downloader: user.to_string(),
virtual_path: format!("@@share\\{user}.mp3"),
real_path: PathBuf::from("/tmp/x.mp3"),
size: 1,
privileged,
seq,
}
}
#[test]
fn an_empty_queue_serves_nobody() {
assert_eq!(next_to_serve(&[]), None);
}
#[test]
fn equals_are_served_in_the_order_they_asked() {
let queue = [queued("amy", false, 1), queued("bob", false, 2)];
assert_eq!(next_to_serve(&queue), Some(0));
assert_eq!(serve_order(&queue), [0, 1]);
}
#[test]
fn a_privileged_peer_is_served_before_one_that_asked_first() {
let queue = [
queued("early_plain", false, 1),
queued("late_donor", true, 2),
];
assert_eq!(
next_to_serve(&queue),
Some(1),
"the donor jumps the plain user who was already waiting"
);
}
#[test]
fn privileged_peers_keep_first_come_order_among_themselves() {
let queue = [
queued("donor_late", true, 9),
queued("plain", false, 2),
queued("donor_early", true, 5),
];
let order: Vec<&str> = serve_order(&queue)
.into_iter()
.map(|i| queue[i].downloader.as_str())
.collect();
assert_eq!(order, ["donor_early", "donor_late", "plain"]);
}
#[test]
fn the_reported_place_counts_from_one() {
let queue = [queued("amy", false, 1), queued("bob", false, 2)];
assert_eq!(place_in_queue(&queue, "amy", "@@share\\amy.mp3"), Some(1));
assert_eq!(place_in_queue(&queue, "bob", "@@share\\bob.mp3"), Some(2));
}
#[test]
fn the_reported_place_reflects_being_overtaken() {
let mut queue = vec![queued("plain", false, 1)];
assert_eq!(
place_in_queue(&queue, "plain", "@@share\\plain.mp3"),
Some(1)
);
queue.push(queued("donor", true, 2));
assert_eq!(
place_in_queue(&queue, "plain", "@@share\\plain.mp3"),
Some(2),
"a peer that got overtaken must be told so, not left on 1"
);
}
#[test]
fn a_file_nobody_queued_has_no_place() {
let queue = [queued("amy", false, 1)];
assert_eq!(place_in_queue(&queue, "amy", "@@share\\other.mp3"), None);
assert_eq!(place_in_queue(&queue, "zoe", "@@share\\amy.mp3"), None);
}
}
/// Tests of the queue as the client actually drives it: what a request, a
/// privileged-user list, and a finished transfer do to a real
/// [`super::ClientContext`].
///
/// These sit here rather than in the e2e suite because a Soulseek server only
/// ever names privileged users it knows about, and the soulfind used for e2e
/// declares nobody — so the one behaviour the rules are about would otherwise
/// go untested.
#[cfg(test)]
mod context_tests {
use super::OFFER_TIMEOUT;
use crate::client::{ClientContext, DEFAULT_UPLOAD_SLOTS};
use crate::types::UploadStatus;
use std::path::PathBuf;
/// A context with `slots` upload slots and a token counter that hands out
/// 1, 2, 3… so an assertion can name a token.
fn context(slots: usize) -> (ClientContext, impl FnMut() -> u32) {
let mut ctx = ClientContext::new();
ctx.upload_slots = slots;
let mut next = 0;
(ctx, move || {
next += 1;
next
})
}
fn ask(ctx: &mut ClientContext, user: &str, file: &str) {
ctx.enqueue_upload(user, file, PathBuf::from("/tmp/x"), 4096);
}
/// Who the pump offered to, by the order it offered them.
fn offered(
ctx: &mut ClientContext,
token: &mut impl FnMut() -> u32,
) -> Vec<String> {
ctx.pump_uploads(token)
.1
.into_iter()
.map(|offer| offer.requester_key)
.collect()
}
#[test]
fn a_fresh_context_starts_with_the_default_cap() {
assert_eq!(ClientContext::new().upload_slots, DEFAULT_UPLOAD_SLOTS);
}
#[test]
fn the_pump_fills_the_slots_and_leaves_the_rest_queued() {
let (mut ctx, mut token) = context(2);
for user in ["a", "b", "c"] {
ask(&mut ctx, user, "f.mp3");
}
assert_eq!(offered(&mut ctx, &mut token), ["a", "b"]);
assert_eq!(
ctx.place_in_queue("c", "f.mp3"),
Some(1),
"the third asker waits, now at the front of the queue"
);
assert!(
offered(&mut ctx, &mut token).is_empty(),
"pumping again must not oversubscribe the slots"
);
}
#[test]
fn a_privileged_list_arriving_late_re_ranks_who_is_waiting() {
let (mut ctx, mut token) = context(1);
ask(&mut ctx, "blocker", "f.mp3");
ask(&mut ctx, "plain", "f.mp3");
ask(&mut ctx, "donor", "f.mp3");
assert_eq!(offered(&mut ctx, &mut token), ["blocker"]);
// Before the server has said anything, first-come holds.
assert_eq!(ctx.place_in_queue("plain", "f.mp3"), Some(1));
assert_eq!(ctx.place_in_queue("donor", "f.mp3"), Some(2));
ctx.set_privileged_users(vec!["donor".to_string()]);
assert!(ctx.is_privileged("donor"));
assert!(!ctx.is_privileged("plain"));
assert_eq!(
ctx.place_in_queue("donor", "f.mp3"),
Some(1),
"the donor should overtake the peer that asked first"
);
assert_eq!(
ctx.place_in_queue("plain", "f.mp3"),
Some(2),
"and the plain peer should be told they were overtaken"
);
}
#[test]
fn the_privileged_peer_is_the_one_served_next() {
let (mut ctx, mut token) = context(1);
ctx.set_privileged_users(vec!["donor".to_string()]);
// Someone else already holds the only slot: a transfer in progress is
// never preempted, so the donor's turn is the *next* one.
ask(&mut ctx, "blocker", "f.mp3");
assert_eq!(offered(&mut ctx, &mut token), ["blocker"]);
ask(&mut ctx, "plain", "f.mp3");
ask(&mut ctx, "donor", "f.mp3");
ctx.uploads.clear(); // the blocker's transfer finished
assert_eq!(
offered(&mut ctx, &mut token),
["donor"],
"the freed slot goes to the donor, not the longer-waiting peer"
);
}
#[test]
fn a_finished_transfer_frees_its_slot_for_the_next_in_line() {
let (mut ctx, mut token) = context(1);
ask(&mut ctx, "first", "f.mp3");
ask(&mut ctx, "second", "f.mp3");
assert_eq!(offered(&mut ctx, &mut token), ["first"]);
ctx.uploads.clear();
assert_eq!(offered(&mut ctx, &mut token), ["second"]);
assert_eq!(ctx.place_in_queue("second", "f.mp3"), None);
}
#[test]
fn an_offer_nobody_answers_frees_its_slot_after_the_timeout() {
let (mut ctx, mut token) = context(1);
ask(&mut ctx, "gone", "f.mp3");
ask(&mut ctx, "next", "f.mp3");
assert_eq!(offered(&mut ctx, &mut token), ["gone"]);
let now = std::time::Instant::now();
assert!(!ctx.expire_stale_offers(now), "a fresh offer stays");
assert!(
ctx.expire_stale_offers(now + OFFER_TIMEOUT),
"an unanswered offer expires"
);
assert_eq!(offered(&mut ctx, &mut token), ["next"]);
assert!(ctx.take_upload_events().iter().any(|event| {
event.username == "gone"
&& matches!(event.status, UploadStatus::Failed(_))
}));
}
#[test]
fn an_accepted_offer_waiting_on_an_address_does_not_expire() {
let (mut ctx, mut token) = context(1);
ask(&mut ctx, "slow", "f.mp3");
offered(&mut ctx, &mut token);
ctx.mark_offer_answered(1);
let later = std::time::Instant::now() + OFFER_TIMEOUT * 2;
assert!(!ctx.expire_stale_offers(later), "an answered offer stays");
assert!(ctx.uploads.contains_key(&1));
}
#[test]
fn completed_transfers_do_not_keep_occupying_slots() {
// active_uploads keeps finished transfers so `uploads()` can report
// them; counting those would wedge the queue shut for the session.
let (mut ctx, mut token) = context(1);
ask(&mut ctx, "first", "f.mp3");
offered(&mut ctx, &mut token);
let job = ctx.uploads.remove(&1).expect("the offer was recorded");
ctx.active_uploads.insert(
1,
crate::client::ActiveUpload {
username: job.downloader,
filename: job.virtual_path,
size: job.size,
bytes_sent: std::sync::Arc::new(
std::sync::atomic::AtomicU64::new(job.size),
),
cancel: std::sync::Arc::new(
std::sync::atomic::AtomicBool::new(false),
),
status: UploadStatus::Completed,
started: std::time::Instant::now(),
},
);
ask(&mut ctx, "second", "f.mp3");
assert_eq!(
offered(&mut ctx, &mut token),
["second"],
"a completed upload must not hold its slot"
);
}
#[test]
fn queued_uploads_are_reported_in_the_order_they_will_be_served() {
let (mut ctx, mut token) = context(1);
ctx.set_privileged_users(vec!["donor".to_string()]);
ask(&mut ctx, "blocker", "f.mp3");
offered(&mut ctx, &mut token);
ask(&mut ctx, "plain", "f.mp3");
ask(&mut ctx, "donor", "f.mp3");
let reported: Vec<(String, UploadStatus)> = ctx
.queued_uploads()
.into_iter()
.map(|upload| (upload.username, upload.status))
.collect();
assert_eq!(
reported,
[
("donor".to_string(), UploadStatus::Queued(1)),
("plain".to_string(), UploadStatus::Queued(2)),
]
);
}
// A peer that waits and is then served inside one poll interval must still
// be reportable as queued: `serve` samples on an interval, so a state that
// only ever existed between two samples would otherwise vanish from the
// transfer log entirely.
#[test]
fn a_queued_upload_is_recorded_even_after_it_leaves_the_queue() {
let (mut ctx, mut token) = context(1);
ask(&mut ctx, "amy", "@@share\\amy.mp3");
ask(&mut ctx, "bob", "@@share\\bob.mp3");
// One slot, so bob waits behind amy.
let _ = ctx.pump_uploads(&mut token);
assert!(
ctx.queued_uploads().iter().any(|u| u.username == "bob"),
"bob should be waiting while amy holds the only slot"
);
// Amy finishes and bob is served, so nothing is waiting any more...
ctx.uploads.clear();
ctx.active_uploads.clear();
let _ = ctx.pump_uploads(&mut token);
assert!(
ctx.queued_uploads().is_empty(),
"the queue should have drained"
);
// ...but the fact that bob waited survives for whoever asks next.
let events = ctx.take_upload_events();
assert!(
events.iter().any(|u| u.username == "bob"
&& matches!(u.status, UploadStatus::Queued(_))),
"bob's wait should still be reportable, got {events:?}"
);
assert!(
ctx.take_upload_events().is_empty(),
"draining twice must not repeat the same events"
);
}
}
+362
View File
@@ -0,0 +1,362 @@
use super::{
ActiveUpload, Arc, BrokeredUpload, Client, ClientContext, DownloadStatus,
PeerMessage, RwLock, RwLockExt, ServerMessage, collect_failed_tokens,
debug, error, next_connect_token, next_upload_token, thread,
};
use crate::message::server::MessageFactory;
use crate::peer::{ConnectionType, upload_peer};
use crate::types::UploadStatus;
use std::net::TcpStream;
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
impl Client {
/// Whether the server listed `username` as privileged. Privileged peers are
/// served before everyone else.
#[must_use]
pub fn is_privileged(&self, username: &str) -> bool {
self.context
.read_safe()
.is_ok_and(|ctx| ctx.is_privileged(username))
}
/// Ask the server how much of our own privilege time is left (code 92). The
/// answer arrives asynchronously; read it with
/// [`Client::own_privilege_seconds`].
///
/// # Errors
/// [`crate::error::SoulseekRs::NotConnected`] without a server connection.
pub fn check_privileges(&self) -> crate::error::Result<()> {
let Some(handle) = &self.server_handle else {
return Err(crate::error::SoulseekRs::NotConnected);
};
let _ = handle.send(super::ServerMessage::CheckPrivileges);
Ok(())
}
/// Seconds of our own privileges left, or `None` until the server has
/// answered a [`Client::check_privileges`].
#[must_use]
pub fn own_privilege_seconds(&self) -> Option<u32> {
self.context
.read_safe()
.ok()
.and_then(|ctx| ctx.own_privileges)
}
/// Queued-upload states recorded since the last call, for a caller that
/// samples [`Client::uploads`] and would otherwise miss a peer that queued
/// and was served between two samples.
#[must_use]
pub fn take_upload_events(&self) -> Vec<crate::types::UploadInfo> {
self.context
.write_safe()
.map(|mut ctx| ctx.take_upload_events())
.unwrap_or_default()
}
/// How many uploads run at once. Lowering it does not interrupt transfers
/// already in flight; the queue simply refills more slowly.
pub fn set_upload_slots(&self, slots: usize) {
if let Ok(mut ctx) = self.context.write_safe() {
ctx.upload_slots = slots.max(1);
}
}
/// Drop everything `username` was queued or offered, then hand their slot to
/// whoever is next.
pub(crate) fn release_upload_slots(
client_context: &Arc<RwLock<ClientContext>>,
username: &str,
) {
let freed = client_context
.write_safe()
.is_ok_and(|mut ctx| ctx.release_upload_slots(username));
if freed {
Self::pump_upload_queue(client_context);
}
}
/// Fill every free upload slot from the queue and send the offers.
///
/// Called whenever the queue or the slot count could have changed: a new
/// request, a finished transfer, or a privileged list that re-ranked who is
/// waiting.
pub(crate) fn pump_upload_queue(
client_context: &Arc<RwLock<ClientContext>>,
) {
let (registry, offers) = match client_context.write_safe() {
Ok(mut ctx) => ctx.pump_uploads(next_upload_token),
Err(e) => {
error!("[client] pump_upload_queue write: {}", e);
return;
}
};
let Some(registry) = registry else {
return;
};
for offer in offers {
let _ = registry.send_to_peer(
&offer.requester_key,
PeerMessage::ServeUpload {
token: offer.token,
filename: offer.virtual_path,
size: offer.size,
},
);
}
}
/// Consume the upload job for `token` and stream the file to `host:port`
/// on a background thread.
pub(crate) fn spawn_serve(
client_context: &Arc<RwLock<ClientContext>>,
own_username: &str,
token: u32,
host: String,
port: u32,
) {
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
let Some(job) = ctx.uploads.remove(&token) else {
return;
};
let bytes_sent = Arc::new(AtomicU64::new(0));
let cancel = Arc::new(AtomicBool::new(false));
ctx.active_uploads.insert(
token,
ActiveUpload {
username: job.downloader.clone(),
filename: job.virtual_path.clone(),
size: job.size,
bytes_sent: bytes_sent.clone(),
cancel: cancel.clone(),
status: UploadStatus::InProgress,
started: std::time::Instant::now(),
},
);
drop(ctx);
let own = own_username.to_string();
let real_path = job.real_path;
let downloader = job.downloader;
let context = client_context.clone();
thread::spawn(move || {
// Most downloaders sit behind a NAT, so dialling them is a gamble
// that costs a 20s timeout when it fails. Like the reference
// clients, ask the server to have them connect back at the same
// time; the first socket to arrive claims the transfer, and port 0
// (the server not knowing where they listen) skips the dial.
let Some((connect_token, claimed)) =
Self::broker_upload(&context, token, &real_path, &downloader)
else {
return;
};
if port == 0 {
return;
}
let stream = match upload_peer::dial(&host, port) {
Ok(stream) => stream,
Err(e) => {
debug!(
"[client] cannot dial {downloader} at {host}:{port} \
for upload {token} ({e}); waiting for them to connect"
);
return;
}
};
if claimed
.compare_exchange(
false,
true,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_err()
{
return;
}
if let Ok(mut ctx) = context.write_safe() {
ctx.take_brokered_upload(connect_token);
}
let result = upload_peer::serve_direct(
stream,
&own,
token,
&real_path,
&bytes_sent,
&cancel,
);
Self::finish_upload(&context, token, &real_path, result);
});
}
/// Park the upload under a correlation token and ask the server to have
/// `downloader` connect back to us with it. Returns the token and the claim
/// flag the dialling route races against; `None` fails the upload.
fn broker_upload(
context: &Arc<RwLock<ClientContext>>,
token: u32,
real_path: &Path,
downloader: &str,
) -> Option<(u32, Arc<AtomicBool>)> {
let connect_token = next_connect_token();
let (claimed, sender) = match context.write_safe() {
Ok(mut ctx) => (
ctx.add_brokered_upload(
connect_token,
token,
real_path.to_path_buf(),
),
ctx.server_sender.clone(),
),
Err(_) => return None,
};
let Some(sender) = sender else {
Self::fail_upload(context, token, "not connected to the server");
return None;
};
let _ = sender.send(ServerMessage::SendMessage(
MessageFactory::build_connect_to_peer(
connect_token,
downloader,
ConnectionType::F,
),
));
Some((connect_token, claimed))
}
/// The downloader of a brokered upload connected back: stream the file
/// over its socket on a background thread, unless the dial beat it.
pub(crate) fn serve_pierced(
client_context: &Arc<RwLock<ClientContext>>,
upload: BrokeredUpload,
stream: TcpStream,
) {
if !upload.claim() {
return;
}
let Some((bytes_sent, cancel)) =
client_context.write_safe().ok().and_then(|mut ctx| {
let active = ctx.active_uploads.get_mut(&upload.token)?;
active.started = std::time::Instant::now();
Some((active.bytes_sent.clone(), active.cancel.clone()))
})
else {
return;
};
let context = client_context.clone();
thread::spawn(move || {
let result = upload_peer::serve_pierced(
stream,
upload.token,
&upload.real_path,
&bytes_sent,
&cancel,
);
Self::finish_upload(
&context,
upload.token,
&upload.real_path,
result,
);
});
}
/// Mark upload `token` failed and hand its slot to the next in line.
pub(crate) fn fail_upload(
client_context: &Arc<RwLock<ClientContext>>,
token: u32,
reason: &str,
) {
if let Ok(mut ctx) = client_context.write_safe()
&& let Some(upload) = ctx.active_uploads.get_mut(&token)
{
error!("[client] upload {token} to {}: {reason}", upload.username);
upload.status = UploadStatus::Failed(reason.to_string());
}
Self::pump_upload_queue(client_context);
}
/// Record how a served upload ended, report its rate to the server and free
/// its slot.
fn finish_upload(
context: &Arc<RwLock<ClientContext>>,
token: u32,
real_path: &Path,
result: std::io::Result<u64>,
) {
let streamed = result.as_ref().ok().copied();
let status = match &result {
Ok(_) => UploadStatus::Completed,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {
UploadStatus::Cancelled
}
Err(e) => {
error!("[client] serve {}: {}", real_path.display(), e);
UploadStatus::Failed(e.to_string())
}
};
let mut report = None;
if let Ok(mut ctx) = context.write_safe()
&& let Some(upload) = ctx.active_uploads.get_mut(&token)
{
let secs = upload.started.elapsed().as_secs_f64();
upload.status = status;
if let Some(bytes) = streamed
&& secs > 0.0
{
let speed = (bytes as f64 / secs) as u32;
ctx.last_upload_speed = speed;
report = ctx.server_sender.clone().map(|s| (s, speed));
}
}
// The server folds each finished upload's rate into the average
// speed it shows other users for us.
if let Some((server, speed)) = report {
let _ = server.send(ServerMessage::SendMessage(
MessageFactory::build_send_upload_speed(speed),
));
}
// The slot this transfer held is free now, so whoever is next in
// line gets it without waiting for another request to arrive.
Self::pump_upload_queue(context);
}
pub(crate) fn process_failed_uploads(
client_context: Arc<RwLock<ClientContext>>,
username: &str,
filename: Option<&str>,
) {
let failed_tokens = match client_context.read_safe() {
Ok(context) => {
collect_failed_tokens(&context.downloads, username, filename)
}
Err(e) => {
error!("[client] process_failed_uploads read: {}", e);
return;
}
};
if failed_tokens.is_empty() {
return;
}
match client_context.write_safe() {
Ok(mut context) => {
for token in failed_tokens {
context.downloads.update_status(
token,
DownloadStatus::Failed(Some(
"The upload failed on the other side".to_string(),
)),
);
context.downloads.remove(token);
}
}
Err(e) => {
error!("[client] process_failed_uploads write: {}", e);
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
use crate::message::{Message, handlers::Handlers};
use std::sync::mpsc::Sender;
use crate::debug;
pub struct MessageDispatcher<Op> {
owner_name: String,
sender: Sender<Op>,
handlers: Handlers<Op>,
}
impl<Op> MessageDispatcher<Op> {
#[must_use]
pub const fn new(
owner_name: String,
sender: Sender<Op>,
handlers: Handlers<Op>,
) -> Self {
Self {
owner_name,
sender,
handlers,
}
}
pub fn dispatch(&self, message: &mut Message) {
let code = message.get_message_code();
if let Some(handler) = self.handlers.get_handler(code) {
message.set_pointer(8);
handler.handle(message, self.sender.clone());
} else {
debug!(
"[{}:dispatcher] No handler found for message code: {code}",
self.owner_name
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::MessageHandler;
use std::sync::mpsc::channel;
struct Echo(u32);
impl MessageHandler<u32> for Echo {
fn get_code(&self) -> u32 {
self.0
}
fn handle(&self, _: &mut Message, sender: Sender<u32>) {
let _ = sender.send(self.0);
}
}
fn framed(code: u32) -> Message {
Message::new_with_data(Message::new().write_int32(code).get_buffer())
}
#[test]
fn a_code_above_255_is_not_confused_with_its_low_byte() {
let (sender, received) = channel();
let mut handlers = Handlers::new();
handlers.register_handler(Echo(1));
handlers.register_handler(Echo(1001));
let dispatcher =
MessageDispatcher::new("test".to_string(), sender, handlers);
dispatcher.dispatch(&mut framed(257));
dispatcher.dispatch(&mut framed(1001));
assert_eq!(received.try_iter().collect::<Vec<_>>(), [1001]);
}
}
+589
View File
@@ -0,0 +1,589 @@
use crate::types::{Download, DownloadStatus};
#[derive(Default)]
pub struct DownloadStore {
downloads: Vec<Download>,
}
impl DownloadStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, download: Download) {
self.downloads.retain(|d| {
!(d.is_finished()
&& d.username == download.username
&& d.filename == download.filename)
});
self.downloads.push(download);
}
pub fn remove(&mut self, token: u32) {
self.downloads.retain(|d| d.token != token);
}
#[must_use]
pub fn get_by_token(&self, token: u32) -> Option<&Download> {
self.downloads.iter().find(|d| d.token == token)
}
pub fn get_by_token_mut(&mut self, token: u32) -> Option<&mut Download> {
self.downloads.iter_mut().find(|d| d.token == token)
}
pub fn get_by_file_mut(
&mut self,
username: &str,
filename: &str,
) -> Option<&mut Download> {
self.downloads
.iter_mut()
.find(|d| d.username == username && d.filename == filename)
}
#[must_use]
pub fn tokens(&self) -> Vec<u32> {
self.downloads.iter().map(|d| d.token).collect()
}
#[must_use]
pub const fn list(&self) -> &Vec<Download> {
&self.downloads
}
pub fn update_status(
&mut self,
token: u32,
status: DownloadStatus,
) -> bool {
let Some(download) = self.get_by_token_mut(token) else {
return false;
};
if download.status.is_terminal()
|| matches!(
(&download.status, &status),
(
DownloadStatus::Paused { .. },
DownloadStatus::InProgress { .. }
)
)
{
return false;
}
download.status = status;
true
}
/// Atomically reserve a queued download for the peer that owns it.
///
/// Every file-connection direction uses this before opening a partial, so
/// two sockets cannot both observe `Queued` and write the same target.
pub fn claim_for_peer(
&mut self,
token: u32,
username: &str,
) -> Option<Download> {
let download = self.get_by_token_mut(token)?;
if download.username != username
|| !matches!(download.status, DownloadStatus::Queued)
{
return None;
}
download.status = DownloadStatus::InProgress {
bytes_downloaded: 0,
total_bytes: download.size,
speed_bytes_per_sec: 0.0,
};
Some(download.clone())
}
pub fn cancel_by_file(
&mut self,
username: &str,
filename: &str,
) -> Option<Download> {
let download = self.get_by_file_mut(username, filename)?;
if download.is_finished() {
return None;
}
download.status = DownloadStatus::Cancelled;
let _ = download.sender.send(DownloadStatus::Cancelled);
Some(download.clone())
}
pub fn update_queue_position(
&mut self,
username: &str,
filename: &str,
position: u32,
) -> bool {
let Some(download) = self.get_by_file_mut(username, filename) else {
return false;
};
download.queue_position = Some(position);
true
}
pub fn remove_queued_by_file(
&mut self,
username: &str,
filename: &str,
) -> bool {
let Some(index) = self.downloads.iter().position(|download| {
download.username == username
&& download.filename == filename
&& matches!(download.status, DownloadStatus::Queued)
}) else {
return false;
};
self.downloads.remove(index);
true
}
/// Remove every download matching `username`/`filename` regardless of
/// status. Used before retrying a failed download so the stale entry (whose
/// md5-derived token collides with the retry's) can't shadow the fresh one.
/// Returns whether anything was removed.
pub fn remove_by_file(&mut self, username: &str, filename: &str) -> bool {
let before = self.downloads.len();
self.downloads
.retain(|d| !(d.username == username && d.filename == filename));
self.downloads.len() != before
}
pub fn pause_by_file(&mut self, username: &str, filename: &str) -> bool {
let Some(download) = self.get_by_file_mut(username, filename) else {
return false;
};
let paused_status = match &download.status {
DownloadStatus::InProgress {
bytes_downloaded,
total_bytes,
..
} => DownloadStatus::Paused {
bytes_downloaded: *bytes_downloaded,
total_bytes: *total_bytes,
},
DownloadStatus::Paused { .. } => return true,
_ => return false,
};
download.status = paused_status.clone();
let _ = download.sender.send(paused_status);
true
}
pub fn resume_by_file(&mut self, username: &str, filename: &str) -> bool {
let Some(download) = self.get_by_file_mut(username, filename) else {
return false;
};
let resumed_status = match &download.status {
DownloadStatus::Paused {
bytes_downloaded,
total_bytes,
} => DownloadStatus::InProgress {
bytes_downloaded: *bytes_downloaded,
total_bytes: *total_bytes,
speed_bytes_per_sec: 0.0,
},
DownloadStatus::InProgress { .. } => return true,
_ => return false,
};
download.status = resumed_status.clone();
let _ = download.sender.send(resumed_status);
true
}
}
/// Returns the tokens of queued downloads matching `username` (and optionally
/// a `filename`) after notifying their senders of `Failed`.
///
/// Caller is responsible for then calling `update_status` and `remove` for
/// each token, typically under a write lock.
#[must_use]
pub fn collect_failed_tokens(
store: &DownloadStore,
username: &str,
filename: Option<&str>,
) -> Vec<u32> {
store
.list()
.iter()
.filter(|d| {
d.username == username && filename.is_none_or(|f| d.filename == *f)
})
.filter(|d| matches!(d.status, DownloadStatus::Queued))
.map(|d| {
let _ = d.sender.send(DownloadStatus::Failed(Some(
"The upload failed on the other side".to_string(),
)));
d.token
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::DownloadMetadata;
use std::sync::mpsc;
fn make_download(token: u32, status: DownloadStatus) -> Download {
Download {
username: "peer".to_string(),
filename: format!("file-{token}.mp3"),
token,
size: 100,
download_directory: "test".to_string(),
status,
sender: mpsc::channel().0,
queue_position: None,
metadata: DownloadMetadata::default(),
}
}
#[test]
fn add_get_remove_roundtrip() {
let mut store = DownloadStore::new();
store.add(make_download(123, DownloadStatus::Queued));
assert!(store.get_by_token(123).is_some());
assert_eq!(store.tokens(), vec![123]);
assert_eq!(store.list().len(), 1);
store.remove(123);
assert!(store.get_by_token(123).is_none());
assert!(store.list().is_empty());
}
#[test]
fn update_queue_position_sets_field_when_match() {
let mut store = DownloadStore::new();
let mut download = make_download(1, DownloadStatus::Queued);
download.username = "peer".to_string();
download.filename = "song.mp3".to_string();
store.add(download);
assert!(store.update_queue_position("peer", "song.mp3", 42));
assert_eq!(store.get_by_token(1).unwrap().queue_position, Some(42));
assert!(!store.update_queue_position("peer", "missing.mp3", 1));
assert!(!store.update_queue_position("other", "song.mp3", 1));
}
#[test]
fn cancel_by_file_flips_an_unfinished_download_and_tells_its_channel() {
let mut store = DownloadStore::new();
let (tx, rx) = mpsc::channel();
let mut queued = make_download(1, DownloadStatus::Queued);
queued.sender = tx;
store.add(queued);
store.add(make_download(
2,
DownloadStatus::InProgress {
bytes_downloaded: 5,
total_bytes: 100,
speed_bytes_per_sec: 1.0,
},
));
store.add(make_download(
3,
DownloadStatus::Paused {
bytes_downloaded: 5,
total_bytes: 100,
},
));
store.add(make_download(4, DownloadStatus::Completed));
store.add(make_download(5, DownloadStatus::Failed(None)));
assert!(store.cancel_by_file("peer", "file-1.mp3").is_some());
assert!(matches!(rx.try_recv(), Ok(DownloadStatus::Cancelled)));
assert!(store.cancel_by_file("peer", "file-2.mp3").is_some());
assert!(store.cancel_by_file("peer", "file-3.mp3").is_some());
assert!(store.cancel_by_file("peer", "file-4.mp3").is_none());
assert!(store.cancel_by_file("peer", "file-5.mp3").is_none());
assert!(store.cancel_by_file("peer", "file-9.mp3").is_none());
for token in 1..=3 {
let download = store.get_by_token(token).unwrap();
assert!(matches!(download.status, DownloadStatus::Cancelled));
assert!(download.is_finished());
}
}
#[test]
fn a_cancelled_download_stays_cancelled() {
let mut store = DownloadStore::new();
store.add(make_download(1, DownloadStatus::Queued));
assert!(store.cancel_by_file("peer", "file-1.mp3").is_some());
assert!(!store.update_status(
1,
DownloadStatus::InProgress {
bytes_downloaded: 0,
total_bytes: 100,
speed_bytes_per_sec: 0.0,
},
));
assert!(
!store
.update_status(1, DownloadStatus::Failed(Some("late".into())))
);
assert!(matches!(
store.get_by_token(1).unwrap().status,
DownloadStatus::Cancelled
));
assert!(store.cancel_by_file("peer", "file-1.mp3").is_none());
assert!(!store.resume_by_file("peer", "file-1.mp3"));
assert!(!store.pause_by_file("peer", "file-1.mp3"));
}
#[test]
fn add_evicts_a_finished_entry_for_the_same_file() {
let mut store = DownloadStore::new();
store.add(make_download(1, DownloadStatus::Cancelled));
store.add(make_download(2, DownloadStatus::Queued));
let mut fresh = make_download(3, DownloadStatus::Queued);
fresh.filename = "file-1.mp3".to_string();
store.add(fresh);
let mut again = make_download(4, DownloadStatus::Queued);
again.filename = "file-2.mp3".to_string();
store.add(again);
assert_eq!(store.tokens(), vec![2, 3, 4], "only the dead row goes");
assert!(store.cancel_by_file("peer", "file-1.mp3").is_some());
assert!(matches!(
store.get_by_token(3).unwrap().status,
DownloadStatus::Cancelled
));
}
#[test]
fn pause_then_resume_in_progress_download() {
let mut store = DownloadStore::new();
let (tx, rx) = mpsc::channel();
let mut download = make_download(
1,
DownloadStatus::InProgress {
bytes_downloaded: 25,
total_bytes: 100,
speed_bytes_per_sec: 10.0,
},
);
download.sender = tx;
store.add(download);
assert!(store.pause_by_file("peer", "file-1.mp3"));
assert!(matches!(
store.get_by_token(1).unwrap().status,
DownloadStatus::Paused {
bytes_downloaded: 25,
total_bytes: 100
}
));
assert!(matches!(
rx.try_recv().unwrap(),
DownloadStatus::Paused {
bytes_downloaded: 25,
total_bytes: 100
}
));
assert!(store.resume_by_file("peer", "file-1.mp3"));
assert!(matches!(
store.get_by_token(1).unwrap().status,
DownloadStatus::InProgress {
bytes_downloaded: 25,
total_bytes: 100,
speed_bytes_per_sec: 0.0
}
));
}
#[test]
fn progress_cannot_implicitly_resume_a_paused_download() {
let mut store = DownloadStore::new();
store.add(make_download(
1,
DownloadStatus::Paused {
bytes_downloaded: 25,
total_bytes: 100,
},
));
assert!(!store.update_status(
1,
DownloadStatus::InProgress {
bytes_downloaded: 50,
total_bytes: 100,
speed_bytes_per_sec: 10.0,
},
));
assert!(matches!(
store.get_by_token(1).unwrap().status,
DownloadStatus::Paused {
bytes_downloaded: 25,
total_bytes: 100
}
));
}
#[test]
fn a_download_can_only_be_claimed_once_and_by_its_peer() {
let mut store = DownloadStore::new();
store.add(make_download(1, DownloadStatus::Queued));
assert!(store.claim_for_peer(1, "other").is_none());
let claimed = store.claim_for_peer(1, "peer").unwrap();
assert!(matches!(
claimed.status,
DownloadStatus::InProgress {
bytes_downloaded: 0,
total_bytes: 100,
speed_bytes_per_sec: 0.0
}
));
assert!(store.claim_for_peer(1, "peer").is_none());
}
#[test]
fn remove_queued_skips_active_downloads() {
let mut store = DownloadStore::new();
store.add(make_download(123, DownloadStatus::Queued));
store.add(make_download(
456,
DownloadStatus::InProgress {
bytes_downloaded: 25,
total_bytes: 100,
speed_bytes_per_sec: 10.0,
},
));
// Override second download's filename so they don't collide
store.get_by_token_mut(456).unwrap().filename =
"active.mp3".to_string();
store.get_by_token_mut(123).unwrap().filename =
"queued.mp3".to_string();
assert!(store.remove_queued_by_file("peer", "queued.mp3"));
assert!(!store.remove_queued_by_file("peer", "active.mp3"));
assert!(store.get_by_token(123).is_none());
assert!(store.get_by_token(456).is_some());
}
#[test]
fn remove_by_file_removes_regardless_of_status() {
let mut store = DownloadStore::new();
// A failed download (the retry case) plus a same-name duplicate that a
// token-migration could have left behind — both must go.
let mut failed = make_download(1, DownloadStatus::Failed(None));
failed.filename = "song.mp3".to_string();
store.add(failed);
let mut dup = make_download(2, DownloadStatus::Queued);
dup.filename = "song.mp3".to_string();
store.add(dup);
let mut other = make_download(3, DownloadStatus::Queued);
other.filename = "other.mp3".to_string();
store.add(other);
assert!(store.remove_by_file("peer", "song.mp3"));
assert!(store.get_by_token(1).is_none());
assert!(store.get_by_token(2).is_none());
assert!(store.get_by_token(3).is_some(), "other file untouched");
assert!(!store.remove_by_file("peer", "song.mp3"), "idempotent");
}
#[test]
fn collect_failed_tokens_collects_only_queued_downloads() {
let mut store = DownloadStore::new();
let (tx_active, rx_active) = mpsc::channel();
let (tx_paused, rx_paused) = mpsc::channel();
let (tx_completed, rx_completed) = mpsc::channel();
let (tx_queued, _rx_queued) = mpsc::channel();
let mut active = make_download(
1,
DownloadStatus::InProgress {
bytes_downloaded: 25,
total_bytes: 100,
speed_bytes_per_sec: 10.0,
},
);
active.sender = tx_active;
let mut paused = make_download(
2,
DownloadStatus::Paused {
bytes_downloaded: 25,
total_bytes: 100,
},
);
paused.sender = tx_paused;
let mut completed = make_download(3, DownloadStatus::Completed);
completed.sender = tx_completed;
let mut queued = make_download(4, DownloadStatus::Queued);
queued.sender = tx_queued;
store.add(active);
store.add(paused);
store.add(completed);
store.add(queued);
let tokens = collect_failed_tokens(&store, "peer", None);
assert_eq!(tokens, vec![4], "only the queued download may fail");
assert!(
rx_active.try_recv().is_err(),
"an in-progress transfer must not be notified of failure"
);
assert!(
rx_paused.try_recv().is_err(),
"a paused transfer must not be notified of failure"
);
assert!(
rx_completed.try_recv().is_err(),
"a completed download must not be notified of failure"
);
}
#[test]
fn collect_failed_tokens_notifies_and_lists_matching() {
let mut store = DownloadStore::new();
let (tx_match, rx_match) = mpsc::channel();
let (tx_other_user, _rx_other_user) = mpsc::channel();
let (tx_other_file, _rx_other_file) = mpsc::channel();
let mut a = make_download(1, DownloadStatus::Queued);
a.sender = tx_match;
a.username = "peer".to_string();
a.filename = "song.mp3".to_string();
let mut b = make_download(2, DownloadStatus::Queued);
b.sender = tx_other_user;
b.username = "other".to_string();
b.filename = "song.mp3".to_string();
let mut c = make_download(3, DownloadStatus::Queued);
c.sender = tx_other_file;
c.username = "peer".to_string();
c.filename = "different.mp3".to_string();
store.add(a);
store.add(b);
store.add(c);
let tokens = collect_failed_tokens(&store, "peer", Some("song.mp3"));
assert_eq!(tokens, vec![1]);
assert!(matches!(
rx_match.try_recv().unwrap(),
DownloadStatus::Failed(_)
));
}
}
+79
View File
@@ -0,0 +1,79 @@
use std::{error::Error, fmt};
/// Custom error type for the Soulseek download library
#[derive(Debug)]
pub enum SoulseekRs {
NetworkError(std::io::Error),
/// Authentication failed during login
AuthenticationFailed,
/// Error parsing messages or data
ParseError(String),
/// Operation timed out
Timeout,
/// Connection was closed unexpectedly
ConnectionClosed,
/// Invalid message format or content
InvalidMessage(String),
/// Server not connected
NotConnected,
/// Compression/decompression error
CompressionError(String),
/// A lock was poisoned by a panic in another thread
LockPoisoned,
}
impl fmt::Display for SoulseekRs {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NetworkError(err) => {
write!(f, "Network error: {err}")
}
Self::AuthenticationFailed => {
write!(f, "Authentication failed")
}
Self::ParseError(msg) => write!(f, "Parse error: {msg}"),
Self::Timeout => write!(f, "Operation timed out"),
Self::ConnectionClosed => write!(f, "Connection closed"),
Self::InvalidMessage(msg) => {
write!(f, "Invalid message: {msg}")
}
Self::NotConnected => write!(f, "Not connected to server"),
Self::CompressionError(msg) => {
write!(f, "Compression error: {msg}")
}
Self::LockPoisoned => {
write!(f, "Lock poisoned by panicking thread")
}
}
}
}
impl Error for SoulseekRs {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::NetworkError(err) => Some(err),
_ => None,
}
}
}
impl From<std::io::Error> for SoulseekRs {
fn from(err: std::io::Error) -> Self {
Self::NetworkError(err)
}
}
impl From<std::num::ParseIntError> for SoulseekRs {
fn from(err: std::num::ParseIntError) -> Self {
Self::ParseError(format!("Integer parse error: {err}"))
}
}
impl From<String> for SoulseekRs {
fn from(err: String) -> Self {
Self::CompressionError(err)
}
}
/// Result type alias for the Soulseek library
pub type Result<T> = std::result::Result<T, SoulseekRs>;
+44
View File
@@ -0,0 +1,44 @@
// The README is the crate's front page, and `cargo test --doc` compiles every
// example in it, so a signature change cannot leave the advertised usage stale.
#![doc = include_str!("../README.md")]
// The public surface is the client, its types, the wire codec and the share
// index. The actors, dispatcher, peer plumbing and download store are how the
// client is built, not what a host programs against, so they stay inside the
// crate and a change to them is not a change to the library's API.
pub(crate) mod actor;
pub mod client;
pub(crate) mod dispatcher;
pub(crate) mod download_store;
pub mod error;
pub mod message;
pub(crate) mod peer;
pub mod shares;
pub mod types;
#[macro_use]
pub mod utils;
// Prelude module for commonly used items
pub mod prelude {
pub use crate::actor::server_actor::PeerAddress;
pub use crate::types::{
ClientVersion, DownloadStatus, File, Recommendation, RoomEvent,
RoomInfo, RoomTicker, Search, SearchResult, SimilarUser, Transfer,
UploadInfo, UploadStatus, UserInfo, UserInterests, UserPresence,
UserStats, UserStatus,
};
pub use crate::{debug, error, info, trace, warn};
}
// Re-export commonly used types
pub use actor::server_actor::{PeerAddress, UserMessage};
pub use client::{CancelHandle, Client, ClientSettings};
pub use error::{Result, SoulseekRs};
pub use message::peer::{SharedDirectory, SharedFileEntry};
pub use peer::{ConnectionType, ParseConnectionTypeError};
pub use types::{
ClientVersion, DownloadStatus, File, Recommendation, RoomEvent, RoomInfo,
RoomTicker, Search, SearchResult, SessionLoss, SimilarUser, Transfer,
UploadInfo, UploadStatus, UserInfo, UserInterests, UserPresence, UserStats,
UserStatus,
};
+249
View File
@@ -0,0 +1,249 @@
//! The distributed search network's wire format.
//!
//! Frames carry a one-byte code, unlike the four-byte server and peer codes,
//! and a leaf only ever reads them: 3 Search, 4 BranchLevel, 5 BranchRoot,
//! 93 EmbeddedMessage.
use crate::message::Message;
use crate::message::server::MessageFactory;
/// A search relayed through the tree carries this in its first field; anything
/// else is a frame we do not understand and must not answer.
const SEARCH_IDENTIFIER: u32 = 49;
/// What a parent sends a leaf.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Distributed {
Search {
username: String,
token: u32,
query: String,
},
BranchLevel(i32),
BranchRoot(String),
}
/// Decode one framed distributed message (length prefix included), or `None`
/// for a ping, an unknown code or a truncated frame.
#[must_use]
pub fn parse(message: &mut Message) -> Option<Distributed> {
if message.get_size() < 5 {
return None;
}
message.set_pointer(5);
parse_at(message, message.get_init_code())
}
/// Decode the body that follows a one-byte `code`, the pointer already past
/// it. A code-93 envelope is unwrapped exactly once, as Nicotine+ does: an
/// envelope inside an envelope is nobody's search.
#[must_use]
pub fn parse_at(message: &mut Message, code: u8) -> Option<Distributed> {
let code = if code == 93 {
message.read_int8()
} else {
code
};
match code {
3 => {
if remaining(message) < 4
|| message.read_int32() != SEARCH_IDENTIFIER
{
return None;
}
let username = message.read_string();
if remaining(message) < 4 {
return None;
}
let token = message.read_int32();
let query = message.read_string();
(!username.is_empty() && !query.is_empty()).then_some(
Distributed::Search {
username,
token,
query,
},
)
}
4 => (remaining(message) >= 4)
.then(|| Distributed::BranchLevel(message.read_int32() as i32)),
5 => {
let root = message.read_string();
(!root.is_empty()).then_some(Distributed::BranchRoot(root))
}
_ => None,
}
}
const fn remaining(message: &mut Message) -> usize {
message.get_size().saturating_sub(message.get_pointer())
}
/// A distributed frame with the one-byte `code`: what a parent (or a test
/// standing in for one) sends down the tree.
fn framed(code: u8, body: impl FnOnce(&mut Message)) -> Message {
let mut message = Message::new();
message.write_int8(code);
body(&mut message);
message
}
#[must_use]
pub fn build_search(username: &str, token: u32, query: &str) -> Message {
framed(3, |m| {
m.write_int32(SEARCH_IDENTIFIER)
.write_string(username)
.write_int32(token)
.write_string(query);
})
}
#[must_use]
pub fn build_branch_level(level: i32) -> Message {
framed(4, |m| {
m.write_int32(level as u32);
})
}
/// What we tell the server about our place in the tree: the four messages
/// Nicotine+ sends together whenever it changes.
#[must_use]
pub fn stance(
root: &str,
level: u32,
has_parent: bool,
accept_children: bool,
) -> Vec<Message> {
vec![
MessageFactory::build_have_no_parent(!has_parent),
MessageFactory::build_branch_root(root),
MessageFactory::build_branch_level(level),
MessageFactory::build_accept_children(accept_children),
]
}
#[must_use]
pub fn build_branch_root(root: &str) -> Message {
framed(5, |m| {
m.write_string(root);
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_distributed_search_carries_who_asked_and_what_for() {
let frame = build_search("seeker", 77, "aphex twin");
let mut message = Message::new_with_data(frame.get_buffer());
assert_eq!(
parse(&mut message),
Some(Distributed::Search {
username: "seeker".to_string(),
token: 77,
query: "aphex twin".to_string(),
})
);
}
#[test]
fn a_search_with_the_wrong_identifier_is_dropped() {
let mut body = Message::new();
body.write_int8(3)
.write_int32(7)
.write_string("seeker")
.write_int32(1)
.write_string("q");
let mut message = Message::new_with_data(body.get_buffer());
assert_eq!(parse(&mut message), None);
}
#[test]
fn branch_level_and_root_frames_parse() {
let mut level =
Message::new_with_data(build_branch_level(2).get_buffer());
assert_eq!(parse(&mut level), Some(Distributed::BranchLevel(2)));
let mut root =
Message::new_with_data(build_branch_root("rooty").get_buffer());
assert_eq!(
parse(&mut root),
Some(Distributed::BranchRoot("rooty".to_string()))
);
}
#[test]
fn an_embedded_search_unwraps_to_the_search_itself() {
let inner = build_search("seeker", 5, "q").get_data();
let mut body = Message::new();
body.write_int8(93).write_raw_bytes(inner);
let mut message = Message::new_with_data(body.get_buffer());
assert!(matches!(
parse(&mut message),
Some(Distributed::Search { token: 5, .. })
));
}
#[test]
fn unknown_or_truncated_frames_parse_to_nothing() {
let mut ping =
Message::new_with_data(Message::new().write_int8(0).get_buffer());
assert_eq!(parse(&mut ping), None);
let mut short = Message::new_with_data(vec![1, 0, 0, 0, 3]);
assert_eq!(parse(&mut short), None);
let mut level = Message::new_with_data(vec![3, 0, 0, 0, 4, 1, 0]);
assert_eq!(parse(&mut level), None, "a level needs four bytes");
let mut root =
Message::new_with_data(build_branch_root("").get_buffer());
assert_eq!(parse(&mut root), None, "an empty root is no root");
let mut blank =
Message::new_with_data(build_search("seeker", 1, "").get_buffer());
assert_eq!(parse(&mut blank), None, "an empty query is no search");
}
// An envelope inside an envelope is unwrapped once and found empty; a
// frame that is nothing but envelopes must not recurse its way off the
// stack.
#[test]
fn a_nested_envelope_is_not_unwrapped_twice() {
let mut nested = Message::new();
nested.write_int8(93).write_raw_bytes(
Message::new()
.write_int8(93)
.write_raw_bytes(build_search("seeker", 5, "q").get_data())
.get_data(),
);
let mut message = Message::new_with_data(nested.get_buffer());
assert_eq!(parse(&mut message), None);
let mut onions = Message::new_with_data(
Message::new()
.write_raw_bytes(vec![93u8; 64 * 1024])
.get_buffer(),
);
assert_eq!(parse(&mut onions), None);
}
// The frame as the protocol documents it, byte for byte: code 3, the
// constant 49, then user, token and query.
#[test]
fn a_search_frame_matches_the_documented_layout() {
let mut bytes = vec![3u8, 49, 0, 0, 0];
bytes.extend(2u32.to_le_bytes());
bytes.extend(b"me");
bytes.extend(9u32.to_le_bytes());
bytes.extend(1u32.to_le_bytes());
bytes.extend(b"q");
let mut framed = (bytes.len() as u32).to_le_bytes().to_vec();
framed.extend(&bytes);
assert_eq!(build_search("me", 9, "q").get_buffer(), framed);
assert_eq!(
parse(&mut Message::new_with_data(framed)),
Some(Distributed::Search {
username: "me".to_string(),
token: 9,
query: "q".to_string()
})
);
}
}
+42
View File
@@ -0,0 +1,42 @@
use std::collections::HashMap;
use crate::message::Message;
use std::sync::mpsc::Sender;
pub trait MessageHandler<Op>: Send {
fn get_code(&self) -> u32;
fn handle(&self, message: &mut Message, sender: Sender<Op>);
}
pub struct Handlers<Op> {
handlers: HashMap<u32, Box<dyn MessageHandler<Op> + Send>>,
}
impl<Op> Default for Handlers<Op> {
fn default() -> Self {
Self::new()
}
}
impl<Op> Handlers<Op> {
#[must_use]
pub fn new() -> Self {
Self {
handlers: HashMap::new(),
}
}
pub fn register_handler<H>(&mut self, handler: H) -> &mut Self
where
H: 'static + MessageHandler<Op> + Send + Sync,
{
self.handlers.insert(handler.get_code(), Box::new(handler));
self
}
#[must_use]
pub fn get_handler(
&self,
code: u32,
) -> Option<&(dyn MessageHandler<Op> + Send)> {
self.handlers.get(&code).map(|v| &**v)
}
}
@@ -0,0 +1,158 @@
use std::io::{self, Read};
use std::{collections::VecDeque, net::TcpStream};
use crate::message::Message;
// Soulseek messages are length-prefixed (u32 LE size, then payload). TCP gives us
// arbitrary-sized chunks, so we accumulate into a buffer and only emit a Message
// once size + 4 bytes are available.
pub struct MessageReader {
buffer: VecDeque<u8>,
}
impl Default for MessageReader {
fn default() -> Self {
Self::new()
}
}
impl MessageReader {
#[must_use]
pub const fn new() -> Self {
Self {
buffer: VecDeque::new(),
}
}
#[cfg(test)]
#[must_use]
pub fn new_with_buffer(buffer: Vec<u8>) -> Self {
Self {
buffer: buffer.into(),
}
}
pub fn read_from_socket(
&mut self,
stream: &mut TcpStream,
) -> io::Result<()> {
let mut temp_buffer = [0; 1024]; // Temporary buffer for reading from the socket
let bytes_read = stream.read(&mut temp_buffer)?;
// On a TCP socket a zero-byte read is the other end hanging up. Passing
// that back as success left the caller polling a dead socket forever,
// which is how a server that closes cleanly went unnoticed.
if bytes_read == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"the remote end closed the connection",
));
}
// Add the read bytes to the internal buffer
self.buffer.extend(&temp_buffer[..bytes_read]);
Ok(())
}
#[must_use]
pub fn buffer_len(&self) -> usize {
self.buffer.len()
}
pub fn get_buffer(&mut self) -> Vec<u8> {
self.buffer.drain(..).collect()
}
pub fn extract_message(&mut self) -> io::Result<Option<Message>> {
let bytes_read = self.buffer.len();
if bytes_read < 4 {
return Ok(None);
}
let message_size = u32::from_le_bytes([
self.buffer[0],
self.buffer[1],
self.buffer[2],
self.buffer[3],
]) as usize;
let total_size = message_size + 4;
if bytes_read < total_size {
return Ok(None);
}
let message_buffer: Vec<u8> = self.buffer.drain(..total_size).collect();
Ok(Some(Message::new_with_data(message_buffer)))
}
}
#[cfg(test)]
mod tests {
use crate::message::MessageReader;
#[test]
fn test_extract_message() {
let buffer: Vec<u8> = [
8, 0, 0, 0, 117, 115, 101, 114, 110, 97, 109, 101, 8, 0, 0, 0, 112,
97, 115, 115, 119, 111, 114, 100, 160, 0, 0, 0, 32, 0, 0, 0, 100,
53, 49, 99, 57, 97, 55, 101, 57, 51, 53, 51, 55, 52, 54, 97, 54,
48, 50, 48, 102, 57, 54, 48, 50, 100, 52, 53, 50, 57, 50, 57, 17,
0, 0, 0,
]
.to_vec();
let mut buffered_reader = MessageReader::new_with_buffer(buffer);
let mut message = buffered_reader.extract_message().unwrap().unwrap();
assert_eq!(
message.get_data(),
vec![8, 0, 0, 0, 117, 115, 101, 114, 110, 97, 109, 101]
);
assert_eq!(message.read_string(), "username");
}
#[test]
fn test_extract_message_incomplete_message() {
let incomplete_buffer = vec![1, 2, 3];
let mut buffered_reader =
MessageReader::new_with_buffer(incomplete_buffer);
let result = buffered_reader.extract_message();
assert_eq!(None, result.unwrap());
let rest: Vec<u8> = buffered_reader
.buffer
.drain(..buffered_reader.buffer.len())
.collect();
assert!(buffered_reader.buffer.is_empty());
assert_eq!(vec![1, 2, 3], rest);
}
/// A hangup has to reach the caller as an error. Reported as success it
/// leaves the actors polling a socket that will never speak again, which is
/// how a server that closed cleanly went unnoticed.
#[test]
fn a_closed_connection_reads_as_an_error_not_as_success() {
use std::io::Write;
use std::net::{TcpListener, TcpStream};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let mut ours = TcpStream::connect(addr).unwrap();
let (mut theirs, _) = listener.accept().unwrap();
theirs.write_all(&[1, 2, 3]).unwrap();
theirs.flush().unwrap();
drop(theirs);
let mut reader = MessageReader::new();
// Whatever was sent before the close still arrives.
while reader.buffer_len() < 3 {
reader.read_from_socket(&mut ours).unwrap();
}
let error = reader
.read_from_socket(&mut ours)
.expect_err("a closed connection is not a successful read");
assert_eq!(error.kind(), std::io::ErrorKind::UnexpectedEof);
}
}
+448
View File
@@ -0,0 +1,448 @@
pub use crate::{debug, error, info, trace, warn};
pub mod distributed;
pub mod handlers;
mod message_reader;
pub mod peer;
pub mod server;
pub use handlers::{Handlers, MessageHandler};
pub use message_reader::MessageReader;
use std::str;
#[derive(Debug, PartialEq, Eq)]
#[allow(dead_code)]
pub enum MessageType {
Server,
Peer,
PeerInit,
Distributed,
}
#[derive(Debug)]
pub struct Error(String);
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for Error {}
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct Message {
data: Vec<u8>,
pointer: usize,
}
impl Default for Message {
fn default() -> Self {
Self::new()
}
}
impl Message {
#[must_use]
pub const fn new() -> Self {
Self {
data: Vec::new(),
pointer: 0,
}
}
#[must_use]
pub fn get_message_code(&self) -> u32 {
if self.data.len() < 8 {
return 0;
}
self.data[4..8].try_into().map_or(0, u32::from_le_bytes)
}
/// The one-byte code of a peer init frame (PierceFirewall 0, PeerInit 1),
/// the only messages whose code is not four bytes wide.
#[must_use]
pub fn get_init_code(&self) -> u8 {
self.data.get(4).copied().unwrap_or(0)
}
#[must_use]
pub const fn new_with_data(data: Vec<u8>) -> Self {
Self { data, pointer: 0 }
}
pub const fn set_pointer(&mut self, pointer: usize) {
self.pointer = pointer;
}
pub const fn get_pointer(&mut self) -> usize {
self.pointer
}
pub const fn get_size(&mut self) -> usize {
self.data.len()
}
#[must_use]
pub fn get_data(&self) -> Vec<u8> {
self.data.clone()
}
#[must_use]
pub fn get_slice(&self, from: usize, to: usize) -> Vec<u8> {
self.data[from..to].to_vec()
}
/// gets buffer with the message length prepended
#[must_use]
pub fn get_buffer(&self) -> Vec<u8> {
let mut b = vec![0u8; 4];
let length = self.data.len() as u32;
b[0..4].copy_from_slice(&length.to_le_bytes());
let mut combined = b;
combined.extend(&self.data);
combined
}
pub fn read_string(&mut self) -> String {
if self.pointer + 4 > self.data.len() {
self.pointer = self.data.len();
return String::new();
}
let size = u32::from_le_bytes([
self.data[self.pointer],
self.data[self.pointer + 1],
self.data[self.pointer + 2],
self.data[self.pointer + 3],
]) as usize;
self.pointer += 4;
// A truncated / malformed length field from an untrusted peer must not
// slice past the buffer end; treat it as an empty string and consume
// the rest of the message.
if self.pointer + size > self.data.len() {
self.pointer = self.data.len();
return String::new();
}
let data = &self.data[self.pointer..self.pointer + size];
self.pointer += size;
match String::from_utf8(data.to_vec()) {
Ok(s) => s,
Err(_) => data.iter().map(|&b| b as char).collect::<String>(),
}
}
pub fn read_int8(&mut self) -> u8 {
if self.pointer >= self.data.len() {
return 0;
}
let val = self.data[self.pointer];
self.pointer += 1;
val
}
#[allow(dead_code)]
pub fn read_int64(&mut self) -> u64 {
if self.pointer + 8 > self.data.len() {
return 0;
}
let val = u64::from_le_bytes([
self.data[self.pointer],
self.data[self.pointer + 1],
self.data[self.pointer + 2],
self.data[self.pointer + 3],
self.data[self.pointer + 4],
self.data[self.pointer + 5],
self.data[self.pointer + 6],
self.data[self.pointer + 7],
]);
self.pointer += 8;
val
}
pub fn read_int32(&mut self) -> u32 {
if self.pointer + 4 > self.data.len() {
return 0;
}
let val = u32::from_le_bytes([
self.data[self.pointer],
self.data[self.pointer + 1],
self.data[self.pointer + 2],
self.data[self.pointer + 3],
]);
self.pointer += 4;
val
}
pub fn read_bool(&mut self) -> bool {
if self.pointer >= self.data.len() {
return false;
}
let val = self.data[self.pointer] == 1;
self.pointer += 1;
val
}
pub fn write_string(&mut self, val: &str) -> &mut Self {
let length = val.len() as u32;
self.data.extend_from_slice(&length.to_le_bytes());
self.data.extend_from_slice(val.as_bytes());
self
}
pub fn write_int8(&mut self, value: u8) -> &mut Self {
self.data.extend_from_slice(&value.to_le_bytes());
self
}
pub fn write_int32(&mut self, value: u32) -> &mut Self {
self.data.extend_from_slice(&value.to_le_bytes());
self
}
#[allow(dead_code)]
pub fn write_int64(&mut self, value: u64) -> &mut Self {
self.data.extend_from_slice(&value.to_le_bytes());
self
}
pub fn write_raw_bytes(&mut self, value: Vec<u8>) -> &mut Self {
self.data.extend_from_slice(&value);
self
}
pub fn write_bool(&mut self, value: bool) -> &mut Self {
self.data.push(u8::from(value));
self
}
pub fn get_message_name(
&self,
msg_type: MessageType,
code: u32,
) -> Result<&str, Error> {
match msg_type {
MessageType::Server => match code {
1 => Ok("Login"),
2 => Ok("SetWaitPort"),
3 => Ok("GetPeerAddress"),
5 => Ok("WatchUser"),
6 => Ok("UnwatchUser"),
7 => Ok("GetUserStatus"),
13 => Ok("SayChatroom"),
14 => Ok("JoinRoom"),
15 => Ok("LeaveRoom"),
18 => Ok("ConnectToPeer"),
22 => Ok("MessageUser"),
23 => Ok("MessageAcked"),
26 => Ok("FileSearch"),
28 => Ok("SetStatus"),
32 => Ok("ServerPing"),
35 => Ok("SharedFoldersFiles"),
36 => Ok("GetUserStats"),
41 => Ok("Relogged"),
42 => Ok("UserSearch"),
64 => Ok("RoomList"),
69 => Ok("PrivilegedUsers"),
71 => Ok("HaveNoParent"),
83 => Ok("ParentMinSpeed"),
84 => Ok("ParentSpeedRatio"),
92 => Ok("CheckPrivileges"),
93 => Ok("EmbeddedMessage"),
100 => Ok("AcceptChildren"),
102 => Ok("PossibleParents"),
104 => Ok("WishlistInterval"),
121 => Ok("SendUploadSpeed"),
160 => Ok("ExcludedSearchPhrases"),
1001 => Ok("CantConnectToPeer"),
_ => Err(Error(format!("Unknown server message code: {code}"))),
},
MessageType::PeerInit => match code {
0 => Ok("PierceFireWall"),
1 => Ok("PeerInit"),
_ => Err(Error(format!(
"Unknown peer init message code: {code}"
))),
},
MessageType::Peer => match code {
1 => Ok("PeerInit"),
4 => Ok("GetShareFileList"),
5 => Ok("SharedFileListResponse"),
9 => Ok("FileSearchResponse"),
15 => Ok("UserInfoRequest"),
16 => Ok("UserInfoResponse"),
36 => Ok("FolderContentsRequest"),
37 => Ok("FolderContentsResponse"),
40 => Ok("TransferRequest"),
41 => Ok("TransferResponse"),
43 => Ok("QueueUpload"),
44 => Ok("PlaceInQueueResponse"),
46 => Ok("UploadFailed"),
50 => Ok("UploadDenied"),
51 => Ok("PlaceInQueueRequest"),
_ => Err(Error(format!("Unknown peer message code: {code}"))),
},
MessageType::Distributed => match code {
3 => Ok("SearchRequest"),
4 => Ok("BranchLevel"),
5 => Ok("BranchRoot"),
93 => Ok("EmbeddedMessage"),
_ => Err(Error(format!(
"Unknown distributed message code: {code}"
))),
},
}
}
}
/// Build a received message the way the dispatcher hands one to a handler:
/// 8 bytes of frame header, then the fields, with the pointer parked at the
/// start of the payload.
#[cfg(test)]
pub fn framed(build: impl FnOnce(&mut Message)) -> Message {
let mut message = Message::new();
message.write_raw_bytes(vec![0u8; 8]);
build(&mut message);
message.set_pointer(8);
message
}
#[test]
fn test_get_buffer() {
let message = Message::new_with_data(
[
26, 0, 0, 0, 219, 178, 47, 28, 11, 0, 0, 0, 116, 104, 101, 32, 119,
101, 101, 107, 101, 110, 100,
]
.to_vec(),
);
assert_eq!(
message.get_buffer(),
[
23, 0, 0, 0, 26, 0, 0, 0, 219, 178, 47, 28, 11, 0, 0, 0, 116, 104,
101, 32, 119, 101, 101, 107, 101, 110, 100,
]
.to_vec()
);
let message = Message::new_with_data(
[
1, 0, 0, 0, 20, 0, 0, 0, 105, 110, 115, 97, 110, 101, 95, 105, 110,
95, 116, 104, 101, 95, 98, 114, 97, 105, 110, 50, 8, 0, 0, 0, 49,
51, 51, 55, 53, 49, 51, 55, 160, 0, 0, 0, 32, 0, 0, 0, 50, 101,
100, 102, 53, 49, 100, 48, 51, 55, 57, 52, 51, 55, 56, 102, 56, 98,
98, 54, 51, 49, 48, 100, 52, 54, 48, 99, 50, 50, 98, 49, 17, 0, 0,
0,
]
.to_vec(),
);
assert_eq!(
message.get_buffer(),
[
84, 0, 0, 0, 1, 0, 0, 0, 20, 0, 0, 0, 105, 110, 115, 97, 110, 101,
95, 105, 110, 95, 116, 104, 101, 95, 98, 114, 97, 105, 110, 50, 8,
0, 0, 0, 49, 51, 51, 55, 53, 49, 51, 55, 160, 0, 0, 0, 32, 0, 0, 0,
50, 101, 100, 102, 53, 49, 100, 48, 51, 55, 57, 52, 51, 55, 56,
102, 56, 98, 98, 54, 51, 49, 48, 100, 52, 54, 48, 99, 50, 50, 98,
49, 17, 0, 0, 0,
]
.to_vec()
);
}
#[test]
fn test_read_string() {
let data = vec![
5, 0, 0, 0, // size = 5
72, 101, 108, 108, 111, // "Hello"
];
let mut test_data = Message::new_with_data(data);
assert_eq!(test_data.read_string(), "Hello");
}
// Robustness against malformed / truncated network input. A remote server or
// peer is untrusted and can send a message whose declared field lengths exceed
// the bytes actually present; parsing must not panic (which would unwind out of
// the actor job and permanently kill a thread-pool worker).
#[test]
fn read_string_with_oversized_length_does_not_panic() {
// size = 0xFFFFFFFF but no payload bytes follow.
let mut msg = Message::new_with_data(vec![255, 255, 255, 255]);
assert_eq!(msg.read_string(), "");
}
#[test]
fn read_string_with_truncated_payload_does_not_panic() {
// size = 10 but only 2 payload bytes present.
let mut msg = Message::new_with_data(vec![10, 0, 0, 0, 65, 66]);
assert_eq!(msg.read_string(), "");
}
#[test]
fn read_string_with_missing_length_prefix_does_not_panic() {
// Only 2 bytes: not even a full 4-byte length prefix.
let mut msg = Message::new_with_data(vec![1, 2]);
assert_eq!(msg.read_string(), "");
}
#[test]
fn get_message_code_on_short_message_does_not_panic() {
// A zero-length frame drains to exactly the 4-byte length prefix, leaving
// no room for a code after it.
let msg = Message::new_with_data(vec![0, 0, 0, 0]);
assert_eq!(msg.get_message_code(), 0);
assert_eq!(msg.get_init_code(), 0);
}
#[test]
fn an_init_frame_reads_its_one_byte_code() {
let init = Message::new_with_data(
Message::new().write_int8(1).write_string("ab").get_buffer(),
);
assert_eq!(init.get_init_code(), 1);
assert_ne!(init.get_message_code(), 1);
}
#[test]
fn read_int_primitives_are_bounds_checked() {
let mut msg = Message::new_with_data(vec![1, 2]);
assert_eq!(msg.read_int64(), 0);
let mut msg = Message::new_with_data(vec![]);
assert_eq!(msg.read_int8(), 0);
assert!(!msg.read_bool());
}
#[test]
fn test_read_string_2() {
let data = vec![
50, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 81, 170, 162, 77, 32, 0, 0, 0,
101, 102, 99, 97, 51, 52, 102, 99, 52, 99, 56, 98, 101, 56, 98, 55,
101, 102, 51, 56, 97, 102, 50, 54, 50, 52, 100, 101, 53, 52, 54, 52, 0,
];
let mut test_data = Message::new_with_data(data);
test_data.set_pointer(9);
assert_eq!(test_data.read_string(), "");
}
#[test]
fn test_read_string_invalid_utf82() {
let data = [
128, 0, 0, 0, 103, 58, 92, 100, 105, 115, 107, 52, 92, 115, 101, 109,
105, 114, 97, 109, 105, 115, 92, 99, 104, 105, 108, 108, 44, 32, 100,
117, 98, 44, 32, 100, 111, 119, 110, 98, 101, 97, 116, 44, 32, 97, 109,
98, 105, 101, 110, 116, 92, 118, 97, 114, 105, 111, 117, 115, 32, 97,
114, 116, 105, 115, 116, 115, 92, 112, 111, 116, 116, 32, 104, 101, 97,
100, 122, 32, 45, 32, 100, 111, 112, 101, 32, 115, 109, 111, 107, 105,
110, 180, 98, 101, 97, 116, 115, 32, 107, 98, 115, 32, 49, 50, 56, 32,
49, 57, 57, 54, 92, 48, 53, 32, 45, 32, 98, 108, 117, 101, 32, 116,
114, 97, 105, 110, 46, 109, 112, 51,
]
.to_vec();
let mut test_data = Message::new_with_data(data);
let str = test_data.read_string();
assert_eq!(
str,
r"g:\disk4\semiramis\chill, dub, downbeat, ambient\various artists\pott headz - dope smokin´beats kbs 128 1996\05 - blue train.mp3"
);
}
@@ -0,0 +1,153 @@
use crate::message::{Message, MessageHandler};
use crate::peer::PeerMessage;
use crate::types::SearchResult;
use crate::utils::zlib::deflate;
use std::sync::mpsc::Sender;
/// A borrowed view of one file to advertise in a search response, kept
/// independent of the shares module so the message layer stays decoupled.
pub struct FileEntry<'a> {
pub name: &'a str,
pub size: u64,
pub attribs: &'a [(u32, u32)],
}
/// Build a `FileSearchResponse` (peer code 9): the zlib-compressed reply we send
/// to a peer whose search matched our shared files. The payload is the exact
/// inverse of [`SearchResult::new_from_message`].
#[must_use]
pub fn build_file_search_response(
own_username: &str,
token: u32,
files: &[FileEntry],
slots: u8,
speed: u32,
queue_length: u32,
) -> Message {
let mut payload = Message::new();
payload
.write_string(own_username)
.write_int32(token)
.write_int32(files.len() as u32);
for file in files {
payload
.write_int8(1)
.write_string(file.name)
.write_int64(file.size)
.write_string("") // extension (decoder reads and discards)
.write_int32(file.attribs.len() as u32);
for &(code, value) in file.attribs {
payload.write_int32(code).write_int32(value);
}
}
payload
.write_int8(slots)
.write_int32(speed)
.write_int32(queue_length);
let compressed = deflate(&payload.get_data());
Message::new()
.write_int32(9)
.write_raw_bytes(compressed)
.clone()
}
pub struct FileSearchResponse;
impl MessageHandler<PeerMessage> for FileSearchResponse {
fn get_code(&self) -> u32 {
9
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
// Skip malformed search results
let Ok(file_search) = SearchResult::new_from_message(message) else {
return;
};
let _ = sender.send(PeerMessage::FileSearchResult(file_search));
}
}
#[test]
fn test_new_from_message() {
let data: Vec<u8> = [
229, 0, 0, 0, 9, 0, 0, 0, 120, 156, 99, 103, 96, 96, 72, 201, 79, 201,
76, 79, 204, 203, 213, 158, 98, 194, 4, 228, 50, 250, 3, 9, 7, 135,
162, 156, 148, 194, 188, 152, 228, 252, 220, 130, 156, 212, 146, 212,
24, 231, 196, 188, 228, 204, 252, 188, 212, 226, 152, 144, 162, 210,
226, 226, 212, 28, 93, 67, 75, 115, 75, 93, 119, 160, 144, 130, 91,
126, 145, 66, 72, 70, 170, 66, 120, 106, 106, 118, 106, 94, 138, 174,
161, 89, 82, 102, 137, 174, 137, 137, 142, 161, 119, 70, 149, 94, 90,
78, 98, 114, 203, 175, 243, 32, 163, 193, 128, 25, 100, 7, 16, 23, 0,
9, 22, 32, 237, 178, 134, 129, 129, 21, 72, 11, 128, 196, 243, 176,
217, 29, 156, 153, 151, 158, 147, 90, 12, 54, 95, 193, 216, 84, 193,
200, 192, 200, 36, 198, 45, 181, 168, 40, 53, 57, 91, 193, 37, 177, 60,
79, 71, 193, 55, 177, 44, 181, 40, 19, 200, 13, 78, 76, 42, 74, 85, 80,
83, 240, 75, 45, 7, 10, 38, 103, 100, 2, 221, 167, 139, 238, 66, 5, 13,
144, 17, 154, 96, 167, 173, 228, 215, 98, 68, 119, 218, 74, 6, 76, 167,
49, 60, 153, 202, 200, 160, 199, 128, 0, 0, 161, 99, 76, 142,
]
.to_vec();
let mut message = Message::new_with_data(data);
message.set_pointer(8);
let file_search = SearchResult::new_from_message(&mut message).unwrap();
assert_eq!(file_search.token, 882125677);
assert_eq!(file_search.files.len(), 2);
let file = &file_search.files[0];
assert_eq!(
file.name,
"@@rldqn\\complete\\Canciones\\Trussel-1979-Gone For The Weekend-16bit-44,1Khz.flac"
);
assert_eq!(file.username, "dodigan");
assert_eq!(file.size, 47184516);
}
#[test]
fn build_file_search_response_roundtrips_through_the_decoder() {
let files = [
FileEntry {
name: "music\\album\\song.mp3",
size: 47_184_516,
attribs: &[(1, 320), (4, 44100), (5, 16)],
},
FileEntry {
name: "b.flac",
size: 456,
attribs: &[],
},
];
let message = build_file_search_response("e2e_sharer", 42, &files, 1, 0, 0);
// Decode via the exact production decoder used for real peer responses:
// the dispatcher positions the pointer at 8 (past length + code).
let mut decoded = Message::new_with_data(message.get_buffer());
decoded.set_pointer(8);
let result = SearchResult::new_from_message(&mut decoded).unwrap();
assert_eq!(result.username, "e2e_sharer");
assert_eq!(result.token, 42);
assert_eq!(result.files.len(), 2);
assert_eq!(result.files[0].name, "music\\album\\song.mp3");
assert_eq!(result.files[0].size, 47_184_516);
assert_eq!(result.files[0].attribs.get(&1), Some(&320));
assert_eq!(result.files[0].attribs.get(&4), Some(&44100));
assert_eq!(result.files[1].name, "b.flac");
assert_eq!(result.files[1].size, 456);
assert!(result.files[1].attribs.is_empty());
assert_eq!(result.slots, 1);
}
#[test]
fn the_trailer_carries_slots_speed_and_queue_length() {
let message = build_file_search_response("me", 1, &[], 0, 900, 7);
let mut body = Message::new_with_data(
crate::utils::zlib::inflate(&message.get_data()[4..]).unwrap(),
);
body.read_string();
body.read_int32();
assert_eq!(body.read_int32(), 0, "no files");
assert_eq!(
(body.read_int8(), body.read_int32(), body.read_int32()),
(0, 900, 7)
);
}
@@ -0,0 +1,112 @@
//! `FolderContentsRequest` (peer code 36) and its reply (code 37): one folder
//! of a share in the listing form code 5 uses, which is what "download
//! folder" in other clients asks for.
use crate::message::peer::SharedDirectory;
#[cfg(test)]
use crate::message::peer::SharedFileEntry;
use crate::message::peer::shared_file_list::{
decompress_body, read_directories, write_directories,
};
use crate::message::{Message, MessageHandler};
use crate::peer::PeerMessage;
use crate::utils::zlib::deflate;
use std::sync::mpsc::Sender;
pub struct FolderContentsRequest;
impl MessageHandler<PeerMessage> for FolderContentsRequest {
fn get_code(&self) -> u32 {
36
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let token = message.read_int32();
let folder = message.read_string();
let _ =
sender.send(PeerMessage::FolderContentsRequested { token, folder });
}
}
/// `FolderContentsResponse` (peer code 37): the reply to a folder we asked
/// for. The token is the one we quoted, so a client with two folders in
/// flight can tell the answers apart.
pub struct FolderContentsResponseHandler;
impl MessageHandler<PeerMessage> for FolderContentsResponseHandler {
fn get_code(&self) -> u32 {
37
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let Some((token, folder, directories)) = parse_folder_contents(message)
else {
return;
};
let _ = sender.send(PeerMessage::FolderContentsReceived {
token,
folder,
directories,
});
}
}
/// Build a `FolderContentsRequest` (peer code 36).
#[must_use]
pub fn build_folder_contents_request(token: u32, folder: &str) -> Message {
Message::new()
.write_int32(36)
.write_int32(token)
.write_string(folder)
.clone()
}
/// Build a `FolderContentsResponse` (peer code 37).
#[must_use]
pub fn build_folder_contents(
token: u32,
folder: &str,
dirs: &[SharedDirectory],
) -> Message {
let mut payload = Message::new();
payload.write_int32(token).write_string(folder);
write_directories(&mut payload, dirs);
Message::new()
.write_int32(37)
.write_raw_bytes(deflate(&payload.get_data()))
.clone()
}
/// Parse a `FolderContentsResponse` positioned at its compressed blob, or
/// `None` when it does not inflate.
#[must_use]
pub fn parse_folder_contents(
message: &mut Message,
) -> Option<(u32, String, Vec<SharedDirectory>)> {
let mut body = decompress_body(message, crate::utils::zlib::MAX_INFLATED)?;
let token = body.read_int32();
let folder = body.read_string();
Some((token, folder, read_directories(&mut body)))
}
#[test]
fn a_folder_listing_round_trips() {
let dirs = vec![SharedDirectory {
name: "music\\album".to_string(),
files: vec![
SharedFileEntry {
name: "one.flac".to_string(),
size: 40,
attributes: vec![(0, 992)],
},
SharedFileEntry {
name: "two.flac".to_string(),
size: 50,
attributes: Vec::new(),
},
],
}];
let built = build_folder_contents(7, "music\\album", &dirs);
let mut message = Message::new_with_data(built.get_data());
message.set_pointer(4);
assert_eq!(
parse_folder_contents(&mut message),
Some((7, "music\\album".to_string(), dirs))
);
}
@@ -0,0 +1,17 @@
use crate::{
message::{Message, MessageHandler},
peer::PeerMessage,
};
use std::sync::mpsc::Sender;
/// A peer asking to browse our shared files (peer code 4). The client (which
/// owns the shares) builds the real SharedFileListResponse in reply.
pub struct GetShareFileList;
impl MessageHandler<PeerMessage> for GetShareFileList {
fn get_code(&self) -> u32 {
4
}
fn handle(&self, _message: &mut Message, sender: Sender<PeerMessage>) {
let _ = sender.send(PeerMessage::ShareListRequested);
}
}
+40
View File
@@ -0,0 +1,40 @@
mod file_search_response;
mod folder_contents;
mod get_share_file_list;
mod peer_init;
mod place_in_queue_request;
mod place_in_queue_response;
mod queue_upload;
mod shared_file_list;
mod transfer_request;
mod transfer_response;
mod upload_denied;
mod upload_failed;
mod user_info;
// Re-export handlers
pub use file_search_response::{
FileEntry, FileSearchResponse, build_file_search_response,
};
pub use folder_contents::{
FolderContentsRequest, FolderContentsResponseHandler,
build_folder_contents, build_folder_contents_request,
parse_folder_contents,
};
pub use get_share_file_list::GetShareFileList;
pub use peer_init::PeerInit;
pub use place_in_queue_request::PlaceInQueueRequest;
pub use place_in_queue_response::PlaceInQueueResponse;
pub use queue_upload::QueueUploadHandler;
pub use shared_file_list::{
SharedDirectory, SharedFileEntry, SharedFileListResponseHandler,
build_shared_file_list, parse_shared_file_list,
};
pub use transfer_request::TransferRequest;
pub use transfer_response::TransferResponse;
pub use upload_denied::UploadDeniedHandler;
pub use upload_failed::UploadFailedHandler;
pub use user_info::{
PeerInfo, UserInfoRequest, UserInfoResponseHandler, build_user_info,
build_user_info_request,
};
@@ -0,0 +1,28 @@
use std::sync::mpsc::Sender;
use crate::{
message::{Message, MessageHandler},
peer::PeerMessage,
trace,
};
pub struct PeerInit;
impl MessageHandler<PeerMessage> for PeerInit {
fn get_code(&self) -> u32 {
1
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
message.set_pointer(4);
let _message_code = message.read_int8();
let username = message.read_string();
let connection_type = message.read_string();
let token = message.read_int32();
trace!(
"PeerInit: username: {}, connection_type: {}, token: {}",
username, connection_type, token
);
let _ = sender.send(PeerMessage::SetUsername(username));
}
}
@@ -0,0 +1,45 @@
use crate::{
message::{Message, MessageHandler},
peer::PeerMessage,
};
use std::sync::mpsc::Sender;
/// `PlaceInQueueRequest` (peer code 51): a peer asking where the file they
/// queued sits in our upload queue.
///
/// Worth answering rather than dropping: a downloader that gets no position
/// cannot tell a long queue from a client that has forgotten about it.
pub struct PlaceInQueueRequest;
impl MessageHandler<PeerMessage> for PlaceInQueueRequest {
fn get_code(&self) -> u32 {
51
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let filename = message.read_string();
let _ = sender.send(PeerMessage::IncomingPlaceInQueueRequest(filename));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
#[test]
fn the_asked_about_filename_is_forwarded() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("@@share\\track.mp3");
});
PlaceInQueueRequest.handle(&mut message, tx);
match rx.try_recv() {
Ok(PeerMessage::IncomingPlaceInQueueRequest(filename)) => {
assert_eq!(filename, "@@share\\track.mp3");
}
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -0,0 +1,21 @@
use crate::{
message::{Message, MessageHandler},
peer::PeerMessage,
};
use std::sync::mpsc::Sender;
pub struct PlaceInQueueResponse;
impl MessageHandler<PeerMessage> for PlaceInQueueResponse {
fn get_code(&self) -> u32 {
44
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let filename = message.read_string();
let place = message.read_int32();
let _ =
sender.send(PeerMessage::PlaceInQueueResponse { filename, place });
}
}
@@ -0,0 +1,19 @@
use crate::{
message::{Message, MessageHandler},
peer::PeerMessage,
};
use std::sync::mpsc::Sender;
/// A peer asking to download one of our shared files (peer code 43).
pub struct QueueUploadHandler;
impl MessageHandler<PeerMessage> for QueueUploadHandler {
fn get_code(&self) -> u32 {
43
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let filename = message.read_string();
let _ = sender.send(PeerMessage::IncomingQueueUpload(filename));
}
}
@@ -0,0 +1,221 @@
//! `SharedFileListResponse` (peer code 5): a peer's full shared-file listing,
//! sent in reply to `GetShareFileList` (code 4). The payload is zlib-compressed
//! and groups files by their virtual directory.
use crate::message::{Message, MessageHandler};
use crate::peer::PeerMessage;
use crate::utils::zlib::{MAX_INFLATED_LISTING, deflate, inflate_limited};
use std::sync::mpsc::Sender;
/// One file in a shared directory: its basename, size and the `(code, value)`
/// audio attributes the owner advertises for it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SharedFileEntry {
pub name: String,
pub size: u64,
pub attributes: Vec<(u32, u32)>,
}
impl SharedFileEntry {
/// The value of attribute `code` (0 bitrate, 1 duration, 2 VBR, 4 sample
/// rate, 5 bit depth), if the owner advertised it.
#[must_use]
pub fn attribute(&self, code: u32) -> Option<u32> {
self.attributes
.iter()
.find(|&&(c, _)| c == code)
.map(|&(_, v)| v)
}
}
/// One shared directory and the files directly in it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SharedDirectory {
pub name: String,
pub files: Vec<SharedFileEntry>,
}
/// Receives a peer's `SharedFileListResponse` (peer code 5) when browsing them.
pub struct SharedFileListResponseHandler;
impl MessageHandler<PeerMessage> for SharedFileListResponseHandler {
fn get_code(&self) -> u32 {
5
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let directories = parse_shared_file_list(message);
let _ = sender.send(PeerMessage::ShareListReceived(directories));
}
}
/// Build a `SharedFileListResponse` (peer code 5) from the directory listing.
#[must_use]
pub fn build_shared_file_list(dirs: &[SharedDirectory]) -> Message {
let mut payload = Message::new();
write_directories(&mut payload, dirs);
payload.write_int32(0); // unknown
payload.write_int32(0); // number of private directories
let compressed = deflate(&payload.get_data());
Message::new()
.write_int32(5)
.write_raw_bytes(compressed)
.clone()
}
/// Parse the (zlib-compressed) `SharedFileListResponse` payload. `message` must
/// be positioned at the compressed blob (the dispatcher sets pointer 8).
///
/// Returns an empty listing if the payload is malformed.
#[must_use]
pub fn parse_shared_file_list(message: &mut Message) -> Vec<SharedDirectory> {
// A whole share listing is the one payload that legitimately runs large.
decompress_body(message, MAX_INFLATED_LISTING)
.map_or_else(Vec::new, |mut body| read_directories(&mut body))
}
/// Write a directory listing in the form both code 5 and code 37 carry.
pub fn write_directories(payload: &mut Message, dirs: &[SharedDirectory]) {
payload.write_int32(dirs.len() as u32);
for dir in dirs {
payload
.write_string(&dir.name)
.write_int32(dir.files.len() as u32);
for file in &dir.files {
payload
.write_int8(1)
.write_string(&file.name)
.write_int64(file.size)
.write_string("") // extension
.write_int32(file.attributes.len() as u32);
for &(code, value) in &file.attributes {
payload.write_int32(code).write_int32(value);
}
}
}
}
/// Read back what [`write_directories`] wrote, stopping early when a hostile
/// count outruns the payload so a bogus length cannot spin into a huge
/// allocation loop.
pub fn read_directories(body: &mut Message) -> Vec<SharedDirectory> {
let dir_count = body.read_int32();
let mut dirs = Vec::new();
for _ in 0..dir_count {
if body.get_pointer() >= body.get_size() {
break;
}
let name = body.read_string();
let file_count = body.read_int32();
let mut files = Vec::new();
for _ in 0..file_count {
if body.get_pointer() >= body.get_size() {
break;
}
body.read_int8(); // code
let name = body.read_string();
let size = body.read_int64();
body.read_string(); // extension
let attr_count = body.read_int32();
let mut attributes = Vec::new();
for _ in 0..attr_count {
// Each attribute is two int32s (8 bytes); read_int32 does not
// advance past the end, so bound the loop explicitly.
if body.get_pointer() + 8 > body.get_size() {
break;
}
attributes.push((body.read_int32(), body.read_int32()));
}
files.push(SharedFileEntry {
name,
size,
attributes,
});
}
dirs.push(SharedDirectory { name, files });
}
dirs
}
/// Inflate a compressed peer payload positioned at the blob, refusing one
/// that expands past `max_out` — a peer we have never met chose these bytes.
pub fn decompress_body(
message: &mut Message,
max_out: usize,
) -> Option<Message> {
let pointer = message.get_pointer();
let size = message.get_size();
inflate_limited(&message.get_slice(pointer, size), max_out)
.ok()
.map(Message::new_with_data)
}
#[test]
fn hostile_dir_count_does_not_hang() {
// A compressed body claiming ~4 billion directories with no data must
// parse to empty promptly rather than looping into an OOM.
let compressed = crate::utils::zlib::deflate(&u32::MAX.to_le_bytes());
let mut message = crate::message::framed(|m| {
m.write_raw_bytes(compressed);
});
assert!(parse_shared_file_list(&mut message).is_empty());
}
#[test]
fn a_listing_carries_each_files_attributes() {
let dirs = vec![SharedDirectory {
name: "music\\album".to_string(),
files: vec![
SharedFileEntry {
name: "one.mp3".to_string(),
size: 40,
attributes: vec![(0, 320), (1, 187)],
},
SharedFileEntry {
name: "two.flac".to_string(),
size: 50,
attributes: Vec::new(),
},
],
}];
let mut message = crate::message::framed(|m| {
m.write_raw_bytes(
build_shared_file_list(&dirs).get_data()[4..].to_vec(),
);
});
assert_eq!(parse_shared_file_list(&mut message), dirs);
}
#[test]
fn shared_file_list_roundtrips() {
let dirs = vec![
SharedDirectory {
name: "music\\album".to_string(),
files: vec![
SharedFileEntry {
name: "song one.flac".to_string(),
size: 123,
attributes: vec![(1, 300), (4, 44_100), (5, 16)],
},
SharedFileEntry {
name: "song two.flac".to_string(),
size: 456,
attributes: Vec::new(),
},
],
},
SharedDirectory {
name: "music".to_string(),
files: vec![SharedFileEntry {
name: "top.mp3".to_string(),
size: 789,
attributes: Vec::new(),
}],
},
];
let message = build_shared_file_list(&dirs);
// Decode via the same offset the dispatcher would use.
let mut decoded = Message::new_with_data(message.get_buffer());
decoded.set_pointer(8);
assert_eq!(parse_shared_file_list(&mut decoded), dirs);
}
@@ -0,0 +1,18 @@
use crate::{
message::{Message, MessageHandler},
peer::PeerMessage,
types::Transfer,
};
use std::sync::mpsc::Sender;
pub struct TransferRequest;
impl MessageHandler<PeerMessage> for TransferRequest {
fn get_code(&self) -> u32 {
40
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let transfer = Transfer::new_from_message(message);
let _ = sender.send(PeerMessage::TransferRequest(transfer));
}
}
@@ -0,0 +1,25 @@
use crate::{
message::{Message, MessageHandler},
peer::PeerMessage,
};
use std::sync::mpsc::Sender;
pub struct TransferResponse;
impl MessageHandler<PeerMessage> for TransferResponse {
fn get_code(&self) -> u32 {
41
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let token = message.read_int32();
let allowed = message.read_int8();
let reason = (allowed == 0).then(|| message.read_string());
let _ = sender.send(PeerMessage::TransferResponse {
token,
allowed: allowed == 1,
reason,
});
}
}
@@ -0,0 +1,56 @@
use crate::info;
use crate::{
message::{Message, MessageHandler},
peer::PeerMessage,
};
use std::sync::mpsc::Sender;
pub struct UploadDeniedHandler;
impl MessageHandler<PeerMessage> for UploadDeniedHandler {
fn get_code(&self) -> u32 {
50
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let filename = message.read_string();
let reason = message.read_string();
info!("Upload denied for {}: {}", filename, reason);
if reason == "Queued" {
return;
}
let _ = sender.send(PeerMessage::UploadFailed(String::new(), filename));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
#[test]
fn a_queued_reason_is_not_a_failure() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("@@share\\gone.mp3").write_string("Queued");
});
UploadDeniedHandler.handle(&mut message, tx);
assert!(rx.try_recv().is_err());
}
#[test]
fn the_denied_filename_is_forwarded() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("@@share\\gone.mp3")
.write_string("File not shared.");
});
UploadDeniedHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(PeerMessage::UploadFailed(_, filename)) => {
assert_eq!(filename, "@@share\\gone.mp3");
}
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -0,0 +1,40 @@
use crate::info;
use crate::{
message::{Message, MessageHandler},
peer::PeerMessage,
};
use std::sync::mpsc::Sender;
pub struct UploadFailedHandler;
impl MessageHandler<PeerMessage> for UploadFailedHandler {
fn get_code(&self) -> u32 {
46
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let filename = message.read_string();
info!("Upload failed for {}", filename);
let _ = sender.send(PeerMessage::UploadFailed(String::new(), filename));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
#[test]
fn the_failed_filename_is_forwarded() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("@@share\\gone.mp3");
});
UploadFailedHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(PeerMessage::UploadFailed(_, filename)) => {
assert_eq!(filename, "@@share\\gone.mp3");
}
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -0,0 +1,181 @@
//! `UserInfoRequest` (peer code 15) and its reply (code 16): what a peer says
//! about itself when another user opens its profile.
use crate::message::{Message, MessageHandler};
use crate::peer::PeerMessage;
use std::sync::mpsc::Sender;
pub struct UserInfoRequest;
impl MessageHandler<PeerMessage> for UserInfoRequest {
fn get_code(&self) -> u32 {
15
}
fn handle(&self, _message: &mut Message, sender: Sender<PeerMessage>) {
let _ = sender.send(PeerMessage::UserInfoRequested);
}
}
/// Build a `UserInfoRequest` (peer code 15, no body): ask a peer what it says
/// about itself.
#[must_use]
pub fn build_user_info_request() -> Message {
Message::new().write_int32(15).clone()
}
/// What a peer says about itself, from its `UserInfoResponse` (code 16).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PeerInfo {
pub description: String,
/// Whether a profile picture came with it, and how many bytes it was. The
/// image itself is not kept: no caller has a use for it yet, and a peer
/// can make it arbitrarily large.
pub picture_bytes: Option<usize>,
/// Uploads the peer says it has done in total.
pub total_uploads: u32,
/// How many transfers are waiting in its queue.
pub queue_size: u32,
/// Whether it has a free upload slot right now.
pub slots_free: bool,
/// Whether it would let us queue anything at all. Absent from older
/// clients, which stop the message one field early.
pub upload_allowed: Option<u32>,
}
/// `UserInfoResponse` (peer code 16): a peer's answer about itself.
pub struct UserInfoResponseHandler;
impl MessageHandler<PeerMessage> for UserInfoResponseHandler {
fn get_code(&self) -> u32 {
16
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let description = message.read_string();
let has_picture = message.read_bool();
let picture_bytes = has_picture.then(|| {
let length = message.read_int32() as usize;
let available =
message.get_size().saturating_sub(message.get_pointer());
let length = length.min(available);
let at = message.get_pointer() + length;
message.set_pointer(at);
length
});
let total_uploads = message.read_int32();
let queue_size = message.read_int32();
let slots_free = message.read_bool();
// Museek+ writes `slotsavail` as an integer, leaving three bytes of
// rubbish where the next field starts; Nicotine+ guards the read the
// same way, by only taking a field there is room for.
let upload_allowed =
(message.get_size().saturating_sub(message.get_pointer()) >= 4)
.then(|| message.read_int32());
let _ = sender.send(PeerMessage::UserInfoReceived(PeerInfo {
description,
picture_bytes,
total_uploads,
queue_size,
slots_free,
upload_allowed,
}));
}
}
/// Build a `UserInfoResponse` (peer code 16) with no description or picture.
#[must_use]
pub fn build_user_info(
upload_slots: u32,
queue_size: u32,
slots_free: bool,
) -> Message {
Message::new()
.write_int32(16)
.write_string("")
.write_bool(false)
.write_int32(upload_slots)
.write_int32(queue_size)
.write_bool(slots_free)
.clone()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
fn parse(build: impl FnOnce(&mut Message)) -> PeerInfo {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(build);
UserInfoResponseHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(PeerMessage::UserInfoReceived(info)) => info,
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn a_peers_answer_carries_its_slots_and_queue() {
let info = parse(|m| {
m.write_string("hello, I share flacs");
m.write_bool(false);
m.write_int32(42); // total uploads
m.write_int32(3); // queue size
m.write_bool(true); // a slot is free
m.write_int32(1); // uploads allowed
});
assert_eq!(info.description, "hello, I share flacs");
assert_eq!(info.picture_bytes, None);
assert_eq!(info.total_uploads, 42);
assert_eq!(info.queue_size, 3);
assert!(info.slots_free);
assert_eq!(info.upload_allowed, Some(1));
}
#[test]
fn a_picture_is_skipped_over_rather_than_kept() {
let info = parse(|m| {
m.write_string("with a picture");
m.write_bool(true);
m.write_int32(4);
m.write_raw_bytes(vec![1, 2, 3, 4]);
m.write_int32(7);
m.write_int32(0);
m.write_bool(false);
m.write_int32(0);
});
assert_eq!(info.picture_bytes, Some(4));
assert_eq!(info.total_uploads, 7, "the fields after it still line up");
}
#[test]
fn a_message_that_stops_early_still_parses() {
// Older clients omit the trailing field, and Museek+ leaves rubbish
// where it would be; neither may cost us the fields before it.
let info = parse(|m| {
m.write_string("older client");
m.write_bool(false);
m.write_int32(5);
m.write_int32(1);
m.write_bool(true);
});
assert_eq!(info.total_uploads, 5);
assert_eq!(info.queue_size, 1);
assert!(info.slots_free);
assert_eq!(info.upload_allowed, None);
}
#[test]
fn a_picture_longer_than_the_message_does_not_run_off_the_end() {
let info = parse(|m| {
m.write_string("hostile");
m.write_bool(true);
m.write_int32(u32::MAX);
m.write_raw_bytes(vec![9, 9]);
});
assert_eq!(info.picture_bytes, Some(2));
assert_eq!(info.total_uploads, 0, "nothing left to read reads as zero");
}
}
@@ -0,0 +1,30 @@
//! `AdminMessage` (server code 66): an announcement from the server to
//! everyone, surfaced as a private message from the user "server".
use crate::actor::server_actor::{ServerMessage, UserMessage};
use crate::message::{Message, MessageHandler};
use std::sync::mpsc::Sender;
use std::time::{SystemTime, UNIX_EPOCH};
pub struct AdminMessageHandler;
impl MessageHandler<ServerMessage> for AdminMessageHandler {
fn get_code(&self) -> u32 {
66
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as u32);
let announcement = UserMessage::new(
0,
now,
"server".to_string(),
message.read_string(),
false,
);
let _ =
sender.send(ServerMessage::PrivateMessageReceived(announcement));
}
}
@@ -0,0 +1,49 @@
use std::sync::mpsc::Sender;
use crate::{
actor::server_actor::ServerMessage,
message::{Message, MessageHandler},
};
/// `CantConnectToPeer` (code 1001): a brokered peer gave up reaching us.
///
/// The token is the one we quoted in our own `ConnectToPeer` (code 18), and it
/// is the only field the server relays — the username is not repeated, so the
/// token is what a client matches the pending attempt on.
///
/// Without this the download sits in "connecting" until a timeout; with it the
/// wait ends the moment the peer gives up.
pub struct CantConnectToPeerHandler;
impl MessageHandler<ServerMessage> for CantConnectToPeerHandler {
fn get_code(&self) -> u32 {
1001
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let token = message.read_int32();
let _ = sender.send(ServerMessage::CantConnectToPeer { token });
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
#[test]
fn the_token_of_the_failed_attempt_is_reported() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_int32(4242);
});
CantConnectToPeerHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::CantConnectToPeer { token }) => {
assert_eq!(token, 4242);
}
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -0,0 +1,17 @@
use crate::actor::server_actor::ServerMessage;
use crate::message::{Message, MessageHandler};
use crate::peer::Peer;
use std::sync::mpsc::Sender;
pub struct ConnectToPeerHandler;
impl MessageHandler<ServerMessage> for ConnectToPeerHandler {
fn get_code(&self) -> u32 {
18
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let Some(peer) = Peer::new_from_message(message) else {
return;
};
let _ = sender.send(ServerMessage::ConnectToPeer(peer));
}
}
@@ -0,0 +1,162 @@
//! What the server says about the distributed network: parent candidates
//! (code 102), a reset (130), and a search embedded for a branch root (93).
use crate::actor::server_actor::ServerMessage;
use crate::message::distributed::{Distributed, parse_at};
use crate::message::{Message, MessageHandler};
use std::net::Ipv4Addr;
use std::sync::mpsc::Sender;
pub struct PossibleParentsHandler;
impl MessageHandler<ServerMessage> for PossibleParentsHandler {
fn get_code(&self) -> u32 {
102
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let count = message.read_int32();
let candidates = (0..count.min(10))
.map_while(|_| entry(message))
.filter_map(|(username, host, port)| {
Some((username, host, u16::try_from(port).ok()?))
})
.collect();
let _ = sender.send(ServerMessage::PossibleParents(candidates));
}
}
/// One whole `(username, host, port)` entry, or `None` where the message
/// runs out: a truncated entry is not a parent to dial.
fn entry(message: &mut Message) -> Option<(String, String, u32)> {
let username = message.read_string();
if username.is_empty()
|| message.get_size().saturating_sub(message.get_pointer()) < 8
{
return None;
}
let host = Ipv4Addr::from(message.read_int32()).to_string();
Some((username, host, message.read_int32()))
}
pub struct ResetDistributedHandler;
impl MessageHandler<ServerMessage> for ResetDistributedHandler {
fn get_code(&self) -> u32 {
130
}
fn handle(&self, _message: &mut Message, sender: Sender<ServerMessage>) {
let _ = sender.send(ServerMessage::ResetDistributed);
}
}
/// A search the server hands us directly because we are a branch root; it is
/// answered like any other.
pub struct EmbeddedMessageHandler;
impl MessageHandler<ServerMessage> for EmbeddedMessageHandler {
fn get_code(&self) -> u32 {
93
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let code = message.read_int8();
if let Some(Distributed::Search {
username,
token,
query,
}) = parse_at(message, code)
{
let _ = sender.send(ServerMessage::FileSearchRequest {
username,
token,
query,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc::channel;
#[test]
fn possible_parents_carry_each_candidates_address() {
let (sender, received) = channel();
let mut message = crate::message::framed(|m| {
m.write_int32(2)
.write_string("alice")
.write_int32(u32::from_le_bytes([1, 0, 0, 127]))
.write_int32(2234)
.write_string("bob")
.write_int32(u32::from_le_bytes([8, 8, 8, 8]))
.write_int32(2235);
});
PossibleParentsHandler.handle(&mut message, sender);
let ServerMessage::PossibleParents(candidates) =
received.try_recv().unwrap()
else {
panic!("expected PossibleParents");
};
assert_eq!(
candidates,
vec![
("alice".to_string(), "127.0.0.1".to_string(), 2234),
("bob".to_string(), "8.8.8.8".to_string(), 2235),
]
);
}
// The count says two, the bytes hold one and a half: the whole one is
// a candidate, the stub is not. A port that is not a port is skipped too.
#[test]
fn a_truncated_or_absurd_entry_is_not_a_candidate() {
let (sender, received) = channel();
let mut message = crate::message::framed(|m| {
m.write_int32(3)
.write_string("alice")
.write_int32(u32::from_le_bytes([1, 0, 0, 127]))
.write_int32(70_000)
.write_string("bob")
.write_int32(u32::from_le_bytes([8, 8, 8, 8]))
.write_int32(2235)
.write_string("carol");
});
PossibleParentsHandler.handle(&mut message, sender);
let ServerMessage::PossibleParents(candidates) =
received.try_recv().unwrap()
else {
panic!("expected PossibleParents");
};
assert_eq!(
candidates,
vec![("bob".to_string(), "8.8.8.8".to_string(), 2235)]
);
}
#[test]
fn a_reset_and_an_embedded_search_become_client_events() {
let (sender, received) = channel();
let mut reset = crate::message::framed(|_| {});
ResetDistributedHandler.handle(&mut reset, sender.clone());
assert!(matches!(
received.try_recv().unwrap(),
ServerMessage::ResetDistributed
));
let inner = crate::message::distributed::build_search("seeker", 9, "q")
.get_data();
let mut embedded = crate::message::framed(|m| {
m.write_raw_bytes(inner);
});
EmbeddedMessageHandler.handle(&mut embedded, sender);
let ServerMessage::FileSearchRequest {
username,
token,
query,
} = received.try_recv().unwrap()
else {
panic!("expected FileSearchRequest");
};
assert_eq!(
(username.as_str(), token, query.as_str()),
("seeker", 9, "q")
);
}
}
@@ -0,0 +1,69 @@
use crate::debug;
use crate::{
actor::server_actor::ServerMessage,
message::{Message, MessageHandler},
};
use std::sync::mpsc::Sender;
pub struct ExcludedSearchPhrasesHandler;
impl MessageHandler<ServerMessage> for ExcludedSearchPhrasesHandler {
fn get_code(&self) -> u32 {
160
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let item_count = message.read_int32();
let mut exluded_phrases: Vec<String> = Vec::new();
for _ in 0..item_count {
// Guard against a hostile item_count outrunning the payload.
if message.get_pointer() + 4 > message.get_size() {
break;
}
let phrase = message.read_string();
exluded_phrases.push(phrase);
}
debug!("Excluded search phrases: {:?}", exluded_phrases);
// Kept, not just logged: the server rejects a search carrying one of
// these, so a client that forgets them spends its search allowance on
// queries that were never going to be answered.
let _ =
sender.send(ServerMessage::ExcludedSearchPhrases(exluded_phrases));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
#[test]
fn hostile_item_count_does_not_hang() {
// item_count=u32::MAX with no phrases: the guard must make this return
// promptly instead of looping ~4 billion times.
let (tx, _rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_int32(u32::MAX);
});
ExcludedSearchPhrasesHandler.handle(&mut message, tx);
}
#[test]
fn the_phrases_are_reported_not_only_logged() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_int32(2);
m.write_string("banned");
m.write_string("blocked");
});
ExcludedSearchPhrasesHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::ExcludedSearchPhrases(phrases)) => {
assert_eq!(phrases, ["banned", "blocked"]);
}
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -0,0 +1,27 @@
use crate::trace;
use std::sync::mpsc::Sender;
use crate::{
actor::server_actor::ServerMessage, message::Message,
message::handlers::MessageHandler,
};
pub struct FileSearchHandler;
impl MessageHandler<ServerMessage> for FileSearchHandler {
fn get_code(&self) -> u32 {
26
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
// The server distributes another user's search to us: [user][token][query].
let username = message.read_string();
let token = message.read_int32();
let query = message.read_string();
trace!("[server] search from {}: {} ({})", username, query, token);
let _ = sender.send(ServerMessage::FileSearchRequest {
username,
token,
query,
});
}
}
@@ -0,0 +1,35 @@
use crate::actor::server_actor::ServerMessage;
use crate::message::{Message, MessageHandler};
use std::sync::mpsc::Sender;
pub struct GetPeerAddressHandler;
impl MessageHandler<ServerMessage> for GetPeerAddressHandler {
fn get_code(&self) -> u32 {
3
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let username = message.read_string();
// Read IP address as 4 bytes
let mut ip: Vec<u8> = vec![];
for _ in 0..4 {
ip.push(message.read_int8());
}
let host = format!("{}.{}.{}.{}", ip[3], ip[2], ip[1], ip[0]);
let port = message.read_int32();
let obfuscation_type = message.read_int32();
let obfuscated_port = message.read_int32() as u16;
crate::debug!("GetPeerAddressHandler: {username:?}");
let _ = sender.send(ServerMessage::GetPeerAddressResponse {
username,
host,
port,
obfuscation_type,
obfuscated_port,
});
}
}
@@ -0,0 +1,58 @@
use std::sync::mpsc::Sender;
use crate::{
actor::server_actor::ServerMessage,
message::{Message, MessageHandler},
};
/// `GlobalRoomMessage` (code 152): a message said in some public room,
/// delivered because we subscribed to the global feed (code 150).
pub struct GlobalRoomMessageHandler;
impl MessageHandler<ServerMessage> for GlobalRoomMessageHandler {
fn get_code(&self) -> u32 {
152
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let room = message.read_string();
let username = message.read_string();
let text = message.read_string();
let _ = sender.send(ServerMessage::GlobalRoomMessageReceived {
room,
username,
message: text,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
#[test]
fn a_global_message_names_the_room_it_was_said_in() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("jazz");
m.write_string("alice");
m.write_string("anyone here?");
});
GlobalRoomMessageHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::GlobalRoomMessageReceived {
room,
username,
message,
}) => {
assert_eq!(
(room.as_str(), username.as_str(), message.as_str()),
("jazz", "alice", "anyone here?")
);
}
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -0,0 +1,346 @@
//! Interests and the queries built on them: recommendations (codes 54, 56,
//! 111), similar users (110, 112) and another user's interests (57).
//!
//! Ratings are signed on the wire: the server sends a descending list of
//! things it recommends and an ascending list of things it recommends
//! against, and the latter carry negative ratings.
use std::sync::mpsc::Sender;
use crate::{
actor::server_actor::ServerMessage,
message::{Message, MessageHandler},
types::{Recommendation, SimilarUser, UserInterests},
};
/// Read a `[count][item][rating]...` vector, stopping if the payload runs out
/// before the count does.
fn read_recommendations(message: &mut Message) -> Vec<Recommendation> {
let count = message.read_int32();
let mut items = Vec::new();
for _ in 0..count {
let item = message.read_string();
if item.is_empty() {
break;
}
#[allow(clippy::cast_possible_wrap)]
let rating = message.read_int32() as i32;
items.push(Recommendation { item, rating });
}
items
}
/// Read a `[count][string]...` vector, stopping at the end of the payload.
fn read_strings(message: &mut Message) -> Vec<String> {
let count = message.read_int32();
let mut items = Vec::new();
for _ in 0..count {
let item = message.read_string();
if item.is_empty() {
break;
}
items.push(item);
}
items
}
/// `GetRecommendations` (code 54): recommendations drawn from our own
/// interests.
pub struct RecommendationsHandler;
impl MessageHandler<ServerMessage> for RecommendationsHandler {
fn get_code(&self) -> u32 {
54
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let recommended = read_recommendations(message);
let unrecommended = read_recommendations(message);
let _ = sender.send(ServerMessage::RecommendationsReceived {
recommended,
unrecommended,
});
}
}
/// `GlobalRecommendations` (code 56): the same shape, server-wide.
pub struct GlobalRecommendationsHandler;
impl MessageHandler<ServerMessage> for GlobalRecommendationsHandler {
fn get_code(&self) -> u32 {
56
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let recommended = read_recommendations(message);
let unrecommended = read_recommendations(message);
let _ = sender.send(ServerMessage::GlobalRecommendationsReceived {
recommended,
unrecommended,
});
}
}
/// `ItemRecommendations` (code 111): what else people who like one item like.
pub struct ItemRecommendationsHandler;
impl MessageHandler<ServerMessage> for ItemRecommendationsHandler {
fn get_code(&self) -> u32 {
111
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let item = message.read_string();
let recommendations = read_recommendations(message);
let _ = sender.send(ServerMessage::ItemRecommendationsReceived {
item,
recommendations,
});
}
}
/// `SimilarUsers` (code 110): users whose interests overlap ours.
pub struct SimilarUsersHandler;
impl MessageHandler<ServerMessage> for SimilarUsersHandler {
fn get_code(&self) -> u32 {
110
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let count = message.read_int32();
let mut users = Vec::new();
for _ in 0..count {
let username = message.read_string();
if username.is_empty() {
break;
}
let weight = message.read_int32();
users.push(SimilarUser { username, weight });
}
let _ = sender.send(ServerMessage::SimilarUsersReceived { users });
}
}
/// `ItemSimilarUsers` (code 112): who likes one item. The server sends only
/// names here, so every weight reads as zero.
pub struct ItemSimilarUsersHandler;
impl MessageHandler<ServerMessage> for ItemSimilarUsersHandler {
fn get_code(&self) -> u32 {
112
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let item = message.read_string();
let usernames = read_strings(message);
let _ = sender
.send(ServerMessage::ItemSimilarUsersReceived { item, usernames });
}
}
/// `UserInterests` (code 57): what one user likes and hates.
pub struct UserInterestsHandler;
impl MessageHandler<ServerMessage> for UserInterestsHandler {
fn get_code(&self) -> u32 {
57
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let username = message.read_string();
let likes = read_strings(message);
let hates = read_strings(message);
let _ =
sender.send(ServerMessage::UserInterestsReceived(UserInterests {
username,
likes,
hates,
}));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
#[test]
fn recommendations_split_into_recommended_and_not() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_int32(1);
m.write_string("jazz");
m.write_int32(3);
m.write_int32(1);
m.write_string("polka");
#[allow(clippy::cast_sign_loss)]
m.write_int32(-2i32 as u32);
});
RecommendationsHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::RecommendationsReceived {
recommended,
unrecommended,
}) => {
assert_eq!(
recommended,
vec![Recommendation {
item: "jazz".into(),
rating: 3
}]
);
assert_eq!(
unrecommended,
vec![Recommendation {
item: "polka".into(),
rating: -2
}]
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn global_recommendations_parse_the_same_shape() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_int32(1);
m.write_string("dub");
m.write_int32(9);
m.write_int32(0);
});
GlobalRecommendationsHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::GlobalRecommendationsReceived {
recommended,
unrecommended,
}) => {
assert_eq!(recommended.len(), 1);
assert_eq!(recommended[0].item, "dub");
assert!(unrecommended.is_empty());
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn similar_users_carry_their_weight() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_int32(2);
m.write_string("alice");
m.write_int32(4);
m.write_string("bob");
m.write_int32(1);
});
SimilarUsersHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::SimilarUsersReceived { users }) => {
assert_eq!(
users,
vec![
SimilarUser {
username: "alice".into(),
weight: 4
},
SimilarUser {
username: "bob".into(),
weight: 1
},
]
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn item_similar_users_report_the_item_they_answer_for() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("jazz");
m.write_int32(1);
m.write_string("alice");
});
ItemSimilarUsersHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::ItemSimilarUsersReceived { item, usernames }) => {
assert_eq!(item, "jazz");
assert_eq!(usernames, vec!["alice".to_string()]);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn item_recommendations_report_their_item() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("jazz");
m.write_int32(1);
m.write_string("blues");
m.write_int32(2);
});
ItemRecommendationsHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::ItemRecommendationsReceived {
item,
recommendations,
}) => {
assert_eq!(item, "jazz");
assert_eq!(recommendations[0].item, "blues");
assert_eq!(recommendations[0].rating, 2);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn a_users_likes_and_hates_arrive_as_two_lists() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("alice");
m.write_int32(2);
m.write_string("jazz");
m.write_string("dub");
m.write_int32(1);
m.write_string("polka");
});
UserInterestsHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::UserInterestsReceived(interests)) => {
assert_eq!(interests.username, "alice");
assert_eq!(interests.likes, ["jazz", "dub"]);
assert_eq!(interests.hates, ["polka"]);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn a_user_with_no_interests_parses_to_empty_lists() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("ghost");
m.write_int32(0);
m.write_int32(0);
});
UserInterestsHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::UserInterestsReceived(interests)) => {
assert!(interests.likes.is_empty());
assert!(interests.hates.is_empty());
}
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -0,0 +1,309 @@
use crate::{
actor::server_actor::ServerMessage,
message::{Message, MessageHandler},
types::{RoomUserStats, UserStatus},
};
use std::sync::mpsc::Sender;
pub struct JoinRoomHandler;
impl MessageHandler<ServerMessage> for JoinRoomHandler {
fn get_code(&self) -> u32 {
14
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
// JoinRoom (code 14): room name, the member usernames, then the
// parallel per-member stat vectors.
let room = message.read_string();
let users = read_strings(message);
let stats = parse_member_stats(message, &users);
let _ = sender.send(ServerMessage::RoomJoined {
room: room.clone(),
users,
});
if !stats.is_empty() {
let _ = sender.send(ServerMessage::RoomMemberStats { room, stats });
}
}
}
/// Read a length-prefixed vector of strings, stopping early if the count
/// outruns the payload: a bogus (possibly hostile) count must not spin us
/// into an OOM allocation loop. A 4-byte floor is the smallest a
/// length-prefixed string can be.
fn read_strings(message: &mut Message) -> Vec<String> {
let count = message.read_int32();
let mut values = Vec::new();
for _ in 0..count {
if message.get_pointer() + 4 > message.get_size() {
break;
}
values.push(message.read_string());
}
values
}
/// Read a length-prefixed vector of 32-bit values, bounded the same way.
fn read_int32s(message: &mut Message) -> Vec<u32> {
let count = message.read_int32();
let mut values = Vec::new();
for _ in 0..count {
if message.get_pointer() + 4 > message.get_size() {
break;
}
values.push(message.read_int32());
}
values
}
/// Parse the per-member statistics that follow the membership list of a
/// `JoinRoom` (code 14) reply: statuses, then a record of share statistics per
/// member, then whether each member's upload slots are full, then country
/// codes.
///
/// Each vector is independently length-prefixed and a server may send fewer
/// entries than there are members (or, on an older server, omit the country
/// vector entirely), so every field is looked up positionally and defaults
/// when absent rather than shifting the whole roster.
#[must_use]
fn parse_member_stats(
message: &mut Message,
users: &[String],
) -> Vec<RoomUserStats> {
let statuses = read_int32s(message);
let stat_count = message.read_int32();
let mut shares = Vec::new();
for _ in 0..stat_count {
// A share record is 20 bytes: speed, an obsolete 64-bit upload
// number, files and folders.
if message.get_pointer() + 20 > message.get_size() {
break;
}
let average_speed = message.read_int32();
let _upload_number = message.read_int64(); // obsolete
let shared_files = message.read_int32();
let shared_folders = message.read_int32();
shares.push((average_speed, shared_files, shared_folders));
}
let slots_full = read_int32s(message);
let countries = read_strings(message);
users
.iter()
.enumerate()
.map(|(i, username)| {
let (average_speed, shared_files, shared_folders) =
shares.get(i).copied().unwrap_or((0, 0, 0));
RoomUserStats {
username: username.clone(),
status: UserStatus::from_code(
statuses.get(i).copied().unwrap_or(0),
),
average_speed,
shared_files,
shared_folders,
slots_full: slots_full.get(i).copied().unwrap_or(0) != 0,
country: countries.get(i).cloned().filter(|c| !c.is_empty()),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
#[test]
fn hostile_user_count_does_not_hang() {
// room="" then user_count=u32::MAX with no usernames: must return
// promptly instead of looping ~4 billion times.
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("").write_int32(u32::MAX);
});
JoinRoomHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::RoomJoined { users, .. }) => {
assert!(users.is_empty());
}
other => panic!("unexpected: {other:?}"),
}
}
/// A full JoinRoom payload: room, members, then the four parallel stat
/// vectors the server sends for them.
fn join_payload() -> Message {
framed(|m| {
m.write_string("nicotine")
.write_int32(2)
.write_string("alice")
.write_string("bob")
// statuses
.write_int32(2)
.write_int32(2)
.write_int32(1)
// share records
.write_int32(2)
.write_int32(1024)
.write_int64(0)
.write_int32(4321)
.write_int32(77)
.write_int32(50)
.write_int64(0)
.write_int32(10)
.write_int32(2)
// slots full
.write_int32(2)
.write_int32(1)
.write_int32(0)
// countries
.write_int32(2)
.write_string("NL")
.write_string("");
})
}
#[test]
fn member_stats_are_parsed_alongside_the_roster() {
let (tx, rx) = std::sync::mpsc::channel();
JoinRoomHandler.handle(&mut join_payload(), tx);
assert!(matches!(
rx.try_recv(),
Ok(ServerMessage::RoomJoined { .. })
));
match rx.try_recv() {
Ok(ServerMessage::RoomMemberStats { room, stats }) => {
assert_eq!(room, "nicotine");
assert_eq!(
stats,
vec![
RoomUserStats {
username: "alice".to_string(),
status: UserStatus::Online,
average_speed: 1024,
shared_files: 4321,
shared_folders: 77,
slots_full: true,
country: Some("NL".to_string()),
},
RoomUserStats {
username: "bob".to_string(),
status: UserStatus::Away,
average_speed: 50,
shared_files: 10,
shared_folders: 2,
slots_full: false,
country: None,
},
]
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn a_server_that_omits_the_stat_vectors_still_yields_a_roster() {
// Nothing after the membership list: every stat reads as a default
// rather than shifting the roster or dropping members.
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("jazz").write_int32(1).write_string("alice");
});
JoinRoomHandler.handle(&mut message, tx);
assert!(matches!(
rx.try_recv(),
Ok(ServerMessage::RoomJoined { .. })
));
match rx.try_recv() {
Ok(ServerMessage::RoomMemberStats { stats, .. }) => {
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].username, "alice");
assert_eq!(stats[0].average_speed, 0);
assert_eq!(stats[0].country, None);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn short_stat_vectors_do_not_shift_the_roster() {
// Stats for the first member only: the second must still be reported,
// with defaults, rather than inheriting the first member's figures.
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("jazz")
.write_int32(2)
.write_string("alice")
.write_string("bob")
.write_int32(1)
.write_int32(2)
.write_int32(1)
.write_int32(1024)
.write_int64(0)
.write_int32(9)
.write_int32(1)
.write_int32(0)
.write_int32(0);
});
JoinRoomHandler.handle(&mut message, tx);
let _ = rx.try_recv();
match rx.try_recv() {
Ok(ServerMessage::RoomMemberStats { stats, .. }) => {
assert_eq!(stats.len(), 2);
assert_eq!(stats[0].shared_files, 9);
assert_eq!(stats[1].username, "bob");
assert_eq!(stats[1].shared_files, 0);
assert_eq!(stats[1].status, UserStatus::Offline);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn hostile_stat_counts_do_not_hang_or_overallocate() {
// Vectors claiming ~4 billion entries in a tiny frame must return
// promptly, bounded by the payload.
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("jazz")
.write_int32(1)
.write_string("alice")
.write_int32(u32::MAX);
});
JoinRoomHandler.handle(&mut message, tx);
assert!(matches!(
rx.try_recv(),
Ok(ServerMessage::RoomJoined { .. })
));
}
#[test]
fn forwards_room_and_member_list() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("nicotine")
.write_int32(2)
.write_string("alice")
.write_string("bob");
});
JoinRoomHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::RoomJoined { room, users }) => {
assert_eq!(room, "nicotine");
assert_eq!(users, vec!["alice", "bob"]);
}
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -0,0 +1,38 @@
use crate::{
actor::server_actor::ServerMessage,
message::{Message, MessageHandler},
};
use std::sync::mpsc::Sender;
pub struct LeaveRoomHandler;
impl MessageHandler<ServerMessage> for LeaveRoomHandler {
fn get_code(&self) -> u32 {
15
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let room = message.read_string();
let _ = sender.send(ServerMessage::RoomLeft { room });
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
#[test]
fn forwards_left_room() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("jazz");
});
LeaveRoomHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::RoomLeft { room }) => assert_eq!(room, "jazz"),
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -0,0 +1,29 @@
use crate::{
actor::server_actor::ServerMessage, debug, info, message::Message,
};
use std::sync::mpsc::Sender;
use crate::message::MessageHandler;
pub struct LoginHandler;
impl MessageHandler<ServerMessage> for LoginHandler {
fn get_code(&self) -> u32 {
1
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let response = message.read_int8();
if response != 1 {
let _ = sender.send(ServerMessage::LoginStatus(false));
return;
}
info!("Login successful");
let greeting = message.read_string();
debug!("Server greeting: {:?}", greeting);
let _ = sender.send(ServerMessage::LoginStatus(true));
}
}
@@ -0,0 +1,950 @@
use crate::{
message::Message,
peer::ConnectionType,
types::{ClientVersion, Transfer},
utils::md5::md5,
};
pub struct MessageFactory;
impl MessageFactory {
#[must_use]
pub fn build_get_peer_address(username: &str) -> Message {
let mut message = Message::new();
message.write_int32(3);
message.write_string(username);
message
}
#[must_use]
pub fn build_login_message(
username: &str,
password: &str,
version: ClientVersion,
) -> Message {
let hash = md5([username, password].join("").as_str());
let mut message = Message::new();
message
.write_int32(1)
.write_string(username)
.write_string(password)
.write_int32(version.major)
.write_string(&hash)
.write_int32(version.minor)
.clone()
}
#[must_use]
pub fn build_shared_folders_message(
folder_count: u32,
file_count: u32,
) -> Message {
Message::new()
.write_int32(35)
.write_int32(folder_count)
.write_int32(file_count)
.clone()
}
#[must_use]
pub fn build_file_search_message(token: u32, query: &str) -> Message {
Message::new()
.write_int32(26)
.write_int32(token)
.write_string(query)
.clone()
}
/// Build a private message (server code 22) to send to another user.
#[must_use]
pub fn build_message_user(username: &str, message: &str) -> Message {
Message::new()
.write_int32(22)
.write_string(username)
.write_string(message)
.clone()
}
/// Acknowledge a received private message (server code 23) so the server
/// stops re-delivering it.
#[must_use]
pub fn build_message_acked(id: u32) -> Message {
Message::new().write_int32(23).write_int32(id).clone()
}
/// Ask the server (code 18) to broker a connection to a firewalled peer:
/// the server tells that peer to connect back to us, quoting `token`.
#[must_use]
pub fn build_connect_to_peer(
token: u32,
username: &str,
connection_type: ConnectionType,
) -> Message {
Message::new()
.write_int32(18)
.write_int32(token)
.write_string(username)
.write_string(&connection_type.to_string())
.clone()
}
/// Ask the server (code 92) how much of our own privilege time is left.
#[must_use]
pub fn build_check_privileges() -> Message {
Message::new().write_int32(92).clone()
}
/// Tell a peer where their queued file sits (peer code 44). Place counts
/// from 1.
#[must_use]
pub fn build_place_in_queue_response(
filename: &str,
place: u32,
) -> Message {
Message::new()
.write_int32(44)
.write_string(filename)
.write_int32(place)
.clone()
}
/// A wishlist search (server code 103). Same shape as a plain `FileSearch`,
/// but the server rate-limits it to the interval it announced in code 104
/// and does not count it against the normal search allowance.
#[must_use]
pub fn build_wishlist_search(token: u32, query: &str) -> Message {
Message::new()
.write_int32(103)
.write_int32(token)
.write_string(query)
.clone()
}
#[must_use]
pub fn build_set_status_message(status_code: u32) -> Message {
Message::new()
.write_int32(28)
.write_int32(status_code)
.clone()
}
#[must_use]
/// Whether we lack a parent in the distributed network (code 71); the
/// server offers PossibleParents while we say so.
pub fn build_have_no_parent(no_parent: bool) -> Message {
Message::new().write_int32(71).write_bool(no_parent).clone()
}
/// The root of the branch we hang from (code 127): ourselves while we
/// have no parent.
#[must_use]
pub fn build_branch_root(username: &str) -> Message {
Message::new()
.write_int32(127)
.write_string(username)
.clone()
}
/// How deep in the tree we sit (code 126): 0 without a parent.
#[must_use]
pub fn build_branch_level(level: u32) -> Message {
Message::new().write_int32(126).write_int32(level).clone()
}
/// Whether we take children in the distributed network (code 100).
#[must_use]
pub fn build_accept_children(accept: bool) -> Message {
Message::new().write_int32(100).write_bool(accept).clone()
}
#[must_use]
pub fn build_set_wait_port_message(port: u16) -> Message {
Message::new()
.write_int32(2)
.write_int32(port.into())
.clone()
}
/// Ask the server (code 7) for a user's online status.
#[must_use]
pub fn build_get_user_status(username: &str) -> Message {
Message::new().write_int32(7).write_string(username).clone()
}
/// Tell the server (code 121) what a finished upload averaged, in bytes
/// per second; it folds that into the speed other users see for us.
#[must_use]
pub fn build_send_upload_speed(bytes_per_second: u32) -> Message {
Message::new()
.write_int32(121)
.write_int32(bytes_per_second)
.clone()
}
/// Ask the server (code 36) for a user's share statistics.
#[must_use]
pub fn build_get_user_stats(username: &str) -> Message {
Message::new()
.write_int32(36)
.write_string(username)
.clone()
}
/// Watch `username` (server code 5): the server replies with their
/// current status and share statistics, then pushes every later status
/// change as a `GetUserStatus` (code 7) until we unwatch them.
#[must_use]
pub fn build_watch_user(username: &str) -> Message {
Message::new().write_int32(5).write_string(username).clone()
}
/// Stop watching `username` (server code 6). The server sends no reply.
#[must_use]
pub fn build_unwatch_user(username: &str) -> Message {
Message::new().write_int32(6).write_string(username).clone()
}
/// Ask the server (code 64) for the list of public chat rooms.
#[must_use]
pub fn build_room_list_request() -> Message {
Message::new().write_int32(64).clone()
}
/// Join a chat room (server code 14). `private` requests a private room.
#[must_use]
pub fn build_join_room(room: &str, private: bool) -> Message {
Message::new()
.write_int32(14)
.write_string(room)
.write_int32(u32::from(private))
.clone()
}
/// Leave a chat room (server code 15).
#[must_use]
pub fn build_leave_room(room: &str) -> Message {
Message::new().write_int32(15).write_string(room).clone()
}
/// Say `message` in chat room `room` (server code 13).
#[must_use]
pub fn build_say_chatroom(room: &str, message: &str) -> Message {
Message::new()
.write_int32(13)
.write_string(room)
.write_string(message)
.clone()
}
/// Ask a peer for their shared-file listing (peer code 4, no body).
#[must_use]
pub fn build_get_share_file_list() -> Message {
Message::new().write_int32(4).clone()
}
#[must_use]
pub fn build_queue_upload_message(filename: &str) -> Message {
Message::new()
.write_int32(43)
.write_string(filename)
.clone()
}
#[must_use]
pub fn build_transfer_request_message(
filename: &str,
token: u32,
) -> Message {
Message::new()
.write_int32(40) // code
.write_int32(0) // direction
.write_int32(token)
.write_string(filename)
.clone()
}
/// A TransferRequest (peer code 40) initiating an *upload*: we offer a file
/// to a peer who queued it, quoting our transfer token and its size.
#[must_use]
pub fn build_upload_transfer_request(
filename: &str,
token: u32,
size: u64,
) -> Message {
Message::new()
.write_int32(40)
.write_int32(1) // direction: upload
.write_int32(token)
.write_string(filename)
.write_int64(size)
.clone()
}
#[must_use]
pub fn build_transfer_response_message(transfer: Transfer) -> Message {
Message::new()
.write_int32(41)
.write_int32(transfer.token)
.write_bool(true)
.clone()
}
#[must_use]
pub fn build_transfer_denial_message(token: u32, reason: &str) -> Message {
Message::new()
.write_int32(41)
.write_int32(token)
.write_bool(false)
.write_string(reason)
.clone()
}
#[must_use]
pub fn build_pierce_firewall_message(token: u32) -> Message {
Message::new()
.write_int8(0) // PierceFirewall message code
.write_int32(token)
.clone()
}
/// Ask a peer where the file we queued with them sits (peer code 51).
#[must_use]
pub fn build_place_in_queue_request(filename: &str) -> Message {
Message::new()
.write_int32(51)
.write_string(filename)
.clone()
}
/// Invite `username` into the private room we own (server code 134).
#[must_use]
pub fn build_add_room_member(room: &str, username: &str) -> Message {
Message::new()
.write_int32(134)
.write_string(room)
.write_string(username)
.clone()
}
/// Remove `username` from the private room we own (server code 135).
#[must_use]
pub fn build_remove_room_member(room: &str, username: &str) -> Message {
Message::new()
.write_int32(135)
.write_string(room)
.write_string(username)
.clone()
}
/// Give up our own membership of a private room (server code 136).
#[must_use]
pub fn build_cancel_room_membership(room: &str) -> Message {
Message::new().write_int32(136).write_string(room).clone()
}
/// Give up ownership of a private room, which deletes it (code 137).
#[must_use]
pub fn build_cancel_room_ownership(room: &str) -> Message {
Message::new().write_int32(137).write_string(room).clone()
}
/// Say whether we accept private-room invitations (server code 141).
#[must_use]
pub fn build_enable_room_invitations(enabled: bool) -> Message {
Message::new()
.write_int32(141)
.write_int8(u8::from(enabled))
.clone()
}
/// Make `username` an operator of our private room (server code 143).
#[must_use]
pub fn build_add_room_operator(room: &str, username: &str) -> Message {
Message::new()
.write_int32(143)
.write_string(room)
.write_string(username)
.clone()
}
/// Take operator status away from `username` (server code 144).
#[must_use]
pub fn build_remove_room_operator(room: &str, username: &str) -> Message {
Message::new()
.write_int32(144)
.write_string(room)
.write_string(username)
.clone()
}
/// Give up our own operator status in a room (server code 147).
#[must_use]
pub fn build_cancel_room_operatorship(room: &str) -> Message {
Message::new().write_int32(147).write_string(room).clone()
}
/// A keepalive ping (server code 32, no body). The server sends no reply;
/// its only job is to keep a quiet connection from being reaped by a NAT
/// or the server's own idle timeout, the way other clients ping.
#[must_use]
pub fn build_server_ping() -> Message {
Message::new().write_int32(32).clone()
}
/// Search one user's shares (server code 42). Replies come back over a
/// peer connection as ordinary `FileSearchResponse` frames, so the token
/// is drawn from the same space as a plain `FileSearch`.
#[must_use]
pub fn build_user_search(
username: &str,
token: u32,
query: &str,
) -> Message {
Message::new()
.write_int32(42)
.write_string(username)
.write_int32(token)
.write_string(query)
.clone()
}
/// Search the shares of everyone in `room` (server code 120).
#[must_use]
pub fn build_room_search(room: &str, token: u32, query: &str) -> Message {
Message::new()
.write_int32(120)
.write_string(room)
.write_int32(token)
.write_string(query)
.clone()
}
/// Tell the server (code 1001) that the peer it asked us to connect to,
/// quoting `token`, could not be reached. The server relays that to the
/// peer so it stops waiting on a connection that is never coming.
#[must_use]
pub fn build_cant_connect_to_peer(token: u32, username: &str) -> Message {
Message::new()
.write_int32(1001)
.write_int32(token)
.write_string(username)
.clone()
}
/// Set our ticker (the scrolling one-line message) in `room`, server code
/// 116. An empty ticker removes ours.
#[must_use]
pub fn build_set_room_ticker(room: &str, ticker: &str) -> Message {
Message::new()
.write_int32(116)
.write_string(room)
.write_string(ticker)
.clone()
}
/// Subscribe to the global room feed (server code 150): every public room
/// message on the server, tagged with the room it came from.
#[must_use]
pub fn build_join_global_room() -> Message {
Message::new().write_int32(150).clone()
}
/// Stop the global room feed (server code 151).
#[must_use]
pub fn build_leave_global_room() -> Message {
Message::new().write_int32(151).clone()
}
/// Add an interest (server code 51). Interests drive the recommendation
/// and similar-user queries below.
#[must_use]
pub fn build_add_thing_i_like(item: &str) -> Message {
Message::new().write_int32(51).write_string(item).clone()
}
/// Drop an interest (server code 52).
#[must_use]
pub fn build_remove_thing_i_like(item: &str) -> Message {
Message::new().write_int32(52).write_string(item).clone()
}
/// Add a dislike (server code 117).
#[must_use]
pub fn build_add_thing_i_hate(item: &str) -> Message {
Message::new().write_int32(117).write_string(item).clone()
}
/// Drop a dislike (server code 118).
#[must_use]
pub fn build_remove_thing_i_hate(item: &str) -> Message {
Message::new().write_int32(118).write_string(item).clone()
}
/// Ask for recommendations based on our own interests (server code 54).
#[must_use]
pub fn build_get_recommendations() -> Message {
Message::new().write_int32(54).clone()
}
/// Ask for the server-wide recommendations (server code 56).
#[must_use]
pub fn build_global_recommendations() -> Message {
Message::new().write_int32(56).clone()
}
/// Ask what `username` likes and hates (server code 57).
#[must_use]
pub fn build_user_interests(username: &str) -> Message {
Message::new()
.write_int32(57)
.write_string(username)
.clone()
}
/// Ask for users whose interests overlap ours (server code 110).
#[must_use]
pub fn build_similar_users() -> Message {
Message::new().write_int32(110).clone()
}
/// Ask what else people who like `item` like (server code 111).
#[must_use]
pub fn build_item_recommendations(item: &str) -> Message {
Message::new().write_int32(111).write_string(item).clone()
}
/// Ask which users like `item` (server code 112).
#[must_use]
pub fn build_item_similar_users(item: &str) -> Message {
Message::new().write_int32(112).write_string(item).clone()
}
/// Hand `days` of our own privileges to `username` (server code 123).
#[must_use]
pub fn build_give_privileges(username: &str, days: u32) -> Message {
Message::new()
.write_int32(123)
.write_string(username)
.write_int32(days)
.clone()
}
/// Change our account password (server code 142). The server echoes the
/// accepted password back under the same code.
#[must_use]
pub fn build_change_password(password: &str) -> Message {
Message::new()
.write_int32(142)
.write_string(password)
.clone()
}
/// Send one private message to several users at once (server code 149).
#[must_use]
pub fn build_message_users(usernames: &[String], message: &str) -> Message {
let mut msg = Message::new();
msg.write_int32(149);
msg.write_int32(usernames.len() as u32);
for username in usernames {
msg.write_string(username);
}
msg.write_string(message);
msg
}
#[must_use]
pub fn build_peer_init_message(
own_username: &str,
connection_type: ConnectionType,
token: u32,
) -> Message {
Message::new()
.write_int8(1)
.write_string(own_username)
.write_string(&connection_type.to_string())
.write_int32(token)
.clone()
}
}
#[test]
fn test_build_get_user_status() {
let message = MessageFactory::build_get_user_status("bob");
let expect: Vec<u8> = [7, 0, 0, 0, 3, 0, 0, 0, b'b', b'o', b'b'].to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_get_user_stats() {
let message = MessageFactory::build_get_user_stats("bob");
let expect: Vec<u8> = [36, 0, 0, 0, 3, 0, 0, 0, b'b', b'o', b'b'].to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_login_message() {
let message = MessageFactory::build_login_message(
"insane_in_the_brain2",
"13375137",
ClientVersion::default(),
);
let expect: Vec<u8> = [
1, 0, 0, 0, 20, 0, 0, 0, 105, 110, 115, 97, 110, 101, 95, 105, 110, 95,
116, 104, 101, 95, 98, 114, 97, 105, 110, 50, 8, 0, 0, 0, 49, 51, 51,
55, 53, 49, 51, 55, 176, 0, 0, 0, 32, 0, 0, 0, 50, 101, 100, 102, 53,
49, 100, 48, 51, 55, 57, 52, 51, 55, 56, 102, 56, 98, 98, 54, 51, 49,
48, 100, 52, 54, 48, 99, 50, 50, 98, 49, 1, 0, 0, 0,
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_upload_transfer_request() {
use crate::types::Transfer;
let message =
MessageFactory::build_upload_transfer_request("song.mp3", 555, 4096);
// Decode via the production Transfer parser (dispatcher starts at offset 8).
let mut decoded = Message::new_with_data(message.get_buffer());
decoded.set_pointer(8);
let transfer = Transfer::new_from_message(&mut decoded);
assert_eq!(transfer.direction, 1); // upload
assert_eq!(transfer.token, 555);
assert_eq!(transfer.filename, "song.mp3");
assert_eq!(transfer.size, 4096);
}
#[test]
fn a_transfer_denial_carries_the_token_and_the_reason() {
let message =
MessageFactory::build_transfer_denial_message(555, "Cancelled");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 41);
decoded.set_pointer(8);
assert_eq!(decoded.read_int32(), 555);
assert!(!decoded.read_bool());
assert_eq!(decoded.read_string(), "Cancelled");
}
#[test]
fn test_build_peer_init_message() {
use crate::peer::ConnectionType;
let message =
MessageFactory::build_peer_init_message("bob", ConnectionType::P, 7);
// [1][len=3]"bob"[len=1]"P"[token=7] — no length prefix in get_data()
let expect: Vec<u8> = [
1, // PeerInit code (int8)
3, 0, 0, 0, 98, 111, 98, // username "bob"
1, 0, 0, 0, 80, // connection type "P"
7, 0, 0, 0, // token
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_message_user() {
let message = MessageFactory::build_message_user("bob", "hi");
let expect: Vec<u8> = [
22, 0, 0, 0, // code
3, 0, 0, 0, 98, 111, 98, // username "bob"
2, 0, 0, 0, 104, 105, // message "hi"
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_message_acked() {
let message = MessageFactory::build_message_acked(7);
let expect: Vec<u8> = [23, 0, 0, 0, 7, 0, 0, 0].to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_join_room() {
let message = MessageFactory::build_join_room("nicotine", false);
let expect: Vec<u8> = [
14, 0, 0, 0, // code
8, 0, 0, 0, 110, 105, 99, 111, 116, 105, 110, 101, // "nicotine"
0, 0, 0, 0, // private = 0
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_leave_room() {
let message = MessageFactory::build_leave_room("nicotine");
let expect: Vec<u8> = [
15, 0, 0, 0, // code
8, 0, 0, 0, 110, 105, 99, 111, 116, 105, 110, 101, // "nicotine"
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_say_chatroom() {
let message = MessageFactory::build_say_chatroom("room", "hi");
let expect: Vec<u8> = [
13, 0, 0, 0, // code
4, 0, 0, 0, 114, 111, 111, 109, // "room"
2, 0, 0, 0, 104, 105, // "hi"
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_room_list_request() {
let message = MessageFactory::build_room_list_request();
assert_eq!(vec![64, 0, 0, 0], message.get_data());
}
#[test]
fn test_build_check_privileges() {
assert_eq!(
vec![92, 0, 0, 0],
MessageFactory::build_check_privileges().get_data()
);
}
#[test]
fn test_build_place_in_queue_response() {
let message = MessageFactory::build_place_in_queue_response("song.mp3", 3);
let expect: Vec<u8> = [
44, 0, 0, 0, // code
8, 0, 0, 0, 115, 111, 110, 103, 46, 109, 112, 51, // "song.mp3"
3, 0, 0, 0, // place
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_wishlist_search() {
let message = MessageFactory::build_wishlist_search(12, "trance wax");
let expect: Vec<u8> = [
103, 0, 0, 0, 12, 0, 0, 0, 10, 0, 0, 0, 116, 114, 97, 110, 99, 101, 32,
119, 97, 120,
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_file_search_message() {
let message = MessageFactory::build_file_search_message(12, "trance wax");
let expect: Vec<u8> = [
26, 0, 0, 0, 12, 0, 0, 0, 10, 0, 0, 0, 116, 114, 97, 110, 99, 101, 32,
119, 97, 120,
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_watch_user() {
let message = MessageFactory::build_watch_user("bob");
let expect: Vec<u8> = [5, 0, 0, 0, 3, 0, 0, 0, b'b', b'o', b'b'].to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_unwatch_user() {
let message = MessageFactory::build_unwatch_user("bob");
let expect: Vec<u8> = [6, 0, 0, 0, 3, 0, 0, 0, b'b', b'o', b'b'].to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn an_upload_speed_report_carries_the_rate() {
let message = MessageFactory::build_send_upload_speed(900);
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 121);
decoded.set_pointer(8);
assert_eq!(decoded.read_int32(), 900);
}
#[test]
fn a_place_in_queue_request_names_the_file() {
let message = MessageFactory::build_place_in_queue_request("a\\b.mp3");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 51);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "a\\b.mp3");
}
#[test]
fn a_ping_is_just_its_code() {
let message = MessageFactory::build_server_ping();
assert_eq!(message.get_data(), [32, 0, 0, 0]);
}
#[test]
fn a_user_search_names_the_user_before_its_token() {
let message = MessageFactory::build_user_search("bob", 99, "jazz");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 42);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "bob");
assert_eq!(decoded.read_int32(), 99);
assert_eq!(decoded.read_string(), "jazz");
}
#[test]
fn a_room_search_names_the_room_before_its_token() {
let message = MessageFactory::build_room_search("nicotine", 7, "dub");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 120);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "nicotine");
assert_eq!(decoded.read_int32(), 7);
assert_eq!(decoded.read_string(), "dub");
}
#[test]
fn a_cant_connect_carries_the_token_then_the_user() {
let message = MessageFactory::build_cant_connect_to_peer(4242, "bob");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 1001);
decoded.set_pointer(8);
assert_eq!(decoded.read_int32(), 4242);
assert_eq!(decoded.read_string(), "bob");
}
#[test]
fn a_room_ticker_carries_its_room_and_text() {
let message = MessageFactory::build_set_room_ticker("jazz", "hello");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 116);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "jazz");
assert_eq!(decoded.read_string(), "hello");
}
#[test]
fn the_global_room_is_joined_and_left_by_code_alone() {
assert_eq!(
MessageFactory::build_join_global_room().get_data(),
[150, 0, 0, 0]
);
assert_eq!(
MessageFactory::build_leave_global_room().get_data(),
[151, 0, 0, 0]
);
}
#[test]
fn each_interest_message_uses_its_own_code() {
for (message, code) in [
(MessageFactory::build_add_thing_i_like("jazz"), 51),
(MessageFactory::build_remove_thing_i_like("jazz"), 52),
(MessageFactory::build_add_thing_i_hate("polka"), 117),
(MessageFactory::build_remove_thing_i_hate("polka"), 118),
(MessageFactory::build_user_interests("bob"), 57),
(MessageFactory::build_item_recommendations("jazz"), 111),
(MessageFactory::build_item_similar_users("jazz"), 112),
] {
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), code);
decoded.set_pointer(8);
assert!(!decoded.read_string().is_empty());
}
}
#[test]
fn the_bodyless_queries_are_just_their_codes() {
assert_eq!(
MessageFactory::build_get_recommendations().get_data(),
[54, 0, 0, 0]
);
assert_eq!(
MessageFactory::build_global_recommendations().get_data(),
[56, 0, 0, 0]
);
assert_eq!(
MessageFactory::build_similar_users().get_data(),
[110, 0, 0, 0]
);
}
#[test]
fn a_privilege_gift_carries_the_recipient_and_days() {
let message = MessageFactory::build_give_privileges("bob", 3);
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 123);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "bob");
assert_eq!(decoded.read_int32(), 3);
}
#[test]
fn a_password_change_carries_the_new_password() {
let message = MessageFactory::build_change_password("hunter2");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 142);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "hunter2");
}
#[test]
fn a_multi_user_message_counts_its_recipients_first() {
let recipients = vec!["alice".to_string(), "bob".to_string()];
let message = MessageFactory::build_message_users(&recipients, "hi all");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 149);
decoded.set_pointer(8);
assert_eq!(decoded.read_int32(), 2);
assert_eq!(decoded.read_string(), "alice");
assert_eq!(decoded.read_string(), "bob");
assert_eq!(decoded.read_string(), "hi all");
}
#[test]
fn the_private_room_roster_messages_carry_room_then_user() {
for (message, code) in [
(MessageFactory::build_add_room_member("club", "bob"), 134),
(MessageFactory::build_remove_room_member("club", "bob"), 135),
(MessageFactory::build_add_room_operator("club", "bob"), 143),
(
MessageFactory::build_remove_room_operator("club", "bob"),
144,
),
] {
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), code);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "club");
assert_eq!(decoded.read_string(), "bob");
}
}
#[test]
fn giving_up_a_room_standing_names_only_the_room() {
for (message, code) in [
(MessageFactory::build_cancel_room_membership("club"), 136),
(MessageFactory::build_cancel_room_ownership("club"), 137),
(MessageFactory::build_cancel_room_operatorship("club"), 147),
] {
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), code);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "club");
}
}
#[test]
fn room_invitations_are_toggled_by_a_single_byte() {
let message = MessageFactory::build_enable_room_invitations(true);
assert_eq!(message.get_data(), [141, 0, 0, 0, 1]);
let message = MessageFactory::build_enable_room_invitations(false);
assert_eq!(message.get_data(), [141, 0, 0, 0, 0]);
}
#[test]
fn a_private_join_sets_the_private_flag() {
let message = MessageFactory::build_join_room("club", true);
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 14);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "club");
assert_eq!(decoded.read_int32(), 1);
}
@@ -0,0 +1,100 @@
use crate::actor::server_actor::{ServerMessage, UserMessage};
use crate::info;
use crate::message::server::MessageFactory;
use crate::message::{Message, MessageHandler};
use std::sync::mpsc::Sender;
pub struct MessageUser;
impl MessageHandler<ServerMessage> for MessageUser {
fn get_code(&self) -> u32 {
22
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let id = message.read_int32();
let timestamp = message.read_int32();
let username = message.read_string();
let message_content = message.read_string();
let new_message = message.read_bool();
let user_message = UserMessage::new(
id,
timestamp,
username,
message_content,
new_message,
);
info!("[MessageUser] User message received:{:?}", user_message);
// Acknowledge every message, not only the ones flagged new. The flag
// marks a message delivered as it was sent; one the server stored
// while we were offline arrives with it false, and those are exactly
// the messages the server keeps re-sending at every login until they
// are acknowledged.
let _ = sender.send(ServerMessage::SendMessage(
MessageFactory::build_message_acked(id),
));
// Surface the message to the client so it can be read via the API.
let _ =
sender.send(ServerMessage::PrivateMessageReceived(user_message));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
fn deliver(new_message: bool) -> Vec<ServerMessage> {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_int32(7);
m.write_int32(1_700_000_000);
m.write_string("alice");
m.write_string("hello");
m.write_int8(u8::from(new_message));
});
MessageUser.handle(&mut message, tx);
rx.try_iter().collect()
}
fn acked_id(messages: &[ServerMessage]) -> Option<u32> {
messages.iter().find_map(|message| match message {
ServerMessage::SendMessage(m) => {
let mut decoded = Message::new_with_data(m.get_buffer());
(decoded.get_message_code() == 23).then(|| {
decoded.set_pointer(8);
decoded.read_int32()
})
}
_ => None,
})
}
#[test]
fn a_freshly_delivered_message_is_acknowledged() {
let sent = deliver(true);
assert_eq!(acked_id(&sent), Some(7));
}
#[test]
fn a_message_stored_while_we_were_offline_is_acknowledged_too() {
// Stored messages arrive with new_message false; without an ack the
// server re-delivers them at every login, forever.
let sent = deliver(false);
assert_eq!(acked_id(&sent), Some(7));
}
#[test]
fn the_message_itself_is_surfaced_to_the_client() {
let sent = deliver(false);
assert!(sent.iter().any(|message| matches!(
message,
ServerMessage::PrivateMessageReceived(m)
if m.message() == "hello" && m.username() == "alice"
)));
}
}

Some files were not shown because too many files have changed in this diff Show More