Skip to content

Apply snode strikes where they were being ignored - #155

Open
mpretty-cyro wants to merge 2 commits into
session-foundation:devfrom
mpretty-cyro:fix/snode-strike-expiry
Open

mpretty-cyro wants to merge 2 commits into
session-foundation:devfrom
mpretty-cyro:fix/snode-strike-expiry

Conversation

@mpretty-cyro

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

Copy link
Copy Markdown
Collaborator

Two places snode strikes were not applied. Separate commits; the second is small and independent
enough to drop on its own if it isn't wanted.


1. Strikes never expired where it counted

STRIKE_EXPIRY (48h) was applied in node_strike_count() — which nothing in this repo calls, it is
client-facing API — and on the disk load. Every place that actually decides something counted the
raw vector instead:

  • refresh_if_needed's usable-node count
  • get_unused_nodes' skip
  • get_swarm's filter_by_strikes

Since record_node_failure only ever 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, and
its timestamp vector grew without bound. Restarting was the only thing that cleared it, because
loading is the one path that filtered.

Worth noting how lopsided this was: _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 the persistence
path honoured expiry; only the in-memory decision sites didn't. The code already believed strikes
expire everywhere except where it counted them. (This commit therefore changes nothing about what
lands on disk.)

It also interacts badly with the refresh policy: once accumulated strikes take a long-running client
below cache_min_size usable nodes, refresh_if_needed demands a refresh on every call, a refresh
doesn't clear strikes, so the condition survives it — throttled only by cache_min_lifetime (2s).

The change: counting moves into one _active_strike_count helper used by all four sites, and a
node's expired strikes are dropped when it collects a new one.

Two further defects this uncovered

Both only reachable at cache_node_strike_threshold == 0, which is what tests/test_snode_pool.cpp
has been running with (the config there is aggregate-initialised positionally and the trailing
comments don't line up with the fields). Both were caught by the existing suite:

  1. Comparing the count against the threshold with a bare >= excludes every node in the pool,
    including ones that have never failed — a never-struck node counts 0 and 0 >= 0. The old code
    avoided this only by requiring the map lookup to succeed first. _node_struck_out keeps a
    threshold of 0 meaning "drop a node on its first strike".
  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. It now records max(1, threshold).

The old test passed only because _snode_strikes[key] created an empty vector as an operator[]
side effect and size() >= 0 was then true.

Behaviour change worth a second opinion

An unreachable-but-still-registered node now returns to rotation 48h after its last strike instead of
never. That is the intent — it may well have recovered, and if it hasn't it is struck out again on
first use — but it is a change, not purely a bug fix.


2. The cached edge node never had its strikes consulted at all

Different subsystem, same theme, and it needs the node_struck_out predicate from the first commit.

OnionRequestRouter keeps a cache of sticky edge nodes so a client's first hop is stable across
sessions. That cached node is handed to _build_path as a forced first hop — and because it is
forced, it never goes through get_unused_nodes, which is the only thing that consults strikes.
So the one node in a path we most want to be sure of is the one node nothing checked; the only thing
that ever dropped it was edge_node_cache_duration (10 days).

A node we already had evidence was unreachable — including one struck by force_remove_node — got
another path built onto it on every launch and every resume (_pre_build_paths_if_needed runs
from _finish_setup() and from resume()), and a path rotation carried it into the replacement path.

The change: the entry is dropped from _cached_edge_nodes once the node is struck out, at both
reuse sites. Scope is deliberately narrow — it loses the cached-edge role, not its place in the
pool.
Nothing here touches _snode_cache, so it stays a node like any other and get_unused_nodes
can pick it again once its strikes expire.

Erasing rather than skipping-but-keeping matters: _cached_edge_nodes is written once, by
_load_from_disk, and never again, so a retained entry stays a candidate for the whole process and
would reclaim the role when its strikes expire — by which point we have been on a different edge node
for two days. That is a second change of first hop, not a return to a stable one, which is the
opposite of what the 10-day stickiness is for.

Severity is low — lower than it first looked. The connection-failure retry already passes the
failed edge node as nodes_to_exclude_, so it is excluded from the next attempt; the real cost is one
wasted build plus one retry delay per launch, self-correcting immediately. Happy for this commit to
be dropped if it isn't felt to be worth the surface.


Tests

New [network][strike_expiry], and it does fail against the unfixed tree (it needs only debug
accessors onto _snode_strikes):

test_snode_pool.cpp:402: FAILED: CHECK( get_unused_nodes(4).size() == 4 )   with expansion: 2 == 4
test_snode_pool.cpp:408: FAILED: CHECK( debug_recorded_strikes(...) == 1 )  with expansion: 4 == 1

Note node_strike_count(...) == 0 passes pre-fix — that's the one function that already filtered, so
an audit checking only it would have cleared this.

New [network][onion_request_router][cached_edge_nodes] for the second commit; mutation-verified —
making the predicate never erase fails REQUIRE(remaining.size() == 1) with 2 == 1.

Full suite green (136 cases). utils/format.sh verify clean.

Forward-port

client has both defects. The first patch conflicts there only on the sysclock_now_s()
clock_now_s() rename; the second applies cleanly.

`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.
@mpretty-cyro
mpretty-cyro marked this pull request as ready for review September 11, 2026 03:16
@mpretty-cyro mpretty-cyro changed the title Expire snode strikes where it matters, not only where it's reported Apply snode strikes where they were being ignored Sep 11, 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