From f7888b4a0be3a8bcdb94352383a57b03195600c3 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 9 Nov 2026 13:52:10 -0600 Subject: [PATCH 1/4] fix(bench): edit a stale card closed instead of deleting it A phone that lost its socket across a delete keeps showing the card, the relay refuses a tap on it and the phone's code ignores the refusal, so the tap vanishes with nothing said. An untouched re-drop or a card no longer asked now stays, as a kept one does: its first line says a tap doesn't count, its seeds come off and a tap on it gets one reply. Publish (b) no longer calls the relay, so nothing there can fail a tick. The edit, its lookup and any failure are the old posts' tick's, on the same budgets, and on the board under "Old cards". Claude-Account: russelledeming@gmail.com Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/bench/item.rs | 6 +- desktop/src-tauri/src/bench/mod.rs | 4 +- desktop/src-tauri/src/bench/publish.rs | 67 ++----- desktop/src-tauri/src/bench/replaced.rs | 65 ++++-- desktop/src-tauri/src/bench/tests.rs | 29 ++- desktop/src-tauri/src/bench/tests/answers.rs | 66 ++++-- desktop/src-tauri/src/bench/tests/capacity.rs | 6 +- desktop/src-tauri/src/bench/tests/channels.rs | 8 +- desktop/src-tauri/src/bench/tests/one_tap.rs | 6 +- desktop/src-tauri/src/bench/tests/replaced.rs | 189 +++++++++++++++++- desktop/src-tauri/src/bench/tests/routes.rs | 4 +- 11 files changed, 321 insertions(+), 129 deletions(-) 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..f8ede788a82 100644 --- a/desktop/src-tauri/src/bench/mod.rs +++ b/desktop/src-tauri/src/bench/mod.rs @@ -142,8 +142,8 @@ 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. diff --git a/desktop/src-tauri/src/bench/publish.rs b/desktop/src-tauri/src/bench/publish.rs index 32f319e625b..4f09c17fe49 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,7 +336,7 @@ 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: +/// Whether a recovered orphan 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( @@ -360,34 +359,6 @@ async fn touched( })) } -/// 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(()) -} - /// A thread the relay will not take: its root is gone, in another channel or /// not a thread root. fn bad_thread(e: &str) -> bool { @@ -397,9 +368,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 +676,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,20 +695,8 @@ 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); } diff --git a/desktop/src-tauri/src/bench/replaced.rs b/desktop/src-tauri/src/bench/replaced.rs index d1ced82d268..f340180bd4e 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`). @@ -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,10 @@ 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`). + #[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 +92,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 +117,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 +134,14 @@ 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); } fn asked(i: &item::Item) -> bool { @@ -273,7 +281,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 +293,24 @@ 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); + } + 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?; + // Its thread root ends older cards' root-reply windows there. + let root = item::parent(e).map(str::to_string); + s.kept_at + .extend(root.clone().map(|root| (root, r.card.created_at))); + let r = &mut s.replaced[i]; + (r.from_post, r.root) = (false, root); + return Ok(true); + } if let Some(shown) = &r.shown { if !spend(budget, 1) { return Ok(false); @@ -366,6 +392,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 +404,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..bdc7373a4ea 100644 --- a/desktop/src-tauri/src/bench/tests/channels.rs +++ b/desktop/src-tauri/src/bench/tests/channels.rs @@ -628,10 +628,10 @@ 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())); 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/replaced.rs b/desktop/src-tauri/src/bench/tests/replaced.rs index f0540114bf9..71688ecc96e 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() @@ -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,118 @@ 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"); } #[tokio::test] @@ -623,6 +729,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 +820,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]); From f7630b2135bd3c37cc02badc7857d2f793f24451 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 9 Nov 2026 14:57:51 -0600 Subject: [PATCH 2/4] fix(bench): a retired card nobody touched ends no older card's window Every card retired in a thread added a kept_at entry, including untouched ones main deleted and left no entry for. An older card still open in that thread then stopped taking replies that name only the root, for good, with nothing said. A retired post in a thread now reads what is on it once it is edited, and adds the entry only if there is something besides its seeds and its own closing edit, as main did. It costs one lookup more, on the old posts' one a tick, and only for a post in a thread. Claude-Account: russelledeming@gmail.com Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/bench/mod.rs | 4 +-- desktop/src-tauri/src/bench/publish.rs | 18 +++++----- desktop/src-tauri/src/bench/replaced.rs | 27 ++++++++++---- desktop/src-tauri/src/bench/tests/channels.rs | 36 +++++++++++++++++++ desktop/src-tauri/src/bench/tests/replaced.rs | 2 +- 5 files changed, 70 insertions(+), 17 deletions(-) diff --git a/desktop/src-tauri/src/bench/mod.rs b/desktop/src-tauri/src/bench/mod.rs index f8ede788a82..322e580993d 100644 --- a/desktop/src-tauri/src/bench/mod.rs +++ b/desktop/src-tauri/src/bench/mod.rs @@ -145,8 +145,8 @@ pub(crate) struct BenchState { /// 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 4f09c17fe49..192e44a455a 100644 --- a/desktop/src-tauri/src/bench/publish.rs +++ b/desktop/src-tauri/src/bench/publish.rs @@ -336,27 +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 orphan 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) - })) + }); + Ok(on) } /// A thread the relay will not take: its root is gone, in another channel or @@ -712,7 +714,7 @@ pub(super) async fn publish( 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_post(ctx, s, &ch, &orphan.event_id, now).await?.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 f340180bd4e..6bce3bf4fc2 100644 --- a/desktop/src-tauri/src/bench/replaced.rs +++ b/desktop/src-tauri/src/bench/replaced.rs @@ -19,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, }; @@ -66,7 +66,8 @@ pub(crate) struct Replaced { /// 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`). + /// 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, @@ -144,6 +145,12 @@ pub(super) fn retired(s: &mut BenchState, id: &str, ch: &str, card: &Posted, 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 { i.kind == item::ItemKind::Pending && i.validity == item::Validity::Current } @@ -297,18 +304,26 @@ async fn finish( 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?; - // Its thread root ends older cards' root-reply windows there. let root = item::parent(e).map(str::to_string); - s.kept_at - .extend(root.clone().map(|root| (root, r.card.created_at))); let r = &mut s.replaced[i]; - (r.from_post, r.root) = (false, root); + (r.from_post, r.root) = (root.is_some(), root); return Ok(true); } if let Some(shown) = &r.shown { diff --git a/desktop/src-tauri/src/bench/tests/channels.rs b/desktop/src-tauri/src/bench/tests/channels.rs index bdc7373a4ea..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"; @@ -637,6 +638,41 @@ async fn closed_card_keeps_its_own_thread_root() { 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/replaced.rs b/desktop/src-tauri/src/bench/tests/replaced.rs index 71688ecc96e..1a718e24202 100644 --- a/desktop/src-tauri/src/bench/tests/replaced.rs +++ b/desktop/src-tauri/src/bench/tests/replaced.rs @@ -22,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() From c70a81779c877f9da8d3f07eacff4009df749d69 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 9 Nov 2026 14:57:51 -0600 Subject: [PATCH 3/4] fix(bench): lost state never adopts a post edited closed Retired posts now stay on the relay with their bench:card marker. With state lost and the same Drop asked again, publish (c) adopted one as the live card: a seedless post saying a tap doesn't count, whose tap would then be read as the answer. An orphan edited closed now goes to kept like any orphan with something on it, and a new card goes up. One lookup per orphan, after lost state only. Claude-Account: russelledeming@gmail.com Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/bench/publish.rs | 9 +++-- desktop/src-tauri/src/bench/tests/replaced.rs | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/bench/publish.rs b/desktop/src-tauri/src/bench/publish.rs index 192e44a455a..97bf9411b0f 100644 --- a/desktop/src-tauri/src/bench/publish.rs +++ b/desktop/src-tauri/src/bench/publish.rs @@ -702,19 +702,22 @@ pub(super) async fn publish( 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 !on_post(ctx, s, &ch, &orphan.event_id, now).await?.is_empty() { + if !on.is_empty() { s.kept.push(orphan.event_id.clone()); } else { if !spend(budget, 1) { diff --git a/desktop/src-tauri/src/bench/tests/replaced.rs b/desktop/src-tauri/src/bench/tests/replaced.rs index 1a718e24202..d1cdffff404 100644 --- a/desktop/src-tauri/src/bench/tests/replaced.rs +++ b/desktop/src-tauri/src/bench/tests/replaced.rs @@ -409,6 +409,39 @@ async fn an_old_post_never_brings_a_repost() { 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] async fn a_tap_on_an_old_post_gets_one_reply_and_counts_for_nothing() { let (mut b, old, answer) = answered_then_replaced().await; From a7d091c5ac88a72ae65011a635f647eeabb3a0b6 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 9 Nov 2026 14:57:51 -0600 Subject: [PATCH 4/4] test(bench): pin the held guard on retiring a stale-format card Nothing failed with `held` taken out of publish (b)'s guard: a re-worded drop waits in the inbox before it gets there, so only a card whose format moved reaches it. Without the guard that card is retired for good with a tap on it unread. Claude-Account: russelledeming@gmail.com Co-Authored-By: Claude Opus 5.5 --- .../src-tauri/src/bench/tests/owner_word.rs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) 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;