Skip to content

Detect configs expired from the swarm and re-store them - #2151

Open
mpretty-cyro wants to merge 13 commits into
session-foundation:devfrom
mpretty-cyro:feature/config-recovery
Open

Detect configs expired from the swarm and re-store them#2151
mpretty-cyro wants to merge 13 commits into
session-foundation:devfrom
mpretty-cyro:feature/config-recovery

Conversation

@mpretty-cyro

Copy link
Copy Markdown
Collaborator

Config messages have a 30-day TTL that is refreshed by the expire bump already piggybacked on every poll. A device offline past the TTL loses its config from the swarm and nothing noticed. This reads the bump's own answer to detect that case and re-stores from local state. Reasoning for each rule is in the code.

  • Detect missing config hashes from the unchanged array the server returns for an extend-only expire. An absent key means detection is unavailable, not that every config is gone.
  • Re-store on a later poll, only once local state is level with the swarm, so a stale local config can never overwrite a newer remote one.
  • Chunk the recovery batch at 20 sub-requests; the server rejects the whole sequence above that, and a single config can split into ~66 parts.
  • Run the obsolete-hash deletes as a separate pass after the stores, built only from restores that fully landed, so a delete can never go out over a config we did not finish storing.
  • Bar a settled hash for an hour rather than for the session; a backgrounded session can outlive the TTL, which would strand the config it just restored.
  • Drop the per-store semaphore and let BatchApiExecutor coalesce; the semaphore was bounding batch size by accident, not by design.

@mpretty-cyro mpretty-cyro self-assigned this Aug 5, 2026
…re-store them

Config messages have a 30-day TTL that is refreshed by the expire bump already
piggybacked on every poll. A device offline past the TTL loses its config from
the swarm and nothing noticed. This reads the bump's own answer to detect that
case and re-stores from local state. Reasoning for each rule is in the code.

- Detect missing config hashes from the `unchanged` array the server returns for
  an extend-only expire. An absent key means detection is unavailable, not that
  every config is gone.
- Re-store on a later poll, only once local state is level with the swarm, so a
  stale local config can never overwrite a newer remote one.
- Chunk the recovery batch at 20 sub-requests; the server rejects the whole
  sequence above that, and a single config can split into ~66 parts.
- Run the obsolete-hash deletes as a separate pass after the stores, built only
  from restores that fully landed, so a delete can never go out over a config we
  did not finish storing.
- Bar a settled hash for an hour rather than for the session; a backgrounded
  session can outlive the TTL, which would strand the config it just restored.
- Drop the per-store semaphore and let BatchApiExecutor coalesce; the semaphore
  was bounding batch size by accident, not by design.
… in the repo

The comments added by the previous commit cited section numbers from the
cross-client design notes they were written against. That document lives in no
repo and won't be in the PR, so every citation was a dangling pointer — and a
bare "§4.1" is worse than none, because it implies the reasoning lives
somewhere the reader is expected to find and can't.

The comments were written to carry their reasoning inline, so most citations
were pure deletion; the few that leaned on one had the substance written in
instead. Vector labels (V13b, V22c) stay in test names: they identify a test
within this repo and are shared vocabulary with the other Session clients, so
a divergence can be pinned to one rule.

Also corrects a comment that still said the chunking fixture "crosses 19"
after the sub-request limit moved to 20. Comments only, no behaviour change.
…test checks

Test names and assertion messages are the one kind of stale prose nothing
contradicts: a wrong comment sits beside code that disagrees with it, but a
green test prints its own description as passing on every run.

- `V13 ... is put back once` did not advance the clock, so a session-scoped
  bar and a time-bounded one both satisfied it. The unqualified "once" was a
  property no assertion there checked; it now says "while the bar holds" and
  names V13g as the only test that separates the two.
- Three vector labels each sat on two different tests (V13b, V16, V22c), in
  every case because a follow-on test reused the label of the one above it.
  The continuations are real properties but not vectors, so they are now
  prose-named. Duplicate labels also defeat a coverage audit by label: an
  uncovered row can read as covered.

Test names and comments only, no assertions changed.
…the guard

Both asserted that a response is not read for absence without an extend
request, and both used a fixture whose `unchanged` key was absent — which
makes the response unreadable on its own grounds. So each passed for a reason
unrelated to its name, and would have passed against an implementation that
read shorten responses for absence.

Verified by mutation rather than by inspection: deleting the extend guard left
both green beforehand, and fails both now. The fixtures are readable
(`unchanged` present and empty) so the guard is the only thing that can
produce Inconclusive.

Tests only, no production change.
…factual on purpose

Both fixtures send a readable sub-response for a request that a real server
would answer without one, so that a single guard is the only thing producing
the asserted result. That makes them less accurate than production on purpose,
and the previous comments explained the isolation without saying so — leaving
the next reader free to restore the realistic shape for accuracy and silently
make both tests vacuous again, passing.

Where two guards cover two different failures, production usually trips both
at once, so isolating one needs a counterfactual, and a counterfactual needs a
note or it gets corrected away.

Comments only.
… own value

detectMissingConfigHashes had three conditions returning one Inconclusive, and
AlterTtlApi's decode-failure path returned the same value for a fourth reason.
No consumer discriminates on it, so the collapse cost nothing at runtime — it
cost every test that touches those guards, because a fixture set up for one
condition usually satisfies another too, and the test then passes without
reaching the guard it names. Two did exactly that.

