@@ -73,6 +73,20 @@ const MAX_POLL_TOTAL_BYTES: usize = 64 * 1024 * 1024;
7373/// relay, so leave the margin.
7474const POLL_WATCHDOG_MS: u64 = 90_000;
7575
76+/// How long a send may stay in flight before we assume the shell lost it.
77+///
78+/// `flushing` gates the whole send loop, and it is only cleared by a
79+/// `SendResult`. If the shell ever drops the HTTP effect (a cancelled task, a
80+/// backgrounded app, a bug), `flushing` stays `true` and the outbox is
81+/// permanently stuck — new messages queue up but never send. The watchdog
82+/// releases the guard so the next `StartFlush` can retry.
83+///
84+/// Kept above the shell's 70 s HTTP timeout for the same reason as
85+/// `POLL_WATCHDOG_MS`: a misfire on a merely slow send is survivable (the late
86+/// `SendResult` finds `in_flight` already cleared and pops nothing), but each
87+/// one costs a redundant request.
88+const FLUSH_WATCHDOG_MS: u64 = 90_000;
89+
7690/// Messages per `/messages` request.
7791///
7892/// The relay rejects a longer batch outright (`413 batch_too_large`,
@@ -210,6 +224,11 @@ pub enum Event {
210224 #[serde(skip)]
211225 #[facet(skip)]
212226 StartFlush,
227+ /// Fires `FLUSH_WATCHDOG_MS` after a flush is issued; a no-op unless the
228+ /// send is somehow still in flight — same pattern as `PollWatchdog`.
229+ #[serde(skip)]
230+ #[facet(skip)]
231+ FlushWatchdog,
213232 #[serde(skip)]
214233 #[facet(skip)]
215234 ChallengeResult(u64, #[facet(opaque)] HttpResult),
@@ -862,6 +881,13 @@ impl App for Skrepka {
862881 }
863882 }
864883 Event::AddContact { input, nickname } => {
884+ // A nickname is stored in the contacts kv blob, which is
885+ // rewritten in full on every change. An unbounded nickname
886+ // bloats the blob and every subsequent write.
887+ if nickname.len() > protocol::MAX_DISPLAY_NAME_LEN {
888+ model.error = Some("nickname too long".into());
889+ return render();
890+ }
865891 match phonemic::try_parse_pubkey(&input) {
866892 Some(hex) if hex != model.my_pubkey => {
867893 model
@@ -933,6 +959,23 @@ impl App for Skrepka {
933959 bio,
934960 photo,
935961 } => {
962+ // The recipient's `parse_payload` enforces these caps and
963+ // silently drops oversized profiles. Without these checks the
964+ // sender's local profile would be saved, broadcast to every
965+ // contact, and silently dropped by every recipient — a silent
966+ // divergence between what the sender sees and what contacts see.
967+ if display_name.chars().count() > protocol::MAX_DISPLAY_NAME_LEN {
968+ model.error = Some("display name too long".into());
969+ return render();
970+ }
971+ if bio.chars().count() > protocol::MAX_BIO_LEN {
972+ model.error = Some("bio too long".into());
973+ return render();
974+ }
975+ if photo.as_ref().is_some_and(|p| p.len() > protocol::MAX_PHOTO_LEN) {
976+ model.error = Some("photo too large".into());
977+ return render();
978+ }
936979 model.profile = OwnProfile {
937980 display_name,
938981 bio,
@@ -1214,6 +1257,19 @@ impl App for Skrepka {
12141257
12151258 // ---------------- outbox ----------------
12161259 Event::StartFlush => self.flush_next(model),
1260+ Event::FlushWatchdog => {
1261+ // The send request never came back — the shell dropped the HTTP
1262+ // effect (backgrounded app, bug), and `flushing` has been stuck
1263+ // ever since. Release the guard and retry the flush. The late
1264+ // `SendResult`, if it ever arrives, finds `in_flight` already
1265+ // cleared and pops nothing.
1266+ if !model.flushing {
1267+ return Command::done();
1268+ }
1269+ model.flushing = false;
1270+ model.in_flight = None;
1271+ self.flush_next(model)
1272+ }
12171273 Event::SendResult(Ok(resp)) => {
12181274 // The in-flight send is over; `flush_next` refuses to run while
12191275 // this is set, so it must be cleared before *any* branch below.
@@ -1530,6 +1586,15 @@ impl Skrepka {
15301586 if body.is_empty() {
15311587 return render();
15321588 }
1589+ // The recipient's `parse_payload` enforces `MAX_BODY_LEN` and silently
1590+ // drops oversized text. Without this check the message would be stored
1591+ // locally, sent to the relay, and marked "sent" — but never arrive, never
1592+ // get acked, and the sender would see it stuck as "sent" forever with no
1593+ // error.
1594+ if body.len() > protocol::MAX_BODY_LEN {
1595+ model.error = Some("message too long".into());
1596+ return render();
1597+ }
15331598
15341599 // Check the contact cap *before* mutating anything. The old order
15351600 // cleared `compose` and inserted the message into `model.messages`
@@ -1733,7 +1798,12 @@ impl Skrepka {
17331798 .header("authorization", format!("Bearer {token}"))
17341799 .body_json(&SendBatch { messages })
17351800 {
1736− Ok(req) => persist.and(req.build().then_send(Event::SendResult)),
1801+ Ok(req) => persist.and(req.build().then_send(Event::SendResult))
1802+ .and(
1803+ Time::notify_after(Duration::from_millis(FLUSH_WATCHDOG_MS))
1804+ .0
1805+ .then_send(|_| Event::FlushWatchdog),
1806+ ),
17371807 Err(_) => {
17381808 // Not a dead loop: the next StartFlush (from a poll page or a
17391809 // fresh send) retries the head of the outbox.
@@ -4313,6 +4383,10 @@ mod tests {
43134383 // send_text block check
43144384 // -----------------------------------------------------------------------
43154385
4386+ // -----------------------------------------------------------------------
4387+ // send_text body length check
4388+ // -----------------------------------------------------------------------
4389+
43164390 /// Blocking cuts a peer off in both directions. `send_text` must refuse to
43174391 /// enqueue a message to a blocked contact — otherwise the block only stops
43184392 /// their incoming mail while we keep sending them ours.
@@ -4341,4 +4415,167 @@ mod tests {
43414415 // and resend, and losing their draft would be surprising.
43424416 assert_eq!(m.compose, "hello?");
43434417 }
4418+
4419+ /// A text body over `MAX_BODY_LEN` would be stored locally and sent to the
4420+ /// relay, but the recipient's `parse_payload` silently drops oversized text.
4421+ /// The sender would see it stuck as "sent" forever with no error.
4422+ #[test]
4423+ fn send_text_refuses_an_oversized_body() {
4424+ let app = Skrepka;
4425+ let mut m = with_identity();
4426+ let peer = peer_hex(9);
4427+ m.active_peer = Some(peer.clone());
4428+ m.compose = "x".repeat(protocol::MAX_BODY_LEN + 1);
4429+
4430+ let _ = app.update(Event::SendText, &mut m);
4431+
4432+ assert!(m.outbox.is_empty(), "nothing is queued");
4433+ assert!(m.messages.get(&peer).is_none_or(Vec::is_empty), "nothing stored");
4434+ assert!(!m.compose.is_empty(), "compose is preserved");
4435+ assert!(m.error.as_ref().is_some_and(|e| e.contains("too long")));
4436+ }
4437+
4438+ // -----------------------------------------------------------------------
4439+ // SaveProfile field length checks
4440+ // -----------------------------------------------------------------------
4441+
4442+ #[test]
4443+ fn save_profile_rejects_an_oversized_display_name() {
4444+ let app = Skrepka;
4445+ let mut m = with_identity();
4446+
4447+ let _ = app.update(Event::SaveProfile {
4448+ display_name: "x".repeat(protocol::MAX_DISPLAY_NAME_LEN + 1),
4449+ bio: String::new(),
4450+ photo: None,
4451+ }, &mut m);
4452+
4453+ assert!(m.outbox.is_empty(), "no broadcast queued");
4454+ assert!(m.profile.display_name.is_empty(), "local profile not updated");
4455+ assert!(m.error.as_ref().is_some_and(|e| e.contains("display name")));
4456+ }
4457+
4458+ #[test]
4459+ fn save_profile_rejects_an_oversized_bio() {
4460+ let app = Skrepka;
4461+ let mut m = with_identity();
4462+
4463+ let _ = app.update(Event::SaveProfile {
4464+ display_name: "Alice".into(),
4465+ bio: "x".repeat(protocol::MAX_BIO_LEN + 1),
4466+ photo: None,
4467+ }, &mut m);
4468+
4469+ assert!(m.outbox.is_empty(), "no broadcast queued");
4470+ assert!(m.profile.bio.is_empty(), "local profile not updated");
4471+ assert!(m.error.as_ref().is_some_and(|e| e.contains("bio")));
4472+ }
4473+
4474+ #[test]
4475+ fn save_profile_rejects_an_oversized_photo() {
4476+ let app = Skrepka;
4477+ let mut m = with_identity();
4478+
4479+ let _ = app.update(Event::SaveProfile {
4480+ display_name: "Alice".into(),
4481+ bio: String::new(),
4482+ photo: Some("x".repeat(protocol::MAX_PHOTO_LEN + 1)),
4483+ }, &mut m);
4484+
4485+ assert!(m.outbox.is_empty(), "no broadcast queued");
4486+ assert!(m.profile.photo.is_none(), "local profile not updated");
4487+ assert!(m.error.as_ref().is_some_and(|e| e.contains("photo")));
4488+ }
4489+
4490+ // -----------------------------------------------------------------------
4491+ // AddContact nickname length check
4492+ // -----------------------------------------------------------------------
4493+
4494+ #[test]
4495+ fn add_contact_rejects_an_oversized_nickname() {
4496+ let app = Skrepka;
4497+ let mut m = with_identity();
4498+ let peer = peer_hex(9);
4499+
4500+ let _ = app.update(Event::AddContact {
4501+ input: peer.clone(),
4502+ nickname: "x".repeat(protocol::MAX_DISPLAY_NAME_LEN + 1),
4503+ }, &mut m);
4504+
4505+ assert!(!m.contacts.contains_key(&peer), "no contact created");
4506+ assert!(m.error.as_ref().is_some_and(|e| e.contains("nickname")));
4507+ }
4508+
4509+ // -----------------------------------------------------------------------
4510+ // FlushWatchdog unsticks a wedged send loop
4511+ // -----------------------------------------------------------------------
4512+
4513+ /// `flushing` is only cleared by `SendResult`. If the shell drops the HTTP
4514+ /// effect, the outbox is permanently stuck. The watchdog releases the guard
4515+ /// so the flush retries.
4516+ #[test]
4517+ fn the_flush_watchdog_unwedges_a_send_that_never_resolved() {
4518+ let app = Skrepka;
4519+ let mut m = with_identity();
4520+ m.token = Some("t".into());
4521+ let peer = peer_hex(9);
4522+ m.outbox.push_back(queued(&peer, "stuck"));
4523+
4524+ let _ = app.update(Event::StartFlush, &mut m);
4525+ assert!(m.flushing, "the send is in flight");
4526+ assert!(m.in_flight.is_some());
4527+
4528+ // The send never comes back. The watchdog fires, clears the stuck guard,
4529+ // and retries the flush — the message goes out again.
4530+ let mut cmd = app.update(Event::FlushWatchdog, &mut m);
4531+
4532+ assert!(m.flushing, "a fresh send is in flight (the retry)");
4533+ assert!(m.in_flight.is_some(), "in_flight is set for the retry");
4534+ let batches = sent_batches(&mut cmd);
4535+ assert_eq!(batches.len(), 1, "the message is re-sent");
4536+ }
4537+
4538+ /// A watchdog that fires after the send already completed is a no-op.
4539+ #[test]
4540+ fn a_stale_flush_watchdog_leaves_a_completed_send_alone() {
4541+ let app = Skrepka;
4542+ let mut m = with_identity();
4543+ m.token = Some("t".into());
4544+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
4545+
4546+ let _ = app.update(Event::StartFlush, &mut m);
4547+ let _ = app.update(Event::SendResult(Ok(ok_response(200))), &mut m);
4548+ assert!(!m.flushing);
4549+ assert!(m.outbox.is_empty());
4550+
4551+ // The watchdog for the completed send fires late.
4552+ let _ = app.update(Event::FlushWatchdog, &mut m);
4553+ assert!(!m.flushing, "a completed send is not re-armed");
4554+ assert!(m.outbox.is_empty());
4555+ }
4556+
4557+ /// A late `SendResult` after the watchdog has already retried sees the
4558+ /// retry's `in_flight` and pops its items. This is correct: the retry sent
4559+ /// the same items, and a 200 means they were accepted — whether by the
4560+ /// original request or the retry is irrelevant. The items are delivered.
4561+ #[test]
4562+ fn a_late_send_result_after_the_watchdog_pops_the_retrys_items() {
4563+ let app = Skrepka;
4564+ let mut m = with_identity();
4565+ m.token = Some("t".into());
4566+ let peer = peer_hex(9);
4567+ m.outbox.push_back(queued(&peer, "stuck"));
4568+
4569+ let _ = app.update(Event::StartFlush, &mut m);
4570+ // The watchdog fires and retries the flush.
4571+ let _ = app.update(Event::FlushWatchdog, &mut m);
4572+ assert_eq!(m.outbox.len(), 1, "the item is still queued (re-sent)");
4573+
4574+ // Now the original send's result arrives late. The retry's in_flight is
4575+ // set, so a 200 pops the item — which is correct, the message was
4576+ // accepted by one of the two sends.
4577+ let _ = app.update(Event::SendResult(Ok(ok_response(200))), &mut m);
4578+ assert!(m.outbox.is_empty(), "the late 200 pops the retry's items — the message was accepted");
4579+ assert!(!m.flushing, "and the guard is released");
4580+ }
43444581}