Skip to content

Snode pool: 421 recovery on evidence, strike expiry, and reporting failed refreshes - #154

Open
mpretty-cyro wants to merge 5 commits into
session-foundation:devfrom
mpretty-cyro:fix/swarm-invalidation-on-421
Open

mpretty-cyro wants to merge 5 commits into
session-foundation:devfrom
mpretty-cyro:fix/swarm-invalidation-on-421

Conversation

@mpretty-cyro

@mpretty-cyro mpretty-cyro commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Five commits against dev, each independently reviewable and any of them droppable. They're together
because they all sit in src/network/ and touch the same few files, so splitting them into separate
PRs just moved the conflict resolution to merge time.


1–2. A 421 couldn't invalidate the mapping it proved was wrong

A 421 says authoritatively that the swarm we resolved for an account is wrong. _handle_421_retry
answered it with refresh_if_needed, which decides on cache_expiration (2h) and so declines for
any cache younger than that, then re-read the same _swarm_cache entry — so the retry went to another
node in the list that had just rejected us, redirect_retry_count (1) was exhausted, and every later
request for that account did the same until the cache aged out. A client could be unable to interact
with a swarm for up to two hours
, recoverable only by waiting or a full clear_cache().

Evicting the swarm cache entry is a no-op, which is worth stating since it's the obvious-looking fix:
_swarm_cache only ever holds swarm::get_swarm(pubkey, _all_swarms), _all_swarms is
generate_swarms(_snode_cache), and both writers of _all_swarms replace _swarm_cache in the same
breath. It can never disagree with the pool. What a 421 disproves is the pool snapshot.

Commit 1 adds invalidate_swarm — evidence-driven, alongside the age-driven refresh_if_needed
with a backoff that doubles each time another rejection arrives after a refresh we already ran for one
(immediate, 1m, 2m, 4m… capped at cache_expiration), so a node rejecting everything costs a handful
of refreshes over a couple of hours rather than one a minute.

Commit 2 makes the redirect the primary path, after review feedback that refreshing on every 421
means a swarm change has every client of that swarm fetch the full node list (51 bytes/node from each
of cache_num_nodes_to_use_for_refresh nodes) where nothing made them fetch it before. A rejecting
node usually names the swarm the account actually belongs to; get_swarm now prefers that until the
pool is refreshed, with the refresh kept as the fallback when a 421 carries no usable redirect.

Only pubkeys are read from the response, and only ones resolving against the pool we fetched
ourselves, so a redirect reaches registered service nodes we already know and nothing else. It can
still choose which of those, so: a redirect naming the swarm we already calculated is refused, fewer
than cache_min_swarm_size resolved names is refused, three in a row without an intervening refresh
is refused, and a pool refresh drops every override it was correcting. Overrides are kept out of
_swarm_cache, which stays a pure memo of _all_swarms.

⚠️ A redirect reports INVALID_SWARM_ID — it names members, not an id, and the new id isn't derivable
from a stale pool. Harmless today (the C API discards the swarm id, _handle_421_retry ignores it) but
it would matter if a consumer started reading it.

3. Strikes never expired where it counted

STRIKE_EXPIRY (48h) was applied in node_strike_count() — which nothing in this repo calls, it's
client-facing API — and on the disk load. Every place that actually decides something counted the
raw vector: refresh_if_needed's usable-node count, get_unused_nodes' skip, and get_swarm's
filter_by_strikes. Since record_node_failure only appends, a node struck three times during a
two-minute outage stayed out of path building, swarm answers and refresh candidates for the life of
the process
, with its vector growing unbounded. Restarting was the only cure, because loading is the
one path that filtered.

Lopsided in a telling way: _perform_strikes_write already filtered by STRIKE_EXPIRY on the way out
and _load_from_disk filtered again on the way in. Both ends of persistence honoured expiry; only the
in-memory decision sites didn't. (So this changes nothing about what lands on disk.)

Two further defects fell out, both only reachable at cache_node_strike_threshold == 0 — which is what
tests/test_snode_pool.cpp has been running with — and both caught by the existing suite:

  1. A bare >= against the threshold excludes every node in the pool, including ones that never
    failed (0 >= 0). The old code dodged it only by requiring the map lookup to succeed first.
  2. A permanent failure recorded no strikes at allfor (i = 0; i < threshold; ++i) is zero
    iterations — leaving a node we know is gone in full rotation. Now max(1, threshold).

⚠️ Behaviour change worth a second opinion: an unreachable-but-registered node now returns to rotation
48h after its last strike instead of never. Intended — it may have recovered, and gets struck out again
on first use — but it is a change.

4. The cached edge node never had its strikes consulted

