A peer answering a REQUEST_SYNC replays archived packets with their
original timestamps, and the only thing marking a reply is TTL=0, so
a receiver applying a freshness window filters the replay out. iOS
added flag 0x10 for this in January (#965) and removed its legacy
acceptance of unflagged TTL=0 replies eleven days later (#998); this
app never set the flag. An iOS user next to Android phones gets two
minutes of history instead of what those phones' archives hold.
Define the flag, carry it through encode and decode, set it at the two
archive replay sites, and propagate it into fragments the way iOS
does. It stays out of the signing preimage, like TTL, so a packet
signed at first send still verifies when later served from the
archive; it stays out of equals/hashCode, as PacketIdUtil computes
identity from type, sender, timestamp and payload alone. The
REQUEST_SYNC spec in docs/sync.md now names the flag. Send-side only:
nothing changes about what this app accepts.
Twelve tests pin the wire bit against iOS, the signing preimage, the
byte-reuse interaction with #863, both replay sites, and fragment
propagation. Gates: 820 tests, 0 failures, 3 pre-existing skips; lint
and build pass. Not validated on hardware; the fragment limit and the
two narrow cases that now match iOS-to-iOS behavior are in the PR.
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.
The collision case fails on main: two packets sharing a 64-byte prefix and
a timestamp, where the second was silently dropped.
The other two pass before and after on purpose. Replay of an identical
packet must still be caught, and the same packet arriving from two
different peers must still be tracked separately — strengthening the
identity must not quietly weaken either.
Replay and duplicate detection keyed on a 32-bit contentHashCode over at
most the first 64 bytes of the payload. Two packets from the same peer in
the same millisecond that agreed on that prefix were the same packet as
far as this cache was concerned, and a collision here is a dropped
message: the second is discarded and nothing reports it.
PacketIdUtil is the identity the rest of the stack already uses for this
question — gossip sync membership, and the message IDs MessageHandler
assigns — and iOS derives it identically: first 16 bytes of SHA-256 over
type, senderID, timestamp and the whole payload. The security path now
agrees with the sync path instead of carrying a weaker private notion of
"same packet", and the FRAGMENT special case disappears because the full
payload is covered either way.
Peer scoping is deliberately kept. PacketIdUtil covers the packet's own
senderID, which is not the peer it arrived from once relayed.