Inconclusive becomes a sealed interface over ExtendNotRequested, NothingAsked,
NoUsableSubResponse and ResponseUnreadable. Behaviour is unchanged and
deliberately so: all four still mark nothing missing and authorise no store,
and the two sites that discriminate do so on Checked, which a sealed subtype
still satisfies. Neither poller names the type.

The decode failure is kept separate from NoUsableSubResponse rather than folded
in: one means we could not read the answer, the other means the swarm answered
and told us nothing. Collapsing them would reintroduce, in the type added to
prevent it, the ambiguity this removes.

Two assertions that sampled one value for all causes now iterate every cause,
so a cause added later cannot quietly acquire the power to flag a group expired
or authorise a store.
…mit made false

Splitting the inconclusive causes turned this fixture's failure mode from
silent to loud: restoring the realistic `unchanged = null` now returns
NoUsableSubResponse where the assertion names ExtendNotRequested, so the test
fails rather than passing vacuously. The comment still warned about the silent
version.

The same sentence in AlterTtlApiTest was updated in that commit; this is its
twin one file over, which was missed.

Comment only.
…d actually is

Dirtying a config moves its current hashes into the old set and clears them, so
a dirty config usually no longer holds anything the swarm reported missing and
the hash-intersection check rejects it before the clean check is consulted —
which makes that check look like dead code to anyone auditing it.

It is not, and the exception is the reason to keep it: active hashes are the
current hashes plus the parts of any pending multipart set, and that second
component survives dirtying. A config that went dirty mid-multipart, one of
whose part hashes has been lost, reaches the check with a real intersection.

Both facts are libsession's rather than ours and cannot be asserted from a JVM
unit test, which cannot load the native library — so the reasoning is recorded
at the guard with its source location, and the test that reaches the branch now
says what it does not establish, since it gets there through a mock that can
present a state the real path reaches only one way.

Comments only.
…no test reached

A sub-response counts as usable only if it is not failed AND carries an
unchanged map. Every failed-node fixture in the suite left unchanged absent, so
the second condition did all the excluding and the first was never consulted:
deleting it passed all 64 tests.

The case it alone covers is a snode that reports failure while still carrying
unchanged. Read as usable, its empty arrays become authority and every
requested hash is reported missing — re-storing configs the swarm still holds,
on the word of a node that said it failed.

Confirmed rather than assumed: dropping the term alongside a control mutation
known to kill two tests left this hole alive while the control fired, proving
the build carried both edits. The new test now dies on that same mutation and
nothing else does.
…sub-response

A sub-response is unusable if it failed or if it omits unchanged, and this
vector is about exactly that — but the fixture had no failed node at all, so it
exercised the second route twice and was insensitive to the first.

It now mixes all three cases and dies under either term being dropped.

The failed node claims to hold nothing, which is what makes its exclusion
observable: absence is decided by any(), so one snode reporting a hash missing
already settles it and a failed node that HELD the hashes could not change the
verdict — including it would be inert and the test would survive looking
correct. Claiming nothing moves the expected set from {h2} to {h1,h2}.
…s being unrecoverable

Four comments and a test name said libsession offers no way to re-serialise a
loaded keys config. libsession retains the bytes of active keys messages and
exposes them; what is missing is a JNI binding, so the limitation is this
platform's and not the format's.

The distinction is the whole point of the comments: attributing it to libsession
reads as impossible, when it is in fact not yet plumbed — and a member CAN
repair a group's keys by pushing retained bytes back, since they land on the
same hash without being re-signed.

The keys-exclusion test now records that it should invert when the binding
lands, rather than be deleted, and no longer asserts the impossibility in its
own name.
The keys-exclusion comment justified the expired banner with "only an admin
rekey can fix it". That is false once any device holds the bytes: retention
happens on LOAD, not on authorship, so an admin immediately after its own rekey
holds nothing for the message it just created and is the device least able to
repair it. A member holding the bytes can push them back, signature and all.

An admin rekey is the remedy only when no device anywhere still holds the bytes,
which is what the banner is actually for.
libsession retains the raw bytes of every keys message this device has loaded,
and pushing them back lands on the same hash without being re-signed — so a
member can repair a group whose keys the swarm has dropped, and an admin
immediately after its own rekey holds nothing and is the device least able to.

- Keys join the restorable set. Every retained message goes back, not just the
  reported one: a generation is a rekey plus its supplementals, a member holding
  part of one cannot derive the key, and the retained map carries no generation
  field to group by. The superset is bounded and idempotent.
- "Expired" now means the keys are gone AND this device cannot put them back.
  That is one rule, so canRepairKeys is an input to the determinant rather than
  an override applied to its answer — a lambda, so answering it (which takes the
  config lock) is deferred until the guards that could make it irrelevant have
  passed.
- The flag is withheld rather than raised and cleared. It drives a visible
  conversation banner, so correcting it afterwards is a flicker on a group that
  was never out of reach.
- A successful keys re-store announces itself, and ExpiredGroupManager merges
  that in as another "not expired" event rather than exposing a setter. The
  reactive clear fires when a keys message is HANDLED, and the device that
  re-stored it already holds that hash, so it may never handle it again.
- One predicate behind both the flag and the re-store. If they disagreed as
  "flag says repairable, recovery declines", the banner would never appear and
  nothing would be fixed.

Requires the activeKeyMessages() binding on the libsession-util-android branch.
@mpretty-cyro
mpretty-cyro force-pushed the feature/config-recovery branch from 88ca431 to 2d92d84 Compare August 7, 2026 01:20
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