mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-12 09:33:23 +00:00
Merge branch 'master' into next
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -138,15 +138,92 @@ jobs:
|
||||
run: cargo install cargo-zigbuild --version 0.19.8 --locked
|
||||
|
||||
- name: Install zig (required by cargo-zigbuild)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ZIG_VERSION="0.13.0"
|
||||
# Each arch carries the expected SHA-256 of its upstream tarball,
|
||||
# taken from ziglang.org's own https://ziglang.org/download/index.json,
|
||||
# field .["<version>"]["<arch>-linux"].shasum:
|
||||
# jq -r '.["0.13.0"]["x86_64-linux"].shasum' index.json
|
||||
# Upstream publishes no .sha256 sidecar and no SHA256SUMS, so
|
||||
# index.json is the only checksum document offered, and it lists only
|
||||
# recent releases: once a version ages out of it the pin can no longer
|
||||
# be re-derived upstream. Bumping ZIG_VERSION means replacing every
|
||||
# hash below, and adding an arch means adding its hash here too.
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64|amd64) ZIG_ARCH="x86_64" ;;
|
||||
aarch64|arm64) ZIG_ARCH="aarch64" ;;
|
||||
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
||||
x86_64|amd64)
|
||||
ZIG_ARCH="x86_64"
|
||||
ZIG_SHA256="d45312e61ebcc48032b77bc4cf7fd6915c11fa16e4aad116b66c9468211230ea"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
ZIG_ARCH="aarch64"
|
||||
ZIG_SHA256="041ac42323837eb5624068acd8b00cd5777dac4cf91179e8dad7a7e90dd0c556"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $ARCH"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" | sudo tar xJ -C /opt
|
||||
if [ -z "${ZIG_SHA256:-}" ]; then
|
||||
echo "No SHA-256 pinned for zig ${ZIG_VERSION} on ${ZIG_ARCH}."
|
||||
echo "Add one to the case above, from https://ziglang.org/download/index.json:"
|
||||
echo " jq -r '.[\"${ZIG_VERSION}\"][\"${ZIG_ARCH}-linux\"].shasum'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NAME="zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz"
|
||||
URL="https://ziglang.org/download/${ZIG_VERSION}/${NAME}"
|
||||
# Stage outside the checkout so a failed attempt cannot leave a stray
|
||||
# tarball in the working tree.
|
||||
ZIG_TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$ZIG_TMP"' EXIT
|
||||
TARBALL="${ZIG_TMP}/${NAME}"
|
||||
|
||||
# Download to a file and check it before anything consumes it: piping
|
||||
# curl straight into tar let a truncated transfer reach the extractor,
|
||||
# which is how this step failed. curl's own --retry does not cover a
|
||||
# short read (exit 18), and a checksum mismatch needs a fresh download
|
||||
# anyway, so the retry is an explicit bounded loop.
|
||||
verified=""
|
||||
previous=""
|
||||
for attempt in 1 2 3; do
|
||||
rm -f "$TARBALL"
|
||||
if curl -fsSL -o "$TARBALL" "$URL" && [ -s "$TARBALL" ]; then
|
||||
actual="$(sha256sum < "$TARBALL" | cut -d' ' -f1)"
|
||||
if [ "$actual" = "$ZIG_SHA256" ]; then
|
||||
echo "zig tarball matches its pinned SHA-256 (${actual})"
|
||||
verified=yes
|
||||
break
|
||||
fi
|
||||
echo "zig tarball failed its checksum on attempt ${attempt}:"
|
||||
echo " expected ${ZIG_SHA256}"
|
||||
echo " actual ${actual}"
|
||||
echo " size $(wc -c < "$TARBALL") bytes"
|
||||
if [ "$actual" = "$previous" ]; then
|
||||
echo "Two attempts fetched byte-identical content, so retrying is not"
|
||||
echo "going to help: the pin is stale, upstream re-published, or the"
|
||||
echo "source is serving the same bad file every time."
|
||||
break
|
||||
fi
|
||||
previous="$actual"
|
||||
else
|
||||
echo "zig tarball download failed on attempt ${attempt}"
|
||||
fi
|
||||
if [ "$attempt" -lt 3 ]; then
|
||||
sleep $((attempt * 10))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$verified" ]; then
|
||||
echo "zig ${ZIG_VERSION} (${ZIG_ARCH}) did not download with its pinned"
|
||||
echo "SHA-256 after ${attempt} attempt(s). Refusing to extract a tarball"
|
||||
echo "that does not match the pin; failing the build."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sudo tar xJ -C /opt -f "$TARBALL"
|
||||
sudo ln -sf /opt/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}/zig /usr/local/bin/zig
|
||||
zig version
|
||||
|
||||
|
||||
+176
@@ -114,6 +114,57 @@ with v0.4.x or earlier peers.
|
||||
additionally wired into the Noise XX handshake cluster
|
||||
(msg1/msg2/msg3) and the rekey-initiator outbound sites on `next`.
|
||||
|
||||
- OpenWrt 802.11s open-mesh backhaul: router-to-router radio links with FIPS
|
||||
providing all encryption, authentication and routing over bare L2 neighbor
|
||||
links. The mesh runs open with `mesh_fwding 0`, since SAE would duplicate the
|
||||
Noise layer and force ath10k raw mode, and FIPS's spanning tree is the
|
||||
routing layer. `fips-mesh-setup` is an opt-in UCI helper creating a per-radio
|
||||
mesh point (`radio0` to `fips-mesh0`, `radio1` to `fips-mesh1`, with a
|
||||
free-index fallback and a collision guard); radio setup stays opt-in because
|
||||
a package must not commandeer radios on install. A dual-band router gets one
|
||||
instance per radio, and FIPS treats the two paths as failover rather than
|
||||
multipath: cross-connection resolution keeps one active link per peer and the
|
||||
second band stands by, re-establishing after keepalive timeout. The shipped
|
||||
`fips.yaml` carries the `mesh0`/`mesh1` Ethernet-transport entries commented
|
||||
out, so a stock install that never creates them logs no per-boot
|
||||
interface-missing warning; the helper uncomments the matching block when it
|
||||
creates the interface and re-comments it on remove (#123).
|
||||
|
||||
- OpenWrt open `!FIPS` access SSID, stacked on the mesh backhaul above: every
|
||||
FIPS router broadcasts the same open SSID, forming one standard ESS that
|
||||
phones and laptops save once and roam between natively, with the Noise
|
||||
handshake as the only security layer. The leading `!` sorts it to the top of
|
||||
alphabetically ordered network pickers, and the encryption type must be
|
||||
uniform across routers or clients treat the ESS as different saved networks.
|
||||
`fips-ap-setup` is an opt-in UCI helper creating the `fips-ap0` open AP on an
|
||||
isolated network with a static ULA /64 and RA-only odhcpd addressing —
|
||||
stateless SLAAC with no DHCP, the minimum that satisfies Android's
|
||||
provisioning check — behind a locked-down `fips_ap` firewall zone with no
|
||||
path to `br-lan` or the WAN, reaching only ICMPv6, mDNS and the FIPS
|
||||
transports. There is no internet by design, so phones keep cellular as their
|
||||
default route (#126).
|
||||
|
||||
- Android-ready core: the daemon's desktop transports and TUN operations are
|
||||
gated by `target_os` rather than by Cargo features, so a plain `cargo build`
|
||||
compiles for every target with no flags and Android self-excludes the raw
|
||||
Ethernet transport as Windows already did. `Node::enable_app_owned_tun()`
|
||||
gives an embedder that owns the TUN file descriptor — an Android
|
||||
`VpnService`, for instance — a channel pair for exchanging IPv6 packet bytes
|
||||
with FIPS instead of FIPS creating a system TUN device, and `start()` then
|
||||
performs no system-TUN or `CAP_NET_ADMIN` operations. Packets entering this
|
||||
way bypass `handle_tun_packet`, so the embedder must push only
|
||||
`fd00::/8`-destined packets and clamp TCP MSS on outbound SYNs. Desktop
|
||||
builds are unchanged and no Cargo features are introduced.
|
||||
|
||||
- A bounded graceful-shutdown drain phase, controlled by the new
|
||||
`node.drain_timeout_secs` (default 2s). On the shutdown signal the node
|
||||
broadcasts Disconnect to all peers and then keeps serving for that window,
|
||||
exiting early once all peers are gone, so in-flight traffic settles and peers
|
||||
observe the disconnect before the transports close, where previously teardown
|
||||
was immediate. The published node state gains a `Draining` variant visible
|
||||
via control queries during the window. The immediate stop path used by
|
||||
non-daemon callers is unchanged.
|
||||
|
||||
- FreeBSD support for the daemon, `fipsctl`, and `fipstop`: native TUN
|
||||
datapath (TUNSIFHEAD address-family framing, kernel-assigned `tunN`
|
||||
device name as with `utun` on macOS), clean service teardown,
|
||||
@@ -165,6 +216,30 @@ with v0.4.x or earlier peers.
|
||||
|
||||
### Changed
|
||||
|
||||
- Node health is determined at start completion instead of unconditionally
|
||||
reaching a single running state. **Zero transports up is now fatal**: the
|
||||
node tears down cleanly and the daemon exits with an error, where it
|
||||
previously came up and served nothing. Any configured optional child that
|
||||
failed to start — a transport beyond the first, Nostr, mDNS, TUN, DNS, or a
|
||||
worker pool — leaves the node degraded but serving, with a warning naming
|
||||
what failed, and all configured children up is full health. A child the node
|
||||
was never asked to run does not count against it. The published node state
|
||||
gains `Degraded` and `Failed`, both visible via control queries, with
|
||||
degraded operational and failed not. Exit detection for the DNS task, the two
|
||||
TUN threads, mDNS and Nostr also re-evaluates health at runtime, so a child
|
||||
that dies after a healthy start now shows as degraded; transports and worker
|
||||
pools expose no runtime-exit signal yet and are unchanged.
|
||||
|
||||
- A connected UDP socket that cannot open now names the syscall that failed and
|
||||
the address it was operating on, the local address for `bind` and the peer
|
||||
address for `connect`. Both paths previously returned a bare OS error that
|
||||
the caller wrapped identically, so a field report of `Address already in use`
|
||||
could not be attributed to either, and the two have entirely different
|
||||
causes: on Linux a UDP `connect(2)` to a 4-tuple another socket already holds
|
||||
returns `EADDRINUSE`, which is not the same fault as `bind` refusing the
|
||||
local address. A node at roughly 245 peers was emitting this three times a
|
||||
second across nine peers with no way to diagnose it.
|
||||
|
||||
- Connected UDP peer drains now batch macOS receives with `recvmsg_x(2)`,
|
||||
matching the wildcard UDP receive path instead of issuing one `recv(2)`
|
||||
syscall per queued datagram.
|
||||
@@ -227,6 +302,53 @@ with v0.4.x or earlier peers.
|
||||
folded into the new tables with a one-time deprecation warning; migrate your
|
||||
`fips.yaml` to the new keys.
|
||||
|
||||
- Config validation now rejects two `node.rekey` settings that appear to
|
||||
disable the trigger and in fact fire it continuously. `after_messages` of
|
||||
zero makes the message-count arm true on every poll, because the trigger
|
||||
compares the counter with greater-or-equal. `after_secs` at or below the
|
||||
per-session jitter bound is the same trap on the timer arm: each session
|
||||
offsets the interval by a random value within plus or minus that bound, so a
|
||||
smaller interval saturates to zero on a negative draw and rekeys on sight,
|
||||
for roughly half of sessions. Both are checked whether or not rekey is
|
||||
enabled, so switching it on later cannot surface the error at a surprising
|
||||
moment, and neither gains an upper bound — a very large value remains the
|
||||
supported way to disable one arm. A config carrying either setting now fails
|
||||
to load instead of starting a node that rekeys constantly.
|
||||
|
||||
- Peer bloom filters are computed for every recipient in one prefix and suffix
|
||||
union sweep rather than rebuilt per recipient. Announcing to R peers
|
||||
previously did R full map builds and R by T merges; at 240 peers that was
|
||||
20.6 ms per tick, roughly half the tick body, with a median per-interval
|
||||
maximum of 34.5 ms. The result is exactly equal rather than approximately:
|
||||
merging is a bytewise OR, so regrouping the unions cannot change it. The
|
||||
trade-off, measured rather than assumed, is that the sweep does its full work
|
||||
regardless of how many peers are ready, so a tick announcing to one or two
|
||||
peers now costs about twice what it did; break-even is around three ready
|
||||
peers. Cadence, the debounce, the sequence rule and the fill-ratio cap are
|
||||
unchanged.
|
||||
|
||||
- Each peer's npub is derived once at construction instead of once per tick.
|
||||
The per-tick stats snapshot ran a bech32 encode for every tracked peer, and a
|
||||
second one for the common peer with no hosts-file entry and no alias, since
|
||||
the display-name fallback bottoms out in the same encode: 14.1 ms per tick at
|
||||
240 peers. The display name itself is deliberately not cached, because the
|
||||
alias map and the host map both mutate at runtime.
|
||||
|
||||
- The peer-retry tick no longer awaits the Nostr advert refetch. It ran inline
|
||||
on the 1-second rx-loop tick, awaiting a fetch with a 2-second timeout for
|
||||
each due peer and discarding the result; with up to sixteen due peers the
|
||||
timeouts stacked, and field profiling measured single 2.00 s stalls as the
|
||||
common case and a worst tick of 12.4 s against a 1 s period, delaying every
|
||||
other rx-loop arm by as much as 4.2 s. The refetch is now spawned, so a dial
|
||||
uses the advert cached at that moment and the refreshed one lands for that
|
||||
peer's next retry.
|
||||
|
||||
- The `Adopted NAT traversal socket` log line now carries the transport id and
|
||||
the local address alongside the peer npub. Without the local address an
|
||||
operator cannot join a host socket table against adoption events, and without
|
||||
the transport id several peers sharing one adopted transport are
|
||||
indistinguishable from several separate adopted transports.
|
||||
|
||||
- Inbound msg1 is classified before it is rate limited, and rekey or restart
|
||||
msg1 arriving on an established link now draws on its own token bucket
|
||||
instead of competing with stranger admission for a single shared one. On a
|
||||
@@ -257,6 +379,38 @@ with v0.4.x or earlier peers.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Nostr NAT traversal signals are now sent only to relays the client pool
|
||||
actually holds. A signal is addressed to the merge of the peer's NIP-17 inbox
|
||||
relays, the relays its advert nominates for signaling, and our own DM relays,
|
||||
but the pool is built once at startup from the configured relays and the send
|
||||
is rejected outright, before anything is contacted, if any single URL in that
|
||||
list is outside it. One unconfigured relay anywhere in the merge therefore
|
||||
killed the whole attempt, including the sends to relays both sides shared. On
|
||||
a public node in open mode this made discovery non-functional: 309 traversal
|
||||
attempts, 290 explicit failures, zero successes, every failure on `relay not
|
||||
found`. Configured peers were unaffected, since they run a matching relay
|
||||
set. Comparison is on the normalized relay URL rather than the raw string, so
|
||||
a configured relay spelled with a trailing slash or different host case is
|
||||
not discarded. Two smaller fixes ride along: the responder resolves its
|
||||
relays before binding a socket and running STUN, rather than spending a STUN
|
||||
round trip and holding an offer slot only to find it has nowhere to answer,
|
||||
and it gained the empty-relay-list guard the initiator already had.
|
||||
|
||||
- A failed log write can no longer panic the thread or task that logged. The
|
||||
subscriber was built with the default internal-error reporting, which sends a
|
||||
failed write to `eprintln!`, and that macro panics when stderr has also
|
||||
failed. The shipped supervisor configurations make that a single condition
|
||||
rather than two: the macOS plist points both standard streams at one
|
||||
unrotated file, and the systemd units route both to journald, so one full
|
||||
disk fails both sinks together. In the daemon a crypto worker was the case
|
||||
that mattered — it logs a warning on send backpressure, and a worker that
|
||||
dies takes its share of the peer space with it permanently, while the panic
|
||||
message is discarded along the same broken path. In `fips-gateway`, which
|
||||
built its subscriber the same way, the casualty is a spawned task: the DNS
|
||||
resolver, the control accept loop or the pool tick, none of which is observed
|
||||
until shutdown, so the process would keep running and reporting healthy with
|
||||
mesh name resolution or lease expiry and NAT cleanup silently stopped.
|
||||
|
||||
- macOS: `peers.allow`, `peers.deny`, and the `hosts` file are now read
|
||||
from `/usr/local/etc/fips/`, matching the install layout the macOS
|
||||
packaging ships (`packaging/macos/`). The default-path constants were
|
||||
@@ -380,6 +534,28 @@ with v0.4.x or earlier peers.
|
||||
counter now charges at the node that makes the decision rather than at the
|
||||
hop after it.
|
||||
|
||||
### Security
|
||||
|
||||
- The FSP session address is now bound to the peer key the Noise handshake
|
||||
authenticated, on both the initial and the rekey path. The responder recorded
|
||||
a session under the source address carried in the datagram without ever
|
||||
checking that address against the static key it had just authenticated, so a
|
||||
peer could complete a genuine handshake while claiming another node's
|
||||
address, and the identity cache, the session map and the address the IPv6
|
||||
shim reconstructs on delivery would all attribute its traffic to the node it
|
||||
named. The address is now derived from the authenticated key at the point it
|
||||
first becomes available in msg3, and a mismatch drops the half-open session
|
||||
without recording either the identity or the session. The rekey responder
|
||||
needed its own check: it returns before that code is reached and never read
|
||||
the peer's static key at all, so a rekey could complete under an established
|
||||
session with a different key than the one that opened it. It now requires the
|
||||
key to be unchanged and abandons the rekey while leaving the existing session
|
||||
intact, rather than tearing the session down, which would have handed an
|
||||
attacker a way to kill established sessions. Both comparisons are on x-only
|
||||
keys, because a stored key may carry a synthesized parity while the handshake
|
||||
learns the true point. The two rejections are counted separately in the
|
||||
session reject statistics.
|
||||
|
||||
## [0.4.1] - 2026-07-19
|
||||
|
||||
### Changed
|
||||
|
||||
+10
-1
@@ -67,7 +67,16 @@ async fn main() {
|
||||
)
|
||||
.from_env_lossy();
|
||||
|
||||
fmt().with_env_filter(filter).with_target(true).init();
|
||||
// As in the daemon: a failed log write must not panic whoever logged. The
|
||||
// default reports write failures with `eprintln!`, which panics when stderr
|
||||
// fails too, and both units send stdout and stderr to journald. Here the
|
||||
// casualty is a spawned task — the DNS resolver or the pool tick — whose
|
||||
// handle nothing observes until shutdown.
|
||||
fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(true)
|
||||
.log_internal_errors(false)
|
||||
.init();
|
||||
|
||||
info!("fips-gateway {} starting", version::short_version());
|
||||
|
||||
|
||||
@@ -101,10 +101,17 @@ async fn run_daemon(
|
||||
|
||||
// ANSI color only when stdout is a terminal — under a supervisor
|
||||
// (daemon(8), systemd) escape codes would litter the log file.
|
||||
//
|
||||
// Never let a failed log write panic the thread that logged. The default
|
||||
// is to report a write failure with `eprintln!`, which itself panics when
|
||||
// stderr fails too — and the shipped supervisor configs point stdout and
|
||||
// stderr at the same place, so one full disk satisfies both. A worker
|
||||
// thread killed that way takes its share of the peer space with it.
|
||||
fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(true)
|
||||
.with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout()))
|
||||
.log_internal_errors(false)
|
||||
.init();
|
||||
|
||||
info!("FIPS {} starting", version::short_version());
|
||||
|
||||
@@ -49,6 +49,10 @@ fn test_routing_unknown_destination() {
|
||||
|
||||
// === Bloom filter priority ===
|
||||
|
||||
/// Scope note: this covers the bloom *ordering* path, not the bloom predicate.
|
||||
/// The chosen peer here is also the greedy tree winner, so the assertion holds
|
||||
/// whichever branch produced it. See the `Seam: NodeRoutingView adapter`
|
||||
/// section at the end of this file for the discriminating tests.
|
||||
#[test]
|
||||
fn test_routing_bloom_filter_hit() {
|
||||
let mut node = make_node();
|
||||
@@ -102,6 +106,9 @@ fn test_routing_bloom_filter_hit() {
|
||||
assert_ne!(result.unwrap().node_addr(), &peer2_addr);
|
||||
}
|
||||
|
||||
/// Scope note: as above, this pins the tie-break ordering rather than the bloom
|
||||
/// predicate — the inline comment below already notes that the self-distance
|
||||
/// check is what does the filtering here.
|
||||
#[test]
|
||||
fn test_routing_bloom_filter_multiple_hits_tiebreak() {
|
||||
let mut node = make_node();
|
||||
@@ -207,6 +214,13 @@ fn test_routing_tree_fallback() {
|
||||
///
|
||||
/// Post-fix behavior: same scenario falls through to greedy tree routing
|
||||
/// and returns the tree-routing-selected next hop.
|
||||
///
|
||||
/// Scope note: despite the name, this does **not** discriminate the bloom
|
||||
/// predicate. Under a `NodeRoutingView::peer_may_reach` that returns `true`
|
||||
/// unconditionally the healthy and broken runs return the same peer by
|
||||
/// different branches, and `find_next_hop` exposes no branch information, so
|
||||
/// no assertion here can tell them apart. The test that does discriminate it
|
||||
/// is `test_seam_bloom_hit_overrides_tree_tiebreak`.
|
||||
#[test]
|
||||
fn test_routing_bloom_hit_not_closer_falls_through_to_tree() {
|
||||
let mut node = make_node();
|
||||
@@ -908,6 +922,14 @@ async fn test_routing_stops_after_peer_removal() {
|
||||
///
|
||||
/// Chain: 0 -- 1 -- 2 -- 3. Only node 0 has node 3's coords cached.
|
||||
/// Nodes 1 and 2 route using bloom filters only.
|
||||
///
|
||||
/// Scope note: despite the name, what this actually pins is that transit
|
||||
/// requires cached coordinates at each hop and that the last hop short-circuits
|
||||
/// on direct peering. It does not discriminate the bloom predicate: the node-1
|
||||
/// and node-2 assertions return from `find_next_hop` before the routing view is
|
||||
/// ever constructed, and node 0 has exactly one peer, so its assertion cannot
|
||||
/// separate any two candidates. See the `Seam: NodeRoutingView adapter` section
|
||||
/// at the end of this file for the tests that do.
|
||||
#[tokio::test]
|
||||
async fn test_routing_bloom_only_transit() {
|
||||
let edges = vec![(0, 1), (1, 2), (2, 3)];
|
||||
@@ -1442,3 +1464,533 @@ fn test_parent_loss_selfroot_invalidates_coord_cache() {
|
||||
"stale old-root entry must be invalidated after self-root"
|
||||
);
|
||||
}
|
||||
|
||||
// === Seam: NodeRoutingView adapter ===
|
||||
//
|
||||
// The tests above exercise the routing *decision*; the ones below exercise the
|
||||
// *adapter* that feeds it. `NodeRoutingView` (src/node/mod.rs) is the only
|
||||
// thing connecting the node's live peer map to the sans-IO routing core, and
|
||||
// the core's own tests in src/proto/routing/tests/ drive a mock view, so
|
||||
// nothing else in the suite observes the real adapter.
|
||||
//
|
||||
// Why no pre-existing test discriminates it: in every other fixture in this
|
||||
// file the bloom winner and the greedy-tree winner are the same peer, which
|
||||
// makes branch 3 and branch 4 of `find_next_hop` indistinguishable — the route
|
||||
// is unchanged whichever branch produced it. The fixtures below break that
|
||||
// collinearity deliberately, so the answer each one asserts is a peer the tree
|
||||
// fallback would *not* have chosen. A broken adapter changes the answer rather
|
||||
// than merely changing the path to it.
|
||||
//
|
||||
// Every assertion pins a peer identity rather than `is_some()`, and every route
|
||||
// goes through the real `Node::find_next_hop`, which constructs the real
|
||||
// `NodeRoutingView` internally and cannot be handed a mock.
|
||||
|
||||
fn seam_now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn seam_add_peer(node: &mut Node, link_num: u64, transport_id: TransportId) -> NodeAddr {
|
||||
let link_id = LinkId::new(link_num);
|
||||
let identity = seed_completed_connection(node, link_id, transport_id, 1000);
|
||||
let addr = *identity.node_addr();
|
||||
node.promote_connection(link_id, identity, 2000).unwrap();
|
||||
addr
|
||||
}
|
||||
|
||||
/// Put `dest` into `peer`'s inbound bloom filter.
|
||||
fn seam_set_filter(node: &mut Node, peer: &NodeAddr, dest: &NodeAddr) {
|
||||
let mut filter = BloomFilter::new();
|
||||
filter.insert(dest);
|
||||
node.get_peer_mut(peer)
|
||||
.unwrap()
|
||||
.update_filter(filter, 1, 3000);
|
||||
}
|
||||
|
||||
/// Set a peer's link cost inputs directly. `etx * (1.0 + srtt_ms / 100.0)`.
|
||||
fn seam_set_cost(node: &mut Node, peer: &NodeAddr, etx: f64, rtt_us: i64) {
|
||||
let mmp = node.get_peer_mut(peer).unwrap().mmp_mut().unwrap();
|
||||
mmp.metrics.etx = etx;
|
||||
mmp.metrics.srtt.update(rtt_us);
|
||||
}
|
||||
|
||||
/// Two peers *equidistant* from `dest`, both strictly closer than we are.
|
||||
///
|
||||
/// Tree (we are root): my ── near ── dest ── far
|
||||
///
|
||||
/// `far` is also a direct peer of ours: a mesh link that is not a tree edge.
|
||||
/// Distances to dest are my = 2, near = 1, far = 1, so both peers clear the
|
||||
/// self-distance check at the same distance and the same link cost. The only
|
||||
/// thing separating them is the address tie-break, which is what lets a test
|
||||
/// make one of the adapter's predicates pick the peer the tree would have
|
||||
/// rejected — and then the two branches finally disagree.
|
||||
///
|
||||
/// Peer addresses come from `Identity::generate()` and are random, so callers
|
||||
/// assign roles at runtime with `near.min(far)` / `near.max(far)` rather than
|
||||
/// assuming which topological role drew the smaller key.
|
||||
///
|
||||
/// Returns `(near, far, dest)`.
|
||||
fn seam_two_equidistant_peers(node: &mut Node) -> (NodeAddr, NodeAddr, NodeAddr) {
|
||||
let transport_id = TransportId::new(1);
|
||||
let my_addr = *node.node_addr();
|
||||
|
||||
let near = seam_add_peer(node, 1, transport_id);
|
||||
let far = seam_add_peer(node, 2, transport_id);
|
||||
let dest = make_node_addr(99);
|
||||
|
||||
let near_coords = TreeCoordinate::from_addrs(vec![near, my_addr]).unwrap();
|
||||
node.tree_state_mut()
|
||||
.update_peer(ParentDeclaration::new(near, my_addr, 1, 1000), near_coords);
|
||||
|
||||
let far_coords = TreeCoordinate::from_addrs(vec![far, dest, near, my_addr]).unwrap();
|
||||
node.tree_state_mut()
|
||||
.update_peer(ParentDeclaration::new(far, dest, 3, 1000), far_coords);
|
||||
|
||||
let dest_coords = TreeCoordinate::from_addrs(vec![dest, near, my_addr]).unwrap();
|
||||
node.coord_cache_mut()
|
||||
.insert(dest, dest_coords, seam_now_ms());
|
||||
|
||||
(near, far, dest)
|
||||
}
|
||||
|
||||
/// Three peers at *distinct* distances from `dest`, strung along one tree path.
|
||||
///
|
||||
/// Tree (we are root): my ── rung3 ── rung2 ── rung1 ── dest
|
||||
///
|
||||
/// All three rungs are also direct peers of ours. Distances to dest are
|
||||
/// my = 4, rung1 = 1, rung2 = 2, rung3 = 3. Putting the bloom hits on
|
||||
/// `{rung2, rung3}` makes the winner depend on the coordinate *values* the
|
||||
/// adapter hands back rather than merely on whether it hands back any: rung2
|
||||
/// wins on distance from inside the bloom set, while the tree fallback would
|
||||
/// have answered rung1. Link costs are all 1.0 and the three distances differ,
|
||||
/// so the address tie-break never engages and the answer does not depend on
|
||||
/// which peer drew the smaller random key.
|
||||
///
|
||||
/// Returns `(rung1, rung2, rung3, dest)`.
|
||||
fn seam_distance_ladder(node: &mut Node) -> (NodeAddr, NodeAddr, NodeAddr, NodeAddr) {
|
||||
let transport_id = TransportId::new(1);
|
||||
let my_addr = *node.node_addr();
|
||||
|
||||
let rung1 = seam_add_peer(node, 1, transport_id);
|
||||
let rung2 = seam_add_peer(node, 2, transport_id);
|
||||
let rung3 = seam_add_peer(node, 3, transport_id);
|
||||
let dest = make_node_addr(99);
|
||||
|
||||
let rung3_coords = TreeCoordinate::from_addrs(vec![rung3, my_addr]).unwrap();
|
||||
node.tree_state_mut().update_peer(
|
||||
ParentDeclaration::new(rung3, my_addr, 1, 1000),
|
||||
rung3_coords,
|
||||
);
|
||||
|
||||
let rung2_coords = TreeCoordinate::from_addrs(vec![rung2, rung3, my_addr]).unwrap();
|
||||
node.tree_state_mut()
|
||||
.update_peer(ParentDeclaration::new(rung2, rung3, 2, 1000), rung2_coords);
|
||||
|
||||
let rung1_coords = TreeCoordinate::from_addrs(vec![rung1, rung2, rung3, my_addr]).unwrap();
|
||||
node.tree_state_mut()
|
||||
.update_peer(ParentDeclaration::new(rung1, rung2, 3, 1000), rung1_coords);
|
||||
|
||||
let dest_coords = TreeCoordinate::from_addrs(vec![dest, rung1, rung2, rung3, my_addr]).unwrap();
|
||||
node.coord_cache_mut()
|
||||
.insert(dest, dest_coords, seam_now_ms());
|
||||
|
||||
(rung1, rung2, rung3, dest)
|
||||
}
|
||||
|
||||
/// Guard on the two seam fixtures rather than on the seam itself.
|
||||
///
|
||||
/// Every seam test below asserts an answer that only holds while the fixture's
|
||||
/// distances are what its comment claims. If tree-coordinate or distance
|
||||
/// semantics ever change so that the fixtures stop discriminating, those tests
|
||||
/// would go on passing while observing nothing — the exact blindness they exist
|
||||
/// to remove. This one fails first and says which fixture collapsed.
|
||||
#[test]
|
||||
fn test_seam_fixture_distances_are_as_documented() {
|
||||
let mut node = make_node();
|
||||
let my_addr = *node.node_addr();
|
||||
let (near, far, dest) = seam_two_equidistant_peers(&mut node);
|
||||
|
||||
let dest_coords = TreeCoordinate::from_addrs(vec![dest, near, my_addr]).unwrap();
|
||||
let ts = node.tree_state();
|
||||
assert_eq!(ts.my_coords().distance_to(&dest_coords), 2, "my distance");
|
||||
assert_eq!(
|
||||
ts.peer_coords(&near).unwrap().distance_to(&dest_coords),
|
||||
1,
|
||||
"near distance"
|
||||
);
|
||||
assert_eq!(
|
||||
ts.peer_coords(&far).unwrap().distance_to(&dest_coords),
|
||||
1,
|
||||
"far distance"
|
||||
);
|
||||
|
||||
// Both peers equidistant and equally cheap, so greedy tree routing resolves
|
||||
// the tie on address. The equidistant-fixture tests below each assert the
|
||||
// *other* peer; this is the answer they are contradicting.
|
||||
let tree_pick = ts.find_next_hop(&dest_coords, &BTreeSet::new()).unwrap();
|
||||
assert_eq!(
|
||||
tree_pick,
|
||||
near.min(far),
|
||||
"greedy tree tie-break is min addr"
|
||||
);
|
||||
|
||||
let mut node = make_node();
|
||||
let my_addr = *node.node_addr();
|
||||
let (rung1, rung2, rung3, dest) = seam_distance_ladder(&mut node);
|
||||
let dest_coords = TreeCoordinate::from_addrs(vec![dest, rung1, rung2, rung3, my_addr]).unwrap();
|
||||
let ts = node.tree_state();
|
||||
assert_eq!(ts.my_coords().distance_to(&dest_coords), 4, "my distance");
|
||||
assert_eq!(
|
||||
ts.peer_coords(&rung1).unwrap().distance_to(&dest_coords),
|
||||
1,
|
||||
"rung1 distance"
|
||||
);
|
||||
assert_eq!(
|
||||
ts.peer_coords(&rung2).unwrap().distance_to(&dest_coords),
|
||||
2,
|
||||
"rung2 distance"
|
||||
);
|
||||
assert_eq!(
|
||||
ts.peer_coords(&rung3).unwrap().distance_to(&dest_coords),
|
||||
3,
|
||||
"rung3 distance"
|
||||
);
|
||||
let tree_pick = ts.find_next_hop(&dest_coords, &BTreeSet::new()).unwrap();
|
||||
assert_eq!(tree_pick, rung1, "greedy tree picks the nearest rung");
|
||||
}
|
||||
|
||||
/// `NodeRoutingView::peer_may_reach` decides the bloom candidate set.
|
||||
///
|
||||
/// The bloom hit is placed on the peer the greedy tree tie-break would have
|
||||
/// *rejected*, so the only path to the asserted answer is branch 3 of
|
||||
/// `find_next_hop` selecting it on that hit. Widening `peer_may_reach` to
|
||||
/// `true` — bloom filtering off, the break that motivated this whole section —
|
||||
/// or inverting it collapses the answer back to the tree pick.
|
||||
#[test]
|
||||
fn test_seam_bloom_hit_overrides_tree_tiebreak() {
|
||||
let mut node = make_node();
|
||||
let (near, far, dest) = seam_two_equidistant_peers(&mut node);
|
||||
let tree_pick = near.min(far);
|
||||
let bloom_pick = near.max(far);
|
||||
|
||||
// Fixture preconditions, so a red below reads as a seam regression rather
|
||||
// than as the fixture having collapsed.
|
||||
assert_eq!(node.peers.len(), 2, "fixture: two peers");
|
||||
assert!(node.tree_state().peer_coords(&near).is_some());
|
||||
assert!(node.tree_state().peer_coords(&far).is_some());
|
||||
|
||||
seam_set_filter(&mut node, &bloom_pick, &dest);
|
||||
assert!(!node.get_peer(&tree_pick).unwrap().may_reach(&dest));
|
||||
|
||||
let hop = node.find_next_hop(&dest).expect("route exists");
|
||||
assert_eq!(
|
||||
hop.node_addr(),
|
||||
&bloom_pick,
|
||||
"the bloom candidate must beat the greedy tree tie-break winner {tree_pick:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `NodeRoutingView::peer_can_send` keeps a down link out of the candidate set.
|
||||
///
|
||||
/// Both peers hold a bloom hit, so the address tie-break would hand the route
|
||||
/// to the low-address peer; that peer is the one marked reconnecting. Widening
|
||||
/// `peer_can_send` to `true` lets it back in and hands a down link to the
|
||||
/// forwarder.
|
||||
#[test]
|
||||
fn test_seam_unsendable_bloom_candidate_is_skipped() {
|
||||
let mut node = make_node();
|
||||
let (near, far, dest) = seam_two_equidistant_peers(&mut node);
|
||||
let low = near.min(far);
|
||||
let high = near.max(far);
|
||||
|
||||
seam_set_filter(&mut node, &low, &dest);
|
||||
seam_set_filter(&mut node, &high, &dest);
|
||||
node.get_peer_mut(&low).unwrap().mark_reconnecting();
|
||||
|
||||
assert_eq!(node.peers.len(), 2, "fixture: two peers");
|
||||
assert!(
|
||||
!node.get_peer(&low).unwrap().can_send(),
|
||||
"fixture: low is down"
|
||||
);
|
||||
assert!(
|
||||
node.get_peer(&high).unwrap().can_send(),
|
||||
"fixture: high is up"
|
||||
);
|
||||
|
||||
let hop = node.find_next_hop(&dest).expect("route exists");
|
||||
assert!(hop.can_send(), "a down link must never be returned");
|
||||
assert_eq!(
|
||||
hop.node_addr(),
|
||||
&high,
|
||||
"the sendable peer must win despite losing the address tie-break"
|
||||
);
|
||||
}
|
||||
|
||||
/// `NodeRoutingView::peer_link_cost` must carry the ETX factor.
|
||||
///
|
||||
/// SRTT is equal on both peers, so ETX is the only thing that can order them,
|
||||
/// and the cheap peer is the one that loses the address tie-break. This reds
|
||||
/// for a flattened cost, a negated cost, and — the shape a rewrite of the
|
||||
/// adapter actually produces — a cost that reads only the latency half of
|
||||
/// `etx * (1.0 + srtt_ms / 100.0)`.
|
||||
#[test]
|
||||
fn test_seam_link_cost_etx_orders_bloom_candidates() {
|
||||
let mut node = make_node();
|
||||
let (near, far, dest) = seam_two_equidistant_peers(&mut node);
|
||||
let low = near.min(far);
|
||||
let high = near.max(far);
|
||||
|
||||
seam_set_filter(&mut node, &low, &dest);
|
||||
seam_set_filter(&mut node, &high, &dest);
|
||||
seam_set_cost(&mut node, &low, 3.0, 1_000);
|
||||
seam_set_cost(&mut node, &high, 1.0, 1_000);
|
||||
|
||||
let cost_low = node.get_peer(&low).unwrap().link_cost();
|
||||
let cost_high = node.get_peer(&high).unwrap().link_cost();
|
||||
assert!(
|
||||
cost_high < cost_low,
|
||||
"fixture: ETX alone must make high cheaper ({cost_high} vs {cost_low})"
|
||||
);
|
||||
|
||||
let hop = node.find_next_hop(&dest).expect("route exists");
|
||||
assert_eq!(
|
||||
hop.node_addr(),
|
||||
&high,
|
||||
"the lower-ETX link must win despite losing the address tie-break"
|
||||
);
|
||||
}
|
||||
|
||||
/// `NodeRoutingView::peer_link_cost` must carry the SRTT factor.
|
||||
///
|
||||
/// The mirror of the ETX test: ETX is equal on both peers, so latency is the
|
||||
/// only thing that can order them. This reds for a flattened cost, a negated
|
||||
/// cost, and a cost that reads only the ETX half.
|
||||
#[test]
|
||||
fn test_seam_link_cost_srtt_orders_bloom_candidates() {
|
||||
let mut node = make_node();
|
||||
let (near, far, dest) = seam_two_equidistant_peers(&mut node);
|
||||
let low = near.min(far);
|
||||
let high = near.max(far);
|
||||
|
||||
seam_set_filter(&mut node, &low, &dest);
|
||||
seam_set_filter(&mut node, &high, &dest);
|
||||
seam_set_cost(&mut node, &low, 1.0, 50_000); // 50 ms -> cost 1.5
|
||||
seam_set_cost(&mut node, &high, 1.0, 1_000); // 1 ms -> cost 1.01
|
||||
|
||||
let cost_low = node.get_peer(&low).unwrap().link_cost();
|
||||
let cost_high = node.get_peer(&high).unwrap().link_cost();
|
||||
assert!(
|
||||
cost_high < cost_low,
|
||||
"fixture: SRTT alone must make high cheaper ({cost_high} vs {cost_low})"
|
||||
);
|
||||
|
||||
let hop = node.find_next_hop(&dest).expect("route exists");
|
||||
assert_eq!(
|
||||
hop.node_addr(),
|
||||
&high,
|
||||
"the lower-latency link must win despite losing the address tie-break"
|
||||
);
|
||||
}
|
||||
|
||||
/// `NodeRoutingView::peer_coords` must return each peer's *own* coordinates.
|
||||
///
|
||||
/// The ladder puts the two bloom candidates at *different* distances, so the
|
||||
/// winner is decided by the coordinate values the adapter returns and not
|
||||
/// merely by whether it returns any: rung2 (distance 2) beats rung3
|
||||
/// (distance 3) from inside the bloom set, while the tree fallback would have
|
||||
/// answered rung1 (distance 1). Dropping the lookup to `None`, or sourcing it
|
||||
/// from the wrong object (our own coordinates rather than the peer's), moves
|
||||
/// the answer to rung1.
|
||||
#[test]
|
||||
fn test_seam_peer_coords_distance_orders_bloom_candidates() {
|
||||
let mut node = make_node();
|
||||
let (rung1, rung2, rung3, dest) = seam_distance_ladder(&mut node);
|
||||
|
||||
// rung1 is the greedy tree winner and deliberately carries no bloom hit.
|
||||
seam_set_filter(&mut node, &rung2, &dest);
|
||||
seam_set_filter(&mut node, &rung3, &dest);
|
||||
|
||||
assert_eq!(node.peers.len(), 3, "fixture: three peers");
|
||||
assert!(
|
||||
!node.get_peer(&rung1).unwrap().may_reach(&dest),
|
||||
"fixture: the tree winner is not a bloom candidate"
|
||||
);
|
||||
|
||||
let hop = node.find_next_hop(&dest).expect("route exists");
|
||||
assert_eq!(
|
||||
hop.node_addr(),
|
||||
&rung2,
|
||||
"the nearer bloom candidate must win: rung1 {rung1:?} is the tree \
|
||||
answer, rung3 {rung3:?} is the farther candidate"
|
||||
);
|
||||
}
|
||||
|
||||
/// A peer with no tree coordinates is infinitely far and can never be chosen.
|
||||
///
|
||||
/// A peer that has completed a handshake but whose tree declaration has not yet
|
||||
/// arrived is an ordinary runtime state, and it is the state the core maps to
|
||||
/// `usize::MAX` in its self-distance check. This exercises the adapter's `None`
|
||||
/// arm and reds if the adapter ever fabricates coordinates for a peer the tree
|
||||
/// does not know — a fallback to some other peer's coordinates would make this
|
||||
/// peer the sole viable bloom candidate and hand it the route.
|
||||
#[test]
|
||||
fn test_seam_peer_without_tree_coords_is_never_selected() {
|
||||
let mut node = make_node();
|
||||
let (near, far, dest) = seam_two_equidistant_peers(&mut node);
|
||||
let tree_pick = near.min(far);
|
||||
|
||||
// A third peer: in the peer map, sendable, holding a bloom hit for dest,
|
||||
// and absent from tree state.
|
||||
let ghost = seam_add_peer(&mut node, 3, TransportId::new(1));
|
||||
seam_set_filter(&mut node, &ghost, &dest);
|
||||
|
||||
assert!(node.get_peer(&ghost).unwrap().can_send());
|
||||
assert!(node.get_peer(&ghost).unwrap().may_reach(&dest));
|
||||
assert!(
|
||||
node.tree_state().peer_coords(&ghost).is_none(),
|
||||
"fixture: ghost has no tree coordinates"
|
||||
);
|
||||
|
||||
let hop = node.find_next_hop(&dest).expect("route exists");
|
||||
assert_ne!(
|
||||
hop.node_addr(),
|
||||
&ghost,
|
||||
"a peer with no tree coordinates must never be selected"
|
||||
);
|
||||
assert_eq!(
|
||||
hop.node_addr(),
|
||||
&tree_pick,
|
||||
"with no viable bloom candidate the tree fallback answers"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every `NodeRoutingView` read must report the live node state it adapts.
|
||||
///
|
||||
/// The tests above cover the reads that change a routing decision. This one
|
||||
/// covers the residue: reads whose corruption has no behavioural consequence
|
||||
/// through `find_next_hop` (a peer enumerated twice cannot displace itself,
|
||||
/// since the core's dominance test is a strict-improvement check) and reads
|
||||
/// `find_next_hop` never performs at all (`is_congested` and `cached_coords`,
|
||||
/// which are reached from the datagram forwarding path).
|
||||
///
|
||||
/// Assertions are against values this test *set*, not against a second call to
|
||||
/// the same production getter the adapter itself calls: a mirror comparison of
|
||||
/// the form `view.x(p) == peer.x()` reduces to `f(x) == f(x)` and is blind to
|
||||
/// anything the two sides share. The parity loop at the end is the secondary
|
||||
/// check, not the primary one.
|
||||
#[test]
|
||||
fn test_seam_routing_view_reads_match_live_peer_state() {
|
||||
use crate::proto::routing::RoutingView;
|
||||
|
||||
let mut node = make_node();
|
||||
let my_addr = *node.node_addr();
|
||||
let (near, far, dest) = seam_two_equidistant_peers(&mut node);
|
||||
|
||||
// Make the two peers differ on every predicate, in opposite directions, so
|
||||
// no constant in either direction can satisfy the assertions below.
|
||||
seam_set_filter(&mut node, &near, &dest);
|
||||
node.get_peer_mut(&far).unwrap().mark_reconnecting();
|
||||
// Both cost factors off the multiplicative identity: with etx pinned at 1.0
|
||||
// a cost that reads only the latency half is indistinguishable from the
|
||||
// real one, and this assertion would be blind to it.
|
||||
seam_set_cost(&mut node, &near, 2.0, 50_000); // 2.0 * (1.0 + 0.5) -> 3.0
|
||||
|
||||
// A short-TTL entry, so the expiry arm of `cached_coords` is exercised and
|
||||
// not only the present/absent arms.
|
||||
let stale = make_node_addr(201);
|
||||
let stale_coords = TreeCoordinate::from_addrs(vec![stale, near, my_addr]).unwrap();
|
||||
node.coord_cache_mut()
|
||||
.insert_with_ttl(stale, stale_coords, 1_000_000, 10);
|
||||
|
||||
let unknown = make_node_addr(202);
|
||||
let near_coords = TreeCoordinate::from_addrs(vec![near, my_addr]).unwrap();
|
||||
let far_coords = TreeCoordinate::from_addrs(vec![far, dest, near, my_addr]).unwrap();
|
||||
|
||||
let view = NodeRoutingView {
|
||||
coord_cache: node.coord_cache(),
|
||||
peers: &node.peers,
|
||||
tree_state: node.tree_state(),
|
||||
congested: true,
|
||||
};
|
||||
|
||||
// Enumeration: every peer exactly once, and no peer the map does not hold.
|
||||
// A visitor that skips *some* peers is order-nondeterministic through
|
||||
// `find_next_hop`; one that repeats a peer is invisible there entirely.
|
||||
let mut handles = Vec::new();
|
||||
view.for_each_peer(|peer| handles.push(peer));
|
||||
assert_eq!(handles.len(), 2, "each peer visited exactly once");
|
||||
let visited: HashSet<NodeAddr> = handles.iter().map(|p| view.peer_addr(*p)).collect();
|
||||
assert_eq!(
|
||||
visited,
|
||||
node.peers.keys().copied().collect::<HashSet<_>>(),
|
||||
"enumeration must cover exactly the live peer map"
|
||||
);
|
||||
|
||||
let near_h = *handles
|
||||
.iter()
|
||||
.find(|p| view.peer_addr(**p) == near)
|
||||
.unwrap();
|
||||
let far_h = *handles.iter().find(|p| view.peer_addr(**p) == far).unwrap();
|
||||
|
||||
// peer_may_reach: near holds a filter containing dest; far holds none.
|
||||
assert!(view.peer_may_reach(near_h, &dest));
|
||||
assert!(!view.peer_may_reach(far_h, &dest));
|
||||
assert!(
|
||||
!view.peer_may_reach(near_h, &unknown),
|
||||
"the filter is per-destination"
|
||||
);
|
||||
|
||||
// peer_can_send: far was marked reconnecting.
|
||||
assert!(view.peer_can_send(near_h));
|
||||
assert!(!view.peer_can_send(far_h));
|
||||
|
||||
// peer_link_cost: etx 2.0 * (1.0 + 50ms/100) for near; far has no RTT
|
||||
// sample, so it takes the optimistic 1.0 default. Neither factor is at the
|
||||
// identity, so a cost reading only one half of the product is caught.
|
||||
assert_eq!(view.peer_link_cost(near_h), 3.0);
|
||||
assert_eq!(view.peer_link_cost(far_h), 1.0);
|
||||
|
||||
// peer_coords: each peer's own coordinates, as the fixture installed them.
|
||||
assert_eq!(view.peer_coords(near_h), Some(&near_coords));
|
||||
assert_eq!(view.peer_coords(far_h), Some(&far_coords));
|
||||
|
||||
// is_congested, both directions: a constant in either direction is wrong,
|
||||
// and the false-negative direction silently drops the ECN CE mark.
|
||||
assert!(view.is_congested(&near));
|
||||
let calm = NodeRoutingView {
|
||||
coord_cache: node.coord_cache(),
|
||||
peers: &node.peers,
|
||||
tree_state: node.tree_state(),
|
||||
congested: false,
|
||||
};
|
||||
assert!(!calm.is_congested(&near));
|
||||
|
||||
// cached_coords: present, absent, and expired. The expiry arm is what
|
||||
// decides PathBroken-vs-CoordsRequired for a route that has gone away.
|
||||
let now_ms = seam_now_ms();
|
||||
assert_eq!(
|
||||
view.cached_coords(&dest, now_ms).as_ref(),
|
||||
node.coord_cache().get(&dest, now_ms)
|
||||
);
|
||||
assert!(view.cached_coords(&unknown, now_ms).is_none());
|
||||
assert!(
|
||||
view.cached_coords(&stale, 1_000_005).is_some(),
|
||||
"within TTL"
|
||||
);
|
||||
assert!(view.cached_coords(&stale, 1_000_100).is_none(), "past TTL");
|
||||
|
||||
// Secondary parity sweep over the live peer map.
|
||||
for peer in &handles {
|
||||
let addr = view.peer_addr(*peer);
|
||||
let live = node.peers.get(&addr).unwrap();
|
||||
assert_eq!(view.peer_may_reach(*peer, &dest), live.may_reach(&dest));
|
||||
assert_eq!(view.peer_can_send(*peer), live.can_send());
|
||||
assert_eq!(view.peer_link_cost(*peer), live.link_cost());
|
||||
assert_eq!(
|
||||
view.peer_coords(*peer),
|
||||
node.tree_state().peer_coords(&addr)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user