OnionRequestRouter keeps sticky edge nodes so a client's first hop is stable across sessions, and
hands one to _build_path as a forced first hop — so it's the one node in a path that never passes
the strike filter get_unused_nodes applies to the rest. Only edge_node_cache_duration (10 days)
ever dropped it, so a node we had evidence was unreachable got another path built onto it on every
launch and every resume.

It now loses the cached-edge role once struck out — not its place in the pool; nothing here touches
_snode_cache, so get_unused_nodes can pick it again when its strikes expire. Dropped rather than
skipped because _cached_edge_nodes is written once at load and never again, so a retained entry would
reclaim the role at expiry, by which point we'd been on a different edge node for two days.

Lowest-value commit of the five — the retry already excludes the failed edge node, so the real cost
is one wasted build plus a retry per launch. Easy to drop.

5. refresh_if_needed could silently never call back

Three ways to do nothing and say nothing — suspended pool, no candidate nodes, no fetcher. Callers set
their state up before calling, so "never" is permanent, not slow:

caller cost
_resync_clock sets _current_clock_resync_id first, so every later clock resync short-circuits as "already in progress" and its queue is stranded
both routers' setup _finish_setup() never runs — the router never becomes usable
get_random_nodes, path build, proxy selection the caller waits on something that never arrives

The callback now carries whether a refresh happened; "nothing needed refreshing" reports true, since
that's the answer the caller wanted. The flag does real work rather than being decoration — simply
always invoking the callback would turn two of these hangs into spins, as get_random_nodes re-enters
itself (and Loop::call runs inline on the loop thread, so that's stack recursion) and the path
builder rebuilds into the same branch.

SnodePool has no C API surface, so this is internal only. The judgement calls are the review-worthy
part: both routers still finish setup (not finishing leaves them permanently unusable), and
_resync_clock routes into the existing _on_clock_resync_complete(), which resets the in-progress id,
fails the queued requests, and leaves the existing offset alone — an old offset beats none.


Tests

Four new cases — [invalidate_swarm], [swarm_redirect], [strike_expiry],
[refresh_callback_contract], [cached_edge_nodes]139 total, all green. utils/format.sh verify clean.

Where a test couldn't fail against the pre-fix tree (it calls a function that didn't exist), the old
behaviour was reproduced by mutation instead and the failing assertion recorded. [strike_expiry] is a
genuine fail-before-fix: 2 == 4 and 4 == 1 against unfixed code.

Forward-port

client (b0a523fd) carries all of these. Commits 1–2 and 4 apply cleanly; 3 conflicts only on the
sysclock_now_s()clock_now_s() rename and 5 on the _loop->call_jq.call job-queue refactor.

A 421 tells us, authoritatively, that the swarm we resolved for an account
is wrong.  `_handle_421_retry` answered it with `refresh_if_needed`, which
decides on `cache_expiration` (2h) and so declines on any cache younger
than that, and then re-read the same `_swarm_cache` entry - so the retry
went to another node in the list that had just rejected us, and every later
request for that account did the same until the cache aged out.

Evicting the swarm cache entry alone cannot fix it: the entry is only ever
a memo of `swarm::get_swarm(pubkey, _all_swarms)`, so it recomputes the
identical answer.  What the rejection disproves is the pool snapshot the
swarms were generated from, and only a refresh replaces that.

`invalidate_swarm` refreshes on that evidence, with a backoff that doubles
each time another rejection arrives after a refresh we already ran for one,
so a node rejecting everything costs a handful of refreshes over a couple
of hours rather than one every minute for as long as it keeps it up.
@mpretty-cyro
mpretty-cyro marked this pull request as ready for review September 11, 2026 03:05
Refreshing on a 421 recovers the account, but it means a swarm change has
every client of that swarm fetch the full node list - 51 bytes per node
from each of `cache_num_nodes_to_use_for_refresh` nodes - where nothing
made them fetch it at all before.  The backoff added with
`invalidate_swarm` bounds how often one client repeats that; it does
nothing about the aggregate.

A node rejecting a request for an account usually names the swarm it
actually belongs to, which corrects the one mapping we know is wrong
without fetching anything.  `get_swarm` prefers such a redirect over its
own calculation until the pool is refreshed, and the refresh stays as the
fallback for a 421 that carries no usable redirect.

Only the node pubkeys are read from the response, and only ones that
resolve against the pool we fetched ourselves, so a redirect reaches
registered service nodes we already know about and nothing else - it
cannot invent a node or name an address of its own choosing.  It can still
choose which of those nodes we talk to, so: a redirect naming the swarm we
already calculated is refused, fewer than `cache_min_swarm_size` resolved
names is refused, three in a row without an intervening refresh is refused,
and a pool refresh drops every override it was correcting.

The redirects are kept out of `_swarm_cache`, which stays a pure memo of
`_all_swarms`.
@mpretty-cyro

Copy link
Copy Markdown
Collaborator Author

