Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58f50d0395
|
||
|
|
c6436c653d
|
||
|
|
871c688b17
|
||
|
|
312d958a91
|
||
|
|
2c28dd77b4
|
||
|
|
2f40c81c3a
|
||
|
|
44b455e369
|
||
|
|
54d73d9cb1
|
||
|
|
d243e01ab0
|
||
|
|
d0b091feb4
|
||
|
|
271c418397
|
||
|
|
704bcd38e7
|
No files matched your search
@@ -7,6 +7,12 @@ test_tool = "nextest"
|
||||
# Async tests + crypto work takes longer under mutation load.
|
||||
timeout_multiplier = 3.0
|
||||
|
||||
# Enable `test-support` so cfg-gated setters (e.g. Builder::dht_backend) and
|
||||
# the integration tests that exercise them are part of the mutation surface.
|
||||
# Without this, dht_backend mutations always "survive" trivially because the
|
||||
# method isn't compiled in.
|
||||
additional_cargo_args = ["--features", "test-support"]
|
||||
|
||||
# Skip paths that either can't be meaningfully mutated or are covered by
|
||||
# other tools:
|
||||
# - sim_impl.rs: simulator is exercised via the simulator example, not
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
patch-updates:
|
||||
update-types: [patch]
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directory: /fuzz
|
||||
schedule:
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 2
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
toolchain: [stable, "1.93.0"] # MSRV
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ matrix.toolchain }}
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
name: cargo deny
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-deny
|
||||
@@ -58,11 +58,13 @@ jobs:
|
||||
name: cargo audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-audit
|
||||
- run: cargo audit
|
||||
# cargo-audit doesn't read deny.toml, so mirror its ignore list here.
|
||||
# See deny.toml [advisories] for rationale on each ID.
|
||||
- run: cargo audit --ignore RUSTSEC-2026-0119 --ignore RUSTSEC-2026-0120
|
||||
|
||||
# ── Public-API semver compliance (PR only) ────────────────────────
|
||||
semver:
|
||||
@@ -70,7 +72,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: taiki-e/install-action@v2
|
||||
@@ -84,7 +86,7 @@ jobs:
|
||||
name: coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
@@ -94,7 +96,7 @@ jobs:
|
||||
tool: cargo-llvm-cov,cargo-nextest
|
||||
- name: lcov
|
||||
run: cargo llvm-cov nextest --all-features --lcov --output-path lcov.info
|
||||
- uses: codecov/codecov-action@v4
|
||||
- uses: codecov/codecov-action@v6
|
||||
with:
|
||||
files: lcov.info
|
||||
fail_ci_if_error: false
|
||||
@@ -106,7 +108,7 @@ jobs:
|
||||
name: examples build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo build --example quickstart
|
||||
@@ -117,7 +119,7 @@ jobs:
|
||||
name: stateright BFS N=3 K=2
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test --test stateright_model --release --all-features -- bfs_n3_k2 --ignored
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
name: miri (sync modules)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
components: miri
|
||||
@@ -25,9 +25,11 @@ jobs:
|
||||
- name: miri on keys/wire/crypto/merge
|
||||
env:
|
||||
MIRIFLAGS: -Zmiri-disable-isolation
|
||||
# Quoted because YAML plain scalars can't contain `: ` sequences
|
||||
# without being misparsed as mapping entries.
|
||||
run: "cargo +nightly miri test --lib keys:: wire:: crypto:: merge::"
|
||||
# `cargo test` takes a single TESTNAME positional, so multi-module
|
||||
# filters must be passed to the test binary after `--`. Quoted because
|
||||
# YAML plain scalars can't contain `: ` sequences without being
|
||||
# misparsed as mapping entries.
|
||||
run: "cargo +nightly miri test --lib -- keys:: wire:: crypto:: merge::"
|
||||
|
||||
mutants:
|
||||
name: cargo-mutants
|
||||
@@ -37,13 +39,13 @@ jobs:
|
||||
matrix:
|
||||
shard: [0, 1, 2, 3]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-mutants,cargo-nextest
|
||||
- run: cargo mutants --shard ${{ matrix.shard }}/4 --in-diff-base ""
|
||||
- run: cargo mutants --shard ${{ matrix.shard }}/4
|
||||
|
||||
fuzz-smoke:
|
||||
name: fuzz smoke (60s / target)
|
||||
@@ -57,19 +59,23 @@ jobs:
|
||||
- fuzz_crypto_open
|
||||
- fuzz_merge
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@nightly
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-fuzz
|
||||
- run: cd fuzz && cargo +nightly fuzz run ${{ matrix.target }} -- -max_total_time=60
|
||||
# `--target` is explicit because the binstall'd `cargo-fuzz` binary is a
|
||||
# statically-linked musl build, and `cargo fuzz`'s default target is the
|
||||
# triple it was compiled for. The libfuzzer sanitizer is incompatible
|
||||
# with the static-crt musl target, so force the glibc triple.
|
||||
- run: cd fuzz && cargo +nightly fuzz run --target x86_64-unknown-linux-gnu ${{ matrix.target }} -- -max_total_time=60
|
||||
|
||||
kani:
|
||||
name: kani
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: model-checking/kani-github-action@v1
|
||||
with:
|
||||
@@ -80,7 +86,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test --test stateright_model --release --all-features -- random_large --ignored
|
||||
@@ -89,7 +95,7 @@ jobs:
|
||||
name: criterion benches
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo bench --all-features
|
||||
@@ -32,6 +32,9 @@ lcov.info
|
||||
# jj (Jujutsu) workspace
|
||||
.jj/
|
||||
|
||||
# Claude Code session state
|
||||
.claude/
|
||||
|
||||
# Direnv
|
||||
.envrc
|
||||
.direnv/
|
||||
+57
-3
@@ -6,6 +6,60 @@ format is based on [Keep a Changelog], and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.1] — 2026-05-11
|
||||
|
||||
### Changed
|
||||
|
||||
- **Fast recovery from `DhtStatus::PublishFailing`.** The write loop
|
||||
previously slept the full `write_period` (default 5 min) between
|
||||
every iteration and still applied the `1/(n+1)` probability gate,
|
||||
so a node stuck in `PublishFailing` could wait many minutes — even
|
||||
half an hour with a populated active view — before retrying. The
|
||||
loop now checks `dht_status` before sleeping: while failing, it
|
||||
uses the new `failure_retry_period` (default 15 s) and bypasses
|
||||
the gate, so every tick attempts a write until one succeeds. On
|
||||
success the steady-state cadence and gate are restored.
|
||||
|
||||
### Added
|
||||
|
||||
- `Builder::failure_retry_period(Duration)` — override the recovery
|
||||
cadence (additive, non-breaking). Public constant
|
||||
`DEFAULT_FAILURE_RETRY_PERIOD = 15 s`.
|
||||
|
||||
### Rationale
|
||||
|
||||
The probability gate exists to keep N healthy publishers from
|
||||
storming the DHT with redundant writes — but once *our* publish has
|
||||
failed, redundancy is irrelevant; we need to land *one* successful
|
||||
write. Skipping the gate while failing turns the recovery time into
|
||||
a function of `failure_retry_period`, not of `(n + 1) × write_period`.
|
||||
|
||||
## [0.2.0] — 2026-05-11
|
||||
|
||||
### Changed
|
||||
|
||||
- **Breaking:** bumped `iroh` to `=1.0.0-rc.0` and `iroh-gossip` to `0.99`.
|
||||
Public API exposes iroh types (`Endpoint`, `SecretKey`, `PublicKey`,
|
||||
`iroh_gossip::api::*`), so consumers must move to the iroh 1.0-rc.0
|
||||
ecosystem in lockstep.
|
||||
- **Breaking:** bumped `ed25519-dalek` to `=3.0.0-pre.7` to match
|
||||
iroh-base 1.0.0-rc.0. The version is pinned with `=` because iroh-base
|
||||
itself hard-pins the pre-release; drop the `=` when iroh-base relaxes.
|
||||
- Loosened the `sha2` pin from `=0.11.0-rc.5` to `^0.11`. iroh-base
|
||||
1.0.0-rc.0 no longer hard-pins the rc, so we follow the same
|
||||
caret-range convention as the rest of the crypto stack.
|
||||
- Bumped transitive crypto deps via `cargo update`: `mainline` 6.0.1 →
|
||||
6.1.2, plus the usual sweep of tokio/hyper/rustls patches.
|
||||
|
||||
### Notes
|
||||
|
||||
- No source changes were required: the iroh/gossip APIs we touch
|
||||
(`Endpoint::builder`, `endpoint::presets::N0`, `SecretKey::generate`,
|
||||
`PublicKey`, `iroh_gossip::api::{Event, GossipSender}`) survived the
|
||||
bump verbatim.
|
||||
- All 96 lib tests, property tests, and integration tests pass against
|
||||
the new stack.
|
||||
|
||||
## [0.1.0] — 2026-04-22
|
||||
|
||||
First public release. Passphrase-based peer rendezvous for iroh-gossip
|
||||
@@ -81,6 +135,6 @@ peer addresses required.
|
||||
|
||||
[Keep a Changelog]: https://keepachangelog.com/en/1.1.0/
|
||||
[Semantic Versioning]: https://semver.org/spec/v2.0.0.html
|
||||
[protocol]: https://github.com/swaits/iroh-gossip-rendezvous/blob/main/PROTOCOL.md
|
||||
[Unreleased]: https://github.com/swaits/iroh-gossip-rendezvous/compare/v0.1.0...HEAD
|
||||
[0.1.0]: https://github.com/swaits/iroh-gossip-rendezvous/releases/tag/v0.1.0
|
||||
[protocol]: https://git.swaits.com/swaits/iroh-gossip-rendezvous/src/branch/main/PROTOCOL.md
|
||||
[Unreleased]: https://git.swaits.com/swaits/iroh-gossip-rendezvous/compare/v0.1.0...main
|
||||
[0.1.0]: https://git.swaits.com/swaits/iroh-gossip-rendezvous/releases/tag/v0.1.0
|
||||
+1
-1
@@ -6,7 +6,7 @@ Bug reports, feature requests, and pull requests welcome.
|
||||
|
||||
```sh
|
||||
# Clone the repo.
|
||||
git clone https://github.com/swaits/iroh-gossip-rendezvous
|
||||
git clone https://git.swaits.com/swaits/iroh-gossip-rendezvous
|
||||
cd iroh-gossip-rendezvous
|
||||
|
||||
# Install the dev toolchain. Most tools are lightweight; Kani is the
|
||||
|
||||
Generated
+279
-495
File diff suppressed because it is too large.
Load diff
+13
-13
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "iroh-gossip-rendezvous"
|
||||
version = "0.1.0"
|
||||
version = "0.2.2"
|
||||
edition = "2024"
|
||||
rust-version = "1.93.0"
|
||||
authors = ["Stephen Waits <steve@waits.net>"]
|
||||
license = "MIT"
|
||||
description = "Passphrase-based rendezvous for iroh-gossip swarms via Mainline DHT"
|
||||
repository = "https://github.com/swaits/iroh-gossip-rendezvous"
|
||||
homepage = "https://github.com/swaits/iroh-gossip-rendezvous"
|
||||
repository = "https://git.swaits.com/swaits/iroh-gossip-rendezvous"
|
||||
homepage = "https://git.swaits.com/swaits/iroh-gossip-rendezvous"
|
||||
keywords = ["iroh", "gossip", "dht", "mainline", "rendezvous"]
|
||||
categories = ["network-programming"]
|
||||
readme = "README.md"
|
||||
@@ -17,6 +17,7 @@ exclude = [
|
||||
".github/",
|
||||
".cargo/",
|
||||
".config/",
|
||||
".claude/",
|
||||
"deny.toml",
|
||||
"justfile",
|
||||
"CODEOWNERS",
|
||||
@@ -87,23 +88,22 @@ name = "keys"
|
||||
harness = false
|
||||
|
||||
[dependencies]
|
||||
iroh = "0.98"
|
||||
iroh-gossip = "0.98"
|
||||
iroh = "=1.0.0-rc.0"
|
||||
iroh-gossip = "0.99"
|
||||
mainline = { version = "6", features = ["async"] }
|
||||
# Crypto stack — several deps are pinned by transitive constraints. See
|
||||
# `UPGRADING.md` or the release notes on any bump.
|
||||
#
|
||||
# * `ed25519-dalek = 2`: 3.0 is pre-release (`3.0.0-pre.6`). Move to 3 once stable.
|
||||
# * `ed25519-dalek = "=3.0.0-pre.7"`: matched to iroh 1.0.0-rc.0 / iroh-base 1.0.0-rc.0.
|
||||
# * `chacha20poly1305 = 0.10`: 0.11 is `rc`. Same story.
|
||||
# * `sha2 = 0.10` / `hkdf = 0.12`: **pinned by `mainline`**, which in turn
|
||||
# hard-pins `digest = "=0.11.0-rc.9"`. Bumping sha2 or hkdf requires either
|
||||
# a mainline release that relaxes the pin, or a coordinated fork. Attempting
|
||||
# `sha2 = 0.11` / `hkdf = 0.13` fails cargo-outdated and `cargo check`.
|
||||
# * `hkdf = 0.13`: works as of mainline 6.1.2, which loosened its `digest`
|
||||
# pin to `0.11`. The HKDF-SHA256 KAT covers compatibility — bump only if
|
||||
# it still passes byte-for-byte.
|
||||
# * `rand = 0.9`: 0.10 is coordinated with iroh + bridge; hold until iroh bumps.
|
||||
ed25519-dalek = "2"
|
||||
ed25519-dalek = "=3.0.0-pre.7"
|
||||
chacha20poly1305 = "0.10"
|
||||
hkdf = "0.12"
|
||||
sha2 = "0.10"
|
||||
hkdf = "0.13"
|
||||
sha2 = "0.11"
|
||||
blake3 = "1"
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
rand = "0.9"
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
> modelled in `tests/stateright_model.rs` for bounded safety + liveness
|
||||
> checks.
|
||||
>
|
||||
> Authoritative copy: <https://github.com/swaits/iroh-gossip-rendezvous/blob/main/PROTOCOL.md>.
|
||||
> Authoritative copy: <https://git.swaits.com/swaits/iroh-gossip-rendezvous/src/branch/main/PROTOCOL.md>.
|
||||
>
|
||||
> [`iroh-gossip-rendezvous`]: https://docs.rs/iroh-gossip-rendezvous
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[](https://crates.io/crates/iroh-gossip-rendezvous)
|
||||
[](https://docs.rs/iroh-gossip-rendezvous)
|
||||
[](https://github.com/swaits/iroh-gossip-rendezvous/actions)
|
||||
[](https://git.swaits.com/swaits/iroh-gossip-rendezvous/actions)
|
||||
[](#msrv)
|
||||
[](LICENSE)
|
||||
|
||||
|
||||
@@ -15,15 +15,23 @@ all-features = true
|
||||
[advisories]
|
||||
version = 2
|
||||
yanked = "deny"
|
||||
# Allow transitive "unmaintained" advisories that come via iroh's dep graph
|
||||
# and can't be fixed downstream. Re-evaluate on each iroh bump.
|
||||
# Note: cargo-deny and cargo-audit disagree on which advisories currently fire;
|
||||
# cargo-audit reports atomic-polyfill, cargo-deny's db may not. We list it here
|
||||
# for cargo-audit's benefit via deny.toml being the canonical source.
|
||||
# Allow transitive advisories that come via iroh's dep graph and can't be
|
||||
# fixed downstream. Re-evaluate on each iroh bump.
|
||||
# Note: cargo-audit does NOT read this file — mirror any vulnerability-class
|
||||
# entries below into the `cargo audit --ignore …` flag list in ci.yml.
|
||||
ignore = [
|
||||
# paste — no longer maintained. Pulled in via iroh → portmapper → netwatch
|
||||
# → netlink-packet-core. No direct dep; stuck until iroh migrates.
|
||||
"RUSTSEC-2024-0436",
|
||||
# hickory-proto 0.26.0-beta.4 — CPU exhaustion via O(n²) name compression
|
||||
# during message encoding. Transitive via iroh → hickory-resolver
|
||||
# (=0.26.0-beta.4, hard-pinned). Fixed in hickory 0.26.1; unblock when
|
||||
# iroh releases a version that bumps the pin.
|
||||
"RUSTSEC-2026-0119",
|
||||
# hickory-net 0.26.0-beta.4 — NSEC3 closest-encloser proof enters an
|
||||
# unbounded loop on cross-zone responses (OOM in release builds; panic
|
||||
# in debug). Same transitive path and same iroh pin as RUSTSEC-2026-0119.
|
||||
"RUSTSEC-2026-0120",
|
||||
]
|
||||
|
||||
# ── Licenses ───────────────────────────────────────────────────────
|
||||
|
||||
Generated
+604
-444
File diff suppressed because it is too large.
Load diff
@@ -33,6 +33,12 @@ pub const DEFAULT_EPOCH_WRITES: u64 = 64;
|
||||
pub const DEFAULT_WRITE_PERIOD: Duration = Duration::from_secs(300);
|
||||
/// T_h — mean heal-loop period.
|
||||
pub const DEFAULT_HEAL_PERIOD: Duration = Duration::from_secs(30);
|
||||
/// T_f — write-loop period while `DhtStatus::PublishFailing`. Used in
|
||||
/// place of `T_w` until a publish succeeds; the probability gate is also
|
||||
/// skipped during recovery so every tick attempts a write. Bounds the
|
||||
/// recovery latency from a transient DHT failure to roughly this period
|
||||
/// (± jitter) regardless of neighbor count.
|
||||
pub const DEFAULT_FAILURE_RETRY_PERIOD: Duration = Duration::from_secs(15);
|
||||
/// σ — jitter factor (± fraction of the period).
|
||||
pub const DEFAULT_JITTER: f32 = 0.5;
|
||||
|
||||
@@ -49,6 +55,7 @@ pub struct Builder {
|
||||
epoch_size: u64,
|
||||
write_period: Duration,
|
||||
heal_period: Duration,
|
||||
failure_retry_period: Duration,
|
||||
jitter: f32,
|
||||
wait_for_first_neighbor: Option<Duration>,
|
||||
/// Test-only: inject a custom DhtSlots backend (e.g., InMemoryDht).
|
||||
@@ -71,6 +78,7 @@ impl Default for Builder {
|
||||
epoch_size: DEFAULT_EPOCH_WRITES,
|
||||
write_period: DEFAULT_WRITE_PERIOD,
|
||||
heal_period: DEFAULT_HEAL_PERIOD,
|
||||
failure_retry_period: DEFAULT_FAILURE_RETRY_PERIOD,
|
||||
jitter: DEFAULT_JITTER,
|
||||
wait_for_first_neighbor: None,
|
||||
#[cfg(feature = "test-support")]
|
||||
@@ -156,6 +164,21 @@ impl Builder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Override `T_f` — the write-loop period used while
|
||||
/// [`DhtStatus::PublishFailing`](crate::state::DhtStatus::PublishFailing).
|
||||
/// Default 15 seconds.
|
||||
///
|
||||
/// While failing, the probability gate `1/(n+1)` is bypassed and a
|
||||
/// write is attempted every tick. Returning to
|
||||
/// [`DhtStatus::Ready`](crate::state::DhtStatus::Ready) on a
|
||||
/// successful publish restores the steady-state `write_period`
|
||||
/// cadence and re-engages the gate.
|
||||
#[must_use]
|
||||
pub fn failure_retry_period(mut self, period: Duration) -> Self {
|
||||
self.failure_retry_period = period;
|
||||
self
|
||||
}
|
||||
|
||||
/// Override `σ`. Default 0.5 (± 50%).
|
||||
#[must_use]
|
||||
pub fn jitter(mut self, sigma: f32) -> Self {
|
||||
@@ -257,6 +280,7 @@ impl Builder {
|
||||
epoch_size: self.epoch_size,
|
||||
write_period: self.write_period,
|
||||
heal_period: self.heal_period,
|
||||
failure_retry_period: self.failure_retry_period,
|
||||
jitter: self.jitter,
|
||||
};
|
||||
|
||||
@@ -446,6 +470,30 @@ mod tests {
|
||||
assert_eq!(Builder::default().heal_period(d).heal_period, d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_retry_period_setter_stores_exact_value() {
|
||||
let d = Duration::from_millis(42);
|
||||
assert_eq!(
|
||||
Builder::default()
|
||||
.failure_retry_period(d)
|
||||
.failure_retry_period,
|
||||
d
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "test-support")]
|
||||
fn dht_backend_setter_stores_value() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::sim::InMemoryDht;
|
||||
let dht = Arc::new(InMemoryDht::new());
|
||||
let before = Builder::default();
|
||||
let after = Builder::default().dht_backend(dht);
|
||||
assert!(before.dht_backend.is_none());
|
||||
assert!(after.dht_backend.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_setter_stores_exact_value() {
|
||||
let b = Builder::default().jitter(0.25);
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ pub use state::{DhtStatus, RendezvousState};
|
||||
/// here so callers (and the `tests/spec_drift.rs` consistency check) can
|
||||
/// reference them without poking at private modules.
|
||||
///
|
||||
/// [`PROTOCOL.md`]: https://github.com/swaits/iroh-gossip-rendezvous/blob/main/PROTOCOL.md
|
||||
/// [`PROTOCOL.md`]: https://git.swaits.com/swaits/iroh-gossip-rendezvous/src/branch/main/PROTOCOL.md
|
||||
pub mod defaults {
|
||||
pub use crate::builder::{
|
||||
DEFAULT_EPOCH_WRITES, DEFAULT_HEAL_PERIOD, DEFAULT_JITTER, DEFAULT_MAX_AGE,
|
||||
|
||||
+138
-9
@@ -48,6 +48,12 @@ pub(crate) struct ProtocolConfig {
|
||||
pub epoch_size: u64,
|
||||
pub write_period: Duration,
|
||||
pub heal_period: Duration,
|
||||
/// Sleep between write-loop ticks while `dht_status == PublishFailing`.
|
||||
/// Used in place of `write_period` to recover from a failed publish
|
||||
/// faster than the steady-state cadence allows. The probability gate
|
||||
/// is also skipped while failing — every tick attempts a write until
|
||||
/// one succeeds.
|
||||
pub failure_retry_period: Duration,
|
||||
pub jitter: f32,
|
||||
}
|
||||
|
||||
@@ -376,19 +382,39 @@ pub(crate) fn spawn_loops(
|
||||
first_iter = false;
|
||||
true
|
||||
} else {
|
||||
let sleep = jittered(state.config.write_period, state.config.jitter);
|
||||
// While the last publish is failing, retry on the
|
||||
// shorter `failure_retry_period` cadence and bypass
|
||||
// the `1/(n+1)` probability gate — the gate exists
|
||||
// to avoid swarm-wide write storms when N is large
|
||||
// and the DHT is healthy, but once *our* publish
|
||||
// has failed we should retry unconditionally until
|
||||
// it succeeds.
|
||||
let failing = matches!(
|
||||
state.observable.lock().map(|s| s.dht_status),
|
||||
Ok(DhtStatus::PublishFailing)
|
||||
);
|
||||
let period = if failing {
|
||||
state.config.failure_retry_period
|
||||
} else {
|
||||
state.config.write_period
|
||||
};
|
||||
let sleep = jittered(period, state.config.jitter);
|
||||
tokio::select! {
|
||||
() = tokio::time::sleep(sleep) => {}
|
||||
() = cancel.cancelled() => break,
|
||||
}
|
||||
// Write with probability 1/(n+1). Scope the (non-Send)
|
||||
// ThreadRng so it doesn't live across the await.
|
||||
let n = gossip.neighbors().len();
|
||||
let r = {
|
||||
let mut rng = rand::rng();
|
||||
rng.random::<f32>()
|
||||
};
|
||||
should_fire_write(r, n)
|
||||
if failing {
|
||||
true
|
||||
} else {
|
||||
// Scope the (non-Send) ThreadRng so it doesn't
|
||||
// live across the await.
|
||||
let n = gossip.neighbors().len();
|
||||
let r = {
|
||||
let mut rng = rand::rng();
|
||||
rng.random::<f32>()
|
||||
};
|
||||
should_fire_write(r, n)
|
||||
}
|
||||
};
|
||||
if should_write {
|
||||
write_once(&state, dht.as_ref(), gossip.as_ref()).await;
|
||||
@@ -472,6 +498,61 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fault-injecting `DhtSlots`. Fails the first `fail_writes` write calls
|
||||
/// (counting attempts, not slots), then delegates to an inner
|
||||
/// [`InMemoryDht`]. Reads always delegate.
|
||||
struct FaultyDht {
|
||||
inner: InMemoryDht,
|
||||
remaining_failures: Mutex<usize>,
|
||||
write_attempts: Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
impl FaultyDht {
|
||||
fn new(fail_writes: usize) -> (Self, Arc<std::sync::atomic::AtomicUsize>) {
|
||||
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
(
|
||||
Self {
|
||||
inner: InMemoryDht::new(),
|
||||
remaining_failures: Mutex::new(fail_writes),
|
||||
write_attempts: attempts.clone(),
|
||||
},
|
||||
attempts,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DhtSlots for FaultyDht {
|
||||
async fn read(
|
||||
&self,
|
||||
slot: crate::dht::SlotKey,
|
||||
) -> Result<Option<crate::dht::SlotRecord>, crate::dht::DhtError> {
|
||||
self.inner.read(slot).await
|
||||
}
|
||||
|
||||
async fn write(
|
||||
&self,
|
||||
slot: crate::dht::SlotKey,
|
||||
record: crate::dht::SlotRecord,
|
||||
) -> Result<(), crate::dht::DhtError> {
|
||||
self.write_attempts
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let should_fail = {
|
||||
let mut r = self.remaining_failures.lock().unwrap();
|
||||
if *r > 0 {
|
||||
*r -= 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if should_fail {
|
||||
return Err(crate::dht::DhtError::Transport("injected".into()));
|
||||
}
|
||||
self.inner.write(slot, record).await
|
||||
}
|
||||
}
|
||||
|
||||
fn test_config(self_id: [u8; 32], shards: usize) -> ProtocolConfig {
|
||||
ProtocolConfig {
|
||||
self_id,
|
||||
@@ -482,6 +563,7 @@ mod tests {
|
||||
epoch_size: 64,
|
||||
write_period: Duration::from_secs(1),
|
||||
heal_period: Duration::from_secs(1),
|
||||
failure_retry_period: Duration::from_millis(50),
|
||||
jitter: 0.5,
|
||||
}
|
||||
}
|
||||
@@ -567,6 +649,53 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_loop_recovers_from_publish_failing_on_short_period() {
|
||||
// Loop should retry on `failure_retry_period` (not `write_period`)
|
||||
// and bypass the `1/(n+1)` gate while `DhtStatus::PublishFailing`.
|
||||
// Setup: write_period=10s, failure_retry_period=30ms, dht fails
|
||||
// first 3 writes then succeeds. Within a few hundred ms we should
|
||||
// see ≥4 attempts (3 fails + 1 success) and status == Ready.
|
||||
|
||||
let self_id = [42u8; 32];
|
||||
let mut config = test_config(self_id, 3);
|
||||
config.write_period = Duration::from_secs(10);
|
||||
config.heal_period = Duration::from_secs(10);
|
||||
config.failure_retry_period = Duration::from_millis(30);
|
||||
// No jitter so the test is deterministic.
|
||||
config.jitter = 0.0;
|
||||
|
||||
let state = Arc::new(test_state(config));
|
||||
let (faulty, attempts) = FaultyDht::new(3);
|
||||
let dht: Arc<dyn DhtSlots> = Arc::new(faulty);
|
||||
let (gossip_view, _joined) = MockGossip::new(vec![]);
|
||||
let gossip: Arc<dyn GossipView> = Arc::new(gossip_view);
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
let mut tasks = spawn_loops(state.clone(), dht.clone(), gossip.clone(), cancel.clone());
|
||||
|
||||
// 4 × 30ms = 120ms minimum. Give 500ms — comfortably above the
|
||||
// retry budget, comfortably below `write_period`. If the loop
|
||||
// were waiting on `write_period` (10s) we'd see only 1 attempt.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
let made = attempts.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let status = state.observable.lock().unwrap().dht_status;
|
||||
|
||||
cancel.cancel();
|
||||
while tasks.join_next().await.is_some() {}
|
||||
|
||||
assert!(
|
||||
made >= 4,
|
||||
"expected ≥4 write attempts (3 fails + 1 success) within 500ms; got {made}"
|
||||
);
|
||||
assert_eq!(
|
||||
status,
|
||||
DhtStatus::Ready,
|
||||
"status should flip to Ready after the 4th write succeeded"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seq_is_monotonic_across_writes() {
|
||||
let self_id = [1u8; 32];
|
||||
|
||||
@@ -278,6 +278,7 @@ fn random_large() {
|
||||
.checker()
|
||||
.threads(num_cpus())
|
||||
.target_max_depth(50)
|
||||
.target_state_count(1_000_000)
|
||||
.spawn_simulation(0, stateright::UniformChooser)
|
||||
.join()
|
||||
.assert_properties();
|
||||
|
||||
Reference in new issue
Block a user