every.channel: sanitized baseline

This commit is contained in:
every.channel 2026-02-15 16:17:27 -05:00
commit 897e556bea
No known key found for this signature in database
258 changed files with 74298 additions and 0 deletions

View file

@ -0,0 +1,3 @@
[target.wasm32-unknown-unknown]
runner = "wasm-bindgen-test-runner"
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']

View file

@ -0,0 +1,10 @@
[test-groups]
run-in-isolation = { max-threads = 32 }
# these are tests that must not run with other tests concurrently. All tests in
# this group can take up at most 32 threads among them, but each one requiring
# 16 threads also. The effect should be that tests run isolated.
[[profile.ci.overrides]]
filter = 'test(::run_in_isolation::)'
test-group = 'run-in-isolation'
threads-required = 32

View file

@ -0,0 +1,13 @@
# Keep GitHub Actions up to date with GitHub's Dependabot...
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem
version: 2
updates:
- package-ecosystem: github-actions
directory: /
groups:
github-actions:
patterns:
- "*" # Group all Actions updates into a single larger pull request
schedule:
interval: weekly

View file

@ -0,0 +1,18 @@
## Description
<!-- A summary of what this pull request achieves and a rough list of changes. -->
## Breaking Changes
<!-- Optional, if there are any breaking changes document them, including how to migrate older code. -->
## Notes & open questions
<!-- Any notes, remarks or open questions you have to make about the PR. -->
## Change checklist
- [ ] Self-review.
- [ ] Documentation updates following the [style guide](https://rust-lang.github.io/rfcs/1574-more-api-documentation-conventions.html#appendix-a-full-conventions-text), if relevant.
- [ ] Tests if relevant.
- [ ] All breaking changes documented.

View file

@ -0,0 +1,43 @@
# Run tests using the beta Rust compiler
name: Beta Rust
on:
schedule:
# 06:50 UTC every Monday
- cron: '50 6 * * 1'
workflow_dispatch:
concurrency:
group: beta-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
IROH_FORCE_STAGING_RELAYS: "1"
jobs:
tests:
uses: './.github/workflows/tests.yaml'
with:
rust-version: beta
notify:
needs: tests
if: ${{ always() }}
runs-on: ubuntu-latest
steps:
- name: Extract test results
run: |
printf '${{ toJSON(needs) }}\n'
result=$(echo '${{ toJSON(needs) }}' | jq -r .tests.result)
echo TESTS_RESULT=$result
echo "TESTS_RESULT=$result" >>"$GITHUB_ENV"
- name: Notify discord on failure
uses: n0-computer/discord-webhook-notify@v1
if: ${{ env.TESTS_RESULT == 'failure' }}
with:
severity: error
details: |
Rustc beta tests failed in **${{ github.repository }}**
See https://github.com/${{ github.repository }}/actions/workflows/beta.yaml
webhookUrl: ${{ secrets.DISCORD_N0_GITHUB_CHANNEL_WEBHOOK_URL }}

View file

@ -0,0 +1,305 @@
name: CI
on:
pull_request:
types: [ 'labeled', 'unlabeled', 'opened', 'synchronize', 'reopened' ]
merge_group:
push:
branches:
- main
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
RUST_BACKTRACE: 1
RUSTFLAGS: -Dwarnings
RUSTDOCFLAGS: -Dwarnings
MSRV: "1.89"
SCCACHE_CACHE_SIZE: "50G"
IROH_FORCE_STAGING_RELAYS: "1"
jobs:
tests:
name: CI Test Suite
if: "github.event_name != 'pull_request' || ! contains(github.event.pull_request.labels.*.name, 'flaky-test')"
uses: './.github/workflows/tests.yaml'
cross_build:
name: Cross Build Only
if: "github.event_name != 'pull_request' || ! contains(github.event.pull_request.labels.*.name, 'flaky-test')"
timeout-minutes: 30
runs-on: [self-hosted, linux, X64]
strategy:
fail-fast: false
matrix:
target:
# cross tests are currently broken vor armv7 and aarch64
# see https://github.com/cross-rs/cross/issues/1311
# - armv7-linux-androideabi
# - aarch64-linux-android
# Freebsd execution fails in cross
# - i686-unknown-freebsd # Linking fails :/
- x86_64-unknown-freebsd
# Netbsd execution fails to link in cross
# - x86_64-unknown-netbsd
steps:
- name: Checkout
uses: actions/checkout@v5
with:
submodules: recursive
- name: Install rust stable
uses: dtolnay/rust-toolchain@stable
- name: Cleanup Docker
continue-on-error: true
run: |
docker kill $(docker ps -q)
# See https://github.com/cross-rs/cross/issues/1222
- uses: taiki-e/install-action@cross
- name: build
# cross tests are currently broken vor armv7 and aarch64
# see https://github.com/cross-rs/cross/issues/1311. So on
# those platforms we only build but do not run tests.
run: cross build --all --target ${{ matrix.target }}
env:
RUST_LOG: ${{ runner.debug && 'TRACE' || 'DEBUG'}}
android_build:
name: Android Build Only
if: "github.event_name != 'pull_request' || ! contains(github.event.pull_request.labels.*.name, 'flaky-test')"
timeout-minutes: 30
# runs-on: ubuntu-latest
runs-on: [self-hosted, linux, X64]
strategy:
fail-fast: false
matrix:
target:
- aarch64-linux-android
- armv7-linux-androideabi
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
target: ${{ matrix.target }}
- name: Install rustup target
run: rustup target add ${{ matrix.target }}
- name: Setup Java
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Setup Android NDK
uses: arqu/setup-ndk@main
id: setup-ndk
with:
ndk-version: r23
add-to-path: true
- name: Build
env:
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
run: |
cargo install --version 3.5.4 cargo-ndk
cargo ndk --target ${{ matrix.target }} build
cross_test:
name: Cross Test
if: "github.event_name != 'pull_request' || ! contains(github.event.pull_request.labels.*.name, 'flaky-test')"
timeout-minutes: 30
runs-on: [self-hosted, linux, X64]
strategy:
fail-fast: false
matrix:
target:
- i686-unknown-linux-gnu
steps:
- name: Checkout
uses: actions/checkout@v5
with:
submodules: recursive
- name: Install rust stable
uses: dtolnay/rust-toolchain@stable
- name: Cleanup Docker
continue-on-error: true
run: |
docker kill $(docker ps -q)
# See https://github.com/cross-rs/cross/issues/1222
- uses: taiki-e/install-action@cross
- name: test
run: cross test --all --target ${{ matrix.target }} -- --test-threads=12
env:
RUST_LOG: ${{ runner.debug && 'TRACE' || 'DEBUG' }}
wasm_build:
name: Build wasm32
runs-on: ubuntu-latest
env:
RUSTFLAGS: '--cfg getrandom_backend="wasm_js"'
steps:
- name: Checkout sources
uses: actions/checkout@v5
- name: Install stable toolchain
uses: dtolnay/rust-toolchain@stable
- name: Add wasm target
run: rustup target add wasm32-unknown-unknown
- name: Install wasm-tools
uses: bytecodealliance/actions/wasm-tools/setup@v1
- name: wasm32 build
run: cargo build --target wasm32-unknown-unknown
# If the Wasm file contains any 'import "env"' declarations, then
# some non-Wasm-compatible code made it into the final code.
- name: Ensure no 'import "env"' in iroh-relay Wasm
run: |
! wasm-tools print --skeleton target/wasm32-unknown-unknown/debug/iroh_gossip.wasm | grep 'import "env"'
check_semver:
runs-on: ubuntu-latest
env:
RUSTC_WRAPPER: "sccache"
SCCACHE_GHA_ENABLED: "on"
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install sccache
uses: mozilla-actions/sccache-action@v0.0.9
- name: Setup Environment (PR)
if: ${{ github.event_name == 'pull_request' }}
shell: bash
run: |
echo "HEAD_COMMIT_SHA=$(git rev-parse origin/${{ github.base_ref }})" >> ${GITHUB_ENV}
- name: Setup Environment (Push)
if: ${{ github.event_name == 'push' || github.event_name == 'merge_group' }}
shell: bash
run: |
echo "HEAD_COMMIT_SHA=$(git rev-parse origin/main)" >> ${GITHUB_ENV}
- name: Check semver
# uses: obi1kenobi/cargo-semver-checks-action@v2
uses: n0-computer/cargo-semver-checks-action@feat-baseline
with:
package: iroh-gossip
baseline-rev: ${{ env.HEAD_COMMIT_SHA }}
use-cache: false
check_fmt:
timeout-minutes: 30
name: Checking fmt
runs-on: ubuntu-latest
env:
RUSTC_WRAPPER: "sccache"
SCCACHE_GHA_ENABLED: "on"
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- uses: mozilla-actions/sccache-action@v0.0.9
- uses: taiki-e/install-action@cargo-make
- run: cargo make format-check
check_docs:
timeout-minutes: 30
name: Checking docs
runs-on: ubuntu-latest
env:
RUSTC_WRAPPER: "sccache"
SCCACHE_GHA_ENABLED: "on"
RUSTDOCFLAGS: -Dwarnings
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@nightly
- uses: dtolnay/install@cargo-docs-rs
- name: Install sccache
uses: mozilla-actions/sccache-action@v0.0.9
- name: iroh-gossip docs
run: cargo docs-rs
clippy_check:
timeout-minutes: 30
runs-on: ubuntu-latest
env:
RUSTC_WRAPPER: "sccache"
SCCACHE_GHA_ENABLED: "on"
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Install sccache
uses: mozilla-actions/sccache-action@v0.0.9
# TODO: We have a bunch of platform-dependent code so should
# probably run this job on the full platform matrix
- name: clippy check (all features)
run: cargo clippy --workspace --all-features --all-targets --bins --tests --benches
- name: clippy check (no features)
run: cargo clippy --workspace --no-default-features --lib --bins --tests
- name: clippy check (default features)
run: cargo clippy --workspace --all-targets
msrv:
if: "github.event_name != 'pull_request' || ! contains(github.event.pull_request.labels.*.name, 'flaky-test')"
timeout-minutes: 30
name: Minimal Supported Rust Version
runs-on: ubuntu-latest
env:
RUSTC_WRAPPER: "sccache"
SCCACHE_GHA_ENABLED: "on"
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.MSRV }}
- name: Install sccache
uses: mozilla-actions/sccache-action@v0.0.9
- name: Check MSRV all features
run: |
cargo +$MSRV check --workspace --all-targets
cargo_deny:
timeout-minutes: 30
name: cargo deny
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: EmbarkStudios/cargo-deny-action@v2
with:
arguments: --workspace --all-features
command: check
command-arguments: "-Dwarnings"
codespell:
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: pip install --user codespell[toml]
- run: codespell --ignore-words-list=ans,atmost,crate,inout,ratatui,ser,stayin,swarmin,worl --skip=CHANGELOG.md

View file

@ -0,0 +1,45 @@
# Run tests using the beta Rust compiler
name: Cleanup
on:
schedule:
# 06:50 UTC every Monday
- cron: '50 6 * * 1'
workflow_dispatch:
concurrency:
group: beta-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
IROH_FORCE_STAGING_RELAYS: "1"
jobs:
clean_docs_branch:
permissions:
issues: write
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
ref: generated-docs-preview
- name: Clean docs branch
run: |
cd pr/
# keep the last 25 prs
dirs=$(ls -1d [0-9]* | sort -n)
total_dirs=$(echo "$dirs" | wc -l)
dirs_to_remove=$(echo "$dirs" | head -n $(($total_dirs - 25)))
if [ -n "$dirs_to_remove" ]; then
echo "$dirs_to_remove" | xargs rm -rf
fi
git add .
git commit -m "Cleanup old docs"
git push

View file

@ -0,0 +1,19 @@
name: Commits
on:
pull_request:
branches: [main]
types: [opened, edited, synchronize]
env:
IROH_FORCE_STAGING_RELAYS: "1"
jobs:
check-for-cc:
runs-on: ubuntu-latest
steps:
- name: check-for-cc
id: check-for-cc
uses: agenthunt/conventional-commit-checker-action@v2.0.0
with:
pr-title-regex: "^(.+)(?:(([^)s]+)))?!?: (.+)"

View file

@ -0,0 +1,73 @@
name: Docs Preview
on:
pull_request:
workflow_dispatch:
inputs:
pr_number:
required: true
type: string
# ensure job runs sequentially so pushing to the preview branch doesn't conflict
concurrency:
group: ci-docs-preview
env:
IROH_FORCE_STAGING_RELAYS: "1"
jobs:
preview_docs:
permissions: write-all
timeout-minutes: 30
name: Docs preview
if: ${{ (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' ) && !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
env:
RUSTC_WRAPPER: "sccache"
SCCACHE_GHA_ENABLED: "on"
SCCACHE_CACHE_SIZE: "50G"
PREVIEW_PATH: pr/${{ github.event.pull_request.number || inputs.pr_number }}/docs
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2025-10-09
- name: Install sccache
uses: mozilla-actions/sccache-action@v0.0.9
- name: Generate Docs
run: cargo doc --workspace --all-features --no-deps
env:
RUSTDOCFLAGS: --cfg iroh_docsrs
- name: Deploy Docs to Preview Branch
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./target/doc/
destination_dir: ${{ env.PREVIEW_PATH }}
publish_branch: generated-docs-preview
- name: Find Docs Comment
uses: peter-evans/find-comment@v4
id: fc
with:
issue-number: ${{ github.event.pull_request.number || inputs.pr_number }}
comment-author: 'github-actions[bot]'
body-includes: Documentation for this PR has been generated
- name: Get current timestamp
id: get_timestamp
run: echo "TIMESTAMP=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
- name: Create or Update Docs Comment
uses: peter-evans/create-or-update-comment@v5
with:
issue-number: ${{ github.event.pull_request.number || inputs.pr_number }}
comment-id: ${{ steps.fc.outputs.comment-id }}
body: |
Documentation for this PR has been generated and is available at: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/${{ env.PREVIEW_PATH }}/iroh_gossip/
Last updated: ${{ env.TIMESTAMP }}
edit-mode: replace

View file

@ -0,0 +1,99 @@
# Run all tests, including flaky test.
#
# The default CI workflow ignores flaky tests. This workflow will run
# all tests, including ignored ones.
#
# To use this workflow you can either:
#
# - Label a PR with "flaky-test", the normal CI workflow will not run
# any jobs but the jobs here will be run. Note that to merge the PR
# you'll need to remove the label eventually because the normal CI
# jobs are required by branch protection.
#
# - Manually trigger the workflow, you may choose a branch for this to
# run on.
#
# Additionally this jobs runs once a day on a schedule.
#
# Currently doctests are not run by this workflow.
name: Flaky CI
on:
pull_request:
types: [ 'labeled', 'unlabeled', 'opened', 'synchronize', 'reopened' ]
schedule:
# 06:30 UTC every day
- cron: '30 6 * * *'
workflow_dispatch:
inputs:
branch:
description: 'Branch to run on, defaults to main'
required: true
default: 'main'
type: string
concurrency:
group: flaky-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
IROH_FORCE_STAGING_RELAYS: "1"
jobs:
tests:
if: "contains(github.event.pull_request.labels.*.name, 'flaky-test') || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'"
uses: './.github/workflows/tests.yaml'
with:
flaky: true
git-ref: ${{ inputs.branch }}
notify:
needs: tests
if: ${{ always() }}
runs-on: ubuntu-latest
steps:
- name: Extract test results
run: |
printf '${{ toJSON(needs) }}\n'
result=$(echo '${{ toJSON(needs) }}' | jq -r .tests.result)
echo TESTS_RESULT=$result
echo "TESTS_RESULT=$result" >>"$GITHUB_ENV"
- name: download nextest reports
uses: actions/download-artifact@v5
with:
pattern: libtest_run_${{ github.run_number }}-${{ github.run_attempt }}-*
merge-multiple: true
path: nextest-results
- name: create summary report
id: make_summary
run: |
# prevent the glob expression in the loop to match on itself when the dir is empty
shopt -s nullglob
# to deal with multiline outputs it's recommended to use a random EOF, the syntax is based on
# https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings
EOF=aP51VriWCxNJ1JjvmO9i
echo "summary<<$EOF" >> $GITHUB_OUTPUT
echo "Flaky tests failure:" >> $GITHUB_OUTPUT
echo " " >> $GITHUB_OUTPUT
for report in nextest-results/*.json; do
# remove the name prefix and extension, and split the parts
name=$(echo ${report:16:-5} | tr _ ' ')
echo $name
echo "- **$name**" >> $GITHUB_OUTPUT
# select the failed tests
# the tests have this format "crate::module$test_name", the sed expressions remove the quotes and replace $ for ::
failure=$(jq --slurp '.[] | select(.["type"] == "test" and .["event"] == "failed" ) | .["name"]' $report | sed -e 's/^"//g' -e 's/\$/::/' -e 's/"//')
echo "$failure"
echo "$failure" >> $GITHUB_OUTPUT
done
echo "" >> $GITHUB_OUTPUT
echo "See https://github.com/${{ github.repository }}/actions/workflows/flaky.yaml" >> $GITHUB_OUTPUT
echo "$EOF" >> $GITHUB_OUTPUT
- name: Notify discord on failure
uses: n0-computer/discord-webhook-notify@v1
if: ${{ env.TESTS_RESULT == 'failure' || env.TESTS_RESULT == 'success' }}
with:
text: "Flaky tests in **${{ github.repository }}**:"
severity: ${{ env.TESTS_RESULT == 'failure' && 'warn' || 'info' }}
details: ${{ env.TESTS_RESULT == 'failure' && steps.make_summary.outputs.summary || 'No flaky failures!' }}
webhookUrl: ${{ secrets.DISCORD_N0_GITHUB_CHANNEL_WEBHOOK_URL }}

View file

@ -0,0 +1,50 @@
name: release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_version:
description: "Release version"
required: true
default: ""
create_release:
description: "Create release"
required: true
default: "true"
jobs:
create-release:
name: create-release
runs-on: ubuntu-latest
outputs:
upload_url: ${{ steps.release.outputs.upload_url }}
release_version: ${{ env.RELEASE_VERSION }}
steps:
- name: Get the release version from the tag (push)
shell: bash
if: env.RELEASE_VERSION == '' && github.event_name == 'push'
run: |
# See: https://github.community/t5/GitHub-Actions/How-to-get-just-the-tag-name/m-p/32167/highlight/true#M1027
echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
echo "version is: ${{ env.RELEASE_VERSION }}"
- name: Get the release version from the tag (dispatch)
shell: bash
if: github.event_name == 'workflow_dispatch'
run: |
echo "RELEASE_VERSION=${{ github.event.inputs.release_version }}" >> $GITHUB_ENV
echo "version is: ${{ env.RELEASE_VERSION }}"
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Create GitHub release
id: release
if: github.event.inputs.create_release == 'true' || github.event_name == 'push'
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ env.RELEASE_VERSION }}
release_name: ${{ env.RELEASE_VERSION }}

View file

@ -0,0 +1,53 @@
name: run simulations
on:
pull_request:
jobs:
run_sim:
runs-on: ubuntu-latest
env:
RUSTC_WRAPPER: "sccache"
SCCACHE_GHA_ENABLED: "on"
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install sccache
uses: mozilla-actions/sccache-action@v0.0.9
- name: Run simulations
run: |
git checkout ${{ github.event.pull_request.base.ref }}
cargo run -q --bin sim --features simulator --release -- run -c simulations/all.toml -o /tmp/sim-main
git checkout ${{ github.event.pull_request.head.sha }}
cargo run -q --bin sim --features simulator --release -- run -c simulations/all.toml -o /tmp/sim-pr --baseline /tmp/sim-main |& tee REPORT
echo "<details><summary>Simulation report</summary>" >> COMMENT
echo "" >> COMMENT
echo '```' >> COMMENT
cat REPORT >> COMMENT
echo "" >> COMMENT
echo '```' >> COMMENT
echo "</details>" >> COMMENT
echo "" >> COMMENT
echo "*Last updated: $(date -u +'%Y-%m-%dT%H:%M:%SZ')*" >> COMMENT
- name: Find Docs Comment
uses: peter-evans/find-comment@v4
id: fc
with:
issue-number: ${{ github.event.pull_request.number || inputs.pr_number }}
comment-author: "github-actions[bot]"
body-includes: Simulation report
- name: Create or Update Docs Comment
uses: peter-evans/create-or-update-comment@v5
with:
issue-number: ${{ github.event.pull_request.number || inputs.pr_number }}
comment-id: ${{ steps.fc.outputs.comment-id }}
body-path: COMMENT
edit-mode: replace

View file

@ -0,0 +1,229 @@
# Run all tests, with or without flaky tests.
name: Tests
on:
workflow_call:
inputs:
rust-version:
description: 'The version of the rust compiler to run'
type: string
default: 'stable'
flaky:
description: 'Whether to also run flaky tests'
type: boolean
default: false
git-ref:
description: 'Which git ref to checkout'
type: string
default: ${{ github.ref }}
env:
RUST_BACKTRACE: 1
RUSTFLAGS: -Dwarnings
RUSTDOCFLAGS: -Dwarnings
SCCACHE_CACHE_SIZE: "50G"
CRATES_LIST: "iroh-gossip"
IROH_FORCE_STAGING_RELAYS: "1"
jobs:
build_and_test_nix:
timeout-minutes: 30
name: "Tests"
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
name: [ubuntu-latest, macOS-arm-latest]
rust: [ '${{ inputs.rust-version }}' ]
features: [all, none, default]
include:
- name: ubuntu-latest
os: ubuntu-latest
release-os: linux
release-arch: amd64
runner: [self-hosted, linux, X64]
- name: macOS-arm-latest
os: macOS-latest
release-os: darwin
release-arch: aarch64
runner: [self-hosted, macOS, ARM64]
env:
# Using self-hosted runners so use local cache for sccache and
# not SCCACHE_GHA_ENABLED.
RUSTC_WRAPPER: "sccache"
steps:
- name: Checkout
uses: actions/checkout@v5
with:
ref: ${{ inputs.git-ref }}
- name: Install ${{ matrix.rust }} rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.rust }}
- name: Install cargo-nextest
uses: taiki-e/install-action@v2
with:
tool: nextest@0.9.80
- name: Install sccache
uses: mozilla-actions/sccache-action@v0.0.9
- name: Select features
run: |
case "${{ matrix.features }}" in
all)
echo "FEATURES=--all-features" >> "$GITHUB_ENV"
;;
none)
echo "FEATURES=--no-default-features" >> "$GITHUB_ENV"
;;
default)
echo "FEATURES=" >> "$GITHUB_ENV"
;;
*)
exit 1
esac
- name: check features
if: ${{ ! inputs.flaky }}
run: |
for i in ${CRATES_LIST//,/ }
do
echo "Checking $i $FEATURES"
if [ $i = "iroh-cli" ]; then
targets="--bins"
else
targets="--lib --bins"
fi
echo cargo check -p $i $FEATURES $targets
cargo check -p $i $FEATURES $targets
done
env:
RUST_LOG: ${{ runner.debug && 'TRACE' || 'DEBUG'}}
- name: build tests
run: |
cargo nextest run --workspace ${{ env.FEATURES }} --lib --bins --tests --no-run
- name: list ignored tests
run: |
cargo nextest list --workspace ${{ env.FEATURES }} --lib --bins --tests --run-ignored ignored-only
- name: run tests
run: |
mkdir -p output
cargo nextest run --workspace ${{ env.FEATURES }} --lib --bins --tests --profile ci --run-ignored ${{ inputs.flaky && 'all' || 'default' }} --no-fail-fast --message-format ${{ inputs.flaky && 'libtest-json' || 'human' }} > output/${{ matrix.name }}_${{ matrix.features }}_${{ matrix.rust }}.json
env:
RUST_LOG: ${{ runner.debug && 'TRACE' || 'DEBUG'}}
NEXTEST_EXPERIMENTAL_LIBTEST_JSON: 1
- name: upload results
if: ${{ failure() && inputs.flaky }}
uses: actions/upload-artifact@v4
with:
name: libtest_run_${{ github.run_number }}-${{ github.run_attempt }}-${{ matrix.name }}_${{ matrix.features }}_${{ matrix.rust }}.json
path: output
retention-days: 45
compression-level: 0
- name: doctests
if: ${{ (! inputs.flaky) && matrix.features == 'all' }}
run: |
if [ -n "${{ runner.debug }}" ]; then
export RUST_LOG=TRACE
else
export RUST_LOG=DEBUG
fi
cargo test --workspace --all-features --doc
build_and_test_windows:
timeout-minutes: 30
name: "Tests"
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
name: [windows-latest]
rust: [ '${{ inputs.rust-version}}' ]
features: [all, none, default]
target:
- x86_64-pc-windows-msvc
include:
- name: windows-latest
os: windows
runner: [self-hosted, windows, x64]
env:
# Using self-hosted runners so use local cache for sccache and
# not SCCACHE_GHA_ENABLED.
RUSTC_WRAPPER: "sccache"
steps:
- name: Checkout
uses: actions/checkout@v5
with:
ref: ${{ inputs.git-ref }}
- name: Install ${{ matrix.rust }}
run: |
rustup toolchain install ${{ matrix.rust }}
rustup toolchain default ${{ matrix.rust }}
rustup target add ${{ matrix.target }}
rustup set default-host ${{ matrix.target }}
- name: Install cargo-nextest
shell: powershell
run: |
$tmp = New-TemporaryFile | Rename-Item -NewName { $_ -replace 'tmp$', 'zip' } -PassThru
Invoke-WebRequest -OutFile $tmp https://get.nexte.st/latest/windows
$outputDir = if ($Env:CARGO_HOME) { Join-Path $Env:CARGO_HOME "bin" } else { "~/.cargo/bin" }
$tmp | Expand-Archive -DestinationPath $outputDir -Force
$tmp | Remove-Item
- name: Select features
run: |
switch ("${{ matrix.features }}") {
"all" {
echo "FEATURES=--all-features" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append
}
"none" {
echo "FEATURES=--no-default-features" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append
}
"default" {
echo "FEATURES=" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append
}
default {
Exit 1
}
}
- name: Install sccache
uses: mozilla-actions/sccache-action@v0.0.9
- uses: msys2/setup-msys2@v2
- name: build tests
run: |
cargo nextest run --workspace ${{ env.FEATURES }} --lib --bins --tests --target ${{ matrix.target }} --no-run
- name: list ignored tests
run: |
cargo nextest list --workspace ${{ env.FEATURES }} --lib --bins --tests --target ${{ matrix.target }} --run-ignored ignored-only
- name: tests
run: |
mkdir -p output
cargo nextest run --workspace ${{ env.FEATURES }} --lib --bins --tests --profile ci --target ${{ matrix.target }} --run-ignored ${{ inputs.flaky && 'all' || 'default' }} --no-fail-fast --message-format ${{ inputs.flaky && 'libtest-json' || 'human' }} > output/${{ matrix.name }}_${{ matrix.features }}_${{ matrix.rust }}.json
env:
RUST_LOG: ${{ runner.debug && 'TRACE' || 'DEBUG'}}
NEXTEST_EXPERIMENTAL_LIBTEST_JSON: 1
- name: upload results
if: ${{ failure() && inputs.flaky }}
uses: actions/upload-artifact@v4
with:
name: libtest_run_${{ github.run_number }}-${{ github.run_attempt }}-${{ matrix.name }}_${{ matrix.features }}_${{ matrix.rust }}.json
path: output
retention-days: 1
compression-level: 0

View file

@ -0,0 +1 @@
/target

View file

@ -0,0 +1,250 @@
# Changelog
All notable changes to iroh-gossip will be documented in this file.
## [0.96.0](https://github.com/n0-computer/iroh-gossip/compare/v0.95.0..0.96.0) - 2026-01-29
### ⛰️ Features
- Add `neighbors()` method to `GossipTopic` ([#124](https://github.com/n0-computer/iroh-gossip/issues/124)) - ([9e4ddaa](https://github.com/n0-computer/iroh-gossip/commit/9e4ddaa904c6e1b853081b9f2e4f9628ed274b08))
### 🐛 Bug Fixes
- Keep topic alive if either senders or receivers exist ([#119](https://github.com/n0-computer/iroh-gossip/issues/119)) - ([34b0e0e](https://github.com/n0-computer/iroh-gossip/commit/34b0e0ea87a2f0a6011d026bcffce78697689072))
- Clean-up connections after unexpected disconnects ([#117](https://github.com/n0-computer/iroh-gossip/issues/117)) - ([84f3945](https://github.com/n0-computer/iroh-gossip/commit/84f394577a4e8b52660a83dbbc955f0accb31b5a))
### 🧪 Testing
- Switch from tracing-test to n0-tracing-test ([#125](https://github.com/n0-computer/iroh-gossip/issues/125)) - ([48f522b](https://github.com/n0-computer/iroh-gossip/commit/48f522bb1504a7fbf4eb51112ea724fa1593a35f))
### ⚙️ Miscellaneous Tasks
- Ignore rustls-pemfile unmaintained advisory ([#122](https://github.com/n0-computer/iroh-gossip/issues/122)) - ([16c68bb](https://github.com/n0-computer/iroh-gossip/commit/16c68bb187c7127eb5694bfbbd1ec3da518bcc25))
- Upgrade to `iroh`v0.96 and the latest version of `iroh-quinn` ([#114](https://github.com/n0-computer/iroh-gossip/issues/114)) - ([13ef379](https://github.com/n0-computer/iroh-gossip/commit/13ef379f60e91f7292ae287051245db25ca8dd02))
## [0.95.0](https://github.com/n0-computer/iroh-gossip/compare/v0.94.0..0.95.0) - 2025-11-06
### Deps/refactor
- [**breaking**] Update to iroh main, port to n0-error ([#113](https://github.com/n0-computer/iroh-gossip/issues/113)) - ([4d2cb2f](https://github.com/n0-computer/iroh-gossip/commit/4d2cb2f3891e8dadd89a985fb6b5ad55d92e4c59))
## [0.94.0](https://github.com/n0-computer/iroh-gossip/compare/v0.93.1..0.94.0) - 2025-10-21
### 🚜 Refactor
- Use discovery service instead of `Endpoint::add_node_addr` ([#108](https://github.com/n0-computer/iroh-gossip/issues/108)) - ([f7e3ef4](https://github.com/n0-computer/iroh-gossip/commit/f7e3ef478a1c4f1ea934e29f3436582e68de734c))
### ⚙️ Miscellaneous Tasks
- Upgrade to iroh 0.94 ([#110](https://github.com/n0-computer/iroh-gossip/issues/110)) - ([ad78602](https://github.com/n0-computer/iroh-gossip/commit/ad78602a4bafad8db2a4264bf16fde12b08f7a5e))
## [0.93.1](https://github.com/n0-computer/iroh-gossip/compare/v0.93.0..0.93.1) - 2025-10-11
### ⚙️ Miscellaneous Tasks
- Update nightly version in CI and docs workflows ([#107](https://github.com/n0-computer/iroh-gossip/issues/107)) - ([b5e3414](https://github.com/n0-computer/iroh-gossip/commit/b5e3414f8db03910b6cea691bad69f798f1c34c6))
## [0.93.0](https://github.com/n0-computer/iroh-gossip/compare/v0.92.0..0.93.0) - 2025-10-09
### ⛰️ Features
- *(ci)* Add auto release on tag version push ([#93](https://github.com/n0-computer/iroh-gossip/issues/93)) - ([afa6e1d](https://github.com/n0-computer/iroh-gossip/commit/afa6e1dca9cef061642bece52fcdad5e877496e3))
- Set custom ALPN ([#92](https://github.com/n0-computer/iroh-gossip/issues/92)) - ([ff87b6a](https://github.com/n0-computer/iroh-gossip/commit/ff87b6a380a39c8274376ff26f874824ff80d752))
### ⚡ Performance
- Don't allocate in `Timers::wait_next` ([#102](https://github.com/n0-computer/iroh-gossip/issues/102)) - ([65278b7](https://github.com/n0-computer/iroh-gossip/commit/65278b75aa67cb6bed06c9770cacf701e952c0d3))
### ⚙️ Miscellaneous Tasks
- *(ci)* Fix url of the beta notification ([#94](https://github.com/n0-computer/iroh-gossip/issues/94)) - ([2021566](https://github.com/n0-computer/iroh-gossip/commit/20215660a71f192562477dafd677d5a974c6a7f3))
- Release prep ([#106](https://github.com/n0-computer/iroh-gossip/issues/106)) - ([099196e](https://github.com/n0-computer/iroh-gossip/commit/099196e72d806392ed609dfe36510b130839d52e))
## [0.92.0](https://github.com/n0-computer/iroh-gossip/compare/v0.91.0..0.92.0) - 2025-09-18
### ⚙️ Miscellaneous Tasks
- Upgrade `iroh`, `iroh-base`, `irpc` ([#91](https://github.com/n0-computer/iroh-gossip/issues/91)) - ([464fe69](https://github.com/n0-computer/iroh-gossip/commit/464fe69789ae8c8fefd7734a2f44db5aa447db26))
## [0.91.0](https://github.com/n0-computer/iroh-gossip/compare/v0.90.0..0.91.0) - 2025-07-31
### 🐛 Bug Fixes
- Make GossipSender `Clone` and GossipTopic `Sync` ([#81](https://github.com/n0-computer/iroh-gossip/issues/81)) - ([f215aa1](https://github.com/n0-computer/iroh-gossip/commit/f215aa13806425491cf328e42c611a0002da4371))
### 📚 Documentation
- Replace `iroh-net` mention in README ([#83](https://github.com/n0-computer/iroh-gossip/issues/83)) - ([e3df4ec](https://github.com/n0-computer/iroh-gossip/commit/e3df4ec7a56bcff0dafe6940d7d706ece5508891))
### ⚙️ Miscellaneous Tasks
- Add patch for `iroh` dependencies ([#82](https://github.com/n0-computer/iroh-gossip/issues/82)) - ([2e82a68](https://github.com/n0-computer/iroh-gossip/commit/2e82a683f93aa1ae50929da8ce95b23f85b466f1))
- [**breaking**] Prep for `v0.91.0` release ([#85](https://github.com/n0-computer/iroh-gossip/issues/85)) - ([d1fbfca](https://github.com/n0-computer/iroh-gossip/commit/d1fbfca15f484a41b8d6e8f771d14bf9fe5c7f81))
### Deps
- Update to irpc@main, iroh@main ([#84](https://github.com/n0-computer/iroh-gossip/issues/84)) - ([af7ae1f](https://github.com/n0-computer/iroh-gossip/commit/af7ae1f9bb9fa74aef97d510e062b09c03e96a87))
## [0.90.0](https://github.com/n0-computer/iroh-gossip/compare/v0.35.0..0.90.0) - 2025-06-27
### ⛰️ Features
- *(net)* Add shutdown function ([#69](https://github.com/n0-computer/iroh-gossip/issues/69)) - ([3cf2cd2](https://github.com/n0-computer/iroh-gossip/commit/3cf2cd2f3af5c79832b335b525b34db4290d0332))
### 🐛 Bug Fixes
- *(hyparview)* [**breaking**] Only add peers to active view after receiving neighbor messages ([#56](https://github.com/n0-computer/iroh-gossip/issues/56)) - ([5a441e6](https://github.com/n0-computer/iroh-gossip/commit/5a441e6cf5589fc3c7cf3c290005b1094895038c))
- *(hyparview)* Use shuffle replies as intended ([#57](https://github.com/n0-computer/iroh-gossip/issues/57)) - ([9632ced](https://github.com/n0-computer/iroh-gossip/commit/9632ced028ad7c211ac256b89d7ac0fcb32f55a6))
- *(hyparview)* Don't emit PeerData event for empty PeerData - ([c345f0a](https://github.com/n0-computer/iroh-gossip/commit/c345f0a0a6a099643f13a7a6743b308548a1c40e))
- *(plumtree)* Ensure eager relation is symmetrical - ([0abface](https://github.com/n0-computer/iroh-gossip/commit/0abface77c81dfde91c9f37da1f00bfee36f86d7))
- *(plumtree)* Clear graft timer to allow retry on new ihaves - ([b65cdce](https://github.com/n0-computer/iroh-gossip/commit/b65cdcea36c8b30ac3ab6443645c6e69795f6ebf))
### 🚜 Refactor
- *(hyparview)* Improve disconnect handling - ([5156d00](https://github.com/n0-computer/iroh-gossip/commit/5156d00f72be478872e7cecdf1b95d739b3b4fca))
- *(hyparview)* Remove obsolete parameter in hyparview - ([d954aa6](https://github.com/n0-computer/iroh-gossip/commit/d954aa62272d7f781ce762b42b06d2521e7d1b30))
- *(net)* [**breaking**] Remove `Joined` event, use `NeighborUp` ([#49](https://github.com/n0-computer/iroh-gossip/issues/49)) - ([c06f2ed](https://github.com/n0-computer/iroh-gossip/commit/c06f2ed64cb887d0714dfa1e75c0d66051c9d3e1))
- [**breaking**] Port to irpc, flatten event enum, remove cli impl ([#67](https://github.com/n0-computer/iroh-gossip/issues/67)) - ([a8d5cd2](https://github.com/n0-computer/iroh-gossip/commit/a8d5cd2b4c749993dd99f9d5eead073fd4b2498d))
- [**breaking**] Port to iroh@0.90 and n0-snafu ([#77](https://github.com/n0-computer/iroh-gossip/issues/77)) - ([1523227](https://github.com/n0-computer/iroh-gossip/commit/1523227c980c7d58efff805645aa50bea17402b0))
- [**breaking**] Change wire protocol to use uni streams per topic ([#75](https://github.com/n0-computer/iroh-gossip/issues/75)) - ([db1a135](https://github.com/n0-computer/iroh-gossip/commit/db1a13550d7b014e959fe807b45c3614e26e7105))
### 📚 Documentation
- Deny warnings for docs in CI ([#78](https://github.com/n0-computer/iroh-gossip/issues/78)) - ([b38b38f](https://github.com/n0-computer/iroh-gossip/commit/b38b38fc5970164a3c037b4d6306d8b7aee10f4f))
### 🧪 Testing
- Improve simulator ([#52](https://github.com/n0-computer/iroh-gossip/issues/52)) - ([8c30674](https://github.com/n0-computer/iroh-gossip/commit/8c306742c5823f8a6655252b1dbbbfb021c3400d))
### ⚙️ Miscellaneous Tasks
- Update clippy ([#79](https://github.com/n0-computer/iroh-gossip/issues/79)) - ([07b7b77](https://github.com/n0-computer/iroh-gossip/commit/07b7b77a8ceacad8094ec83209aa3d701a63d5b4))
- Upgrade to `iroh` at `0.90.0` and `irpc` at `0.5.0` ([#80](https://github.com/n0-computer/iroh-gossip/issues/80)) - ([0e613d8](https://github.com/n0-computer/iroh-gossip/commit/0e613d884e95203940d94b3b5363c972f4ef00d1))
### Change
- *(hyparview)* Send a ShuffleReply before disconnecting ([#59](https://github.com/n0-computer/iroh-gossip/issues/59)) - ([fd379fc](https://github.com/n0-computer/iroh-gossip/commit/fd379fc5f32ee52c2c7aad03c03c373c2ac69816))
## [0.35.0](https://github.com/n0-computer/iroh-gossip/compare/v0.34.1..0.35.0) - 2025-05-12
### 🐛 Bug Fixes
- Respect max message size when constructing IHave messages ([#63](https://github.com/n0-computer/iroh-gossip/issues/63)) - ([77c56f1](https://github.com/n0-computer/iroh-gossip/commit/77c56f1a769e561d1c8b91ebed6e02e7792bc2cb))
### 🚜 Refactor
- [**breaking**] Use new iroh-metrics version, no more global tracking ([#58](https://github.com/n0-computer/iroh-gossip/issues/58)) - ([2a37214](https://github.com/n0-computer/iroh-gossip/commit/2a372144b08f6db43f67536e8694659b4b326698))
### ⚙️ Miscellaneous Tasks
- Update dependencies ([#66](https://github.com/n0-computer/iroh-gossip/issues/66)) - ([dbec9b0](https://github.com/n0-computer/iroh-gossip/commit/dbec9b033cded5aa3e09b0c80d52bed697dfe880))
- Update to `iroh` v0.35 ([#68](https://github.com/n0-computer/iroh-gossip/issues/68)) - ([e6af27d](https://github.com/n0-computer/iroh-gossip/commit/e6af27d924db780e00b10017b18d4da3ef8db18a))
## [0.34.1](https://github.com/n0-computer/iroh-gossip/compare/v0.34.0..0.34.1) - 2025-03-24
### 🐛 Bug Fixes
- Allow instant reconnects, and always prefer newest connection ([#43](https://github.com/n0-computer/iroh-gossip/issues/43)) - ([ea1c773](https://github.com/n0-computer/iroh-gossip/commit/ea1c773659f88d7eed776b6b15cc0e559267afea))
## [0.34.0](https://github.com/n0-computer/iroh-gossip/compare/v0.33.0..0.34.0) - 2025-03-18
### 🐛 Bug Fixes
- Repo link for flaky tests ([#38](https://github.com/n0-computer/iroh-gossip/issues/38)) - ([0a03543](https://github.com/n0-computer/iroh-gossip/commit/0a03543db6aaedb7ac403e38360d5a1afc88b3f4))
### ⚙️ Miscellaneous Tasks
- Patch to use main branch of iroh dependencies ([#40](https://github.com/n0-computer/iroh-gossip/issues/40)) - ([d76305d](https://github.com/n0-computer/iroh-gossip/commit/d76305da7d75639638efcd537a1ffb13d07ef1ee))
- Update to latest iroh ([#42](https://github.com/n0-computer/iroh-gossip/issues/42)) - ([129e2e8](https://github.com/n0-computer/iroh-gossip/commit/129e2e80ec7a6efd29606fcdaf0202791a25778f))
## [0.33.0](https://github.com/n0-computer/iroh-gossip/compare/v0.32.0..0.33.0) - 2025-02-25
### ⛰️ Features
- Compile to wasm and run in browsers ([#37](https://github.com/n0-computer/iroh-gossip/issues/37)) - ([8f99f7d](https://github.com/n0-computer/iroh-gossip/commit/8f99f7d85fd8c410512b430a4ee2efd014828550))
### ⚙️ Miscellaneous Tasks
- Patch to use main branch of iroh dependencies ([#36](https://github.com/n0-computer/iroh-gossip/issues/36)) - ([7e16be8](https://github.com/n0-computer/iroh-gossip/commit/7e16be85dbf52af721aa8bb4c68723c029ce4bd2))
- Upgrade to latest `iroh` and `quic-rpc` ([#39](https://github.com/n0-computer/iroh-gossip/issues/39)) - ([a2ef813](https://github.com/n0-computer/iroh-gossip/commit/a2ef813c6033f1683162bb09d50f1f988f774cbe))
## [0.32.0](https://github.com/n0-computer/iroh-gossip/compare/v0.31.0..0.32.0) - 2025-02-04
### ⛰️ Features
- [**breaking**] Use explicit errors ([#34](https://github.com/n0-computer/iroh-gossip/issues/34)) - ([534f010](https://github.com/n0-computer/iroh-gossip/commit/534f01046332a21f6356d189c686f7c6c17af3c2))
### ⚙️ Miscellaneous Tasks
- Pin nextest version ([#29](https://github.com/n0-computer/iroh-gossip/issues/29)) - ([72b32d2](https://github.com/n0-computer/iroh-gossip/commit/72b32d25e8a810011456a2740581b3b3802f1cab))
- Remove individual repo project tracking ([#31](https://github.com/n0-computer/iroh-gossip/issues/31)) - ([8a79db6](https://github.com/n0-computer/iroh-gossip/commit/8a79db65a928ae0610d85301b009d3ec13b0fbe1))
- Update dependencies ([#35](https://github.com/n0-computer/iroh-gossip/issues/35)) - ([3c257a1](https://github.com/n0-computer/iroh-gossip/commit/3c257a1db9ea0ade0c35b060a28b1287321a532a))
## [0.31.0](https://github.com/n0-computer/iroh-gossip/compare/v0.30.1..0.31.0) - 2025-01-14
### ⚙️ Miscellaneous Tasks
- Add project tracking ([#28](https://github.com/n0-computer/iroh-gossip/issues/28)) - ([bf89c85](https://github.com/n0-computer/iroh-gossip/commit/bf89c85c3ffa78fea462d5ad7c7bae10f828d7b0))
- Upgrade to `iroh@v0.31.0` ([#30](https://github.com/n0-computer/iroh-gossip/issues/30)) - ([60f371e](https://github.com/n0-computer/iroh-gossip/commit/60f371ec61992889c390d64611e907a491812b96))
## [0.30.1](https://github.com/n0-computer/iroh-gossip/compare/v0.30.0..0.30.1) - 2024-12-20
### 🐛 Bug Fixes
- Add missing Sync bound to EventStream's inner - ([d7039c4](https://github.com/n0-computer/iroh-gossip/commit/d7039c4684e0072bce1c1fe4bce7d39ba42e8390))
## [0.30.0](https://github.com/n0-computer/iroh-gossip/compare/v0.29.0..0.30.0) - 2024-12-17
### ⛰️ Features
- Remove rpc from default features - ([10e9b68](https://github.com/n0-computer/iroh-gossip/commit/10e9b685f6ede483ace4be4360466a111dfcfec4))
- [**breaking**] Introduce builder pattern to construct Gossip ([#17](https://github.com/n0-computer/iroh-gossip/issues/17)) - ([0e6fd20](https://github.com/n0-computer/iroh-gossip/commit/0e6fd20203c6468af9d783f1e62379eca283188a))
- Update to iroh 0.30 - ([b3a5a33](https://github.com/n0-computer/iroh-gossip/commit/b3a5a33351b57e01cba816826d642f3314f00e7d))
### 🐛 Bug Fixes
- Improve connection handling ([#22](https://github.com/n0-computer/iroh-gossip/issues/22)) - ([61e64c7](https://github.com/n0-computer/iroh-gossip/commit/61e64c79961640cd2aa2412e607035cd7750f824))
- Prevent task leak for rpc handler task ([#20](https://github.com/n0-computer/iroh-gossip/issues/20)) - ([03db85d](https://github.com/n0-computer/iroh-gossip/commit/03db85d218738df7b4c39cc2d178f2f90ba58ea3))
### 🚜 Refactor
- Adapt ProtocolHandler impl ([#16](https://github.com/n0-computer/iroh-gossip/issues/16)) - ([d5285e7](https://github.com/n0-computer/iroh-gossip/commit/d5285e7240da4e233be7c8f83099741f6f272bb0))
- [**breaking**] Align api naming between RPC and direct calls - ([35d73db](https://github.com/n0-computer/iroh-gossip/commit/35d73db8a982d7bbe1eb3cba126ac25422f5c1b6))
- Manually track dials, instead of using `iroh::dialer` ([#21](https://github.com/n0-computer/iroh-gossip/issues/21)) - ([2d90828](https://github.com/n0-computer/iroh-gossip/commit/2d90828a682574e382f5b0fbc43395ff698a63e2))
### 📚 Documentation
- Add "Getting Started" to the README and add the readme to the docs ([#19](https://github.com/n0-computer/iroh-gossip/issues/19)) - ([1625123](https://github.com/n0-computer/iroh-gossip/commit/1625123a89278cb09827abe8e7ee2bf409cf2f20))
## [0.29.0](https://github.com/n0-computer/iroh-gossip/compare/v0.28.1..0.29.0) - 2024-12-04
### ⛰️ Features
- Add cli - ([16f3505](https://github.com/n0-computer/iroh-gossip/commit/16f35050fe47534052e79dcbca42da4212dc6256))
- Update to latest iroh ([#11](https://github.com/n0-computer/iroh-gossip/issues/11)) - ([89e91a3](https://github.com/n0-computer/iroh-gossip/commit/89e91a34bd046fb7fbd504b2b8d0849e2865d410))
- Reexport ALPN at top level - ([7a0ec63](https://github.com/n0-computer/iroh-gossip/commit/7a0ec63a0ab7f14d78c77f8c779b2abef956da40))
- Update to iroh@0.29.0 - ([a28327c](https://github.com/n0-computer/iroh-gossip/commit/a28327ca512407a18a3802800c6712adc33acf84))
### 🚜 Refactor
- Use hex for debugging and display - ([b487112](https://github.com/n0-computer/iroh-gossip/commit/b4871121ed1862da4459353f63415d8ae4b3f8c5))
### ⚙️ Miscellaneous Tasks
- Fixup deny.toml - ([e614d86](https://github.com/n0-computer/iroh-gossip/commit/e614d86c0a690ac4acb6b4ef394a0bf55662dcc7))
- Prune some deps ([#8](https://github.com/n0-computer/iroh-gossip/issues/8)) - ([ba0f6b0](https://github.com/n0-computer/iroh-gossip/commit/ba0f6b0f54a740d8eae7ee6683f4aa1d8d8c8eb2))
- Init changelog - ([3eb675b](https://github.com/n0-computer/iroh-gossip/commit/3eb675b6a1ad51279ce225d0b36ef9957f17aa06))
- Fix changelog generation - ([95a4611](https://github.com/n0-computer/iroh-gossip/commit/95a4611aafee248052d3dc9ef97c9bc8a26d4821))
## [0.28.1](https://github.com/n0-computer/iroh-gossip/compare/v0.28.0..v0.28.1) - 2024-11-04
### 🐛 Bug Fixes
- Update to quic-rpc@0.14 - ([7b73408](https://github.com/n0-computer/iroh-gossip/commit/7b73408e80381b77534ae3721be0421da110de80))
- Use correctly patched iroh-net - ([276e36a](https://github.com/n0-computer/iroh-gossip/commit/276e36aa1caff8d41f89d57d8aef229ffa9924cb))
### ⚙️ Miscellaneous Tasks
- Release iroh-gossip version 0.28.1 - ([efce3e1](https://github.com/n0-computer/iroh-gossip/commit/efce3e1dc991c15a7f1fc6f579f04876a22a7b1e))

4965
third_party/iroh-org/iroh-gossip/Cargo.lock generated vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,172 @@
[package]
name = "iroh-gossip"
version = "0.96.0"
edition = "2021"
readme = "README.md"
description = "gossip messages over broadcast trees"
license = "MIT/Apache-2.0"
authors = ["n0 team"]
repository = "https://github.com/n0-computer/iroh-gossip"
resolver = "2"
# Sadly this also needs to be updated in .github/workflows/ci.yml
rust-version = "1.89"
[lib]
crate-type = ["cdylib", "rlib"]
[lints.rust]
missing_debug_implementations = "warn"
# We use this --cfg for documenting the cargo features on which an API
# is available. To preview this locally use: RUSTFLAGS="--cfg
# iroh_docsrs cargo +nightly doc --all-features". We use our own
# iroh_docsrs instead of the common docsrs to avoid also enabling this
# feature in any dependencies, because some indirect dependencies
# require a feature enabled when using `--cfg docsrs` which we can not
# do. To enable for a crate set `#![cfg_attr(iroh_docsrs,
# feature(doc_cfg))]` in the crate.
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(iroh_docsrs)"] }
[lints.clippy]
unused-async = "warn"
[dependencies]
blake3 = "1.8"
bytes = { version = "1.7", features = ["serde"] }
data-encoding = "2.6.0"
derive_more = { version = "2.0.1", features = [
"add",
"debug",
"deref",
"display",
"from",
"try_into",
"into",
] }
ed25519-dalek = { version = "3.0.0-pre.1", features = ["serde", "rand_core"] }
hex = "0.4.3"
indexmap = "2.0"
iroh-metrics = { version = "0.38", default-features = false }
iroh-base = { version = "0.96", default-features = false, features = ["key"] }
n0-future = "0.3"
postcard = { version = "1", default-features = false, features = [
"alloc",
"use-std",
"experimental-derive",
] }
rand = { version = "0.9.2", features = ["std_rng"] }
serde = { version = "1.0.164", features = ["derive"] }
# net dependencies (optional)
futures-lite = { version = "2.3", optional = true }
futures-concurrency = { version = "7.6.1", optional = true }
futures-util = { version = "0.3.30", optional = true }
iroh = { version = "0.96", default-features = false, optional = true }
tokio = { version = "1", optional = true, features = ["io-util", "sync"] }
tokio-util = { version = "0.7.12", optional = true, features = ["codec"] }
tracing = "0.1"
irpc = { version = "0.12.0", optional = true, default-features = false, features = [
"derive",
"stream",
"spans",
] }
n0-error = { version = "0.1", features = ["anyhow"] }
# rpc dependencies (optional)
quinn = { package = "iroh-quinn", version = "0.16.0", optional = true }
# test-utils dependencies (optional)
rand_chacha = { version = "0.9", optional = true }
humantime-serde = { version = "1.1.1", optional = true }
# simulator dependencies (optional)
clap = { version = "4", features = ["derive"], optional = true }
toml = { version = "0.9.8", optional = true }
tracing-subscriber = { version = "0.3", features = [
"env-filter",
], optional = true }
serde_json = { version = "1", optional = true }
rayon = { version = "1.10.0", optional = true }
comfy-table = { version = "7.1.4", optional = true }
[dev-dependencies]
tokio = { version = "1", features = [
"io-util",
"sync",
"rt",
"macros",
"net",
"fs",
] }
clap = { version = "4", features = ["derive"] }
humantime-serde = { version = "1.1.1" }
iroh = { version = "0.96", default-features = false, features = [
"metrics",
"test-utils",
] }
rand_chacha = "0.9"
testresult = "0.4.1"
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
n0-tracing-test = "0.3"
url = "2.4.0"
serde-byte-array = "0.1.2"
[features]
default = ["net", "metrics"]
net = [
"dep:irpc",
"dep:futures-lite",
"dep:iroh",
"dep:tokio",
"dep:tokio-util",
"dep:futures-util",
"dep:futures-concurrency",
]
rpc = [
"dep:irpc",
"dep:tokio",
"dep:quinn",
"irpc/rpc",
"irpc/quinn_endpoint_setup",
]
test-utils = ["dep:rand_chacha", "dep:humantime-serde"]
simulator = [
"test-utils",
"dep:tracing-subscriber",
"dep:toml",
"dep:clap",
"dep:serde_json",
"dep:rayon",
"dep:comfy-table",
]
metrics = ["iroh-metrics/metrics"]
examples = ["net"]
[[test]]
name = "sim"
path = "tests/sim.rs"
required-features = ["test-utils"]
[[bin]]
name = "sim"
required-features = ["simulator"]
[[example]]
name = "chat"
required-features = ["examples"]
[[example]]
name = "setup"
required-features = ["examples"]
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "iroh_docsrs"]
[profile.bench]
debug = true
[profile.release]
debug = true

View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [2023] [N0, INC]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -0,0 +1,25 @@
Copyright 2023 N0, INC.
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 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.

View file

@ -0,0 +1,28 @@
# Use cargo-make to run tasks here: https://crates.io/crates/cargo-make
[tasks.format]
workspace = false
command = "cargo"
args = [
"fmt",
"--all",
"--",
"--config",
"unstable_features=true",
"--config",
"imports_granularity=Crate,group_imports=StdExternalCrate,reorder_imports=true",
]
[tasks.format-check]
workspace = false
command = "cargo"
args = [
"fmt",
"--all",
"--check",
"--",
"--config",
"unstable_features=true",
"--config",
"imports_granularity=Crate,group_imports=StdExternalCrate,reorder_imports=true",
]

View file

@ -0,0 +1,93 @@
# iroh-gossip
This crate implements the `iroh-gossip` protocol.
It is based on *epidemic broadcast trees* to disseminate messages among a swarm of peers interested in a *topic*.
The implementation is based on the papers [HyParView](https://asc.di.fct.unl.pt/~jleitao/pdf/dsn07-leitao.pdf) and [PlumTree](https://asc.di.fct.unl.pt/~jleitao/pdf/srds07-leitao.pdf).
The crate is made up from two modules:
The `proto` module is the protocol implementation, as a state machine without any IO.
The `net` module implements networking logic for running `iroh-gossip` on `iroh` connections.
The `net` module is optional behind the `net` feature flag (enabled by default).
# Getting Started
The `iroh-gossip` protocol was designed to be used in conjunction with `iroh`. [Iroh](https://docs.rs/iroh) is a networking library for making direct connections, these connections are how gossip messages are sent.
Iroh provides a [`Router`](https://docs.rs/iroh/latest/iroh/protocol/struct.Router.html) that takes an [`Endpoint`](https://docs.rs/iroh/latest/iroh/endpoint/struct.Endpoint.html) and any protocols needed for the application. Similar to a router in webserver library, it runs a loop accepting incoming connections and routes them to the specific protocol handler, based on `ALPN`.
Here is a basic example of how to set up `iroh-gossip` with `iroh`:
```rust,no_run
use iroh::{protocol::Router, Endpoint, EndpointId};
use iroh_gossip::{api::Event, Gossip, TopicId};
use n0_error::{Result, StdResultExt};
use n0_future::StreamExt;
#[tokio::main]
async fn main() -> Result<()> {
// create an iroh endpoint that includes the standard discovery mechanisms
// we've built at number0
let endpoint = Endpoint::bind().await?;
// build gossip protocol
let gossip = Gossip::builder().spawn(endpoint.clone());
// setup router
let router = Router::builder(endpoint)
.accept(iroh_gossip::ALPN, gossip.clone())
.spawn();
// gossip swarms are centered around a shared "topic id", which is a 32 byte identifier
let topic_id = TopicId::from_bytes([23u8; 32]);
// and you need some bootstrap peers to join the swarm
let bootstrap_peers = bootstrap_peers();
// then, you can subscribe to the topic and join your initial peers
let (sender, mut receiver) = gossip
.subscribe(topic_id, bootstrap_peers)
.await?
.split();
// you might want to wait until you joined at least one other peer:
receiver.joined().await?;
// then, you can broadcast messages to all other peers!
sender.broadcast(b"hello world this is a gossip message".to_vec().into()).await?;
// and read messages from others!
while let Some(event) = receiver.next().await {
match event? {
Event::Received(message) => {
println!("received a message: {:?}", std::str::from_utf8(&message.content));
}
_ => {}
}
}
// clean shutdown makes sure that other peers are notified that you went offline
router.shutdown().await.std_context("shutdown router")?;
Ok(())
}
fn bootstrap_peers() -> Vec<EndpointId> {
// insert your bootstrap peers here, or get them from your environment
vec![]
}
```
# License
This project is licensed under either of
* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or
<http://www.apache.org/licenses/LICENSE-2.0>)
* MIT license ([LICENSE-MIT](LICENSE-MIT) or
<http://opensource.org/licenses/MIT>)
at your option.
### Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in this project by you, as defined in the Apache-2.0 license,
shall be dual licensed as above, without any additional terms or conditions.

View file

@ -0,0 +1,64 @@
[changelog]
# changelog header
header = """
# Changelog\n
All notable changes to iroh-gossip will be documented in this file.\n
"""
body = """
{% if version %}\
{% if previous.version %}\
## [{{ version | trim_start_matches(pat="v") }}](<REPO>/compare/{{ previous.version }}..{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% endif %}\
{% else %}\
## [unreleased]
{% endif %}\
{% macro commit(commit) -%}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{% if commit.breaking %}[**breaking**] {% endif %}\
{{ commit.message | upper_first }} - ([{{ commit.id | truncate(length=7, end="") }}](<REPO>/commit/{{ commit.id }}))\
{% endmacro -%}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits
| filter(attribute="scope")
| sort(attribute="scope") %}
{{ self::commit(commit=commit) }}
{%- endfor -%}
{% raw %}\n{% endraw %}\
{%- for commit in commits %}
{%- if not commit.scope -%}
{{ self::commit(commit=commit) }}
{% endif -%}
{% endfor -%}
{% endfor %}\n
"""
footer = ""
postprocessors = [
{ pattern = '<REPO>', replace = "https://github.com/n0-computer/iroh-gossip" },
{ pattern = "\\(#([0-9]+)\\)", replace = "([#${1}](https://github.com/n0-computer/iroh-gossip/issues/${1}))"}
]
[git]
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->⛰️ Features" },
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
{ message = "^doc", group = "<!-- 3 -->📚 Documentation" },
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
{ message = "^test", group = "<!-- 6 -->🧪 Testing" },
{ message = "^chore\\(release\\)", skip = true },
{ message = "^chore\\(deps\\)", skip = true },
{ message = "^chore\\(pr\\)", skip = true },
{ message = "^chore\\(pull\\)", skip = true },
{ message = "^chore|ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
]

View file

@ -0,0 +1,13 @@
# Code of Conduct
Online or off, Number Zero is a harassment-free environment for everyone, regardless of gender, gender identity and expression, sexual orientation, disability, physical appearance, body size, race, age or religion or technical skill level. We do not tolerate harassment of participants in any form.
Harassment includes verbal comments that reinforce social structures of domination related to gender, gender identity and expression, sexual orientation, disability, physical appearance, body size, race, age, religion, sexual images in public spaces, deliberate intimidation, stalking, following, harassing photography or recording, sustained disruption of talks or other events, inappropriate physical contact, and unwelcome sexual attention. Participants asked to stop any harassing behavior are expected to comply immediately.
If a participant engages in harassing behaviour, the organizers may take any action they deem appropriate, including warning the offender or expulsion from events and online forums.
If you are being harassed, notice that someone else is being harassed, or have any other concerns, please contact a member of the organizing team immediately.
At offline events, organizers will identify themselves, and will help participants contact venue security or local law enforcement, provide escorts, or otherwise assist those experiencing harassment to feel safe for the duration of the event. We value your participation!
This document is based on a similar code from [EDGI](https://envirodatagov.org/) and [Civic Tech Toronto](http://civictech.ca/about-us/), itself derived from the [Recurse Centers Social Rules](https://www.recurse.com/manual#sec-environment), and the [anti-harassment policy from the Geek Feminism Wiki](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy).

View file

@ -0,0 +1,41 @@
[advisories]
ignore = [
"RUSTSEC-2024-0436",
"RUSTSEC-2023-0089",
]
[bans]
deny = [
"aws-lc",
"aws-lc-rs",
"aws-lc-sys",
"native-tls",
"openssl",
]
multiple-versions = "allow"
[licenses]
allow = [
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"BSL-1.0",
"CDLA-Permissive-2.0",
"ISC",
"MIT",
"Zlib",
"Unicode-3.0",
"MPL-2.0",
"Unlicense",
]
[[licenses.clarify]]
expression = "MIT AND ISC AND OpenSSL"
name = "ring"
[[licenses.clarify.license-files]]
hash = 3171872035
path = "LICENSE"
[sources]

View file

@ -0,0 +1,319 @@
use std::{
collections::HashMap,
fmt,
net::{Ipv4Addr, SocketAddrV4},
str::FromStr,
};
use bytes::Bytes;
use clap::Parser;
use futures_lite::StreamExt;
use iroh::{
address_lookup::memory::MemoryLookup, Endpoint, EndpointAddr, PublicKey, RelayMode, RelayUrl,
SecretKey,
};
use iroh_gossip::{
api::{Event, GossipReceiver},
net::{Gossip, GOSSIP_ALPN},
proto::TopicId,
};
use n0_error::{bail_any, AnyError, Result, StdResultExt};
use n0_future::task;
use serde::{Deserialize, Serialize};
use serde_byte_array::ByteArray;
/// Chat over iroh-gossip
///
/// This broadcasts signed messages over iroh-gossip and verifies signatures
/// on received messages.
///
/// By default a new endpoint id is created when starting the example. To reuse your identity,
/// set the `--secret-key` flag with the secret key printed on a previous invocation.
///
/// By default, the relay server run by n0 is used. To use a local relay server, run
/// cargo run --bin iroh-relay --features iroh-relay -- --dev
/// in another terminal and then set the `-d http://localhost:3340` flag on this example.
#[derive(Parser, Debug)]
struct Args {
/// secret key to derive our endpoint id from.
#[clap(long)]
secret_key: Option<String>,
/// Set a custom relay server. By default, the relay server hosted by n0 will be used.
#[clap(short, long)]
relay: Option<RelayUrl>,
/// Disable relay completely.
#[clap(long)]
no_relay: bool,
/// Set your nickname.
#[clap(short, long)]
name: Option<String>,
/// Set the bind port for our socket. By default, a random port will be used.
#[clap(short, long, default_value = "0")]
bind_port: u16,
#[clap(subcommand)]
command: Command,
}
#[derive(Parser, Debug)]
enum Command {
/// Open a chat room for a topic and print a ticket for others to join.
///
/// If no topic is provided, a new topic will be created.
Open {
/// Optionally set the topic id (64 bytes, as hex string).
topic: Option<TopicId>,
},
/// Join a chat room from a ticket.
Join {
/// The ticket, as base32 string.
ticket: String,
},
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let args = Args::parse();
// parse the cli command
let (topic, peers) = match &args.command {
Command::Open { topic } => {
let topic = topic.unwrap_or_else(|| TopicId::from_bytes(rand::random()));
println!("> opening chat room for topic {topic}");
(topic, vec![])
}
Command::Join { ticket } => {
let Ticket { topic, peers } = Ticket::from_str(ticket)?;
println!("> joining chat room for topic {topic}");
(topic, peers)
}
};
// parse or generate our secret key
let secret_key = match args.secret_key {
None => SecretKey::generate(&mut rand::rng()),
Some(key) => key.parse()?,
};
println!(
"> our secret key: {}",
data_encoding::HEXLOWER.encode(&secret_key.to_bytes())
);
// configure our relay map
let relay_mode = match (args.no_relay, args.relay) {
(false, None) => RelayMode::Default,
(false, Some(url)) => RelayMode::Custom(url.into()),
(true, None) => RelayMode::Disabled,
(true, Some(_)) => bail_any!("You cannot set --no-relay and --relay at the same time"),
};
println!("> using relay servers: {}", fmt_relay_mode(&relay_mode));
// create a memory lookup to pass in endpoint addresses to
let memory_lookup = MemoryLookup::new();
// build our magic endpoint
let endpoint = Endpoint::builder()
.secret_key(secret_key)
.address_lookup(memory_lookup.clone())
.relay_mode(relay_mode.clone())
.bind_addr(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, args.bind_port))?
.bind()
.await?;
println!("> our endpoint id: {}", endpoint.id());
// create the gossip protocol
let gossip = Gossip::builder().spawn(endpoint.clone());
// print a ticket that includes our own endpoint id and endpoint addresses
if !matches!(relay_mode, RelayMode::Disabled) {
// if we are expecting a relay, wait until we get a home relay
// before moving on
endpoint.online().await;
}
let ticket = {
let me = endpoint.addr();
let peers = peers.iter().cloned().chain([me]).collect();
Ticket { topic, peers }
};
println!("> ticket to join us: {ticket}");
// setup router
let router = iroh::protocol::Router::builder(endpoint.clone())
.accept(GOSSIP_ALPN, gossip.clone())
.spawn();
// join the gossip topic by connecting to known peers, if any
let peer_ids = peers.iter().map(|p| p.id).collect();
if peers.is_empty() {
println!("> waiting for peers to join us...");
} else {
println!("> trying to connect to {} peers...", peers.len());
// add the peer addrs from the ticket to our endpoint's addressbook so that they can be dialed
for peer in peers.into_iter() {
memory_lookup.add_endpoint_info(peer);
}
};
let (sender, receiver) = gossip.subscribe_and_join(topic, peer_ids).await?.split();
println!("> connected!");
// broadcast our name, if set
if let Some(name) = args.name {
let message = Message::AboutMe { name };
let encoded_message = SignedMessage::sign_and_encode(endpoint.secret_key(), &message)?;
sender.broadcast(encoded_message).await?;
}
// subscribe and print loop
task::spawn(subscribe_loop(receiver));
// spawn an input thread that reads stdin
// not using tokio here because they recommend this for "technical reasons"
let (line_tx, mut line_rx) = tokio::sync::mpsc::channel(1);
std::thread::spawn(move || input_loop(line_tx));
// broadcast each line we type
println!("> type a message and hit enter to broadcast...");
while let Some(text) = line_rx.recv().await {
let message = Message::Message { text: text.clone() };
let encoded_message = SignedMessage::sign_and_encode(endpoint.secret_key(), &message)?;
sender.broadcast(encoded_message).await?;
println!("> sent: {text}");
}
// shutdown
router.shutdown().await.anyerr()?;
Ok(())
}
async fn subscribe_loop(mut receiver: GossipReceiver) -> Result<()> {
// init a peerid -> name hashmap
let mut names = HashMap::new();
while let Some(event) = receiver.try_next().await? {
if let Event::Received(msg) = event {
let (from, message) = SignedMessage::verify_and_decode(&msg.content)?;
match message {
Message::AboutMe { name } => {
names.insert(from, name.clone());
println!("> {} is now known as {}", from.fmt_short(), name);
}
Message::Message { text } => {
let name = names
.get(&from)
.map_or_else(|| from.fmt_short().to_string(), String::to_string);
println!("{name}: {text}");
}
}
}
}
Ok(())
}
fn input_loop(line_tx: tokio::sync::mpsc::Sender<String>) -> Result<()> {
let mut buffer = String::new();
let stdin = std::io::stdin(); // We get `Stdin` here.
loop {
stdin.read_line(&mut buffer).anyerr()?;
line_tx.blocking_send(buffer.clone()).anyerr()?;
buffer.clear();
}
}
const SIGNATURE_LENGTH: usize = iroh::Signature::LENGTH;
type Signature = ByteArray<SIGNATURE_LENGTH>;
#[derive(Debug, Serialize, Deserialize)]
struct SignedMessage {
from: PublicKey,
data: Bytes,
signature: Signature,
}
impl SignedMessage {
pub fn verify_and_decode(bytes: &[u8]) -> Result<(PublicKey, Message)> {
let signed_message: Self =
postcard::from_bytes(bytes).std_context("decode signed message")?;
let key: PublicKey = signed_message.from;
key.verify(
&signed_message.data,
&iroh::Signature::from_bytes(&signed_message.signature),
)
.std_context("verify signature")?;
let message: Message =
postcard::from_bytes(&signed_message.data).std_context("decode message")?;
Ok((signed_message.from, message))
}
pub fn sign_and_encode(secret_key: &SecretKey, message: &Message) -> Result<Bytes> {
let data: Bytes = postcard::to_stdvec(&message)
.std_context("encode message")?
.into();
let signature = secret_key.sign(&data);
let from: PublicKey = secret_key.public();
let signed_message = Self {
from,
data,
signature: ByteArray::new(signature.to_bytes()),
};
let encoded = postcard::to_stdvec(&signed_message).std_context("encode signed message")?;
Ok(encoded.into())
}
}
#[derive(Debug, Serialize, Deserialize)]
enum Message {
AboutMe { name: String },
Message { text: String },
}
#[derive(Debug, Serialize, Deserialize)]
struct Ticket {
topic: TopicId,
peers: Vec<EndpointAddr>,
}
impl Ticket {
/// Deserializes from bytes.
fn from_bytes(bytes: &[u8]) -> Result<Self> {
postcard::from_bytes(bytes).std_context("decode ticket")
}
/// Serializes to bytes.
pub fn to_bytes(&self) -> Vec<u8> {
postcard::to_stdvec(self).expect("postcard::to_stdvec is infallible")
}
}
/// Serializes to base32.
impl fmt::Display for Ticket {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let mut text = data_encoding::BASE32_NOPAD.encode(&self.to_bytes()[..]);
text.make_ascii_lowercase();
write!(f, "{text}")
}
}
/// Deserializes from base32.
impl FromStr for Ticket {
type Err = AnyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let bytes = data_encoding::BASE32_NOPAD
.decode(s.to_ascii_uppercase().as_bytes())
.std_context("decode ticket base32")?;
Self::from_bytes(&bytes)
}
}
// helpers
fn fmt_relay_mode(relay_mode: &RelayMode) -> String {
match relay_mode {
RelayMode::Disabled => "None".to_string(),
RelayMode::Default => "Default Relay (production) servers".to_string(),
RelayMode::Staging => "Default Relay (staging) servers".to_string(),
RelayMode::Custom(map) => map
.urls::<Vec<_>>()
.into_iter()
.map(|url| url.to_string())
.collect::<Vec<_>>()
.join(", "),
}
}

View file

@ -0,0 +1,21 @@
use iroh::{protocol::Router, Endpoint};
use iroh_gossip::{net::Gossip, ALPN};
use n0_error::{Result, StdResultExt};
#[tokio::main]
async fn main() -> Result<()> {
// create an iroh endpoint that includes the standard address lookup mechanisms
// we've built at number0
let endpoint = Endpoint::bind().await?;
// build gossip protocol
let gossip = Gossip::builder().spawn(endpoint.clone());
// setup router
let router = Router::builder(endpoint.clone())
.accept(ALPN, gossip.clone())
.spawn();
// do fun stuff with the gossip protocol
router.shutdown().await.std_context("shutdown router")?;
Ok(())
}

View file

@ -0,0 +1 @@
pre-release-hook = ["git", "cliff", "--prepend", "CHANGELOG.md", "--tag", "{{version}}", "--unreleased" ]

View file

@ -0,0 +1,35 @@
seeds = [0, 1, 212312388123, 123]
config.latency.dynamic = { min = "10ms", max = "50ms" }
[[scenario]]
sim = "GossipSingle"
nodes = 20
[[scenario]]
sim = "GossipSingle"
nodes = 100
[[scenario]]
sim = "GossipSingle"
nodes = 1000
[[scenario]]
sim = "GossipMulti"
nodes = 20
[[scenario]]
sim = "GossipMulti"
nodes = 100
[[scenario]]
sim = "GossipMulti"
nodes = 1000
[[scenario]]
sim = "GossipAll"
nodes = 20
[[scenario]]
sim = "GossipAll"
nodes = 100
rounds = 5

View file

@ -0,0 +1,535 @@
//! Public API for using iroh-gossip
//!
//! The API is usable both locally and over RPC.
use std::{
collections::{BTreeSet, HashSet},
pin::Pin,
task::{Context, Poll},
};
use bytes::Bytes;
use iroh_base::EndpointId;
use irpc::{channel::mpsc, rpc_requests, Client};
use n0_error::{e, stack_error};
use n0_future::{Stream, StreamExt, TryStreamExt};
use serde::{Deserialize, Serialize};
use crate::proto::{DeliveryScope, TopicId};
/// Default channel capacity for topic subscription channels (one per topic)
const TOPIC_EVENTS_DEFAULT_CAP: usize = 2048;
/// Channel capacity for topic command send channels.
const TOPIC_COMMANDS_CAP: usize = 64;
/// Input messages for the gossip actor.
#[rpc_requests(message = RpcMessage, rpc_feature = "rpc")]
#[derive(Debug, Serialize, Deserialize)]
pub(crate) enum Request {
#[rpc(tx=mpsc::Sender<Event>, rx=mpsc::Receiver<Command>)]
Join(JoinRequest),
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct JoinRequest {
pub topic_id: TopicId,
pub bootstrap: BTreeSet<EndpointId>,
}
#[allow(missing_docs)]
#[stack_error(derive, add_meta, from_sources)]
#[non_exhaustive]
pub enum ApiError {
#[error(transparent)]
Rpc { source: irpc::Error },
/// The gossip topic was closed.
#[error("topic closed")]
Closed,
}
impl From<irpc::channel::SendError> for ApiError {
fn from(value: irpc::channel::SendError) -> Self {
irpc::Error::from(value).into()
}
}
impl From<irpc::channel::mpsc::RecvError> for ApiError {
fn from(value: irpc::channel::mpsc::RecvError) -> Self {
irpc::Error::from(value).into()
}
}
impl From<irpc::channel::oneshot::RecvError> for ApiError {
fn from(value: irpc::channel::oneshot::RecvError) -> Self {
irpc::Error::from(value).into()
}
}
/// API to control a [`Gossip`] instance.
///
/// This has methods to subscribe and join gossip topics, which return handles to publish
/// and receive messages on topics.
///
/// [`Gossip`] derefs to [`GossipApi`], so all functions on [`GossipApi`] are directly callable
/// from [`Gossip`].
///
/// Additionally, a [`GossipApi`] can be created by connecting to an RPC server. See [`Gossip::listen`]
/// and [`GossipApi::connect`] (*requires the `rpc` feature).
///
/// [`Gossip`]: crate::net::Gossip
/// [`Gossip::listen`]: crate::net::Gossip::listen
#[derive(Debug, Clone)]
pub struct GossipApi {
client: Client<Request>,
}
impl GossipApi {
#[cfg(feature = "net")]
pub(crate) fn local(tx: tokio::sync::mpsc::Sender<RpcMessage>) -> Self {
let local = irpc::LocalSender::<Request>::from(tx);
Self {
client: local.into(),
}
}
/// Connect to a remote as a RPC client.
#[cfg(feature = "rpc")]
pub fn connect(endpoint: quinn::Endpoint, addr: std::net::SocketAddr) -> Self {
let inner = irpc::Client::quinn(endpoint, addr);
Self { client: inner }
}
/// Listen on a quinn endpoint for incoming RPC connections.
#[cfg(all(feature = "rpc", feature = "net"))]
pub(crate) async fn listen(&self, endpoint: quinn::Endpoint) {
use irpc::rpc::{listen, RemoteService};
let local = self
.client
.as_local()
.expect("cannot listen on remote client");
let handler = Request::remote_handler(local);
listen::<Request>(endpoint, handler).await
}
/// Join a gossip topic with options.
///
/// Returns a [`GossipTopic`] instantly. To wait for at least one connection to be established,
/// you can await [`GossipTopic::joined`].
///
/// Messages will be queued until a first connection is available. If the internal channel becomes full,
/// the oldest messages will be dropped from the channel.
pub async fn subscribe_with_opts(
&self,
topic_id: TopicId,
opts: JoinOptions,
) -> Result<GossipTopic, ApiError> {
let req = JoinRequest {
topic_id,
bootstrap: opts.bootstrap,
};
let (tx, rx) = self
.client
.bidi_streaming(req, TOPIC_COMMANDS_CAP, opts.subscription_capacity)
.await?;
Ok(GossipTopic::new(tx, rx))
}
/// Join a gossip topic with the default options and wait for at least one active connection.
pub async fn subscribe_and_join(
&self,
topic_id: TopicId,
bootstrap: Vec<EndpointId>,
) -> Result<GossipTopic, ApiError> {
let mut sub = self
.subscribe_with_opts(topic_id, JoinOptions::with_bootstrap(bootstrap))
.await?;
sub.joined().await?;
Ok(sub)
}
/// Join a gossip topic with the default options.
///
/// Note that this will not wait for any bootstrap endpoint to be available.
/// To ensure the topic is connected to at least one endpoint, use [`GossipTopic::joined`]
/// or [`Self::subscribe_and_join`]
pub async fn subscribe(
&self,
topic_id: TopicId,
bootstrap: Vec<EndpointId>,
) -> Result<GossipTopic, ApiError> {
let sub = self
.subscribe_with_opts(topic_id, JoinOptions::with_bootstrap(bootstrap))
.await?;
Ok(sub)
}
}
/// Sender for a gossip topic.
#[derive(Debug, Clone)]
pub struct GossipSender(mpsc::Sender<Command>);
impl GossipSender {
pub(crate) fn new(sender: mpsc::Sender<Command>) -> Self {
Self(sender)
}
/// Broadcasts a message to all endpoints.
pub async fn broadcast(&self, message: Bytes) -> Result<(), ApiError> {
self.send(Command::Broadcast(message)).await?;
Ok(())
}
/// Broadcasts a message to our direct neighbors.
pub async fn broadcast_neighbors(&self, message: Bytes) -> Result<(), ApiError> {
self.send(Command::BroadcastNeighbors(message)).await?;
Ok(())
}
/// Joins a set of peers.
pub async fn join_peers(&self, peers: Vec<EndpointId>) -> Result<(), ApiError> {
self.send(Command::JoinPeers(peers)).await?;
Ok(())
}
async fn send(&self, command: Command) -> Result<(), irpc::channel::SendError> {
self.0.send(command).await?;
Ok(())
}
}
/// Subscribed gossip topic.
///
/// This handle is a [`Stream`] of [`Event`]s from the topic, and can be used to send messages.
///
/// Once the [`GossipTopic`] is dropped, the network actor will leave the gossip topic.
///
/// It may be split into sender and receiver parts with [`Self::split`]. In this case, the topic will
/// be left once both the [`GossipSender`] and [`GossipReceiver`] halves are dropped.
#[derive(Debug)]
pub struct GossipTopic {
sender: GossipSender,
receiver: GossipReceiver,
}
impl GossipTopic {
pub(crate) fn new(sender: mpsc::Sender<Command>, receiver: mpsc::Receiver<Event>) -> Self {
let sender = GossipSender::new(sender);
Self {
sender,
receiver: GossipReceiver::new(receiver),
}
}
/// Splits `self` into [`GossipSender`] and [`GossipReceiver`] parts.
pub fn split(self) -> (GossipSender, GossipReceiver) {
(self.sender, self.receiver)
}
/// Sends a message to all peers.
pub async fn broadcast(&mut self, message: Bytes) -> Result<(), ApiError> {
self.sender.broadcast(message).await
}
/// Sends a message to our direct neighbors in the swarm.
pub async fn broadcast_neighbors(&mut self, message: Bytes) -> Result<(), ApiError> {
self.sender.broadcast_neighbors(message).await
}
/// Lists our current direct neighbors.
pub fn neighbors(&self) -> impl Iterator<Item = EndpointId> + '_ {
self.receiver.neighbors()
}
/// Waits until we are connected to at least one endpoint.
///
/// See [`GossipReceiver::joined`] for details.
pub async fn joined(&mut self) -> Result<(), ApiError> {
self.receiver.joined().await
}
/// Returns `true` if we are connected to at least one endpoint.
pub fn is_joined(&self) -> bool {
self.receiver.is_joined()
}
}
impl Stream for GossipTopic {
type Item = Result<Event, ApiError>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
Pin::new(&mut self.receiver).poll_next(cx)
}
}
/// Receiver for gossip events on a topic.
///
/// This is a [`Stream`] of [`Event`]s emitted from the topic.
#[derive(derive_more::Debug)]
pub struct GossipReceiver {
#[debug("BoxStream")]
stream: Pin<Box<dyn Stream<Item = Result<Event, ApiError>> + Send + Sync + 'static>>,
neighbors: HashSet<EndpointId>,
}
impl GossipReceiver {
pub(crate) fn new(events_rx: mpsc::Receiver<Event>) -> Self {
let stream = events_rx.into_stream().map_err(ApiError::from);
let stream = Box::pin(stream);
Self {
stream,
neighbors: Default::default(),
}
}
/// Lists our current direct neighbors.
pub fn neighbors(&self) -> impl Iterator<Item = EndpointId> + '_ {
self.neighbors.iter().copied()
}
/// Waits until we are connected to at least one endpoint.
///
/// Progresses the event stream to the first [`Event::NeighborUp`] event.
///
/// Note that this consumes this initial `NeighborUp` event. If you want to track
/// neighbors, use [`Self::neighbors`] after awaiting [`Self::joined`], and then
/// continue to track `NeighborUp` events on the event stream.
pub async fn joined(&mut self) -> Result<(), ApiError> {
while !self.is_joined() {
let _event = self.next().await.ok_or(e!(ApiError::Closed))??;
}
Ok(())
}
/// Returns `true` if we are connected to at least one endpoint.
pub fn is_joined(&self) -> bool {
!self.neighbors.is_empty()
}
}
impl Stream for GossipReceiver {
type Item = Result<Event, ApiError>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let item = std::task::ready!(Pin::new(&mut self.stream).poll_next(cx));
if let Some(Ok(item)) = &item {
match item {
Event::NeighborUp(endpoint_id) => {
self.neighbors.insert(*endpoint_id);
}
Event::NeighborDown(endpoint_id) => {
self.neighbors.remove(endpoint_id);
}
_ => {}
}
}
Poll::Ready(item)
}
}
/// Events emitted from a gossip topic.
///
/// These are the events emitted from a [`GossipReceiver`].
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize, Deserialize)]
pub enum Event {
/// We have a new, direct neighbor in the swarm membership layer for this topic.
NeighborUp(EndpointId),
/// We dropped direct neighbor in the swarm membership layer for this topic.
NeighborDown(EndpointId),
/// We received a gossip message for this topic.
Received(Message),
/// We missed some messages because our [`GossipReceiver`] was not progressing fast enough.
Lagged,
}
impl From<crate::proto::Event<EndpointId>> for Event {
fn from(event: crate::proto::Event<EndpointId>) -> Self {
match event {
crate::proto::Event::NeighborUp(endpoint_id) => Self::NeighborUp(endpoint_id),
crate::proto::Event::NeighborDown(endpoint_id) => Self::NeighborDown(endpoint_id),
crate::proto::Event::Received(message) => Self::Received(Message {
content: message.content,
scope: message.scope,
delivered_from: message.delivered_from,
}),
}
}
}
/// A gossip message
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, derive_more::Debug, Serialize, Deserialize)]
pub struct Message {
/// The content of the message
#[debug("Bytes({})", self.content.len())]
pub content: Bytes,
/// The scope of the message.
/// This tells us if the message is from a direct neighbor or actual gossip.
pub scope: DeliveryScope,
/// The endpoint that delivered the message. This is not the same as the original author.
pub delivered_from: EndpointId,
}
/// Command for a gossip topic.
#[derive(Serialize, Deserialize, derive_more::Debug, Clone)]
pub enum Command {
/// Broadcasts a message to all endpoints in the swarm.
Broadcast(#[debug("Bytes({})", _0.len())] Bytes),
/// Broadcasts a message to all direct neighbors.
BroadcastNeighbors(#[debug("Bytes({})", _0.len())] Bytes),
/// Connects to a set of peers.
JoinPeers(Vec<EndpointId>),
}
/// Options for joining a gossip topic.
#[derive(Serialize, Deserialize, Debug)]
pub struct JoinOptions {
/// The initial bootstrap endpoints.
pub bootstrap: BTreeSet<EndpointId>,
/// The maximum number of messages that can be buffered in a subscription.
///
/// If this limit is reached, the subscriber will receive a `Lagged` response,
/// the message will be dropped, and the subscriber will be closed.
///
/// This is to prevent a single slow subscriber from blocking the dispatch loop.
/// If a subscriber is lagging, it should be closed and re-opened.
pub subscription_capacity: usize,
}
impl JoinOptions {
/// Creates [`JoinOptions`] with the provided bootstrap endpoints and the default subscription
/// capacity.
pub fn with_bootstrap(endpoints: impl IntoIterator<Item = EndpointId>) -> Self {
Self {
bootstrap: endpoints.into_iter().collect(),
subscription_capacity: TOPIC_EVENTS_DEFAULT_CAP,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::GossipTopic;
#[cfg(all(feature = "rpc", feature = "net"))]
#[tokio::test]
#[n0_tracing_test::traced_test]
async fn test_rpc() -> n0_error::Result<()> {
use iroh::{address_lookup::memory::MemoryLookup, protocol::Router, RelayMap};
use n0_error::{AnyError, Result, StackResultExt, StdResultExt};
use n0_future::{time::Duration, StreamExt};
use rand_chacha::rand_core::SeedableRng;
use crate::{
api::{Event, GossipApi},
net::{test::create_endpoint, Gossip},
proto::TopicId,
ALPN,
};
let mut rng = rand_chacha::ChaCha12Rng::seed_from_u64(1);
let (relay_map, _relay_url, _guard) = iroh::test_utils::run_relay_server().await.unwrap();
async fn create_gossip_endpoint(
rng: &mut rand_chacha::ChaCha12Rng,
relay_map: RelayMap,
) -> Result<(Router, Gossip)> {
let endpoint = create_endpoint(rng, relay_map, None).await?;
let gossip = Gossip::builder().spawn(endpoint.clone());
let router = Router::builder(endpoint)
.accept(ALPN, gossip.clone())
.spawn();
Ok((router, gossip))
}
let topic_id = TopicId::from_bytes([0u8; 32]);
// create our gossip endpoint
let (router, gossip) = create_gossip_endpoint(&mut rng, relay_map.clone()).await?;
// create a second endpoint so that we can test actually joining
let (endpoint2_id, endpoint2_addr, endpoint2_task) = {
let (router, gossip) = create_gossip_endpoint(&mut rng, relay_map.clone()).await?;
let endpoint_addr = router.endpoint().addr();
let endpoint_id = router.endpoint().id();
let task = tokio::task::spawn(async move {
let mut topic = gossip.subscribe_and_join(topic_id, vec![]).await?;
topic.broadcast(b"hello".to_vec().into()).await?;
Ok::<_, AnyError>(router)
});
(endpoint_id, endpoint_addr, task)
};
// create a memory lookup service to add endpoint addr manually
let memory_lookup = MemoryLookup::new();
memory_lookup.add_endpoint_info(endpoint2_addr);
router.endpoint().address_lookup().add(memory_lookup);
// expose the gossip endpoint over RPC
let (rpc_server_endpoint, rpc_server_cert) =
irpc::util::make_server_endpoint("127.0.0.1:0".parse().unwrap())
.context("make server endpoint")?;
let rpc_server_addr = rpc_server_endpoint
.local_addr()
.std_context("resolve server addr")?;
let rpc_server_task = tokio::task::spawn(async move {
gossip.listen(rpc_server_endpoint).await;
});
// connect to the RPC endpoint with a new client
let rpc_client_endpoint =
irpc::util::make_client_endpoint("127.0.0.1:0".parse().unwrap(), &[&rpc_server_cert])
.context("make client endpoint")?;
let rpc_client = GossipApi::connect(rpc_client_endpoint, rpc_server_addr);
// join via RPC
let recv = async move {
let mut topic = rpc_client
.subscribe_and_join(topic_id, vec![endpoint2_id])
.await?;
// wait for a message
while let Some(event) = topic.try_next().await? {
match event {
Event::Received(message) => {
assert_eq!(&message.content[..], b"hello");
break;
}
Event::Lagged => panic!("unexpected lagged event"),
_ => {}
}
}
Ok::<_, AnyError>(())
};
// timeout to not hang in case of failure
tokio::time::timeout(Duration::from_secs(10), recv)
.await
.std_context("rpc recv timeout")??;
// shutdown
rpc_server_task.abort();
router.shutdown().await.std_context("shutdown router")?;
let router2 = endpoint2_task.await.std_context("join endpoint task")??;
router2
.shutdown()
.await
.std_context("shutdown second router")?;
Ok(())
}
#[test]
fn ensure_gossip_topic_is_sync() {
#[allow(unused)]
fn get() -> GossipTopic {
unimplemented!()
}
#[allow(unused)]
fn check(_t: impl Sync) {}
#[allow(unused)]
fn foo() {
check(get());
}
}
}

View file

@ -0,0 +1,418 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use clap::Parser;
use comfy_table::{presets::NOTHING, Cell, CellAlignment, Table};
use iroh_gossip::proto::sim::{
BootstrapMode, NetworkConfig, RoundStats, RoundStatsAvg, RoundStatsDiff, Simulator,
SimulatorConfig,
};
use n0_error::{Result, StackResultExt, StdResultExt};
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use serde::{Deserialize, Serialize};
use tracing::{error_span, info, warn};
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[allow(clippy::enum_variant_names)]
enum Simulation {
/// A single sender broadcasts a single message per round.
GossipSingle,
/// Each round a different sender is chosen at random, and broadcasts a single message
GossipMulti,
/// Each round, all peers broadcast a single message simultaneously.
GossipAll,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ScenarioDescription {
sim: Simulation,
nodes: u32,
#[serde(default)]
bootstrap: BootstrapMode,
#[serde(default = "defaults::rounds")]
rounds: u32,
config: Option<NetworkConfig>,
}
impl ScenarioDescription {
pub fn label(&self) -> String {
let &ScenarioDescription {
sim,
nodes,
rounds,
config: _,
bootstrap: _,
} = &self;
format!("{sim:?}-n{nodes}-r{rounds}")
}
}
mod defaults {
pub fn rounds() -> u32 {
30
}
}
#[derive(Debug, Serialize, Deserialize)]
struct SimConfig {
seeds: Vec<u64>,
config: Option<NetworkConfig>,
scenario: Vec<ScenarioDescription>,
}
#[derive(Debug, Parser)]
struct Cli {
#[clap(subcommand)]
command: Command,
}
#[derive(Debug, Parser)]
enum Command {
/// Run simulations
Run {
#[clap(short, long)]
config_path: PathBuf,
#[clap(short, long)]
out_dir: Option<PathBuf>,
#[clap(short, long)]
baseline: Option<PathBuf>,
#[clap(short, long)]
single_threaded: bool,
#[clap(short, long)]
filter: Vec<String>,
},
/// Compare simulation runs
Compare {
baseline: PathBuf,
current: PathBuf,
#[clap(short, long)]
filter: Vec<String>,
},
}
fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let args: Cli = Cli::parse();
match args.command {
Command::Run {
config_path,
out_dir,
baseline,
single_threaded,
filter,
} => {
let config_text = std::fs::read_to_string(&config_path)
.with_std_context(|_| format!("read config {}", config_path.display()))?;
let config: SimConfig = toml::from_str(&config_text).std_context("parse config")?;
let base_config = config.config.unwrap_or_default();
info!("base config: {base_config:?}");
let seeds = config.seeds;
let mut scenarios = config.scenario;
for scenario in scenarios.iter_mut() {
scenario.config.get_or_insert_with(|| base_config.clone());
}
if let Some(out_dir) = out_dir.as_ref() {
std::fs::create_dir_all(out_dir)
.with_std_context(|_| format!("create output dir {}", out_dir.display()))?;
}
let filter_fn = |s: &ScenarioDescription| {
let label = s.label();
if filter.is_empty() {
true
} else {
filter.iter().any(|x| x == &label)
}
};
let results: Result<Vec<_>> = if !single_threaded {
scenarios
.into_par_iter()
.filter(filter_fn)
.map(|scenario| run_and_save_simulation(scenario, &seeds, out_dir.as_ref()))
.collect()
} else {
scenarios
.into_iter()
.filter(filter_fn)
.map(|scenario| run_and_save_simulation(scenario, &seeds, out_dir.as_ref()))
.collect()
};
let mut results = results?;
results.sort_by_key(|a| a.scenario.label());
for result in results {
print_result(&result);
}
if let (Some(baseline), Some(out_dir)) = (baseline, out_dir) {
compare_dirs(baseline, out_dir, filter)?;
}
}
Command::Compare {
baseline,
current,
filter,
} => {
compare_dirs(baseline, current, filter)?;
}
}
Ok(())
}
fn run_and_save_simulation(
scenario: ScenarioDescription,
seeds: &[u64],
out_dir: Option<impl AsRef<Path>>,
) -> Result<SimulationResults> {
let label = scenario.label();
if let Some(out_dir) = out_dir.as_ref() {
let path = out_dir.as_ref().join(format!("{label}.config.toml"));
let encoded = toml::to_string(&scenario).std_context("encode scenario")?;
std::fs::write(&path, encoded)
.with_std_context(|_| format!("write scenario {}", &path.display()))?;
}
let result = run_simulation(seeds, scenario);
if let Some(out_dir) = out_dir.as_ref() {
let path = out_dir.as_ref().join(format!("{label}.results.json"));
let encoded = serde_json::to_string(&result).std_context("encode results")?;
std::fs::write(&path, encoded)
.with_std_context(|_| format!("write results {}", path.display()))?;
}
Ok(result)
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct SimulationResults {
scenario: ScenarioDescription,
/// Maps seeds to results
results: HashMap<u64, RoundStatsAvg>,
average: Option<RoundStatsAvg>,
}
impl SimulationResults {
fn load_from_file(path: impl AsRef<Path>) -> Result<Self> {
let s = std::fs::read_to_string(path.as_ref())
.with_std_context(|_| format!("read results {}", path.as_ref().display()))?;
let out = serde_json::from_str(&s).std_context("decode results")?;
Ok(out)
}
}
fn run_simulation(seeds: &[u64], scenario: ScenarioDescription) -> SimulationResults {
let mut results = HashMap::new();
let network_config = scenario.config.clone().unwrap_or_default();
for &seed in seeds {
let span = error_span!("sim", name=%scenario.label(), %seed);
let _guard = span.enter();
let sim_config = SimulatorConfig {
rng_seed: seed,
peers: scenario.nodes as usize,
..Default::default()
};
let bootstrap = scenario.bootstrap.clone();
let mut simulator = Simulator::new(sim_config, network_config.clone());
info!("start");
let outcome = simulator.bootstrap(bootstrap);
if outcome.has_peers_with_no_neighbors() {
warn!("not all nodes active after bootstrap: {outcome:?}");
} else {
info!("bootstrapped, all nodes active");
}
let result = match scenario.sim {
Simulation::GossipSingle => BigSingle.run(simulator, scenario.rounds as usize),
Simulation::GossipMulti => BigMulti.run(simulator, scenario.rounds as usize),
Simulation::GossipAll => BigAll.run(simulator, scenario.rounds as usize),
};
info!("done");
results.insert(seed, result);
}
let stats: Vec<_> = results.values().cloned().collect();
let average = if !stats.is_empty() {
let avg = RoundStatsAvg::avg(&stats);
Some(avg)
} else {
None
};
SimulationResults {
average,
results,
scenario,
}
}
fn print_result(r: &SimulationResults) {
let seeds = r.results.len();
println!("{} with {seeds} seeds", r.scenario.label());
let Some(avg) = r.average.as_ref() else {
println!("no results, simulation did not complete");
return;
};
let mut table = Table::new();
let header = ["", "RMR", "LDH", "missed", "duration"]
.into_iter()
.map(|s| Cell::new(s).set_alignment(CellAlignment::Right));
table
.load_preset(NOTHING)
.set_header(header)
.add_row(fmt_round("mean", &avg.mean))
.add_row(fmt_round("max", &avg.max))
.add_row(fmt_round("min", &avg.min));
println!("{table}");
if avg.max.missed > 0.0 {
println!("WARN: Messages were missed!")
}
println!();
}
trait Scenario {
fn run(self, sim: Simulator, rounds: usize) -> RoundStatsAvg;
}
struct BigSingle;
impl Scenario for BigSingle {
fn run(self, mut simulator: Simulator, rounds: usize) -> RoundStatsAvg {
let from = simulator.random_peer();
for i in 0..rounds {
let message = format!("m{i}").into_bytes().into();
let messages = vec![(from, message)];
simulator.gossip_round(messages);
}
simulator.round_stats_average()
}
}
struct BigMulti;
impl Scenario for BigMulti {
fn run(self, mut simulator: Simulator, rounds: usize) -> RoundStatsAvg {
for i in 0..rounds {
let from = simulator.random_peer();
let message = format!("m{i}").into_bytes().into();
let messages = vec![(from, message)];
simulator.gossip_round(messages);
}
simulator.round_stats_average()
}
}
struct BigAll;
impl Scenario for BigAll {
fn run(self, mut simulator: Simulator, rounds: usize) -> RoundStatsAvg {
let messages_per_peer = 1;
for i in 0..rounds {
let mut messages = vec![];
for id in simulator.network.peer_ids() {
for j in 0..messages_per_peer {
let message: bytes::Bytes = format!("{i}:{j}.{id}").into_bytes().into();
messages.push((id, message));
}
}
simulator.gossip_round(messages);
}
simulator.round_stats_average()
}
}
fn compare_dirs(baseline_dir: PathBuf, current_path: PathBuf, filter: Vec<String>) -> Result<()> {
let mut paths = vec![];
for entry in std::fs::read_dir(&current_path)
.with_std_context(|_| format!("read directory {}", current_path.display()))?
.filter_map(Result::ok)
.filter(|x| x.path().is_file())
{
let current_file = entry.path().to_owned();
let Some(filename) = current_file.file_name().and_then(|s| s.to_str()) else {
continue;
};
let Some(basename) = filename.strip_suffix(".results.json") else {
continue;
};
if !filter.is_empty() && !filter.iter().any(|x| x == basename) {
continue;
}
let baseline_file = baseline_dir.join(filename);
if !baseline_file.exists() {
println!("skip {filename} (not in baseline)");
}
paths.push((basename.to_string(), baseline_file, current_file));
}
paths.sort();
for (basename, baseline_file, current_file) in paths {
println!("comparing {basename}");
if let Err(err) = compare_files(&baseline_file, &current_file) {
println!(" skip (reason: {err:#}");
}
}
Ok(())
}
fn compare_files(baseline: impl AsRef<Path>, current: impl AsRef<Path>) -> Result<()> {
let baseline =
SimulationResults::load_from_file(baseline.as_ref()).context("failed to load baseline")?;
let current =
SimulationResults::load_from_file(current.as_ref()).context("failed to load current")?;
compare_results(baseline, current);
Ok(())
}
fn compare_results(baseline: SimulationResults, current: SimulationResults) {
match (baseline.average, current.average) {
(None, Some(_avg)) => {
println!("baseline run did not finish");
}
(Some(_avg), None) => {
println!("current run did not finish");
}
(None, None) => println!("both runs did not finish"),
(Some(baseline), Some(current)) => {
let diff = baseline.diff(&current);
let mut table = Table::new();
let header = ["", "RMR", "LDH", "missed", "duration"]
.into_iter()
.map(|s| Cell::new(s).set_alignment(CellAlignment::Right));
table
.load_preset(NOTHING)
.set_header(header)
.add_row(fmt_diff_round("mean", &diff.mean))
.add_row(fmt_diff_round("max", &diff.max))
.add_row(fmt_diff_round("min", &diff.min));
println!("{table}");
}
}
}
fn fmt_round(label: &str, round: &RoundStats) -> Vec<Cell> {
[
label.to_string(),
format!("{:.2}", round.rmr),
format!("{:.2}", round.ldh),
format!("{:.2}", round.missed),
format!("{}ms", round.duration.as_millis()),
]
.into_iter()
.map(|s| Cell::new(s).set_alignment(CellAlignment::Right))
.collect()
}
fn fmt_diff_round(label: &str, round: &RoundStatsDiff) -> Vec<String> {
vec![
label.to_string(),
fmt_percent(round.rmr),
fmt_percent(round.ldh),
fmt_percent(round.missed),
fmt_percent(round.duration),
]
}
fn fmt_percent(diff: f32) -> String {
format!("{:>+10.2}%", diff * 100.)
}

View file

@ -0,0 +1,25 @@
#![cfg_attr(feature = "net", doc = include_str!("../README.md"))]
//! Broadcast messages to peers subscribed to a topic
//!
//! The crate is designed to be used from the [iroh] crate, which provides a
//! [high level interface](https://docs.rs/iroh/latest/iroh/client/gossip/index.html),
//! but can also be used standalone.
//!
//! [iroh]: https://docs.rs/iroh
#![deny(missing_docs, rustdoc::broken_intra_doc_links)]
#![cfg_attr(iroh_docsrs, feature(doc_cfg))]
#[cfg(feature = "net")]
pub use net::Gossip;
#[cfg(feature = "net")]
#[doc(inline)]
pub use net::GOSSIP_ALPN as ALPN;
#[cfg(any(feature = "net", feature = "rpc"))]
pub mod api;
pub mod metrics;
#[cfg(feature = "net")]
pub mod net;
pub mod proto;
pub use proto::TopicId;

View file

@ -0,0 +1,45 @@
//! Metrics for iroh-gossip
use iroh_metrics::{Counter, MetricsGroup};
/// Enum of metrics for the module
#[derive(Debug, Default, MetricsGroup)]
#[metrics(name = "gossip")]
pub struct Metrics {
/// Number of control messages sent
pub msgs_ctrl_sent: Counter,
/// Number of control messages received
pub msgs_ctrl_recv: Counter,
/// Number of data messages sent
pub msgs_data_sent: Counter,
/// Number of data messages received
pub msgs_data_recv: Counter,
/// Total size of all data messages sent
pub msgs_data_sent_size: Counter,
/// Total size of all data messages received
pub msgs_data_recv_size: Counter,
/// Total size of all control messages sent
pub msgs_ctrl_sent_size: Counter,
/// Total size of all control messages received
pub msgs_ctrl_recv_size: Counter,
/// Number of times we connected to a peer
pub neighbor_up: Counter,
/// Number of times we disconnected from a peer
pub neighbor_down: Counter,
/// Number of times the main actor loop ticked
pub actor_tick_main: Counter,
/// Number of times the actor ticked for a message received
pub actor_tick_rx: Counter,
/// Number of times the actor ticked for an endpoint event
pub actor_tick_endpoint: Counter,
/// Number of times the actor ticked for a dialer event
pub actor_tick_dialer: Counter,
/// Number of times the actor ticked for a successful dialer event
pub actor_tick_dialer_success: Counter,
/// Number of times the actor ticked for a failed dialer event
pub actor_tick_dialer_failure: Counter,
/// Number of times the actor ticked for an incoming event
pub actor_tick_in_event_rx: Counter,
/// Number of times the actor ticked for a timer event
pub actor_tick_timers: Counter,
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,175 @@
//! An address lookup service to gather addressing info collected from gossip Join and ForwardJoin messages.
use std::{
collections::{btree_map::Entry, BTreeMap},
sync::{Arc, RwLock},
time::Duration,
};
use iroh::address_lookup::{self, AddressLookup, EndpointData, EndpointInfo};
use iroh_base::EndpointId;
use n0_future::{
boxed::BoxStream,
stream::{self, StreamExt},
task::AbortOnDropHandle,
time::SystemTime,
};
pub(crate) struct RetentionOpts {
/// How long to keep received endpoint info records alive before pruning them
retention: Duration,
/// How often to check for expired entries
evict_interval: Duration,
}
impl Default for RetentionOpts {
fn default() -> Self {
Self {
retention: Duration::from_secs(60 * 5),
evict_interval: Duration::from_secs(30),
}
}
}
/// An address lookup service that expires endpoints after some time.
///
/// It is added to the endpoint when constructing a gossip instance, and the gossip actor
/// then adds endpoint addresses as received with Join or ForwardJoin messages.
#[derive(Debug, Clone)]
pub(crate) struct GossipAddressLookup {
endpoints: NodeMap,
_task_handle: Arc<AbortOnDropHandle<()>>,
}
type NodeMap = Arc<RwLock<BTreeMap<EndpointId, StoredEndpointInfo>>>;
#[derive(Debug)]
struct StoredEndpointInfo {
data: EndpointData,
last_updated: SystemTime,
}
impl Default for GossipAddressLookup {
fn default() -> Self {
Self::new()
}
}
impl GossipAddressLookup {
const PROVENANCE: &'static str = "gossip";
/// Creates a new gossip address lookup instance.
pub(crate) fn new() -> Self {
Self::with_opts(Default::default())
}
pub(crate) fn with_opts(opts: RetentionOpts) -> Self {
let endpoints: NodeMap = Default::default();
let task = {
let endpoints = Arc::downgrade(&endpoints);
n0_future::task::spawn(async move {
let mut interval = n0_future::time::interval(opts.evict_interval);
loop {
interval.tick().await;
let Some(endpoints) = endpoints.upgrade() else {
break;
};
let now = SystemTime::now();
endpoints.write().expect("poisoned").retain(|_k, v| {
let age = now.duration_since(v.last_updated).unwrap_or(Duration::MAX);
age <= opts.retention
});
}
})
};
Self {
endpoints,
_task_handle: Arc::new(AbortOnDropHandle::new(task)),
}
}
/// Augments endpoint addressing information for the given endpoint ID.
///
/// The provided addressing information is combined with the existing info in the in-memory
/// lookup. Any new direct addresses are added to those already present while the
/// relay URL is overwritten.
pub(crate) fn add(&self, endpoint_info: impl Into<EndpointInfo>) {
let last_updated = SystemTime::now();
let EndpointInfo { endpoint_id, data } = endpoint_info.into();
let mut guard = self.endpoints.write().expect("poisoned");
match guard.entry(endpoint_id) {
Entry::Occupied(mut entry) => {
let existing = entry.get_mut();
existing.data.add_addrs(data.addrs().cloned());
existing.data.set_user_data(data.user_data().cloned());
existing.last_updated = last_updated;
}
Entry::Vacant(entry) => {
entry.insert(StoredEndpointInfo { data, last_updated });
}
}
}
}
impl AddressLookup for GossipAddressLookup {
fn resolve(
&self,
endpoint_id: EndpointId,
) -> Option<BoxStream<Result<address_lookup::Item, address_lookup::Error>>> {
let guard = self.endpoints.read().expect("poisoned");
let info = guard.get(&endpoint_id)?;
let last_updated = info
.last_updated
.duration_since(SystemTime::UNIX_EPOCH)
.expect("time drift")
.as_micros() as u64;
let item = address_lookup::Item::new(
EndpointInfo::from_parts(endpoint_id, info.data.clone()),
Self::PROVENANCE,
Some(last_updated),
);
Some(stream::iter(Some(Ok(item))).boxed())
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use iroh::{address_lookup::AddressLookup, EndpointAddr, SecretKey};
use n0_future::StreamExt;
use rand::SeedableRng;
use super::{GossipAddressLookup, RetentionOpts};
#[tokio::test]
async fn test_retention() {
let opts = RetentionOpts {
evict_interval: Duration::from_millis(100),
retention: Duration::from_millis(500),
};
let disco = GossipAddressLookup::with_opts(opts);
let rng = &mut rand_chacha::ChaCha12Rng::seed_from_u64(1);
let k1 = SecretKey::generate(rng);
let a1 = EndpointAddr::new(k1.public());
disco.add(a1);
assert!(matches!(
disco.resolve(k1.public()).unwrap().next().await,
Some(Ok(_))
));
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(matches!(
disco.resolve(k1.public()).unwrap().next().await,
Some(Ok(_))
));
tokio::time::sleep(Duration::from_millis(700)).await;
assert!(disco.resolve(k1.public()).is_none());
}
}

View file

@ -0,0 +1,435 @@
//! Utilities for iroh-gossip networking
use std::{
collections::{hash_map, HashMap},
io,
time::Duration,
};
use bytes::{Bytes, BytesMut};
use iroh::{
endpoint::{Connection, RecvStream, SendStream},
EndpointId,
};
use n0_error::{e, stack_error};
use n0_future::{
time::{sleep_until, Instant},
FuturesUnordered, StreamExt,
};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
sync::mpsc,
task::JoinSet,
};
use tracing::{debug, trace, Instrument};
use super::{InEvent, ProtoMessage};
use crate::proto::{util::TimerMap, TopicId};
/// Errors related to message writing
#[allow(missing_docs)]
#[stack_error(derive, add_meta, from_sources)]
#[non_exhaustive]
pub(crate) enum WriteError {
/// Connection error
#[error("Connection error")]
Connection {
#[error(std_err)]
source: iroh::endpoint::ConnectionError,
},
/// Serialization failed
#[error("Serialization failed")]
Ser {
#[error(std_err)]
source: postcard::Error,
},
/// IO error
#[error("IO error")]
Io {
#[error(std_err)]
source: std::io::Error,
},
/// Message was larger than the configured maximum message size
#[error("message too large")]
TooLarge {},
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct StreamHeader {
pub(crate) topic_id: TopicId,
}
impl StreamHeader {
pub(crate) async fn read(
stream: &mut RecvStream,
buffer: &mut BytesMut,
max_message_size: usize,
) -> Result<Self, ReadError> {
let header: Self = read_frame(stream, buffer, max_message_size)
.await?
.ok_or_else(|| {
ReadError::from(io::Error::new(
io::ErrorKind::UnexpectedEof,
"stream ended before header",
))
})?;
Ok(header)
}
pub(crate) async fn write(
self,
stream: &mut SendStream,
buffer: &mut Vec<u8>,
max_message_size: usize,
) -> Result<(), WriteError> {
write_frame(stream, &self, buffer, max_message_size).await?;
Ok(())
}
}
pub(crate) struct RecvLoop {
remote_endpoint_id: EndpointId,
conn: Connection,
max_message_size: usize,
in_event_tx: mpsc::Sender<InEvent>,
}
impl RecvLoop {
pub(crate) fn new(
remote_endpoint_id: EndpointId,
conn: Connection,
in_event_tx: mpsc::Sender<InEvent>,
max_message_size: usize,
) -> Self {
Self {
remote_endpoint_id,
conn,
max_message_size,
in_event_tx,
}
}
pub(crate) async fn run(&mut self) -> Result<(), ReadError> {
let mut read_futures = FuturesUnordered::new();
let mut conn_is_closed = false;
let closed = self.conn.closed();
tokio::pin!(closed);
while !conn_is_closed || !read_futures.is_empty() {
tokio::select! {
_ = &mut closed, if !conn_is_closed => {
conn_is_closed = true;
}
stream = self.conn.accept_uni(), if !conn_is_closed => {
let stream = match stream {
Ok(stream) => stream,
Err(_) => {
conn_is_closed = true;
continue;
}
};
let state = RecvStreamState::new(stream, self.max_message_size).await?;
debug!(topic=%state.header.topic_id.fmt_short(), "stream opened");
read_futures.push(state.next());
}
Some(res) = read_futures.next(), if !read_futures.is_empty() => {
let (state, msg) = match res {
Ok((state, msg)) => (state, msg),
Err(err) => {
debug!("recv stream closed with error: {err:#}");
continue;
}
};
match msg {
None => debug!(topic=%state.header.topic_id.fmt_short(), "stream closed"),
Some(msg) => {
if self.in_event_tx.send(InEvent::RecvMessage(self.remote_endpoint_id, msg)).await.is_err() {
debug!("stop recv loop: actor closed");
break;
}
read_futures.push(state.next());
}
}
}
}
}
debug!("recv loop closed");
Ok(())
}
}
#[derive(Debug)]
struct RecvStreamState {
stream: RecvStream,
header: StreamHeader,
buffer: BytesMut,
max_message_size: usize,
}
impl RecvStreamState {
async fn new(mut stream: RecvStream, max_message_size: usize) -> Result<Self, ReadError> {
let mut buffer = BytesMut::new();
let header = StreamHeader::read(&mut stream, &mut buffer, max_message_size).await?;
Ok(Self {
buffer: BytesMut::new(),
max_message_size,
stream,
header,
})
}
/// Reads the next message from the stream.
///
/// Returns `self` and the next message, or `None` if the stream ended gracefully.
///
/// ## Cancellation safety
///
/// This function is not cancellation-safe.
async fn next(mut self) -> Result<(Self, Option<ProtoMessage>), ReadError> {
let msg = read_frame(&mut self.stream, &mut self.buffer, self.max_message_size).await?;
let msg = msg.map(|msg| ProtoMessage {
topic: self.header.topic_id,
message: msg,
});
Ok((self, msg))
}
}
pub(crate) struct SendLoop {
conn: Connection,
streams: HashMap<TopicId, SendStream>,
buffer: Vec<u8>,
max_message_size: usize,
finishing: JoinSet<()>,
send_rx: mpsc::Receiver<ProtoMessage>,
}
impl SendLoop {
pub(crate) fn new(
conn: Connection,
send_rx: mpsc::Receiver<ProtoMessage>,
max_message_size: usize,
) -> Self {
Self {
conn,
max_message_size,
buffer: Default::default(),
streams: Default::default(),
finishing: Default::default(),
send_rx,
}
}
pub(crate) async fn run(&mut self, queue: Vec<ProtoMessage>) -> Result<(), WriteError> {
for msg in queue {
self.write_message(&msg).await?;
}
let conn_clone = self.conn.clone();
let closed = conn_clone.closed();
tokio::pin!(closed);
loop {
tokio::select! {
biased;
_ = &mut closed => break,
Some(msg) = self.send_rx.recv() => self.write_message(&msg).await?,
_ = self.finishing.join_next(), if !self.finishing.is_empty() => {}
else => break,
}
}
// Close remaining streams.
for (topic_id, mut stream) in self.streams.drain() {
stream.finish().ok();
self.finishing.spawn(
async move {
stream.stopped().await.ok();
debug!(topic=%topic_id.fmt_short(), "stream closed");
}
.instrument(tracing::Span::current()),
);
}
if !self.finishing.is_empty() {
trace!(
"send loop closing, waiting for {} send streams to finish",
self.finishing.len()
);
// Wait for the remote to acknowledge all streams are finished.
if let Err(_elapsed) = n0_future::time::timeout(Duration::from_secs(5), async move {
while self.finishing.join_next().await.is_some() {}
})
.await
{
debug!("not all send streams finished within timeout, abort")
}
}
debug!("send loop closed");
Ok(())
}
/// Write a [`ProtoMessage`] as a length-prefixed, postcard-encoded message on its stream.
///
/// If no stream is opened yet, this opens a new stream for the topic and writes the topic header.
///
/// This function is not cancellation-safe.
pub async fn write_message(&mut self, message: &ProtoMessage) -> Result<(), WriteError> {
let ProtoMessage { topic, message } = message;
let topic_id = *topic;
let is_last = message.is_disconnect();
let mut entry = match self.streams.entry(topic_id) {
hash_map::Entry::Occupied(entry) => entry,
hash_map::Entry::Vacant(entry) => {
let mut stream = self.conn.open_uni().await?;
let header = StreamHeader { topic_id };
header
.write(&mut stream, &mut self.buffer, self.max_message_size)
.await?;
debug!(topic=%topic_id.fmt_short(), "stream opened");
entry.insert_entry(stream)
}
};
let stream = entry.get_mut();
write_frame(stream, message, &mut self.buffer, self.max_message_size).await?;
if is_last {
trace!(topic=%topic_id.fmt_short(), "stream closing");
let mut stream = entry.remove();
if stream.finish().is_ok() {
self.finishing.spawn(
async move {
stream.stopped().await.ok();
debug!(topic=%topic_id.fmt_short(), "stream closed");
}
.instrument(tracing::Span::current()),
);
}
}
Ok(())
}
}
/// Errors related to message reading
#[allow(missing_docs)]
#[stack_error(derive, add_meta, from_sources)]
#[non_exhaustive]
pub(crate) enum ReadError {
/// Deserialization failed
#[error("Deserialization failed")]
De {
#[error(std_err)]
source: postcard::Error,
},
/// IO error
#[error("IO error")]
Io {
#[error(std_err)]
source: std::io::Error,
},
/// Message was larger than the configured maximum message size
#[error("message too large")]
TooLarge {},
}
/// Read a length-prefixed frame and decode with postcard.
pub async fn read_frame<T: DeserializeOwned>(
reader: &mut RecvStream,
buffer: &mut BytesMut,
max_message_size: usize,
) -> Result<Option<T>, ReadError> {
match read_lp(reader, buffer, max_message_size).await? {
None => Ok(None),
Some(data) => {
let message = postcard::from_bytes(&data)?;
Ok(Some(message))
}
}
}
/// Reads a length prefixed buffer.
///
/// Returns the frame as raw bytes. If the end of the stream is reached before
/// the frame length starts, `None` is returned.
pub async fn read_lp(
reader: &mut RecvStream,
buffer: &mut BytesMut,
max_message_size: usize,
) -> Result<Option<Bytes>, ReadError> {
let size = match reader.read_u32().await {
Ok(size) => size,
Err(err) if err.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
Err(err) => return Err(err.into()),
};
let size = usize::try_from(size).map_err(|_| e!(ReadError::TooLarge))?;
if size > max_message_size {
return Err(e!(ReadError::TooLarge));
}
buffer.resize(size, 0u8);
reader
.read_exact(&mut buffer[..])
.await
.map_err(io::Error::other)?;
Ok(Some(buffer.split_to(size).freeze()))
}
/// Writes a length-prefixed frame.
pub async fn write_frame<T: Serialize>(
stream: &mut SendStream,
message: &T,
buffer: &mut Vec<u8>,
max_message_size: usize,
) -> Result<(), WriteError> {
let len = postcard::experimental::serialized_size(&message)?;
if len >= max_message_size {
return Err(e!(WriteError::TooLarge));
}
buffer.clear();
buffer.resize(len, 0u8);
let slice = postcard::to_slice(&message, buffer)?;
stream.write_u32(len as u32).await?;
stream.write_all(slice).await.map_err(io::Error::other)?;
Ok(())
}
/// A [`TimerMap`] with an async method to wait for the next timer expiration.
#[derive(Debug)]
pub struct Timers<T> {
map: TimerMap<T>,
}
impl<T> Default for Timers<T> {
fn default() -> Self {
Self {
map: TimerMap::default(),
}
}
}
impl<T> Timers<T> {
/// Creates a new timer map.
pub fn new() -> Self {
Self::default()
}
/// Inserts a new entry at the specified instant
pub fn insert(&mut self, instant: Instant, item: T) {
self.map.insert(instant, item);
}
/// Waits for the next timer to elapse.
pub async fn wait_next(&mut self) -> Instant {
match self.map.first() {
None => std::future::pending::<Instant>().await,
Some(instant) => {
sleep_until(*instant).await;
*instant
}
}
}
/// Pops the earliest timer that expires at or before `now`.
pub fn pop_before(&mut self, now: Instant) -> Option<(Instant, T)> {
self.map.pop_before(now)
}
}

View file

@ -0,0 +1,344 @@
//! Implementation of the iroh-gossip protocol, as an IO-less state machine
//!
//! This module implements the iroh-gossip protocol. The entry point is [`State`], which contains
//! the protocol state for a node.
//!
//! The iroh-gossip protocol is made up from two parts: A swarm membership protocol, based on
//! [HyParView][hyparview], and a gossip broadcasting protocol, based on [PlumTree][plumtree].
//!
//! For a full explanation it is recommended to read the two papers. What follows is a brief
//! outline of the protocols.
//!
//! All protocol messages are namespaced by a [`TopicId`], a 32 byte identifier. Topics are
//! separate swarms and broadcast scopes. The HyParView and PlumTree algorithms both work in the
//! scope of a single topic. Thus, joining multiple topics increases the number of open connections
//! to peers and the size of the local routing table.
//!
//! The **membership protocol** ([HyParView][hyparview]) is a cluster protocol where each peer
//! maintains a partial view of all nodes in the swarm.
//! A peer joins the swarm for a topic by connecting to any known peer that is a member of this
//! topic's swarm. Obtaining this initial contact info happens out of band. The peer then sends
//! a `Join` message to that initial peer. All peers maintain a list of
//! `active` and `passive` peers. Active peers are those that you maintain active connections to.
//! Passive peers is an addressbook of additional peers. If one of your active peers goes offline,
//! its slot is filled with a random peer from the passive set. In the default configuration, the
//! active view has a size of 5 and the passive view a size of 30.
//! The HyParView protocol ensures that active connections are always bidirectional, and regularly
//! exchanges nodes for the passive view in a `Shuffle` operation.
//! Thus, this protocol exposes a high degree of reliability and auto-recovery in the case of node
//! failures.
//!
//! The **gossip protocol** ([PlumTree][plumtree]) builds upon the membership protocol. It exposes
//! a method to broadcast messages to all peers in the swarm. On each node, it maintains two sets
//! of peers: An `eager` set and a `lazy` set. Both are subsets of the `active` view from the
//! membership protocol. When broadcasting a message from the local node, or upon receiving a
//! broadcast message, the message is pushed to all peers in the eager set. Additionally, the hash
//! of the message (which uniquely identifies it), but not the message content, is lazily pushed
//! to all peers in the `lazy` set. When receiving such lazy pushes (called `Ihaves`), those peers
//! may request the message content after a timeout if they didn't receive the message by one of
//! their eager peers before. When requesting a message from a currently-lazy peer, this peer is
//! also upgraded to be an eager peer from that moment on. This strategy self-optimizes the
//! messaging graph by latency. Note however that this optimization will work best if the messaging
//! paths are stable, i.e. if it's always the same peer that broadcasts. If not, the relative
//! message redundancy will grow and the ideal messaging graph might change frequently.
//!
//! [hyparview]: https://asc.di.fct.unl.pt/~jleitao/pdf/dsn07-leitao.pdf
//! [plumtree]: https://asc.di.fct.unl.pt/~jleitao/pdf/srds07-leitao.pdf
use std::{fmt, hash::Hash};
use bytes::Bytes;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
mod hyparview;
mod plumtree;
pub mod state;
pub mod topic;
pub mod util;
#[cfg(any(test, feature = "test-utils"))]
pub mod sim;
pub use hyparview::Config as HyparviewConfig;
pub use plumtree::{Config as PlumtreeConfig, DeliveryScope, Scope};
pub use state::{InEvent, Message, OutEvent, State, Timer, TopicId};
pub use topic::{Command, Config, Event, IO};
/// The default maximum size in bytes for a gossip message.
/// This is a sane but arbitrary default and can be changed in the [`Config`].
pub const DEFAULT_MAX_MESSAGE_SIZE: usize = 4096;
/// The minimum allowed value for [`Config::max_message_size`].
pub const MIN_MAX_MESSAGE_SIZE: usize = 512;
/// The identifier for a peer.
///
/// The protocol implementation is generic over this trait. When implementing the protocol,
/// a concrete type must be chosen that will then be used throughout the implementation to identify
/// and index individual peers.
///
/// Note that the concrete type will be used in protocol messages. Therefore, implementations of
/// the protocol are only compatible if the same concrete type is supplied for this trait.
///
/// TODO: Rename to `PeerId`? It does not necessarily refer to a peer's address, as long as the
/// networking layer can translate the value of its concrete type into an address.
pub trait PeerIdentity: Hash + Eq + Ord + Copy + fmt::Debug + Serialize + DeserializeOwned {}
impl<T> PeerIdentity for T where
T: Hash + Eq + Ord + Copy + fmt::Debug + Serialize + DeserializeOwned
{
}
/// Opaque binary data that is transmitted on messages that introduce new peers.
///
/// Implementations may use these bytes to supply addresses or other information needed to connect
/// to a peer that is not included in the peer's [`PeerIdentity`].
#[derive(derive_more::Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
#[debug("PeerData({}b)", self.0.len())]
pub struct PeerData(Bytes);
impl PeerData {
/// Create a new [`PeerData`] from a byte buffer.
pub fn new(data: impl Into<Bytes>) -> Self {
Self(data.into())
}
/// Get a reference to the contained [`bytes::Bytes`].
pub fn inner(&self) -> &bytes::Bytes {
&self.0
}
/// Get the peer data as a byte slice.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
/// PeerInfo contains a peer's identifier and the opaque peer data as provided by the implementer.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
struct PeerInfo<PI> {
pub id: PI,
pub data: Option<PeerData>,
}
impl<PI> From<(PI, Option<PeerData>)> for PeerInfo<PI> {
fn from((id, data): (PI, Option<PeerData>)) -> Self {
Self { id, data }
}
}
#[cfg(test)]
mod test {
use std::{collections::HashSet, env, fmt, str::FromStr};
use n0_tracing_test::traced_test;
use rand::SeedableRng;
use rand_chacha::ChaCha12Rng;
use super::{Command, Config, Event};
use crate::proto::{
sim::{LatencyConfig, Network, NetworkConfig},
Scope, TopicId,
};
#[test]
#[traced_test]
fn hyparview_smoke() {
// Create a network with 4 nodes and active_view_capacity 2
let rng = ChaCha12Rng::seed_from_u64(read_var("SEED", 0));
let mut config = Config::default();
config.membership.active_view_capacity = 2;
let network_config = NetworkConfig {
proto: config,
latency: LatencyConfig::default_static(),
};
let mut network = Network::new(network_config, rng);
for i in 0..4 {
network.insert(i);
}
let t: TopicId = [0u8; 32].into();
// Do some joins between nodes 0,1,2
network.command(0, t, Command::Join(vec![1, 2]));
network.command(1, t, Command::Join(vec![2]));
network.command(2, t, Command::Join(vec![]));
network.run_trips(3);
// Confirm emitted events
let actual = network.events_sorted();
let expected = sort(vec![
(0, t, Event::NeighborUp(1)),
(0, t, Event::NeighborUp(2)),
(1, t, Event::NeighborUp(2)),
(1, t, Event::NeighborUp(0)),
(2, t, Event::NeighborUp(0)),
(2, t, Event::NeighborUp(1)),
]);
assert_eq!(actual, expected);
// Confirm active connections
assert_eq!(network.conns(), vec![(0, 1), (0, 2), (1, 2)]);
// Now let node 3 join node 0.
// Node 0 is full, so it will disconnect from either node 1 or node 2.
network.command(3, t, Command::Join(vec![0]));
network.run_trips(2);
// Confirm emitted events. There's two options because whether node 0 disconnects from
// node 1 or node 2 is random.
let actual = network.events_sorted();
eprintln!("actual {actual:#?}");
let expected1 = sort(vec![
(3, t, Event::NeighborUp(0)),
(0, t, Event::NeighborUp(3)),
(0, t, Event::NeighborDown(1)),
(1, t, Event::NeighborDown(0)),
]);
let expected2 = sort(vec![
(3, t, Event::NeighborUp(0)),
(0, t, Event::NeighborUp(3)),
(0, t, Event::NeighborDown(2)),
(2, t, Event::NeighborDown(0)),
]);
assert!((actual == expected1) || (actual == expected2));
// Confirm active connections.
if actual == expected1 {
assert_eq!(network.conns(), vec![(0, 2), (0, 3), (1, 2)]);
} else {
assert_eq!(network.conns(), vec![(0, 1), (0, 3), (1, 2)]);
}
assert!(network.check_synchronicity());
}
#[test]
#[traced_test]
fn plumtree_smoke() {
let rng = ChaCha12Rng::seed_from_u64(read_var("SEED", 0));
let network_config = NetworkConfig {
proto: Config::default(),
latency: LatencyConfig::default_static(),
};
let mut network = Network::new(network_config, rng);
// build a network with 6 nodes
for i in 0..6 {
network.insert(i);
}
let t = [0u8; 32].into();
// let node 0 join the topic but do not connect to any peers
network.command(0, t, Command::Join(vec![]));
// connect nodes 1 and 2 to node 0
(1..3).for_each(|i| network.command(i, t, Command::Join(vec![0])));
// connect nodes 4 and 5 to node 3
network.command(3, t, Command::Join(vec![]));
(4..6).for_each(|i| network.command(i, t, Command::Join(vec![3])));
// run ticks and drain events
network.run_trips(4);
let _ = network.events();
assert!(network.check_synchronicity());
// now broadcast a first message
network.command(
1,
t,
Command::Broadcast(b"hi1".to_vec().into(), Scope::Swarm),
);
network.run_trips(4);
let events = network.events();
let received = events.filter(|x| matches!(x, (_, _, Event::Received(_))));
// message should be received by two other nodes
assert_eq!(received.count(), 2);
assert!(network.check_synchronicity());
// now connect the two sections of the swarm
network.command(2, t, Command::Join(vec![5]));
network.run_trips(3);
let _ = network.events();
println!("{}", network.report());
// now broadcast again
network.command(
1,
t,
Command::Broadcast(b"hi2".to_vec().into(), Scope::Swarm),
);
network.run_trips(5);
let events = network.events();
let received = events.filter(|x| matches!(x, (_, _, Event::Received(_))));
// message should be received by all 5 other nodes
assert_eq!(received.count(), 5);
assert!(network.check_synchronicity());
println!("{}", network.report());
}
#[test]
#[traced_test]
fn quit() {
// Create a network with 4 nodes and active_view_capacity 2
let rng = ChaCha12Rng::seed_from_u64(read_var("SEED", 0));
let mut config = Config::default();
config.membership.active_view_capacity = 2;
let mut network = Network::new(config.into(), rng);
let num = 4;
for i in 0..num {
network.insert(i);
}
let t: TopicId = [0u8; 32].into();
// join all nodes
network.command(0, t, Command::Join(vec![]));
network.command(1, t, Command::Join(vec![0]));
network.command(2, t, Command::Join(vec![1]));
network.command(3, t, Command::Join(vec![2]));
network.run_trips(2);
// assert all peers appear in the connections
let all_conns: HashSet<u64> = HashSet::from_iter((0u64..4).flat_map(|p| {
network
.neighbors(&p, &t)
.into_iter()
.flat_map(|x| x.into_iter())
}));
assert_eq!(all_conns, HashSet::from_iter([0, 1, 2, 3]));
assert!(network.check_synchronicity());
// let node 3 leave the swarm
network.command(3, t, Command::Quit);
network.run_trips(4);
assert!(network.peer(&3).unwrap().state(&t).is_none());
// assert all peers without peer 3 appear in the connections
let all_conns: HashSet<u64> = HashSet::from_iter((0..num).flat_map(|p| {
network
.neighbors(&p, &t)
.into_iter()
.flat_map(|x| x.into_iter())
}));
assert_eq!(all_conns, HashSet::from_iter([0, 1, 2]));
assert!(network.check_synchronicity());
}
fn read_var<T: FromStr<Err: fmt::Display + fmt::Debug>>(name: &str, default: T) -> T {
env::var(name)
.map(|x| {
x.parse()
.unwrap_or_else(|_| panic!("Failed to parse environment variable {name}"))
})
.unwrap_or(default)
}
fn sort<T: Ord + Clone>(items: Vec<T>) -> Vec<T> {
let mut sorted = items;
sorted.sort();
sorted
}
}

View file

@ -0,0 +1,764 @@
//! Implementation of the HyParView membership protocol
//!
//! The implementation is based on [this paper][paper] by Joao Leitao, Jose Pereira, Luıs Rodrigues
//! and the [example implementation][impl] by Bartosz Sypytkowski
//!
//! [paper]: https://asc.di.fct.unl.pt/~jleitao/pdf/dsn07-leitao.pdf
//! [impl]: https://gist.github.com/Horusiath/84fac596101b197da0546d1697580d99
use std::collections::{HashMap, HashSet};
use derive_more::{From, Sub};
use n0_future::time::Duration;
use rand::{rngs::ThreadRng, Rng};
use serde::{Deserialize, Serialize};
use tracing::debug;
use super::{util::IndexSet, PeerData, PeerIdentity, PeerInfo, IO};
/// Input event for HyParView
#[derive(Debug)]
pub enum InEvent<PI> {
/// A [`Message`] was received from a peer.
RecvMessage(PI, Message<PI>),
/// A timer has expired.
TimerExpired(Timer<PI>),
/// A peer was disconnected on the IO layer.
PeerDisconnected(PI),
/// Send a join request to a peer.
RequestJoin(PI),
/// Update the peer data that is transmitted on join requests.
UpdatePeerData(PeerData),
/// Quit the swarm, informing peers about us leaving.
Quit,
}
/// Output event for HyParView
#[derive(Debug)]
pub enum OutEvent<PI> {
/// Ask the IO layer to send a [`Message`] to peer `PI`.
SendMessage(PI, Message<PI>),
/// Schedule a [`Timer`].
ScheduleTimer(Duration, Timer<PI>),
/// Ask the IO layer to close the connection to peer `PI`.
DisconnectPeer(PI),
/// Emit an [`Event`] to the application.
EmitEvent(Event<PI>),
/// New [`PeerData`] was received for peer `PI`.
PeerData(PI, PeerData),
}
/// Event emitted by the [`State`] to the application.
#[derive(Clone, Debug)]
pub enum Event<PI> {
/// A peer was added to our set of active connections.
NeighborUp(PI),
/// A peer was removed from our set of active connections.
NeighborDown(PI),
}
/// Kinds of timers HyParView needs to schedule.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Timer<PI> {
DoShuffle,
PendingNeighborRequest(PI),
}
/// Messages that we can send and receive from peers within the topic.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub enum Message<PI> {
/// Sent to a peer if you want to join the swarm
Join(Option<PeerData>),
/// When receiving Join, ForwardJoin is forwarded to the peer's ActiveView to introduce the
/// new member.
ForwardJoin(ForwardJoin<PI>),
/// A shuffle request is sent occasionally to re-shuffle the PassiveView with contacts from
/// other peers.
Shuffle(Shuffle<PI>),
/// Peers reply to [`Message::Shuffle`] requests with a random peers from their active and
/// passive views.
ShuffleReply(ShuffleReply<PI>),
/// Request to add sender to an active view of recipient. If [`Neighbor::priority`] is
/// [`Priority::High`], the request cannot be denied.
Neighbor(Neighbor),
/// Request to disconnect from a peer.
/// If [`Disconnect::alive`] is true, the other peer is not shutting down, so it should be
/// added to the passive set.
Disconnect(Disconnect),
}
/// The time-to-live for this message.
///
/// Each time a message is forwarded, the `Ttl` is decreased by 1. If the `Ttl` reaches 0, it
/// should not be forwarded further.
#[derive(From, Sub, Eq, PartialEq, Clone, Debug, Copy, Serialize, Deserialize)]
pub struct Ttl(pub u16);
impl Ttl {
pub fn expired(&self) -> bool {
*self == Ttl(0)
}
pub fn next(&self) -> Ttl {
Ttl(self.0.saturating_sub(1))
}
}
/// A message informing other peers that a new peer joined the swarm for this topic.
///
/// Will be forwarded in a random walk until `ttl` reaches 0.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct ForwardJoin<PI> {
/// The peer that newly joined the swarm
peer: PeerInfo<PI>,
/// The time-to-live for this message
ttl: Ttl,
}
/// Shuffle messages are sent occasionally to shuffle our passive view with peers from other peer's
/// active and passive views.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct Shuffle<PI> {
/// The peer that initiated the shuffle request.
origin: PI,
/// A random subset of the active and passive peers of the `origin` peer.
nodes: Vec<PeerInfo<PI>>,
/// The time-to-live for this message.
ttl: Ttl,
}
/// Once a shuffle messages reaches a [`Ttl`] of 0, a peer replies with a `ShuffleReply`.
///
/// The reply is sent to the peer that initiated the shuffle and contains a subset of the active
/// and passive views of the peer at the end of the random walk.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct ShuffleReply<PI> {
/// A random subset of the active and passive peers of the peer sending the `ShuffleReply`.
nodes: Vec<PeerInfo<PI>>,
}
/// The priority of a `Join` message
///
/// This is `High` if the sender does not have any active peers, and `Low` otherwise.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub enum Priority {
/// High priority join that may not be denied.
///
/// A peer may only send high priority joins if it doesn't have any active peers at the moment.
High,
/// Low priority join that can be denied.
Low,
}
/// A neighbor message is sent after adding a peer to our active view to inform them that we are
/// now neighbors.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct Neighbor {
/// The priority of the `Join` or `ForwardJoin` message that triggered this neighbor request.
priority: Priority,
/// The user data of the peer sending this message.
data: Option<PeerData>,
}
/// Message sent when leaving the swarm or closing down to inform peers about us being gone.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct Disconnect {
/// Whether we are actually shutting down or closing the connection only because our limits are
/// reached.
alive: bool,
/// Obsolete field (kept in the struct to maintain wire compatibility).
_respond: bool,
}
/// Configuration for the swarm membership layer
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// Number of peers to which active connections are maintained
pub active_view_capacity: usize,
/// Number of peers for which contact information is remembered,
/// but to which we are not actively connected to.
pub passive_view_capacity: usize,
/// Number of hops a `ForwardJoin` message is propagated until the new peer's info
/// is added to a peer's active view.
pub active_random_walk_length: Ttl,
/// Number of hops a `ForwardJoin` message is propagated until the new peer's info
/// is added to a peer's passive view.
pub passive_random_walk_length: Ttl,
/// Number of hops a `Shuffle` message is propagated until a peer replies to it.
pub shuffle_random_walk_length: Ttl,
/// Number of active peers to be included in a `Shuffle` request.
pub shuffle_active_view_count: usize,
/// Number of passive peers to be included in a `Shuffle` request.
pub shuffle_passive_view_count: usize,
/// Interval duration for shuffle requests
pub shuffle_interval: Duration,
/// Timeout after which a `Neighbor` request is considered failed
pub neighbor_request_timeout: Duration,
}
impl Default for Config {
/// Default values for the HyParView layer
fn default() -> Self {
Self {
// From the paper (p9)
active_view_capacity: 5,
// From the paper (p9)
passive_view_capacity: 30,
// From the paper (p9)
active_random_walk_length: Ttl(6),
// From the paper (p9)
passive_random_walk_length: Ttl(3),
// From the paper (p9)
shuffle_random_walk_length: Ttl(6),
// From the paper (p9)
shuffle_active_view_count: 3,
// From the paper (p9)
shuffle_passive_view_count: 4,
// Wild guess
shuffle_interval: Duration::from_secs(60),
// Wild guess
neighbor_request_timeout: Duration::from_millis(500),
}
}
}
#[derive(Default, Debug, Clone)]
pub struct Stats {
total_connections: usize,
}
/// The state of the HyParView protocol
#[derive(Debug)]
pub struct State<PI, RG = ThreadRng> {
/// Our peer identity
me: PI,
/// Our opaque user data to transmit to peers on join messages
me_data: Option<PeerData>,
/// The active view, i.e. peers we are connected to
pub(crate) active_view: IndexSet<PI>,
/// The passive view, i.e. peers we know about but are not connected to at the moment
pub(crate) passive_view: IndexSet<PI>,
/// Protocol configuration (cannot change at runtime)
config: Config,
/// Whether a shuffle timer is currently scheduled
shuffle_scheduled: bool,
/// Random number generator
rng: RG,
/// Statistics
pub(crate) stats: Stats,
/// The set of neighbor requests we sent out but did not yet receive a reply for
pending_neighbor_requests: HashSet<PI>,
/// The opaque user peer data we received for other peers
peer_data: HashMap<PI, PeerData>,
/// List of peers that are disconnecting, but which we want to keep in the passive set once the connection closes
alive_disconnect_peers: HashSet<PI>,
}
impl<PI, RG> State<PI, RG>
where
PI: PeerIdentity,
RG: Rng,
{
pub fn new(me: PI, me_data: Option<PeerData>, config: Config, rng: RG) -> Self {
Self {
me,
me_data,
active_view: IndexSet::new(),
passive_view: IndexSet::new(),
config,
shuffle_scheduled: false,
rng,
stats: Stats::default(),
pending_neighbor_requests: Default::default(),
peer_data: Default::default(),
alive_disconnect_peers: Default::default(),
}
}
pub fn handle(&mut self, event: InEvent<PI>, io: &mut impl IO<PI>) {
match event {
InEvent::RecvMessage(from, message) => self.handle_message(from, message, io),
InEvent::TimerExpired(timer) => match timer {
Timer::DoShuffle => self.handle_shuffle_timer(io),
Timer::PendingNeighborRequest(peer) => self.handle_pending_neighbor_timer(peer, io),
},
InEvent::PeerDisconnected(peer) => self.handle_connection_closed(peer, io),
InEvent::RequestJoin(peer) => self.handle_join(peer, io),
InEvent::UpdatePeerData(data) => {
self.me_data = Some(data);
}
InEvent::Quit => self.handle_quit(io),
}
// this will only happen on the first call
if !self.shuffle_scheduled {
io.push(OutEvent::ScheduleTimer(
self.config.shuffle_interval,
Timer::DoShuffle,
));
self.shuffle_scheduled = true;
}
}
fn handle_message(&mut self, from: PI, message: Message<PI>, io: &mut impl IO<PI>) {
let is_disconnect = matches!(message, Message::Disconnect(Disconnect { .. }));
if !is_disconnect && !self.active_view.contains(&from) {
self.stats.total_connections += 1;
}
match message {
Message::Join(data) => self.on_join(from, data, io),
Message::ForwardJoin(details) => self.on_forward_join(from, details, io),
Message::Shuffle(details) => self.on_shuffle(from, details, io),
Message::ShuffleReply(details) => self.on_shuffle_reply(details, io),
Message::Neighbor(details) => self.on_neighbor(from, details, io),
Message::Disconnect(details) => self.on_disconnect(from, details, io),
}
// Disconnect from passive nodes right after receiving a message.
// TODO(frando): I'm not sure anymore that this is correct. Maybe remove?
if !is_disconnect && !self.active_view.contains(&from) {
io.push(OutEvent::DisconnectPeer(from));
}
}
fn handle_join(&mut self, peer: PI, io: &mut impl IO<PI>) {
io.push(OutEvent::SendMessage(
peer,
Message::Join(self.me_data.clone()),
));
}
/// We received a disconnect message.
fn on_disconnect(&mut self, peer: PI, details: Disconnect, io: &mut impl IO<PI>) {
self.pending_neighbor_requests.remove(&peer);
if self.active_view.contains(&peer) {
self.remove_active(
&peer,
RemovalReason::DisconnectReceived {
is_alive: details.alive,
},
io,
);
} else if details.alive && self.passive_view.contains(&peer) {
self.alive_disconnect_peers.insert(peer);
}
}
/// A connection was closed by the peer.
fn handle_connection_closed(&mut self, peer: PI, io: &mut impl IO<PI>) {
self.pending_neighbor_requests.remove(&peer);
if self.active_view.contains(&peer) {
self.remove_active(&peer, RemovalReason::ConnectionClosed, io);
} else if !self.alive_disconnect_peers.remove(&peer) {
self.passive_view.remove(&peer);
self.peer_data.remove(&peer);
}
}
fn handle_quit(&mut self, io: &mut impl IO<PI>) {
for peer in self.active_view.clone().into_iter() {
self.active_view.remove(&peer);
self.send_disconnect(peer, false, io);
}
}
fn send_disconnect(&mut self, peer: PI, alive: bool, io: &mut impl IO<PI>) {
// Before disconnecting, send a `ShuffleReply` with some of our nodes to
// prevent the other node from running out of connections. This is especially
// relevant if the other node just joined the swarm.
self.send_shuffle_reply(
peer,
self.config.shuffle_active_view_count + self.config.shuffle_passive_view_count,
io,
);
let message = Message::Disconnect(Disconnect {
alive,
_respond: false,
});
io.push(OutEvent::SendMessage(peer, message));
io.push(OutEvent::DisconnectPeer(peer));
}
fn on_join(&mut self, peer: PI, data: Option<PeerData>, io: &mut impl IO<PI>) {
// "A node that receives a join request will start by adding the new
// node to its active view, even if it has to drop a random node from it. (6)"
self.add_active(peer, data.clone(), Priority::High, true, io);
// "The contact node c will then send to all other nodes in its active view a ForwardJoin
// request containing the new node identifier. Associated to the join procedure,
// there are two configuration parameters, named Active Random Walk Length (ARWL),
// that specifies the maximum number of hops a ForwardJoin request is propagated,
// and Passive Random Walk Length (PRWL), that specifies at which point in the walk the node
// is inserted in a passive view. To use these parameters, the ForwardJoin request carries
// a “time to live” field that is initially set to ARWL and decreased at every hop. (7)"
let ttl = self.config.active_random_walk_length;
let peer_info = PeerInfo { id: peer, data };
for node in self.active_view.iter_without(&peer) {
let message = Message::ForwardJoin(ForwardJoin {
peer: peer_info.clone(),
ttl,
});
io.push(OutEvent::SendMessage(*node, message));
}
}
fn on_forward_join(&mut self, sender: PI, message: ForwardJoin<PI>, io: &mut impl IO<PI>) {
let peer_id = message.peer.id;
// If the peer is already in our active view, we renew our neighbor relationship.
if self.active_view.contains(&peer_id) {
self.insert_peer_info(message.peer, io);
self.send_neighbor(peer_id, Priority::High, io);
}
// "i) If the time to live is equal to zero or if the number of nodes in ps active view is equal to one,
// it will add the new node to its active view (7)"
else if message.ttl.expired() || self.active_view.len() <= 1 {
self.insert_peer_info(message.peer, io);
// Modification from paper: Instead of adding the peer directly to our active view,
// we only send the Neighbor message. We will add the peer to our active view once we receive a
// reply from our neighbor.
// This prevents us adding unreachable peers to our active view.
self.send_neighbor(peer_id, Priority::High, io);
} else {
// "ii) If the time to live is equal to PRWL, p will insert the new node into its passive view"
if message.ttl == self.config.passive_random_walk_length {
self.add_passive(peer_id, message.peer.data.clone(), io);
}
// "iii) The time to live field is decremented."
// "iv) If, at this point, n has not been inserted
// in ps active view, p will forward the request to a random node in its active view
// (different from the one from which the request was received)."
if !self.active_view.contains(&peer_id)
&& !self.pending_neighbor_requests.contains(&peer_id)
{
match self
.active_view
.pick_random_without(&[&sender], &mut self.rng)
{
None => {
unreachable!("if the peer was not added, there are at least two peers in our active view.");
}
Some(next) => {
let message = Message::ForwardJoin(ForwardJoin {
peer: message.peer,
ttl: message.ttl.next(),
});
io.push(OutEvent::SendMessage(*next, message));
}
}
}
}
}
fn on_neighbor(&mut self, from: PI, details: Neighbor, io: &mut impl IO<PI>) {
let is_reply = self.pending_neighbor_requests.remove(&from);
let do_reply = !is_reply;
// "A node q that receives a high priority neighbor request will always accept the request, even
// if it has to drop a random member from its active view (again, the member that is dropped will
// receive a Disconnect notification). If a node q receives a low priority Neighbor request, it will
// only accept the request if it has a free slot in its active view, otherwise it will refuse the request."
if !self.add_active(from, details.data, details.priority, do_reply, io) {
self.send_disconnect(from, true, io);
}
}
/// Get the peer [`PeerInfo`] for a peer.
fn peer_info(&self, id: &PI) -> PeerInfo<PI> {
let data = self.peer_data.get(id).cloned();
PeerInfo { id: *id, data }
}
fn insert_peer_info(&mut self, peer_info: PeerInfo<PI>, io: &mut impl IO<PI>) {
if let Some(data) = peer_info.data {
let old = self.peer_data.remove(&peer_info.id);
let same = matches!(old, Some(old) if old == data);
if !same && !data.0.is_empty() {
io.push(OutEvent::PeerData(peer_info.id, data.clone()));
}
self.peer_data.insert(peer_info.id, data);
}
}
/// Handle a [`Message::Shuffle`]
///
/// > A node q that receives a Shuffle request will first decrease its time to live. If the time
/// > to live of the message is greater than zero and the number of nodes in qs active view is
/// > greater than 1, the node will select a random node from its active view, different from the
/// > one he received this shuffle message from, and simply forwards the Shuffle request.
/// > Otherwise, node q accepts the Shuffle request and send back (p.8)
fn on_shuffle(&mut self, from: PI, shuffle: Shuffle<PI>, io: &mut impl IO<PI>) {
if shuffle.ttl.expired() || self.active_view.len() <= 1 {
let len = shuffle.nodes.len();
for node in shuffle.nodes {
self.add_passive(node.id, node.data, io);
}
self.send_shuffle_reply(shuffle.origin, len, io);
} else if let Some(node) = self
.active_view
.pick_random_without(&[&shuffle.origin, &from], &mut self.rng)
{
let message = Message::Shuffle(Shuffle {
origin: shuffle.origin,
nodes: shuffle.nodes,
ttl: shuffle.ttl.next(),
});
io.push(OutEvent::SendMessage(*node, message));
}
}
fn send_shuffle_reply(&mut self, to: PI, len: usize, io: &mut impl IO<PI>) {
let mut nodes = self.passive_view.shuffled_and_capped(len, &mut self.rng);
// If we don't have enough passive nodes for the expected length, we fill with
// active nodes.
if nodes.len() < len {
nodes.extend(
self.active_view
.shuffled_and_capped(len - nodes.len(), &mut self.rng),
);
}
let nodes = nodes.into_iter().map(|id| self.peer_info(&id));
let message = Message::ShuffleReply(ShuffleReply {
nodes: nodes.collect(),
});
io.push(OutEvent::SendMessage(to, message));
}
fn on_shuffle_reply(&mut self, message: ShuffleReply<PI>, io: &mut impl IO<PI>) {
for node in message.nodes {
self.add_passive(node.id, node.data, io);
}
self.refill_active_from_passive(&[], io);
}
fn handle_shuffle_timer(&mut self, io: &mut impl IO<PI>) {
if let Some(node) = self.active_view.pick_random(&mut self.rng) {
let active = self.active_view.shuffled_without_and_capped(
&[node],
self.config.shuffle_active_view_count,
&mut self.rng,
);
let passive = self.passive_view.shuffled_without_and_capped(
&[node],
self.config.shuffle_passive_view_count,
&mut self.rng,
);
let nodes = active
.iter()
.chain(passive.iter())
.map(|id| self.peer_info(id));
let me = PeerInfo {
id: self.me,
data: self.me_data.clone(),
};
let nodes = nodes.chain([me]);
let message = Shuffle {
origin: self.me,
nodes: nodes.collect(),
ttl: self.config.shuffle_random_walk_length,
};
io.push(OutEvent::SendMessage(*node, Message::Shuffle(message)));
}
io.push(OutEvent::ScheduleTimer(
self.config.shuffle_interval,
Timer::DoShuffle,
));
}
fn passive_is_full(&self) -> bool {
self.passive_view.len() >= self.config.passive_view_capacity
}
fn active_is_full(&self) -> bool {
self.active_view.len() >= self.config.active_view_capacity
}
/// Add a peer to the passive view.
///
/// If the passive view is full, it will first remove a random peer and then insert the new peer.
/// If a peer is currently in the active view it will not be added.
fn add_passive(&mut self, peer: PI, data: Option<PeerData>, io: &mut impl IO<PI>) {
self.insert_peer_info((peer, data).into(), io);
if self.active_view.contains(&peer) || self.passive_view.contains(&peer) || peer == self.me
{
return;
}
if self.passive_is_full() {
self.passive_view.remove_random(&mut self.rng);
}
self.passive_view.insert(peer);
}
/// Remove a peer from the active view.
///
/// If `reason` is [`RemovalReason::Random`], a [`Disconnect`] message will be sent to the peer.
fn remove_active(&mut self, peer: &PI, reason: RemovalReason, io: &mut impl IO<PI>) {
if let Some(idx) = self.active_view.get_index_of(peer) {
let removed_peer = self.remove_active_by_index(idx, reason, io).unwrap();
self.refill_active_from_passive(&[&removed_peer], io);
}
}
fn refill_active_from_passive(&mut self, skip_peers: &[&PI], io: &mut impl IO<PI>) {
if self.active_view.len() + self.pending_neighbor_requests.len()
>= self.config.active_view_capacity
{
return;
}
// "When a node p suspects that one of the nodes present in its active view has failed
// (by either disconnecting or blocking), it selects a random node q from its passive view and
// attempts to establish a TCP connection with q. If the connection fails to establish,
// node q is considered failed and removed from ps passive view; another node q is selected
// at random and a new attempt is made. The procedure is repeated until a connection is established
// with success." (p7)
let mut skip_peers = skip_peers.to_vec();
skip_peers.extend(self.pending_neighbor_requests.iter());
if let Some(node) = self
.passive_view
.pick_random_without(&skip_peers, &mut self.rng)
.copied()
{
let priority = match self.active_view.is_empty() {
true => Priority::High,
false => Priority::Low,
};
self.send_neighbor(node, priority, io);
// schedule a timer that checks if the node replied with a neighbor message,
// otherwise try again with another passive node.
io.push(OutEvent::ScheduleTimer(
self.config.neighbor_request_timeout,
Timer::PendingNeighborRequest(node),
));
};
}
fn handle_pending_neighbor_timer(&mut self, peer: PI, io: &mut impl IO<PI>) {
if self.pending_neighbor_requests.remove(&peer) {
self.passive_view.remove(&peer);
self.refill_active_from_passive(&[], io);
}
}
fn remove_active_by_index(
&mut self,
peer_index: usize,
reason: RemovalReason,
io: &mut impl IO<PI>,
) -> Option<PI> {
if let Some(peer) = self.active_view.remove_index(peer_index) {
io.push(OutEvent::EmitEvent(Event::NeighborDown(peer)));
match reason {
// send a disconnect message, then close connection.
RemovalReason::Random => self.send_disconnect(peer, true, io),
// close connection without sending anything further.
RemovalReason::DisconnectReceived { is_alive: _ } => {
io.push(OutEvent::DisconnectPeer(peer))
}
RemovalReason::ConnectionClosed => io.push(OutEvent::DisconnectPeer(peer)),
}
let keep_as_passive = match reason {
// keep alive if previously marked as alive.
RemovalReason::ConnectionClosed => self.alive_disconnect_peers.remove(&peer),
// keep alive if other peer said to be still alive.
RemovalReason::DisconnectReceived { is_alive } => is_alive,
// keep alive (only we are removing for now)
RemovalReason::Random => true,
};
if keep_as_passive {
let data = self.peer_data.remove(&peer);
self.add_passive(peer, data, io);
// mark peer as alive, so it doesn't get removed from the passive view if the conn closes.
if !matches!(reason, RemovalReason::ConnectionClosed) {
self.alive_disconnect_peers.insert(peer);
}
}
debug!(other = ?peer, "removed from active view, reason: {reason:?}");
Some(peer)
} else {
None
}
}
/// Remove a random peer from the active view.
fn free_random_slot_in_active_view(&mut self, io: &mut impl IO<PI>) {
if let Some(index) = self.active_view.pick_random_index(&mut self.rng) {
self.remove_active_by_index(index, RemovalReason::Random, io);
}
}
/// Add a peer to the active view.
///
/// If the active view is currently full, a random peer will be removed first.
/// Sends a Neighbor message to the peer. If high_priority is true, the peer
/// may not deny the Neighbor request.
fn add_active(
&mut self,
peer: PI,
data: Option<PeerData>,
priority: Priority,
reply: bool,
io: &mut impl IO<PI>,
) -> bool {
if peer == self.me {
return false;
}
self.insert_peer_info((peer, data).into(), io);
if self.active_view.contains(&peer) {
if reply {
self.send_neighbor(peer, priority, io);
}
return true;
}
match (priority, self.active_is_full()) {
(Priority::High, is_full) => {
if is_full {
self.free_random_slot_in_active_view(io);
}
self.add_active_unchecked(peer, Priority::High, reply, io);
true
}
(Priority::Low, false) => {
self.add_active_unchecked(peer, Priority::Low, reply, io);
true
}
(Priority::Low, true) => false,
}
}
fn add_active_unchecked(
&mut self,
peer: PI,
priority: Priority,
reply: bool,
io: &mut impl IO<PI>,
) {
self.passive_view.remove(&peer);
if self.active_view.insert(peer) {
debug!(other = ?peer, "add to active view");
io.push(OutEvent::EmitEvent(Event::NeighborUp(peer)));
if reply {
self.send_neighbor(peer, priority, io);
}
}
}
fn send_neighbor(&mut self, peer: PI, priority: Priority, io: &mut impl IO<PI>) {
if self.pending_neighbor_requests.insert(peer) {
let message = Message::Neighbor(Neighbor {
priority,
data: self.me_data.clone(),
});
io.push(OutEvent::SendMessage(peer, message));
}
}
}
#[derive(Debug)]
enum RemovalReason {
/// A peer is removed because the connection was closed ungracefully.
ConnectionClosed,
/// A peer is removed because we received a disconnect message.
DisconnectReceived { is_alive: bool },
/// A peer is removed after random selection to make room for a newly joined peer.
Random,
}

View file

@ -0,0 +1,909 @@
//! Implementation of the Plumtree epidemic broadcast tree protocol
//!
//! The implementation is based on [this paper][paper] by Joao Leitao, Jose Pereira, Luıs Rodrigues
//! and the [example implementation][impl] by Bartosz Sypytkowski
//!
//! [paper]: https://asc.di.fct.unl.pt/~jleitao/pdf/srds07-leitao.pdf
//! [impl]: https://gist.github.com/Horusiath/84fac596101b197da0546d1697580d99
use std::{
collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
hash::Hash,
};
use bytes::Bytes;
use derive_more::{Add, From, Sub};
use n0_future::time::{Duration, Instant};
use postcard::experimental::max_size::MaxSize;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use super::{
util::{idbytes_impls, TimeBoundCache},
PeerIdentity, IO,
};
/// A message identifier, which is the message content's blake3 hash.
#[derive(Serialize, Deserialize, Clone, Hash, Copy, PartialEq, Eq, MaxSize)]
pub struct MessageId([u8; 32]);
idbytes_impls!(MessageId, "MessageId");
impl MessageId {
/// Create a `[MessageId]` by hashing the message content.
///
/// This hashes the input with [`blake3::hash`].
pub fn from_content(message: &[u8]) -> Self {
Self::from(blake3::hash(message))
}
}
/// Events Plumtree is informed of from the peer sampling service and IO layer.
#[derive(Debug)]
pub enum InEvent<PI> {
/// A [`Message`] was received from the peer.
RecvMessage(PI, Message),
/// Broadcast the contained payload to the given scope.
Broadcast(Bytes, Scope),
/// A timer has expired.
TimerExpired(Timer),
/// New member `PI` has joined the topic.
NeighborUp(PI),
/// Peer `PI` has disconnected from the topic.
NeighborDown(PI),
}
/// Events Plumtree emits.
#[derive(Debug, PartialEq, Eq)]
pub enum OutEvent<PI> {
/// Ask the IO layer to send a [`Message`] to peer `PI`.
SendMessage(PI, Message),
/// Schedule a [`Timer`].
ScheduleTimer(Duration, Timer),
/// Emit an [`Event`] to the application.
EmitEvent(Event<PI>),
}
/// Kinds of timers Plumtree needs to schedule.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Timer {
/// Request the content for [`MessageId`] by sending [`Message::Graft`].
///
/// The message will be sent to a peer that sent us an [`Message::IHave`] for this [`MessageId`],
/// which will send us the message content in reply and also move the peer into the eager set.
/// Will be a no-op if the message for [`MessageId`] was already received from another peer by now.
SendGraft(MessageId),
/// Dispatch the [`Message::IHave`] in our lazy push queue.
DispatchLazyPush,
/// Evict the message cache
EvictCache,
}
/// Event emitted by the [`State`] to the application.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Event<PI> {
/// A new gossip message was received.
Received(GossipEvent<PI>),
}
#[derive(Clone, derive_more::Debug, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
pub struct GossipEvent<PI> {
/// The content of the gossip message.
#[debug("<{}b>", content.len())]
pub content: Bytes,
/// The peer that we received the gossip message from. Note that this is not the peer that
/// originally broadcasted the message, but the peer before us in the gossiping path.
pub delivered_from: PI,
/// The broadcast scope of the message.
pub scope: DeliveryScope,
}
impl<PI> GossipEvent<PI> {
fn from_message(message: &Gossip, from: PI) -> Self {
Self {
content: message.content.clone(),
scope: message.scope,
delivered_from: from,
}
}
}
/// Number of delivery hops a message has taken.
#[derive(
From,
Add,
Sub,
Serialize,
Deserialize,
Eq,
PartialEq,
PartialOrd,
Ord,
Clone,
Copy,
Debug,
Hash,
MaxSize,
)]
pub struct Round(u16);
impl Round {
pub fn next(&self) -> Round {
Round(self.0 + 1)
}
}
/// Messages that we can send and receive from peers within the topic.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub enum Message {
/// When receiving Gossip, emit as event and forward full message to eager peer and (after a
/// delay) message IDs to lazy peers.
Gossip(Gossip),
/// When receiving Prune, move the peer from the eager to the lazy set.
Prune,
/// When receiving Graft, move the peer to the eager set and send the full content for the
/// included message ID.
Graft(Graft),
/// When receiving IHave, do nothing initially, and request the messages for the included
/// message IDs after some time if they aren't pushed eagerly to us.
IHave(Vec<IHave>),
}
/// Payload messages transmitted by the protocol.
#[derive(Serialize, Deserialize, Clone, derive_more::Debug, PartialEq, Eq)]
pub struct Gossip {
/// Id of the message.
id: MessageId,
/// Message contents.
#[debug("<{}b>", content.len())]
content: Bytes,
/// Scope to broadcast to.
scope: DeliveryScope,
}
impl Gossip {
fn round(&self) -> Option<Round> {
match self.scope {
DeliveryScope::Swarm(round) => Some(round),
DeliveryScope::Neighbors => None,
}
}
}
/// The scope to deliver the message to.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Copy)]
pub enum DeliveryScope {
/// This message was received from the swarm, with a distance (in hops) travelled from the
/// original broadcaster.
Swarm(Round),
/// This message was received from a direct neighbor that broadcasted the message to neighbors
/// only.
Neighbors,
}
impl DeliveryScope {
/// Whether this message was directly received from its publisher.
pub fn is_direct(&self) -> bool {
matches!(self, Self::Neighbors | Self::Swarm(Round(0)))
}
}
/// The broadcast scope of a gossip message.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Copy)]
pub enum Scope {
/// The message is broadcast to all peers in the swarm.
Swarm,
/// The message is broadcast only to the immediate neighbors of a peer.
Neighbors,
}
impl Gossip {
/// Get a clone of this `Gossip` message and increase the delivery round by 1.
pub fn next_round(&self) -> Option<Gossip> {
match self.scope {
DeliveryScope::Neighbors => None,
DeliveryScope::Swarm(round) => Some(Gossip {
id: self.id,
content: self.content.clone(),
scope: DeliveryScope::Swarm(round.next()),
}),
}
}
/// Validate that the message id is the blake3 hash of the message content.
pub fn validate(&self) -> bool {
let expected = MessageId::from_content(&self.content);
expected == self.id
}
}
/// Control message to inform peers we have a message without transmitting the whole payload.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, MaxSize)]
pub struct IHave {
/// Id of the message.
pub(crate) id: MessageId,
/// Delivery round of the message.
pub(crate) round: Round,
}
/// Control message to signal a peer that they have been moved to the eager set, and to ask the
/// peer to do the same with this node.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct Graft {
/// Message id that triggers the graft, if any.
/// On receiving a graft, the payload message must be sent in reply if a message id is set.
id: Option<MessageId>,
/// Delivery round of the [`Message::IHave`] that triggered this Graft message.
round: Round,
}
/// Configuration for the gossip broadcast layer.
///
/// Currently, the expectation is that the configuration is the same for all peers in the
/// network (as recommended in the paper).
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// When receiving an `IHave` message, this timeout is registered. If the message for the
/// `IHave` was not received once the timeout is expired, a `Graft` message is sent to the
/// peer that sent us the `IHave` to request the message payload.
///
/// The plumtree paper notes:
/// > The timeout value is a protocol parameter that should be configured considering the
/// > diameter of the overlay and a target maximum recovery latency, defined by the application
/// > requirements. (p.8)
pub graft_timeout_1: Duration,
/// This timeout is registered when sending a `Graft` message. If a reply has not been
/// received once the timeout expires, we send another `Graft` message to the next peer that
/// sent us an `IHave` for this message.
///
/// The plumtree paper notes:
/// > This second timeout value should be smaller that the first, in the order of an average
/// > round trip time to a neighbor.
pub graft_timeout_2: Duration,
/// Timeout after which `IHave` messages are pushed to peers.
pub dispatch_timeout: Duration,
/// The protocol performs a tree optimization, which promotes lazy peers to eager peers if the
/// `Message::IHave` messages received from them have a lower number of hops from the
/// message's origin as the `InEvent::Broadcast` messages received from our eager peers. This
/// parameter is the number of hops that the lazy peers must be closer to the origin than our
/// eager peers to be promoted to become an eager peer.
pub optimization_threshold: Round,
/// Duration for which to keep gossip messages in the internal message cache.
///
/// Messages broadcast from this node or received from other nodes are kept in an internal
/// cache for this duration before being evicted. If this is too low, other nodes will not be
/// able to retrieve messages once they need them. If this is high, the cache will grow.
///
/// Should be at least around several round trip times to peers.
pub message_cache_retention: Duration,
/// Duration for which to keep the `MessageId`s for received messages.
///
/// Should be at least as long as [`Self::message_cache_retention`], usually will be longer to
/// not accidentally receive messages multiple times.
pub message_id_retention: Duration,
/// How often the internal caches will be checked for expired items.
pub cache_evict_interval: Duration,
}
impl Default for Config {
/// Sensible defaults for the plumtree configuration
//
// TODO: Find out what good defaults are for the three timeouts here. Current numbers are
// guesses that need validation. The paper does not have concrete recommendations for these
// numbers.
fn default() -> Self {
Self {
// Paper: "The timeout value is a protocol parameter that should be configured considering
// the diameter of the overlay and a target maximum recovery latency, defined by the
// application requirements. This is a parameter that should be statically configured
// at deployment time." (p. 8)
//
// Earthstar has 5ms it seems, see https://github.com/earthstar-project/earthstar/blob/1523c640fedf106f598bf79b184fb0ada64b1cc0/src/syncer/plum_tree.ts#L75
// However in the paper it is more like a few roundtrips if I read things correctly.
graft_timeout_1: Duration::from_millis(80),
// Paper: "This second timeout value should be smaller that the first, in the order of an
// average round trip time to a neighbor." (p. 9)
//
// Earthstar doesn't have this step from my reading.
graft_timeout_2: Duration::from_millis(40),
// Again, paper does not tell a recommended number here. Likely should be quite small,
// as to not delay messages without need. This would also be the time frame in which
// `IHave`s are aggregated to save on packets.
//
// Eartstar dispatches immediately from my reading.
dispatch_timeout: Duration::from_millis(5),
// This number comes from experiment settings the plumtree paper (p. 12)
optimization_threshold: Round(7),
// This is a certainly-high-enough value for usual operation.
message_cache_retention: Duration::from_secs(30),
message_id_retention: Duration::from_secs(90),
cache_evict_interval: Duration::from_secs(1),
}
}
}
/// Stats about this topic's plumtree.
#[derive(Debug, Default, Clone)]
pub struct Stats {
/// Number of payload messages received so far.
///
/// See [`Message::Gossip`].
pub payload_messages_received: u64,
/// Number of control messages received so far.
///
/// See [`Message::Prune`], [`Message::Graft`], [`Message::IHave`].
pub control_messages_received: u64,
/// Max round seen so far.
pub max_last_delivery_hop: u16,
}
/// State of the plumtree.
#[derive(Debug)]
pub struct State<PI> {
/// Our address.
me: PI,
/// Configuration for this plumtree.
config: Config,
/// Set of peers used for payload exchange.
pub(crate) eager_push_peers: BTreeSet<PI>,
/// Set of peers used for control message exchange.
pub(crate) lazy_push_peers: BTreeSet<PI>,
lazy_push_queue: BTreeMap<PI, Vec<IHave>>,
/// Messages for which a [`MessageId`] has been seen via a [`Message::IHave`] but we have not
/// yet received the full payload. For each, we store the peers that have claimed to have this
/// message.
missing_messages: HashMap<MessageId, VecDeque<(PI, Round)>>,
/// Messages for which the full payload has been seen.
received_messages: TimeBoundCache<MessageId, ()>,
/// Payloads of received messages.
cache: TimeBoundCache<MessageId, Gossip>,
/// Message ids for which a [`Timer::SendGraft`] has been scheduled.
graft_timer_scheduled: HashSet<MessageId>,
/// Whether a [`Timer::DispatchLazyPush`] has been scheduled.
dispatch_timer_scheduled: bool,
/// Set to false after the first message is received. Used for initial timer scheduling.
init: bool,
/// [`Stats`] of this plumtree.
pub(crate) stats: Stats,
max_message_size: usize,
}
impl<PI: PeerIdentity> State<PI> {
/// Initialize the [`State`] of a plumtree.
pub fn new(me: PI, config: Config, max_message_size: usize) -> Self {
Self {
me,
eager_push_peers: Default::default(),
lazy_push_peers: Default::default(),
lazy_push_queue: Default::default(),
config,
missing_messages: Default::default(),
received_messages: Default::default(),
graft_timer_scheduled: Default::default(),
dispatch_timer_scheduled: false,
cache: Default::default(),
init: false,
stats: Default::default(),
max_message_size,
}
}
/// Handle an [`InEvent`].
pub fn handle(&mut self, event: InEvent<PI>, now: Instant, io: &mut impl IO<PI>) {
if !self.init {
self.init = true;
self.on_evict_cache_timer(now, io)
}
match event {
InEvent::RecvMessage(from, message) => self.handle_message(from, message, now, io),
InEvent::Broadcast(data, scope) => self.broadcast(data, scope, now, io),
InEvent::NeighborUp(peer) => self.on_neighbor_up(peer),
InEvent::NeighborDown(peer) => self.on_neighbor_down(peer),
InEvent::TimerExpired(timer) => match timer {
Timer::DispatchLazyPush => self.on_dispatch_timer(io),
Timer::SendGraft(id) => {
self.on_send_graft_timer(id, io);
}
Timer::EvictCache => self.on_evict_cache_timer(now, io),
},
}
}
/// Get access to the [`Stats`] of the plumtree.
pub fn stats(&self) -> &Stats {
&self.stats
}
/// Handle receiving a [`Message`].
fn handle_message(&mut self, sender: PI, message: Message, now: Instant, io: &mut impl IO<PI>) {
if matches!(message, Message::Gossip(_)) {
self.stats.payload_messages_received += 1;
} else {
self.stats.control_messages_received += 1;
}
match message {
Message::Gossip(details) => self.on_gossip(sender, details, now, io),
Message::Prune => self.on_prune(sender),
Message::IHave(details) => self.on_ihave(sender, details, io),
Message::Graft(details) => self.on_graft(sender, details, io),
}
}
/// Dispatches messages from lazy queue over to lazy peers.
fn on_dispatch_timer(&mut self, io: &mut impl IO<PI>) {
let chunk_size = self.max_message_size
// Space for discriminator
- 1
// Space for length prefix
- 2;
let chunk_len = chunk_size / IHave::POSTCARD_MAX_SIZE;
while let Some((peer, list)) = self.lazy_push_queue.pop_first() {
for chunk in list.chunks(chunk_len) {
io.push(OutEvent::SendMessage(peer, Message::IHave(chunk.to_vec())));
}
}
self.dispatch_timer_scheduled = false;
}
/// Send a gossip message.
///
/// Will be pushed in full to eager peers.
/// Pushing the message id to the lazy peers is delayed by a timer.
fn broadcast(&mut self, content: Bytes, scope: Scope, now: Instant, io: &mut impl IO<PI>) {
let id = MessageId::from_content(&content);
let scope = match scope {
Scope::Neighbors => DeliveryScope::Neighbors,
Scope::Swarm => DeliveryScope::Swarm(Round(0)),
};
let message = Gossip { id, content, scope };
let me = self.me;
if let DeliveryScope::Swarm(_) = scope {
self.received_messages
.insert(id, (), now + self.config.message_id_retention);
self.cache.insert(
id,
message.clone(),
now + self.config.message_cache_retention,
);
self.lazy_push(message.clone(), &me, io);
}
self.eager_push(message.clone(), &me, io);
}
/// Handle receiving a [`Message::Gossip`].
fn on_gossip(&mut self, sender: PI, message: Gossip, now: Instant, io: &mut impl IO<PI>) {
// Validate that the message id is the blake3 hash of the message content.
if !message.validate() {
// TODO: Do we want to take any measures against the sender if we received a message
// with a spoofed message id?
warn!(
peer = ?sender,
"Received a message with spoofed message id ({})", message.id
);
return;
}
// if we already received this message: move peer to lazy set
// and notify peer about this.
if self.received_messages.contains_key(&message.id) {
self.add_lazy(sender);
io.push(OutEvent::SendMessage(sender, Message::Prune));
// otherwise store the message, emit to application and forward to peers
} else {
if let DeliveryScope::Swarm(prev_round) = message.scope {
// insert the message in the list of received messages
self.received_messages.insert(
message.id,
(),
now + self.config.message_id_retention,
);
// increase the round for forwarding the message, and add to cache
// to reply to Graft messages later
// TODO: add callback/event to application to get missing messages that were received before?
let message = message.next_round().expect("just checked");
self.cache.insert(
message.id,
message.clone(),
now + self.config.message_cache_retention,
);
// push the message to our peers
self.eager_push(message.clone(), &sender, io);
self.lazy_push(message.clone(), &sender, io);
// cleanup places where we track missing messages
self.graft_timer_scheduled.remove(&message.id);
let previous_ihaves = self.missing_messages.remove(&message.id);
// do the optimization step from the paper
if let Some(previous_ihaves) = previous_ihaves {
self.optimize_tree(&sender, &message, previous_ihaves, io);
}
self.stats.max_last_delivery_hop =
self.stats.max_last_delivery_hop.max(prev_round.0);
}
// emit event to application
io.push(OutEvent::EmitEvent(Event::Received(
GossipEvent::from_message(&message, sender),
)));
}
}
/// Optimize the tree by pruning the `sender` of a [`Message::Gossip`] if we previously
/// received a [`Message::IHave`] for the same message with a much lower number of delivery
/// hops from the original broadcaster of the message.
///
/// See [Config::optimization_threshold].
fn optimize_tree(
&mut self,
gossip_sender: &PI,
message: &Gossip,
previous_ihaves: VecDeque<(PI, Round)>,
io: &mut impl IO<PI>,
) {
let round = message.round().expect("only called for swarm messages");
let best_ihave = previous_ihaves
.iter()
.min_by(|(_a_peer, a_round), (_b_peer, b_round)| a_round.cmp(b_round))
.copied();
if let Some((ihave_peer, ihave_round)) = best_ihave {
if (ihave_round < round) && (round - ihave_round) >= self.config.optimization_threshold
{
// Graft the sender of the IHave, but only if it's not already eager.
if !self.eager_push_peers.contains(&ihave_peer) {
let message = Message::Graft(Graft {
id: None,
round: ihave_round,
});
self.add_eager(ihave_peer);
io.push(OutEvent::SendMessage(ihave_peer, message));
}
// Prune the sender of the Gossip.
self.add_lazy(*gossip_sender);
io.push(OutEvent::SendMessage(*gossip_sender, Message::Prune));
}
}
}
/// Handle receiving a [`Message::Prune`].
fn on_prune(&mut self, sender: PI) {
self.add_lazy(sender);
}
/// Handle receiving a [`Message::IHave`].
///
/// > When a node receives a IHAVE message, it simply marks the corresponding message as
/// > missing It then starts a timer, with a predefined timeout value, and waits for the missing
/// > message to be received via eager push before the timer expires. The timeout value is a
/// > protocol parameter that should be configured considering the diameter of the overlay and a
/// > target maximum recovery latency, defined by the application requirements. This is a
/// > parameter that should be statically configured at deployment time. (p8)
fn on_ihave(&mut self, sender: PI, ihaves: Vec<IHave>, io: &mut impl IO<PI>) {
for ihave in ihaves {
if !self.received_messages.contains_key(&ihave.id) {
self.missing_messages
.entry(ihave.id)
.or_default()
.push_back((sender, ihave.round));
if !self.graft_timer_scheduled.contains(&ihave.id) {
self.graft_timer_scheduled.insert(ihave.id);
io.push(OutEvent::ScheduleTimer(
self.config.graft_timeout_1,
Timer::SendGraft(ihave.id),
));
}
}
}
}
/// A scheduled [`Timer::SendGraft`] has reached it's deadline.
fn on_send_graft_timer(&mut self, id: MessageId, io: &mut impl IO<PI>) {
self.graft_timer_scheduled.remove(&id);
// if the message was received before the timer ran out, there is no need to request it
// again
if self.received_messages.contains_key(&id) {
return;
}
// get the first peer that advertised this message
let entry = self
.missing_messages
.get_mut(&id)
.and_then(|entries| entries.pop_front());
if let Some((peer, round)) = entry {
self.add_eager(peer);
let message = Message::Graft(Graft {
id: Some(id),
round,
});
io.push(OutEvent::SendMessage(peer, message));
// "when a GRAFT message is sent, another timer is started to expire after a certain timeout,
// to ensure that the message will be requested to another neighbor if it is not received
// meanwhile. This second timeout value should be smaller that the first, in the order of
// an average round trip time to a neighbor." (p9)
io.push(OutEvent::ScheduleTimer(
self.config.graft_timeout_2,
Timer::SendGraft(id),
));
}
}
/// Handle receiving a [`Message::Graft`].
fn on_graft(&mut self, sender: PI, details: Graft, io: &mut impl IO<PI>) {
self.add_eager(sender);
if let Some(id) = details.id {
if let Some(message) = self.cache.get(&id) {
io.push(OutEvent::SendMessage(
sender,
Message::Gossip(message.clone()),
));
} else {
debug!(?id, peer=?sender, "on_graft failed to graft: message not in cache");
}
}
}
/// Handle a [`InEvent::NeighborUp`] when a peer joins the topic.
fn on_neighbor_up(&mut self, peer: PI) {
self.add_eager(peer);
}
/// Handle a [`InEvent::NeighborDown`] when a peer leaves the topic.
/// > When a neighbor is detected to leave the overlay, it is simple removed from the
/// > membership. Furthermore, the record of IHAVE messages sent from failed members is deleted
/// > from the missing history. (p9)
fn on_neighbor_down(&mut self, peer: PI) {
self.missing_messages.retain(|_message_id, ihaves| {
ihaves.retain(|(ihave_peer, _round)| *ihave_peer != peer);
!ihaves.is_empty()
});
self.eager_push_peers.remove(&peer);
self.lazy_push_peers.remove(&peer);
}
fn on_evict_cache_timer(&mut self, now: Instant, io: &mut impl IO<PI>) {
self.cache.expire_until(now);
io.push(OutEvent::ScheduleTimer(
self.config.cache_evict_interval,
Timer::EvictCache,
));
}
/// Moves peer into eager set.
fn add_eager(&mut self, peer: PI) {
self.lazy_push_peers.remove(&peer);
self.eager_push_peers.insert(peer);
}
/// Moves peer into lazy set.
fn add_lazy(&mut self, peer: PI) {
self.eager_push_peers.remove(&peer);
self.lazy_push_peers.insert(peer);
}
/// Immediately sends message to eager peers.
fn eager_push(&mut self, gossip: Gossip, sender: &PI, io: &mut impl IO<PI>) {
for peer in self
.eager_push_peers
.iter()
.filter(|peer| **peer != self.me && *peer != sender)
{
io.push(OutEvent::SendMessage(
*peer,
Message::Gossip(gossip.clone()),
));
}
}
/// Queue lazy message announcements into the queue that will be sent out as batched
/// [`Message::IHave`] messages once the [`Timer::DispatchLazyPush`] timer is triggered.
fn lazy_push(&mut self, gossip: Gossip, sender: &PI, io: &mut impl IO<PI>) {
let Some(round) = gossip.round() else {
return;
};
for peer in self.lazy_push_peers.iter().filter(|x| *x != sender) {
self.lazy_push_queue.entry(*peer).or_default().push(IHave {
id: gossip.id,
round,
});
}
if !self.dispatch_timer_scheduled {
io.push(OutEvent::ScheduleTimer(
self.config.dispatch_timeout,
Timer::DispatchLazyPush,
));
self.dispatch_timer_scheduled = true;
}
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn optimize_tree() {
let mut io = VecDeque::new();
let config: Config = Default::default();
let mut state = State::new(1, config.clone(), 1024);
let now = Instant::now();
// we receive an IHave message from peer 2
// it has `round: 2` which means that the the peer that sent us the IHave was
// two hops away from the original sender of the message
let content: Bytes = b"hi".to_vec().into();
let id = MessageId::from_content(&content);
let event = InEvent::RecvMessage(
2u32,
Message::IHave(vec![IHave {
id,
round: Round(2),
}]),
);
state.handle(event, now, &mut io);
io.clear();
// we then receive a `Gossip` message with the same `MessageId` from peer 3
// the message has `round: 6`, which means it travelled 6 hops until it reached us
// this is less hops than to peer 2, but not enough to trigger the optimization
// because we use the default config which has `optimization_threshold: 7`
let event = InEvent::RecvMessage(
3,
Message::Gossip(Gossip {
id,
content: content.clone(),
scope: DeliveryScope::Swarm(Round(6)),
}),
);
state.handle(event, now, &mut io);
let expected = {
// we expect a dispatch timer schedule and receive event, but no Graft or Prune
// messages
let mut io = VecDeque::new();
io.push(OutEvent::ScheduleTimer(
config.dispatch_timeout,
Timer::DispatchLazyPush,
));
io.push(OutEvent::EmitEvent(Event::Received(GossipEvent {
content,
delivered_from: 3,
scope: DeliveryScope::Swarm(Round(6)),
})));
io
};
assert_eq!(io, expected);
io.clear();
// now we run the same flow again but this time peer 3 is 9 hops away from the message's
// sender. message's sender. this will trigger the optimization:
// peer 2 will be promoted to eager and peer 4 demoted to lazy
let content: Bytes = b"hi2".to_vec().into();
let id = MessageId::from_content(&content);
let event = InEvent::RecvMessage(
2u32,
Message::IHave(vec![IHave {
id,
round: Round(2),
}]),
);
state.handle(event, now, &mut io);
io.clear();
let event = InEvent::RecvMessage(
3,
Message::Gossip(Gossip {
id,
content: content.clone(),
scope: DeliveryScope::Swarm(Round(9)),
}),
);
state.handle(event, now, &mut io);
let expected = {
// this time we expect the Graft and Prune messages to be sent, performing the
// optimization step
let mut io = VecDeque::new();
io.push(OutEvent::SendMessage(
2,
Message::Graft(Graft {
id: None,
round: Round(2),
}),
));
io.push(OutEvent::SendMessage(3, Message::Prune));
io.push(OutEvent::EmitEvent(Event::Received(GossipEvent {
content,
delivered_from: 3,
scope: DeliveryScope::Swarm(Round(9)),
})));
io
};
assert_eq!(io, expected);
}
#[test]
fn spoofed_messages_are_ignored() {
let config: Config = Default::default();
let mut state = State::new(1, config.clone(), 1024);
let now = Instant::now();
// we recv a correct gossip message and expect the Received event to be emitted
let content: Bytes = b"hello1".to_vec().into();
let message = Message::Gossip(Gossip {
content: content.clone(),
id: MessageId::from_content(&content),
scope: DeliveryScope::Swarm(Round(1)),
});
let mut io = VecDeque::new();
state.handle(InEvent::RecvMessage(2, message), now, &mut io);
let expected = {
let mut io = VecDeque::new();
io.push(OutEvent::ScheduleTimer(
config.cache_evict_interval,
Timer::EvictCache,
));
io.push(OutEvent::ScheduleTimer(
config.dispatch_timeout,
Timer::DispatchLazyPush,
));
io.push(OutEvent::EmitEvent(Event::Received(GossipEvent {
content,
delivered_from: 2,
scope: DeliveryScope::Swarm(Round(1)),
})));
io
};
assert_eq!(io, expected);
// now we recv with a spoofed id and expect no event to be emitted
let content: Bytes = b"hello2".to_vec().into();
let message = Message::Gossip(Gossip {
content,
id: MessageId::from_content(b"foo"),
scope: DeliveryScope::Swarm(Round(1)),
});
let mut io = VecDeque::new();
state.handle(InEvent::RecvMessage(2, message), now, &mut io);
let expected = VecDeque::new();
assert_eq!(io, expected);
}
#[test]
fn cache_is_evicted() {
let config: Config = Default::default();
let mut state = State::new(1, config.clone(), 1024);
let now = Instant::now();
let content: Bytes = b"hello1".to_vec().into();
let message = Message::Gossip(Gossip {
content: content.clone(),
id: MessageId::from_content(&content),
scope: DeliveryScope::Swarm(Round(1)),
});
let mut io = VecDeque::new();
state.handle(InEvent::RecvMessage(2, message), now, &mut io);
assert_eq!(state.cache.len(), 1);
let now = now + Duration::from_secs(1);
state.handle(InEvent::TimerExpired(Timer::EvictCache), now, &mut io);
assert_eq!(state.cache.len(), 1);
let now = now + config.message_cache_retention;
state.handle(InEvent::TimerExpired(Timer::EvictCache), now, &mut io);
assert_eq!(state.cache.len(), 0);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,381 @@
//! The protocol state of the `iroh-gossip` protocol.
use std::collections::{hash_map, HashMap, HashSet};
use n0_future::time::{Duration, Instant};
use rand::Rng;
use serde::{Deserialize, Serialize};
use tracing::trace;
use crate::{
metrics::Metrics,
proto::{
topic::{self, Command},
util::idbytes_impls,
Config, PeerData, PeerIdentity, MIN_MAX_MESSAGE_SIZE,
},
};
/// The identifier for a topic
#[derive(Clone, Copy, Eq, PartialEq, Hash, Serialize, Ord, PartialOrd, Deserialize)]
pub struct TopicId([u8; 32]);
idbytes_impls!(TopicId, "TopicId");
impl TopicId {
/// Convert to a hex string limited to the first 5 bytes for a friendly string
/// representation of the key.
pub fn fmt_short(&self) -> String {
data_encoding::HEXLOWER.encode(&self.as_bytes()[..5])
}
}
/// Protocol wire message
///
/// This is the wire frame of the `iroh-gossip` protocol.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Message<PI> {
pub(crate) topic: TopicId,
pub(crate) message: topic::Message<PI>,
}
impl<PI> Message<PI> {
/// Get the kind of this message
pub fn kind(&self) -> MessageKind {
self.message.kind()
}
}
impl<PI: Serialize> Message<PI> {
pub(crate) fn postcard_header_size() -> usize {
// We create a message that has no payload (gossip::Message::Prune), calculate the encoded size,
// and subtract 1 for the discriminator of the inner gossip::Message enum.
let m = Self {
topic: TopicId(Default::default()),
message: topic::Message::<PI>::Gossip(super::plumtree::Message::Prune),
};
postcard::experimental::serialized_size(&m).unwrap() - 1
}
}
/// Whether this is a control or data message
#[derive(Debug)]
pub enum MessageKind {
/// A data message.
Data,
/// A control message.
Control,
}
impl<PI: Serialize> Message<PI> {
/// Get the encoded size of this message
pub fn size(&self) -> postcard::Result<usize> {
postcard::experimental::serialized_size(&self)
}
}
/// A timer to be registered into the runtime
///
/// As the implementation of the protocol is an IO-less state machine, registering timers does not
/// happen within the protocol implementation. Instead, these `Timer` structs are emitted as
/// [`OutEvent`]s. The implementer must register the timer in its runtime to be emitted on the specified [`Instant`],
/// and once triggered inject an [`InEvent::TimerExpired`] into the protocol state.
#[derive(Clone, Debug)]
pub struct Timer<PI> {
topic: TopicId,
timer: topic::Timer<PI>,
}
/// Input event to the protocol state.
#[derive(Clone, Debug)]
pub enum InEvent<PI> {
/// Message received from the network.
RecvMessage(PI, Message<PI>),
/// Execute a command from the application.
Command(TopicId, Command<PI>),
/// Trigger a previously scheduled timer.
TimerExpired(Timer<PI>),
/// Peer disconnected on the network level.
PeerDisconnected(PI),
/// Update the opaque peer data about yourself.
UpdatePeerData(PeerData),
}
/// Output event from the protocol state.
#[derive(Debug, Clone)]
pub enum OutEvent<PI> {
/// Send a message on the network
SendMessage(PI, Message<PI>),
/// Emit an event to the application.
EmitEvent(TopicId, topic::Event<PI>),
/// Schedule a timer. The runtime is responsible for sending an [InEvent::TimerExpired]
/// after the duration.
ScheduleTimer(Duration, Timer<PI>),
/// Close the connection to a peer on the network level.
DisconnectPeer(PI),
/// Updated peer data
PeerData(PI, PeerData),
}
type ConnsMap<PI> = HashMap<PI, HashSet<TopicId>>;
type Outbox<PI> = Vec<OutEvent<PI>>;
enum InEventMapped<PI> {
All(topic::InEvent<PI>),
TopicEvent(TopicId, topic::InEvent<PI>),
}
impl<PI> From<InEvent<PI>> for InEventMapped<PI> {
fn from(event: InEvent<PI>) -> InEventMapped<PI> {
match event {
InEvent::RecvMessage(from, Message { topic, message }) => {
Self::TopicEvent(topic, topic::InEvent::RecvMessage(from, message))
}
InEvent::Command(topic, command) => {
Self::TopicEvent(topic, topic::InEvent::Command(command))
}
InEvent::TimerExpired(Timer { topic, timer }) => {
Self::TopicEvent(topic, topic::InEvent::TimerExpired(timer))
}
InEvent::PeerDisconnected(peer) => Self::All(topic::InEvent::PeerDisconnected(peer)),
InEvent::UpdatePeerData(data) => Self::All(topic::InEvent::UpdatePeerData(data)),
}
}
}
/// The state of the `iroh-gossip` protocol.
///
/// The implementation works as an IO-less state machine. The implementer injects events through
/// [`Self::handle`], which returns an iterator of [`OutEvent`]s to be processed.
///
/// This struct contains a map of [`topic::State`] for each topic that was joined. It mostly acts as
/// a forwarder of [`InEvent`]s to matching topic state. Each topic's state is completely
/// independent; thus the actual protocol logic lives with [`topic::State`].
#[derive(Debug)]
pub struct State<PI, R> {
me: PI,
me_data: PeerData,
config: Config,
rng: R,
states: HashMap<TopicId, topic::State<PI, R>>,
outbox: Outbox<PI>,
peer_topics: ConnsMap<PI>,
}
impl<PI: PeerIdentity, R: Rng + Clone> State<PI, R> {
/// Create a new protocol state instance.
///
/// `me` is the [`PeerIdentity`] of the local node, `peer_data` is the initial [`PeerData`]
/// (which can be updated over time).
/// For the protocol to perform as recommended in the papers, the [`Config`] should be
/// identical for all nodes in the network.
///
/// ## Panics
///
/// Panics if [`Config::max_message_size`] is below [`MIN_MAX_MESSAGE_SIZE`].
pub fn new(me: PI, me_data: PeerData, config: Config, rng: R) -> Self {
assert!(
config.max_message_size >= MIN_MAX_MESSAGE_SIZE,
"max_message_size must be at least {MIN_MAX_MESSAGE_SIZE}"
);
Self {
me,
me_data,
config,
rng,
states: Default::default(),
outbox: Default::default(),
peer_topics: Default::default(),
}
}
/// Get a reference to the node's [`PeerIdentity`]
pub fn me(&self) -> &PI {
&self.me
}
/// Get a reference to the protocol state for a topic.
pub fn state(&self, topic: &TopicId) -> Option<&topic::State<PI, R>> {
self.states.get(topic)
}
/// Resets the tracked stats for a topic.
pub fn reset_stats(&mut self, topic: &TopicId) {
if let Some(state) = self.states.get_mut(topic) {
state.reset_stats();
}
}
/// Get an iterator of all joined topics.
pub fn topics(&self) -> impl Iterator<Item = &TopicId> {
self.states.keys()
}
/// Get an iterator for the states of all joined topics.
pub fn states(&self) -> impl Iterator<Item = (&TopicId, &topic::State<PI, R>)> {
self.states.iter()
}
/// Check if a topic has any active (connected) peers.
pub fn has_active_peers(&self, topic: &TopicId) -> bool {
self.state(topic)
.map(|s| s.has_active_peers())
.unwrap_or(false)
}
/// Returns the maximum message size configured in the gossip protocol.
pub fn max_message_size(&self) -> usize {
self.config.max_message_size
}
/// Handle an [`InEvent`]
///
/// This returns an iterator of [`OutEvent`]s that must be processed.
pub fn handle(
&mut self,
event: InEvent<PI>,
now: Instant,
metrics: Option<&Metrics>,
) -> impl Iterator<Item = OutEvent<PI>> + '_ + use<'_, PI, R> {
trace!("in : {event:?}");
if let Some(metrics) = &metrics {
track_in_event(&event, metrics);
}
let event: InEventMapped<PI> = event.into();
match event {
InEventMapped::TopicEvent(topic, event) => {
// when receiving a join command, initialize state if it doesn't exist
if matches!(&event, topic::InEvent::Command(Command::Join(_peers))) {
if let hash_map::Entry::Vacant(e) = self.states.entry(topic) {
e.insert(topic::State::with_rng(
self.me,
Some(self.me_data.clone()),
self.config.clone(),
self.rng.clone(),
));
}
}
// when receiving a quit command, note this and drop the topic state after
// processing this last event
let quit = matches!(event, topic::InEvent::Command(Command::Quit));
// pass the event to the state handler
if let Some(state) = self.states.get_mut(&topic) {
// when receiving messages, update our conn map to take note that this topic state may want
// to keep this connection
if let topic::InEvent::RecvMessage(from, _message) = &event {
self.peer_topics.entry(*from).or_default().insert(topic);
}
let out = state.handle(event, now);
for event in out {
handle_out_event(topic, event, &mut self.peer_topics, &mut self.outbox);
}
}
if quit {
self.states.remove(&topic);
}
}
// when a peer disconnected on the network level, forward event to all states
InEventMapped::All(event) => {
if let topic::InEvent::UpdatePeerData(data) = &event {
self.me_data = data.clone();
}
for (topic, state) in self.states.iter_mut() {
let out = state.handle(event.clone(), now);
for event in out {
handle_out_event(*topic, event, &mut self.peer_topics, &mut self.outbox);
}
}
}
}
// track metrics
if let Some(metrics) = &metrics {
track_out_events(&self.outbox, metrics);
}
self.outbox.drain(..)
}
}
fn handle_out_event<PI: PeerIdentity>(
topic: TopicId,
event: topic::OutEvent<PI>,
conns: &mut ConnsMap<PI>,
outbox: &mut Outbox<PI>,
) {
trace!("out: {event:?}");
match event {
topic::OutEvent::SendMessage(to, message) => {
outbox.push(OutEvent::SendMessage(to, Message { topic, message }))
}
topic::OutEvent::EmitEvent(event) => outbox.push(OutEvent::EmitEvent(topic, event)),
topic::OutEvent::ScheduleTimer(delay, timer) => {
outbox.push(OutEvent::ScheduleTimer(delay, Timer { topic, timer }))
}
topic::OutEvent::DisconnectPeer(peer) => {
let empty = conns
.get_mut(&peer)
.map(|list| list.remove(&topic) || list.is_empty())
.unwrap_or(false);
if empty {
conns.remove(&peer);
outbox.push(OutEvent::DisconnectPeer(peer));
}
}
topic::OutEvent::PeerData(peer, data) => outbox.push(OutEvent::PeerData(peer, data)),
}
}
fn track_out_events<PI: Serialize>(events: &[OutEvent<PI>], metrics: &Metrics) {
for event in events {
match event {
OutEvent::SendMessage(_to, message) => match message.kind() {
MessageKind::Data => {
metrics.msgs_data_sent.inc();
metrics
.msgs_data_sent_size
.inc_by(message.size().unwrap_or(0) as u64);
}
MessageKind::Control => {
metrics.msgs_ctrl_sent.inc();
metrics
.msgs_ctrl_sent_size
.inc_by(message.size().unwrap_or(0) as u64);
}
},
OutEvent::EmitEvent(_topic, event) => match event {
super::Event::NeighborUp(_peer) => {
metrics.neighbor_up.inc();
}
super::Event::NeighborDown(_peer) => {
metrics.neighbor_down.inc();
}
_ => {}
},
_ => {}
}
}
}
fn track_in_event<PI: Serialize>(event: &InEvent<PI>, metrics: &Metrics) {
if let InEvent::RecvMessage(_from, message) = event {
match message.kind() {
MessageKind::Data => {
metrics.msgs_data_recv.inc();
metrics
.msgs_data_recv_size
.inc_by(message.size().unwrap_or(0) as u64);
}
MessageKind::Control => {
metrics.msgs_ctrl_recv.inc();
metrics
.msgs_ctrl_recv_size
.inc_by(message.size().unwrap_or(0) as u64);
}
}
}
}

View file

@ -0,0 +1,363 @@
//! This module contains the implementation of the gossiping protocol for an individual topic
use std::collections::VecDeque;
use bytes::Bytes;
use derive_more::From;
use n0_future::time::{Duration, Instant};
use rand::Rng;
use serde::{Deserialize, Serialize};
use super::{
hyparview::{self, InEvent as SwarmIn},
plumtree::{self, GossipEvent, InEvent as GossipIn, Scope},
state::MessageKind,
PeerData, PeerIdentity, DEFAULT_MAX_MESSAGE_SIZE,
};
use crate::proto::MIN_MAX_MESSAGE_SIZE;
/// Input event to the topic state handler.
#[derive(Clone, Debug)]
pub enum InEvent<PI> {
/// Message received from the network.
RecvMessage(PI, Message<PI>),
/// Execute a command from the application.
Command(Command<PI>),
/// Trigger a previously scheduled timer.
TimerExpired(Timer<PI>),
/// Peer disconnected on the network level.
PeerDisconnected(PI),
/// Update the opaque peer data about yourself.
UpdatePeerData(PeerData),
}
/// An output event from the state handler.
#[derive(Debug, PartialEq, Eq)]
pub enum OutEvent<PI> {
/// Send a message on the network
SendMessage(PI, Message<PI>),
/// Emit an event to the application.
EmitEvent(Event<PI>),
/// Schedule a timer. The runtime is responsible for sending an [InEvent::TimerExpired]
/// after the duration.
ScheduleTimer(Duration, Timer<PI>),
/// Close the connection to a peer on the network level.
DisconnectPeer(PI),
/// Emitted when new [`PeerData`] was received for a peer.
PeerData(PI, PeerData),
}
impl<PI> From<hyparview::OutEvent<PI>> for OutEvent<PI> {
fn from(event: hyparview::OutEvent<PI>) -> Self {
use hyparview::OutEvent::*;
match event {
SendMessage(to, message) => Self::SendMessage(to, message.into()),
ScheduleTimer(delay, timer) => Self::ScheduleTimer(delay, timer.into()),
DisconnectPeer(peer) => Self::DisconnectPeer(peer),
EmitEvent(event) => Self::EmitEvent(event.into()),
PeerData(peer, data) => Self::PeerData(peer, data),
}
}
}
impl<PI> From<plumtree::OutEvent<PI>> for OutEvent<PI> {
fn from(event: plumtree::OutEvent<PI>) -> Self {
use plumtree::OutEvent::*;
match event {
SendMessage(to, message) => Self::SendMessage(to, message.into()),
ScheduleTimer(delay, timer) => Self::ScheduleTimer(delay, timer.into()),
EmitEvent(event) => Self::EmitEvent(event.into()),
}
}
}
/// A trait for a concrete type to push `OutEvent`s to.
///
/// The implementation is generic over this trait, which allows the upper layer to supply a
/// container of their choice for `OutEvent`s emitted from the protocol state.
pub trait IO<PI: Clone> {
/// Push an event in the IO container
fn push(&mut self, event: impl Into<OutEvent<PI>>);
/// Push all events from an iterator into the IO container
fn push_from_iter(&mut self, iter: impl IntoIterator<Item = impl Into<OutEvent<PI>>>) {
for event in iter.into_iter() {
self.push(event);
}
}
}
/// A protocol message for a particular topic
#[derive(From, Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub enum Message<PI> {
/// A message of the swarm membership layer
Swarm(hyparview::Message<PI>),
/// A message of the gossip broadcast layer
Gossip(plumtree::Message),
}
impl<PI> Message<PI> {
/// Get the kind of this message
pub fn kind(&self) -> MessageKind {
match self {
Message::Swarm(_) => MessageKind::Control,
Message::Gossip(message) => match message {
plumtree::Message::Gossip(_) => MessageKind::Data,
_ => MessageKind::Control,
},
}
}
/// Returns `true` if this is a disconnect message (which is the last message sent to a peer per topic).
pub fn is_disconnect(&self) -> bool {
matches!(self, Message::Swarm(hyparview::Message::Disconnect(_)))
}
}
/// An event to be emitted to the application for a particular topic.
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize, Deserialize)]
pub enum Event<PI> {
/// We have a new, direct neighbor in the swarm membership layer for this topic
NeighborUp(PI),
/// We dropped direct neighbor in the swarm membership layer for this topic
NeighborDown(PI),
/// A gossip message was received for this topic
Received(GossipEvent<PI>),
}
impl<PI> From<hyparview::Event<PI>> for Event<PI> {
fn from(value: hyparview::Event<PI>) -> Self {
match value {
hyparview::Event::NeighborUp(peer) => Self::NeighborUp(peer),
hyparview::Event::NeighborDown(peer) => Self::NeighborDown(peer),
}
}
}
impl<PI> From<plumtree::Event<PI>> for Event<PI> {
fn from(value: plumtree::Event<PI>) -> Self {
match value {
plumtree::Event::Received(event) => Self::Received(event),
}
}
}
/// A timer to be registered for a particular topic.
///
/// This should be treated as an opaque value by the implementer and, once emitted, simply returned
/// to the protocol through [`InEvent::TimerExpired`].
#[derive(Clone, From, Debug, PartialEq, Eq)]
pub enum Timer<PI> {
/// A timer for the swarm layer
Swarm(hyparview::Timer<PI>),
/// A timer for the gossip layer
Gossip(plumtree::Timer),
}
/// A command to the protocol state for a particular topic.
#[derive(Clone, derive_more::Debug)]
pub enum Command<PI> {
/// Join this topic and connect to peers.
///
/// If the list of peers is empty, will prepare the state and accept incoming join requests,
/// but only become operational after the first join request by another peer.
Join(Vec<PI>),
/// Broadcast a message for this topic.
Broadcast(#[debug("<{}b>", _0.len())] Bytes, Scope),
/// Leave this topic and drop all state.
Quit,
}
impl<PI: Clone> IO<PI> for VecDeque<OutEvent<PI>> {
fn push(&mut self, event: impl Into<OutEvent<PI>>) {
self.push_back(event.into())
}
}
/// Protocol configuration
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// Configuration for the swarm membership layer
pub membership: hyparview::Config,
/// Configuration for the gossip broadcast layer
pub broadcast: plumtree::Config,
/// Max message size in bytes.
///
/// This size should be the same across a network to ensure all nodes can transmit and read large messages.
///
/// At minimum, this size should be large enough to send gossip control messages. This can vary, depending on the size of the [`PeerIdentity`] you use and the size of the [`PeerData`] you transmit in your messages.
///
/// The default is [`DEFAULT_MAX_MESSAGE_SIZE`].
pub max_message_size: usize,
}
impl Default for Config {
fn default() -> Self {
Self {
membership: Default::default(),
broadcast: Default::default(),
max_message_size: DEFAULT_MAX_MESSAGE_SIZE,
}
}
}
/// The topic state maintains the swarm membership and broadcast tree for a particular topic.
#[derive(Debug)]
pub struct State<PI, R> {
me: PI,
pub(crate) swarm: hyparview::State<PI, R>,
pub(crate) gossip: plumtree::State<PI>,
outbox: VecDeque<OutEvent<PI>>,
stats: Stats,
}
impl<PI: PeerIdentity> State<PI, rand::rngs::ThreadRng> {
/// Initialize the local state with the default random number generator.
///
/// ## Panics
///
/// Panics if [`Config::max_message_size`] is below [`MIN_MAX_MESSAGE_SIZE`].
pub fn new(me: PI, me_data: Option<PeerData>, config: Config) -> Self {
Self::with_rng(me, me_data, config, rand::rng())
}
}
impl<PI, R> State<PI, R> {
/// The address of your local endpoint.
pub fn endpoint(&self) -> &PI {
&self.me
}
}
impl<PI: PeerIdentity, R: Rng> State<PI, R> {
/// Initialize the local state with a custom random number generator.
///
/// ## Panics
///
/// Panics if [`Config::max_message_size`] is below [`MIN_MAX_MESSAGE_SIZE`].
pub fn with_rng(me: PI, me_data: Option<PeerData>, config: Config, rng: R) -> Self {
assert!(
config.max_message_size >= MIN_MAX_MESSAGE_SIZE,
"max_message_size must be at least {MIN_MAX_MESSAGE_SIZE}"
);
let max_payload_size =
config.max_message_size - super::Message::<PI>::postcard_header_size();
Self {
swarm: hyparview::State::new(me, me_data, config.membership, rng),
gossip: plumtree::State::new(me, config.broadcast, max_payload_size),
me,
outbox: VecDeque::new(),
stats: Stats::default(),
}
}
/// Handle an incoming event.
///
/// Returns an iterator of outgoing events that must be processed by the application.
pub fn handle(
&mut self,
event: InEvent<PI>,
now: Instant,
) -> impl Iterator<Item = OutEvent<PI>> + '_ {
let io = &mut self.outbox;
// Process the event, store out events in outbox.
match event {
InEvent::Command(command) => match command {
Command::Join(peers) => {
for peer in peers {
self.swarm.handle(SwarmIn::RequestJoin(peer), io);
}
}
Command::Broadcast(data, scope) => {
self.gossip
.handle(GossipIn::Broadcast(data, scope), now, io)
}
Command::Quit => self.swarm.handle(SwarmIn::Quit, io),
},
InEvent::RecvMessage(from, message) => {
self.stats.messages_received += 1;
match message {
Message::Swarm(message) => {
self.swarm.handle(SwarmIn::RecvMessage(from, message), io)
}
Message::Gossip(message) => {
self.gossip
.handle(GossipIn::RecvMessage(from, message), now, io)
}
}
}
InEvent::TimerExpired(timer) => match timer {
Timer::Swarm(timer) => self.swarm.handle(SwarmIn::TimerExpired(timer), io),
Timer::Gossip(timer) => self.gossip.handle(GossipIn::TimerExpired(timer), now, io),
},
InEvent::PeerDisconnected(peer) => {
self.swarm.handle(SwarmIn::PeerDisconnected(peer), io);
self.gossip.handle(GossipIn::NeighborDown(peer), now, io);
}
InEvent::UpdatePeerData(data) => self.swarm.handle(SwarmIn::UpdatePeerData(data), io),
}
// Forward NeighborUp and NeighborDown events from hyparview to plumtree
let mut io = VecDeque::new();
for event in self.outbox.iter() {
match event {
OutEvent::EmitEvent(Event::NeighborUp(peer)) => {
self.gossip
.handle(GossipIn::NeighborUp(*peer), now, &mut io)
}
OutEvent::EmitEvent(Event::NeighborDown(peer)) => {
self.gossip
.handle(GossipIn::NeighborDown(*peer), now, &mut io)
}
_ => {}
}
}
// Note that this is a no-op because plumtree::handle(NeighborUp | NeighborDown)
// above does not emit any OutEvents.
self.outbox.extend(io.drain(..));
// Update sent message counter
self.stats.messages_sent += self
.outbox
.iter()
.filter(|event| matches!(event, OutEvent::SendMessage(_, _)))
.count();
self.outbox.drain(..)
}
/// Get stats on how many messages were sent and received.
// TODO: Remove/replace with metrics?
pub fn stats(&self) -> &Stats {
&self.stats
}
/// Reset all statistics.
pub fn reset_stats(&mut self) {
self.gossip.stats = Default::default();
self.swarm.stats = Default::default();
self.stats = Default::default();
}
/// Get statistics for the gossip broadcast state
///
/// TODO: Remove/replace with metrics?
pub fn gossip_stats(&self) -> &plumtree::Stats {
self.gossip.stats()
}
/// Check if this topic has any active (connected) peers.
pub fn has_active_peers(&self) -> bool {
!self.swarm.active_view.is_empty()
}
}
/// Statistics for the protocol state of a topic
#[derive(Clone, Debug, Default)]
pub struct Stats {
/// Number of messages sent
pub messages_sent: usize,
/// Number of messages received
pub messages_received: usize,
}

View file

@ -0,0 +1,532 @@
//! Utilities used in the protocol implementation
use std::{
collections::{hash_map, BinaryHeap, HashMap},
hash::Hash,
};
use n0_future::time::Instant;
use rand::{
seq::{IteratorRandom, SliceRandom},
Rng,
};
/// Implement methods, display, debug and conversion traits for 32 byte identifiers.
macro_rules! idbytes_impls {
($ty:ty, $name:expr) => {
impl $ty {
/// Create from a byte array.
pub const fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
/// Get as byte slice.
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
impl<T: ::std::convert::Into<[u8; 32]>> ::std::convert::From<T> for $ty {
fn from(value: T) -> Self {
Self::from_bytes(value.into())
}
}
impl ::std::fmt::Display for $ty {
fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result {
write!(f, "{}", ::hex::encode(&self.0))
}
}
impl ::std::fmt::Debug for $ty {
fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result {
write!(f, "{}({})", $name, ::hex::encode(&self.0))
}
}
impl ::std::str::FromStr for $ty {
type Err = ::hex::FromHexError;
fn from_str(s: &str) -> ::std::result::Result<Self, Self::Err> {
let mut bytes = [0u8; 32];
::hex::decode_to_slice(s, &mut bytes)?;
Ok(Self::from_bytes(bytes))
}
}
impl ::std::convert::AsRef<[u8]> for $ty {
fn as_ref(&self) -> &[u8] {
&self.0
}
}
impl ::std::convert::AsRef<[u8; 32]> for $ty {
fn as_ref(&self) -> &[u8; 32] {
&self.0
}
}
};
}
pub(crate) use idbytes_impls;
/// A hash set where the iteration order of the values is independent of their
/// hash values.
///
/// This is wrapper around [indexmap::IndexSet] which couple of utility methods
/// to randomly select elements from the set.
#[derive(Default, Debug, Clone, derive_more::Deref)]
pub(crate) struct IndexSet<T> {
inner: indexmap::IndexSet<T>,
}
impl<T: Hash + Eq> PartialEq for IndexSet<T> {
fn eq(&self, other: &Self) -> bool {
self.inner == other.inner
}
}
impl<T: Hash + Eq + PartialEq> IndexSet<T> {
pub fn new() -> Self {
Self {
inner: indexmap::IndexSet::new(),
}
}
pub fn insert(&mut self, value: T) -> bool {
self.inner.insert(value)
}
/// Remove a random element from the set.
pub fn remove_random<R: Rng + ?Sized>(&mut self, rng: &mut R) -> Option<T> {
self.pick_random_index(rng)
.and_then(|idx| self.inner.shift_remove_index(idx))
}
/// Pick a random element from the set.
pub fn pick_random<R: Rng + ?Sized>(&self, rng: &mut R) -> Option<&T> {
self.pick_random_index(rng)
.and_then(|idx| self.inner.get_index(idx))
}
/// Pick a random element from the set, but not any of the elements in `without`.
pub fn pick_random_without<R: Rng + ?Sized>(&self, without: &[&T], rng: &mut R) -> Option<&T> {
self.iter().filter(|x| !without.contains(x)).choose(rng)
}
/// Pick a random index for an element in the set.
pub fn pick_random_index<R: Rng + ?Sized>(&self, rng: &mut R) -> Option<usize> {
if self.is_empty() {
None
} else {
Some(rng.random_range(0..self.inner.len()))
}
}
/// Remove an element from the set.
///
/// NOTE: the value is removed by swapping it with the last element of the set and popping it off.
/// **This modifies the order of element by moving the last element**
pub fn remove(&mut self, value: &T) -> Option<T> {
self.inner.swap_remove_full(value).map(|(_i, v)| v)
}
/// Remove an element from the set by its index.
///
/// NOTE: the value is removed by swapping it with the last element of the set and popping it off.
/// **This modifies the order of element by moving the last element**
pub fn remove_index(&mut self, index: usize) -> Option<T> {
self.inner.swap_remove_index(index)
}
/// Create an iterator over the set in the order of insertion, while skipping the element in
/// `without`.
pub fn iter_without<'a>(&'a self, value: &'a T) -> impl Iterator<Item = &'a T> {
self.iter().filter(move |x| *x != value)
}
}
impl<T> IndexSet<T>
where
T: Hash + Eq + Clone,
{
/// Create a vector of all elements in the set in random order.
pub fn shuffled<R: Rng + ?Sized>(&self, rng: &mut R) -> Vec<T> {
let mut items: Vec<_> = self.inner.iter().cloned().collect();
items.shuffle(rng);
items
}
/// Create a vector of all elements in the set in random order, and shorten to
/// the first `len` elements after shuffling.
pub fn shuffled_and_capped<R: Rng + ?Sized>(&self, len: usize, rng: &mut R) -> Vec<T> {
let mut items = self.shuffled(rng);
items.truncate(len);
items
}
/// Create a vector of the elements in the set in random order while omitting
/// the elements in `without`.
pub fn shuffled_without<R: Rng + ?Sized>(&self, without: &[&T], rng: &mut R) -> Vec<T> {
let mut items = self
.inner
.iter()
.filter(|x| !without.contains(x))
.cloned()
.collect::<Vec<_>>();
items.shuffle(rng);
items
}
/// Create a vector of the elements in the set in random order while omitting
/// the elements in `without`, and shorten to the first `len` elements.
pub fn shuffled_without_and_capped<R: Rng + ?Sized>(
&self,
without: &[&T],
len: usize,
rng: &mut R,
) -> Vec<T> {
let mut items = self.shuffled_without(without, rng);
items.truncate(len);
items
}
}
impl<T> IntoIterator for IndexSet<T> {
type Item = T;
type IntoIter = <indexmap::IndexSet<T> as IntoIterator>::IntoIter;
fn into_iter(self) -> Self::IntoIter {
self.inner.into_iter()
}
}
impl<T> FromIterator<T> for IndexSet<T>
where
T: Hash + Eq,
{
fn from_iter<I: IntoIterator<Item = T>>(iterable: I) -> Self {
IndexSet {
inner: indexmap::IndexSet::from_iter(iterable),
}
}
}
/// A [`BinaryHeap`] with entries sorted by [`Instant`]. Allows to process expired items.
#[derive(Debug)]
pub struct TimerMap<T> {
heap: BinaryHeap<TimerMapEntry<T>>,
seq: u64,
}
// Can't derive default because we don't want a `T: Default` bound.
impl<T> Default for TimerMap<T> {
fn default() -> Self {
Self {
heap: Default::default(),
seq: 0,
}
}
}
impl<T> TimerMap<T> {
/// Create a new, empty TimerMap.
pub fn new() -> Self {
Self::default()
}
/// Insert a new entry at the specified instant.
pub fn insert(&mut self, instant: Instant, item: T) {
let seq = self.seq;
self.seq += 1;
let entry = TimerMapEntry {
seq,
time: instant,
item,
};
self.heap.push(entry);
}
/// Remove and return all entries before and equal to `from`.
pub fn drain_until(
&mut self,
from: &Instant,
) -> impl Iterator<Item = (Instant, T)> + '_ + use<'_, T> {
let from = *from;
std::iter::from_fn(move || self.pop_before(from))
}
/// Pop the first entry, if equal or before `limit`.
pub fn pop_before(&mut self, limit: Instant) -> Option<(Instant, T)> {
match self.heap.peek() {
Some(item) if item.time <= limit => self.heap.pop().map(|item| (item.time, item.item)),
_ => None,
}
}
/// Get a reference to the earliest entry in the `TimerMap`.
pub fn first(&self) -> Option<&Instant> {
self.heap.peek().map(|x| &x.time)
}
#[cfg(test)]
fn to_vec(&self) -> Vec<(Instant, T)>
where
T: Clone,
{
self.heap
.clone()
.into_sorted_vec()
.into_iter()
.rev()
.map(|x| (x.time, x.item))
.collect()
}
}
#[derive(Debug, Clone)]
struct TimerMapEntry<T> {
time: Instant,
seq: u64,
item: T,
}
impl<T> PartialEq for TimerMapEntry<T> {
fn eq(&self, other: &Self) -> bool {
self.time == other.time && self.seq == other.seq
}
}
impl<T> Eq for TimerMapEntry<T> {}
impl<T> PartialOrd for TimerMapEntry<T> {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl<T> Ord for TimerMapEntry<T> {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.time
.cmp(&other.time)
.reverse()
.then_with(|| self.seq.cmp(&other.seq).reverse())
}
}
/// A hash map where entries expire after a time
#[derive(Debug)]
pub struct TimeBoundCache<K, V> {
map: HashMap<K, (Instant, V)>,
expiry: TimerMap<K>,
}
impl<K, V> Default for TimeBoundCache<K, V> {
fn default() -> Self {
Self {
map: Default::default(),
expiry: Default::default(),
}
}
}
impl<K: Hash + Eq + Clone, V> TimeBoundCache<K, V> {
/// Insert an item into the cache, marked with an expiration time.
pub fn insert(&mut self, key: K, value: V, expires: Instant) {
self.map.insert(key.clone(), (expires, value));
self.expiry.insert(expires, key);
}
/// Returns `true` if the map contains a value for the specified key.
pub fn contains_key(&self, key: &K) -> bool {
self.map.contains_key(key)
}
/// Get the number of entries in the cache.
pub fn len(&self) -> usize {
self.map.len()
}
/// Returns `true` if the map contains no elements.
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
/// Get an item from the cache.
pub fn get(&self, key: &K) -> Option<&V> {
self.map.get(key).map(|(_expires, value)| value)
}
/// Get the expiration time for an item.
pub fn expires(&self, key: &K) -> Option<&Instant> {
self.map.get(key).map(|(expires, _value)| expires)
}
/// Iterate over all items in the cache.
pub fn iter(&self) -> impl Iterator<Item = (&K, &V, &Instant)> {
self.map.iter().map(|(k, (expires, v))| (k, v, expires))
}
/// Remove all entries with an expiry instant lower or equal to `instant`.
///
/// Returns the number of items that were removed.
pub fn expire_until(&mut self, instant: Instant) -> usize {
let drain = self.expiry.drain_until(&instant);
let mut count = 0;
for (time, key) in drain {
match self.map.entry(key) {
hash_map::Entry::Occupied(entry) if entry.get().0 == time => {
// If the entry's time matches that of the item we are draining from the expiry list,
// remove the entry from the map and increase the count of items we removed.
entry.remove();
count += 1;
}
hash_map::Entry::Occupied(_entry) => {
// If the entry's time does not match the time of the item we are draining,
// do not remove the entry: It means that it was re-added with a later time.
}
hash_map::Entry::Vacant(_) => {
// If the entry is not in the map, it means that it was already removed,
// which can happen if it was inserted multiple times.
}
}
}
count
}
}
#[cfg(test)]
mod test {
use std::str::FromStr;
use n0_future::time::{Duration, Instant};
use rand::SeedableRng;
use super::{IndexSet, TimeBoundCache, TimerMap};
fn test_rng() -> rand_chacha::ChaCha12Rng {
rand_chacha::ChaCha12Rng::seed_from_u64(42)
}
#[test]
fn indexset() {
let elems = [1, 2, 3, 4];
let set = IndexSet::from_iter(elems);
let x = set.shuffled(&mut test_rng());
assert_eq!(x, vec![2, 1, 4, 3]);
let x = set.shuffled_and_capped(2, &mut test_rng());
assert_eq!(x, vec![2, 1]);
let x = set.shuffled_without(&[&1], &mut test_rng());
assert_eq!(x, vec![3, 2, 4]);
let x = set.shuffled_without_and_capped(&[&1], 2, &mut test_rng());
assert_eq!(x, vec![3, 2]);
// recreate the rng - otherwise we get failures on some architectures when cross-compiling,
// likely due to usize differences pulling different amounts of randomness.
let x = set.pick_random(&mut test_rng());
assert_eq!(x, Some(&1));
let x = set.pick_random_without(&[&3], &mut test_rng());
assert_eq!(x, Some(&4));
let mut set = set;
set.remove_random(&mut test_rng());
assert_eq!(set, IndexSet::from_iter([2, 3, 4]));
}
#[test]
fn timer_map() {
let mut map = TimerMap::new();
let now = Instant::now();
let times = [
now - Duration::from_secs(1),
now,
now + Duration::from_secs(1),
now + Duration::from_secs(2),
];
map.insert(times[0], -1);
map.insert(times[0], -2);
map.insert(times[1], 0);
map.insert(times[2], 1);
map.insert(times[3], 2);
map.insert(times[3], 3);
assert_eq!(
map.to_vec(),
vec![
(times[0], -1),
(times[0], -2),
(times[1], 0),
(times[2], 1),
(times[3], 2),
(times[3], 3)
]
);
assert_eq!(map.first(), Some(&times[0]));
let drain = map.drain_until(&now);
assert_eq!(
drain.collect::<Vec<_>>(),
vec![(times[0], -1), (times[0], -2), (times[1], 0),]
);
assert_eq!(
map.to_vec(),
vec![(times[2], 1), (times[3], 2), (times[3], 3)]
);
let drain = map.drain_until(&now);
assert_eq!(drain.collect::<Vec<_>>(), vec![]);
let drain = map.drain_until(&(now + Duration::from_secs(10)));
assert_eq!(
drain.collect::<Vec<_>>(),
vec![(times[2], 1), (times[3], 2), (times[3], 3)]
);
}
#[test]
fn hex() {
#[derive(Eq, PartialEq)]
struct Id([u8; 32]);
idbytes_impls!(Id, "Id");
let id: Id = [1u8; 32].into();
assert_eq!(id, Id::from_str(&format!("{id}")).unwrap());
assert_eq!(
&format!("{id}"),
"0101010101010101010101010101010101010101010101010101010101010101"
);
assert_eq!(
&format!("{id:?}"),
"Id(0101010101010101010101010101010101010101010101010101010101010101)"
);
assert_eq!(id.as_bytes(), &[1u8; 32]);
}
#[test]
fn time_bound_cache() {
let mut cache = TimeBoundCache::default();
let t0 = Instant::now();
let t1 = t0 + Duration::from_secs(1);
let t2 = t0 + Duration::from_secs(2);
cache.insert(1, 10, t0);
cache.insert(2, 20, t1);
cache.insert(3, 30, t1);
cache.insert(4, 40, t2);
assert_eq!(cache.get(&2), Some(&20));
assert_eq!(cache.len(), 4);
let removed = cache.expire_until(t1);
assert_eq!(removed, 3);
assert_eq!(cache.len(), 1);
assert_eq!(cache.get(&2), None);
assert_eq!(cache.get(&4), Some(&40));
let t3 = t2 + Duration::from_secs(1);
cache.insert(5, 50, t2);
assert_eq!(cache.expires(&5), Some(&t2));
cache.insert(5, 50, t3);
assert_eq!(cache.expires(&5), Some(&t3));
cache.expire_until(t2);
assert_eq!(cache.get(&4), None);
assert_eq!(cache.get(&5), Some(&50));
}
}

View file

@ -0,0 +1,134 @@
//! Tests that use the [`iroh_gossip::proto::sim::Simulator`].
use std::{env, fmt, str::FromStr, time::Duration};
use iroh_gossip::proto::{
sim::{BootstrapMode, LatencyConfig, NetworkConfig, Simulator, SimulatorConfig},
Config,
};
#[test]
// #[traced_test]
fn big_hyparview() {
tracing_subscriber::fmt::try_init().ok();
let mut proto = Config::default();
proto.membership.shuffle_interval = Duration::from_secs(5);
let config = SimulatorConfig::from_env();
let bootstrap = BootstrapMode::default();
let network_config = NetworkConfig {
proto,
latency: LatencyConfig::default(),
};
let mut simulator = Simulator::new(config, network_config);
simulator.bootstrap(bootstrap);
let state = simulator.report();
println!("{state}");
assert!(!state.has_peers_with_no_neighbors());
}
#[test]
// #[traced_test]
fn big_multiple_sender() {
tracing_subscriber::fmt::try_init().ok();
let network_config = NetworkConfig::default();
let config = SimulatorConfig::from_env();
let bootstrap = BootstrapMode::default();
let mut simulator = Simulator::new(config, network_config);
simulator.bootstrap(bootstrap);
let rounds = read_var("ROUNDS", 30);
for i in 0..rounds {
let from = simulator.random_peer();
let message = format!("m{i}").into_bytes().into();
let messages = vec![(from, message)];
simulator.gossip_round(messages);
}
let avg = simulator.round_stats_average().mean;
println!(
"average with {} peers after {} rounds:\n{}",
simulator.peer_count(),
rounds,
avg
);
println!("{}", simulator.report());
assert!(avg.ldh < 18.);
assert!(avg.rmr < 1.);
assert_eq!(avg.missed, 0.0);
}
#[test]
// #[traced_test]
fn big_single_sender() {
tracing_subscriber::fmt::try_init().ok();
let network_config = NetworkConfig::default();
let config = SimulatorConfig::from_env();
let bootstrap = BootstrapMode::default();
let rounds = read_var("ROUNDS", 30);
let mut simulator = Simulator::new(config, network_config);
simulator.bootstrap(bootstrap);
let from = simulator.random_peer();
for i in 0..rounds {
let message = format!("m{i}").into_bytes().into();
let messages = vec![(from, message)];
simulator.gossip_round(messages);
}
let avg = simulator.round_stats_average().mean;
println!(
"average with {} peers after {} rounds:\n{}",
simulator.peer_count(),
rounds,
avg
);
println!("{}", simulator.report());
assert!(avg.ldh < 15.);
assert!(avg.rmr < 0.2);
assert_eq!(avg.missed, 0.0);
}
#[test]
// #[traced_test]
fn big_burst() {
tracing_subscriber::fmt::try_init().ok();
let network_config = NetworkConfig::default();
let config = SimulatorConfig::from_env();
let bootstrap = BootstrapMode::default();
let rounds = read_var("ROUNDS", 5);
let mut simulator = Simulator::new(config, network_config);
simulator.bootstrap(bootstrap);
let messages_per_peer = read_var("MESSAGES_PER_PEER", 1);
for i in 0..rounds {
let mut messages = vec![];
for id in simulator.network.peer_ids() {
for j in 0..messages_per_peer {
let message: bytes::Bytes = format!("{i}:{j}.{id}").into_bytes().into();
messages.push((id, message));
}
}
simulator.gossip_round(messages);
}
let avg = simulator.round_stats_average().mean;
println!(
"average with {} peers after {} rounds:\n{}",
simulator.peer_count(),
rounds,
avg
);
println!("{}", simulator.report());
assert!(avg.ldh < 30.);
assert!(avg.rmr < 3.);
assert_eq!(avg.missed, 0.0);
}
fn read_var<T: FromStr<Err: fmt::Display + fmt::Debug>>(name: &str, default: T) -> T {
env::var(name)
.map(|x| {
x.parse()
.unwrap_or_else(|_| panic!("Failed to parse environment variable {name}"))
})
.unwrap_or(default)
}