Skip to content

Port dev's swarm invalidation on 421 (#154) to client - #178

Open
jagerman wants to merge 28 commits into
session-foundation:clientfrom
jagerman:port-swarm-invalidation-421
Open

jagerman wants to merge 28 commits into
session-foundation:clientfrom
jagerman:port-swarm-invalidation-421

Conversation

@jagerman

@jagerman jagerman commented Oct 1, 2026

Copy link
Copy Markdown
Member

Forward-ports dev's recent changes to client, and records dev (through 3e92795) as merged.

Merge this as a merge commit, not a squash: the last commit is a -s ours merge of dev, which is what lets a later merge of client into dev go through without conflicts.

Commits

  • Fix uninitialized requested_unlock_height usage: dev's 348b778, line-for-line.

  • Port swarm invalidation on 421 from dev (Snode pool: 421 recovery on evidence, strike expiry, and reporting failed refreshes #154): brings client's snode pool, network and routers up to dev's Snode pool: 421 recovery on evidence, strike expiry, and reporting failed refreshes #154 behaviour:

    • a 421's swarm list is used as a redirect, but only nodes we already know are resolved from it
    • refreshes triggered by rejections, with a global exponential backoff
    • swarm overrides kept separate from the calculated swarm, and dropped on refresh
    • refresh callbacks report whether a refresh actually happened
    • strike expiry applies everywhere strikes are counted
    • every get_swarm checks whether the pool is stale
    • each sub-request of a batch/sequence is attributed to its own account when parsing rejections
    • responses that can't be decrypted are reported as ERROR_UNAUTHENTICATED_RESPONSE
    • new ERROR_INSUFFICIENT_NODES
    • QUIC suspend/close fixes

    The one piece of client's design that stays is that Core, not Network, retries a 421. So Network corrects the swarm but does not retry:

    • it reports ERROR_MISDIRECTED_REQUEST once re-resolving would give a better answer: straight away when a redirect was taken, otherwise after the refresh
    • it reports the new ERROR_SWARM_UNRESOLVED when the swarm couldn't be corrected, and Core then ends the operation rather than retrying against the swarm that just rejected it

    This replaces client's set_swarm handling of a 421's swarm. Dev's tests come across, adapted to client's code (JobQueue, Loop&, std::byte).

  • Merge dev into client: a -s ours marker covering the above. Dev's 3e92795 version bump is 1.x-only and not taken.

Notes

  • Core-level tests ([attachments], [configs][core], [send_dm], …) now log a lot of "Cannot refresh cache: No seed nodes are configured!": get_swarm now checks for staleness on every call, as on dev, and those tests' pools have no seed nodes. Nothing fails.

mpretty-cyro and others added 28 commits September 11, 2026 10:11
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.
Some responses (like 421s) don't include requested_unlock_height values,
and when that happens we were leaving the value uninitialized.  This
fixes it to default to -1, which means "unknown" so that a missing value
can't get misinterpreted as a node about to leave the network.
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`.
`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.
Issue: a batch or sequence request that gets a 421 carries its redirect
inside each sub-response, but the redirect was only read from the top level
of the body.  Every batched 421 therefore fell back to a full pool refresh -
the very thundering herd the redirect exists to prevent, on the path Session
polls through.  Separately, a batch that is rejected for some accounts and
answered for others (as coalescing requests for several accounts into one
batch to their shared swarm will produce) never reached the 421 handling at
all, because its results aren't uniformly 421.

Solution: the redirects are taken from every snode response, per account.
`Request::swarm_pubkey` becomes `swarm_pubkeys`, one account per
sub-request in order, and each 421 is attributed to the account at its
position; the legacy C API fills this in by parsing the pregenerated batch
body.  Session storage server 2.12 also echoes the rejected account as
`pubkey` in the 421 body, but that is the responding node's word, so it is
only checked against the position: a 421 naming a different account is
ignored.  Trusting it instead would let any node the client talks to install
a redirect for any account - a group everyone is in, say - rather than only
the ones it was asked about.

A rejection of a single account still retries the whole request on its
(possibly redirected) swarm.  A batch rejected for several accounts is not
retried - a retry goes to one swarm - but its redirects are recorded, so the
next request for each account goes to the right place.  A rejection that
names no usable swarm falls back to one pool refresh for the whole response.
Because the redirects are now recorded before anything else happens to the
response, a 421 on a request whose retry budget is already spent no longer
discards its redirect either.

The batch walk shared by `find_uniform_batch_error`, `_update_network_state`
and the redirect extraction is now one `response::subresponses`, and the
response body is parsed once rather than separately by each.

Forward-porting to `client`: Core's `_swarm_request` needs to fill in
`swarm_pubkeys` from the accounts it builds each body for.
Issue: an onion or proxied request's response is encrypted by the
destination, and only a response that decrypts is the destination's
answer.  When decryption fails - as it does for anything a node along the
path sends back on its own - the onion router and the session-router proxy
both passed the outer HTTP status through unchanged.  Network acts on some
statuses as the destination's verdict:

- 421 re-resolves the account's swarm, refreshing the whole pool when there
  is no usable redirect, and retries;
- 406 resyncs the clock against the snode network;
- 425 resyncs the clock against a server.

So any node on the path could force a pool refresh or a clock resync just by
answering with one of those statuses in plaintext.  With 421 recovery now
refreshing on evidence rather than on the pool's age, that is a cheap way to
make a client fetch the full node list.

Solution: when a response can't be decrypted, those three statuses are
reported as a new ERROR_UNAUTHENTICATED_RESPONSE instead.  Every other status
passes through as before: the same path also carries timeouts and transport
failures, and the onion router's path-failure handling matches on the
statuses nodes along the path return, none of which is one of these three.
The error message now carries the status the node sent, since the reported
status no longer does.

Requests sent to a storage server through session-router tunnels, and by the
direct router, are unaffected: they aren't body-encrypted, their status is
the destination's own over an authenticated connection, and they never reach
either decryption path.
Issue: a redirect was refused as "the node contradicting itself" when it
named the swarm calculated from our pool.  That comparison measures a
redirect against exactly the data the 421 says is out of date.  Swarms can
be rearranged any way at all - members join or leave, a swarm is disbanded
and its members scattered, a new one is formed from nodes anywhere in the
pool - so the account's actual swarm can relate to our grouping in any way:
the same, a part of it, overlapping, or disjoint.  How a redirect compares
with it says nothing about whether the redirect is right.  In particular,
once a redirect had been followed to swarm T, a node in T correctly naming
the calculated swarm - which is how a wrong redirect gets put right - was
refused, and since refused redirects don't count towards the limit, the
account stayed pinned to T until a pool refresh, which invalidate_swarm
declines while the pool is under a minute old or its backoff is active.

Solution: the comparison is gone.  A redirect is followed whenever enough of
the nodes it names resolve against our pool.  What bounds a redirect is
unchanged and never depended on the comparison: only nodes already in the
pool can be named, and every redirect is dropped at the next pool refresh.
Issue: refresh_if_needed reports true both when a refresh ran and when
nothing needed refreshing, and in the latter case calls back straight away -
inline, since Network, the pool and the routers share one loop.  Two
callers retried on true without checking that they could now get what they
needed:

- get_random_nodes called itself again;
- _build_path rebuilt the path.

get_unused_nodes applies subnet diversity, exclusions and strikes, none of
which refresh_if_needed's count of usable nodes does, so a pool can be
healthy by refresh_if_needed's measure and still unable to supply the
caller.  Each retry then landed back in the same branch with nothing
changed, recursing until the stack gave out: get_random_nodes from the C API
with a count the pool can't meet, _build_path on a pool whose usable nodes
sit in too few /24s to fill a path, such as a small testnet or devnet.  This
predates the refresh callback reporting a result - it only stopped the
retry when a refresh could not be started at all.

Solution: both check before retrying, as the session-router proxy selection
already did, so each makes at most one refresh request.  get_random_nodes
calls back with an empty list, per its documented contract, and _build_path
fails the queued requests for the category through the same path as a
refresh that could not start, with messages that now say which it was.

The onion router test's snode pool can now call back from
refresh_if_needed, up to a limit so that code which asks again on every
callback stops rather than overflowing the stack.  Against the unfixed
_build_path the new test sees 21 refresh requests and the queued request
never answered; with the fix it sees one, and the request failed.
Issue: after MAX_CONSECUTIVE_SWARM_REDIRECTS (3) redirects for an account,
the next was refused and the account's override erased, for the caller to
fall back on refreshing the pool.  But that refresh goes through
invalidate_swarm's throttle, which declines while the pool is under a minute
old or its backoff is active - exactly when a network-wide rearrangement
makes hitting the limit likely.  When it declined, the request failed, the
account went back to the swarm calculated from the pool (the one the first
421 had already disproved), and with the entry erased the count started over
from zero, so the limit never held.  The count was also never reset by
anything but a refresh, so "consecutive" was wrong, and legitimate changes
to a swarm's membership spent it just as disagreeing nodes did.

Solution: the limit only ever asks for a refresh; it never refuses a
redirect, since refusing one only guarantees the request fails.  Past it,
each further redirect is followed as usual and also asks invalidate_swarm
for a refresh, so the refresh happens at the first redirect its throttle
allows, and until then the account stays on its latest redirect.  Renamed to
SWARM_REDIRECTS_BEFORE_REFRESH for what it now does, and raised to 8: a
genuine bounce between disagreeing nodes still reaches it within a few
requests, while ordinary rearrangements, which take one or two redirects an
account, don't.  The count saturates rather than wrapping, since it keeps
climbing for as long as the refresh stays throttled.
Issue: a redirect was refused unless at least cache_min_swarm_size of the
nodes it named were in our pool.  That is the realistic way a redirect
can't be followed in full: a swarm rearranged partly out of nodes that
registered after our last pool refresh.  The nodes we do know are still
members of the account's actual swarm, but refusing the redirect left the
account on the swarm that had just rejected it, with the request falling
back to invalidate_swarm - which declines while the pool is under a minute
old or its backoff is active, and when it declines fails the request
outright.

Solution: a redirect is followed with whichever of its nodes are in our
pool, and refused only when none are.  When some of the named nodes are
unknown and that leaves fewer than cache_min_swarm_size, it also asks
invalidate_swarm for a refresh, through the same throttle as the redirect
limit, to fill in the rest.  A small swarm whose members are all known
doesn't count, since the pool isn't missing anything.

Failing the request when the swarm can't be re-resolved is kept, and now
says why: it is reached only when no node the redirect named is one we
know, so a retry could only go back to the swarm that rejected us.
Issue: when a path couldn't be built, or the session router couldn't find a
proxy, because the snode pool couldn't supply enough usable nodes, the
failure was reported with a bare -1.  Network failures are otherwise
reported with the named ERROR_* codes, which is what lets a caller (through
the C API included) tell what went wrong; -1 only says that something did.

Solution: a new ERROR_INSUFFICIENT_NODES (-10013) for all three sites - the
path builder failing its queued requests, and proxy selection after a pool
refresh that failed or left no usable node.
Issue: QuicTransport::suspend() returned early unless the transport was
already suspended - the guard was inverted - so it never did anything and
_suspended was never set.  In production that mostly didn't show, because
Network::suspend closed the transport's connections straight afterwards,
which resets its endpoint: requests during a suspension still failed at
once, just with -1 ("Network is invalid") rather than
ERROR_NETWORK_SUSPENDED.  The defect was the wrong error code and a
suspended flag nothing could rely on.  This is on dev as well.

Fixing the guard makes the transport's suspend() close its connections
itself, and exposes how that close treated the nodes it closed on:

- The connections are closed synchronously by the endpoint's destructor
  when _close_connections() resets it (close_conns() only defers, and its
  deferred job finds the endpoint gone).  Each close runs the transport's
  handler with code 0, and _fail_connection passes that code to every
  pending edge verification.  The router strikes an edge node whose
  verification fails with an error code other than a handshake timeout, and
  0 counts - so a close we asked for struck every edge node with a
  verification in flight.  Verifications with no connection yet were
  cancelled with -1, to the same effect.
- _fail_connection also fires the transport's failure listeners, and the
  router's listener retires the path and starts rebuilding it.

Both reach the router while it is still live, because Network::suspend
suspended the transport before the router, and Network::_close_connections
(the public close_connections() route, on dev too) closes the transport
before the router.

Solution:

- suspend() returns early only when already suspended.
- _close_connections() takes the pending verifications and requests out of
  their maps before resetting the endpoint, so the destructor's closes find
  nothing to fail, and answers the copies once the transport is fully
  closed: verifications with no error code, since our own close is no
  evidence against a node, and requests with ERROR_NETWORK_SUSPENDED when
  suspended or ERROR_CONNECTION_CLOSED when not (they were told "suspended"
  either way).  Answering copies rather than walking the members also means
  a callback that calls back into the transport - the router's path rebuild
  calling verify_connectivity, say - can't insert into the maps being
  walked; such a call finds no endpoint and fails cleanly, without an error
  code.  That is a reason to keep this shape even if the code-0 close is
  ever avoided some other way.
- Network::suspend suspends the router before the transport, and
  Network::_close_connections closes the router before the transport, so
  the router has dropped its path builds and failure listeners before the
  transport closes the connections they are attached to.

Tests: a request sent to a suspended transport is refused with
ERROR_NETWORK_SUSPENDED (with the inverted guard it went out and failed on
the network with -1), and a verification in flight when the transport is
closed is answered exactly once, with no error code (before, it got 0 from
the destructor's close).
Issue: SnodePool::suspend() set its flag and flushed strikes, and left any
refresh in progress to carry on.  With the transport and router both
refusing requests while suspended, every fetch that refresh made failed at
once, and its retry paths - a failed fetch moving to the next candidate, a
rejected set of results being fetched again - don't check for suspension,
so it worked its way through the candidate list at the retry delay (capped
at 5s, three chains in parallel): about an hour for a mainnet-sized pool,
waking the process to do nothing useful on Android and desktop (iOS freezes
it, so there the timers never fire).  Whatever waited on the refresh was
answered only when the candidates ran out, and the failures it counted up
slowed the first refreshes after resume.

Solution: suspend() abandons a refresh in progress - resetting its id,
candidates, results and failure count - and answers everything waiting on
it with false, as the router and transport already do with their own work
in flight.  Treating a suspension as a fresh start drops genuine
pre-suspend failures too; that is deliberate, since they say nothing about
the network we resume on.  Nothing can be in flight while suspended any
more, so _refresh_snode_cache's suspended branch answers its waiters
unconditionally.

Retries belonging to an abandoned refresh can still fire afterwards, so:

- _launch_next_refresh_request does nothing unless its refresh is still the
  current one; before, it only checked that some refresh was, and would
  take candidates from whichever refresh had replaced it;
- the "ran out of candidates" retry captures its refresh id and does
  nothing when that refresh is gone.  Before, it reset the id and started a
  new refresh unconditionally, which could wipe out a refresh started after
  resume.  This was the one place a refresh restarted itself; after a
  suspension, the next refresh now comes from the pool's ordinary callers
  asking again - the router's path pre-build on resume, via
  get_unused_nodes, or a clock resync.

A refresh invalidate_swarm started has already stepped its backoff when it
is abandoned, so after a short suspension the first rejection-driven
refresh can be declined, for at most the current backoff interval.  The
throttle resets itself after quiet for twice that interval, so a longer
suspension carries nothing over.
Issue: [get_unused_nodes], [update_cache] and [refresh_min_cache_size]
built their config::SnodePool positionally, with trailing comments naming
fields the values no longer went into.  Each list was one entry short of
the struct and misaligned by two, so the value commented as
cache_node_strike_threshold went two fields early and the trailing false
filled a numeric field as 0:

- in [get_unused_nodes] (both of its configs) and [update_cache], the 3
  went into cache_num_nodes_to_use_for_refresh and the false into
  cache_min_num_refresh_presence_to_include_node;
- in [refresh_min_cache_size], which also sets cache_min_size, the 3 went
  into cache_min_num_refresh_presence_to_include_node and the false into
  cache_node_strike_threshold.

Every one of them therefore ran with a strike threshold of 0 - a node out
on its first strike.  None of the three tests' outcomes depended on that,
or on the refresh settings the values landed in: TestSnodePool makes
refresh_if_needed a no-op, none of them reaches a refresh through get_swarm
on an empty pool, and [refresh_min_cache_size] feeds _on_refresh_complete a
single response, for which the presence requirement doesn't apply.  But the
one strike check, in [get_unused_nodes], uses permanent failures, which
strike a node out at any threshold, so nothing checked the threshold
itself.

Solution: all four configs are designated, with the values the comments
meant - cache_node_strike_threshold 3, plus cache_min_size 12 where marked -
and everything else they didn't annotate left 0.  So besides the threshold,
cache_num_nodes_to_use_for_refresh goes from 3 to 0 in the short lists and
the presence requirement from 3 to 0 in [refresh_min_cache_size]; for the
reasons above, neither has any effect on these tests.  [get_unused_nodes]
now also checks the threshold: a node with two ordinary strikes is still
picked, and with a third it isn't.  At the old threshold of 0 that check
fails, since two strikes already exclude the node.
Issue: _rotate_path keeps a path's edge node across rotations - so a
client's first hop stays stable - unless it has been in use longer than
edge_node_cache_duration, or (since the cached edge node change) it has
been struck out.  Nothing tested the struck-out case; the existing test
covers only the edge nodes cached from a previous run.

Solution: [onion_request_router][rotate_path] rotates a path whose edge
node was connected just now, so the ten-day rule can't be what decides, and
checks what the rotation asks the pool for: the rest of a path keeping the
edge node (path_length - 1 nodes) when it isn't struck, a whole new path
(path_length) once it is.  The mock pool now records the count of each
get_unused_nodes call, since its canned result doesn't reflect it.  Without
the struck-out condition the second rotation asks for path_length - 1 and
the test fails.

TestOnionRequestRouter::set_paths used emplace, which leaves a category's
paths alone if it already has some, so setting them twice on one router
silently kept the first set.  It now replaces them, which is what its name
says and what the new test needs; no existing test set them twice.
Issue: get_swarm schedules a background refresh_if_needed, the check that
refreshes a pool past cache_expiration, only on the path that works a swarm
out from the pool.  Answers from the swarm cache return before it, and so,
since the redirect commits, do answers from an override.  So each account
triggered the check once per pool generation, on its first lookup, and
never again while its swarm was cached - and an overridden account, whose
override only a refresh clears, never did at all.  Refreshes still happened,
because path rotation and building call get_unused_nodes, which schedules
the same check, but only thanks to that unrelated caller.  The cached case
predates this branch; the override case widened it.

Solution: the check is scheduled at the top of get_swarm, so every call
makes it.  It costs nothing when the pool is fresh or a refresh is already
running.  A test counts the checks: one each for a swarm worked out from
the pool, one answered from the cache and one answered from an override;
before, the last two made none.
Issue: session_network_send_request takes each sub-request's account from
a pregenerated batch or sequence body, and uses the caller's
swarm_pubkey_hex only when the body can't be parsed at all.  A sub-request
whose params carry no pubkey got an empty entry even when the caller had
said which account the request was for.  With 421s attributed strictly by
position in the request, a 421 for that sub-request could then be
attributed to no account, and the redirect it carried was lost.

Solution: batch_request_accounts takes a fallback account, which fills any
sub-request whose params name none; accounts parsed from the body are kept,
since they can differ per sub-request.  The C API passes swarm_pubkey_hex
as the fallback.
Issue: every accepted swarm redirect logged at info, and once an account is
past SWARM_REDIRECTS_BEFORE_REFRESH every further redirect warned and asked
invalidate_swarm for a refresh, whose "leaving it alone" line for a
throttled refresh was also at info.  During a bounce between disagreeing
nodes that is several lines per request for as long as the backoff holds,
up to its two-hour cap - useful once, noise after that.

Solution: the per-redirect line and invalidate_swarm's decline line are
debug.  The limit warning fires only on the redirect that crosses it, and
later ones log at debug.  The warning for a redirect naming nodes our pool
doesn't have is unchanged.  It can repeat too - every request for that
account produces one while the refresh it asks for is throttled - and the
same first-then-debug treatment would apply, but that waits for evidence
that it is noisy in practice.
A redirect asked for a pool refresh only when it named nodes we don't have
*and* that left fewer than cache_min_swarm_size of them.  The first half
reads "our pool is behind", which is a fair guess for an honest redirect -
a rearrangement pulls in nodes registered since our last refresh, so some
names don't resolve - but it is exactly backwards for a dishonest one: a
node naming only itself leaves nothing unresolved, so the pool looks fine
and no refresh is asked for, while the account is pinned to a single node
until one happens for another reason.

Dropping that half changes one shape - a redirect naming fewer than
cache_min_swarm_size nodes, all of which we know.  Once enough resolve the
condition is false either way, so a redirect naming a node or two we don't
happen to have alongside a full swarm still asks for nothing.

The redirect is followed either way; this only decides whether a refresh is
also requested, and that request goes through invalidate_swarm's throttle.
The Linux CI stages build against the system liboxenquic, which deprecates
quic::opt::disable_mtu_discovery in favour of max_udp_payload::minimum(),
and -Werror makes that fatal.  The vendored libquic this repo pins has no
max_udp_payload at all, so the deprecated name is the only spelling that
compiles against both - there is nothing to migrate to here yet.

Moving the pin is not a pin change: the newer session-router defines
oxen::quic through its own session-deps, which collides with the definition
in external/CMakeLists.txt, so the dependency handling has to move onto
session-deps first.  That comes over with the Session client work, and the
TODO goes with it.
tests/utils.hpp uses std::condition_variable, std::mutex and the lock
guards without including <condition_variable> or <mutex>, and
test_config_userprofile.cpp calls std::this_thread::sleep_for without
<thread>.  Newer standard libraries pull all three in through something
else, so this builds everywhere except the older libstdc++ on the Debian 12
CI stage, which is where it fails.

Both predate this branch.  They surfaced now because every Linux stage used
to fail compiling the network library, before it got as far as the tests.
…-invalidation-on-421

Snode pool: 421 recovery on evidence, strike expiry, and reporting failed refreshes
Forward-port of dev commit 348b778; line-for-line identical to dev's.

from_json left requested_unlock_height uninitialized whenever the JSON omitted
it, which swarm responses and 421 bodies always do, and an oxend node dump does
for a node with no unlock requested.  The field becomes int64_t defaulting to
-1 so "we were not told" is distinguishable from "not requested", and the
snode_pool filter tests `> 0` rather than truthiness.
Brings client's snode pool, network and routers to dev's session-foundation#154 behaviour:
swarm redirects taken from a 421's own swarm list (resolved only against
nodes we already know), evidence-driven refreshes with global exponential
backoff, overrides kept apart from the calculated swarm and dropped on
refresh, refresh callbacks that report whether a refresh happened, strike
expiry applied everywhere strikes are counted, a stale check on every
get_swarm, per-account rejection parsing for batch/sequence requests,
undecryptable responses reported as ERROR_UNAUTHENTICATED_RESPONSE,
ERROR_INSUFFICIENT_NODES, and the QUIC suspend/close fixes.

The one piece of client's design kept is that 421 retries belong to Core,
not Network.  Network therefore corrects the swarm but does not retry: it
reports ERROR_MISDIRECTED_REQUEST once re-resolving the swarm would give a
better answer (immediately when a redirect was taken, after the refresh
otherwise), and the new ERROR_SWARM_UNRESOLVED when it could not be
corrected, so Core ends the operation instead of retrying against the
swarm that just rejected it.  This replaces client's set_swarm-based
adoption of a 421's swarm.

Tests are dev's, adapted to client's JobQueue/Loop& and std::byte idioms.
Marks dev through 3e92795 as merged without taking its tree:

- session-foundation#154 (swarm invalidation on 421) is ported in 87a6bb9, adapted to
  client's Core-side 421 retry.
- 348b778 (uninitialized requested_unlock_height) is ported in 4df438e.
- 3e92795 (version bump) is 1.x-only and not taken.
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.

2 participants