Pushed a second commit (329f8b9d) answering the aggregate-bandwidth feedback. It's a fast-forward60c6d799 is unchanged and still the first commit, so nothing already reviewed has been rewritten.

What changed. The point stands that refreshing the pool on a 421 means a swarm change has every client of that swarm fetch the full node list (51 bytes/node from each of cache_num_nodes_to_use_for_refresh nodes), where nothing made them fetch it before — and the backoff in the first commit bounds how often one client repeats that, not the aggregate.

So the redirect is now the primary path: a node rejecting a request for an account usually names the swarm it actually belongs to, and get_swarm prefers that over its own calculation until the pool is refreshed. The pool refresh stays as the fallback for a 421 carrying no usable redirect, so both paths are needed.

On safety — this was the objection I'd originally raised against using the 421 body, and taking only pubkeys and resolving them against our own pool answers it: a redirect can reach registered service nodes we already know about and nothing else. It can still choose which of those we talk to, so it's bounded:

  • a redirect naming the swarm we already calculated is refused (the node contradicting itself)
  • fewer than cache_min_swarm_size resolved names is refused
  • three in a row without an intervening refresh is refused, then we fall back to refreshing
  • a pool refresh drops every override it was correcting, so a claim can't outlive the ground truth that would overrule it

Overrides are kept out of _swarm_cache, which stays a pure memo of _all_swarms.

One thing worth a second opinion: a redirect reports INVALID_SWARM_ID, because a redirect names swarm members, not an id, and the new id isn't derivable from a stale pool. Harmless today — the C API discards the swarm id and the 421 handler ignores it — but it would matter if a consumer ever started reading it.

New [network][swarm_redirect] test covers the accept path and all four refusals, and asserts no refresh is started against a deliberately-aged pool, so "no refresh happened" means the redirect was taken rather than the pool being too fresh to bother. Full suite green (136 cases).

`STRIKE_EXPIRY` was applied in `node_strike_count()` - which nothing in
this repo calls - and on the disk load.  Every place that actually decides
something counted the raw vector instead, and `record_node_failure` only
ever appends, so a node struck during a two-minute outage stayed out of
path building, swarm answers and refresh candidates for the life of the
process, and its timestamp vector grew without bound.  Restarting was the
only thing that cleared it, because loading is the one path that filtered.

Counting is now in one place, used by all four, and a node's expired
strikes are dropped when it collects a new one.

Two things this uncovered, both only reachable with a strike threshold of
0, which is what `tests/test_snode_pool.cpp` has been running with:

- comparing the count against the threshold with a bare `>=` excludes every
  node in the pool, including ones that have never failed, so a threshold
  of 0 has to keep meaning "drop a node on its first strike".
- a permanent failure looped up to the threshold, recording no strikes at
  all and leaving a node we know is gone in rotation.
A cached edge node is handed to `_build_path` as a forced first hop, so it
is the one node in a path that never passes the strike filter
`get_unused_nodes` applies to the rest, and the only thing that dropped it
was `edge_node_cache_duration` (10 days).  So a node we already had
evidence was unreachable got another path built onto it on every launch
and every resume, and a path rotation carried it into the replacement
path.

It loses the cached-edge role rather than its place in the pool: it stays
a node like any other and can be picked again once its strikes expire.
Keeping the entry and merely skipping it would hand the role back at that
expiry, by which point we have been running on a different edge node for
two days - a second change of first hop, not a return to a stable one.
`refresh_if_needed` had three ways to do nothing and say nothing: a
suspended pool, no candidate nodes, and no fetcher.  The callback was
simply never invoked, and every caller sets its own state up before
calling, so "never" is not a delay - it is permanent:

- `_resync_clock` sets `_current_clock_resync_id` first, so a dropped
  callback makes every later clock resync short-circuit as "already in
  progress" and strands everything queued behind it.
- both routers finish setup from the callback, so they never finish it.
- `get_random_nodes` and the path builder retry from the callback, so
  their callers wait on something that will not arrive.

The callback now takes whether a refresh actually happened, which is the
smallest thing that lets a caller tell "the pool is fine" from "we could
not find out".  Nothing needing a refresh reports true - that is the
answer the caller wanted, not a failure to get one.

Each caller had to be given a failing branch, and two of them would have
spun rather than hung if the callback had simply always been invoked:
`get_random_nodes` re-enters itself, and the path builder rebuilds into
the same too-few-nodes branch.

`SnodePool` has no C API surface, so this is internal only.
@mpretty-cyro mpretty-cyro changed the title Recover from a 421 on the evidence rather than on the cache's age Snode pool: 421 recovery on evidence, strike expiry, and reporting failed refreshes Sep 22, 2026
@mpretty-cyro mpretty-cyro self-assigned this Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant