If SQLite clearAll fails mid-panic, in-memory state was already cleared
but a process restart could reload encrypted history from disk (#699).
Fall back to deleting the database files before reporting failure.
A relay that fails is retried on an exponential backoff, then abandoned
for the lifetime of the process. Nothing brings it back: the relay layer
registers no connectivity callback, the periodic subscription validator
only repairs subscriptions on sockets that are already open (and returns
immediately when connectedRelayCount is 0, which is exactly the state
after an outage), and connect() runs once from NostrClient.initialize().
The remaining paths that reset reconnectAttempts are a manual retry, a
Tor state change, and a successful open.
Two ways a relay died permanently:
- Any error whose message mentioned DNS returned before scheduling
anything at all. "Unable to resolve host" is what this device reports
when it simply has no network, so a moment in a tunnel killed every
relay at once, with no retry ever.
- Otherwise the schedule stopped at MAX_RECONNECT_ATTEMPTS. With
INITIAL=1s and MULTIPLIER=2 that is nine waits totalling about eight
and a half minutes, after which the relay was dead. MAX_BACKOFF_INTERVAL
was unreachable: attempt 9 asks for 256s and attempt 10 gave up, so the
five-minute ceiling the constant defines never applied to anything.
Let the backoff saturate at MAX_BACKOFF_INTERVAL and keep retrying there.
A name-resolution failure now backs off like any other error. Steady state
costs one connection attempt per relay per five minutes; the previous
behaviour cost the user every internet DM, delivery receipt and geohash
channel until they noticed and restarted the app.
The schedule moves into RelayReconnectPolicy so it is unit-testable
without OkHttp or a Context.
Extends the existing LEAVE replay-window test to the relayed types.
Pins that the bound is future-only — a six-hour-old message still
delivers so store-and-forward keeps working — and that it is no tighter
than the announcement allowance.
Removing the check fails the two rejection tests and leaves the three
admission tests green.
validatePacket bounds the timestamp of exactly one packet type. LEAVE
gets a symmetric five-minute window; ANNOUNCE gets one in
AnnouncementIdentityValidator. Everything else — MESSAGE, FRAGMENT,
FILE_TRANSFER, VOICE_FRAME — is admitted with any timestamp its sender
signs, because a signature authenticates the sender's choice without
sanity-checking it.
That timestamp then decides ordering downstream. GossipSyncManager builds
its advertised sync filter with sortByDescending { it.timestamp } and
takes the top N, so broadcasts stamped far in the future occupy every
slot and keep real traffic out of gossip sync — not just locally, but for
every neighbor that syncs against us. The public timeline orders on the
same field.
Bound the future direction for every type at the allowance that already
admits the peer's announcement. A device skewed further than that cannot
get an ANNOUNCE verified, so it has no working session today and nothing
that currently works starts failing. LEAVE keeps its tighter symmetric
window.
The past deliberately stays unbounded: store-and-forward replays packets
cached for up to twelve hours, and bounding it would drop them silently.