@@ -5,13 +5,9 @@
55//! key-value storage, a timer, and (natively, fed back as events) the Keychain,
66//! QR scanning, photo picking, and clipboard.
77
8−use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
9−use std::sync::Arc;
8+use std::collections::{BTreeMap, VecDeque};
109use std::time::{Duration, SystemTime, UNIX_EPOCH};
1110
12−use base64::prelude::*;
13−use base64::Engine;
14−
1511use crux_core::{
1612 macros::effect,
1713 render::{render, RenderOperation},
@@ -61,24 +57,6 @@ const MAX_CONTACTS: usize = 500;
6157/// the budget is dropped exactly like an undecryptable blob would be.
6258const MAX_POLL_TOTAL_BYTES: usize = 64 * 1024 * 1024;
6359
64−/// Cap on the number of events we will attempt to decrypt out of a single poll
65−/// page, independent of the byte budget. Minimum-size blobs (173 bytes decoded)
66−/// would otherwise permit ~388K events within the 64 MiB byte budget, each
67−/// costing a full scalar mult + SHA-512 + AEAD pass — enough to freeze the app
68−/// for tens of seconds per poll. An honest relay caps at 50 events
69−/// (`maxPollEvents`); this is a hostile-relay bound. Set above `MAX_ACK_IDS`
70−/// (1024) so a legitimate large page from one sender still fully processes.
71−const MAX_POLL_EVENTS: usize = 2000;
72−
73−/// Upper bound on a poll response body we will buffer and parse, in bytes.
74−///
75−/// Before the per-blob byte/count budgets in `ingest_poll` ever run, the client
76−/// reads the entire response body into a `Vec<u8>` and deserializes it. A
77−/// multi-gigabyte response from a hostile relay would OOM the app before
78−/// `ingest_poll`'s defenses are consulted. This cap (generously above what 50
79−/// max-size blobs + JSON envelope could cost) bounds the allocation.
80−const MAX_POLL_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
81−
8260/// How long a poll may stay in flight before we assume the shell lost it.
8361///
8462/// `polling` gates the whole long-poll loop, and it is only cleared by a
@@ -94,71 +72,16 @@ const MAX_POLL_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
9472/// relay, so leave the margin.
9573const POLL_WATCHDOG_MS: u64 = 90_000;
9674
97−/// How long a send may stay in flight before we assume the shell lost it.
98−///
99−/// `flushing` gates the whole send loop, and it is only cleared by a
100−/// `SendResult`. If the shell ever drops the HTTP effect (a cancelled task, a
101−/// backgrounded app, a bug), `flushing` stays `true` and the outbox is
102−/// permanently stuck — new messages queue up but never send. The watchdog
103−/// releases the guard so the next `StartFlush` can retry.
104−///
105−/// Kept above the shell's 70 s HTTP timeout for the same reason as
106−/// `POLL_WATCHDOG_MS`: a misfire on a merely slow send is survivable (the late
107−/// `SendResult` finds `in_flight` already cleared and pops nothing), but each
108−/// one costs a redundant request.
109−const FLUSH_WATCHDOG_MS: u64 = 90_000;
110−
111−/// How long an auth round-trip may stay in flight before we assume the
112−/// shell lost it. Same rationale as `POLL_WATCHDOG_MS`: `authenticating`
113−/// gates the whole auth flow, and without a watchdog a dropped effect
114−/// leaves it stuck forever. Kept above the 70 s shell HTTP timeout.
115−const AUTH_WATCHDOG_MS: u64 = 90_000;
116−
117−/// Messages per `/messages` request.
118−///
119−/// The relay rejects a longer batch outright (`413 batch_too_large`,
120−/// PROTOCOL.md §7), so this is a hard ceiling, not a tuning knob.
121−const MAX_SEND_BATCH: usize = 100;
122−
123−/// ...and a byte ceiling on the same batch, counted in hex characters — i.e. in
124−/// the units the request body is actually built from.
125−///
126−/// The count limit alone is not enough: a hundred profile broadcasts each
127−/// carrying a 64 KiB photo would build a request far past the 16 MiB body cap a
128−/// relay has by default (`--http-max-body-bytes`, PROTOCOL.md §7), and the whole
129−/// batch would come back `413` — which we read as a transient failure and retry,
130−/// forever. Bounding the body here keeps the batch inside what any conforming
131−/// relay accepts. A single item over the budget still goes out alone: it is the
132−/// relay's job to reject it, and its rejection is per-message and permanent.
133−const MAX_SEND_BATCH_BYTES: usize = 4 * 1024 * 1024;
134−
135−/// Ceiling on a `Retry-After` we will honour from a relay.
136−///
137−/// The header is a number the relay chooses and we obey, so without a cap a
138−/// single `Retry-After: 999999999` — from a hostile relay, or a merely broken
139−/// one — parks the outbox for thirty years with nothing on screen to say why.
140−/// Five minutes is longer than any real rate-limit window and still recovers on
141−/// its own.
142−const MAX_RETRY_AFTER_MS: u64 = 5 * 60 * 1000;
143−
14475// kv keys
14576const K_SETTINGS: &str = "settings";
14677const K_PROFILE: &str = "profile";
14778const K_CONTACTS: &str = "contacts";
14879const K_CURSOR: &str = "cursor";
14980const K_OUTBOX: &str = "outbox";
150−const K_SEEN_IDS: &str = "seen_ids";
15181fn k_messages(peer: &str) -> String {
15282 format!("messages:{peer}")
15383}
15484
155−/// N7: Cap on the per-peer `seen_ids` dedup set. Without a cap the set grows
156−/// without bound — a peer who sends thousands of messages accumulates
157−/// thousands of IDs, and the `seen_ids` kv blob is rewritten in full on every
158−/// incoming message. Kept well above `MAX_MESSAGES_PER_PEER` (1000) so a
159−/// trim-and-replay within the conversation window still hits the set.
160−const MAX_SEEN_IDS_PER_PEER: usize = 2000;
161−
16285// ---------------------------------------------------------------------------
16386// Effect
16487// ---------------------------------------------------------------------------
@@ -196,10 +119,6 @@ pub enum Event {
196119 SendText,
197120 AddContact { input: String, nickname: String },
198121 SetBlocked { peer: String, blocked: bool },
199− /// Forget a peer entirely: the contact, the conversation, and anything still
200− /// queued for them. Blocking only silences a peer; the entry stays in the
201− /// list forever, and there was no way to be rid of it.
202− DeleteContact { peer: String },
203122 SaveProfile {
204123 display_name: String,
205124 bio: String,
@@ -226,9 +145,6 @@ pub enum Event {
226145 LoadedOutbox(#[facet(opaque)] KvData),
227146 #[serde(skip)]
228147 #[facet(skip)]
229− LoadedSeenIds(#[facet(opaque)] KvData),
230− #[serde(skip)]
231− #[facet(skip)]
232148 LoadedMessages(String, #[facet(opaque)] KvData),
233149 #[serde(skip)]
234150 #[facet(skip)]
@@ -262,17 +178,6 @@ pub enum Event {
262178 #[serde(skip)]
263179 #[facet(skip)]
264180 StartFlush,
265− /// Fires `FLUSH_WATCHDOG_MS` after generation `n`'s flush is issued; a no-op
266− /// unless that exact send is somehow still in flight — same pattern as
267− /// `PollWatchdog`.
268− #[serde(skip)]
269− #[facet(skip)]
270− FlushWatchdog(u64),
271− /// Fires `AUTH_WATCHDOG_MS` after generation `n`'s auth round-trip is
272− /// issued; a no-op unless that exact auth is somehow still in flight.
273− #[serde(skip)]
274− #[facet(skip)]
275− AuthWatchdog(u64),
276181 #[serde(skip)]
277182 #[facet(skip)]
278183 ChallengeResult(u64, #[facet(opaque)] HttpResult),
@@ -284,7 +189,7 @@ pub enum Event {
284189 PollResult(u64, #[facet(opaque)] HttpResult),
285190 #[serde(skip)]
286191 #[facet(skip)]
287− SendResult(u64, #[facet(opaque)] HttpResult),
192+ SendResult(#[facet(opaque)] HttpResult),
288193}
289194
290195// ---------------------------------------------------------------------------
@@ -310,24 +215,9 @@ pub enum Page {
310215 EditProfile,
311216}
312217
313−/// The six kv keys loaded at startup (`settings`, `profile`, `contacts`,
314−/// `cursor`, `outbox`, `seen_ids`). `Connect` waits for all of them.
315−const STARTUP_LOADS: u8 = 6;
316−
317−/// The batch a live `/messages` request is carrying: the first `count` items of
318−/// the outbox, all bound for `recipient`.
319−///
320−/// `SendResult` needs all three. The count is what it pops on success and what it
321−/// charges a retry against on a transient failure; the recipient is what tells
322−/// `SetBlocked` and `DeleteContact` whether the items they are about to remove
323−/// are the very ones this send is going to pop; and `bytes` — the size of the
324−/// body we actually built — is what a `413` halves to find a batch this relay
325−/// will accept (see `Model::send_batch_budget`).
326−struct InFlight {
327− recipient: String,
328− count: usize,
329− bytes: usize,
330−}
218+/// The five kv keys loaded at startup (`settings`, `profile`, `contacts`,
219+/// `cursor`, `outbox`). `Connect` waits for all of them.
220+const STARTUP_LOADS: u8 = 5;
331221
332222pub struct Model {
333223 /// Zeroized on drop: this is the Ed25519 seed, the one secret whose leak
@@ -338,23 +228,11 @@ pub struct Model {
338228 profile: OwnProfile,
339229 contacts: BTreeMap<String, Contact>,
340230 messages: BTreeMap<String, Vec<StoredMessage>>,
341− /// Q18: Per-peer dedup ID set, independent of the trimmed conversation.
342− /// Without this, a replayed blob whose `id` was aged out by `trim_history`
343− /// (past the 1000-message cap) passes the dedup check and re-enters the
344− /// conversation. This set is loaded from and persisted to kv alongside
345− /// messages, so it survives restarts.
346− seen_ids: BTreeMap<String, HashSet<String>>,
347231 outbox: VecDeque<OutboxItem>,
348232 cursor: i64,
349233 token: Option<String>,
350234 conn: ConnStatus,
351235 poll_retries: u32,
352− /// The send loop's own backoff counter, spent when a `429` arrives with no
353− /// `Retry-After` to obey. Kept apart from `poll_retries` because the poll loop
354− /// resets that one on every page it takes: a client polling happily while the
355− /// relay rate-limits its sends would restart the send backoff at 3 s forever,
356− /// which is not a backoff.
357− send_retries: u32,
358236 /// A poll request is in flight. Guards against stacking concurrent long-poll
359237 /// loops (every extra loop re-polls itself forever and never terminates).
360238 polling: bool,
@@ -382,65 +260,11 @@ pub struct Model {
382260 /// flight from before the app was backgrounded must not install a token or
383261 /// overwrite `conn` on top of the attempt that replaced it.
384262 auth_gen: u64,
385− /// A `/messages` request is in flight. One at a time: `SendResult` pops the
386− /// items it sent off the head of the outbox, so two overlapping sends would
387− /// pop each other's messages.
388263 flushing: bool,
389− /// Which flush the in-flight `SendResult` / `FlushWatchdog` is allowed to
390− /// belong to. Same pattern as `poll_gen`: bumped every time a flush is
391− /// issued and every time one is abandoned, so a `SendResult` or watchdog
392− /// from a superseded send cannot clear the guard or pop items belonging to
393− /// the replacement.
394− flush_gen: u64,
395− /// What the in-flight send will pop off the head of the outbox when it
396− /// succeeds — see `InFlight`. `None` while nothing is in flight, and also
397− /// while a send is in flight whose items have since been removed from under
398− /// it (`SetBlocked`, `DeleteContact`): there is then nothing left to pop, and
399− /// popping by count anyway would eat whichever messages slid into their place.
400− in_flight: Option<InFlight>,
401− /// What this relay will actually accept as a request body, in hex characters,
402− /// once it has told us. `None` until then — meaning "assume
403− /// `MAX_SEND_BATCH_BYTES`", which is a guess at a cap the protocol never
404− /// publishes.
405− ///
406− /// A relay configured below our guess answers `413`, and a `413` read as a
407− /// transient failure is a batch that is rebuilt identically, rejected
408− /// identically, and retried until every item in it has burned its retry budget
409− /// and been dropped — the outbox drains into the void without a single message
410− /// leaving. So a `413` *measures* the cap instead: halve what we just tried and
411− /// rebuild smaller. It is deliberately not reset on success — the batch that
412− /// worked is evidence about the relay, and the relay's cap does not grow — only
413− /// on a server change, where the evidence no longer applies.
414− send_batch_budget: Option<usize>,
415264 /// Startup kv loads still outstanding. `Connect` only fires at 0, so a poll
416265 /// can't land and be persisted against a half-loaded model — which would
417266 /// then be clobbered by the loads still in flight.
418267 loads_pending: u8,
419− /// A startup kv *read* failed — as opposed to finding the key absent.
420− ///
421− /// The distinction is the whole point of `parse_kv`'s `Result<Option<T>, ()>`:
422− /// an absent key is a fresh install and defaulting is right, but a failed read
423− /// leaves a hole in the model over data that is still on disk, and writing that
424− /// hole back destroys it. The outbox is the sharpest case — `ingest_poll`
425− /// persists it on *every* poll page, so a single failed read followed by one
426− /// empty poll writes `[]` over a queue of real, unsent messages.
427− ///
428− /// So this latches the model as untrustworthy, and every write path checks it:
429− /// no persist, no ingest, no flush. Nothing is lost, because nothing is
430− /// written — the data stays on disk for the next launch, which re-runs the
431− /// loads (`IdentityLoaded` fires on every boot) and clears the flag if they
432− /// succeed. It is deliberately one flag and not a per-key set: a read that
433− /// fails because the device is locked fails for every key, and a model that is
434− /// wrong about its outbox is not one to be trusted about its contacts either.
435− kv_load_failed: bool,
436− /// `now_ms()` before which `flush_next` must not send.
437− ///
438− /// A `429` schedules a `StartFlush` for the far side of the relay's
439− /// `Retry-After`, but that timer is not the only thing that fires one — a poll
440− /// page and a fresh `SendText` both do. Without a pause the wait is decorative:
441− /// the next poll page walks straight through it and hammers the relay that just
442− /// asked us to stop.
443− flush_paused_until: i64,
444268 page: Page,
445269 active_peer: Option<String>,
446270 compose: String,
@@ -456,24 +280,17 @@ impl Default for Model {
456280 profile: OwnProfile::default(),
457281 contacts: BTreeMap::new(),
458282 messages: BTreeMap::new(),
459− seen_ids: BTreeMap::new(),
460283 outbox: VecDeque::new(),
461284 cursor: 0,
462285 token: None,
463286 conn: ConnStatus::Offline,
464287 poll_retries: 0,
465− send_retries: 0,
466288 polling: false,
467289 poll_gen: 0,
468290 authenticating: false,
469291 auth_gen: 0,
470292 flushing: false,
471− flush_gen: 0,
472− in_flight: None,
473− send_batch_budget: None,
474293 loads_pending: 0,
475− kv_load_failed: false,
476− flush_paused_until: 0,
477294 page: Page::default(),
478295 active_peer: None,
479296 compose: String::new(),
@@ -520,49 +337,11 @@ impl Model {
520337 self.auth_gen
521338 }
522339
523− /// Open a new flush generation and return it. Same pattern as
524− /// `next_poll_gen`: a `SendResult` or `FlushWatchdog` whose generation no
525− /// longer matches is dropped, so a late result from a superseded send
526− /// cannot clear the guard or pop items belonging to the replacement.
527− fn next_flush_gen(&mut self) -> u64 {
528− self.flush_gen = self.flush_gen.wrapping_add(1);
529− self.flush_gen
530− }
531−
532− /// Abandon the in-flight flush, if any: release the guard and retire the
533− /// generation so a late `SendResult` cannot clear the *replacement* flush's
534− /// guard or pop its items.
535− fn abandon_flush(&mut self) {
536− self.flushing = false;
537− self.in_flight = None;
538− self.flush_gen = self.flush_gen.wrapping_add(1);
539− }
540−
541340 /// Abandon the in-flight auth round-trip (either leg).
542341 fn abandon_auth(&mut self) {
543342 self.authenticating = false;
544343 self.auth_gen = self.auth_gen.wrapping_add(1);
545344 }
546−
547− /// Every item bound for `peer` is about to be removed from the outbox. If the
548− /// send in flight is the one carrying them, forget what it was going to pop:
549− /// those items will not be there when it lands, and popping by count anyway
550− /// would take whatever slid into their place — another peer's mail.
551− ///
552− /// The `flushing` guard stays up. Nothing can cancel the request; it is still
553− /// out there, and a second send started underneath it would have its own items
554− /// popped by the first one's result.
555− fn detach_in_flight(&mut self, peer: &str) {
556− if self.in_flight.as_ref().is_some_and(|f| f.recipient == peer) {
557− self.in_flight = None;
558− }
559− }
560−
561− /// How many items at the head of the outbox are already on the wire, and so
562− /// must not be rewritten in place (their ciphertext is built and gone).
563− fn in_flight_count(&self) -> usize {
564− self.in_flight.as_ref().map_or(0, |f| f.count)
565− }
566345}
567346
568347// ---------------------------------------------------------------------------
@@ -579,12 +358,11 @@ impl Model {
579358/// `ingest_poll`) would have to become a two-step round-trip through the shell.
580359/// That refactor is worth doing; it is not worth folding into a bug-fix pass, so
581360/// the impurity is documented rather than hidden.
582−// TODO: replace with crux_time effect for deterministic timestamps
583361fn now_ms() -> i64 {
584362 SystemTime::now()
585363 .duration_since(UNIX_EPOCH)
586364 .map(|d| d.as_millis() as i64)
587− .unwrap_or(1)
365+ .unwrap_or(0)
588366}
589367
590368/// Bare lowercased hostname of a server URL (no scheme/port/trailing dot).
@@ -672,16 +450,6 @@ struct PollEvent {
672450 #[serde(rename = "encryptedBlob")]
673451 encrypted_blob: String,
674452}
675−
676−/// Q17: Deferred delivery-ack accumulator. Collects ack_ids and the max `ts`
677−/// across all `delivery.ack` payloads from one sender in a single poll page,
678−/// so the staleness check and watermark update run once per sender after the
679−/// event loop — immune to relay page ordering.
680−#[derive(Default)]
681−struct PendingAck {
682− ts: i64,
683− ids: HashSet<String>,
684−}
685453#[derive(Serialize)]
686454struct SendBatch {
687455 messages: Vec<Envelope>,
@@ -715,152 +483,63 @@ impl App for Skrepka {
715483 // Fan out the startup loads. `Connect` waits until the last of
716484 // them lands (see `startup_load_done`).
717485 model.loads_pending = STARTUP_LOADS;
718− // A fresh round of loads: whatever a previous one failed to read,
719− // this one gets to answer for. Clearing it here is what lets a
720− // relaunch recover from a locked-device read failure.
721− model.kv_load_failed = false;
722486 Command::all([
723487 KeyValue::get(K_SETTINGS).then_send(Event::LoadedSettings),
724488 KeyValue::get(K_PROFILE).then_send(Event::LoadedProfile),
725489 KeyValue::get(K_CONTACTS).then_send(Event::LoadedContacts),
726490 KeyValue::get(K_CURSOR).then_send(Event::LoadedCursor),
727491 KeyValue::get(K_OUTBOX).then_send(Event::LoadedOutbox),
728− KeyValue::get(K_SEEN_IDS).then_send(Event::LoadedSeenIds),
729492 ])
730493 .and(render())
731494 }
732495
733496 Event::LoadedSettings(res) => {
734− match parse_kv::<Settings>(res) {
735− Ok(Some(s)) => {
736− // Re-validate on the way in. The URL was normalized when the
737− // user typed it, but the blob on disk is not trustworthy: an
738− // older build wrote URLs this parser rejects, and a corrupted
739− // or hand-edited file would reach `Http::post`, which unwraps
740− // the parse. That is a panic on every launch — a boot loop
741− // with no way out from inside the app.
742− model.settings.server_url = normalize_server_url(&s.server_url)
743− .unwrap_or_else(|| DEFAULT_SERVER_URL.to_string());
744− }
745− Ok(None) => {}
746− Err(()) => {
747− model.kv_load_failed = true;
748− model.error = Some("could not read settings from storage".into());
749− }
497+ if let Some(s) = parse_kv::<Settings>(res) {
498+ // Re-validate on the way in. The URL was normalized when the
499+ // user typed it, but the blob on disk is not trustworthy: an
500+ // older build wrote URLs this parser rejects, and a corrupted
501+ // or hand-edited file would reach `Http::post`, which unwraps
502+ // the parse. That is a panic on every launch — a boot loop
503+ // with no way out from inside the app.
504+ model.settings.server_url = normalize_server_url(&s.server_url)
505+ .unwrap_or_else(|| DEFAULT_SERVER_URL.to_string());
750506 }
751507 self.startup_done(model).and(render())
752508 }
753509 Event::LoadedProfile(res) => {
754− match parse_kv::<OwnProfile>(res) {
755− Ok(Some(p)) => model.profile = p,
756− Ok(None) => {}
757− Err(()) => {
758− model.kv_load_failed = true;
759− model.error = Some("could not read profile from storage".into());
760− }
510+ if let Some(p) = parse_kv::<OwnProfile>(res) {
511+ model.profile = p;
761512 }
762513 self.startup_done(model).and(render())
763514 }
764515 Event::LoadedContacts(res) => {
765516 let mut cmds = vec![self.startup_done(model)];
766− match parse_kv::<Vec<Contact>>(res) {
767− Ok(Some(list)) => {
768− model.contacts = list.into_iter().map(|c| (c.pubkey.clone(), c)).collect();
769− // Lazily load each conversation's messages.
770− cmds.extend(model.contacts.keys().map(|peer| {
771− let peer = peer.clone();
772− KeyValue::get(k_messages(&peer))
773− .then_send(move |r| Event::LoadedMessages(peer.clone(), r))
774− }));
775− }
776− Ok(None) => {}
777− Err(()) => {
778− model.kv_load_failed = true;
779− model.error = Some("could not read contacts from storage".into());
780− }
517+ if let Some(list) = parse_kv::<Vec<Contact>>(res) {
518+ model.contacts = list.into_iter().map(|c| (c.pubkey.clone(), c)).collect();
519+ // Lazily load each conversation's messages.
520+ cmds.extend(model.contacts.keys().map(|peer| {
521+ let peer = peer.clone();
522+ KeyValue::get(k_messages(&peer))
523+ .then_send(move |r| Event::LoadedMessages(peer.clone(), r))
524+ }));
781525 }
782526 Command::all(cmds).and(render())
783527 }
784528 Event::LoadedCursor(res) => {
785− match parse_kv::<i64>(res) {
786− Ok(Some(c)) => model.cursor = c,
787− Ok(None) => {}
788− Err(()) => {
789− model.kv_load_failed = true;
790− model.error = Some("could not read cursor from storage".into());
791− }
529+ if let Some(c) = parse_kv::<i64>(res) {
530+ model.cursor = c;
792531 }
793532 self.startup_done(model).and(render())
794533 }
795534 Event::LoadedOutbox(res) => {
796− match parse_kv::<Vec<OutboxItem>>(res) {
797− Ok(Some(list)) => model.outbox = list.into(),
798− Ok(None) => {}
799− Err(()) => {
800− model.kv_load_failed = true;
801− model.error = Some("could not read outbox from storage".into());
802− }
803− }
804− self.startup_done(model).and(render())
805− }
806− Event::LoadedSeenIds(res) => {
807− match parse_kv::<BTreeMap<String, HashSet<String>>>(res) {
808− Ok(Some(map)) => model.seen_ids = map,
809− Ok(None) => {}
810− Err(()) => {
811− // Non-fatal: dedup falls back to the conversation-derived
812− // set, which is weaker but functional.
813− model.seen_ids = BTreeMap::new();
814− }
535+ if let Some(list) = parse_kv::<Vec<OutboxItem>>(res) {
536+ model.outbox = list.into();
815537 }
816538 self.startup_done(model).and(render())
817539 }
818540 Event::LoadedMessages(peer, res) => {
819− match parse_kv::<Vec<StoredMessage>>(res) {
820− Ok(Some(list)) => {
821− // D5: Add on-disk message IDs to seen_ids so future
822− // replays don't pass dedup. Without this, a seen_ids
823− // kv load failure leaves the set empty, and on-disk
824− // messages aren't protected against replay.
825− if let Some(id_set) = model.seen_ids.get_mut(&peer) {
826− id_set.extend(list.iter().map(|m| m.id.clone()));
827− }
828− // Merge: a poll that beat this lazy load may have already
829− // inserted messages for this peer. Overwriting with the
830− // on-disk list (which the GET read before the poll wrote)
831− // would silently destroy the freshly-ingested messages.
832− // Union by id, keeping both the on-disk history and any
833− // in-memory additions.
834− match model.messages.get_mut(&peer) {
835− Some(existing) => {
836− let existing_ids: HashSet<&String> =
837− existing.iter().map(|m| &m.id).collect();
838− let new_msgs: Vec<StoredMessage> = list
839− .into_iter()
840− .filter(|m| !existing_ids.contains(&m.id))
841− .collect();
842− existing.extend(new_msgs);
843− // Re-sort by ts to maintain the invariant.
844− existing.sort_by_key(|m| m.ts);
845− trim_history(existing);
846− }
847− None => {
848− model.messages.insert(peer, list);
849− }
850− }
851− }
852− Ok(None) => {}
853− Err(()) => {
854− // Leaving the conversation absent is not enough on its own.
855− // An absent conversation is an *empty* one to everything
856− // downstream, so the next message from this peer would be
857− // appended to nothing and `persist_messages` would write
858− // that one message over the whole history still on disk.
859− // Latch the model as untrustworthy instead — same reasoning
860− // as the outbox, same fix.
861− model.kv_load_failed = true;
862− model.error = Some("could not read messages from storage".into());
863− }
541+ if let Some(list) = parse_kv::<Vec<StoredMessage>>(res) {
542+ model.messages.insert(peer, list);
864543 }
865544 render()
866545 }
@@ -938,12 +617,6 @@ impl App for Skrepka {
938617 model.settings.server_url = url;
939618 model.token = None;
940619 model.conn = ConnStatus::Offline;
941− // Whatever we learned about the old relay's body cap was about *it*.
942− // A new relay is entitled to a bigger one, and carrying a shrunken
943− // budget over would cap our batches against a limit that no longer
944− // exists — with no `413` left to correct it, since the new relay has
945− // no reason to send one.
946− model.send_batch_budget = None;
947620 // The cursor is *per relay*: it is that server's own sequence
948621 // number, and the next poll acks everything up to it. Carrying
949622 // relay A's cursor (roughly `now_ms()`) over to relay B tells B
@@ -957,30 +630,12 @@ impl App for Skrepka {
957630 // would then ack a sequence number B never issued, and B would
958631 // drop every message waiting for us.
959632 model.abandon_poll();
960− // ...and retire the send that is still in flight *to relay A*,
961− // for the same reason: its `SendResult` carries A's acceptance,
962− // and popping items here would eat whatever the new relay's
963− // send is about to carry. The generation check in `SendResult`
964− // is what makes the late result a no-op.
965− model.abandon_flush();
966− // The old relay's rate limit was the old relay's opinion. A new one is
967− // entitled to take our sends immediately, and carrying the pause over
968− // would park the outbox against a limit that no longer applies.
969− model.flush_paused_until = 0;
970− model.send_retries = 0;
971− // D2: Reset poll retries too — the new relay's poll failures
972− // should start fresh, not inherit the old relay's backoff.
973− model.poll_retries = 0;
974− let writes = if self.refuse_write(model) {
975− Command::done()
976− } else {
977− Command::all([
978− KeyValue::set(K_SETTINGS, json_bytes(&model.settings))
979− .then_send(Event::Saved),
980− KeyValue::set(K_CURSOR, json_bytes(&0i64)).then_send(Event::Saved),
981− ])
982− };
983− writes.and(Command::event(Event::Connect)).and(render())
633+ Command::all([
634+ KeyValue::set(K_SETTINGS, json_bytes(&model.settings)).then_send(Event::Saved),
635+ KeyValue::set(K_CURSOR, json_bytes(&0i64)).then_send(Event::Saved),
636+ ])
637+ .and(Command::event(Event::Connect))
638+ .and(render())
984639 }
985640 Event::Connect => {
986641 // Connect restarts the auth flow, so whatever challenge/verify was
@@ -991,42 +646,13 @@ impl App for Skrepka {
991646 // and about to resolve. Clearing the guard alone would let it land
992647 // and install its token on top of the attempt replacing it.
993648 model.abandon_auth();
994− // The shell fires `Connect` on every return to the foreground. A model
995− // that failed a startup read must not take that invitation: connecting
996− // leads to polling, and polling into a hole acks mail to the relay that
997− // we cannot store. Staying offline is what keeps the disk intact until
998− // a relaunch re-runs the loads.
999− if model.kv_load_failed {
1000− return render();
1001− }
1002649 if model.secret_key.is_some() && model.conn != ConnStatus::Online {
1003650 Command::event(Event::Authenticate)
1004− } else if model.secret_key.is_some()
1005− && model.conn == ConnStatus::Online
1006− && !model.polling
1007− {
1008− // The poll loop can die without `conn` going offline: after
1009− // `PollResult` clears `polling`, the re-poll is chained off
1010− // `SavedCursor`. If the shell drops that effect (backgrounded
1011− // app, bug), `polling` stays `false` with no pending re-poll
1012− // and the watchdog sees `!polling` and does nothing. The
1013− // status shows "online" but no polling ever happens. Restart
1014− // the loop here — `Connect` is fired on every foreground return.
1015− Command::event(Event::Poll)
1016651 } else {
1017652 render()
1018653 }
1019654 }
1020655 Event::AddContact { input, nickname } => {
1021− // Clear a stale error from a previous failed add.
1022− model.error = None;
1023− // A nickname is stored in the contacts kv blob, which is
1024− // rewritten in full on every change. An unbounded nickname
1025− // bloats the blob and every subsequent write.
1026− if nickname.len() > protocol::MAX_DISPLAY_NAME_LEN {
1027− model.error = Some("nickname too long".into());
1028− return render();
1029− }
1030656 match phonemic::try_parse_pubkey(&input) {
1031657 Some(hex) if hex != model.my_pubkey => {
1032658 model
@@ -1056,9 +682,6 @@ impl App for Skrepka {
1056682 if let Some(c) = model.contacts.get_mut(&peer) {
1057683 c.blocked = blocked;
1058684 }
1059− // Clear a stale error (e.g. "cannot send to a blocked contact")
1060− // when the user unblocks — the block that caused it is now lifted.
1061− model.error = None;
1062685 if blocked {
1063686 // Gating *ingest* on the block only stops the acks we have not
1064687 // queued yet. Anything already sitting in the outbox for this
@@ -1068,68 +691,16 @@ impl App for Skrepka {
1068691 // signal the block is supposed to cut off. Drop it all: once
1069692 // blocked, nothing more goes to this peer.
1070693 model.outbox.retain(|item| item.recipient != peer);
1071− model.detach_in_flight(&peer);
1072694 return Command::all([self.persist_contacts(model), self.persist_outbox(model)])
1073695 .and(render());
1074696 }
1075697 self.persist_contacts(model).and(render())
1076698 }
1077− Event::DeleteContact { peer } => {
1078− model.contacts.remove(&peer);
1079− model.messages.remove(&peer);
1080− model.seen_ids.remove(&peer);
1081− model.outbox.retain(|item| item.recipient != peer);
1082− // The send in flight may be carrying the very items we just
1083− // dropped; if so its result must pop nothing.
1084− model.detach_in_flight(&peer);
1085− if model.active_peer.as_deref() == Some(peer.as_str()) {
1086− model.active_peer = None;
1087− model.page = Page::Conversations;
1088− }
1089− model.error = None;
1090− Command::all([
1091− self.persist_contacts(model),
1092− // The conversation is gone from the model, but `persist_messages`
1093− // would leave a `messages:<peer>` blob behind holding every
1094− // message the user just asked us to forget. Delete the key.
1095− KeyValue::delete(k_messages(&peer)).then_send(Event::Saved),
1096− self.persist_outbox(model),
1097− // Q18: persist the seen_ids map without the removed peer.
1098− self.persist_seen_ids(model),
1099− ])
1100− .and(render())
1101− }
1102699 Event::SaveProfile {
1103700 display_name,
1104701 bio,
1105702 photo,
1106703 } => {
1107− // Clear a stale error from a previous failed save.
1108− model.error = None;
1109− // The recipient's `parse_payload` enforces these caps and
1110− // silently drops oversized profiles. Without these checks the
1111− // sender's local profile would be saved, broadcast to every
1112− // contact, and silently dropped by every recipient — a silent
1113− // divergence between what the sender sees and what contacts see.
1114− if display_name.chars().count() > protocol::MAX_DISPLAY_NAME_LEN {
1115− model.error = Some("display name too long".into());
1116− return render();
1117− }
1118− if bio.chars().count() > protocol::MAX_BIO_LEN {
1119− model.error = Some("bio too long".into());
1120− return render();
1121− }
1122− if photo.as_ref().is_some_and(|p| p.len() > protocol::MAX_PHOTO_LEN) {
1123− model.error = Some("photo too large".into());
1124− return render();
1125− }
1126− // S6: Validate the photo is valid base64 before broadcasting —
1127− // the recipient's parse_payload rejects invalid base64, so an
1128− // invalid photo would be silently dropped by every recipient.
1129− if photo.as_ref().is_some_and(|p| BASE64_STANDARD.decode(p).is_err()) {
1130− model.error = Some("photo is not valid base64".into());
1131− return render();
1132− }
1133704 model.profile = OwnProfile {
1134705 display_name,
1135706 bio,
@@ -1138,8 +709,8 @@ impl App for Skrepka {
1138709 model.page = Page::Conversations;
1139710 let ts = now_ms();
1140711 let payload = Payload::Profile {
1141− display_name: Some(model.profile.display_name.clone()),
1142− bio: Some(model.profile.bio.clone()),
712+ display_name: model.profile.display_name.clone(),
713+ bio: model.profile.bio.clone(),
1143714 photo: model.profile.photo.clone(),
1144715 };
1145716 // Broadcast the new profile to every contact we haven't blocked.
@@ -1147,44 +718,25 @@ impl App for Skrepka {
1147718 // profile broadcast would keep feeding them our display name,
1148719 // avatar, and a liveness signal.
1149720 //
1150− // One `Arc<String>` for the whole fan-out: the payload carries a
1151− // base64 photo, and a copy per contact would be megabytes in memory
1152− // for a list of any size. (The `outbox` kv blob still serializes the
1153− // string once per item — serde has no idea the `Arc`s are shared —
1154− // but the supersede-in-place below caps that at one pending profile
1155− // per contact, which is the bound that actually matters.)
721+ // Each item carries its own copy of the serialized payload, so a
722+ // profile with a photo is duplicated (base64 and all) once per
723+ // contact, in memory and in the `outbox` kv blob. Acceptable at
724+ // today's contact counts; the fix is to intern the payload and
725+ // have `OutboxItem` reference it by id.
1156726 let recipients: Vec<String> = model
1157727 .contacts
1158728 .values()
1159729 .filter(|c| !c.blocked)
1160730 .map(|c| c.pubkey.clone())
1161731 .collect();
1162− let payload_json = Arc::new(protocol::serialize_payload(&payload, ts));
1163− // Items already on the wire cannot be rewritten: their ciphertext is
1164− // built and gone, and `SendResult` will pop them on success — so a
1165− // payload swapped into one now would be dropped having never been
1166− // sent. Supersede only what is still behind them.
1167− let live = model.in_flight_count();
1168732 for peer in recipients {
1169− // A profile is state, not an event: only the newest one means
1170− // anything. Overwrite the pending one for this peer instead of
1171− // queueing a second — five quick edits to a profile with a photo
1172− // would otherwise be five payloads per contact, each of which we
1173− // then encrypt and send.
1174− match model
1175− .outbox
1176− .iter_mut()
1177− .skip(live)
1178− .find(|i| i.is_profile() && i.recipient == peer)
1179− {
1180− Some(pending) => pending.envelope_json = Arc::clone(&payload_json),
1181− None => model
1182− .outbox
1183− .push_back(OutboxItem::profile(peer, Arc::clone(&payload_json))),
1184− }
733+ model.outbox.push_back(OutboxItem {
734+ recipient: peer,
735+ envelope_json: protocol::serialize_payload(&payload, ts),
736+ });
1185737 }
1186738 Command::all([
1187− self.persist_profile(model),
739+ KeyValue::set(K_PROFILE, json_bytes(&model.profile)).then_send(Event::Saved),
1188740 self.persist_outbox(model),
1189741 ])
1190742 .and(Command::event(Event::StartFlush))
@@ -1218,11 +770,6 @@ impl App for Skrepka {
1218770 let gen = model.next_auth_gen();
1219771 req.build()
1220772 .then_send(move |r| Event::ChallengeResult(gen, r))
1221− .and(
1222− Time::notify_after(Duration::from_millis(AUTH_WATCHDOG_MS))
1223− .0
1224− .then_send(move |_| Event::AuthWatchdog(gen)),
1225− )
1226773 .and(render())
1227774 }
1228775 Err(_) => {
@@ -1387,214 +934,42 @@ impl App for Skrepka {
1387934 return self.backoff_poll(model);
1388935 }
1389936 model.poll_retries = 0;
1390− // Ingesting a page into a model with a hole in it is the worst thing
1391− // this client can do. The cursor write below is what lets the relay
1392− // delete the mail we just took, so a page ingested into a model that
1393− // cannot persist it is a page destroyed on both sides at once. Stop
1394− // the poll loop instead — it restarts on the next launch, which
1395− // re-runs the loads.
1396− if model.kv_load_failed {
1397− model.error = Some(
1398− "storage read failed — restart the app; messages are not being saved"
1399− .into(),
1400− );
1401− return render();
1402− }
1403937 let bytes = resp.take_body().unwrap_or_default();
1404− // Bound the response before parsing: a hostile relay can return
1405− // a multi-gigabyte body that OOMs the app before `ingest_poll`'s
1406− // per-blob budgets are even consulted.
1407− if bytes.len() > MAX_POLL_RESPONSE_BYTES {
1408− model.error = Some("relay sent an oversized poll response".into());
1409− return self.backoff_poll(model);
1410− }
1411− // Q25: A malformed or unparseable poll response must not be
1412− // treated as an empty page — `unwrap_or_default()` would adopt
1413− // cursor 0, forcing a re-fetch from the beginning and wasting
1414− // bandwidth on already-seen (deduped) messages. Back off instead.
1415− let parsed: PollResp = match serde_json::from_slice(&bytes) {
1416− Ok(p) => p,
1417− Err(_) => {
1418− model.error = Some("relay sent a malformed poll response".into());
1419− return self.backoff_poll(model);
1420− }
1421− };
1422− let cursor_before = model.cursor;
938+ let parsed: PollResp = serde_json::from_slice(&bytes).unwrap_or_default();
1423939 let cmd = self.ingest_poll(model, parsed);
1424940 // The re-poll hangs off the *cursor write*, not off this event.
1425941 // Polling again is what acks the batch we just took — the server
1426942 // deletes everything up to `cursor` — so it must not happen until
1427943 // the batch is durable on our side. `SavedCursor` re-polls.
1428− //
1429− // Arm a watchdog alongside the cursor write: if the shell ever
1430− // drops the KV effect (suspension, bug), `SavedCursor` never fires,
1431− // `polling` stays `false`, and the loop is dead until the next
1432− // foreground `Connect`. The watchdog fires `Poll` if the loop is
1433− // still idle — same pattern as the poll/send HTTP watchdogs.
1434− //
1435− // Skip the cursor write when the cursor did not advance (an empty
1436− // or fully-deduped page): there is nothing new to persist, so
1437− // re-poll directly. The watchdog is still armed as a fallback.
1438− let cursor_advanced = model.cursor != cursor_before;
1439− cmd.and(if cursor_advanced {
944+ cmd.and(
1440945 KeyValue::set(K_CURSOR, json_bytes(&model.cursor))
1441− .then_send(Event::SavedCursor)
1442− } else {
1443− Command::event(Event::Poll)
1444− })
1445− .and(
1446− Time::notify_after(Duration::from_millis(POLL_WATCHDOG_MS))
1447− .0
1448− .then_send(|_| Event::Poll),
946+ .then_send(Event::SavedCursor),
1449947 )
1450948 .and(render())
1451949 }
1452950
1453951 // ---------------- outbox ----------------
1454952 Event::StartFlush => self.flush_next(model),
1455− Event::FlushWatchdog(gen) => {
1456− // Only the flush this watchdog was armed for, and only while it
1457− // is still the current one and still in flight. A watchdog whose
1458− // send already completed (or was superseded) is a stale timer and
1459− // must not touch a healthy successor — same pattern as
1460− // `PollWatchdog`.
1461− if gen != model.flush_gen || !model.flushing {
1462− return Command::done();
1463− }
1464− // Retire it as we give up on it. The request itself is not
1465− // cancelled, so if it eventually resolves the generation check
1466− // in `SendResult` is what stops it from popping the replacement's
1467− // items.
1468− model.abandon_flush();
1469− self.flush_next(model)
1470− }
1471− Event::AuthWatchdog(gen) => {
1472− // Only the auth this watchdog was armed for, and only while it is
1473− // still the current one and still in flight.
1474− if gen != model.auth_gen || !model.authenticating {
1475− return Command::done();
1476− }
1477− model.abandon_auth();
1478− Command::event(Event::Authenticate)
1479− }
1480− Event::SendResult(gen, Ok(resp)) => {
1481− // A result for a send we already abandoned (a watchdog gave up
1482− // on it, or a server change invalidated it). Dropping it is safe
1483− // and required: its items were re-queued, so popping by count
1484− // here would eat whichever messages slid into their place.
1485− if gen != model.flush_gen {
1486− return Command::done();
1487− }
953+ Event::SendResult(Ok(resp)) => {
1488954 // The in-flight send is over; `flush_next` refuses to run while
1489955 // this is set, so it must be cleared before *any* branch below.
1490956 model.flushing = false;
1491− let batch = model.in_flight.take();
1492957 let status = u16::from(resp.status());
1493958 if status == 401 {
1494959 model.token = None;
1495− // The batch stays exactly as it is — re-auth, then re-send it.
1496960 return Command::event(Event::Authenticate);
1497961 }
1498− if status == 429 {
1499− // Rate-limited. Not a delivery failure and not the items'
1500− // fault, so they are not charged a retry — a client that spent
1501− // its delivery budget on the relay's load would drop perfectly
1502− // good messages for being sent too enthusiastically. They stay
1503− // queued untouched; we just wait.
1504− //
1505− // `Retry-After` is the relay saying how long. Only the
1506− // delta-seconds form is read — the HTTP-date form falls back to
1507− // our own backoff — and it is clamped either way, because
1508− // obeying an unbounded number from a relay is how the outbox
1509− // gets parked forever.
1510− let delay = resp
1511− .header("retry-after")
1512− .map(|v| v.last().as_str())
1513− .and_then(|v| v.trim().parse::<u64>().ok())
1514− .map_or_else(
1515− || {
1516− // Nothing to honour, so guess — on the *send* loop's
1517− // counter, not the poll loop's. `poll_retries` is reset
1518− // by every page the poll loop takes, so a client polling
1519− // happily while the relay throttles its sends would
1520− // restart the send backoff at 3 s on every page, forever.
1521− // That is not a backoff; it is a fixed 3 s retry dressed
1522− // as one.
1523− let ms = backoff_ms(model.send_retries);
1524− model.send_retries = model.send_retries.saturating_add(1);
1525− Duration::from_millis(ms)
1526− },
1527− |secs| {
1528− Duration::from_millis(
1529− secs.saturating_mul(1000).min(MAX_RETRY_AFTER_MS),
1530− )
1531− },
1532− );
1533− // Park the send loop for the duration, on the *model*. The timer
1534− // below is not the only thing that fires `StartFlush` — a poll page
1535− // and a fresh `SendText` both do — and without a pause they walk
1536− // straight through the wait and hammer the relay that just asked us
1537− // to stop. `flush_next` refuses to send until this passes, whoever
1538− // wakes it.
1539− model.flush_paused_until =
1540− now_ms().saturating_add(i64::try_from(delay.as_millis()).unwrap_or(i64::MAX));
1541− return Time::notify_after(delay)
1542− .0
1543− .then_send(|_| Event::StartFlush)
1544− .and(render());
1545− }
1546− if status == 413 {
1547− // The body outgrew what this relay accepts. `MAX_SEND_BATCH_BYTES`
1548− // is only a guess at that cap, and this relay's is smaller — so
1549− // treating the `413` as transient rebuilds the very same batch,
1550− // which is rejected the very same way, forever.
1551− //
1552− // Measure the cap instead: halve what this batch actually
1553− // weighed and rebuild under that. The items are untouched (we
1554− // only pop on 200/400), so there is nothing to re-queue.
1555− //
1556− // Only a *splittable* batch takes this path. A lone item over
1557− // the cap cannot be made any smaller, and halving the budget
1558− // around it would spin — `flush_next` always takes the first
1559− // item whatever it weighs. It falls through to `retry_batch`,
1560− // whose budget is what eventually drops it, with an error the
1561− // user can see.
1562− if let Some(b) = batch.as_ref().filter(|b| b.count > 1) {
1563− model.send_batch_budget = Some(b.bytes / 2);
1564− return self.flush_next(model);
1565− }
1566− }
1567962 if (200..300).contains(&status) || status == 400 {
1568− // The batch was accepted, or rejected outright (`self_send` /
1569− // `invalid_message` — both permanent, and the relay takes a
1570− // batch all-or-nothing). Either way it is finished with.
1571− //
1572− // The relay is taking our sends again, so the rate-limit backoff
1573− // has done its job: forget it, or the *next* 429 — possibly hours
1574− // later, under load that has nothing to do with this one — starts
1575− // at whatever height the last one climbed to.
1576− model.send_retries = 0;
1577− // D3-app: Don't pop if kv is broken — the persist would be
1578− // refused, and the items would be lost from memory but still
1579− // on disk, causing duplicate sends on relaunch.
1580− if !model.kv_load_failed {
1581− for _ in 0..batch.map_or(0, |b| b.count) {
1582− model.outbox.pop_front();
1583− }
1584− }
963+ // success, or a permanent rejection (self_send/invalid) — drop it.
964+ model.outbox.pop_front();
1585965 return self.persist_outbox(model).and(self.flush_next(model));
1586966 }
1587− // A transient failure: a 5xx, a rate limit, a relay restarting.
1588− self.retry_batch(model, batch)
967+ // transient error — stop; will resume on next connect/poll.
968+ render()
1589969 }
1590− Event::SendResult(gen, Err(_)) => {
1591− // A transport error is the same transient failure as a 5xx.
1592− if gen != model.flush_gen {
1593− return Command::done();
1594− }
970+ Event::SendResult(Err(_)) => {
1595971 model.flushing = false;
1596− let batch = model.in_flight.take();
1597− self.retry_batch(model, batch)
972+ render()
1598973 }
1599974 }
1600975 }
@@ -1615,13 +990,18 @@ impl App for Skrepka {
1615990
1616991 let active_peer = model.active_peer.clone().unwrap_or_default();
1617992
1618− // `view()` is called after every event the core processes. The
1619− // per-keystroke optimization below assumes `ComposeChanged` fires on
1620− // every keystroke, but the Swift shell only sends it before `SendText`
1621− // — so in practice this path runs once per message, not per keypress.
1622− // The optimization is still correct (it withholds avatars on the chat
1623− // page regardless of how often `view` runs), just the original comment
1624− // overstated the frequency.
993+ // `view()` runs on every render, and `ComposeChanged` renders on every
994+ // keystroke. A contact's `photo` is a base64 avatar — tens of KiB each —
995+ // so cloning all of them per keypress meant megabytes of allocation and
996+ // bincode-encoding to type one message.
997+ //
998+ // Only the chat page has a composer (`ComposeChanged` is sent from
999+ // `ChatView` and nowhere else), so only the chat page is on that hot path
1000+ // — and while it is up, the only avatar on screen is the peer we are
1001+ // talking to. Every *other* page renders from a cold event, and the
1002+ // conversations list draws an avatar per row, so it gets the real photos.
1003+ // Withholding them there would not save a single keystroke's work; it
1004+ // would just replace every contact's picture with their initials.
16251005 let chat_open = model.page == Page::Chat;
16261006
16271007 let mut contacts: Vec<ContactVM> = model
@@ -1686,6 +1066,7 @@ impl App for Skrepka {
16861066 active_peer_ob: hex_to_ob(&active_peer),
16871067 active_peer_blocked: active_contact.is_some_and(|c| c.blocked),
16881068 messages,
1069+ compose: model.compose.clone(),
16891070 error: model.error.clone().unwrap_or_default(),
16901071 }
16911072 }
@@ -1699,83 +1080,28 @@ impl Skrepka {
16991080 /// still in flight would then overwrite what the poll just ingested, and the
17001081 /// stale `cursor` (0) would make us re-fetch mail we had already acked.
17011082 fn startup_done(&self, model: &mut Model) -> Command<Effect, Event> {
1702− if !model.startup_load_done() {
1703− return Command::done();
1704− }
1705− if model.kv_load_failed {
1706− // One of the loads could not be read (as opposed to being absent), so
1707− // the model has a hole in it over data that is still on disk. Do not
1708− // connect: a poll would ingest mail into that hole and ack it, and the
1709− // relay would then delete the only copy of messages we never persisted.
1710− //
1711− // Everything downstream is already write-guarded, so this is belt and
1712− // braces — but it is also the only place that can *say* so, and the
1713− // recovery is a relaunch, which re-runs the loads.
1714− model.error = Some(
1715− "storage read failed — restart the app; changes will not be saved until you do"
1716− .into(),
1717− );
1718− return render();
1719− }
1720− Command::event(Event::Connect)
1721− }
1722−
1723− /// The three model-state writes, and the one rule they share: a model that
1724− /// failed to read a key must never write one.
1725− ///
1726− /// `persist_outbox` is the reason this matters. `ingest_poll` calls it on every
1727− /// poll page — including an empty one — so a failed outbox read followed by a
1728− /// single idle poll is enough to write `[]` over a queue of real unsent
1729− /// messages. The user's mail is destroyed by a client doing nothing at all.
1730− fn refuse_write(&self, model: &mut Model) -> bool {
1731− if model.kv_load_failed {
1732− model.error = Some("storage read failed — changes will not be saved".into());
1733− return true;
1083+ if model.startup_load_done() {
1084+ Command::event(Event::Connect)
1085+ } else {
1086+ Command::done()
17341087 }
1735− false
17361088 }
17371089
1738− fn persist_contacts(&self, model: &mut Model) -> Command<Effect, Event> {
1739− if self.refuse_write(model) {
1740− return Command::done();
1741− }
1090+ fn persist_contacts(&self, model: &Model) -> Command<Effect, Event> {
17421091 let list: Vec<Contact> = model.contacts.values().cloned().collect();
17431092 KeyValue::set(K_CONTACTS, json_bytes(&list)).then_send(Event::Saved)
17441093 }
17451094
1746− fn persist_profile(&self, model: &mut Model) -> Command<Effect, Event> {
1747− if self.refuse_write(model) {
1748− return Command::done();
1749− }
1750− KeyValue::set(K_PROFILE, json_bytes(&model.profile)).then_send(Event::Saved)
1751− }
1752−
1753− fn persist_outbox(&self, model: &mut Model) -> Command<Effect, Event> {
1754− if self.refuse_write(model) {
1755− return Command::done();
1756− }
1095+ fn persist_outbox(&self, model: &Model) -> Command<Effect, Event> {
17571096 let list: Vec<OutboxItem> = model.outbox.iter().cloned().collect();
17581097 KeyValue::set(K_OUTBOX, json_bytes(&list)).then_send(Event::Saved)
17591098 }
17601099
1761− fn persist_messages(&self, model: &mut Model, peer: &str) -> Command<Effect, Event> {
1762− if self.refuse_write(model) {
1763− return Command::done();
1764− }
1100+ fn persist_messages(&self, model: &Model, peer: &str) -> Command<Effect, Event> {
17651101 let list = model.messages.get(peer).cloned().unwrap_or_default();
17661102 KeyValue::set(k_messages(peer), json_bytes(&list)).then_send(Event::Saved)
17671103 }
17681104
1769− /// Q18: Persist the per-peer dedup ID set. Written alongside messages so
1770− /// the set survives restarts and covers IDs that `trim_history` has aged
1771− /// out of the conversation.
1772− fn persist_seen_ids(&self, model: &mut Model) -> Command<Effect, Event> {
1773− if self.refuse_write(model) {
1774− return Command::done();
1775− }
1776− KeyValue::set(K_SEEN_IDS, json_bytes(&model.seen_ids)).then_send(Event::Saved)
1777− }
1778−
17791105 fn schedule_reconnect(&self, model: &mut Model) -> Command<Effect, Event> {
17801106 let delay = backoff_ms(model.poll_retries);
17811107 model.poll_retries = model.poll_retries.saturating_add(1);
@@ -1806,95 +1132,47 @@ impl Skrepka {
18061132 let Some(peer) = model.active_peer.clone() else {
18071133 return render();
18081134 };
1809− // Clear a stale error from a previous failed send, so the error display
1810− // does not persist after the user fixes the issue and sends again.
1811− model.error = None;
1812− // Blocking cuts the peer off in both directions (PROTOCOL.md §4). The
1813− // ingest side drops their messages; the send side must refuse too, or
1814− // a blocked peer still receives our texts, acks, and the liveness
1815− // signal that blocking is supposed to cut off.
1816− if model.contacts.get(&peer).is_some_and(|c| c.blocked) {
1817− model.error = Some("cannot send to a blocked contact".into());
1818− return render();
1819− }
18201135 let body = model.compose.trim().to_string();
18211136 if body.is_empty() {
18221137 return render();
18231138 }
1824− // The recipient's `parse_payload` enforces `MAX_BODY_LEN` and silently
1825− // drops oversized text. Without this check the message would be stored
1826− // locally, sent to the relay, and marked "sent" — but never arrive, never
1827− // get acked, and the sender would see it stuck as "sent" forever with no
1828− // error.
1829− if body.len() > protocol::MAX_BODY_LEN {
1830− model.error = Some("message too long".into());
1831− return render();
1832− }
1833−
1834− // Check the contact cap *before* mutating anything. The old order
1835− // cleared `compose` and inserted the message into `model.messages`
1836− // first, then hit the cap and returned early — so the user's text was
1837− // gone (compose cleared), the message sat in an invisible orphan
1838− // conversation (no contact entry to click on), and nothing was
1839− // persisted (no `persist_messages` before the early return). On next
1840− // launch the message was silently lost.
1841− let was_new = !model.contacts.contains_key(&peer);
1842− if was_new && model.contacts.len() >= MAX_CONTACTS {
1843− model.error = Some("contact list is full".into());
1844− return render();
1845− }
1846−
18471139 model.compose.clear();
18481140 let ts = now_ms();
18491141 let id = uuid::Uuid::new_v4().to_string();
18501142
18511143 let convo = model.messages.entry(peer.clone()).or_default();
1852− // Insert in order rather than appending: an incoming message may hold a
1853− // `ts` up to `MAX_FUTURE_SKEW_MS` ahead of ours, so appending would leave
1854− // the conversation unsorted — and `trim_history` ages out the *front*.
1855− insert_sorted(
1856− convo,
1857− StoredMessage {
1858− id: id.clone(),
1859− body: body.clone(),
1860− ts,
1861− outgoing: true,
1862− delivered: false,
1863− },
1864− );
1144+ let first_to_peer = !convo.iter().any(|m| m.outgoing);
1145+ convo.push(StoredMessage {
1146+ id: id.clone(),
1147+ body: body.clone(),
1148+ ts,
1149+ outgoing: true,
1150+ delivered: false,
1151+ });
18651152 trim_history(convo);
18661153
1867− // Auto-create a bare contact if messaging a brand-new key, respecting
1868− // the same `MAX_CONTACTS` cap `ingest_poll` enforces. This is
1869− // user-driven (not an attack vector), but the cap exists to bound the
1870− // `contacts` kv blob, which is rewritten in full on every change.
1871− if was_new {
1872− model
1873− .contacts
1874− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), ts));
1875− }
1154+ // Auto-create a bare contact if messaging a brand-new key.
1155+ model
1156+ .contacts
1157+ .entry(peer.clone())
1158+ .or_insert_with(|| Contact::new(peer.clone(), String::new(), ts));
18761159
18771160 let text = Payload::Text { id, body };
1878− model.outbox.push_back(OutboxItem::new(
1879− peer.clone(),
1880− Arc::new(protocol::serialize_payload(&text, ts)),
1881− ));
1882−
1883− // On first contact, share our profile *after* the text. The text
1884− // auto-creates the contact on the recipient's side; the profile that
1885− // follows in the same batch then applies to the now-existing contact.
1886− // (Previously profile was queued before the text, but the recipient's
1887− // ingest drops profiles from non-contacts, so the profile was lost.)
1888− if was_new {
1161+ model.outbox.push_back(OutboxItem {
1162+ recipient: peer.clone(),
1163+ envelope_json: protocol::serialize_payload(&text, ts),
1164+ });
1165+ // On first contact, also share our profile.
1166+ if first_to_peer {
18891167 let profile = Payload::Profile {
1890− display_name: Some(model.profile.display_name.clone()),
1891− bio: Some(model.profile.bio.clone()),
1168+ display_name: model.profile.display_name.clone(),
1169+ bio: model.profile.bio.clone(),
18921170 photo: model.profile.photo.clone(),
18931171 };
1894− model.outbox.push_back(OutboxItem::profile(
1895− peer.clone(),
1896− Arc::new(protocol::serialize_payload(&profile, ts)),
1897− ));
1172+ model.outbox.push_back(OutboxItem {
1173+ recipient: peer.clone(),
1174+ envelope_json: protocol::serialize_payload(&profile, ts),
1175+ });
18981176 }
18991177
19001178 Command::all([
@@ -1906,23 +1184,8 @@ impl Skrepka {
19061184 .and(render())
19071185 }
19081186
1909− /// Encrypt and send a batch from the head of the outbox, discarding any
1910− /// unsendable items in the way.
1911− ///
1912− /// The batch is the longest run of items at the head that share a recipient,
1913− /// bounded by `MAX_SEND_BATCH` and `MAX_SEND_BATCH_BYTES`. Sending them one
1914− /// at a time meant an HTTP round-trip per message — and per *ack*, which
1915− /// `ingest_poll` queues one of per sender per page, so catching up after a
1916− /// long offline stretch was one request per message received. The relay takes
1917− /// up to a hundred per request (PROTOCOL.md §7); take it up on that.
1918− ///
1919− /// Stopping at the first recipient change keeps the outbox's FIFO ordering
1920− /// intact — a batch is delivered as a unit, so hoisting a later item for the
1921− /// same peer over an earlier one for a different peer could only reorder
1922− /// *across* conversations, but it would also let one busy peer starve the
1923− /// rest of the queue. Consecutive-only is the cheap, obviously-fair rule, and
1924− /// it already collapses the two cases that matter: a burst of texts to the
1925− /// open chat, and a page's worth of acks back to one sender.
1187+ /// Encrypt and send the head of the outbox, discarding any unsendable items
1188+ /// ahead of it.
19261189 ///
19271190 /// The discards happen in one pass, followed by a single `persist_outbox`.
19281191 /// The previous shape recursed once per bad item and emitted a full outbox
@@ -1933,223 +1196,86 @@ impl Skrepka {
19331196 if model.flushing {
19341197 return Command::done();
19351198 }
1936− // A failed startup read means the outbox in memory is not the outbox on
1937− // disk. Sending from it is not the danger — `persist_outbox` refusing to
1938− // write is — but a send that cannot record having happened is one that will
1939− // happen again on the next launch, from the queue still on disk. Sit still.
1940− if model.kv_load_failed {
1941− return Command::done();
1942− }
1943− // A 429 parked the loop until the far side of the relay's `Retry-After`.
1944− // The timer that set the pause also scheduled the `StartFlush` that resumes
1945− // us, so whoever woke us early — a poll page, a fresh `SendText` — the
1946− // answer is the same: the relay asked us to stop until then.
1947− if now_ms() < model.flush_paused_until {
1948− return Command::done();
1949− }
19501199 let (Some(id), Some(token)) = (model.identity(), model.token.clone()) else {
19511200 return Command::done();
19521201 };
19531202
1954− let now = now_ms();
1955− let mut dirty = false;
1956− let mut recipient: Option<String> = None;
1957− let mut messages: Vec<Envelope> = Vec::new();
1958− let mut bytes = 0usize;
1959− // What we believe this relay's body cap to be — `MAX_SEND_BATCH_BYTES`
1960− // until a `413` tells us otherwise.
1961− let batch_budget = model.send_batch_budget.unwrap_or(MAX_SEND_BATCH_BYTES);
1962−
1963− while messages.len() < MAX_SEND_BATCH {
1964− let Some(item) = model.outbox.get(messages.len()) else {
1965− break;
1966− };
1967− if recipient.as_ref().is_some_and(|r| r != &item.recipient) {
1968− break;
1969− }
1970− let Some(blob) = encrypt_for(&id, item) else {
1971− // An unusable recipient key can never be encrypted to, so the item
1972− // has to go or it blocks the head of the outbox forever — but say
1973− // so, rather than letting the message vanish. (Only reachable at
1974− // the head: an item deeper in the batch shares the recipient of one
1975− // we have already encrypted to, so its key is good by construction.)
1976− model.error = Some(format!(
1977− "cannot send to {}: invalid key",
1978− trunc_ob(&item.recipient)
1979− ));
1980− model.outbox.remove(messages.len());
1981− dirty = true;
1982− continue;
1203+ let mut dropped = false;
1204+ let sendable = loop {
1205+ let Some(item) = model.outbox.front() else {
1206+ break None;
19831207 };
1984− let hex_blob = hex::encode(blob);
1985− // Always take the first item, whatever it weighs — a lone oversized
1986− // payload must still reach the relay, which rejects it permanently
1987− // (`400`) and so gets it out of the queue for good. Past the first,
1988− // stop before the body outgrows what a relay will accept.
1989− if !messages.is_empty() && bytes + hex_blob.len() > batch_budget {
1990− break;
1208+ match encrypt_for(&id, item) {
1209+ Some(blob) => break Some((item.recipient.clone(), blob)),
1210+ None => {
1211+ // An unusable recipient key can never be encrypted to, so the
1212+ // item has to go or it blocks the head of the outbox forever —
1213+ // but say so, rather than letting the message vanish.
1214+ model.error = Some(format!(
1215+ "cannot send to {}: invalid key",
1216+ trunc_ob(&item.recipient)
1217+ ));
1218+ model.outbox.pop_front();
1219+ dropped = true;
1220+ }
19911221 }
1992− bytes += hex_blob.len();
1993− recipient = Some(item.recipient.clone());
1994− messages.push(Envelope {
1995− to: item.recipient.clone(),
1996− encrypted_blob: hex_blob,
1997− });
1998− }
1222+ };
1223+
1224+ let persist = if dropped {
1225+ self.persist_outbox(model)
1226+ } else {
1227+ Command::done()
1228+ };
19991229
2000− let Some(recipient) = recipient else {
1230+ let Some((recipient, blob)) = sendable else {
20011231 // Nothing left to send. Render only if we dropped something, so the
20021232 // user sees why their message went away.
2003− return if dirty {
2004− self.persist_outbox(model).and(render())
1233+ return if dropped {
1234+ persist.and(render())
20051235 } else {
20061236 Command::done()
20071237 };
20081238 };
20091239
2010− // Stamp the items now going out for the first time. This is what the TTL
2011− // in `OutboxItem::is_expired` measures from, so it has to be durable —
2012− // otherwise a relaunch resets the clock and a permanently-stuck item is
2013− // immortal again.
2014− for item in model.outbox.iter_mut().take(messages.len()) {
2015− if item.first_attempt == 0 {
2016− item.first_attempt = now;
2017− dirty = true;
2018− }
2019− }
2020− let persist = if dirty {
2021− self.persist_outbox(model)
2022− } else {
2023− Command::done()
2024− };
2025−
20261240 model.flushing = true;
2027− let gen = model.next_flush_gen();
2028− model.in_flight = Some(InFlight {
2029− recipient,
2030− count: messages.len(),
2031− bytes,
2032− });
1241+ let batch = SendBatch {
1242+ messages: vec![Envelope {
1243+ to: recipient,
1244+ encrypted_blob: hex::encode(blob),
1245+ }],
1246+ };
20331247 let url = format!("{}/messages", model.settings.server_url);
20341248 match Http::post(url)
20351249 .header("authorization", format!("Bearer {token}"))
2036− .body_json(&SendBatch { messages })
1250+ .body_json(&batch)
20371251 {
2038− Ok(req) => persist.and(req.build().then_send(move |r| Event::SendResult(gen, r)))
2039− .and(
2040− Time::notify_after(Duration::from_millis(FLUSH_WATCHDOG_MS))
2041− .0
2042− .then_send(move |_| Event::FlushWatchdog(gen)),
2043− ),
1252+ Ok(req) => persist.and(req.build().then_send(Event::SendResult)),
20441253 Err(_) => {
20451254 // Not a dead loop: the next StartFlush (from a poll page or a
20461255 // fresh send) retries the head of the outbox.
20471256 model.flushing = false;
2048− model.in_flight = None;
20491257 model.error = Some("could not build send request".into());
20501258 persist.and(render())
20511259 }
20521260 }
20531261 }
20541262
2055− /// A send failed transiently (a 5xx, a rate limit, a dropped connection). The
2056− /// batch stays queued and goes out again on the next `StartFlush` — but not
2057− /// forever.
2058− ///
2059− /// The outbox is a strict FIFO, so an item that can never be delivered blocks
2060− /// every message queued behind it, permanently: a single unreachable recipient
2061− /// relay used to be enough to silence the client for good. Charge every item in
2062− /// the failed batch a retry and drop the ones that have exhausted their budget
2063− /// (`OutboxItem::is_expired`) — with an error, rather than letting them vanish.
2064− fn retry_batch(&self, model: &mut Model, batch: Option<InFlight>) -> Command<Effect, Event> {
2065− // `None` means the items were removed out from under the send (the peer was
2066− // blocked, or deleted). There is nothing left to charge or to drop.
2067− let Some(batch) = batch else {
2068− return render();
2069− };
2070−
2071− let now = now_ms();
2072− let mut expired = 0usize;
2073− let mut kept: VecDeque<OutboxItem> = VecDeque::with_capacity(model.outbox.len());
2074− for (i, mut item) in std::mem::take(&mut model.outbox).into_iter().enumerate() {
2075− if i < batch.count {
2076− item.retries = item.retries.saturating_add(1);
2077− if item.is_expired(now) {
2078− expired += 1;
2079− continue;
2080− }
2081− }
2082− kept.push_back(item);
2083− }
2084− model.outbox = kept;
2085−
2086− if expired == 0 {
2087− // Nothing dropped, but the retry counters still have to be durable: a
2088− // relaunch that handed every stuck item a fresh budget would wedge the
2089− // outbox across restarts exactly as it did before there was a budget.
2090− return self.persist_outbox(model).and(render());
2091− }
2092− model.error = Some(format!(
2093− "gave up sending {expired} message(s) to {}",
2094− trunc_ob(&batch.recipient)
2095− ));
2096− // The head of the outbox has moved, so whatever was stuck behind the items
2097− // we just dropped can finally go out.
2098− self.persist_outbox(model)
2099− .and(self.flush_next(model))
2100− .and(render())
2101− }
2102−
21031263 /// Decrypt and fold a poll page into the model; queue delivery acks.
21041264 fn ingest_poll(&self, model: &mut Model, page: PollResp) -> Command<Effect, Event> {
2105− // The caller already refuses to poll into a broken model, and must — the
2106− // cursor write is its own, and advancing it is what acks the page. This
2107− // stands guard for whoever calls this next: ingesting is only safe if what
2108− // is ingested can be written down.
2109− if model.kv_load_failed {
2110− return Command::done();
2111− }
2112− let prev_cursor = model.cursor;
2113− // Clamp the relay-supplied cursor: honest cursors are wall-clock-seeded
2114− // sequence numbers, so anything far in the future is a hostile or buggy
2115− // relay. Adopting `i64::MAX` would ack (delete) all queued mail on the
2116− // next poll and persist that poison across relaunches.
2117− let clamped_cursor = page.cursor.min(now_ms().saturating_add(60_000));
2118− // Q16: Only advance the cursor when the page actually carried events.
2119− // An empty page with a cursor higher than ours is suspicious — a
2120− // compromised relay could poison the cursor to ack (delete) messages
2121− // the client hasn't seen. When there are no events, there is nothing
2122− // to acknowledge, so the cursor should not move. (An honest relay's
2123− // empty-page cursor equals what the client sent, so this is a no-op
2124− // in the normal case.)
2125− if !page.events.is_empty() && clamped_cursor > model.cursor {
2126− model.cursor = clamped_cursor;
1265+ if page.cursor > model.cursor {
1266+ model.cursor = page.cursor;
21271267 }
21281268 let Some(id) = model.identity() else {
21291269 return Command::done();
21301270 };
21311271
21321272 let now = now_ms();
2133− let mut touched_convos: HashSet<String> = HashSet::new();
1273+ let mut touched_convos: Vec<String> = Vec::new();
21341274 let mut contacts_dirty = false;
2135− let mut outbox_dirty = false;
21361275 // sender hex -> message ids needing acks
21371276 let mut ack_targets: BTreeMap<String, Vec<String>> = BTreeMap::new();
2138− // Q17: Per-sender deferred ack accumulation. Collected during the event
2139− // loop, applied after it — so relay page ordering cannot cause an older
2140− // ack to be dropped as stale by a newer one processed first.
2141− let mut pending_acks: HashMap<String, PendingAck> = HashMap::new();
21421277
21431278 let mut budget = MAX_POLL_TOTAL_BYTES;
2144− let mut events_processed = 0usize;
2145− // N9: Track whether any event was successfully decrypted and stored.
2146− // When the budget/count is exhausted mid-page, we ack the page (keep
2147− // the advanced cursor) only if at least one event was processed —
2148− // the unprocessed tail is from a hostile/misconfigured relay and
2149− // losing them is better than livelocking. If zero events were
2150− // processed (a completely hostile page), restore the cursor so we
2151− // don't ack mail we never saw.
2152− let mut events_stored = 0usize;
21531279 for ev in page.events {
21541280 // Reject an oversized blob on its hex length, before it costs us a
21551281 // decode. No conforming sender produces one (the relay caps blobs at
@@ -2157,37 +1283,12 @@ impl Skrepka {
21571283 if ev.encrypted_blob.len() > MAX_BLOB_LEN * 2 {
21581284 continue;
21591285 }
2160− // Event count cap: the byte budget alone permits ~388K minimum-size
2161− // events, each costing a full decrypt attempt. A hostile relay can
2162− // freeze the app for tens of seconds per poll with a page of small
2163− // junk blobs. Stop processing past this count.
2164− if events_processed >= MAX_POLL_EVENTS {
2165− // N9: If we stored at least one event, keep the advanced cursor
2166− // to ack the page and avoid a livelock. The unprocessed events
2167− // are hostile junk or beyond our capacity — we can't process
2168− // them anyway, and re-fetching them forever would wedge the
2169− // client.
2170− if events_stored == 0 {
2171− model.cursor = prev_cursor;
2172− }
2173− model.error = Some("relay sent too many poll events".into());
2174− break;
2175− }
2176− events_processed += 1;
21771286 match budget.checked_sub(ev.encrypted_blob.len() / 2) {
21781287 Some(left) => budget = left,
21791288 None => {
2180− // N9: Budget exhausted. If we stored at least one event,
2181− // keep the advanced cursor to ack the page — re-fetching
2182− // an oversized page forever is a livelock. The unprocessed
2183− // events are beyond our capacity and would be re-served
2184− // on every poll, wedging the client. Acking them breaks
2185− // the loop. If zero events were stored (completely hostile
2186− // page), restore the cursor to avoid acking mail we
2187− // never saw.
2188− if events_stored == 0 {
2189− model.cursor = prev_cursor;
2190− }
1289+ // A page this large is a misbehaving relay, not real mail.
1290+ // Stop here; the rest of the page is discarded exactly as an
1291+ // undecryptable blob would be.
21911292 model.error = Some("relay sent an oversized poll page".into());
21921293 break;
21931294 }
@@ -2201,23 +1302,8 @@ impl Skrepka {
22011302 let Some(parsed) = protocol::parse_payload(&dec.plaintext) else {
22021303 continue;
22031304 };
2204− // D1: Any event that was successfully decrypted AND parsed counts
2205− // as "stored" for cursor advancement — the client has processed it
2206− // even if it was a duplicate/stale/stranger that was dropped.
2207− events_stored += 1;
22081305 let sender = dec.sender_hex;
22091306
2210− // Mail from ourselves. A relay cannot forge this — the sender identity
2211− // is signed inside the AEAD — but it can *echo back* a blob we sent
2212− // through it, and one addressed to ourselves decrypts perfectly well.
2213− // Nothing downstream would notice: it would auto-create a contact for
2214− // our own key (which `AddContact` explicitly refuses to do), file our
2215− // own words as an incoming message, and queue a delivery ack addressed
2216− // to us — which flushes, comes back, and is acked again.
2217− if sender == model.my_pubkey {
2218− continue;
2219− }
2220−
22211307 // Blocking cuts the peer off entirely (PROTOCOL.md §4) — not just
22221308 // their texts. A blocked peer must not be able to rewrite their
22231309 // display name and avatar in our contact list, mark our messages
@@ -2231,14 +1317,8 @@ impl Skrepka {
22311317 // `ts` is chosen by the sender and drives conversation ordering,
22321318 // in-chat ordering, and the displayed time. Bound it to the near
22331319 // future so a peer cannot pin itself to the top of the list forever
2234− // with `ts = i64::MAX`, and bound it to the non-negative past so a
2235− // malicious peer cannot make their own message invisible by dating
2236− // it at `i64::MIN` — `insert_sorted` would put it at position 0 and
2237− // `trim_history` would age it out first.
2238− let ts = parsed
2239− .ts
2240− .max(0)
2241− .min(now.saturating_add(MAX_FUTURE_SKEW_MS));
1320+ // with `ts = i64::MAX`.
1321+ let ts = parsed.ts.min(now.saturating_add(MAX_FUTURE_SKEW_MS));
22421322 let known = model.contacts.contains_key(&sender);
22431323 match parsed.payload {
22441324 Payload::Text { id: msg_id, body } => {
@@ -2256,103 +1336,38 @@ impl Skrepka {
22561336 Contact::new(sender.clone(), String::new(), ts),
22571337 );
22581338 contacts_dirty = true;
2259− // D6-app: Send our profile to the new contact so they
2260− // see our display name and avatar, not just our @p.
2261− let profile = Payload::Profile {
2262− display_name: Some(model.profile.display_name.clone()),
2263− bio: Some(model.profile.bio.clone()),
2264− photo: model.profile.photo.clone(),
2265− };
2266− model.outbox.push_back(OutboxItem::profile(
2267− sender.clone(),
2268− Arc::new(protocol::serialize_payload(&profile, ts)),
2269− ));
2270− outbox_dirty = true;
22711339 }
22721340 let convo = model.messages.entry(sender.clone()).or_default();
2273− // Q18: Use the persistent per-peer dedup set from
2274− // `model.seen_ids` (loaded from kv at startup) instead of
2275− // deriving it from the trimmed conversation. Without this,
2276− // a replayed blob whose `id` was aged out by `trim_history`
2277− // (past the 1000-message cap) passes the dedup check and
2278− // re-enters the conversation.
2279− let id_set = model
2280− .seen_ids
2281− .entry(sender.clone())
2282− .or_default();
2283− // Also seed from the conversation in case the kv load was
2284− // missing or partial — covers the upgrade path.
2285− if id_set.is_empty() {
2286− id_set.extend(convo.iter().map(|m| m.id.clone()));
2287− }
2288− // dedup by id — O(1) via HashSet
2289− if !id_set.contains(&msg_id) {
2290− // The conversation is *kept* sorted rather than re-sorted:
2291− // a `sort_by_key` per arrival is O(n log n) on a list of up
2292− // to `MAX_MESSAGES_PER_PEER`, every time, and a poll page
2293− // can carry hundreds of messages.
2294− insert_sorted(
2295− convo,
2296− StoredMessage {
2297− id: msg_id.clone(),
2298− body,
2299− ts,
2300− outgoing: false,
2301− delivered: false,
2302− },
2303− );
2304− id_set.insert(msg_id.clone());
2305− // N7: Cap the dedup set so it doesn't grow without
2306− // bound. The set is rewritten to kv on every message
2307− // receipt, so an unbounded set makes every receipt
2308− // slower. Keep the most recent IDs by draining excess
2309− // from the oldest. `HashSet` has no ordering, so when
2310− // the cap is exceeded we rebuild from the conversation
2311− // (which is trimmed to MAX_MESSAGES_PER_PEER and
2312− // sorted by ts) — the oldest IDs in the set that are
2313− // no longer in the conversation are the ones to drop.
2314− if id_set.len() > MAX_SEEN_IDS_PER_PEER {
2315− let convo_ids: HashSet<&String> =
2316− convo.iter().map(|m| &m.id).collect();
2317− let excess = id_set.len() - MAX_SEEN_IDS_PER_PEER;
2318− let to_remove: Vec<String> = id_set
2319− .iter()
2320− .filter(|id| !convo_ids.contains(*id))
2321− .take(excess)
2322− .cloned()
2323− .collect();
2324− for id in to_remove {
2325− id_set.remove(&id);
2326− }
2327− }
1341+ // dedup by id
1342+ if !convo.iter().any(|m| m.id == msg_id) {
1343+ convo.push(StoredMessage {
1344+ id: msg_id.clone(),
1345+ body,
1346+ ts,
1347+ outgoing: false,
1348+ delivered: false,
1349+ });
1350+ convo.sort_by_key(|m| m.ts);
23281351 trim_history(convo);
2329− touched_convos.insert(sender.clone());
2330− // Only ack a message we actually stored — a duplicate id
2331− // was already acked when its first copy was ingested.
2332− ack_targets.entry(sender.clone()).or_default().push(msg_id);
1352+ if !touched_convos.contains(&sender) {
1353+ touched_convos.push(sender.clone());
1354+ }
23331355 }
1356+ ack_targets.entry(sender).or_default().push(msg_id);
23341357 }
23351358 Payload::DeliveryAck { ack_ids } => {
2336− // Q17: Defer the staleness check until after the event loop.
2337− // Within a single page, a relay can reorder events so that a
2338− // newer ack (higher ts) is processed before an older one. If
2339− // we applied the watermark immediately, the older ack would be
2340− // dropped as stale — even if it acks different message IDs.
2341− // Instead, accumulate all ack_ids per sender and the max ts,
2342− // then apply them in a second pass below the loop.
2343− let prev_ts = pending_acks
2344− .get(&sender)
2345− .map(|p| p.ts)
2346− .unwrap_or(0);
2347− if ts > prev_ts {
2348− let entry = pending_acks.entry(sender.clone()).or_default();
2349− entry.ts = ts;
1359+ if let Some(convo) = model.messages.get_mut(&sender) {
1360+ let mut changed = false;
1361+ for m in convo.iter_mut() {
1362+ if m.outgoing && ack_ids.contains(&m.id) && !m.delivered {
1363+ m.delivered = true;
1364+ changed = true;
1365+ }
1366+ }
1367+ if changed && !touched_convos.contains(&sender) {
1368+ touched_convos.push(sender);
1369+ }
23501370 }
2351− pending_acks
2352− .entry(sender.clone())
2353− .or_default()
2354− .ids
2355− .extend(ack_ids);
23561371 }
23571372 Payload::Profile {
23581373 display_name,
@@ -2366,73 +1381,20 @@ impl Skrepka {
23661381 let Some(c) = model.contacts.get_mut(&sender) else {
23671382 continue;
23681383 };
2369− // Ignore stale profile replays (PROTOCOL.md §4). The watermark
2370− // is clamped to *now* for the same reason `last_ack_ts` is: a
2371− // peer that parked it in the future would freeze its own
2372− // profile, since every honest update it sent afterwards would
2373− // read as a stale replay of itself.
2374− //
2375− // `>=` (not `>`): a contact's `last_profile_ts` starts at 0,
2376− // and a first profile whose `ts` is also 0 — the default when
2377− // the field is missing — must still be accepted. `>` would
2378− // silently drop it, leaving the contact with no profile at all.
2379− // A replayed profile at the same `ts` overwrites with identical
2380− // content, which is a no-op in practice.
1384+ // Ignore stale profile replays (PROTOCOL.md §4). The clamped
1385+ // `ts` is what gets recorded, so a peer cannot park
1386+ // `last_profile_ts` at `i64::MAX` and freeze its own profile.
23811387 if ts >= c.last_profile_ts {
2382− // D13: Only update fields that are present (Some).
2383− // An absent field (None) means "don't change".
2384− if let Some(name) = display_name {
2385− c.display_name = name;
2386− }
2387− if let Some(b) = bio {
2388− c.bio = b;
2389− }
2390− if let Some(p) = photo {
2391− c.photo = Some(p);
2392− }
2393− c.last_profile_ts = ts.min(now);
1388+ c.display_name = display_name;
1389+ c.bio = bio;
1390+ c.photo = photo;
1391+ c.last_profile_ts = ts;
23941392 contacts_dirty = true;
23951393 }
23961394 }
23971395 }
23981396 }
23991397
2400− // Q17: Apply deferred delivery acks. Collected during the event loop
2401− // above, now applied with a single staleness check per sender using the
2402− // highest `ts` seen — so relay page ordering cannot cause an older ack
2403− // to be dropped as stale by a newer one processed first.
2404− for (sender, pending) in &pending_acks {
2405− let stale = model
2406− .contacts
2407− .get(sender)
2408− .is_some_and(|c| pending.ts < c.last_ack_ts);
2409− if stale {
2410− continue;
2411− }
2412− // The watermark is clamped to *now*, not merely into the skew window:
2413− // `ts` is the peer's to choose, and `last_ack_ts` is what every later
2414− // ack is measured against. A single ack at `ts = i64::MAX` would
2415− // otherwise park the watermark a minute into the future, and every
2416− // honest ack that followed — the ones that actually mark our messages
2417− // delivered — would read as stale and be dropped.
2418− if let Some(c) = model.contacts.get_mut(sender) {
2419− c.last_ack_ts = pending.ts.min(now);
2420− contacts_dirty = true;
2421− }
2422− if let Some(convo) = model.messages.get_mut(sender) {
2423− let mut changed = false;
2424− for m in convo.iter_mut() {
2425− if m.outgoing && pending.ids.contains(&m.id) && !m.delivered {
2426− m.delivered = true;
2427− changed = true;
2428− }
2429− }
2430− if changed {
2431− touched_convos.insert(sender.clone());
2432− }
2433− }
2434− }
2435−
24361398 // Queue delivery acks, in batches the recipient will actually accept.
24371399 //
24381400 // `parse_payload` drops a `delivery.ack` carrying more than `MAX_ACK_IDS`
@@ -2448,14 +1410,11 @@ impl Skrepka {
24481410 let payload = Payload::DeliveryAck {
24491411 ack_ids: batch.to_vec(),
24501412 };
2451− // Consecutive and same-recipient, so `flush_next` puts every one of
2452− // a sender's ack batches into a single `/messages` request.
2453− model.outbox.push_back(OutboxItem::new(
2454− sender.clone(),
2455− Arc::new(protocol::serialize_payload(&payload, ack_ts)),
2456− ));
1413+ model.outbox.push_back(OutboxItem {
1414+ recipient: sender.clone(),
1415+ envelope_json: protocol::serialize_payload(&payload, ack_ts),
1416+ });
24571417 }
2458− outbox_dirty = true;
24591418 }
24601419
24611420 // Persist everything touched.
@@ -2463,16 +1422,10 @@ impl Skrepka {
24631422 for peer in &touched_convos {
24641423 cmds.push(self.persist_messages(model, peer));
24651424 }
2466− // Q18: Persist the dedup ID set whenever we touched conversations.
2467− if !touched_convos.is_empty() {
2468− cmds.push(self.persist_seen_ids(model));
2469− }
24701425 if contacts_dirty {
24711426 cmds.push(self.persist_contacts(model));
24721427 }
2473− if outbox_dirty {
2474− cmds.push(self.persist_outbox(model));
2475− }
1428+ cmds.push(self.persist_outbox(model));
24761429 cmds.push(Command::event(Event::StartFlush));
24771430 Command::all(cmds)
24781431 }
@@ -2492,20 +1445,6 @@ fn backoff_ms(retries: u32) -> u64 {
24921445 (3000u64.saturating_mul(1u64 << retries.min(4))).min(30_000)
24931446}
24941447
2495−/// Insert a message into a conversation held in `ts` order.
2496−///
2497−/// `partition_point` lands *after* every message sharing the new one's `ts`, so
2498−/// ties keep arrival order — exactly what the stable `sort_by_key` this replaces
2499−/// produced, at O(log n) + a shift instead of O(n log n) on every message.
2500−///
2501−/// This is what makes "sorted by `ts`" an invariant rather than something
2502−/// `ingest_poll` restored after the fact, which is what `trim_history` has always
2503−/// assumed (it ages out the *front*) and what `view()` renders in order.
2504−fn insert_sorted(convo: &mut Vec<StoredMessage>, msg: StoredMessage) {
2505− let pos = convo.partition_point(|m| m.ts <= msg.ts);
2506− convo.insert(pos, msg);
2507−}
2508−
25091448/// Age out local history: keep only the most recent `MAX_MESSAGES_PER_PEER`
25101449/// messages of a conversation (PROTOCOL.md §9). Callers keep `convo` sorted by
25111450/// `ts`, so the oldest messages are at the front. The subsequent kv write
@@ -2516,36 +1455,31 @@ fn trim_history(convo: &mut Vec<StoredMessage>) {
25161455 }
25171456}
25181457
2519−/// Decode a kv `get` result into a typed value.
1458+/// Decode a kv `get` result into a typed value, ignoring errors / absence.
25201459///
2521−/// Returns `Ok(Some(value))` when the key exists and deserializes, `Ok(None)`
2522−/// when the key is absent (a fresh install), and `Err` when the read itself
2523−/// failed (e.g. the file is locked or corrupt). The caller MUST NOT overwrite
2524−/// a key with a default when the read failed — the data is still on disk, and
2525−/// writing the default back destroys it. An absent key is safe to default.
1460+/// `None` conflates three different things: the key is absent (a fresh install),
1461+/// the read failed, and the bytes are present but don't deserialize. The caller
1462+/// keeps its default in all three, and the next mutation writes that default back
1463+/// — so a blob we merely *failed to read* is destroyed rather than left alone.
25261464///
2527−/// The old `parse_kv` conflated read errors with absence, which meant a
2528−/// background wake while the device was locked (where `completeUnlessOpen`
2529−/// refuses to open existing files) would load an empty model, and the next
2530−/// `persist_*` would write that emptiness back over the real data.
2531−fn parse_kv<T: for<'de> Deserialize<'de>>(res: KvData) -> Result<Option<T>, ()> {
2532− match res {
2533− Ok(Some(bytes)) => serde_json::from_slice(&bytes)
2534− .map(Some)
2535− .map_err(|_| ()),
2536− Ok(None) => Ok(None),
2537− Err(_) => Err(()),
2538− }
1465+/// The mitigation is upstream: every persisted type is `#[serde(default)]` (see
1466+/// `model.rs`), so adding a field can't make an old blob unreadable, which is by
1467+/// far the likeliest way this fires in practice. A genuinely corrupt blob is
1468+/// still lost. Fixing that properly means distinguishing the three cases here and
1469+/// refusing to overwrite on the third — a change to every caller and to the error
1470+/// surface, not a drive-by.
1471+fn parse_kv<T: for<'de> Deserialize<'de>>(res: KvData) -> Option<T> {
1472+ let bytes = res.ok().flatten()?;
1473+ serde_json::from_slice(&bytes).ok()
25391474}
25401475
25411476#[cfg(test)]
25421477mod tests {
25431478 use super::*;
25441479 use crate::crypto::Identity;
2545− use crate::model::{MAX_OUTBOX_RETRIES, OUTBOX_TTL_MS};
25461480
25471481 fn with_identity() -> Model {
2548− let id = Identity::from_seed(&[3u8; 32]).unwrap();
1482+ let id = Identity::from_seed(&[3u8; 32]);
25491483 Model {
25501484 secret_key: Some(Zeroizing::new(id.secret_key.to_vec())),
25511485 my_pubkey: id.public_key_hex(),
@@ -2554,7 +1488,7 @@ mod tests {
25541488 }
25551489
25561490 fn peer_hex(seed: u8) -> String {
2557− Identity::from_seed(&[seed; 32]).unwrap().public_key_hex()
1491+ Identity::from_seed(&[seed; 32]).public_key_hex()
25581492 }
25591493
25601494 /// `Command::event` *queues* an event; it does not run it. The runtime drains
@@ -2577,7 +1511,7 @@ mod tests {
25771511
25781512 /// A poll page carrying one payload, encrypted from `sender_seed` to `me`.
25791513 fn page_from(sender_seed: u8, me: &Model, payload: &Payload, ts: i64) -> PollResp {
2580− let sender = Identity::from_seed(&[sender_seed; 32]).unwrap();
1514+ let sender = Identity::from_seed(&[sender_seed; 32]);
25811515 let recipient = Identity::from_secret_bytes(me.secret_key.as_ref().unwrap()).unwrap();
25821516 let json = protocol::serialize_payload(payload, ts);
25831517 let blob = crate::crypto::encrypt(
@@ -2602,895 +1536,6 @@ mod tests {
26021536 }
26031537 }
26041538
2605− fn queued(recipient: &str, body: &str) -> OutboxItem {
2606− OutboxItem::new(
2607− recipient.to_string(),
2608− Arc::new(protocol::serialize_payload(&text(body, body), 1)),
2609− )
2610− }
2611−
2612− /// The `/messages` batches a command actually puts on the wire. Reading them
2613− /// off the effect is the only way to see what was *sent*, as opposed to what
2614− /// the model thinks it sent.
2615− fn sent_batches(cmd: &mut Command<Effect, Event>) -> Vec<Vec<Envelope>> {
2616− #[derive(Deserialize)]
2617− struct Sent {
2618− messages: Vec<Envelope>,
2619− }
2620− cmd.effects()
2621− .filter_map(|e| match e {
2622− Effect::Http(req) if req.operation.url.ends_with("/messages") => {
2623− serde_json::from_slice::<Sent>(&req.operation.body)
2624− .ok()
2625− .map(|b| b.messages)
2626− }
2627− _ => None,
2628− })
2629− .collect()
2630− }
2631−
2632− fn ok_response(status: u16) -> crux_http::Response<Vec<u8>> {
2633− let status = crux_http::http::StatusCode::try_from(status).unwrap();
2634− crux_http::testing::ResponseBuilder::with_status(status)
2635− .body(Vec::new())
2636− .build()
2637− }
2638−
2639− /// The same, plus one header — the only thing a relay can say to us beyond a
2640− /// status code, and `Retry-After` is the one we act on.
2641− fn response_with_header(status: u16, name: &str, value: &str) -> crux_http::Response<Vec<u8>> {
2642− let status = crux_http::http::StatusCode::try_from(status).unwrap();
2643− crux_http::testing::ResponseBuilder::with_status(status)
2644− .header(name, value)
2645− .body(Vec::new())
2646− .build()
2647− }
2648−
2649− /// The delays a command actually asks the shell to wait, in milliseconds.
2650− /// Asserting on the model alone cannot see these — a backoff lives entirely in
2651− /// the effect.
2652− fn scheduled_delays(cmd: &mut Command<Effect, Event>) -> Vec<u64> {
2653− cmd.effects()
2654− .filter_map(|e| match e {
2655− Effect::Time(req) => match req.operation {
2656− crux_time::TimeRequest::NotifyAfter { duration, .. } => {
2657− Some(Duration::from(duration).as_millis() as u64)
2658− }
2659− _ => None,
2660− },
2661− _ => None,
2662− })
2663− .collect()
2664− }
2665−
2666− /// One full send round-trip: flush the outbox, then answer with `status`.
2667− fn send_and_answer(app: &Skrepka, m: &mut Model, status: u16) {
2668− let _ = app.update(Event::StartFlush, m);
2669− let gen = m.flush_gen;
2670− let _ = app.update(Event::SendResult(gen, Ok(ok_response(status))), m);
2671− }
2672−
2673− // -----------------------------------------------------------------------
2674− // outbox: retry budget and TTL
2675− // -----------------------------------------------------------------------
2676−
2677− /// The outbox is a strict FIFO, so an item that can never be delivered used to
2678− /// block every message queued behind it — forever.
2679− #[test]
2680− fn an_undeliverable_item_is_dropped_once_it_runs_out_of_retries() {
2681− let app = Skrepka;
2682− let mut m = with_identity();
2683− m.token = Some("t".into());
2684− let stuck = peer_hex(9);
2685− let behind = peer_hex(11);
2686− m.outbox.push_back(queued(&stuck, "wedged"));
2687− m.outbox.push_back(queued(&behind, "stranded"));
2688−
2689− // The relay keeps 500-ing on the head of the queue.
2690− for i in 1..MAX_OUTBOX_RETRIES {
2691− send_and_answer(&app, &mut m, 503);
2692− assert_eq!(m.outbox.len(), 2, "still queued after {i} failure(s)");
2693− assert_eq!(m.outbox[0].retries, i, "and its budget is being spent");
2694− }
2695−
2696− // The last one exhausts the budget: the head goes, and what was stuck
2697− // behind it goes out.
2698− let _ = app.update(Event::StartFlush, &mut m);
2699− let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(503))), &mut m);
2700−
2701− assert_eq!(m.outbox.len(), 1, "the undeliverable item is dropped");
2702− assert_eq!(m.outbox[0].recipient, behind, "and the queue moves on");
2703− assert_eq!(
2704− sent_batches(&mut cmd).len(),
2705− 1,
2706− "the message behind it is sent immediately, not left for the next flush"
2707− );
2708− let err = m.error.expect("the user is told the message was given up on");
2709− assert!(err.contains("gave up sending 1"), "got: {err}");
2710− }
2711−
2712− /// The counter alone is not enough: retries only accrue when a send is
2713− /// attempted, so a client that goes online once a day would take a fortnight to
2714− /// spend a ten-retry budget. The TTL bounds it in wall-clock time instead.
2715− #[test]
2716− fn an_item_older_than_the_ttl_is_dropped_on_its_next_failure() {
2717− let app = Skrepka;
2718− let mut m = with_identity();
2719− m.token = Some("t".into());
2720− let peer = peer_hex(9);
2721−
2722− let mut old = queued(&peer, "ancient");
2723− old.first_attempt = now_ms() - OUTBOX_TTL_MS - 1;
2724− old.retries = 1; // nowhere near the retry cap
2725− m.outbox.push_back(old);
2726−
2727− send_and_answer(&app, &mut m, 503);
2728−
2729− assert!(m.outbox.is_empty(), "the TTL alone is enough to drop it");
2730− assert!(m.error.is_some_and(|e| e.contains("gave up sending")));
2731− }
2732−
2733− /// An item queued during a week offline must not be dropped the moment the
2734− /// network returns: the TTL runs from the first *attempt*, not from when the
2735− /// message was written. An outbox blob from a build with no `first_attempt`
2736− /// field deserializes to 0, which must mean "never attempted", not "epoch".
2737− #[test]
2738− fn an_item_that_was_never_attempted_has_no_ttl_to_outlive() {
2739− let item = OutboxItem::new(peer_hex(9), Arc::new("{}".into()));
2740− assert_eq!(item.first_attempt, 0);
2741− assert!(
2742− !item.is_expired(now_ms()),
2743− "an unsent item must not age out against the epoch"
2744− );
2745− }
2746−
2747− /// The TTL clock has to survive a relaunch, or a permanently-stuck item is
2748− /// handed a fresh 24 hours on every launch and the outbox wedges forever again.
2749− #[test]
2750− fn the_first_attempt_stamp_is_persisted_when_the_item_goes_out() {
2751− let app = Skrepka;
2752− let mut m = with_identity();
2753− m.token = Some("t".into());
2754− m.outbox.push_back(queued(&peer_hex(9), "hi"));
2755− let before = now_ms();
2756−
2757− let mut cmd = app.update(Event::StartFlush, &mut m);
2758−
2759− assert!(m.outbox[0].first_attempt >= before, "the item is stamped");
2760− // ...and the stamp is written to the `outbox` key, not just to memory.
2761− let wrote_outbox = cmd.effects().any(|e| {
2762− matches!(e, Effect::KeyValue(req)
2763− if matches!(&req.operation, crux_kv::KeyValueOperation::Set { key, .. } if key == K_OUTBOX))
2764− });
2765− assert!(wrote_outbox, "the stamped outbox is persisted");
2766− }
2767−
2768− /// A transient failure below the budget changes nothing but the counter — the
2769− /// message stays queued and goes out on the next flush.
2770− #[test]
2771− fn a_transient_failure_within_budget_keeps_the_message() {
2772− let app = Skrepka;
2773− let mut m = with_identity();
2774− m.token = Some("t".into());
2775− m.outbox.push_back(queued(&peer_hex(9), "hi"));
2776−
2777− send_and_answer(&app, &mut m, 503);
2778− assert_eq!(m.outbox.len(), 1);
2779− assert_eq!(m.outbox[0].retries, 1);
2780− assert!(m.error.is_none(), "a single 5xx is not worth shouting about");
2781− assert!(!m.flushing, "and the guard is released so the next flush can run");
2782−
2783− // The relay recovers; the message goes out and the item is gone.
2784− send_and_answer(&app, &mut m, 200);
2785− assert!(m.outbox.is_empty());
2786− }
2787−
2788− /// A 401 is not a delivery failure — it means our token expired. The batch must
2789− /// not be charged a retry for it, or a long-lived client burns its whole budget
2790− /// on routine re-auths.
2791− #[test]
2792− fn a_401_does_not_spend_the_retry_budget() {
2793− let app = Skrepka;
2794− let mut m = with_identity();
2795− m.token = Some("t".into());
2796− m.outbox.push_back(queued(&peer_hex(9), "hi"));
2797−
2798− send_and_answer(&app, &mut m, 401);
2799−
2800− assert_eq!(m.outbox.len(), 1, "the message is still queued");
2801− assert_eq!(m.outbox[0].retries, 0, "and unpunished");
2802− assert!(m.token.is_none(), "but the token is thrown away");
2803− }
2804−
2805− /// A 429 is the relay's load, not the message's fault. Charging the batch a
2806− /// retry for it would let a busy relay drive perfectly good messages out of the
2807− /// outbox — and the relay usually says exactly how long to wait, so wait that
2808− /// long rather than guessing.
2809− #[test]
2810− fn a_429_waits_out_retry_after_without_spending_the_retry_budget() {
2811− let app = Skrepka;
2812− let mut m = with_identity();
2813− m.token = Some("t".into());
2814− m.outbox.push_back(queued(&peer_hex(9), "hi"));
2815−
2816− let _ = app.update(Event::StartFlush, &mut m);
2817− let mut cmd = app.update(
2818− Event::SendResult(m.flush_gen, Ok(response_with_header(429, "retry-after", "2"))),
2819− &mut m,
2820− );
2821−
2822− assert_eq!(m.outbox.len(), 1, "the message is still queued");
2823− assert_eq!(m.outbox[0].retries, 0, "and unpunished — it did nothing wrong");
2824− assert!(!m.flushing, "the guard is released so the retry can run");
2825− assert_eq!(
2826− scheduled_delays(&mut cmd),
2827− vec![2_000],
2828− "and we wait exactly as long as the relay asked"
2829− );
2830− }
2831−
2832− /// Without the header there is nothing to honour, so fall back to our own
2833− /// backoff rather than hammering the relay that just asked us to slow down.
2834− ///
2835− /// On the *send* loop's counter. `poll_retries` is reset by every page the poll
2836− /// loop takes, so spending it here means a client that polls happily while the
2837− /// relay throttles its sends restarts the send backoff at 3 s forever.
2838− #[test]
2839− fn a_429_without_header_uses_send_side_backoff() {
2840− let app = Skrepka;
2841− let mut m = with_identity();
2842− m.token = Some("t".into());
2843− m.outbox.push_back(queued(&peer_hex(9), "hi"));
2844− // The poll loop is healthy and has just taken a page — exactly the state that
2845− // used to reset the send backoff out from under itself.
2846− m.poll_retries = 0;
2847−
2848− let _ = app.update(Event::StartFlush, &mut m);
2849− let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(429))), &mut m);
2850−
2851− assert_eq!(m.outbox[0].retries, 0, "still not the message's fault");
2852− assert_eq!(
2853− scheduled_delays(&mut cmd),
2854− vec![backoff_ms(0)],
2855− "the first send-side backoff"
2856− );
2857− assert_eq!(m.send_retries, 1, "and the send loop's own counter is spent");
2858−
2859− // A second 429 climbs, rather than restarting at 3 s.
2860− m.flush_paused_until = 0;
2861− let _ = app.update(Event::StartFlush, &mut m);
2862− let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(429))), &mut m);
2863− assert_eq!(scheduled_delays(&mut cmd), vec![backoff_ms(1)], "it climbs");
2864− assert_eq!(m.send_retries, 2);
2865− }
2866−
2867− /// The `Retry-After` timer is not the only thing that fires `StartFlush` — a poll
2868− /// page and a fresh `SendText` both do. Without a pause latched on the model the
2869− /// wait is decorative: the next poll page walks through it and hammers the relay
2870− /// that just asked us to stop.
2871− #[test]
2872− fn a_429_pauses_flush_until_the_delay_elapses() {
2873− let app = Skrepka;
2874− let mut m = with_identity();
2875− m.token = Some("t".into());
2876− m.outbox.push_back(queued(&peer_hex(9), "hi"));
2877−
2878− let _ = app.update(Event::StartFlush, &mut m);
2879− let _ = app.update(
2880− Event::SendResult(m.flush_gen, Ok(response_with_header(429, "retry-after", "2"))),
2881− &mut m,
2882− );
2883− assert!(
2884− m.flush_paused_until > now_ms(),
2885− "the relay's wait is latched on the model, not just on a timer"
2886− );
2887−
2888− // Anything else that wakes the send loop during the wait — a poll page, a new
2889− // message — must find it closed.
2890− let mut cmd = app.update(Event::StartFlush, &mut m);
2891− assert!(
2892− sent_batches(&mut cmd).is_empty(),
2893− "nothing goes out while the relay has asked us to wait"
2894− );
2895− assert!(!m.flushing, "and the loop is left free to resume later");
2896− assert_eq!(m.outbox.len(), 1, "the message is still queued");
2897−
2898− // Time passes (the clock cannot be moved in a test, so retire the pause the
2899− // way the elapsing of it would) and the timer's own `StartFlush` lands.
2900− m.flush_paused_until = 0;
2901− let mut cmd = app.update(Event::StartFlush, &mut m);
2902− assert_eq!(
2903− sent_batches(&mut cmd).len(),
2904− 1,
2905− "and once the wait is over the batch goes out"
2906− );
2907− }
2908−
2909− /// A send the relay accepts means the throttle is off. Keeping the counter would
2910− /// make the next 429 — hours later, under unrelated load — resume at whatever
2911− /// height the last one climbed to.
2912− #[test]
2913− fn a_successful_send_clears_the_send_side_backoff() {
2914− let app = Skrepka;
2915− let mut m = with_identity();
2916− m.token = Some("t".into());
2917− m.send_retries = 4;
2918− m.outbox.push_back(queued(&peer_hex(9), "hi"));
2919−
2920− send_and_answer(&app, &mut m, 200);
2921−
2922− assert_eq!(m.send_retries, 0, "the relay is taking our sends again");
2923− }
2924−
2925− /// `Retry-After` is a number a relay chooses and we obey. Unbounded, one of
2926− /// them parks the outbox for thirty years — with nothing on screen to say why.
2927− #[test]
2928− fn an_absurd_retry_after_is_clamped() {
2929− let app = Skrepka;
2930− let mut m = with_identity();
2931− m.token = Some("t".into());
2932− m.outbox.push_back(queued(&peer_hex(9), "hi"));
2933−
2934− let _ = app.update(Event::StartFlush, &mut m);
2935− let mut cmd = app.update(
2936− Event::SendResult(m.flush_gen, Ok(response_with_header(429, "retry-after", "999999999"))),
2937− &mut m,
2938− );
2939−
2940− assert_eq!(
2941− scheduled_delays(&mut cmd),
2942− vec![MAX_RETRY_AFTER_MS],
2943− "we wait, but only as long as we are willing to"
2944− );
2945− }
2946−
2947− // -----------------------------------------------------------------------
2948− // outbox: batching
2949− // -----------------------------------------------------------------------
2950−
2951− /// Sending one message per request meant a round-trip per *ack* too, so
2952− /// catching up on a hundred messages cost a hundred requests.
2953− #[test]
2954− fn consecutive_items_for_one_recipient_go_out_in_a_single_request() {
2955− let app = Skrepka;
2956− let mut m = with_identity();
2957− m.token = Some("t".into());
2958− let peer = peer_hex(9);
2959− for i in 0..5 {
2960− m.outbox.push_back(queued(&peer, &format!("m{i}")));
2961− }
2962−
2963− let mut cmd = app.update(Event::StartFlush, &mut m);
2964−
2965− let batches = sent_batches(&mut cmd);
2966− assert_eq!(batches.len(), 1, "one request, not five");
2967− assert_eq!(batches[0].len(), 5, "carrying every message");
2968− assert!(batches[0].iter().all(|e| e.to == peer));
2969−
2970− // ...and its success clears the whole batch, not just the head.
2971− let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
2972− assert!(m.outbox.is_empty());
2973− }
2974−
2975− /// The batch stops at the first recipient change: a batch is delivered as a
2976− /// unit, and hoisting a later item for the same peer over an earlier one for a
2977− /// different peer would let one busy conversation starve the rest of the queue.
2978− #[test]
2979− fn a_batch_stops_at_the_first_recipient_change() {
2980− let app = Skrepka;
2981− let mut m = with_identity();
2982− m.token = Some("t".into());
2983− let a = peer_hex(9);
2984− let b = peer_hex(11);
2985− m.outbox.push_back(queued(&a, "a1"));
2986− m.outbox.push_back(queued(&a, "a2"));
2987− m.outbox.push_back(queued(&b, "b1"));
2988− m.outbox.push_back(queued(&a, "a3"));
2989−
2990− let mut cmd = app.update(Event::StartFlush, &mut m);
2991− let batches = sent_batches(&mut cmd);
2992− assert_eq!(batches.len(), 1);
2993− assert_eq!(batches[0].len(), 2, "only the run at the head");
2994− assert!(batches[0].iter().all(|e| e.to == a));
2995−
2996− let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
2997− assert_eq!(m.outbox.len(), 2);
2998− let batches = sent_batches(&mut cmd);
2999− assert_eq!(batches[0].len(), 1, "then b's single message");
3000− assert_eq!(batches[0][0].to, b);
3001− }
3002−
3003− /// The relay answers `413 batch_too_large` past a hundred (PROTOCOL.md §7) —
3004− /// which we would read as a transient failure and retry forever.
3005− #[test]
3006− fn a_batch_is_capped_at_the_relays_limit() {
3007− let app = Skrepka;
3008− let mut m = with_identity();
3009− m.token = Some("t".into());
3010− let peer = peer_hex(9);
3011− for i in 0..(MAX_SEND_BATCH + 20) {
3012− m.outbox.push_back(queued(&peer, &format!("m{i}")));
3013− }
3014−
3015− let mut cmd = app.update(Event::StartFlush, &mut m);
3016− let batches = sent_batches(&mut cmd);
3017− assert_eq!(batches[0].len(), MAX_SEND_BATCH);
3018−
3019− let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
3020− assert_eq!(m.outbox.len(), 20, "the rest stays queued for the next batch");
3021− }
3022−
3023− /// A hundred profile broadcasts each carrying a 64 KiB photo would build a
3024− /// request far past the body-size cap a relay has by default — and the `413`
3025− /// that came back would look transient, so we would retry it forever.
3026− #[test]
3027− fn a_batch_is_capped_by_body_size_as_well_as_count() {
3028− let app = Skrepka;
3029− let mut m = with_identity();
3030− m.token = Some("t".into());
3031− let peer = peer_hex(9);
3032− // A body at the cap, and incompressible — `crypto::encrypt` deflates the
3033− // plaintext, so a run of one character would ride out to nearly nothing and
3034− // prove no cap at all.
3035− let mut seed = 1u64;
3036− let big: String = std::iter::repeat_with(|| {
3037− seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
3038− char::from(b'a' + (seed >> 33) as u8 % 26)
3039− })
3040− .take(protocol::MAX_BODY_LEN)
3041− .collect();
3042− for i in 0..MAX_SEND_BATCH {
3043− m.outbox.push_back(OutboxItem::new(
3044− peer.clone(),
3045− Arc::new(protocol::serialize_payload(&text(&format!("m{i}"), &big), 1)),
3046− ));
3047− }
3048−
3049− let mut cmd = app.update(Event::StartFlush, &mut m);
3050− let batches = sent_batches(&mut cmd);
3051− let body: usize = batches[0].iter().map(|e| e.encrypted_blob.len()).sum();
3052−
3053− assert!(batches[0].len() < MAX_SEND_BATCH, "the count cap is not the binding one");
3054− assert!(!batches[0].is_empty(), "but something still goes out");
3055− assert!(body <= MAX_SEND_BATCH_BYTES, "the body stays inside the budget");
3056− }
3057−
3058− /// `MAX_SEND_BATCH_BYTES` is a *guess* at a cap the protocol never publishes, and
3059− /// a relay configured below it answers `413`. Read as a transient failure, that
3060− /// `413` rebuilds the identical batch, is rejected identically, and repeats until
3061− /// every item has burned its retry budget — the outbox drains without a single
3062− /// message being delivered. So a `413` measures the cap instead of fighting it.
3063− #[test]
3064− fn a_413_halves_the_batch_budget_and_retries_smaller() {
3065− let app = Skrepka;
3066− let mut m = with_identity();
3067− m.token = Some("t".into());
3068− let peer = peer_hex(9);
3069− for i in 0..5 {
3070− m.outbox.push_back(queued(&peer, &format!("m{i}")));
3071− }
3072−
3073− // Nothing is known about this relay yet, so all five go out at once.
3074− let mut cmd = app.update(Event::StartFlush, &mut m);
3075− let first = sent_batches(&mut cmd);
3076− assert_eq!(first[0].len(), 5, "the whole run goes out on the first attempt");
3077− assert!(m.send_batch_budget.is_none(), "and we have assumed nothing");
3078− let sent_bytes: usize = first[0].iter().map(|e| e.encrypted_blob.len()).sum();
3079−
3080− // ...and the relay says that was too big.
3081− let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(413))), &mut m);
3082−
3083− assert_eq!(
3084− m.send_batch_budget,
3085− Some(sent_bytes / 2),
3086− "the budget is halved against what actually failed, not against the constant"
3087− );
3088− assert_eq!(m.outbox.len(), 5, "a 413 delivered nothing, so nothing is popped");
3089− assert!(
3090− m.outbox.iter().all(|i| i.retries == 0),
3091− "and the items are not charged for the relay being smaller than we guessed"
3092− );
3093−
3094− // The retry goes out at once, and it is smaller.
3095− let second = sent_batches(&mut cmd);
3096− assert_eq!(second.len(), 1, "retried immediately, not left for the next flush");
3097− let shrunk = second[0].len();
3098− assert!(shrunk < 5, "with a smaller batch, got {shrunk} items");
3099− assert!(shrunk > 0, "but not an empty one");
3100−
3101− // The relay takes the smaller batch, and exactly it is popped.
3102− let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
3103− assert_eq!(m.outbox.len(), 5 - shrunk, "exactly the batch that landed is gone");
3104− assert_eq!(
3105− m.send_batch_budget,
3106− Some(sent_bytes / 2),
3107− "and what we learned about the relay survives the success — its cap did not grow"
3108− );
3109− }
3110−
3111− /// A batch of one cannot be split, and `flush_next` takes the first item whatever
3112− /// it weighs — so halving the budget around a lone oversized item would rebuild
3113− /// the same one-item batch forever, at full speed, with no retry ever charged.
3114− /// It has to fall through to the retry budget, which is what eventually drops it.
3115− #[test]
3116− fn a_413_on_a_single_item_ages_out_instead_of_spinning() {
3117− let app = Skrepka;
3118− let mut m = with_identity();
3119− m.token = Some("t".into());
3120− m.outbox.push_back(queued(&peer_hex(9), "bigger than this relay allows"));
3121−
3122− for i in 1..MAX_OUTBOX_RETRIES {
3123− send_and_answer(&app, &mut m, 413);
3124− assert_eq!(m.outbox.len(), 1, "still queued after {i} rejection(s)");
3125− assert_eq!(m.outbox[0].retries, i, "and charged for each of them");
3126− }
3127− assert!(
3128− m.send_batch_budget.is_none(),
3129− "an unsplittable batch teaches us nothing about the cap"
3130− );
3131−
3132− send_and_answer(&app, &mut m, 413);
3133− assert!(m.outbox.is_empty(), "the item that can never be sent is dropped");
3134− assert!(
3135− m.error.is_some_and(|e| e.contains("gave up sending")),
3136− "and the user is told, rather than the message quietly vanishing"
3137− );
3138− }
3139−
3140− /// What we learned is about *that relay*. A new one is entitled to a bigger cap,
3141− /// and it will never send the `413` that would correct a budget carried over.
3142− #[test]
3143− fn changing_relay_forgets_the_learned_batch_budget() {
3144− let app = Skrepka;
3145− let mut m = with_identity();
3146− m.send_batch_budget = Some(1024);
3147−
3148− let _ = app.update(Event::SetServerUrl("https://other.example".into()), &mut m);
3149−
3150− assert!(m.send_batch_budget.is_none());
3151− }
3152−
3153− /// An unusable key at the head still has to be discarded, batching or not —
3154− /// and the discard must not take the rest of the queue with it.
3155− #[test]
3156− fn an_unsendable_head_is_dropped_and_the_batch_behind_it_still_goes_out() {
3157− let app = Skrepka;
3158− let mut m = with_identity();
3159− m.token = Some("t".into());
3160− let good = peer_hex(9);
3161− m.outbox.push_back(queued(&hex::encode([0xabu8; 32]), "doomed"));
3162− m.outbox.push_back(queued(&good, "fine"));
3163− m.outbox.push_back(queued(&good, "also fine"));
3164−
3165− let mut cmd = app.update(Event::StartFlush, &mut m);
3166−
3167− assert_eq!(m.outbox.len(), 2, "only the unsendable item is dropped");
3168− let batches = sent_batches(&mut cmd);
3169− assert_eq!(batches[0].len(), 2, "and the batch behind it goes out whole");
3170− assert!(m.error.is_some_and(|e| e.contains("invalid key")));
3171− }
3172−
3173− // -----------------------------------------------------------------------
3174− // outbox: profile supersede
3175− // -----------------------------------------------------------------------
3176−
3177− /// A profile is state, not an event: only the newest one means anything. Five
3178− /// quick edits used to queue five payloads per contact — each with the photo.
3179− #[test]
3180− fn repeated_profile_edits_supersede_rather_than_pile_up() {
3181− let app = Skrepka;
3182− let mut m = with_identity();
3183− let a = peer_hex(9);
3184− let b = peer_hex(11);
3185− for peer in [&a, &b] {
3186− m.contacts
3187− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3188− }
3189−
3190− for name in ["One", "Two", "Three"] {
3191− let _ = app.update(
3192− Event::SaveProfile {
3193− display_name: name.into(),
3194− bio: String::new(),
3195− photo: None,
3196− },
3197− &mut m,
3198− );
3199− }
3200−
3201− assert_eq!(m.outbox.len(), 2, "one pending profile per contact, not three");
3202− for item in &m.outbox {
3203− assert!(item.is_profile());
3204− let parsed = protocol::parse_payload(item.envelope_json.as_bytes()).unwrap();
3205− match parsed.payload {
3206− Payload::Profile { display_name, .. } => {
3207− assert_eq!(display_name, Some("Three".to_string()), "and it is the latest one");
3208− }
3209− _ => panic!("expected a profile"),
3210− }
3211− }
3212− }
3213−
3214− /// Superseding must not reach into a payload that is already on the wire: its
3215− /// ciphertext is built and gone, and `SendResult` pops it on success — so a
3216− /// profile swapped in now would be dropped having never been sent.
3217− #[test]
3218− fn a_profile_already_in_flight_is_not_rewritten_under_the_send() {
3219− let app = Skrepka;
3220− let mut m = with_identity();
3221− m.token = Some("t".into());
3222− let peer = peer_hex(9);
3223− m.contacts
3224− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3225−
3226− let _ = app.update(
3227− Event::SaveProfile {
3228− display_name: "Old".into(),
3229− bio: String::new(),
3230− photo: None,
3231− },
3232− &mut m,
3233− );
3234− // It goes out and is still in flight.
3235− let _ = app.update(Event::StartFlush, &mut m);
3236− assert!(m.flushing);
3237−
3238− // A second edit lands mid-send. It must queue a *new* item rather than
3239− // rewrite the one whose ciphertext is already gone.
3240− let _ = app.update(
3241− Event::SaveProfile {
3242− display_name: "New".into(),
3243− bio: String::new(),
3244− photo: None,
3245− },
3246− &mut m,
3247− );
3248− assert_eq!(m.outbox.len(), 2, "the in-flight item is left alone");
3249−
3250− let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
3251− assert_eq!(m.outbox.len(), 1, "the sent one is popped");
3252− let parsed = protocol::parse_payload(m.outbox[0].envelope_json.as_bytes()).unwrap();
3253− match parsed.payload {
3254− Payload::Profile { display_name, .. } => assert_eq!(
3255− display_name, Some("New".to_string()),
3256− "and the newer profile survives to be sent"
3257− ),
3258− _ => panic!("expected a profile"),
3259− }
3260− }
3261−
3262− // -----------------------------------------------------------------------
3263− // in-flight bookkeeping
3264− // -----------------------------------------------------------------------
3265−
3266− /// `SendResult` pops by count off the head of the outbox. If the items it sent
3267− /// were removed from under it — the user blocked the peer mid-send — popping by
3268− /// count anyway takes whatever slid into their place: another peer's mail.
3269− #[test]
3270− fn blocking_the_peer_mid_send_does_not_pop_someone_elses_messages() {
3271− let app = Skrepka;
3272− let mut m = with_identity();
3273− m.token = Some("t".into());
3274− let enemy = peer_hex(9);
3275− let friend = peer_hex(11);
3276− m.contacts
3277− .insert(enemy.clone(), Contact::new(enemy.clone(), String::new(), 0));
3278− m.outbox.push_back(queued(&enemy, "e1"));
3279− m.outbox.push_back(queued(&enemy, "e2"));
3280− m.outbox.push_back(queued(&friend, "f1"));
3281−
3282− let _ = app.update(Event::StartFlush, &mut m);
3283− assert_eq!(m.in_flight.as_ref().unwrap().count, 2);
3284−
3285− // Blocked mid-flight: the two items in the air are dropped from the queue.
3286− let _ = app.update(
3287− Event::SetBlocked {
3288− peer: enemy.clone(),
3289− blocked: true,
3290− },
3291− &mut m,
3292− );
3293− assert_eq!(m.outbox.len(), 1);
3294−
3295− // The send lands. It must pop nothing.
3296− let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
3297− assert_eq!(m.outbox.len(), 1, "the friend's message is untouched");
3298− assert_eq!(m.outbox[0].recipient, friend);
3299− }
3300−
3301− // -----------------------------------------------------------------------
3302− // delete contact
3303− // -----------------------------------------------------------------------
3304−
3305− /// Blocking only silences a peer; the entry stayed in the list forever.
3306− #[test]
3307− fn deleting_a_contact_forgets_the_contact_the_history_and_the_queue() {
3308− let app = Skrepka;
3309− let mut m = with_identity();
3310− let peer = peer_hex(9);
3311− let other = peer_hex(11);
3312− for p in [&peer, &other] {
3313− m.contacts
3314− .insert(p.clone(), Contact::new(p.clone(), String::new(), 0));
3315− m.messages.insert(
3316− p.clone(),
3317− vec![StoredMessage {
3318− id: "m1".into(),
3319− body: "hi".into(),
3320− ts: 1,
3321− outgoing: false,
3322− delivered: false,
3323− }],
3324− );
3325− m.outbox.push_back(queued(p, "queued"));
3326− }
3327− m.active_peer = Some(peer.clone());
3328− m.page = Page::Chat;
3329−
3330− let mut cmd = app.update(Event::DeleteContact { peer: peer.clone() }, &mut m);
3331−
3332− assert!(!m.contacts.contains_key(&peer));
3333− assert!(!m.messages.contains_key(&peer));
3334− assert!(
3335− m.outbox.iter().all(|i| i.recipient != peer),
3336− "nothing more goes to a peer we have forgotten"
3337− );
3338− assert_eq!(m.page, Page::Conversations, "the open chat is closed");
3339− assert!(m.active_peer.is_none());
3340−
3341− // Everyone else is untouched.
3342− assert!(m.contacts.contains_key(&other));
3343− assert_eq!(m.messages[&other].len(), 1);
3344− assert_eq!(m.outbox.len(), 1);
3345−
3346− // The history blob is *deleted*, not left behind holding what the user just
3347− // asked us to forget.
3348− let deleted = cmd.effects().any(|e| {
3349− matches!(e, Effect::KeyValue(req)
3350− if matches!(&req.operation, crux_kv::KeyValueOperation::Delete { key } if key == &k_messages(&peer)))
3351− });
3352− assert!(deleted, "the messages:<peer> key is removed");
3353− }
3354−
3355− /// Deleting a peer we are not looking at must not throw the user out of the
3356− /// chat they *are* looking at.
3357− #[test]
3358− fn deleting_another_contact_leaves_the_open_chat_alone() {
3359− let app = Skrepka;
3360− let mut m = with_identity();
3361− let open = peer_hex(9);
3362− let doomed = peer_hex(11);
3363− for p in [&open, &doomed] {
3364− m.contacts
3365− .insert(p.clone(), Contact::new(p.clone(), String::new(), 0));
3366− }
3367− m.active_peer = Some(open.clone());
3368− m.page = Page::Chat;
3369−
3370− let _ = app.update(Event::DeleteContact { peer: doomed }, &mut m);
3371−
3372− assert_eq!(m.page, Page::Chat);
3373− assert_eq!(m.active_peer.as_deref(), Some(open.as_str()));
3374− }
3375−
3376− // -----------------------------------------------------------------------
3377− // watermark clamping
3378− // -----------------------------------------------------------------------
3379−
3380− /// An ack at `ts = i64::MAX` used to park `last_ack_ts` a minute into the
3381− /// future — and every honest ack that followed, the ones that actually mark our
3382− /// messages delivered, read as stale and was dropped. A peer could silence its
3383− /// own delivery receipts forever with one payload.
3384− #[test]
3385− fn a_far_future_ack_cannot_suppress_the_acks_that_follow_it() {
3386− let app = Skrepka;
3387− let mut m = with_identity();
3388− let peer = peer_hex(9);
3389− m.contacts
3390− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3391− m.messages.insert(
3392− peer.clone(),
3393− vec![StoredMessage {
3394− id: "m1".into(),
3395− body: "hi".into(),
3396− ts: 1,
3397− outgoing: true,
3398− delivered: false,
3399− }],
3400− );
3401−
3402− // The poisoned ack: acks nothing we sent, but sets the watermark.
3403− let ack = Payload::DeliveryAck {
3404− ack_ids: vec!["nothing".into()],
3405− };
3406− let page = page_from(9, &m, &ack, i64::MAX);
3407− let _ = app.ingest_poll(&mut m, page);
3408− assert!(
3409− m.contacts[&peer].last_ack_ts <= now_ms(),
3410− "the watermark cannot be pushed into the future"
3411− );
3412−
3413− // A perfectly ordinary ack, sent now. It must still land.
3414− let ack = Payload::DeliveryAck {
3415− ack_ids: vec!["m1".into()],
3416− };
3417− let page = page_from(9, &m, &ack, now_ms());
3418− let _ = app.ingest_poll(&mut m, page);
3419− assert!(
3420− m.messages[&peer][0].delivered,
3421− "an honest ack after a far-future one must still be honoured"
3422− );
3423− }
3424−
3425− /// The same trick against `last_profile_ts`: a profile at `ts = i64::MAX` would
3426− /// freeze the peer's own entry, since every honest update they sent afterwards
3427− /// would read as a stale replay of itself.
3428− #[test]
3429− fn a_far_future_profile_cannot_freeze_the_peers_own_profile() {
3430− let app = Skrepka;
3431− let mut m = with_identity();
3432− let peer = peer_hex(9);
3433− m.contacts
3434− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3435−
3436− let profile = |name: &str| Payload::Profile {
3437− display_name: Some(name.to_string()),
3438− bio: Some(String::new()),
3439− photo: None,
3440− };
3441− let page = page_from(9, &m, &profile("Far"), i64::MAX);
3442− let _ = app.ingest_poll(&mut m, page);
3443− let first_ts = m.contacts[&peer].last_profile_ts;
3444− assert!(first_ts <= now_ms());
3445−
3446− // A later profile at a greater ts must still apply — the clamping
3447− // is what stops the far-future one from freezing the peer.
3448− let page = page_from(9, &m, &profile("Now"), first_ts + 1);
3449− let _ = app.ingest_poll(&mut m, page);
3450− assert_eq!(
3451− m.contacts[&peer].display_name, "Now",
3452− "a later, honest profile must still apply"
3453− );
3454− }
3455−
3456− // -----------------------------------------------------------------------
3457− // conversation ordering
3458− // -----------------------------------------------------------------------
3459−
3460− /// The conversation is held in `ts` order rather than re-sorted per arrival.
3461− /// `trim_history` ages out the *front*, so an out-of-order insert does not just
3462− /// render wrong — it throws away the wrong message.
3463− #[test]
3464− fn messages_are_inserted_in_timestamp_order() {
3465− let app = Skrepka;
3466− let mut m = with_identity();
3467− let peer = peer_hex(9);
3468−
3469− // Arrive out of order, including a tie.
3470− for (i, ts) in [300i64, 100, 200, 200].into_iter().enumerate() {
3471− let page = page_from(9, &m, &text(&format!("m{i}"), "x"), ts);
3472− let _ = app.ingest_poll(&mut m, page);
3473− }
3474− // ...and an outgoing message, which used to be appended blindly.
3475− m.active_peer = Some(peer.clone());
3476− m.compose = "mine".into();
3477− let _ = app.update(Event::SendText, &mut m);
3478−
3479− let convo = &m.messages[&peer];
3480− assert!(
3481− convo.windows(2).all(|w| w[0].ts <= w[1].ts),
3482− "the conversation is sorted: {:?}",
3483− convo.iter().map(|s| s.ts).collect::<Vec<_>>()
3484− );
3485− // Ties keep arrival order, exactly as the stable sort used to give.
3486− let tied: Vec<&str> = convo
3487− .iter()
3488− .filter(|s| s.ts == 200)
3489− .map(|s| s.id.as_str())
3490− .collect();
3491− assert_eq!(tied, vec!["m2", "m3"]);
3492− }
3493−
34941539 /// PROTOCOL.md §4: a blocked peer's messages are not shown — and acking them
34951540 /// would tell the blocked peer we are online and reading.
34961541 #[test]
@@ -3532,11 +1577,8 @@ mod tests {
35321577 let (m, peer) = ingest_one_text_from_an_unblocked_peer();
35331578 assert_eq!(m.messages[&peer].len(), 1);
35341579 assert_eq!(m.messages[&peer][0].body, "hi");
3535− // D6-app: A profile broadcast is queued alongside the delivery.ack
3536− // so the new contact sees our display name and avatar.
3537− assert_eq!(m.outbox.len(), 2, "one delivery.ack + one profile queued");
1580+ assert_eq!(m.outbox.len(), 1, "one delivery.ack is queued");
35381581 assert_eq!(m.outbox[0].recipient, peer);
3539− assert_eq!(m.outbox[1].recipient, peer);
35401582 }
35411583
35421584 /// A peer sending `ts = i64::MAX` used to pin itself to the top of the
@@ -3609,8 +1651,10 @@ mod tests {
36091651 let mut m = with_identity();
36101652 m.token = Some("t".into());
36111653 let bad = hex::encode([0xabu8; 32]); // 32 bytes, not a curve point
3612− m.outbox
3613− .push_back(OutboxItem::new(bad, Arc::new("{}".into())));
1654+ m.outbox.push_back(OutboxItem {
1655+ recipient: bad,
1656+ envelope_json: "{}".into(),
1657+ });
36141658
36151659 let _ = app.update(Event::StartFlush, &mut m);
36161660
@@ -3717,28 +1761,6 @@ mod tests {
37171761 assert!(m.error.is_some());
37181762 }
37191763
3720− /// A stale error from a previous failed add is cleared on the next attempt,
3721− /// so the AddContact form does not show an old error after the user fixes
3722− /// the input and tries again.
3723− #[test]
3724− fn add_contact_clears_a_stale_error_on_success() {
3725− let app = Skrepka;
3726− let mut m = with_identity();
3727− m.error = Some("invalid public key".into());
3728−
3729− let peer = peer_hex(9);
3730− let _ = app.update(
3731− Event::AddContact {
3732− input: peer.clone(),
3733− nickname: "Bob".into(),
3734− },
3735− &mut m,
3736− );
3737−
3738− assert!(m.error.is_none(), "a successful add clears the stale error");
3739− assert!(m.contacts.contains_key(&peer));
3740− }
3741−
37421764 #[test]
37431765 fn send_text_enqueues_message_profile_and_outbox() {
37441766 let app = Skrepka;
@@ -3764,51 +1786,16 @@ mod tests {
37641786
37651787 #[test]
37661788 fn send_text_without_peer_or_empty_is_noop() {
3767− let app = Skrepka;
3768− let mut m = with_identity();
3769− m.compose = "x".into();
3770− let _ = app.update(Event::SendText, &mut m); // no active peer
3771− assert!(m.outbox.is_empty());
3772−
3773− m.active_peer = Some(peer_hex(9));
3774− m.compose = " ".into();
3775− let _ = app.update(Event::SendText, &mut m); // whitespace only
3776− assert!(m.outbox.is_empty());
3777− }
3778−
3779− /// The contact cap must be checked *before* mutating anything. The old
3780− /// order cleared `compose` and inserted the message into `model.messages`
3781− /// first, then hit the cap and returned early — losing the user's text and
3782− /// leaving an invisible orphan conversation that was never persisted.
3783− #[test]
3784− fn send_text_to_a_new_peer_at_the_contact_cap_preserves_compose() {
3785− let app = Skrepka;
3786− let mut m = with_identity();
3787− // Fill the contact list to the cap.
3788− for i in 0..MAX_CONTACTS {
3789− let k = format!("{i:064x}");
3790− m.contacts.insert(k.clone(), Contact::new(k, String::new(), 0));
3791− }
3792− let new_peer = peer_hex(9);
3793− m.active_peer = Some(new_peer.clone());
3794− m.compose = "hello new friend".into();
3795−
3796− let _ = app.update(Event::SendText, &mut m);
1789+ let app = Skrepka;
1790+ let mut m = with_identity();
1791+ m.compose = "x".into();
1792+ let _ = app.update(Event::SendText, &mut m); // no active peer
1793+ assert!(m.outbox.is_empty());
37971794
3798− assert_eq!(
3799− m.compose, "hello new friend",
3800− "compose must not be cleared on rejection"
3801− );
3802− assert!(
3803− m.messages.get(&new_peer).is_none_or(Vec::is_empty),
3804− "no orphan conversation must be created"
3805− );
3806− assert!(m.outbox.is_empty(), "nothing must be queued");
3807− assert!(!m.contacts.contains_key(&new_peer), "no contact created");
3808− assert!(
3809− m.error.as_ref().is_some_and(|e| e.contains("full")),
3810− "the user is told why"
3811− );
1795+ m.active_peer = Some(peer_hex(9));
1796+ m.compose = " ".into();
1797+ let _ = app.update(Event::SendText, &mut m); // whitespace only
1798+ assert!(m.outbox.is_empty());
38121799 }
38131800
38141801 #[test]
@@ -3830,30 +1817,6 @@ mod tests {
38301817 assert!(vm.contacts.iter().any(|c| c.pubkey == peer && c.blocked));
38311818 }
38321819
3833− /// Unblocking clears a stale "cannot send to a blocked contact" error, so the
3834− /// chat error display does not persist after the user lifts the block.
3835− #[test]
3836− fn unblocking_clears_the_stale_send_error() {
3837− let app = Skrepka;
3838− let mut m = with_identity();
3839− let peer = peer_hex(9);
3840− let mut contact = Contact::new(peer.clone(), "Bob".into(), 0);
3841− contact.blocked = true;
3842− m.contacts.insert(peer.clone(), contact);
3843− m.error = Some("cannot send to a blocked contact".into());
3844−
3845− let _ = app.update(
3846− Event::SetBlocked {
3847− peer: peer.clone(),
3848− blocked: false,
3849− },
3850− &mut m,
3851− );
3852−
3853− assert!(!m.contacts[&peer].blocked);
3854− assert!(m.error.is_none(), "unblocking clears the stale error");
3855− }
3856−
38571820 /// Gating ingest stops the acks we have not queued yet — but the ack for the
38581821 /// message that made the user hit "block" is already in the outbox, and an ack
38591822 /// is exactly the "we are online and reading you" signal the block exists to
@@ -3869,15 +1832,15 @@ mod tests {
38691832
38701833 // An ack to the peer we are about to block, plus unrelated mail.
38711834 for recipient in [&enemy, &friend] {
3872− m.outbox.push_back(OutboxItem::new(
3873− recipient.clone(),
3874− Arc::new(protocol::serialize_payload(
1835+ m.outbox.push_back(OutboxItem {
1836+ recipient: recipient.clone(),
1837+ envelope_json: protocol::serialize_payload(
38751838 &Payload::DeliveryAck {
38761839 ack_ids: vec!["m1".into()],
38771840 },
38781841 1,
3879− )),
3880− ));
1842+ ),
1843+ });
38811844 }
38821845
38831846 let _ = app.update(
@@ -3966,14 +1929,14 @@ mod tests {
39661929 let app = Skrepka;
39671930 let mut m = with_identity();
39681931 m.token = Some("t".into());
3969− m.outbox
3970− .push_back(OutboxItem::new(peer_hex(9), Arc::new("{}".into())));
3971−
3972− let _ = app.update(Event::StartFlush, &mut m);
3973− assert!(m.flushing);
1932+ m.flushing = true;
1933+ m.outbox.push_back(OutboxItem {
1934+ recipient: peer_hex(9),
1935+ envelope_json: "{}".into(),
1936+ });
39741937
39751938 let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
3976− let _ = app.update(Event::SendResult(m.flush_gen, Ok(resp)), &mut m);
1939+ let _ = app.update(Event::SendResult(Ok(resp)), &mut m);
39771940
39781941 assert!(!m.flushing, "flushing must be cleared on the success path");
39791942 assert!(m.outbox.is_empty(), "the sent item is dropped from the outbox");
@@ -3984,10 +1947,11 @@ mod tests {
39841947 let app = Skrepka;
39851948 let mut m = with_identity();
39861949 m.token = Some("t".into());
3987− // Two different peers, or the batching would send both at once.
3988− for seed in [9u8, 11] {
3989− m.outbox
3990− .push_back(OutboxItem::new(peer_hex(seed), Arc::new("{}".into())));
1950+ for _ in 0..2 {
1951+ m.outbox.push_back(OutboxItem {
1952+ recipient: peer_hex(9),
1953+ envelope_json: "{}".into(),
1954+ });
39911955 }
39921956 // First send goes out and marks the model as flushing.
39931957 let _ = app.update(Event::StartFlush, &mut m);
@@ -3995,7 +1959,7 @@ mod tests {
39951959
39961960 // Its success must both drop the item and let the *next* one go out.
39971961 let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
3998− let _ = app.update(Event::SendResult(m.flush_gen, Ok(resp)), &mut m);
1962+ let _ = app.update(Event::SendResult(Ok(resp)), &mut m);
39991963 assert_eq!(m.outbox.len(), 1);
40001964 assert!(m.flushing, "the second item is now in flight");
40011965 }
@@ -4138,7 +2102,6 @@ mod tests {
41382102 Event::LoadedProfile(Ok(None)),
41392103 Event::LoadedContacts(Ok(None)),
41402104 Event::LoadedCursor(Ok(None)),
4141− Event::LoadedSeenIds(Ok(None)),
41422105 ]
41432106 .into_iter()
41442107 .enumerate()
@@ -4152,7 +2115,7 @@ mod tests {
41522115 assert_eq!(connects(&mut cmd), 0, "and none of them connects");
41532116 }
41542117
4155− // The sixth — and only the sixth — emits Connect.
2118+ // The fifth — and only the fifth — emits Connect.
41562119 let mut cmd = app.update(Event::LoadedOutbox(Ok(None)), &mut m);
41572120 assert_eq!(m.loads_pending, 0);
41582121 assert_eq!(connects(&mut cmd), 1, "the last load connects, exactly once");
@@ -4162,94 +2125,6 @@ mod tests {
41622125 assert_eq!(connects(&mut cmd), 0);
41632126 }
41642127
4165− /// The sharpest edge of a failed read, and the one that costs the user real
4166− /// messages: `ingest_poll` persists the outbox on *every* poll page, including
4167− /// an empty one. So a single unreadable outbox — a background wake with the
4168− /// device locked — followed by one idle poll used to write `[]` over a queue of
4169− /// unsent mail. The client destroys the user's messages by doing nothing at all.
4170− ///
4171− /// A read failure is not an empty key. Nothing may be written until a launch
4172− /// reads the key successfully.
4173− #[test]
4174− fn a_failed_read_never_writes_over_what_it_could_not_read() {
4175− let app = Skrepka;
4176− let mut m = with_identity();
4177− m.loads_pending = STARTUP_LOADS;
4178−
4179− let connects = |cmd: &mut Command<Effect, Event>| {
4180− cmd.events()
4181− .filter(|e| matches!(e, Event::Connect))
4182− .count()
4183− };
4184− // Every key a command actually writes. The model's own view of what it saved
4185− // cannot see these — the write lives entirely in the effect.
4186− let kv_writes = |cmd: &mut Command<Effect, Event>| {
4187− cmd.effects()
4188− .filter_map(|e| match e {
4189− Effect::KeyValue(req) => match &req.operation {
4190− crux_kv::KeyValueOperation::Set { key, .. } => Some(key.clone()),
4191− _ => None,
4192− },
4193− _ => None,
4194− })
4195− .collect::<Vec<_>>()
4196− };
4197−
4198− let _ = app.update(Event::LoadedSettings(Ok(None)), &mut m);
4199− let _ = app.update(Event::LoadedProfile(Ok(None)), &mut m);
4200− let _ = app.update(Event::LoadedContacts(Ok(None)), &mut m);
4201− let _ = app.update(Event::LoadedCursor(Ok(None)), &mut m);
4202− // The outbox is the one that could not be read — a background wake with the
4203− // device locked. The model now believes it is empty; the disk says otherwise.
4204− let mut cmd = app.update(
4205− Event::LoadedOutbox(Err(crux_kv::error::KeyValueError::Io {
4206− message: "device is locked".into(),
4207− })),
4208− &mut m,
4209− );
4210−
4211− assert!(m.kv_load_failed, "the model knows it has a hole in it");
4212− assert!(m.error.is_some(), "and says so");
4213− assert_eq!(
4214− connects(&mut cmd),
4215− 0,
4216− "the last load does not connect: a poll would ack mail we cannot store"
4217− );
4218−
4219− // Every write path is closed, whatever tries to open it.
4220− let mut cmd = app.update(
4221− Event::AddContact {
4222− input: peer_hex(9),
4223− nickname: "n".into(),
4224− },
4225− &mut m,
4226− );
4227− assert!(
4228− kv_writes(&mut cmd).is_empty(),
4229− "contacts are not written over an unread model"
4230− );
4231−
4232− // And the regression itself. `ingest_poll` persists the outbox on every page,
4233− // so one idle poll used to be enough to write `[]` over the queue on disk.
4234− m.token = Some("t".into());
4235− let resp = crux_http::testing::ResponseBuilder::ok()
4236− .body(br#"{"events":[],"cursor":42}"#.to_vec())
4237− .build();
4238− let mut cmd = app.update(Event::PollResult(m.poll_gen, Ok(resp)), &mut m);
4239− assert!(
4240− kv_writes(&mut cmd).is_empty(),
4241− "an empty poll writes nothing — not the outbox, not the cursor"
4242− );
4243−
4244− // The recovery is a relaunch, which re-runs the loads and clears the latch.
4245− let sk = Identity::from_seed(&[3u8; 32]).unwrap().secret_key.to_vec();
4246− let _ = app.update(Event::IdentityLoaded(sk), &mut m);
4247− assert!(
4248− !m.kv_load_failed,
4249− "a fresh round of loads gets to answer for itself"
4250− );
4251− }
4252−
42532128 /// `Authenticate` is reachable from startup, a 401 on poll, a 401 on send and
42542129 /// a reconnect timer. Without a guard a network blip fires all of them, and
42552130 /// every token that comes back starts its own poll loop.
@@ -4349,8 +2224,8 @@ mod tests {
43492224 );
43502225
43512226 let profile = Payload::Profile {
4352− display_name: Some("Mallory".into()),
4353− bio: Some("spam".into()),
2227+ display_name: "Mallory".into(),
2228+ bio: "spam".into(),
43542229 photo: Some("aGk=".into()),
43552230 };
43562231 let page = page_from(9, &m, &profile, now_ms());
@@ -4431,8 +2306,8 @@ mod tests {
44312306 let peer = peer_hex(9);
44322307
44332308 let profile = Payload::Profile {
4434− display_name: Some("Mallory".into()),
4435− bio: Some(String::new()),
2309+ display_name: "Mallory".into(),
2310+ bio: String::new(),
44362311 photo: Some("aGk=".into()),
44372312 };
44382313 let page = page_from(9, &m, &profile, now_ms());
@@ -4447,43 +2322,6 @@ mod tests {
44472322 assert_eq!(m.contacts[&peer].display_name, "Mallory");
44482323 }
44492324
4450− /// A relay cannot forge mail from us — the sender identity is signed inside the
4451− /// AEAD — but it can *echo back* a blob we sent through it, and one addressed to
4452− /// ourselves decrypts perfectly well. Nothing downstream would notice: it would
4453− /// file our own words as incoming, put our own key in our contact list (which
4454− /// `AddContact` explicitly refuses), and queue a delivery ack addressed to us —
4455− /// which flushes, comes back, and is acked again.
4456− #[test]
4457− fn a_message_from_ourselves_is_dropped() {
4458− let app = Skrepka;
4459− let mut m = with_identity();
4460− let me = Identity::from_secret_bytes(m.secret_key.as_ref().unwrap()).unwrap();
4461−
4462− let json = protocol::serialize_payload(&text("m1", "echo"), now_ms());
4463− let blob = crate::crypto::encrypt(
4464− &mut rand_core::OsRng,
4465− &me,
4466− &me.public_key(),
4467− json.as_bytes(),
4468− )
4469− .unwrap();
4470− let page = PollResp {
4471− events: vec![PollEvent {
4472− encrypted_blob: hex::encode(blob),
4473− }],
4474− cursor: 1,
4475− };
4476−
4477− let _ = app.ingest_poll(&mut m, page);
4478−
4479− assert!(
4480− !m.contacts.contains_key(&m.my_pubkey),
4481− "we are not our own contact"
4482− );
4483− assert!(m.messages.is_empty(), "and our own words are not incoming mail");
4484− assert!(m.outbox.is_empty(), "and we do not ack ourselves");
4485− }
4486−
44872325 /// `polling` gates the whole long-poll loop and is cleared only by a
44882326 /// `PollResult`. If the shell ever drops the HTTP effect, the client goes
44892327 /// silent forever with no visible error.
@@ -4644,7 +2482,7 @@ mod tests {
46442482
46452483 // One page holding more messages from one sender than fits in a single ack.
46462484 let count = protocol::MAX_ACK_IDS + 5;
4647− let sender = Identity::from_seed(&[9u8; 32]).unwrap();
2485+ let sender = Identity::from_seed(&[9u8; 32]);
46482486 let me = Identity::from_secret_bytes(m.secret_key.as_ref().unwrap()).unwrap();
46492487 let events = (0..count)
46502488 .map(|i| {
@@ -4694,334 +2532,4 @@ mod tests {
46942532 assert_eq!(vm.my_pubkey_hex, m.my_pubkey);
46952533 assert_eq!(vm.my_pubkey_ob.split('-').count(), 16);
46962534 }
4697−
4698− /// A replayed delivery.ack with an older `ts` must be dropped, and
4699− /// `last_ack_ts` must not regress — otherwise a relay re-delivering an old
4700− /// page could flip messages back to "not delivered" or keep a stale ack live.
4701− #[test]
4702− fn a_replayed_delivery_ack_is_ignored() {
4703− let app = Skrepka;
4704− let mut m = with_identity();
4705− let peer = peer_hex(9);
4706− let mut contact = Contact::new(peer.clone(), "Bob".into(), 0);
4707− contact.last_ack_ts = 100;
4708− m.contacts.insert(peer.clone(), contact);
4709− m.messages.insert(
4710− peer.clone(),
4711− vec![StoredMessage {
4712− id: "m1".into(),
4713− body: "hi".into(),
4714− ts: 50,
4715− outgoing: true,
4716− delivered: false,
4717− }],
4718− );
4719−
4720− // An ack with ts = 50 (< 100) is stale → ignored.
4721− let ack = Payload::DeliveryAck {
4722− ack_ids: vec!["m1".into()],
4723− };
4724− let page = page_from(9, &m, &ack, 50);
4725− let _ = app.ingest_poll(&mut m, page);
4726− assert!(
4727− !m.messages[&peer][0].delivered,
4728− "a stale ack must not mark the message delivered"
4729− );
4730− assert_eq!(
4731− m.contacts[&peer].last_ack_ts, 100,
4732− "last_ack_ts must not regress"
4733− );
4734−
4735− // An ack with ts = 200 (> 100) passes → message is marked delivered.
4736− let ack = Payload::DeliveryAck {
4737− ack_ids: vec!["m1".into()],
4738− };
4739− let page = page_from(9, &m, &ack, 200);
4740− let _ = app.ingest_poll(&mut m, page);
4741− assert!(
4742− m.messages[&peer][0].delivered,
4743− "a fresh ack must mark the message delivered"
4744− );
4745− assert_eq!(
4746− m.contacts[&peer].last_ack_ts, 200,
4747− "last_ack_ts advances to the fresh ack's ts"
4748− );
4749− }
4750−
4751− // -----------------------------------------------------------------------
4752− // cursor restoration on budget exhaustion
4753− // -----------------------------------------------------------------------
4754−
4755− /// The cursor is advanced to `page.cursor` before the event loop runs. If the
4756− /// poll-byte budget is exhausted mid-page, the loop breaks — but the cursor
4757− /// must be restored to its pre-page value. Otherwise the next poll acks the
4758− /// whole page to the relay, which deletes every event we skipped: permanent
4759− /// message loss.
4760− #[test]
4761− fn a_budget_exhausted_mid_page_restores_the_cursor() {
4762− let app = Skrepka;
4763− let mut m = with_identity();
4764− let peer = peer_hex(9);
4765− m.contacts
4766− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
4767− m.cursor = 100;
4768−
4769− // Two blobs, each at the per-blob ceiling (MAX_BLOB_LEN hex chars =
4770− // MAX_BLOB_LEN/2 decoded bytes = 21 MiB). Individually they pass the
4771− // per-blob size gate; together they exceed the 64 MiB page budget.
4772− // The first passes the budget check (21 < 64) and fails decrypt (it's
4773− // not a real blob); the second fails the budget check (21 + 21 + 21 > 64).
4774− // Three blobs to be sure: 3 × 21 = 63 MiB (just under 64), so use four.
4775− let big_hex = "ab".repeat(MAX_BLOB_LEN); // MAX_BLOB_LEN hex chars = MAX_BLOB_LEN/2 bytes
4776− let page = PollResp {
4777− events: (0..4)
4778− .map(|_| PollEvent {
4779− encrypted_blob: big_hex.clone(),
4780− })
4781− .collect(),
4782− cursor: 200,
4783− };
4784− let _ = app.ingest_poll(&mut m, page);
4785−
4786− assert_eq!(
4787− m.cursor, 100,
4788− "the cursor must be restored — the page was not fully processed"
4789− );
4790− assert!(
4791− m.error.as_ref().is_some_and(|e| e.contains("oversized")),
4792− "the user is told"
4793− );
4794− assert!(
4795− m.messages.get(&peer).is_none_or(Vec::is_empty),
4796− "nothing was stored from the oversized page"
4797− );
4798− }
4799−
4800− // -----------------------------------------------------------------------
4801− // send_text block check
4802− // -----------------------------------------------------------------------
4803−
4804− // -----------------------------------------------------------------------
4805− // send_text body length check
4806− // -----------------------------------------------------------------------
4807−
4808− /// Blocking cuts a peer off in both directions. `send_text` must refuse to
4809− /// enqueue a message to a blocked contact — otherwise the block only stops
4810− /// their incoming mail while we keep sending them ours.
4811− #[test]
4812− fn send_text_to_a_blocked_peer_is_refused() {
4813− let app = Skrepka;
4814− let mut m = with_identity();
4815− let peer = peer_hex(9);
4816− let mut contact = Contact::new(peer.clone(), "Enemy".into(), 0);
4817− contact.blocked = true;
4818− m.contacts.insert(peer.clone(), contact);
4819− m.active_peer = Some(peer.clone());
4820− m.compose = "hello?".into();
4821−
4822− let _ = app.update(Event::SendText, &mut m);
4823−
4824− assert!(
4825− m.outbox.is_empty(),
4826− "no message is queued for a blocked peer"
4827− );
4828− assert!(
4829− m.error.as_ref().is_some_and(|e| e.contains("blocked")),
4830− "the user is told why"
4831− );
4832− // The compose buffer is not cleared — the user might want to unblock
4833− // and resend, and losing their draft would be surprising.
4834− assert_eq!(m.compose, "hello?");
4835− }
4836−
4837− /// A text body over `MAX_BODY_LEN` would be stored locally and sent to the
4838− /// relay, but the recipient's `parse_payload` silently drops oversized text.
4839− /// The sender would see it stuck as "sent" forever with no error.
4840− #[test]
4841− fn send_text_refuses_an_oversized_body() {
4842− let app = Skrepka;
4843− let mut m = with_identity();
4844− let peer = peer_hex(9);
4845− m.active_peer = Some(peer.clone());
4846− m.compose = "x".repeat(protocol::MAX_BODY_LEN + 1);
4847−
4848− let _ = app.update(Event::SendText, &mut m);
4849−
4850− assert!(m.outbox.is_empty(), "nothing is queued");
4851− assert!(m.messages.get(&peer).is_none_or(Vec::is_empty), "nothing stored");
4852− assert!(!m.compose.is_empty(), "compose is preserved");
4853− assert!(m.error.as_ref().is_some_and(|e| e.contains("too long")));
4854− }
4855−
4856− /// A stale error from a previous failed send must be cleared on the next
4857− /// attempt, so the error display does not persist after the user fixes the
4858− /// issue and sends again successfully.
4859− #[test]
4860− fn send_text_clears_a_stale_error_on_success() {
4861− let app = Skrepka;
4862− let mut m = with_identity();
4863− let peer = peer_hex(9);
4864− m.active_peer = Some(peer.clone());
4865− // Simulate a prior failure that left an error behind.
4866− m.error = Some("message too long".into());
4867−
4868− m.compose = "hello".into();
4869− let _ = app.update(Event::SendText, &mut m);
4870−
4871− assert!(m.error.is_none(), "a successful send clears the stale error");
4872− assert!(!m.outbox.is_empty(), "the message was queued");
4873− }
4874−
4875− // -----------------------------------------------------------------------
4876− // SaveProfile field length checks
4877− // -----------------------------------------------------------------------
4878−
4879− #[test]
4880− fn save_profile_rejects_an_oversized_display_name() {
4881− let app = Skrepka;
4882− let mut m = with_identity();
4883−
4884− let _ = app.update(Event::SaveProfile {
4885− display_name: "x".repeat(protocol::MAX_DISPLAY_NAME_LEN + 1),
4886− bio: String::new(),
4887− photo: None,
4888− }, &mut m);
4889−
4890− assert!(m.outbox.is_empty(), "no broadcast queued");
4891− assert!(m.profile.display_name.is_empty(), "local profile not updated");
4892− assert!(m.error.as_ref().is_some_and(|e| e.contains("display name")));
4893− }
4894−
4895− #[test]
4896− fn save_profile_rejects_an_oversized_bio() {
4897− let app = Skrepka;
4898− let mut m = with_identity();
4899−
4900− let _ = app.update(Event::SaveProfile {
4901− display_name: "Alice".into(),
4902− bio: "x".repeat(protocol::MAX_BIO_LEN + 1),
4903− photo: None,
4904− }, &mut m);
4905−
4906− assert!(m.outbox.is_empty(), "no broadcast queued");
4907− assert!(m.profile.bio.is_empty(), "local profile not updated");
4908− assert!(m.error.as_ref().is_some_and(|e| e.contains("bio")));
4909− }
4910−
4911− #[test]
4912− fn save_profile_rejects_an_oversized_photo() {
4913− let app = Skrepka;
4914− let mut m = with_identity();
4915−
4916− let _ = app.update(Event::SaveProfile {
4917− display_name: "Alice".into(),
4918− bio: String::new(),
4919− photo: Some("x".repeat(protocol::MAX_PHOTO_LEN + 1)),
4920− }, &mut m);
4921−
4922− assert!(m.outbox.is_empty(), "no broadcast queued");
4923− assert!(m.profile.photo.is_none(), "local profile not updated");
4924− assert!(m.error.as_ref().is_some_and(|e| e.contains("photo")));
4925− }
4926−
4927− // -----------------------------------------------------------------------
4928− // AddContact nickname length check
4929− // -----------------------------------------------------------------------
4930−
4931− #[test]
4932− fn add_contact_rejects_an_oversized_nickname() {
4933− let app = Skrepka;
4934− let mut m = with_identity();
4935− let peer = peer_hex(9);
4936−
4937− let _ = app.update(Event::AddContact {
4938− input: peer.clone(),
4939− nickname: "x".repeat(protocol::MAX_DISPLAY_NAME_LEN + 1),
4940− }, &mut m);
4941−
4942− assert!(!m.contacts.contains_key(&peer), "no contact created");
4943− assert!(m.error.as_ref().is_some_and(|e| e.contains("nickname")));
4944− }
4945−
4946− // -----------------------------------------------------------------------
4947− // FlushWatchdog unsticks a wedged send loop
4948− // -----------------------------------------------------------------------
4949−
4950− /// `flushing` is only cleared by `SendResult`. If the shell drops the HTTP
4951− /// effect, the outbox is permanently stuck. The watchdog releases the guard
4952− /// so the flush retries.
4953− #[test]
4954− fn the_flush_watchdog_unwedges_a_send_that_never_resolved() {
4955− let app = Skrepka;
4956− let mut m = with_identity();
4957− m.token = Some("t".into());
4958− let peer = peer_hex(9);
4959− m.outbox.push_back(queued(&peer, "stuck"));
4960−
4961− let _ = app.update(Event::StartFlush, &mut m);
4962− assert!(m.flushing, "the send is in flight");
4963− assert!(m.in_flight.is_some());
4964−
4965− // The send never comes back. The watchdog fires, clears the stuck guard,
4966− // and retries the flush — the message goes out again.
4967− let mut cmd = app.update(Event::FlushWatchdog(m.flush_gen), &mut m);
4968−
4969− assert!(m.flushing, "a fresh send is in flight (the retry)");
4970− assert!(m.in_flight.is_some(), "in_flight is set for the retry");
4971− let batches = sent_batches(&mut cmd);
4972− assert_eq!(batches.len(), 1, "the message is re-sent");
4973− }
4974−
4975− /// A watchdog that fires after the send already completed is a no-op.
4976− #[test]
4977− fn a_stale_flush_watchdog_leaves_a_completed_send_alone() {
4978− let app = Skrepka;
4979− let mut m = with_identity();
4980− m.token = Some("t".into());
4981− m.outbox.push_back(queued(&peer_hex(9), "hi"));
4982−
4983− let _ = app.update(Event::StartFlush, &mut m);
4984− let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
4985− assert!(!m.flushing);
4986− assert!(m.outbox.is_empty());
4987−
4988− // The watchdog for the completed send fires late.
4989− let _ = app.update(Event::FlushWatchdog(m.flush_gen), &mut m);
4990− assert!(!m.flushing, "a completed send is not re-armed");
4991− assert!(m.outbox.is_empty());
4992− }
4993−
4994− /// A late `SendResult` from a superseded send is dropped by the generation
4995− /// check. The watchdog abandoned the first send and retried under a new
4996− /// generation; the late result's generation no longer matches, so it cannot
4997− /// pop the retry's items or clear its guard. The retry's own `SendResult`
4998− /// is what eventually pops them.
4999− #[test]
5000− fn a_late_send_result_after_the_watchdog_is_dropped_by_generation() {
5001− let app = Skrepka;
5002− let mut m = with_identity();
5003− m.token = Some("t".into());
5004− let peer = peer_hex(9);
5005− m.outbox.push_back(queued(&peer, "stuck"));
5006−
5007− let _ = app.update(Event::StartFlush, &mut m);
5008− let first_gen = m.flush_gen;
5009− // The watchdog fires and retries the flush under a new generation.
5010− let _ = app.update(Event::FlushWatchdog(first_gen), &mut m);
5011− assert_eq!(m.outbox.len(), 1, "the item is still queued (re-sent)");
5012− assert_ne!(m.flush_gen, first_gen, "the generation was bumped");
5013− assert!(m.flushing, "the retry is in flight");
5014−
5015− // The original send's result arrives late. Its generation no longer
5016− // matches, so it is dropped — it cannot pop the retry's items or
5017− // clear the retry's guard.
5018− let _ = app.update(Event::SendResult(first_gen, Ok(ok_response(200))), &mut m);
5019− assert!(m.flushing, "the retry's guard is untouched");
5020− assert_eq!(m.outbox.len(), 1, "the late result popped nothing");
5021−
5022− // The retry's own result arrives and pops the items.
5023− let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
5024− assert!(m.outbox.is_empty(), "the retry's result pops the items");
5025− assert!(!m.flushing, "and the guard is released");
5026− }
50272535}