Apply snode strikes where they were being ignored - #155
Open
mpretty-cyro wants to merge 2 commits into
Open
mpretty-cyro wants to merge 2 commits into
mpretty-cyro wants to merge 2 commits into
Conversation
`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
marked this pull request as ready for review
September 11, 2026 03:16
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 innode_strike_count()— which nothing in this repo calls, it isclient-facing API — and on the disk load. Every place that actually decides something counted the
raw vector instead:
refresh_if_needed's usable-node countget_unused_nodes' skipget_swarm'sfilter_by_strikesSince
record_node_failureonly ever appends, a node struck three times during a two-minute outagestayed 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_writealready filtered bySTRIKE_EXPIRYon the way out, and
_load_from_diskfiltered again on the way in. Both ends of the persistencepath 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_sizeusable nodes,refresh_if_neededdemands a refresh on every call, a refreshdoesn't clear strikes, so the condition survives it — throttled only by
cache_min_lifetime(2s).The change: counting moves into one
_active_strike_counthelper used by all four sites, and anode'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 whattests/test_snode_pool.cpphas 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:
>=excludes every node in the pool,including ones that have never failed — a never-struck node counts 0 and
0 >= 0. The old codeavoided this only by requiring the map lookup to succeed first.
_node_struck_outkeeps athreshold of 0 meaning "drop a node on its first strike".
for (i = 0; i < threshold; ++i)is zeroiterations — 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 anoperator[]side effect and
size() >= 0was 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_outpredicate from the first commit.OnionRequestRouterkeeps a cache of sticky edge nodes so a client's first hop is stable acrosssessions. That cached node is handed to
_build_pathas a forced first hop — and because it isforced, 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— gotanother path built onto it on every launch and every resume (
_pre_build_paths_if_neededrunsfrom
_finish_setup()and fromresume()), and a path rotation carried it into the replacement path.The change: the entry is dropped from
_cached_edge_nodesonce the node is struck out, at bothreuse 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 andget_unused_nodescan pick it again once its strikes expire.
Erasing rather than skipping-but-keeping matters:
_cached_edge_nodesis written once, by_load_from_disk, and never again, so a retained entry stays a candidate for the whole process andwould 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 onewasted 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 debugaccessors onto
_snode_strikes):Note
node_strike_count(...) == 0passes pre-fix — that's the one function that already filtered, soan 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)with2 == 1.Full suite green (136 cases).
utils/format.sh verifyclean.Forward-port
clienthas both defects. The first patch conflicts there only on thesysclock_now_s()→clock_now_s()rename; the second applies cleanly.