diff --git a/desktop/src-tauri/src/bench/item.rs b/desktop/src-tauri/src/bench/item.rs index 877dcd38d5c..771c50a2b53 100644 --- a/desktop/src-tauri/src/bench/item.rs +++ b/desktop/src-tauri/src/bench/item.rs @@ -318,7 +318,7 @@ pub(crate) struct Item { /// next one posts without the p tag. A new Drop starts false. #[serde(default)] pub quiet_repost: bool, - /// Its card left the relay (retired for a refresh, deleted by hand) and + /// Its card is gone (retired for a refresh, deleted by hand) and /// has not posted again: it keeps its `MAX_CARDS` place till it does. #[serde(default)] pub repost: bool, @@ -1013,8 +1013,8 @@ pub(crate) fn render_card(item: &Item, hhmm: &str) -> String { out } -/// A stale card kept for what is on it (publish (b)), from its text as -/// posted: closed, it no longer says how to answer, and says where to now. +/// An old post (`replaced`), from what it shows: closed, it no longer says how +/// to answer, and says where to now. pub(crate) fn render_closed(posted: &str) -> String { let open = [ format!(" {TAP}"), diff --git a/desktop/src-tauri/src/bench/mod.rs b/desktop/src-tauri/src/bench/mod.rs index 9ac64faffba..322e580993d 100644 --- a/desktop/src-tauri/src/bench/mod.rs +++ b/desktop/src-tauri/src/bench/mod.rs @@ -142,11 +142,11 @@ pub(crate) struct BenchState { /// Each bound channel's join or admin check: cards go only where it passed. channels: BTreeMap, /// Engaged or owner-told cards a new question left behind, and stale ones - /// closed for what is on them: on the relay for good, never adopted again - /// by recover (which would retire them as stale). + /// retired: on the relay for good, never adopted again by recover (which + /// would retire them as stale). 64 bytes each, a few a day at 4 Nov 2026. kept: Vec, - /// Each kept card posted in a thread, as (its root, its created_at): it - /// still ends the root-reply window of an older card there. + /// Each kept card in a thread with anything on it, as (its root, its + /// created_at): it still ends the root-reply window of an older card there. kept_at: Vec<(String, u64)>, /// Old posts owed an edit or a reply or watched for a tap; since when their read fails. replaced: Vec, diff --git a/desktop/src-tauri/src/bench/publish.rs b/desktop/src-tauri/src/bench/publish.rs index 32f319e625b..97bf9411b0f 100644 --- a/desktop/src-tauri/src/bench/publish.rs +++ b/desktop/src-tauri/src/bench/publish.rs @@ -8,9 +8,8 @@ use std::sync::{Mutex, PoisonError}; use super::channels::{card_channel, channel_ready, joined, reads_replies}; use super::{ - item, live_card, local_hhmm, next_ts, outbox, post_as_writer, query, query_writer, same_drop, - save_both, spend, submit, BenchState, Ctx, Outcome, Posted, BOARD_MAX_AGE_SECS, MAX_CARDS, - RECOVER_LIMIT, + item, live_card, local_hhmm, next_ts, outbox, post_as_writer, query, same_drop, save_both, + spend, submit, BenchState, Ctx, Outcome, Posted, BOARD_MAX_AGE_SECS, MAX_CARDS, RECOVER_LIMIT, }; use crate::events; @@ -337,55 +336,29 @@ fn df_answer(parts: [&str; 3]) -> Result { nostr::Tag::parse(["df-answer", n, by, id]).map_err(|e| e.to_string()) } -/// Whether a recovered card has anything on it but the writer's own seeds: -/// a tap, a reply, a route notice, or a Recorded or receipt edit. A cut page -/// holds more than the four seeds, so it counts too. -async fn touched( +/// What a post has on it but the writer's own seeds: a tap, a reply, a route +/// notice, or a Recorded, receipt or closing edit. A cut page holds more than +/// the four seeds, so it counts too. +pub(super) async fn on_post( ctx: &Ctx<'_>, s: &BenchState, ch: &str, card: &str, now: u64, -) -> Result { +) -> Result, String> { let filter = serde_json::json!({ "kinds": [7, 9, 40003], "#h": [ch], "#e": [card], "limit": RECOVER_LIMIT, }); let writer = ctx.writer.public_key(); - Ok(query(ctx, s, filter, now).await?.iter().any(|e| { + let mut on = query(ctx, s, filter, now).await?; + on.retain(|e| { let on_card = e .tags .iter() .any(|t| matches!(t.as_slice(), [k, v, ..] if k == "e" && v == card)); on_card && !(e.kind.as_u16() == 7 && e.pubkey == writer) - })) -} - -/// A stale card kept for what is on it (`touched`), for good: edited closed -/// from its own text as posted (`replaced::old_post`), its thread root (its -/// parent) ending older cards' root-reply windows there, and watched for a tap -/// (`replaced`). One gone from the relay is only kept. -async fn close( - ctx: &Ctx<'_>, - s: &mut BenchState, - id: &str, - ch: &str, - card: &Posted, - now: u64, -) -> Result<(), String> { - let filter = serde_json::json!({ "ids": [card.event_id], "kinds": [9], "#h": [ch] }); - if let Some(e) = query_writer(ctx, s, filter, now).await?.first() { - let text = super::replaced::old_post(s, id, &e.content); - let text = text + &item::thread_line(ch, &card.event_id); - match edit(ctx, s, ch, &card.event_id, &text, now).await { - Err(err) if !err.contains("not found") => return Err(err), - _ => {} - } - let root = item::parent(e).map(|r| (r.to_string(), card.created_at)); - s.kept_at.extend(root); - super::replaced::closed(s, id, ch, card, item::parent(e), now); - } - s.kept.push(card.event_id.clone()); - Ok(()) + }); + Ok(on) } /// A thread the relay will not take: its root is gone, in another channel or @@ -397,9 +370,10 @@ fn bad_thread(e: &str) -> bool { } /// Cards, seeds, then the board, at most `budget` posts. A card is posted -/// once per Drop and card format; it is edited to record an answer and, once -/// closed out, to become a receipt. Only a card nobody engaged with is ever -/// deleted. Every post made once a card's own id is known links its thread. +/// once per Drop and card format; it is edited to record an answer, once closed +/// out to become a receipt and once stale to become an old post (`replaced`), so +/// only a recovered orphan nobody touched is ever deleted. Every post made once +/// a card's own id is known links its thread. /// An owner's notice that fails to go again lands in `failed`, for /// lastError, and the rest goes on. #[allow(clippy::too_many_arguments)] @@ -704,12 +678,13 @@ pub(super) async fn publish( s.items.get_mut(&id).expect("routed id").routed_reply = Some(r.event_id.clone()); } - // (b) Delete cards that are stale (another Drop, card format or stamp) or + // (b) Retire cards that are stale (another Drop, card format or stamp) or // no longer asked (superseded, expired, no longer pending), never for // MAX_CARDS, unless someone answered or replied on them or they are a - // receipt: those stay on the relay for good, and so, closed, does one - // with anything else on it but its seeds. A card in a channel that is - // not ready, or `held`, waits: its taps are read once it is again. + // receipt: those keep their place. A retired card stays on the relay for + // good, kept from recover and owed its edit closed (`replaced`), so no + // failure here fails the tick. A card in a channel that is not ready, or + // `held`, waits: its taps are read once it is again. let stale: Vec<(String, String, Posted)> = s .items .values() @@ -722,36 +697,27 @@ pub(super) async fn publish( }) .collect(); for (id, ch, card) in stale { - // One post per card, its lookups riding on it: the delete, or the edit - // that closes it. - if !spend(budget, 1) { - return Ok(()); - } - // A reply or tap the item never read or no longer holds (free text on - // an unscoped card with no route, or one asked again) still makes the - // card a record: it stays, as `carry_card` keeps one, but closed, - // since nothing reads it any more. - if touched(ctx, s, &ch, &card.event_id, now).await? { - close(ctx, s, &id, &ch, &card, now).await?; - } else { - delete(ctx, s, &ch, &card.event_id, now).await?; - } + super::replaced::retired(s, &id, &ch, &card, now); + s.kept.push(card.event_id); forget_card(ctx, s, &id); } - // (c) Post missing cards, adopting a recovered orphan when its hash matches. + // (c) Post missing cards, adopting a recovered orphan when its hash matches + // and it was not edited closed: that post says a tap there doesn't count. for id in &wanted { if s.items[id].card.is_some() { continue; } let ch = card_channel(s, &s.items[id]); if let Some(orphan) = s.orphan_cards.get(id).cloned() { - if orphan.hash == item::card_hash(&s.items[id]) { + let on = on_post(ctx, s, &ch, &orphan.event_id, now).await?; + let closed = on.iter().any(super::replaced::closes); + if orphan.hash == item::card_hash(&s.items[id]) && !closed { s.items.get_mut(id).expect("wanted id").card = s.orphan_cards.remove(id); continue; } // An answered, replied or receipt card stays on the relay for good. - if touched(ctx, s, &ch, &orphan.event_id, now).await? { + if !on.is_empty() { s.kept.push(orphan.event_id.clone()); } else { if !spend(budget, 1) { diff --git a/desktop/src-tauri/src/bench/replaced.rs b/desktop/src-tauri/src/bench/replaced.rs index d1ced82d268..6bce3bf4fc2 100644 --- a/desktop/src-tauri/src/bench/replaced.rs +++ b/desktop/src-tauri/src/bench/replaced.rs @@ -7,6 +7,10 @@ //! card that was shown, so it never answers, never reaches the outbox and //! never moves to the new card. //! +//! None is deleted. A phone that lost its socket across the delete keeps +//! showing the post, the relay refuses a tap on it and the phone's code ignores +//! the refusal, so that tap would vanish with nothing said. +//! //! Nothing here fails a tick: lastError stops the steward kit's approvals, and //! the live cards are fine. What keeps failing is on the board (`alarm`). @@ -15,7 +19,7 @@ use std::collections::BTreeSet; use serde::{Deserialize, Serialize}; use super::channels::{card_channel, ready}; -use super::publish::{edit, post_message, thread_of}; +use super::publish::{edit, on_post, post_message, thread_of}; use super::{ item, post_as_writer, query, query_writer, spend, BenchState, Ctx, Posted, RECOVER_LIMIT, }; @@ -30,10 +34,11 @@ const NOT_ASKED: &str = "No longer asked. A tap here doesn't count."; const WATCH_SECS: u64 = 86_400; /// Old posts read for taps at once, newest first. One batched /query per tick /// while any is watched, however many: +30/min of the relay's 300/min (about -/// 5/s), beside `poll_answers`' up to 120. Sized for ten times the churn at 1 -/// Nov 2026 (28 drops in a day, 7 cards kept in all, most re-drops deleted as -/// untouched). A cut page keeps its newest events, the ones looked for. Past -/// this the oldest go unread and the board says so (`alarm`). +/// 5/s), beside `poll_answers`' up to 120. Sized for three times the busiest day +/// at 1 Nov 2026, 28 drops if each left an old post; ten times that outgrows it. +/// A cut page keeps its newest events, the ones looked for. Past this the oldest +/// go unread and the board says so (`alarm`); raising it only lengthens the one +/// query's `#e`. pub(super) const WATCH_MAX: usize = 100; /// An old post that would not update is tried again this soon. const RETRY_SECS: u64 = 60; @@ -60,6 +65,11 @@ pub(crate) struct Replaced { pub at: u64, /// The item as the post shows it, until the post is edited. pub shown: Option>, + /// Owed its edit, before its seeds, from its own text on the relay: its + /// item already holds the newer Drop (`retired`). With a `root`, edited: + /// what is on it is still to read, for `kept_at`. + #[serde(default)] + pub from_post: bool, pub seeds_off: bool, /// Read for a tap: the newest old post of its item, not told yet. pub watch: bool, @@ -83,6 +93,7 @@ impl Replaced { root: root.map(str::to_string), at: now, shown: None, + from_post: false, seeds_off: false, watch: true, seen: Vec::new(), @@ -107,8 +118,8 @@ fn add(s: &mut BenchState, r: Replaced) { } /// A new Drop left the card of the item it replaces behind (`carry_card`): it -/// has no card, the item still in state does. publish edits it from what it -/// shows, its Recorded line or receipt included. +/// has no card, the item still in state does. Edited from what it shows, its +/// Recorded line or receipt included. pub(super) fn left_behind(s: &mut BenchState, new: &item::Item, now: u64) { let Some(old) = s.items.get(&new.id).filter(|_| new.card.is_none()) else { return; @@ -124,16 +135,20 @@ pub(super) fn left_behind(s: &mut BenchState, new: &item::Item, now: u64) { add(s, r); } -/// A stale card publish (b) just edited closed (`old_post`). -pub(super) fn closed( - s: &mut BenchState, - id: &str, - ch: &str, - card: &Posted, - root: Option<&str>, - now: u64, -) { - add(s, Replaced::new(id, ch, card, root, now)); +/// A stale card publish (b) just retired: its item holds the newer Drop, so it +/// is edited from its own text as posted. +pub(super) fn retired(s: &mut BenchState, id: &str, ch: &str, card: &Posted, now: u64) { + let r = Replaced { + from_post: true, + ..Replaced::new(id, ch, card, None, now) + }; + add(s, r); +} + +/// Whether `e` is an edit that made a post an old post. +pub(super) fn closes(e: &nostr::Event) -> bool { + let first = e.content.lines().next().unwrap_or_default(); + e.kind.as_u16() == 40003 && (first == REPLACED || first == NOT_ASKED) } fn asked(i: &item::Item) -> bool { @@ -273,7 +288,7 @@ async fn read(ctx: &Ctx<'_>, s: &mut BenchState, now: u64, owner_set: &[String]) /// never comes back from /query, so this resumes), its one reply. Ok(false): /// the tick's budget or lookups ran out first. The relay is asked before the /// reply goes, so a crash after it landed and before state saved never sends -/// it twice. +/// it twice. A post no longer there is an Err saying "not found" (`tick`). async fn finish( ctx: &Ctx<'_>, s: &mut BenchState, @@ -285,6 +300,32 @@ async fn finish( ) -> Result { let r = s.replaced[i].clone(); let (ch, card) = (&r.channel, &r.card.event_id); + if r.from_post { + if *budget == 0 || !spend(lookups, 1) { + return Ok(false); + } + // Edited, in a thread: with anything else on it, it ends older cards' + // root-reply windows there, as a kept card does. With nothing, a reply + // naming only the root is still theirs. + if let Some(root) = &r.root { + let on = on_post(ctx, s, ch, card, now).await?; + if on.iter().any(|e| !closes(e)) { + s.kept_at.push((root.clone(), r.card.created_at)); + } + s.replaced[i].from_post = false; + return Ok(true); + } + let filter = serde_json::json!({ "ids": [card], "kinds": [9], "#h": [ch] }); + let posted = query_writer(ctx, s, filter, now).await?; + let e = posted.first().ok_or("not found: no such post")?; + let text = old_post(s, &r.item, &e.content) + &item::thread_line(ch, card); + spend(budget, 1); + edit(ctx, s, ch, card, &text, now).await?; + let root = item::parent(e).map(str::to_string); + let r = &mut s.replaced[i]; + (r.from_post, r.root) = (root.is_some(), root); + return Ok(true); + } if let Some(shown) = &r.shown { if !spend(budget, 1) { return Ok(false); @@ -366,6 +407,10 @@ pub(super) async fn tick( hhmm: &str, owner_set: &[String], ) { + // Past 30 failing the loop runs out of lookups every tick and never reaches + // the retain below, so one that has failed goes at its window here. + s.replaced + .retain(|r| r.retry_at == 0 || now < r.at + WATCH_SECS); read(ctx, s, now, owner_set).await; let mut lookups = LOOKUPS_PER_TICK; for i in 0..s.replaced.len() { @@ -374,6 +419,7 @@ pub(super) async fn tick( continue; } match finish(ctx, s, i, budget, &mut lookups, now, hhmm).await { + // Not `break`: the retain would drop what is past its window untried. Ok(false) => return, Ok(true) => s.replaced[i].retry_at = 0, // Gone from the relay: nothing is left to edit or to reply under. diff --git a/desktop/src-tauri/src/bench/tests.rs b/desktop/src-tauri/src/bench/tests.rs index 71c3e4d2b21..3f11c36c697 100644 --- a/desktop/src-tauri/src/bench/tests.rs +++ b/desktop/src-tauri/src/bench/tests.rs @@ -776,6 +776,8 @@ struct Relay { clock: Mutex>, /// A /query naming this id in its `#e` fails with a 500. e_down: Mutex>, + /// A write on this id, or a /query for it by `ids`, fails with a 500. + post_down: Mutex>, } fn tag_value(ev: &Value, name: &str) -> Option { @@ -823,6 +825,9 @@ async fn fake_events( return (StatusCode::OK, Json(reply)); } let target = tag_value(&ev, "e").unwrap_or_default(); + if r.post_down.lock().unwrap().as_ref() == Some(&target) { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({}))); + } if matches!(kind, 5 | 9 | 40003) && r.gone.lock().unwrap().contains(&target) { let message = match kind { 5 => "target event not found", @@ -865,6 +870,10 @@ async fn fake_query( if down.is_some_and(|id| q[0]["#e"].as_array().is_some_and(|e| e.contains(&id))) { return Err(StatusCode::INTERNAL_SERVER_ERROR); } + let down = r.post_down.lock().unwrap().clone().map(|id| json!(id)); + if down.is_some_and(|id| q[0]["ids"].as_array().is_some_and(|e| e.contains(&id))) { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } let mut evs = r.query_reply.lock().unwrap().clone(); if let Some(authors) = q[0]["authors"].as_array() { evs.retain(|e| authors.contains(&e["pubkey"])); @@ -938,6 +947,7 @@ impl Bench { admins_down: Mutex::new(false), clock: Mutex::new(None), e_down: Mutex::new(None), + post_down: Mutex::new(None), }); let base = fake_relay(relay.clone()).await; let state: &'static AppState = Box::leak(Box::new(crate::app_state::build_app_state())); @@ -1108,9 +1118,11 @@ async fn resync_is_idempotent() { } #[tokio::test] -async fn pending_reword_deletes_and_reposts_never_edits() { +async fn pending_reword_reposts_and_edits_the_old_card_closed() { let mut b = seeded_bench(Gate::Open, &[]).await; let old_card = b.card("orchestrator/relay-choice"); + let old_post = answers::posted(&b, &old_card.event_id); + answers::reply(&b, &[&old_post]); let old_board = b.s.board.clone().unwrap(); let before = b.posts().len(); b.drop_file( @@ -1124,16 +1136,15 @@ async fn pending_reword_deletes_and_reposts_never_edits() { .iter() .map(|(e, _)| e["kind"].as_u64().unwrap()) .collect(); - assert_eq!(kinds, [5, 9, 7, 7, 9, 5]); - assert_eq!(tag_value(&posts[0].0, "e").unwrap(), old_card.event_id); + assert_eq!(kinds, [9, 7, 7, 40003, 9, 5]); let new_card = b.card("orchestrator/relay-choice"); assert_ne!(new_card.hash, old_card.hash); assert_eq!( - tag_value(&posts[1].0, "client").unwrap(), + tag_value(&posts[0].0, "client").unwrap(), format!("bench:card:orchestrator/relay-choice@{}", new_card.hash) ); + assert_eq!(tag_value(&posts[3].0, "e").unwrap(), old_card.event_id); assert_eq!(tag_value(&posts[5].0, "e").unwrap(), old_board.event_id); - assert!(!b.kinds().contains(&40003)); } #[tokio::test] @@ -1331,7 +1342,7 @@ async fn vanished_targets_are_dropped_not_retried() { b.settle(2).await; assert_eq!(b.kinds()[before..], [40003, 9]); assert_ne!(b.s.board.as_ref().unwrap().event_id, old_board.event_id); - // A hard-purged card: the retiring kind 5 is refused, the repost goes on. + // A hard-purged card: nothing is left to edit, the repost goes on. let old_card = b.card("orchestrator/relay-choice"); b.relay .gone @@ -1345,7 +1356,7 @@ async fn vanished_targets_are_dropped_not_retried() { &drop_json(&[("options", json!(["Stay", "Go"]))]), ); b.settle(3).await; - assert_eq!(b.kinds()[before..], [5, 9, 7, 7, 9, 5]); + assert_eq!(b.kinds()[before..], [9, 7, 7, 9, 5]); assert_ne!( b.card("orchestrator/relay-choice").event_id, old_card.event_id @@ -1382,8 +1393,8 @@ async fn a_tick_never_exceeds_the_post_budget() { b.tick().await.unwrap(); assert!(b.posts().len() - n <= MAX_POSTS_PER_TICK); } - // Delete, card, seed; then the board and its predecessor's delete as a pair. - assert_eq!(b.kinds()[before..], [5, 9, 7, 9, 5]); + // Card, seed; then the board and its predecessor's delete as a pair. + assert_eq!(b.kinds()[before..], [9, 7, 9, 5]); } #[tokio::test] diff --git a/desktop/src-tauri/src/bench/tests/answers.rs b/desktop/src-tauri/src/bench/tests/answers.rs index 261a1811628..79089a9cfd6 100644 --- a/desktop/src-tauri/src/bench/tests/answers.rs +++ b/desktop/src-tauri/src/bench/tests/answers.rs @@ -457,16 +457,15 @@ async fn pending_reword_closes_a_card_someone_replied_or_reacted_on() { &drop_json(&[("options", json!(["Stay", "Move away"]))]), ); b.settle(3).await; - // No delete: the old card is closed, then a new one, its seeds, the - // board. + // No delete: a new card and its seeds, the old one closed, the board. let posts = b.posts()[before..].to_vec(); assert_eq!( b.kinds()[before..], - [40003, 9, 7, 7, 9, 5], + [9, 7, 7, 40003, 9, 5], "heart: {heart}" ); - assert_eq!(tag_value(&posts[0].0, "e").unwrap(), old_card.event_id); - let closed = text(&posts[0].0); + assert_eq!(tag_value(&posts[3].0, "e").unwrap(), old_card.event_id); + let closed = text(&posts[3].0); assert!(closed.contains("**Relay choice**\n\nStay or move?\n\n1. Stay\n2. Move\n\nas of")); assert!(!closed.contains("Tap one number"), "{closed}"); assert!(closed.contains("\n\nClosed: a tap or reply here is no longer read.")); @@ -479,30 +478,52 @@ async fn pending_reword_closes_a_card_someone_replied_or_reacted_on() { } } +/// The worst case at once: every card asked again in one tick. New cards go +/// first, each tick stays inside the post budget and one lookup, each old post +/// is edited once and none deleted. #[tokio::test] -async fn stale_cards_are_looked_up_within_the_post_budget() { +async fn cards_asked_again_at_once_stay_inside_the_budgets() { let mut b = seeded_bench(Gate::Open, &[]).await; let ids = ["a", "b", "c", "d", "relay-choice"]; for id in &ids[..4] { b.drop_file("orchestrator", id, &drop_json(&[("title", json!(id))])); } b.settle(10).await; - assert!(ids + let cards: Vec = ids .iter() - .all(|id| b.s.items[&format!("orchestrator/{id}")].card.is_some())); + .map(|id| b.card(&format!("orchestrator/{id}")).event_id) + .collect(); + let old: Vec = cards.iter().map(|c| posted(&b, c)).collect(); + reply(&b, &old.iter().collect::>()); // All five asked again at once, against four posts a tick. for id in ids { let title = json!(format!("{id}, reworded")); b.drop_file("orchestrator", id, &drop_json(&[("title", title)])); } - let (posts, queries) = (b.posts().len(), b.relay.queries.lock().unwrap().len()); - b.tick().await.unwrap(); - assert_eq!(b.kinds()[posts..], [5, 5, 5, 5]); - let lookups = b.relay.queries.lock().unwrap()[queries..] - .iter() - .filter(|q| q[0]["kinds"] == json!([7, 9, 40003])) - .count(); - assert_eq!(lookups, 4); + let start = b.posts().len(); + let writer = json!([b.writer_hex()]); + for tick in 0..24 { + let (posts, queries) = (b.posts().len(), b.relay.queries.lock().unwrap().len()); + b.tick().await.unwrap(); + assert!(b.posts().len() - posts <= MAX_POSTS_PER_TICK); + let q = b.relay.queries.lock().unwrap()[queries..].to_vec(); + let lookup = |q: &&Value| q[0]["authors"] == writer || q[0]["ids"].is_array(); + assert!(q.iter().filter(lookup).count() <= 1); + if tick == 0 { + assert_eq!(b.kinds()[posts..], [9, 9, 9, 9]); + } + } + let posts = b.posts()[start..].to_vec(); + for card in &cards { + let on = |e: &&(Value, Option)| tag_value(&e.0, "e").as_ref() == Some(card); + let kinds: Vec = posts + .iter() + .filter(on) + .map(|(e, _)| e["kind"].as_u64().unwrap()) + .collect(); + assert_eq!(kinds, [40003], "{card}"); + } + assert_eq!(b.s.kept, cards); } #[tokio::test] @@ -740,19 +761,20 @@ async fn older_card_format_case(older: &str) { let answer = b.s.items[ITEM].answer.clone(); assert!(answer.is_some()); // The tapped card takes the new text by its Recorded edit; only the - // unanswered one is refreshed: delete, quiet repost, seeds, board pair. + // unanswered one is refreshed: quiet repost, seeds, board pair. No post of + // the old one is on this fake relay to edit. let posts = b.posts()[before..].to_vec(); - assert_eq!(b.kinds()[before..], [40003, 5, 9, 7, 7, 9, 5]); + assert_eq!(b.kinds()[before..], [40003, 9, 7, 7, 9, 5]); assert_eq!(tag_value(&posts[0].0, "e").unwrap(), kept); - assert_eq!(tag_value(&posts[1].0, "e").unwrap(), old); + assert_eq!(b.s.kept, [old]); let new_card = b.card(OTHER); assert_eq!( - tag_value(&posts[2].0, "client").unwrap(), + tag_value(&posts[1].0, "client").unwrap(), format!("bench:card:{OTHER}@{}", card_hash(&b.s.items[OTHER])) ); assert!(text(&posts[0].0).contains("\n**Relay choice**\n")); - assert!(text(&posts[2].0).contains("\n**Other**\n") && text(&posts[2].0).contains(HINT)); - assert_eq!(tag_value(&posts[2].0, "p"), None); + assert!(text(&posts[1].0).contains("\n**Other**\n") && text(&posts[1].0).contains(HINT)); + assert_eq!(tag_value(&posts[1].0, "p"), None); assert!(!b.s.items[OTHER].quiet_repost); // The answered card, its answer and its outbox file stay. assert_eq!(b.card(ITEM).event_id, kept); diff --git a/desktop/src-tauri/src/bench/tests/capacity.rs b/desktop/src-tauri/src/bench/tests/capacity.rs index 7d842454e08..64de11aa4e0 100644 --- a/desktop/src-tauri/src/bench/tests/capacity.rs +++ b/desktop/src-tauri/src/bench/tests/capacity.rs @@ -202,8 +202,8 @@ async fn a_card_retired_for_a_refresh_keeps_its_place() { b.drop_file("orchestrator", "a-first", &attention); b.settle(2).await; assert_eq!(queued(&b), ["a-first", "hold-36", "hold-37", "hold-38"]); - // Four cards change format at once: (b) spends the tick retiring them, - // and they come back before the newcomer, which still waits. + // Four cards change format at once: (b) retires them, and they come back + // before the newcomer, which still waits. let refreshed = ["hold-32", "hold-33", "hold-34", "hold-35"]; for id in refreshed { let i = b.s.items.get_mut(&format!("orchestrator/{id}")).unwrap(); @@ -211,7 +211,7 @@ async fn a_card_retired_for_a_refresh_keeps_its_place() { i.card.as_mut().unwrap().hash = format!("{hash}.old-format"); } b.tick().await.unwrap(); - assert!(b.s.items["orchestrator/hold-32"].card.is_none()); + assert_eq!(b.s.kept.len(), refreshed.len()); b.settle(4).await; for id in refreshed { let i = &b.s.items[&format!("orchestrator/{id}")]; diff --git a/desktop/src-tauri/src/bench/tests/channels.rs b/desktop/src-tauri/src/bench/tests/channels.rs index ca6d81ab1c1..ef280cafd16 100644 --- a/desktop/src-tauri/src/bench/tests/channels.rs +++ b/desktop/src-tauri/src/bench/tests/channels.rs @@ -5,6 +5,7 @@ use super::super::roles::AgentAccess; use super::answers::{posted, reply, tap, ITEM}; +use super::replaced::last_edit; use super::*; pub(super) const X: &str = "105f15cc-1d5a-431a-9826-9402fba1a12b"; @@ -628,15 +629,50 @@ async fn closed_card_keeps_its_own_thread_root() { b.drop_file("reset-worker", "welcome", &ask(&[("replyTo", json!(r2))])); b.settle(3).await; assert_eq!(b.s.kept, [old.event_id.as_str()]); - assert_eq!(b.s.kept_at, [(r1, old.created_at)]); - let closed = &b.posts()[before].0; - assert_eq!(closed["kind"], 40003); - assert_eq!(tag_value(closed, "e").unwrap(), old.event_id); + assert_eq!(b.s.kept_at, [(r1.clone(), old.created_at)]); + assert_eq!(b.s.replaced[0].root, Some(r1), "where its one reply goes"); + let on_old = |e: &Value| e["kind"] == 40003 && tag_value(e, "e") == Some(old.event_id.clone()); + assert!(b.posts()[before..].iter().any(|(e, _)| on_old(e))); let new = b.card(ASK); assert_ne!(new.event_id, old.event_id); assert_eq!(parent(&posted(&b, &new.event_id)), Some(r2.as_str())); } +/// A newer card nobody touched, withdrawn: with only its seeds and closing edit +/// on it, a reply naming only the root still reaches the one open card there. +#[tokio::test] +async fn a_retired_card_nobody_touched_ends_no_older_cards_window() { + let mut b = seeded_bench(Gate::Open, &[]).await; + bind(&mut b); + let list = admins(&b, &b.relay.keys.clone(), &[]); + reply(&b, &[&list]); + let root = "ab".repeat(32); + b.drop_file("reset-worker", "welcome", &ask(&[("replyTo", json!(root))])); + b.settle(6).await; + let second = |validity: &str| { + ask(&[ + ("replyTo", json!(root)), + ("title", json!("Second")), + ("validity", json!(validity)), + ]) + }; + b.drop_file("reset-worker", "second", &second("current")); + b.settle(6).await; + let newer = b.card("reset-worker/second").event_id; + let post = posted(&b, &newer); + reply(&b, &[&list, &post]); + b.drop_file("reset-worker", "second", &second("superseded")); + b.tick().await.unwrap(); + let closing = serde_json::from_value(last_edit(&b, &newer)).unwrap(); + reply(&b, &[&list, &post, &closing]); + b.settle(3).await; + assert!(b.s.kept_at.is_empty(), "{:?}", b.s.kept_at); + let said = thread_reply(&b.ctx.owner, &root, "1", b.s.last_created_at + 5, &[]); + reply(&b, &[&list, &said]); + b.settle(3).await; + assert_eq!(b.s.items[ASK].answer.as_ref().map(|a| a.option), Some(0)); +} + #[tokio::test] async fn unreadable_roles_hold_scoped_drops_and_every_card() { let (mut b, _p, _list) = bound_bench().await; diff --git a/desktop/src-tauri/src/bench/tests/one_tap.rs b/desktop/src-tauri/src/bench/tests/one_tap.rs index f4471580b5a..6077d9fcc37 100644 --- a/desktop/src-tauri/src/bench/tests/one_tap.rs +++ b/desktop/src-tauri/src/bench/tests/one_tap.rs @@ -501,9 +501,9 @@ async fn an_unanswered_card_asked_again_is_replaced_as_today() { b.drop_file("orchestrator", "relay-choice", &drop); }; let replaced = |b: &Bench, before: usize, old: &str| { - assert_eq!(b.kinds()[before..], [5, 9, 7, 7, 9, 5]); - assert_eq!(tag_value(&b.posts()[before].0, "e").as_deref(), Some(old)); - content(&b.posts()[before + 1].0).to_string() + assert_eq!(b.kinds()[before..], [9, 7, 7, 9, 5]); + assert!(b.s.kept.iter().any(|k| k == old)); + content(&b.posts()[before].0).to_string() }; let old = b.card(ITEM).event_id; let before = b.posts().len(); diff --git a/desktop/src-tauri/src/bench/tests/owner_word.rs b/desktop/src-tauri/src/bench/tests/owner_word.rs index dd62e9b714d..e31aaf8b717 100644 --- a/desktop/src-tauri/src/bench/tests/owner_word.rs +++ b/desktop/src-tauri/src/bench/tests/owner_word.rs @@ -199,6 +199,40 @@ async fn a_cut_owner_page_retires_nothing() { assert!(!deleted(&b)); } +/// `held` is all that keeps a card from retiring when only its card format +/// moved: nothing asked again waits in the inbox, and the tap is still unread. +#[tokio::test] +async fn a_cut_owner_page_leaves_a_stale_format_card_up() { + let mut b = seeded_bench(Gate::Open, &[]).await; + bind(&mut b); + let list = admins(&b, &b.relay.keys.clone(), &[]); + reply(&b, &[&list]); + let root = "ab".repeat(32); + b.drop_file("reset-worker", "welcome", &ask(&[("replyTo", json!(root))])); + b.settle(6).await; + let i = b.s.items.get_mut(ASK).unwrap(); + i.card.as_mut().unwrap().hash = format!("{}.3", i.hash); + let card = b.card(ASK); + let two = tap(&b.ctx.owner, &card.event_id, OPTION_EMOJI[1], b.now); + let chat = owners_chat(&b, &root, card.created_at); + let mut evs = vec![&list, &two]; + evs.extend(&chat); + reply(&b, &evs); + let before = b.posts().len(); + for _ in 0..3 { + let _ = b.tick().await; + } + assert_eq!(b.s.held, [ASK]); + assert_eq!(b.card(ASK), card); + assert!(b.s.replaced.is_empty()); + let edited = |e: &Value| e["kind"] == 40003 && tag_value(e, "e") == Some(card.event_id.clone()); + assert!(!b.posts()[before..].iter().any(|(e, _)| edited(e))); + // Read in full, the tap counts on the card it was made on. + reply(&b, &[&list, &two]); + b.tick().await.unwrap(); + assert_eq!(b.s.items[ASK].answer.as_ref().map(|a| a.option), Some(1)); +} + #[tokio::test] async fn a_re_asked_card_still_ends_an_older_cards_window() { let mut b = seeded_bench(Gate::Open, &[]).await; diff --git a/desktop/src-tauri/src/bench/tests/replaced.rs b/desktop/src-tauri/src/bench/tests/replaced.rs index f0540114bf9..d1cdffff404 100644 --- a/desktop/src-tauri/src/bench/tests/replaced.rs +++ b/desktop/src-tauri/src/bench/tests/replaced.rs @@ -7,9 +7,10 @@ use super::channels::X; use super::receipts::close_out; use super::*; use crate::bench::channels::ChannelCheck; -use crate::bench::replaced::{closed, tick as old_posts_tick, WATCH_MAX}; +use crate::bench::replaced::{retired, tick as old_posts_tick, Replaced, WATCH_MAX}; const REPLACED: &str = "Replaced by a newer card. A tap here doesn't count."; +const NOT_ASKED: &str = "No longer asked. A tap here doesn't count."; fn content(ev: &Value) -> &str { ev["content"].as_str().unwrap() @@ -21,7 +22,7 @@ fn redrop(b: &Bench, title: &str) { } /// The post the Bench last edited `card` to. -fn last_edit(b: &Bench, card: &str) -> Value { +pub(super) fn last_edit(b: &Bench, card: &str) -> Value { b.posts() .into_iter() .rev() @@ -91,6 +92,13 @@ async fn old_tick(b: &mut Bench, mut budget: usize) -> usize { budget } +/// An old post already edited closed. +fn closed(s: &mut BenchState, id: &str, ch: &str, card: &Posted, root: Option<&str>, now: u64) { + retired(s, id, ch, card, now); + let r = s.replaced.last_mut().unwrap(); + (r.from_post, r.root) = (false, root.map(str::to_string)); +} + /// An old post with no history, its id made from `n`, in `ch`. fn old_card(b: &mut Bench, n: usize, ch: &str) -> Posted { let old = Posted { @@ -243,10 +251,7 @@ async fn a_re_ask_edits_the_old_post_first_line_and_takes_its_seeds_off() { async fn a_touched_stale_card_says_so_first_too() { for (drop, first) in [ (vec![("options", json!(["Stay", "Move away"]))], REPLACED), - ( - vec![("validity", json!("superseded"))], - "No longer asked. A tap here doesn't count.", - ), + (vec![("validity", json!("superseded"))], NOT_ASKED), ] { let mut b = seeded_bench(Gate::Open, &[]).await; let old = b.card(ITEM); @@ -290,17 +295,151 @@ async fn an_old_receipt_keeps_its_outcome_time() { assert!(!text.contains("23:59"), "{text}"); } -/// `pending_reword_deletes_and_reposts_never_edits` pins the post; a deleted -/// card has nothing left to say so on, and the relay refuses a tap on it. +/// An untouched card stays as any other: a phone that lost its socket across a +/// delete would keep showing it, and the relay would refuse a tap there with +/// nothing said. It says so from its own text, its seeds come off and a tap on +/// it is told, once. #[tokio::test] -async fn an_untouched_stale_card_is_only_deleted() { +async fn an_untouched_stale_card_is_edited_not_deleted() { let mut b = seeded_bench(Gate::Open, &[]).await; let old = b.card(ITEM).event_id; + let on_card = seeds(&b, &old); + let post = posted(&b, &old); + let mut relay: Vec<&nostr::Event> = on_card.iter().collect(); + relay.push(&post); + reply(&b, &relay); redrop(&b, "Relay choice, reworded"); + b.settle(4).await; + + let edited = last_edit(&b, &old); + let text = content(&edited); + assert!(text.starts_with(&format!("{REPLACED}\n🔴 · ")), "{text}"); + assert!(text.contains("**Relay choice**"), "as asked: {text}"); + assert!(!text.contains("reworded"), "{text}"); + assert!(text.ends_with(&thread_line(&b.channel(), &old)), "{text}"); + let off = removed(&b); + assert!(!off.contains(&old), "deleted: {off:?}"); + for seed in &on_card { + assert!(off.contains(&seed.id.to_hex()), "{off:?}"); + } + assert_eq!(b.s.kept, [old.as_str()]); + + let stray = tap(&b.ctx.owner, &old, OPTION_EMOJI[0], b.now + 1); + reply(&b, &[&stray]); b.settle(3).await; - assert!(removed(&b).contains(&old)); - assert!(b.s.replaced.is_empty()); - assert!(b.s.kept.is_empty()); + assert_eq!(replies(&b).len(), 1); + assert!(content(&replies(&b)[0]).contains("The live card is the one that counts:")); +} + +/// lastError stops the kit's approvals: a lookup that fails, an edit refused or +/// a post gone from the relay is the board's and the retry's, never the tick's. +#[tokio::test] +async fn a_stale_card_that_will_not_update_fails_no_tick() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let old = b.card(ITEM).event_id; + let post = posted(&b, &old); + reply(&b, &[&post]); + *b.relay.post_down.lock().unwrap() = Some(old.clone()); + redrop(&b, "Relay choice, reworded"); + b.tick().await.unwrap(); + assert!(b.s.replaced[0].from_post); + // Saved by a build before this one, the key is absent and state still loads. + let mut saved = serde_json::to_value(&b.s.replaced[0]).unwrap(); + saved.as_object_mut().unwrap().remove("fromPost"); + assert!(!serde_json::from_value::(saved).unwrap().from_post); + let retry = b.s.replaced[0].retry_at; + assert_eq!(retry, b.now + 60); + b.settle(2).await; + assert!( + board(&b).contains("\n\nOld cards\nWould not update: 1."), + "{}", + board(&b) + ); + assert!(b.s.replaced[0].from_post, "not before its retry"); + *b.relay.post_down.lock().unwrap() = None; + b.now = retry; + b.settle(4).await; + assert!(content(&last_edit(&b, &old)).starts_with(REPLACED)); + assert!(!board(&b).contains("Old cards"), "{}", board(&b)); + + // One gone from the relay is let go, with nothing to edit. + let gone = b.card(ITEM).event_id; + reply(&b, &[]); + let before = b.posts().len(); + redrop(&b, "Relay choice, reworded again"); + b.settle(3).await; + assert!(!b.s.replaced.iter().any(|r| r.card.event_id == gone)); + let edited = |e: &Value| e["kind"] == 40003 && tag_value(e, "e") == Some(gone.clone()); + assert!(!b.posts()[before..].iter().any(|(e, _)| edited(e))); + assert!(b.s.kept.contains(&gone)); +} + +/// Nothing an old post does brings a new card back: edited, kept and read again +/// through a relaunch, the item keeps the one newer card it was given. +#[tokio::test] +async fn an_old_post_never_brings_a_repost() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let old = b.card(ITEM).event_id; + let post = posted(&b, &old); + reply(&b, &[&post]); + redrop(&b, "Relay choice, reworded"); + b.settle(6).await; + let new = b.card(ITEM).event_id; + assert_ne!(new, old); + let board_id = b.s.board.as_ref().unwrap().event_id.clone(); + let (new_post, board_post) = (posted(&b, &new), posted(&b, &board_id)); + let before = b.posts().len(); + b.settle(10).await; + assert_eq!(b.posts().len(), before, "quiet once it is done"); + + // A relaunch reads both posts: the old one is kept, so never adopted. + reply(&b, &[&post, &new_post, &board_post]); + b.s.needs_rebuild = true; + b.settle(10).await; + assert_eq!(b.card(ITEM).event_id, new); + assert!(b.s.orphan_cards.is_empty() && b.s.strays.is_empty()); + assert_eq!(b.posts().len(), before, "no repost, no delete"); + + // State lost: the newest post is the card, the old one only listed. + b.s.kept.clear(); + b.s.needs_rebuild = true; + b.settle(10).await; + assert_eq!(b.card(ITEM).event_id, new); + assert_eq!(b.s.strays, [old]); + assert_eq!(b.posts().len(), before, "no repost, no delete"); +} + +/// State lost, then the same Drop again: a post edited closed says a tap +/// doesn't count, so a new card goes up. One with its Recorded edit is the card. +#[tokio::test] +async fn lost_state_never_adopts_a_post_edited_closed() { + for closed in [true, false] { + let mut b = seeded_bench(Gate::Open, &[]).await; + let first = b.card(ITEM).event_id; + let post = posted(&b, &first); + if closed { + reply(&b, &[&post]); + let gone = drop_json(&[("validity", json!("superseded"))]); + b.drop_file("orchestrator", "relay-choice", &gone); + } else { + let owner_tap = tap(&b.ctx.owner, &first, OPTION_EMOJI[0], b.now); + reply(&b, &[&post, &owner_tap]); + } + b.settle(4).await; + let edit = last_edit(&b, &first); + assert_eq!(content(&edit).starts_with(NOT_ASKED), closed); + reply(&b, &[&post, &serde_json::from_value(edit).unwrap()]); + b.s.items.clear(); + b.s.kept.clear(); + b.s.replaced.clear(); + b.s.needs_rebuild = true; + b.settle(1).await; + assert!(b.s.orphan_cards.contains_key(ITEM)); + b.drop_file("orchestrator", "relay-choice", &drop_json(&[])); + b.settle(3).await; + assert_eq!(b.card(ITEM).event_id == first, !closed); + assert_eq!(b.s.kept.contains(&first), closed); + } } #[tokio::test] @@ -623,6 +762,56 @@ async fn removals_and_replies_stop_at_the_post_budget() { assert!(b.s.replaced[1].tapped.is_some()); } +/// One lookup a tick and a minute's retry: with more posts failing than a +/// minute's lookups, every tick runs out. They still go at the end of their +/// window. +#[tokio::test] +async fn posts_failing_past_the_lookups_still_go_at_their_window() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let ch = b.channel(); + let seeds: Vec<_> = (1..=40) + .map(|n| { + let old = old_card(&mut b, n, &ch); + tap(&b.ctx.writer, &old.event_id, OPTION_EMOJI[0], b.now) + }) + .collect(); + reply(&b, &seeds.iter().collect::>()); + let end = b.now + 86_400; + *b.relay.clock.lock().unwrap() = Some(b.now + 10_000); + for _ in 0..120 { + b.now += 2; + old_tick(&mut b, 4).await; + } + assert_eq!(b.s.replaced.len(), 40); + b.now = end; + // The 30 tried go at once; the 10 waiting behind them go a tick each. + for _ in 0..10 { + b.now += 2; + old_tick(&mut b, 4).await; + } + assert!(b.s.replaced.is_empty()); +} + +/// Out of lookups, a post past its window that hasn't failed waits for its try: +/// a Bench off for a day still closes what it owed. +#[tokio::test] +async fn past_its_window_a_post_still_gets_its_try_when_lookups_run_out() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let ch = b.channel(); + let seeds = [1, 2].map(|n| { + let old = old_card(&mut b, n, &ch); + tap(&b.ctx.writer, &old.event_id, OPTION_EMOJI[0], b.now) + }); + reply(&b, &[&seeds[0], &seeds[1]]); + b.now += 86_400; + old_tick(&mut b, 4).await; + assert_eq!(removed(&b).len(), 1); + assert_eq!(b.s.replaced.len(), 2); + old_tick(&mut b, 4).await; + assert_eq!(removed(&b).len(), 2); + assert!(b.s.replaced.is_empty()); +} + /// With no post left to spend, the lookups before a removal or a reply wait too. #[tokio::test] async fn no_budget_no_lookup() { @@ -664,6 +853,17 @@ async fn lookups_are_capped_per_tick() { assert_eq!(lookups(&b, from), 1); } +/// Its lookup is a lookup and its edit a post, like any other. +#[tokio::test] +async fn an_edit_from_its_post_spends_a_post() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let (ch, card) = (b.channel(), b.card(ITEM)); + let post = posted(&b, &card.event_id); + reply(&b, &[&post]); + retired(&mut b.s, "orchestrator/old", &ch, &card, b.now); + assert_eq!(old_tick(&mut b, 4).await, 3); +} + /// A read that keeps failing fails no tick (lastError stops the kit's approvals). /// Past a minute the board says taps go unanswered, until a read goes through. #[tokio::test] diff --git a/desktop/src-tauri/src/bench/tests/routes.rs b/desktop/src-tauri/src/bench/tests/routes.rs index 5c1e9cf6883..17143feb63c 100644 --- a/desktop/src-tauri/src/bench/tests/routes.rs +++ b/desktop/src-tauri/src/bench/tests/routes.rs @@ -778,8 +778,8 @@ async fn a_reword_closes_a_routed_card_with_only_a_reply() { let reworded = drop_json(&[("options", json!(["Stay", "Move away"]))]); b.drop_file("orchestrator", "relay-choice", &reworded); b.settle(3).await; - assert_eq!(b.kinds()[before..], [40003, 9, 7, 7, 9, 5]); - let closed = &b.posts()[before].0; + assert_eq!(b.kinds()[before..], [9, 7, 7, 40003, 9, 5]); + let closed = &b.posts()[before + 3].0; assert_eq!(tag_value(closed, "e").unwrap(), old_card.event_id); assert!(content(closed).contains("\n\nClosed: a tap or reply here is no longer read.")); assert_eq!(b.s.kept, [old_card.event_id]);