diff --git a/lib/src/rust/api/messages.dart b/lib/src/rust/api/messages.dart index 883bb5bb0..edf7ac7da 100644 --- a/lib/src/rust/api/messages.dart +++ b/lib/src/rust/api/messages.dart @@ -7,8 +7,8 @@ import '../frb_generated.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'types.dart'; -// These functions are ignored because they are not marked as `pub`: `active_chats`, `add_message`, `admin_chat_context`, `advance_cursor`, `budget_ok`, `chat_context`, `chat_still_relevant`, `chat_subscription_id`, `cursor_key`, `ensure_durable`, `ensure_hydrated`, `get_messages`, `guard_key`, `handle_chat_event`, `id_prefix`, `insert`, `is_known`, `is_supported_mime_type`, `load_chat_cursor`, `mark_as_read`, `message_store`, `message_type`, `mime_to_file_type`, `new`, `new`, `new`, `new`, `parse_chat_payload`, `persist_decrypted_attachment`, `publish_chat_payload_for`, `publish_chat_payload`, `quota_exceeded`, `rebuild_session`, `reject`, `resubscribe_active_chats`, `run_chat_subscription`, `safe_filename`, `session_or_rebuild`, `store_chat_cursor`, `store_outgoing_admin_message`, `subscribe_incoming_chat`, `try_take`, `unread_count_inner` -// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `BoundedIdSet`, `ChatChannel`, `ChatContext`, `ChatRxState`, `MessageStore`, `TokenBucket` +// These functions are ignored because they are not marked as `pub`: `active_chats`, `add_message`, `admin_chat_context`, `advance_cursor`, `budget_ok`, `chat_context`, `chat_still_relevant`, `chat_subscription_id`, `cursor_key`, `ensure_durable`, `ensure_hydrated`, `get_messages`, `guard_key`, `handle_chat_event`, `id_prefix`, `insert`, `is_known`, `is_supported_mime_type`, `load_chat_cursor`, `mark_as_read`, `message_store`, `message_type`, `mime_to_file_type`, `new`, `new`, `new`, `new`, `parse_chat_payload`, `peer_to_wake`, `persist_decrypted_attachment`, `publish_chat_payload_for`, `publish_chat_payload`, `quota_exceeded`, `rebuild_session`, `reject`, `resubscribe_active_chats`, `run_chat_subscription`, `safe_filename`, `session_or_rebuild`, `store_chat_cursor`, `store_outgoing_admin_message`, `subscribe_incoming_chat`, `try_take`, `unread_count_inner` +// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `BoundedIdSet`, `ChatChannel`, `ChatContext`, `ChatRxState`, `MessageStore`, `PublishedChat`, `TokenBucket` // These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `clone`, `clone`, `eq`, `fmt`, `fmt` /// Send an encrypted text message to the trade counterparty. diff --git a/lib/src/rust/api/push.dart b/lib/src/rust/api/push.dart index 70a9f9bdd..f6dcc4b28 100644 --- a/lib/src/rust/api/push.dart +++ b/lib/src/rust/api/push.dart @@ -7,12 +7,12 @@ import '../frb_generated.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'types.dart'; -// These functions are ignored because they are not marked as `pub`: `classify`, `clear_node_refusal`, `client`, `current_wanted`, `emit_status`, `issuing_nodes_of_live_trades`, `last_report`, `load_state`, `mirror_path`, `note_failure`, `post`, `production_server`, `reconcile_core`, `reconcile_with`, `request_reconcile`, `save_registrations`, `start_push_timer`, `status_of`, `status_tx`, `unregister_all`, `write_mirror` -// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `HttpPushServer`, `PushState`, `ReconcileReport`, `ServerOutcome` -// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt` -// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `register`, `unregister` +// These functions are ignored because they are not marked as `pub`: `classify`, `clear_node_refusal`, `client`, `current_wanted`, `emit_status`, `issuing_nodes_of_live_trades`, `last_notify`, `last_report`, `load_state`, `mirror_path`, `note_failure`, `notify_peer_with`, `post`, `production_server`, `reconcile_core`, `reconcile_with`, `request_reconcile`, `save_registrations`, `start_push_timer`, `status_of`, `status_tx`, `unregister_all`, `wake_peer`, `write_mirror` +// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `HttpPushServer`, `NotifyOutcome`, `PushState`, `ReconcileReport`, `ServerOutcome` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt` +// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `notify`, `register`, `unregister` // These functions are ignored (category: IgnoreBecauseOwnerTyShouldIgnore): `default`, `default` -// These functions have error during generation (see debug logs or enable `stop_on_error: true` for more details): `register`, `unregister` +// These functions have error during generation (see debug logs or enable `stop_on_error: true` for more details): `notify`, `register`, `unregister` /// Bring what the push server holds in step with what is wanted, now. /// Single-flight: a pass in progress finishes first, and a request that diff --git a/rust/src/api/messages.rs b/rust/src/api/messages.rs index 8f905f603..9a9391bff 100644 --- a/rust/src/api/messages.rs +++ b/rust/src/api/messages.rs @@ -315,7 +315,23 @@ pub(crate) async fn publish_chat_payload_for( ctx: &ChatContext, payload: &str, ) -> Result { - publish_chat_payload(ctx, payload).await + publish_chat_payload(ctx, payload).await.map(|p| p.inner) +} + +/// A chat envelope handed to the pool. +struct PublishedChat { + /// The signed inner event: the message's durable identity. + inner: nostr_sdk::prelude::Event, + /// Whether at least one relay accepted the envelope. `send_event` returns + /// `Ok` even when every relay rejected it. + delivered: bool, +} + +/// The peer to wake after a publish, if any: only an envelope some relay +/// accepted is worth a wake. Waking for one that reached nobody rings the +/// peer for nothing and debounces the wake of the retry that does land. +fn peer_to_wake(delivered: bool, peer_hex: &str) -> Option<&str> { + delivered.then_some(peer_hex) } /// Record a message we just sent to the solver, mirroring what `send_message` @@ -342,7 +358,7 @@ pub(crate) async fn store_outgoing_admin_message( let _ = message_store().add_message(msg).await; } -async fn publish_chat_payload(ctx: &ChatContext, payload: &str) -> Result { +async fn publish_chat_payload(ctx: &ChatContext, payload: &str) -> Result { let (outer, inner) = crate::nostr::transport::mostro_wrap(&ctx.trade_keys, &ctx.conv, &ctx.sign, payload) .await?; @@ -375,7 +391,10 @@ async fn publish_chat_payload(ctx: &ChatContext, payload: &str) -> Result Result log::warn!("[messages] send_message trade={trade_id}: {e}"), - Ok(inner) => { - id = inner.id.to_hex(); - created_at = inner.created_at.as_secs() as i64; + Ok(published) => { + id = published.inner.id.to_hex(); + created_at = published.inner.created_at.as_secs() as i64; + // The envelope's `p` tag is `pub(K_conv)`, which + // the push server cannot match: ask it to ring the + // peer's trade key (docs/PUSH_NOTIFICATIONS.md §7.3). + if let Some(peer) = peer_to_wake(published.delivered, peer_hex) { + crate::api::push::wake_peer(peer); + } } } } @@ -570,9 +595,12 @@ pub async fn send_file( Err(e) => log::warn!("[messages] send_file trade={trade_id}: {e}"), Ok(ctx) => match publish_chat_payload(&ctx, &payload).await { Err(e) => log::warn!("[messages] send_file trade={trade_id}: {e}"), - Ok(inner) => { - published_id = Some(inner.id.to_hex()); - msg_created_at = inner.created_at.as_secs() as i64; + Ok(published) => { + published_id = Some(published.inner.id.to_hex()); + msg_created_at = published.inner.created_at.as_secs() as i64; + if let Some(peer) = peer_to_wake(published.delivered, peer_hex) { + crate::api::push::wake_peer(peer); + } } }, } @@ -834,7 +862,9 @@ async fn persist_decrypted_attachment( _file_name: &str, _data: &[u8], ) -> Result { - Err(anyhow!("attachment download to disk is not supported on web")) + Err(anyhow!( + "attachment download to disk is not supported on web" + )) } fn is_supported_mime_type(mime: &str) -> bool { @@ -883,8 +913,14 @@ const MAX_STORED_BYTES_PER_TRADE: usize = 5 * 1024 * 1024; /// Subscription id for the chat envelope of one order — explicit so every /// exit path can unsubscribe and a lingering relay subscription never /// outlives its task. -fn chat_subscription_id(channel: ChatChannel, order_id: &str) -> nostr_sdk::prelude::SubscriptionId { - nostr_sdk::prelude::SubscriptionId::new(format!("mostro-chat-{}{order_id}", channel.id_prefix())) +fn chat_subscription_id( + channel: ChatChannel, + order_id: &str, +) -> nostr_sdk::prelude::SubscriptionId { + nostr_sdk::prelude::SubscriptionId::new(format!( + "mostro-chat-{}{order_id}", + channel.id_prefix() + )) } /// Which conversation an envelope subscription serves. @@ -935,7 +971,6 @@ impl ChatChannel { fn cursor_key(self, order_id: &str) -> String { crate::db::settings_keys::chat_cursor(&format!("{}{order_id}", self.id_prefix())) } - } /// Orders with a live chat task. Single-owner guard: the peer-reveal capture @@ -1110,7 +1145,10 @@ pub(crate) async fn subscribe_incoming_chat( // Cleanup on every exit path: release ownership and drop the relay // subscriptions so they never outlive the task. - active_chats().lock().await.remove(&channel.guard_key(&order_id)); + active_chats() + .lock() + .await + .remove(&channel.guard_key(&order_id)); if let Ok(pool) = crate::api::nostr::get_pool() { let client = pool.client(); // Unsubscribing a subscription that already went away is not an @@ -1253,8 +1291,17 @@ async fn run_chat_subscription( .. }) => { if subscription_id == sub_id { - handle_chat_event(channel, order_id, &allowed_signers, conv, &sign_pubkey, &my_trade_pubkey, &event, &mut state) - .await; + handle_chat_event( + channel, + order_id, + &allowed_signers, + conv, + &sign_pubkey, + &my_trade_pubkey, + &event, + &mut state, + ) + .await; } if state.flooded { return; @@ -1427,7 +1474,12 @@ pub(crate) async fn resubscribe_active_chats() { }; log::info!("[messages] resubscribing chat order={order_id}"); crate::rt::spawn(subscribe_incoming_chat( - ChatChannel::Peer, order_id, trade_keys, peer, conv, sign, + ChatChannel::Peer, + order_id, + trade_keys, + peer, + conv, + sign, )); } } @@ -1561,6 +1613,20 @@ async fn rebuild_session( mod tests { use super::*; + const PEER: &str = "aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11"; + + #[test] + fn a_message_no_relay_accepted_wakes_nobody() { + // `send_message` and `send_file` both gate `wake_peer` on this: an + // empty `output.success` still comes back as `Ok` from the pool. + assert_eq!(peer_to_wake(false, PEER), None); + } + + #[test] + fn a_message_some_relay_accepted_wakes_the_peer() { + assert_eq!(peer_to_wake(true, PEER), Some(PEER)); + } + #[test] fn the_two_channels_of_one_order_never_collide() { let order = "order-1"; @@ -1924,7 +1990,10 @@ mod tests { // misinterpreted. let (content, att) = parse_chat_payload(r#"{"type":"file","url":"x"}"#); assert_eq!(content, r#"{"type":"file","url":"x"}"#); - assert!(att.is_none(), "incomplete pointer must not become an attachment"); + assert!( + att.is_none(), + "incomplete pointer must not become an attachment" + ); } #[test] @@ -2145,7 +2214,10 @@ mod tests { let trade_keys = nostr_sdk::prelude::Keys::generate(); let trade = live_trade(&order_id, "not-a-pubkey", 1); assert!(rebuild_session(&trade, &trade_keys).await.is_none()); - assert!(crate::mostro::session::session_manager().get_session(&order_id).await.is_none()); + assert!(crate::mostro::session::session_manager() + .get_session(&order_id) + .await + .is_none()); } /// The seam test for #381: `session_or_rebuild` is only useful if the @@ -2163,10 +2235,8 @@ mod tests { #[tokio::test] #[ignore = "claims the process-global app_db and identity — run with --ignored"] async fn send_message_rebuilds_session_from_trade_row() { - let db_path = std::env::temp_dir().join(format!( - "mostro-381-seam-test-{}.db", - uuid::Uuid::new_v4() - )); + let db_path = + std::env::temp_dir().join(format!("mostro-381-seam-test-{}.db", uuid::Uuid::new_v4())); crate::db::app_db::init_db(db_path.to_str().unwrap()) .await .expect("init app db"); @@ -2195,7 +2265,10 @@ mod tests { .await .expect("save the post-reveal row"); assert!( - crate::mostro::session::session_manager().get_session(&order_id).await.is_none(), + crate::mostro::session::session_manager() + .get_session(&order_id) + .await + .is_none(), "the restart shape: row persisted, session gone" ); diff --git a/rust/src/api/push.rs b/rust/src/api/push.rs index aaf06f741..18e3898bf 100644 --- a/rust/src/api/push.rs +++ b/rust/src/api/push.rs @@ -68,6 +68,9 @@ pub(crate) trait PushServer { mostro_pubkey: &str, ) -> impl std::future::Future; fn unregister(&self, trade_pubkey: &str) -> impl std::future::Future; + // The web build does not wake peers yet (mostro-push-server#44). + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + fn notify(&self, trade_pubkey: &str) -> impl std::future::Future; } /// The real server over HTTPS. Nothing but the JSON bodies of §3.1 is ever @@ -172,6 +175,14 @@ impl PushServer for HttpPushServer { ) .await } + + async fn notify(&self, trade_pubkey: &str) -> ServerOutcome { + Self::post( + "/api/notify", + serde_json::json!({ "trade_pubkey": trade_pubkey }), + ) + .await + } } // ── Persisted state ───────────────────────────────────────────────────────── @@ -709,6 +720,111 @@ pub(crate) async fn unregister_all() { ); } +// ── Peer wake (docs/PUSH_NOTIFICATIONS.md §7.3) ───────────────────────────── + +/// What one wake attempt came to, for the log and the tests. +#[flutter_rust_bridge::frb(ignore)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum NotifyOutcome { + /// The server answered `202`: it will wake whoever it holds a token for, + /// which it never tells anyone. + Sent, + /// Another wake for the same peer went out less than + /// [`rules::NOTIFY_DEBOUNCE_SECS`] ago. + Debounced, + /// Not a 64-hex pubkey: nothing is sent. + InvalidPeer, + /// Any other answer. Never retried: the next message rings again. + NotDelivered, +} + +/// Wake the counterparty after a chat message reached the relays. +/// +/// The chat envelope is `p`-tagged to `pub(K_conv)`, which the push server's +/// listener cannot match to any registration, so without this a backgrounded +/// peer learns of the message only when they next open the app. The sender +/// asks the server to ring the peer's trade pubkey instead (`/api/notify`). +/// +/// - **Not gated on this device's own push toggle.** It is the peer's +/// setting that decides whether anything reaches them; the server answers +/// `202` either way and reveals nothing. +/// - **Debounced per peer**, so a burst of short messages costs one wake and +/// stays far under the server's 30/min per pubkey. +/// - **Fire-and-forget**: spawned, so the send never waits on the push +/// server, and a failure never fails the send. +/// - **Peer chat only.** The dispute channel does not call this: its +/// counterpart is a solver, not a push client (§7.3). +/// - **Not from the web build** until the server answers CORS +/// (mostro-push-server#44, T4.5): the browser would block the request. +pub(crate) fn wake_peer(peer_trade_pubkey: &str) { + #[cfg(target_arch = "wasm32")] + { + let _ = peer_trade_pubkey; + } + #[cfg(not(target_arch = "wasm32"))] + { + if tokio::runtime::Handle::try_current().is_err() { + return; + } + let peer = peer_trade_pubkey.to_string(); + crate::rt::spawn(async move { + let outcome = notify_peer_with( + &production_server(), + last_notify(), + &peer, + crate::rt::unix_now(), + ) + .await; + log::debug!("[push] peer wake: {outcome:?}"); + }); + } +} + +/// The wake, with its server, its debounce memory and its clock injected. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(crate) async fn notify_peer_with( + server: &impl PushServer, + last_notify: &std::sync::Mutex>, + peer_trade_pubkey: &str, + now: i64, +) -> NotifyOutcome { + let peer = peer_trade_pubkey.to_lowercase(); + if peer.len() != 64 || hex::decode(&peer).is_err() { + return NotifyOutcome::InvalidPeer; + } + { + let Ok(mut last) = last_notify.lock() else { + return NotifyOutcome::NotDelivered; + }; + if !rules::notify_allowed(last.get(&peer).copied(), now) { + return NotifyOutcome::Debounced; + } + // Recorded before the request, not after: two messages sent while + // the first request is still in flight must not both ring. + last.insert(peer.clone(), now); + // Forget peers not heard from in a while, so the map stays small. + last.retain(|_, at| now - *at < 3600); + } + match server.notify(&peer).await { + ServerOutcome::Accepted => NotifyOutcome::Sent, + ServerOutcome::BadRequest(msg) => { + log::warn!("[push] peer wake rejected as malformed: {msg}"); + NotifyOutcome::NotDelivered + } + other => { + log::info!("[push] peer wake not delivered: {other:?}"); + NotifyOutcome::NotDelivered + } + } +} + +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +fn last_notify() -> &'static std::sync::Mutex> { + static LAST: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + LAST.get_or_init(|| std::sync::Mutex::new(HashMap::new())) +} + // ── Status stream ─────────────────────────────────────────────────────────── const STATUS_CHANNEL_CAPACITY: usize = 16; @@ -806,6 +922,13 @@ mod tests { .push(format!("unregister {trade_pubkey}")); self.next() } + async fn notify(&self, trade_pubkey: &str) -> ServerOutcome { + self.calls + .lock() + .unwrap() + .push(format!("notify {trade_pubkey}")); + self.next() + } } /// What production paths get under test: a server that is never there. @@ -818,6 +941,9 @@ mod tests { async fn unregister(&self, _: &str) -> ServerOutcome { ServerOutcome::Failed("no push server under test".into()) } + async fn notify(&self, _: &str) -> ServerOutcome { + ServerOutcome::Failed("no push server under test".into()) + } } fn enabled_state() -> PushState { @@ -1004,6 +1130,78 @@ mod tests { assert_eq!(server.calls().last().unwrap(), &format!("unregister {K2}")); } + // ── Peer wake ──────────────────────────────────────────────────────── + + #[tokio::test] + async fn a_peer_wake_asks_the_server_once_then_debounces() { + let server = FakeServer::default(); + let last = Mutex::new(HashMap::new()); + + let first = notify_peer_with(&server, &last, K1, NOW).await; + let burst = notify_peer_with(&server, &last, K1, NOW + 3).await; + let later = notify_peer_with(&server, &last, K1, NOW + rules::NOTIFY_DEBOUNCE_SECS).await; + + assert_eq!(first, NotifyOutcome::Sent); + assert_eq!(burst, NotifyOutcome::Debounced, "a burst costs one wake"); + assert_eq!(later, NotifyOutcome::Sent); + assert_eq!( + server.calls(), + vec![format!("notify {K1}"), format!("notify {K1}")] + ); + } + + #[tokio::test] + async fn the_debounce_is_per_peer() { + let server = FakeServer::default(); + let last = Mutex::new(HashMap::new()); + + notify_peer_with(&server, &last, K1, NOW).await; + let other = notify_peer_with(&server, &last, K2, NOW + 1).await; + + assert_eq!(other, NotifyOutcome::Sent); + assert_eq!(server.calls().len(), 2); + } + + #[tokio::test] + async fn a_peer_that_is_not_a_pubkey_is_never_sent() { + let server = FakeServer::default(); + let last = Mutex::new(HashMap::new()); + + for bad in ["", "aabbccdd", &"zz".repeat(32), &"a".repeat(65)] { + assert_eq!( + notify_peer_with(&server, &last, bad, NOW).await, + NotifyOutcome::InvalidPeer, + "{bad:?}" + ); + } + assert!(server.calls().is_empty()); + } + + #[tokio::test] + async fn the_peer_is_lowercased_as_the_server_matches_it() { + let server = FakeServer::default(); + let last = Mutex::new(HashMap::new()); + + notify_peer_with(&server, &last, &K1.to_uppercase().replace('1', "A"), NOW).await; + + assert_eq!(server.calls(), vec![format!("notify {}", "a".repeat(64))]); + } + + #[tokio::test] + async fn a_rejected_wake_is_not_retried_and_still_debounces() { + let server = FakeServer::answering(vec![ServerOutcome::BadRequest( + "Invalid trade_pubkey format".into(), + )]); + let last = Mutex::new(HashMap::new()); + + let rejected = notify_peer_with(&server, &last, K1, NOW).await; + let again = notify_peer_with(&server, &last, K1, NOW + 1).await; + + assert_eq!(rejected, NotifyOutcome::NotDelivered); + assert_eq!(again, NotifyOutcome::Debounced); + assert_eq!(server.calls().len(), 1); + } + #[test] fn statuses_classify_as_the_rules_expect() { assert_eq!( diff --git a/specs/004-mostro-p2p-client/contracts/messages.md b/specs/004-mostro-p2p-client/contracts/messages.md index 59d14080a..8b167641d 100644 --- a/specs/004-mostro-p2p-client/contracts/messages.md +++ b/specs/004-mostro-p2p-client/contracts/messages.md @@ -64,6 +64,10 @@ normative list): comes online. - Isolation: chat runs on its own task and bounded channels; it can never block the order state machine, the daemon transport, or a dispute. +- Push wake: once a peer message or attachment pointer reached the relays, + the sender asks the push server to ring the counterparty's trade pubkey, + debounced per peer and fire-and-forget (`contracts/push.md`, *Peer wake*). + The dispute channel does not. ## Functions diff --git a/specs/004-mostro-p2p-client/contracts/push.md b/specs/004-mostro-p2p-client/contracts/push.md index 4b4b26a77..80d4cbc34 100644 --- a/specs/004-mostro-p2p-client/contracts/push.md +++ b/specs/004-mostro-p2p-client/contracts/push.md @@ -77,6 +77,30 @@ are Dart's to know and are read separately (`PushNotificationService.isSupported Explicit trigger. Never fails: every outcome is logged and reflected in the status. +## Peer wake + +Not a bridge call: `send_message` and `send_file` ring the counterparty +themselves once the chat envelope reached the relays +(`docs/PUSH_NOTIFICATIONS.md` §7.3). The envelope is `p`-tagged to +`pub(K_conv)`, which the push server's listener cannot match, so the sender +asks it to wake the peer's trade pubkey with `POST /api/notify`. + +- Not gated on this device's own push toggle: the peer's setting decides + what reaches them, and the server answers `202` either way. +- Debounced per peer (10 s): a burst of messages costs one wake, far under + the server's 30/min per pubkey. Recorded before the request, so messages + sent while one is in flight do not each ring. +- Fire-and-forget: spawned, never awaited by the send, never retried, never + a reason for the send to fail. A `400` is logged as a client bug. +- Peer chat only. The dispute channel does not ring its solver. +- Not from the web build until the server answers CORS + (mostro-push-server#44). +- No relay, no wake: an envelope every relay rejected (`send_event` is still + `Ok` with an empty success set) reached no one, so it rings nobody and + cannot debounce the wake of a retry that does land. +- No reveal, no wake: before the peer reveal (#334) there is no peer pubkey + and the message stays local-only anyway. + ## Streams ### on_push_status_changed() → Stream