Merge modified Soulseek project into main
This commit is contained in:
@@ -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
|
||||
Executable
+20
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
/target
|
||||
|
||||
# Editor and assistant scratch, not repository content
|
||||
.claude/
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+61
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
[](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
|
||||
|
||||
[](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
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
) { };
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
max_width = 80
|
||||
Executable
+54
@@ -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"
|
||||
Executable
+37
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
));
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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()
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user