diff --git a/.github/workflows/_ci-desktop.yml b/.github/workflows/_ci-desktop.yml index 108e18a215..15893a1cb4 100644 --- a/.github/workflows/_ci-desktop.yml +++ b/.github/workflows/_ci-desktop.yml @@ -24,7 +24,10 @@ jobs: desktop-core: name: Desktop Core runs-on: ubuntu-latest - timeout-minutes: 45 + # The compiled-flag verification step rebuilds the workspace for each + # BUZZ_BUILD_* state and runs the full suite under all three compile + # states; the complete recipe needs ~46m, so budget 60m. + timeout-minutes: 60 if: github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust permissions: contents: read diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index 41d9a214bd..3d011eb8eb 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -270,6 +270,10 @@ Forum event kinds: 4. **Prompting** — When events are pending and no prompt is in flight for that channel, drains all queued events for the oldest channel into a single batched prompt via ACP `session/prompt`. 5. **Agent response** — The agent processes the prompt and uses the Buzz CLI (`send_message`, `get_messages`, etc.) to interact with Buzz. 6. **Recovery** — If the agent crashes, the harness respawns it. If the relay disconnects, the harness reconnects with a `since` filter to avoid missing events. + If the inbound queue overflows, the harness attempts replay for affected + subscriptions when capacity and relay quota permit, with at least five seconds + between attempts. Recovery depends on available relay history and the consumer + making progress; complete delivery is not guaranteed. Each channel has at most one prompt in flight. Multiple channels can be processed concurrently when agents > 1. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index cc2952a2f8..ab7233d493 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -4783,6 +4783,23 @@ fn handle_prompt_result( } else { hard_timeout_fate_suffix = Some(" — requeued for retry (recently active)"); } + } else if matches!( + &result.outcome, + PromptOutcome::Error(acp::AcpError::AgentError { code: -32002, message }) + if message.contains("model not found") + ) { + // Retrying the same missing model cannot repair its configuration. + tracing::warn!( + channel_id = %batch.channel_id, + events = batch.events.len(), + "dead-lettering batch immediately — model not found" + ); + let content = "⚠️ I couldn't process the last request: the configured model \ + wasn't found at the provider's endpoint. Open agent settings, select a \ + different model from the dropdown, and save your changes. Restart the agent \ + to apply the new configuration, then re-send your request." + .to_string(); + spawn_failure_notice(rest_client, &batch, content); } else if matches!(&result.outcome, PromptOutcome::Error(e) if is_auth_error(e)) { // Auth errors are non-retryable: the token won't self-repair // between retries, so requeueing only wastes attempt slots and @@ -11012,12 +11029,25 @@ mod error_outcome_emission_tests { ); } - /// A non-auth application error (e.g. usage credits) must still follow the - /// standard requeue path so today's behavior is unchanged. #[tokio::test] - async fn non_auth_application_error_is_requeued() { - let keys = nostr::Keys::generate(); - let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "test") + async fn model_not_found_posts_recovery_notice_without_retrying() { + use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let rest = relay::RestClient { + http: reqwest::Client::new(), + base_url: format!("http://{}", listener.local_addr().unwrap()), + keys: Keys::generate(), + auth_tag_json: None, + }; + let keys = Keys::generate(); + let root = nostr::EventId::from_byte_array([0xaa; 32]); + let parent = nostr::EventId::from_byte_array([0xbb; 32]); + let event = EventBuilder::new(Kind::Custom(9), "test") + .tags([ + nostr::Tag::parse(["e", &root.to_hex(), "", "root"]).unwrap(), + nostr::Tag::parse(["e", &parent.to_hex(), "", "reply"]).unwrap(), + ]) .sign_with_keys(&keys) .unwrap(); let channel_id = uuid::Uuid::new_v4(); @@ -11033,10 +11063,173 @@ mod error_outcome_emission_tests { cancel_reason: None, }; - // Usage-credits error — AgentError but NOT an auth error. - let usage_error = acp::AcpError::AgentError { + let raw_error = r#"llm model not found: (gpt-6-astra) 404 Not Found: {"error_code":"NOT_FOUND","message":"'gpt-6-astra' does not exist."}"#; + let model_error = AcpError::AgentError { + code: -32002, + message: raw_error.to_string(), + }; + let expected_error = model_error.to_string(); + let observer = ObserverHandle::in_process(); + + let agent = dummy_agent(0).await; + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: None, + scope: None, + turn_id: "test-turn-id".to_string(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + successful_steer_deliveries: HashSet::new(), + }, + ); + let mut queue = EventQueue::new(config::DedupMode::Queue); + let config = test_config(); + let mut heartbeat_in_flight = false; + let removed_channels = std::collections::HashSet::new(); + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let result = PromptResult { + agent, + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), + turn_id: "test-turn-id".to_string(), + outcome: PromptOutcome::Error(model_error), + batch: Some(batch), + }; + handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut heartbeat_in_flight, + &removed_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + Some(observer.clone()), + Some(&rest), + ); + + // The batch must not be requeued: pending_channels returns 0. + assert_eq!( + queue.pending_channels(), + 0, + "model-not-found must stop immediately — batch must not be requeued" + ); + assert_eq!( + queue.queued_event_count(channel_id), + 0, + "model-not-found must stop immediately — no events should be pending" + ); + + assert!( + pool.agents_mut()[0].is_some(), + "healthy process remains reusable" + ); + assert!(respawn_tasks.is_empty()); + let errors: Vec<_> = observer + .snapshot() + .into_iter() + .filter(|event| event.kind == "turn_error") + .collect(); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].payload["code"], -32002); + assert_eq!(errors[0].payload["error"], expected_error); + + // Capture the real signed notice sent by handle_prompt_result, without a live relay. + let notice: nostr::Event = tokio::time::timeout(Duration::from_secs(3), async { + let (socket, _) = listener.accept().await.unwrap(); + let mut reader = BufReader::new(socket); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + assert_eq!(line, "POST /events HTTP/1.1\r\n"); + let mut content_length = None; + for _ in 0..64 { + line.clear(); + assert_ne!(reader.read_line(&mut line).await.unwrap(), 0); + if line == "\r\n" { + break; + } + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + content_length = Some(value.trim().parse::().unwrap()); + } + } + let size = content_length.expect("request Content-Length"); + assert!(size < 65536); + let mut body = vec![0; size]; + reader.read_exact(&mut body).await.unwrap(); + reader + .get_mut() + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}") + .await + .unwrap(); + serde_json::from_slice(&body).unwrap() + }) + .await + .expect("failure notice must be posted on the first failure"); + notice.verify().unwrap(); + assert_eq!(notice.pubkey, rest.keys.public_key()); + assert_eq!(notice.kind, Kind::Custom(9)); + assert_eq!( + notice.content, + "⚠️ I couldn't process the last request: the configured model wasn't found at the provider's endpoint. Open agent settings, select a different model from the dropdown, and save your changes. Restart the agent to apply the new configuration, then re-send your request." + ); + let tags = serde_json::to_value(¬ice.tags).unwrap(); + assert!(tags + .as_array() + .unwrap() + .iter() + .any(|tag| tag[0] == "h" && tag[1] == channel_id.to_string())); + let threading = queue::parse_thread_tags(¬ice); + assert_eq!(threading.root_event_id, Some(root.to_hex())); + assert_eq!(threading.parent_event_id, Some(parent.to_hex())); + } + + /// A non-auth application error (e.g. usage credits) must still follow the + /// standard requeue path so today's behavior is unchanged. + #[tokio::test] + async fn non_auth_application_error_is_requeued() { + assert_application_error_is_requeued(acp::AcpError::AgentError { code: -32000, message: "Usage credits required for 1M context".to_string(), + }) + .await; + } + + #[tokio::test] + async fn non_model_resource_not_found_is_requeued() { + assert_application_error_is_requeued(acp::AcpError::AgentError { + code: -32002, + message: "Resource not found: session no longer exists".to_string(), + }) + .await; + } + + async fn assert_application_error_is_requeued(error: acp::AcpError) { + let keys = nostr::Keys::generate(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "test") + .sign_with_keys(&keys) + .unwrap(); + let channel_id = uuid::Uuid::new_v4(); + let batch = FlushBatch { + channel_id, + scope: scope::SessionScope::Conversation { channel_id }, + events: vec![BatchEvent { + event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, }; let agent = dummy_agent(0).await; @@ -11070,7 +11263,7 @@ mod error_outcome_emission_tests { agent, source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), - outcome: PromptOutcome::Error(usage_error), + outcome: PromptOutcome::Error(error), batch: Some(batch), }; handle_prompt_result( diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 23ed454fa2..a019e75834 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -1160,10 +1160,8 @@ struct BgState { /// On reconnect resubscribe, `since` = min(last_seen, channel_dropped_since). /// Cleared per-channel after a successful resubscribe. channel_dropped_since: HashMap, - /// Set by the backpressure handler when the event channel is full. - /// The main loop checks this flag and triggers a proactive resubscribe - /// (without waiting for a disconnect) so dropped events are replayed. - proactive_resubscribe_needed: bool, + /// Rate/fairness bookkeeping only; replay cursors retain baseline semantics. + recovery: recovery::RecoverySchedule, /// Unix timestamp captured just before the relay connection was established. /// Used as the floor `since` for membership notification replay so events /// predating this session are never re-delivered. @@ -1237,7 +1235,7 @@ impl BgState { membership_sub_active: false, observer_control_sub_active: false, channel_dropped_since: HashMap::new(), - proactive_resubscribe_needed: false, + recovery: recovery::RecoverySchedule::default(), startup_watermark: None, subscribe_since: HashMap::new(), rate_limit_gate: None, @@ -1298,6 +1296,9 @@ impl BgState { /// Prevents stale replay on re-subscribe and avoids unbounded state growth /// for channels that are removed and never re-added. fn clear_channel_state(&mut self, channel_id: &Uuid) { + self.recovery + .last_attempt + .remove(&channel_sub_id(*channel_id)); self.last_seen.remove(channel_id); self.subscribe_since.remove(channel_id); self.channel_dropped_since.remove(channel_id); @@ -1826,82 +1827,6 @@ async fn run_background_task( let mut drain_pacing_next: Option = None; loop { - if state.proactive_resubscribe_needed { - state.proactive_resubscribe_needed = false; - info!("proactive resubscribe triggered by backpressure event loss"); - // Proactive resubscribe runs on the EXISTING socket — do NOT clear the - // rate-limit gate or pending queues. - match resubscribe_after_reconnect( - &mut ws, - &mut cmd_rx, - &mut state, - &agent_pubkey_hex, - false, // existing socket — preserve gate state - ) - .await - { - ResubscribeResult::Ok => {} - ResubscribeResult::Shutdown => return, - ResubscribeResult::RetryConnection => { - warn!("proactive resubscribe had failures — triggering reconnect"); - let _ = event_tx.try_send(None); - match try_autonomous_reconnect( - &mut ws, - &mut cmd_rx, - &mut state, - &keys, - &relay_url, - &agent_pubkey_hex, - &event_tx, - &observer_control_tx, - auth_tag.as_ref(), - ) - .await - { - ReconnectOutcome::Ok => { - if matches!( - drain_post_reconnect( - &mut ws, - &mut cmd_rx, - &mut state, - &agent_pubkey_hex - ) - .await, - ReconnectOutcome::Shutdown - ) { - return; - } - } - ReconnectOutcome::Shutdown => return, - ReconnectOutcome::Failed => { - if matches!( - wait_for_reconnect( - &mut ws, - &mut cmd_rx, - &mut state, - &keys, - &relay_url, - &agent_pubkey_hex, - &event_tx, - &observer_control_tx, - true, - auth_tag.as_ref(), - ) - .await, - ReconnectOutcome::Shutdown - ) { - return; - } - } - } - ping_sent = false; - last_pong = Instant::now(); - connected_since = Instant::now(); - stable_logged = false; - } - } - } - // Drain pending subs, one REQ per pacing tick within the relay's // admission window. let drain_window_open = drain_pacing_next.is_none_or(|t| tokio::time::Instant::now() >= t); @@ -1982,7 +1907,11 @@ async fn run_background_task( } } + let recovery_at = recovery::ready_at(&mut state); tokio::select! { + _ = recovery::ready(&event_tx, recovery_at) => { + recovery::recover_one(&mut ws, &mut state, &event_tx, &agent_pubkey_hex).await; + } raw = ws.next() => { // Determine if the socket is lost. let socket_lost = match raw { @@ -2358,12 +2287,10 @@ async fn handle_ws_message( // replay starts early enough to re-deliver it. state.membership_dropped_since = Some(state.membership_dropped_since.map_or(ts, |d| d.min(ts))); - // Proactively trigger resubscribe without waiting for a disconnect. - state.proactive_resubscribe_needed = true; warn!( channel_id = %channel_uuid, ts, - "membership notification dropped (backpressure) — proactive resubscribe queued" + "membership notification dropped (backpressure) — targeted recovery pending" ); } Err(mpsc::error::TrySendError::Closed(_)) => return false, @@ -2400,12 +2327,10 @@ async fn handle_ws_message( .entry(channel_id) .and_modify(|d| *d = (*d).min(ts)) .or_insert(ts); - // Proactively trigger resubscribe without waiting for a disconnect. - state.proactive_resubscribe_needed = true; warn!( channel_id = %channel_id, ts, - "event channel full — dropping event for channel {channel_id} — proactive resubscribe queued" + "event channel full — dropping event for channel {channel_id} — targeted recovery pending" ); } Err(mpsc::error::TrySendError::Closed(_)) => { @@ -4248,6 +4173,11 @@ async fn wait_for_any_ok( } } +mod recovery; + +#[cfg(test)] +mod recovery_tests; + #[cfg(test)] mod tests { use super::*; @@ -4775,7 +4705,7 @@ mod tests { .expect("signing should succeed") } - async fn test_ws_pair() -> (WsStream, WebSocketStream) { + pub(super) async fn test_ws_pair() -> (WsStream, WebSocketStream) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind test websocket"); @@ -4792,7 +4722,7 @@ mod tests { (client, server.await.expect("join test websocket server")) } - async fn next_test_frame( + pub(super) async fn next_test_frame( server: &mut WebSocketStream, ) -> serde_json::Value { let message = timeout(Duration::from_secs(1), server.next()) @@ -5035,14 +4965,14 @@ mod tests { )); } - fn test_channel_filter() -> ChannelFilter { + pub(super) fn test_channel_filter() -> ChannelFilter { ChannelFilter { kinds: Some(vec![9]), require_mention: false, } } - fn seed_test_subscription(state: &mut BgState, channel_id: Uuid) { + pub(super) fn seed_test_subscription(state: &mut BgState, channel_id: Uuid) { apply_command_to_state( state, RelayCommand::Subscribe { diff --git a/crates/buzz-acp/src/relay/recovery.rs b/crates/buzz-acp/src/relay/recovery.rs new file mode 100644 index 0000000000..62da119a4b --- /dev/null +++ b/crates/buzz-acp/src/relay/recovery.rs @@ -0,0 +1,132 @@ +//! Overflow recovery is an attempted replay, not an EOSE/consumer receipt. +//! Keep the existing IDs and cursor retirement rules; bound when work is sent. +use super::*; + +pub(super) const RECOVERY_INTERVAL: Duration = Duration::from_secs(5); + +#[derive(Default)] +pub(super) struct RecoverySchedule { + next_attempt: Option, + pub(super) last_attempt: HashMap, +} + +/// Attempt at most one affected subscription, with space for replay to arrive. +/// Failed writes retain the loss cursor and are paced too. No EOSE is interpreted +/// as completion: overlapping requests keep their existing stable wire IDs. +pub(super) async fn recover_one( + ws: &mut WsStream, + state: &mut BgState, + event_tx: &mpsc::Sender>, + agent_pubkey_hex: &str, +) { + let now = tokio::time::Instant::now(); + if event_tx.is_closed() + || event_tx.capacity() < event_tx.max_capacity().div_ceil(2) + || state.recovery.next_attempt.is_some_and(|next| now < next) + || state.check_rate_gate().is_some() + { + return; + } + + let channel = next_channel(state); + let Some(channel) = channel else { return }; + let sub = channel.map_or_else(|| MEMBERSHIP_NOTIF_SUB_ID.to_owned(), channel_sub_id); + state.recovery.last_attempt.insert(sub.clone(), now); + info!(subscription = sub, "attempting targeted overflow replay"); + + if let Some(ch) = channel { + if let Some(filter) = state.active_filters.get(&ch).cloned() { + let since = state.channel_since(&ch); + if send_subscribe(ws, state, ch, agent_pubkey_hex, since, &filter).await { + // Baseline retirement point: REQ write, NOT proven delivery. + // New overflow after this attempt creates another pending cursor. + state.channel_dropped_since.remove(&ch); + } + } + } else { + let since = match (state.membership_dropped_since, state.membership_last_seen) { + (Some(d), Some(l)) => Some(d.min(l)), + (Some(d), None) => Some(d), + (None, Some(l)) => Some(l), + (None, None) => state.startup_watermark, + }; + if send_membership_subscribe(ws, agent_pubkey_hex, since).await { + state.membership_dropped_since = None; + } + } + // Pace from the end of a potentially backpressured write. No catch-up burst. + // The existing bounded write timeout and read/ping owner detect socket loss. + state.recovery.next_attempt = Some(tokio::time::Instant::now() + RECOVERY_INTERVAL); +} + +/// No timer or capacity waiter when another authority owns all pending loss. +/// Only actual attempts advance the cooldown; closed gates wake at expiry. +pub(super) fn ready_at(state: &mut BgState) -> Option { + next_channel(state)?; + Some( + state + .recovery + .next_attempt + .unwrap_or_else(tokio::time::Instant::now) + .max( + state + .check_rate_gate() + .unwrap_or_else(tokio::time::Instant::now), + ), + ) +} + +/// Select-local readiness, not a send or a reservation carried across reads. +/// The socket task is the sole producer. `select!` drops this future (including +/// partial permits) BEFORE handling another frame/command, so live try_send +/// never competes with a recovery reservation. Receives only add capacity. +/// Keep this future inside select!: awaiting it alone would block the reader; +/// persisting it across iterations would steal capacity from live delivery. +pub(super) async fn ready( + event_tx: &mpsc::Sender>, + at: Option, +) { + if let Some(at) = at { + if tokio::time::Instant::now() < at { + tokio::time::sleep_until(at).await; + } + // Use the channel's own race-free capacity wake, not periodic samples. + // Return ALL permits before recover_one rechecks capacity and intent. + if let Ok(permits) = event_tx + .reserve_many(event_tx.max_capacity().div_ceil(2)) + .await + { + drop(permits); + return; + } + } + // No loss or a closed receiver: no immediate-ready/error wake loop. + std::future::pending::<()>().await; +} + +fn next_channel(state: &BgState) -> Option> { + // One record per active intent, not per loss or per request generation. + // Least recently attempted prevents a repeatedly overflowing channel from + // starving other channels or membership. Missing filters fail closed. + state + .channel_dropped_since + .keys() + .filter(|ch| { + state.active_subscriptions.contains_key(ch) + && state.active_filters.contains_key(ch) + && !state.rate_limited_pending.contains_key(ch) + && !state.resubscribe_retry.contains(ch) + }) + .copied() + .map(Some) + .chain( + (state.membership_sub_active + && state.membership_dropped_since.is_some() + && !state.membership_resub_needed) + .then_some(None), + ) + .min_by_key(|ch| { + let sub = ch.map_or_else(|| MEMBERSHIP_NOTIF_SUB_ID.to_owned(), channel_sub_id); + (state.recovery.last_attempt.get(&sub).copied(), sub) + }) +} diff --git a/crates/buzz-acp/src/relay/recovery_tests.rs b/crates/buzz-acp/src/relay/recovery_tests.rs new file mode 100644 index 0000000000..4e51a0c614 --- /dev/null +++ b/crates/buzz-acp/src/relay/recovery_tests.rs @@ -0,0 +1,388 @@ +//! Bounded synthetic WebSocket fixtures; no relay service, proxy or real agent. +use super::tests::{next_test_frame, seed_test_subscription, test_channel_filter, test_ws_pair}; +use super::*; + +fn fixture_event(channel: Uuid, n: u64, kind: u16) -> Event { + let keys = + Keys::parse("0000000000000000000000000000000000000000000000000000000000000001").unwrap(); + EventBuilder::new(Kind::Custom(kind), format!("synthetic-{n}")) + .tags([Tag::parse(["h", &channel.to_string()]).unwrap()]) + .custom_created_at(nostr::Timestamp::from(1_000 + n)) + .sign_with_keys(&keys) + .unwrap() +} + +async fn dispatch( + client: &mut WsStream, + state: &mut BgState, + tx: &mpsc::Sender>, + frame: Value, +) { + let (control_tx, _control_rx) = mpsc::channel(1); + assert!( + handle_ws_message( + Message::Text(frame.to_string().into()), + client, + tx, + &control_tx, + state, + &Keys::generate(), + "ws://127.0.0.1:1", + "synthetic-agent", + None, + ) + .await + ); +} + +#[tokio::test] +async fn repeated_overflow_recovers_only_affected_channel_after_capacity() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let channels: Vec<_> = (0..18).map(|_| Uuid::new_v4()).collect(); + for ch in &channels { + seed_test_subscription(&mut state, *ch); + } + let ch = channels[0]; + let sub = channel_sub_id(ch); + let (tx, mut rx) = mpsc::channel(256); + // Relay history is newest-first; the oldest dropped event must survive + // a watermark already advanced by much newer successfully-enqueued events. + let events: Vec<_> = (0..320).rev().map(|n| fixture_event(ch, n, 9)).collect(); + for event in &events { + dispatch(&mut client, &mut state, &tx, json!(["EVENT", sub, event])).await; + recovery::recover_one(&mut client, &mut state, &tx, "synthetic-agent").await; + } + assert_eq!(rx.len(), 256); + assert_eq!(state.channel_dropped_since[&ch], 1_000); + assert!(timeout(Duration::from_millis(30), server.next()) + .await + .is_err()); + for event in &events[..256] { + assert_eq!(rx.recv().await.unwrap().unwrap().event.id, event.id); + } + recovery::recover_one(&mut client, &mut state, &tx, "synthetic-agent").await; + let req = next_test_frame(&mut server).await; + assert_eq!(req[0], "REQ"); + assert_eq!(req[1], sub); + assert_eq!(req[2]["#h"], json!([ch.to_string()])); + assert_eq!(req[2]["kinds"], json!([9])); + assert_eq!(req[2]["since"], 995); + // Concurrent timer ticks / duplicate arrivals cannot replace the replay. + for _ in 0..40 { + recovery::recover_one(&mut client, &mut state, &tx, "synthetic-agent").await; + } + assert!(timeout(Duration::from_millis(30), server.next()) + .await + .is_err()); + for event in &events { + dispatch(&mut client, &mut state, &tx, json!(["EVENT", sub, event])).await; + } + dispatch(&mut client, &mut state, &tx, json!(["EOSE", sub])).await; + assert_eq!(rx.len(), 64, "delivered IDs must remain deduplicated"); + for event in &events[256..] { + assert_eq!(rx.recv().await.unwrap().unwrap().event.id, event.id); + } + assert_eq!(state.channel_since(&ch), Some(1_319)); + recovery::recover_one(&mut client, &mut state, &tx, "synthetic-agent").await; + assert!(timeout(Duration::from_millis(30), server.next()) + .await + .is_err()); + let live = fixture_event(ch, 400, 9); + dispatch(&mut client, &mut state, &tx, json!(["EVENT", sub, live])).await; + assert_eq!(rx.recv().await.unwrap().unwrap().event.id, live.id); + println!("18 subscriptions; 320 newest-first arrivals; 64 losses coalesced; 0 REQ while full; 1 targeted REQ; 320 unique deliveries + live"); +} + +#[tokio::test] +async fn socket_owner_services_ping_shutdown_and_coalesces_overflow_ticks() { + let (client, mut server) = test_ws_pair().await; + let (tx, mut rx) = mpsc::channel(1); + let (control_tx, _control_rx) = mpsc::channel(1); + let (cmd_tx, cmd_rx) = mpsc::channel(64); + let task = tokio::spawn(run_background_task( + client, + VecDeque::new(), + tx, + control_tx, + cmd_rx, + Keys::generate(), + "ws://127.0.0.1:1".into(), + "synthetic-agent".into(), + None, + )); + let channels: Vec<_> = (0..18).map(|_| Uuid::new_v4()).collect(); + for ch in &channels { + cmd_tx + .send(RelayCommand::Subscribe { + channel_id: *ch, + filter: test_channel_filter(), + replay_since: Some(1_000), + }) + .await + .unwrap(); + } + let mut subscriptions = 0; + while subscriptions < 18 { + let frame = timeout(Duration::from_secs(2), server.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + match frame { + Message::Text(text) => { + let req: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(req[0], "REQ"); + server + .send(Message::Text(json!(["EOSE", req[1]]).to_string().into())) + .await + .unwrap(); + subscriptions += 1; + } + Message::Ping(payload) => server.send(Message::Pong(payload)).await.unwrap(), + other => panic!("unexpected {other:?}"), + } + } + let sub = channel_sub_id(channels[0]); + for n in 0..40 { + server + .send(Message::Text( + json!(["EVENT", sub, fixture_event(channels[0], n, 9)]) + .to_string() + .into(), + )) + .await + .unwrap(); + } + server.send(Message::Ping(vec![42].into())).await.unwrap(); + timeout(Duration::from_secs(2), async { + loop { + match server.next().await.unwrap().unwrap() { + Message::Ping(payload) => server.send(Message::Pong(payload)).await.unwrap(), + Message::Pong(payload) => { + assert_eq!(payload.as_ref(), &[42]); + break; + } + other => panic!("no immediate all-channel recovery before ping: {other:?}"), + } + } + }) + .await + .unwrap(); + // Wait across a recovery tick with the consumer still full. + assert!(socket_frame(&mut server, Duration::from_millis(5_100)) + .await + .is_none()); + rx.recv().await.unwrap().unwrap(); + let frame = timeout(Duration::from_secs(6), server.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let req: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap(); + assert_eq!(req[1], sub); + assert_eq!(req[2]["since"], 996); + assert!(timeout(Duration::from_millis(100), server.next()) + .await + .is_err()); + // Sustained lag: each replay is followed by another burst, without EOSE. + // Recovery must stay paced, not permanently stall or sweep healthy channels. + for round in 1..=3 { + let started = tokio::time::Instant::now(); + for n in round * 40..(round + 1) * 40 { + server + .send(Message::Text( + json!(["EVENT", sub, fixture_event(channels[0], n, 9)]) + .to_string() + .into(), + )) + .await + .unwrap(); + } + server.send(Message::Ping(vec![43].into())).await.unwrap(); + let pong = timeout(Duration::from_secs(1), server.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!( + matches!(pong, Message::Pong(_)), + "recovery preempted ping: {pong:?}" + ); + rx.recv().await.unwrap().unwrap(); + let frame = timeout(Duration::from_secs(6), server.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let req: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap(); + assert_eq!(req[1], sub, "healthy channels must not be swept"); + assert!( + started.elapsed() >= Duration::from_secs(4), + "unpaced repeat" + ); + } + cmd_tx.send(RelayCommand::Shutdown).await.unwrap(); + timeout(Duration::from_secs(1), task) + .await + .unwrap() + .unwrap(); + println!("socket-owner seam: 18 live REQs, 39 coalesced losses, PONG while full, zero recovery across full-capacity timer tick, four paced targeted REQs over sustained lag without EOSE, responsive shutdown"); +} + +#[tokio::test] +async fn recovery_is_fair_and_paced_even_with_new_loss_and_stale_eose() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let channels = [Uuid::new_v4(), Uuid::new_v4()]; + for ch in channels { + seed_test_subscription(&mut state, ch); + state.channel_dropped_since.insert(ch, 600); + } + state.membership_sub_active = true; + state.membership_dropped_since = Some(500); + let (tx, _rx) = mpsc::channel(1); + let mut visited = HashSet::new(); + for round in 0..9 { + timeout( + Duration::from_millis(100), + recovery::ready(&tx, recovery::ready_at(&mut state)), + ) + .await + .unwrap(); + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + let req = next_test_frame(&mut server).await; + let sub = req[1].as_str().unwrap(); + if round < 3 { + assert!(visited.insert(sub.to_owned()), "starved intent"); + } + if sub == MEMBERSHIP_NOTIF_SUB_ID { + assert_eq!(req[2]["since"], 495); + state.membership_dropped_since = Some(500); + } else { + let ch = channel_id_from_sub_id(sub).unwrap(); + assert_eq!(req[2]["since"], 595); + state.channel_dropped_since.insert(ch, 600); + } + // Neither stale nor current EOSE creates completion state or erases loss. + dispatch(&mut client, &mut state, &tx, json!(["EOSE", sub])).await; + for _ in 0..30 { + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + } + assert!(timeout(Duration::from_millis(1), server.next()) + .await + .is_err()); + advance_clock(recovery::RECOVERY_INTERVAL).await; + } + for ch in channels { + state.active_subscriptions.remove(&ch); + state.clear_channel_state(&ch); + assert!(!state + .recovery + .last_attempt + .contains_key(&channel_sub_id(ch))); + } + assert_eq!(state.recovery.last_attempt.len(), 1); +} + +#[tokio::test] +async fn gate_headroom_failed_writes_and_reconnect_preserve_pending_attempts() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let ch = Uuid::new_v4(); + seed_test_subscription(&mut state, ch); + state.channel_dropped_since.insert(ch, 700); + let (tx, mut rx) = mpsc::channel(4); + for _ in 0..3 { + tx.try_send(None).unwrap(); + } + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + assert!(state.recovery.last_attempt.is_empty()); + rx.recv().await; + state.rate_limit_gate = Some(tokio::time::Instant::now() + Duration::from_secs(10)); + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + assert!(state.recovery.last_attempt.is_empty()); + advance_clock(Duration::from_secs(10)).await; + // Close locally, so the actual production writer fails deterministically. + client.close(None).await.unwrap(); + server.next().await; + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + assert_eq!(state.channel_dropped_since[&ch], 700); + let attempted = state.recovery.last_attempt.clone(); + for _ in 0..30 { + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + } + assert_eq!(state.recovery.last_attempt, attempted); + advance_clock(recovery::RECOVERY_INTERVAL).await; + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + assert_ne!( + state.recovery.last_attempt, attempted, + "failed write must be retried" + ); + assert_eq!(state.channel_dropped_since[&ch], 700); + + let (mut client, mut server) = test_ws_pair().await; + let (_cmd_tx, mut cmd_rx) = mpsc::channel(1); + assert!(matches!( + resubscribe_after_reconnect(&mut client, &mut cmd_rx, &mut state, "agent", true,).await, + ResubscribeResult::Ok + )); + let req = next_test_frame(&mut server).await; + assert_eq!(req[1], channel_sub_id(ch)); + assert_eq!(req[2]["since"], 695); + assert!(!state.channel_dropped_since.contains_key(&ch)); + // This is deliberately the baseline write-retirement contract, not a receipt. +} + +async fn advance_clock(duration: Duration) { + tokio::time::pause(); + tokio::time::advance(duration).await; + tokio::time::resume(); +} + +#[tokio::test] +async fn blocked_recovery_write_is_bounded_and_retains_loss() { + let (mut client, _stalled_server) = test_ws_pair().await; + let mut state = BgState::new(); + let ch = Uuid::new_v4(); + seed_test_subscription(&mut state, ch); + // Bounded 16MB JSON request exceeds loopback TCP buffering. The server does + // not read it. This tests the real production write/timeout, not a mock sink. + state.active_filters.get_mut(&ch).unwrap().kinds = Some(vec![9; 8_000_000]); + state.channel_dropped_since.insert(ch, 700); + let (tx, _rx) = mpsc::channel(1); + let started = tokio::time::Instant::now(); + timeout( + Duration::from_secs(15), + recovery::recover_one(&mut client, &mut state, &tx, "agent"), + ) + .await + .unwrap(); + assert!(started.elapsed() >= Duration::from_secs(WS_SEND_TIMEOUT_SECS)); + assert_eq!(state.channel_dropped_since[&ch], 700); + let attempted = state.recovery.last_attempt.clone(); + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + assert_eq!(state.recovery.last_attempt, attempted); +} + +// Keep the fixture responsive to independent client keepalives while checking +// recovery traffic. Wall-clock scheduling may deliver the initial ping late. +async fn socket_frame( + server: &mut WebSocketStream, + duration: Duration, +) -> Option { + let deadline = tokio::time::Instant::now() + duration; + loop { + match tokio::time::timeout_at(deadline, server.next()).await { + Err(_) => return None, + Ok(Some(Ok(Message::Ping(payload)))) => { + server.send(Message::Pong(payload)).await.unwrap(); + } + Ok(Some(Ok(frame))) => return Some(frame), + other => panic!("unexpected socket state: {other:?}"), + } + } +} + +#[path = "recovery_wake_tests.rs"] +mod wake; diff --git a/crates/buzz-acp/src/relay/recovery_wake_tests.rs b/crates/buzz-acp/src/relay/recovery_wake_tests.rs new file mode 100644 index 0000000000..de27c7d06c --- /dev/null +++ b/crates/buzz-acp/src/relay/recovery_wake_tests.rs @@ -0,0 +1,507 @@ +//! Capacity-wake boundaries and the independently reproduced R1 schedule. +use super::*; + +// Reviewer-authored, exact-source comparison of recurring headroom at the socket owner. +// A small periodically refilled queue is empty for most of each five-second period. +async fn review_count_until( + server: &mut WebSocketStream, + deadline: tokio::time::Instant, +) -> usize { + let mut count = 0; + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + match socket_frame(server, remaining).await { + None => break, + Some(Message::Text(text)) => { + let frame: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(frame[0], "REQ"); + count += 1; + } + other => panic!("unexpected {other:?}"), + } + } + count +} + +async fn review_barrier(server: &mut WebSocketStream) -> usize { + server.send(Message::Ping(vec![77].into())).await.unwrap(); + let mut count = 0; + loop { + match socket_frame(server, Duration::from_secs(2)).await.unwrap() { + Message::Pong(payload) => { + assert_eq!(payload.as_ref(), &[77]); + return count; + } + Message::Text(text) => { + let frame: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(frame[0], "REQ"); + count += 1; + } + other => panic!("unexpected {other:?}"), + } + } +} + +#[tokio::test] +async fn review_recurring_headroom_between_ticks_gets_an_attempt() { + let (client, mut server) = test_ws_pair().await; + let (tx, mut rx) = mpsc::channel(1); + let (control_tx, _control_rx) = mpsc::channel(1); + let (cmd_tx, cmd_rx) = mpsc::channel(8); + let start = tokio::time::Instant::now(); + let task = tokio::spawn(run_background_task( + client, + VecDeque::new(), + tx, + control_tx, + cmd_rx, + Keys::generate(), + "ws://127.0.0.1:1".into(), + "agent".into(), + None, + )); + let ch = Uuid::new_v4(); + let sub = channel_sub_id(ch); + cmd_tx + .send(RelayCommand::Subscribe { + channel_id: ch, + filter: test_channel_filter(), + replay_since: Some(1000), + }) + .await + .unwrap(); + let frame = socket_frame(&mut server, Duration::from_secs(2)) + .await + .unwrap(); + let req: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap(); + assert_eq!(req[1], sub); + let lost = fixture_event(ch, 1, 9); + for event in [fixture_event(ch, 0, 9), lost.clone()] { + server + .send(Message::Text( + json!(["EVENT", sub, event]).to_string().into(), + )) + .await + .unwrap(); + } + let mut attempts = review_barrier(&mut server).await; + assert_eq!(rx.len(), 1); + rx.recv().await.unwrap().unwrap(); + for round in 1..=4 { + // Headroom until 1s before the tick; then only one live arrival, no new + // overflow. The queue stays full across the tick and drains 0.5s later. + attempts += review_count_until( + &mut server, + start + Duration::from_millis(round * 5000 - 1000), + ) + .await; + assert_eq!(rx.len(), 0); + server + .send(Message::Text( + json!(["EVENT", sub, fixture_event(ch, 10 + round, 9)]) + .to_string() + .into(), + )) + .await + .unwrap(); + attempts += review_barrier(&mut server).await; + assert_eq!(rx.len(), 1); + attempts += review_count_until( + &mut server, + start + Duration::from_millis(round * 5000 + 500), + ) + .await; + rx.recv().await.unwrap().unwrap(); + } + println!("REVIEW periodic consumer: 4 full-at-tick windows, empty >=3.5s each period, recovery_requests={attempts}"); + assert_eq!( + attempts, 1, + "recurring headroom must not strand the first attempt" + ); + // Return the missing event using the actual requested stable subscription. + server + .send(Message::Text( + json!(["EVENT", sub, lost]).to_string().into(), + )) + .await + .unwrap(); + assert_eq!(review_barrier(&mut server).await, 0); + assert_eq!(rx.recv().await.unwrap().unwrap().event.id, lost.id); + let after = review_count_until(&mut server, start + Duration::from_millis(25_500)).await; + assert_eq!( + after, 0, + "no timer churn or extra requests after successful write" + ); + println!( + "REVIEW continuous-headroom control: additional_requests={after}; missing event delivered" + ); + cmd_tx.send(RelayCommand::Shutdown).await.unwrap(); + timeout(Duration::from_secs(2), task) + .await + .unwrap() + .unwrap(); +} + +/// Same real socket owner as production, with no control of its internal state. +struct Owner { + server: WebSocketStream, + rx: mpsc::Receiver>, + cmd: mpsc::Sender, + task: tokio::task::JoinHandle<()>, + ch: Uuid, +} + +impl Owner { + async fn new(capacity: usize) -> Self { + let (client, server) = test_ws_pair().await; + let (tx, rx) = mpsc::channel(capacity); + let (control_tx, _control_rx) = mpsc::channel(1); + let (cmd, cmd_rx) = mpsc::channel(8); + let task = tokio::spawn(run_background_task( + client, + VecDeque::new(), + tx, + control_tx, + cmd_rx, + Keys::generate(), + "ws://127.0.0.1:1".into(), + "agent".into(), + None, + )); + let mut owner = Self { + server, + rx, + cmd, + task, + ch: Uuid::new_v4(), + }; + owner.subscribe().await; + owner + } + + async fn subscribe(&mut self) { + self.cmd + .send(RelayCommand::Subscribe { + channel_id: self.ch, + filter: test_channel_filter(), + replay_since: Some(1000), + }) + .await + .unwrap(); + let req = self.request(Duration::from_secs(2)).await; + assert_eq!(req[1], channel_sub_id(self.ch)); + } + + async fn event(&mut self, n: u64) { + self.server + .send(Message::Text( + json!([ + "EVENT", + channel_sub_id(self.ch), + fixture_event(self.ch, n, 9) + ]) + .to_string() + .into(), + )) + .await + .unwrap(); + } + + async fn request(&mut self, duration: Duration) -> Value { + let frame = socket_frame(&mut self.server, duration) + .await + .expect("recovery not woken"); + let req: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap(); + assert_eq!(req[0], "REQ"); + req + } + + async fn shutdown(self) { + self.cmd.send(RelayCommand::Shutdown).await.unwrap(); + timeout(Duration::from_secs(1), self.task) + .await + .unwrap() + .unwrap(); + } +} + +#[tokio::test] +async fn capacity_flapping_cannot_storm_or_delay_an_allowed_attempt() { + let mut owner = Owner::new(1).await; + owner.event(0).await; + owner.event(1).await; + assert_eq!(review_barrier(&mut owner.server).await, 0); + owner.rx.recv().await.unwrap(); + let first = owner.request(Duration::from_millis(500)).await; + let first_at = tokio::time::Instant::now(); + assert_eq!(first[2]["since"], 996); + // New loss, then rapid full/empty transitions during the attempt cooldown. + // No socket activity or capacity transition may reset or bypass that bound. + for n in 1..=20 { + owner.event(n * 2).await; + owner.event(n * 2 + 1).await; + assert_eq!(review_barrier(&mut owner.server).await, 0); + owner.rx.recv().await.unwrap(); + assert!(socket_frame(&mut owner.server, Duration::from_millis(10)) + .await + .is_none()); + } + let req = owner.request(Duration::from_secs(6)).await; + assert_eq!(req[1], channel_sub_id(owner.ch)); + // Arrival timestamps approximate send completion; leave tolerance for TCP. + assert!(first_at.elapsed() >= Duration::from_millis(4_900)); + assert!(first_at.elapsed() < Duration::from_secs(6)); + assert_eq!(req[2]["since"], 998); + assert!(socket_frame(&mut owner.server, Duration::from_millis(100)) + .await + .is_none()); + owner.shutdown().await; +} + +#[tokio::test] +async fn partial_capacity_wait_does_not_steal_live_slots_and_cancels_on_unsubscribe() { + let mut owner = Owner::new(5).await; // odd capacity: threshold rounds UP to 3 + for n in 0..6 { + owner.event(n).await; + } + assert_eq!(review_barrier(&mut owner.server).await, 0); + owner.rx.recv().await.unwrap(); // partial reservation, insufficient for replay + assert!(socket_frame(&mut owner.server, Duration::from_millis(30)) + .await + .is_none()); + owner.event(6).await; + assert_eq!(review_barrier(&mut owner.server).await, 0); + assert_eq!( + owner.rx.len(), + 5, + "select must release partial permits BEFORE try_send" + ); + for _ in 0..2 { + owner.rx.recv().await.unwrap(); + } + assert!(socket_frame(&mut owner.server, Duration::from_millis(30)) + .await + .is_none()); + owner.rx.recv().await.unwrap(); // exactly three slots free, prompt capacity wake + let req = owner.request(Duration::from_millis(500)).await; + assert_eq!(req[2]["since"], 1000); + assert_eq!( + owner + .rx + .recv() + .await + .unwrap() + .unwrap() + .event + .created_at + .as_secs(), + 1004 + ); + assert_eq!( + owner + .rx + .recv() + .await + .unwrap() + .unwrap() + .event + .created_at + .as_secs(), + 1006 + ); + + // Wait out cooldown then create another pending loss and partial reservation. + tokio::time::sleep(recovery::RECOVERY_INTERVAL).await; + for n in 7..13 { + owner.event(n).await; + } + assert_eq!(review_barrier(&mut owner.server).await, 0); + owner.rx.recv().await.unwrap(); + assert!(socket_frame(&mut owner.server, Duration::from_millis(30)) + .await + .is_none()); + owner + .cmd + .send(RelayCommand::Unsubscribe { + channel_id: owner.ch, + }) + .await + .unwrap(); + let close = socket_frame(&mut owner.server, Duration::from_secs(1)) + .await + .unwrap(); + let close: Value = serde_json::from_str(close.to_text().unwrap()).unwrap(); + assert_eq!(close[0], "CLOSE"); + while owner.rx.try_recv().is_ok() {} + assert!(socket_frame(&mut owner.server, Duration::from_millis(100)) + .await + .is_none()); + owner.subscribe().await; + assert!(socket_frame(&mut owner.server, Duration::from_millis(100)) + .await + .is_none()); + // A re-added intent can record and recover fresh loss immediately. + for n in 20..26 { + owner.event(n).await; + } + assert_eq!(review_barrier(&mut owner.server).await, 0); + while owner.rx.try_recv().is_ok() {} + let req = owner.request(Duration::from_millis(500)).await; + assert_eq!(req[2]["since"], 1020); + owner.shutdown().await; +} + +#[tokio::test] +async fn shutdown_and_transport_loss_cancel_a_capacity_wait() { + let mut owner = Owner::new(1).await; + owner.event(0).await; + owner.event(1).await; + assert_eq!(review_barrier(&mut owner.server).await, 0); + // Full queue cannot block commands or processing an actual socket close. + owner.server.close(None).await.unwrap(); + owner.shutdown().await; + let mut owner = Owner::new(1).await; + owner.event(0).await; + owner.event(1).await; + assert_eq!(review_barrier(&mut owner.server).await, 0); + owner.shutdown().await; +} + +#[tokio::test] +async fn readiness_gate_ownership_and_attempt_deadlines_are_not_polling_ticks() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let ch = Uuid::new_v4(); + seed_test_subscription(&mut state, ch); + let (tx, mut rx) = mpsc::channel(4); + assert!(recovery::ready_at(&mut state).is_none()); + state.channel_dropped_since.insert(ch, 700); + state + .rate_limited_pending + .insert(ch, tokio::time::Instant::now()); + assert!(recovery::ready_at(&mut state).is_none()); + state.rate_limited_pending.clear(); + state.resubscribe_retry.insert(ch); + assert!(recovery::ready_at(&mut state).is_none()); + state.resubscribe_retry.clear(); + state.rate_limit_gate = Some(tokio::time::Instant::now() + Duration::from_millis(80)); + let at = recovery::ready_at(&mut state); + assert_eq!(at, state.rate_limit_gate); + assert!(timeout(Duration::from_millis(20), recovery::ready(&tx, at)) + .await + .is_err()); + timeout(Duration::from_millis(200), recovery::ready(&tx, at)) + .await + .unwrap(); + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + next_test_frame(&mut server).await; + state.channel_dropped_since.insert(ch, 800); + let at = recovery::ready_at(&mut state).unwrap(); + let remaining = at.saturating_duration_since(tokio::time::Instant::now()); + assert!(remaining > Duration::from_millis(4900)); + // Neither new loss nor an intervening gate shorter than cooldown delays it. + state.rate_limit_gate = Some(tokio::time::Instant::now() + Duration::from_millis(10)); + assert_eq!(recovery::ready_at(&mut state), Some(at)); + state.rate_limit_gate = Some(at + Duration::from_secs(1)); + assert_eq!(recovery::ready_at(&mut state), state.rate_limit_gate); + advance_clock(Duration::from_secs(6)).await; + // Cancellation releases partial permits. No hidden reservation survives it. + for _ in 0..3 { + tx.try_send(None).unwrap(); + } + assert!(timeout( + Duration::from_millis(20), + recovery::ready(&tx, recovery::ready_at(&mut state)) + ) + .await + .is_err()); + assert_eq!(tx.capacity(), 1); + rx.recv().await.unwrap(); + timeout( + Duration::from_millis(100), + recovery::ready(&tx, recovery::ready_at(&mut state)), + ) + .await + .unwrap(); + assert_eq!(tx.capacity(), 2); + drop(rx); + assert!(timeout( + Duration::from_millis(20), + recovery::ready(&tx, recovery::ready_at(&mut state)) + ) + .await + .is_err()); +} + +#[tokio::test] +async fn readiness_uses_channel_wakes_without_idle_churn_or_lost_capacity() { + use std::future::Future; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + use std::task::{Context, Wake, Waker}; + #[derive(Default)] + struct Wakes(AtomicUsize); + impl Wake for Wakes { + fn wake(self: Arc) { + self.0.fetch_add(1, Ordering::SeqCst); + } + fn wake_by_ref(self: &Arc) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + let wakes = Arc::new(Wakes::default()); + let waker = Waker::from(wakes.clone()); + let mut cx = Context::from_waker(&waker); + let (tx, mut rx) = mpsc::channel(4); + for _ in 0..4 { + tx.try_send(None).unwrap(); + } + let mut idle = Box::pin(recovery::ready(&tx, None)); + assert!(idle.as_mut().poll(&mut cx).is_pending()); + rx.recv().await.unwrap(); + rx.recv().await.unwrap(); + assert_eq!( + wakes.0.load(Ordering::SeqCst), + 0, + "no loss: no capacity subscription" + ); + drop(idle); + // Capacity freed before registration cannot be lost; ready on first poll. + let now = Some(tokio::time::Instant::now()); + let mut ready = Box::pin(recovery::ready(&tx, now)); + assert!(ready.as_mut().poll(&mut cx).is_ready()); + assert_eq!(tx.capacity(), 2, "successful readiness returns all permits"); + drop(ready); + for _ in 0..2 { + tx.try_send(None).unwrap(); + } + let mut ready = Box::pin(recovery::ready(&tx, now)); + assert!(ready.as_mut().poll(&mut cx).is_pending()); + assert_eq!(wakes.0.load(Ordering::SeqCst), 0); + rx.recv().await.unwrap(); + assert_eq!( + wakes.0.load(Ordering::SeqCst), + 0, + "below threshold: do not wake" + ); + rx.recv().await.unwrap(); + assert_eq!( + wakes.0.load(Ordering::SeqCst), + 1, + "threshold: channel wakes its waiter" + ); + assert!(ready.as_mut().poll(&mut cx).is_ready()); + assert_eq!(tx.capacity(), 2); + drop(ready); + drop(rx); + let mut closed = Box::pin(recovery::ready(&tx, now)); + let before = wakes.0.load(Ordering::SeqCst); + assert!(closed.as_mut().poll(&mut cx).is_pending()); + assert_eq!( + wakes.0.load(Ordering::SeqCst), + before, + "closed: no self-wake loop" + ); +} diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 66a8ff9e7c..082522b123 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -743,8 +743,21 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc { conn.send(RelayMessage::ok(&event_id_hex, true, "")); } - Err(message) => { - reject("invalid"); + Err(e) => { + // Rejections carry the ingest taxonomy so backend failures + // count as `error` — a Redis presence-storage outage is a + // server fault, not client misbehavior — while genuine + // client-input rejections (bad signature, non-member + // sender) stay `invalid`. The ephemeral handler only emits + // fixed, sanitized message strings, so unlike the + // persistent arm below, `Internal` text is safe to forward + // verbatim. + let (message, reason) = match e { + IngestError::Rejected(message) => (message, "invalid"), + IngestError::AuthFailed(message) => (message, "auth"), + IngestError::Internal(message) => (message, "error"), + }; + reject(reason); conn.send(RelayMessage::ok(&event_id_hex, false, &message)); } } @@ -792,6 +805,12 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc, state: Arc, -) -> Result<(), String> { +) -> Result<(), IngestError> { let event_clone = event.clone(); let event_id = event.id.to_hex(); let verify_result = tokio::task::spawn_blocking(move || verify_event(&event_clone)).await; match verify_result { Ok(Ok(())) => {} - Ok(Err(e)) => return Err(format!("invalid: {e}")), - Err(_) => return Err("error: internal error".to_string()), + Ok(Err(e)) => return Err(IngestError::Rejected(format!("invalid: {e}"))), + Err(_) => return Err(IngestError::Internal("error: internal error".to_string())), } // Special handling for presence events (kind:20001). @@ -829,16 +848,45 @@ async fn handle_ephemeral_event( raw }; + // Presence mutation is the inclusion contract for the live fan-out + // below: a client that observes the fanned-out event treats a later + // snapshot as reflecting it (see `synthesize_presence` in + // `api/bridge.rs`, which reads Redis). If the mutation failed we + // published nothing — so we must also fan out nothing and reject the + // ACK, or a snapshot later "confirms" stale storage over a live event + // the sender believes was delivered. if status == "offline" { - let _ = state + if let Err(e) = state .pubsub .clear_presence(&conn.tenant, &auth_pubkey) - .await; - } else { - let _ = state - .pubsub - .set_presence(&conn.tenant, &auth_pubkey, &status) - .await; + .await + { + warn!( + conn_id = %conn_id, + event_id = %event_id, + "Presence clear failed, refusing publish and fan-out: {e}" + ); + // Internal, not Rejected: a storage outage is a server + // failure, so the dispatcher must count it under the + // `error` reason, not as client-invalid input. + return Err(IngestError::Internal( + "error: presence storage unavailable".to_string(), + )); + } + } else if let Err(e) = state + .pubsub + .set_presence(&conn.tenant, &auth_pubkey, &status) + .await + { + warn!( + conn_id = %conn_id, + event_id = %event_id, + "Presence set failed, refusing publish and fan-out: {e}" + ); + // Internal for the same reason as the clear arm above. + return Err(IngestError::Internal( + "error: presence storage unavailable".to_string(), + )); } // Presence is a channel-less ephemeral event. After updating Redis @@ -848,8 +896,12 @@ async fn handle_ephemeral_event( // Check channel membership before publishing other ephemeral events. if let Some(ch_id) = super::ingest::extract_channel_id(&event) { + // Membership refusals are client-input rejections, and the shared + // gate's message text is surfaced verbatim exactly as before this + // typed classification; no behavior change on this path. super::ingest::check_channel_membership(&conn.tenant, &state, ch_id, &pubkey_bytes, None) - .await?; + .await + .map_err(IngestError::Rejected)?; // Mark as local before Redis publish to prevent double-delivery when // the event comes back through the Redis subscriber loop. @@ -1435,6 +1487,321 @@ mod tests { ); } + // PostgreSQL/Redis tests are discovered by the isolated postgres-ci lane. + mod presence_storage_postgres_tests { + use super::*; + use buzz_core::{CommunityId, TenantContext}; + use nostr::Filter; + + async fn exercise(storage_available: bool, statuses: &[&str]) { + let redis_url = std::env::var("REDIS_URL").expect("test Redis URL required"); + // Pick an unused local endpoint for a real connection failure. + let dead_socket = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let dead_port = dead_socket.local_addr().unwrap().port(); + drop(dead_socket); + let dead_url = format!("redis://127.0.0.1:{dead_port}"); + let state = fanout_access::test_state_with_redis_url(if storage_available { + &redis_url + } else { + &dead_url + }) + .await; + let pool = sqlx::PgPool::connect(&state.config.database_url) + .await + .unwrap(); + let community_uuid = Uuid::new_v4(); + let host = format!("presence-storage-{}.example", community_uuid.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_uuid) + .bind(&host) + .execute(&pool) + .await + .expect("seed active community"); + let tenant = TenantContext::resolved(CommunityId::from_uuid(community_uuid), host); + let keys = Keys::generate(); + let (send_tx, mut send_rx) = mpsc::channel(10); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(10); + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: Uuid::new_v4(), + tenant: tenant.clone(), + remote_addr: "127.0.0.1:1234".parse().unwrap(), + auth_state: RwLock::new(crate::connection::AuthState::Authenticated( + buzz_auth::AuthContext { + pubkey: keys.public_key(), + scopes: vec![], + channel_ids: None, + auth_method: buzz_auth::AuthMethod::Nip42, + agent_owner_pubkey: None, + }, + )), + subscriptions: Arc::new(Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + cancel: CancellationToken::new(), + backpressure_count: Arc::new(AtomicU8::new(0)), + grace_limit: 3, + }); + let watcher = Uuid::new_v4(); + let (tx, mut rx) = mpsc::channel(10); + let (ctrl, _ctrl_rx) = mpsc::channel(10); + state.conn_manager.register( + watcher, + tx, + ctrl, + None, + CancellationToken::new(), + tenant.community(), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + state.sub_registry.register_scoped( + tenant.community(), + watcher, + "presence".into(), + vec![Filter::new().kind(Kind::Custom(KIND_PRESENCE_UPDATE as u16))], + None, + ); + // Online followed by offline also proves DEL removes an existing value. + for &status in statuses { + let event = EventBuilder::new(Kind::Custom(KIND_PRESENCE_UPDATE as u16), status) + .sign_with_keys(&keys) + .unwrap(); + super::super::handle_event(event.clone(), conn.clone(), state.clone()).await; + let axum::extract::ws::Message::Text(text) = send_rx.try_recv().expect("ACK") + else { + panic!("expected text ACK"); + }; + let ack: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(ack[0], "OK"); + assert_eq!(ack[1], event.id.to_hex()); + assert_eq!(ack[2], storage_available); + if storage_available { + assert_eq!(ack[3], ""); + let stored = state + .pubsub + .get_presence(&tenant, &keys.public_key()) + .await + .unwrap(); + assert_eq!(stored.as_deref(), (status != "offline").then_some(status)); + let axum::extract::ws::Message::Text(text) = + rx.try_recv().expect("live fanout") + else { + panic!("expected text event"); + }; + let frame: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(frame[0], "EVENT"); + assert_eq!(frame[2]["id"], event.id.to_hex()); + } else { + assert_eq!(ack[3], "error: presence storage unavailable"); + assert!(state + .local_event_ids + .get(&(tenant.community(), event.id.to_bytes())) + .is_none()); + } + assert!(rx.try_recv().is_err(), "no extra or rejected-event fanout"); + assert!(send_rx.try_recv().is_err(), "exactly one ACK"); + } + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_uuid) + .execute(&pool) + .await + .unwrap(); + } + + #[tokio::test] + #[ignore = "requires PostgreSQL and Redis"] + async fn rejects_online_when_presence_storage_fails() { + exercise(false, &["online"]).await; + } + + #[tokio::test] + #[ignore = "requires PostgreSQL and Redis"] + async fn rejects_offline_when_presence_storage_fails() { + exercise(false, &["offline"]).await; + } + + #[tokio::test] + #[ignore = "requires PostgreSQL and Redis"] + async fn accepts_stores_and_fans_out_online_and_offline() { + exercise(true, &["online", "offline"]).await; + } + + /// Production-seam regression for the rejection classification: a + /// Redis presence-storage outage must count as a server `error`, + /// never as client `invalid`, while a genuinely invalid event + /// through the same seam stays `invalid`. Both rejections still ACK + /// `false` with no fan-out and no local-event marker (the shared + /// storage guard is unchanged); only the counter routing is under + /// test here. + /// + /// The recorder guard form (not `metrics::with_local_recorder`, + /// whose closure cannot host an await) keeps the thread-local + /// recorder installed across the `.await` points of this + /// single-threaded test runtime — the same convention as the + /// buzz-db route-decision counter tests. The isolated postgres-ci + /// lane runs each test in its own nextest process, so no parallel + /// test can race this counter snapshot. + #[tokio::test] + #[ignore = "requires PostgreSQL"] + async fn counts_presence_storage_failure_as_error_not_invalid() { + // Pick an unused local endpoint for a real connection failure. + let dead_socket = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let dead_port = dead_socket.local_addr().unwrap().port(); + drop(dead_socket); + let dead_url = format!("redis://127.0.0.1:{dead_port}"); + let state = fanout_access::test_state_with_redis_url(&dead_url).await; + let pool = sqlx::PgPool::connect(&state.config.database_url) + .await + .unwrap(); + let community_uuid = Uuid::new_v4(); + let host = format!("presence-metrics-{}.example", community_uuid.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_uuid) + .bind(&host) + .execute(&pool) + .await + .expect("seed active community"); + let tenant = TenantContext::resolved(CommunityId::from_uuid(community_uuid), host); + let keys = Keys::generate(); + let (send_tx, mut send_rx) = mpsc::channel(10); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(10); + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: Uuid::new_v4(), + tenant: tenant.clone(), + remote_addr: "127.0.0.1:1234".parse().unwrap(), + auth_state: RwLock::new(crate::connection::AuthState::Authenticated( + buzz_auth::AuthContext { + pubkey: keys.public_key(), + scopes: vec![], + channel_ids: None, + auth_method: buzz_auth::AuthMethod::Nip42, + agent_owner_pubkey: None, + }, + )), + subscriptions: Arc::new(Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + cancel: CancellationToken::new(), + backpressure_count: Arc::new(AtomicU8::new(0)), + grace_limit: 3, + }); + // Same watcher registration as the ACK/fan-out cases, proving + // the storage failure still reaches no subscriber while its + // rejection is counted under `error`. + let watcher = Uuid::new_v4(); + let (tx, mut rx) = mpsc::channel(10); + let (ctrl, _ctrl_rx) = mpsc::channel(10); + state.conn_manager.register( + watcher, + tx, + ctrl, + None, + CancellationToken::new(), + tenant.community(), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + state.sub_registry.register_scoped( + tenant.community(), + watcher, + "presence".into(), + vec![Filter::new().kind(Kind::Custom(KIND_PRESENCE_UPDATE as u16))], + None, + ); + + let valid = EventBuilder::new(Kind::Custom(KIND_PRESENCE_UPDATE as u16), "online") + .sign_with_keys(&keys) + .unwrap(); + // Invalid control through the same production seam: identical + // event, signature no longer verifies. The id is unchanged (it + // does not cover the signature), so ACKs are told apart by + // order and message text below. + use nostr::JsonUtil as _; + let mut json: serde_json::Value = + serde_json::from_str(&valid.as_json()).expect("parse event json"); + json["sig"] = serde_json::Value::String("0".repeat(128)); + let tampered = nostr::Event::from_json(json.to_string()).expect("parse tampered"); + + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let acks: Vec = { + let _guard = metrics::set_default_local_recorder(&recorder); + super::super::handle_event(valid.clone(), conn.clone(), state.clone()).await; + super::super::handle_event(tampered, conn.clone(), state.clone()).await; + (0..2) + .map(|_| { + let axum::extract::ws::Message::Text(text) = + send_rx.try_recv().expect("ACK") + else { + panic!("expected text ACK"); + }; + serde_json::from_str(&text).unwrap() + }) + .collect() + }; + + // Storage failure: rejected ACK, no fan-out frame, no marker. + assert_eq!(acks[0][0], "OK"); + assert_eq!(acks[0][1], valid.id.to_hex()); + assert_eq!(acks[0][2], false); + assert_eq!(acks[0][3], "error: presence storage unavailable"); + // Invalid control through the same dispatcher arm. + assert_eq!(acks[1][0], "OK"); + assert_eq!(acks[1][1], valid.id.to_hex()); + assert_eq!(acks[1][2], false); + assert!( + acks[1][3].as_str().unwrap().starts_with("invalid:"), + "tampered control must stay an invalid rejection, got: {}", + acks[1][3] + ); + assert!(rx.try_recv().is_err(), "no fan-out for either rejection"); + assert!(send_rx.try_recv().is_err(), "exactly two ACKs"); + assert!(state + .local_event_ids + .get(&(tenant.community(), valid.id.to_bytes())) + .is_none()); + + let mut rejections: Vec<(String, String, u64)> = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter(|(key, ..)| key.key().name() == "buzz_events_rejected_total") + .map(|(key, _, _, value)| { + let metrics_util::debugging::DebugValue::Counter(count) = value else { + panic!("buzz_events_rejected_total must be a counter"); + }; + let labels: Vec<_> = key.key().labels().collect(); + let label = |name: &str| { + labels + .iter() + .find(|l| l.key() == name) + .map(|l| l.value().to_owned()) + .unwrap_or_default() + }; + (label("transport"), label("reason"), count) + }) + .collect(); + rejections.sort(); + assert_eq!( + rejections, + vec![ + ("ws".to_owned(), "error".to_owned(), 1), + ("ws".to_owned(), "invalid".to_owned(), 1), + ], + "storage outage must count as reason=\"error\" and the tampered \ + control as reason=\"invalid\"; got {rejections:?}" + ); + + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_uuid) + .execute(&pool) + .await + .unwrap(); + } + } + mod pubsub_fanout { use std::collections::HashMap; use std::sync::atomic::AtomicU8; diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index aad2580dad..76f27b6176 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -142,6 +142,8 @@ export default defineConfig({ "**/drafts-screenshots.spec.ts", "**/drafts-all-fix-screenshots.spec.ts", "**/inbox-refactor-screenshots.spec.ts", + "**/inbox-title-overlap.spec.ts", + "**/message-author-overlap.spec.ts", "**/buzz-theme-screenshots.spec.ts", "**/appearance-previews.spec.ts", "**/channel-sort.spec.ts", diff --git a/desktop/scripts/check-pubkey-truncation.mjs b/desktop/scripts/check-pubkey-truncation.mjs index d65db13545..67ce2a9091 100644 --- a/desktop/scripts/check-pubkey-truncation.mjs +++ b/desktop/scripts/check-pubkey-truncation.mjs @@ -6,8 +6,10 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); // Truncated pubkey prefixes are forgeable (vanity grinding), so all display -// truncation goes through the canonical `truncatePubkey` / `` — this -// guard keeps ad-hoc `pubkey.slice(0, N)` forms from fragmenting again. +// truncation goes through the canonical `truncateNpub` (identity keys — +// compact npub), `truncatePubkey` (generic hex identifiers such as event and +// blob IDs), or `` — this guard keeps ad-hoc `pubkey.slice(0, N)` +// forms from fragmenting again. const rules = [ { root: "src", diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index ccca7c4abf..99dcc98145 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1167,7 +1167,7 @@ mod tests { // Simulate the minimum supported adapter version. std::fs::write( &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.7'\nexit 0\n", + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.10.0'\nexit 0\n", ) .expect("write script"); std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) @@ -1189,10 +1189,10 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let bin = dir.path().join("codex-acp"); - // A 1.x adapter below MIN_CODEX_ACP_VERSION must still be reinstalled. + // The observed adapter bundles Codex 0.148.x and must be upgraded. std::fs::write( &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.5'\nexit 0\n", + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.6.2'\nexit 0\n", ) .expect("write script"); std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index ec2357b85e..8852fcb7e0 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -11,19 +11,46 @@ use crate::{ relay::{self, relay_api_base_url_with_override, relay_ws_url_with_override}, }; -/// Encode `pubkey` as npub bech32 and truncate it for display: first 10 chars -/// + "…" + last 4 chars. Returns the full bech32 when it is 16 chars or fewer. +/// Encode `pubkey` as npub bech32 and truncate it for display: first 8 +/// chars, an ellipsis, then the last 4 chars, mirroring the frontend +/// `truncateNpub` compact policy (`first8…last4` of the whole npub string). +/// Returns the full bech32 when it is 12 chars or fewer, mirroring +/// `truncatePubkey`'s short-string threshold. fn truncated_display_name(pubkey: &PublicKey) -> Result { let bech32 = pubkey .to_bech32() .map_err(|error| format!("bech32 encode failed: {error}"))?; - Ok(if bech32.len() > 16 { - format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..]) + Ok(if bech32.len() > 12 { + format!("{}…{}", &bech32[..8], &bech32[bech32.len() - 4..]) } else { bech32 }) } +#[cfg(test)] +mod truncated_display_name_tests { + use super::truncated_display_name; + use nostr::{PublicKey, ToBech32}; + + #[test] + fn compacts_to_first_8_and_last_4_of_the_npub() { + // Vector shared with the frontend `truncateNpub` tests; the expected + // form is derived from the encoded npub, not hardcoded, so the test + // asserts the compaction policy rather than one key's string. + let hex = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328"; + let pubkey = PublicKey::from_hex(hex).unwrap(); + let npub = pubkey.to_bech32().unwrap(); + let expected = format!("{}…{}", &npub[..8], &npub[npub.len() - 4..]); + assert_eq!(truncated_display_name(&pubkey).unwrap(), expected); + // 13 characters, matching the frontend compact form (the ellipsis is + // one char but three UTF-8 bytes, so count chars, not bytes). + assert_eq!(expected.chars().count(), 13); + assert!(expected.starts_with("npub1")); + // The compact form must not carry the raw hex key. + assert!(!expected.contains(hex)); + } +} + #[tauri::command] pub fn get_identity(state: State<'_, AppState>) -> Result { let keys = state.keys.lock().map_err(|error| error.to_string())?; diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index b781f8d9e6..a746cc8059 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -10,6 +10,8 @@ use reqwest::{ use serde::Serialize; use url::Url; +#[path = "link_preview_cancellation.rs"] +mod cancellation; #[path = "link_preview_image_retry.rs"] mod image_retry; #[path = "link_preview_rate_limit.rs"] @@ -17,15 +19,19 @@ mod rate_limit; #[path = "link_preview_youtube.rs"] mod youtube; -use rate_limit::{image_host_cooldown_remaining, retry_after_duration, set_image_host_cooldown}; +use rate_limit::{ + image_host_cooldown_remaining, image_host_gate, retry_after_duration, set_image_host_cooldown, +}; const MAX_PREVIEW_FETCH_BYTES: usize = 256 * 1024; const MAX_IMAGE_FETCH_BYTES: usize = 2 * 1024 * 1024; const MAX_IMAGE_DIMENSION: u32 = 4096; const MAX_IMAGE_PIXELS: u64 = 16_000_000; const MAX_SANITIZED_DIMENSION: u32 = 1200; -const PREVIEW_FETCH_TIMEOUT: Duration = Duration::from_secs(4); -const PREVIEW_TOTAL_TIMEOUT: Duration = Duration::from_secs(10); +const TRANSPORT_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +const TRANSPORT_IDLE_TIMEOUT: Duration = Duration::from_secs(30); +const DNS_RESOLUTION_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_INLINE_IMAGE_COOLDOWN: Duration = Duration::from_secs(30); const MAX_REDIRECTS: usize = 3; const MAX_METADATA_CHARS: usize = 180; const MAX_METADATA_DESCRIPTION_CHARS: usize = 280; @@ -55,27 +61,46 @@ pub struct LinkPreviewMetadata { #[tauri::command] pub async fn fetch_link_preview_metadata( href: String, + request_id: Option, ) -> Result, String> { - tokio::time::timeout( - PREVIEW_TOTAL_TIMEOUT, - fetch_link_preview_metadata_inner(href), - ) - .await - .map_err(|_| "link preview request timed out".to_string())? + let cancellation = cancellation::begin(request_id.as_deref()); + let result = match cancellation { + Some(cancellation) => { + tokio::select! { + result = fetch_link_preview_metadata_for_url(href) => result, + () = cancellation.cancelled() => Err("link preview request cancelled".to_string()), + } + } + None => fetch_link_preview_metadata_for_url(href).await, + }; + cancellation::finish(request_id.as_deref()); + result } -async fn fetch_link_preview_metadata_inner( +/// Cancel renderer-owned metadata work, including an in-flight response body. +#[tauri::command] +pub fn cancel_link_preview_metadata(request_id: String) { + cancellation::cancel(&request_id); +} + +/// Release a renderer's cancellation record after its invocation settles. +#[tauri::command] +pub fn release_link_preview_metadata(request_id: String) { + cancellation::finish(Some(&request_id)); +} + +async fn fetch_link_preview_metadata_for_url( href: String, ) -> Result, String> { let mut url = Url::parse(href.trim()).map_err(|error| format!("invalid URL: {error}"))?; - validate_public_https_url(&url).await?; + validate_metadata_url(&url).await?; if youtube::is_video_url(&url) { return youtube::fetch_oembed_metadata(&url).await; } for redirect_count in 0..=MAX_REDIRECTS { - let response = send_pinned_request(&url, "text/html,application/xhtml+xml;q=0.9").await?; + let response = send_metadata_request(&url, "text/html,application/xhtml+xml;q=0.9").await?; if response.status().is_redirection() { if redirect_count == MAX_REDIRECTS { @@ -90,7 +115,7 @@ async fn fetch_link_preview_metadata_inner( url = url .join(location) .map_err(|error| format!("invalid link preview redirect: {error}"))?; - validate_public_https_url(&url).await?; + validate_metadata_url(&url).await?; continue; } @@ -107,28 +132,15 @@ async fn fetch_link_preview_metadata_inner( let (image_result, favicon_result) = tokio::join!( async { match image_url { - Some(image_url) => Some( - tokio::time::timeout( - PREVIEW_FETCH_TIMEOUT, - fetch_sanitized_image_with_retry(image_url, false), - ) - .await - .unwrap_or(Err(ImageFetchError::Transient { - retry_after: None, - retry_inline: false, - })), - ), + Some(image_url) => { + Some(fetch_sanitized_image_with_retry(image_url, false).await) + } None => None, } }, async { match favicon_url { - Some(favicon_url) => tokio::time::timeout( - PREVIEW_FETCH_TIMEOUT, - fetch_sanitized_image(favicon_url, true), - ) - .await - .ok(), + Some(favicon_url) => Some(fetch_sanitized_image(favicon_url, true).await), None => None, } } @@ -166,6 +178,15 @@ fn apply_image_result( } } +async fn validate_metadata_url(url: &Url) -> Result<(), String> { + #[cfg(test)] + if METADATA_TEST_SERVER.try_with(|_| ()).is_ok() { + return Ok(()); + } + + validate_public_https_url(url).await +} + async fn validate_public_https_url(url: &Url) -> Result<(), String> { if url.scheme() != "https" || url.username() != "" || url.password().is_some() { return Err("link previews require an HTTPS URL without credentials".to_string()); @@ -182,11 +203,15 @@ async fn validate_public_https_url(url: &Url) -> Result<(), String> { async fn resolve_public_addresses(host: &str) -> Result, String> { let host = host.to_string(); - let addresses = tokio::net::lookup_host((host.as_str(), 443)) - .await - .map_err(|error| format!("link preview DNS resolution failed: {error}"))? - .map(|address| address.ip()) - .collect::>(); + let addresses = tokio::time::timeout( + DNS_RESOLUTION_TIMEOUT, + tokio::net::lookup_host((host.as_str(), 443)), + ) + .await + .map_err(|_| "link preview DNS resolution timed out".to_string())? + .map_err(|error| format!("link preview DNS resolution failed: {error}"))? + .map(|address| address.ip()) + .collect::>(); if addresses.is_empty() { return Err("link preview DNS resolution returned no addresses".to_string()); @@ -198,6 +223,25 @@ async fn resolve_public_addresses(host: &str) -> Result, String> { Ok(addresses) } +#[cfg(test)] +tokio::task_local! { + static METADATA_TEST_SERVER: std::net::SocketAddr; +} + +async fn send_metadata_request(url: &Url, accept: &str) -> Result { + #[cfg(test)] + if let Ok(address) = METADATA_TEST_SERVER.try_with(|address| *address) { + return reqwest::Client::new() + .get(format!("http://{address}{}", url.path())) + .header(ACCEPT, accept) + .send() + .await + .map_err(|error| format!("link preview test request failed: {error}")); + } + + send_pinned_request(url, accept).await +} + async fn send_pinned_request(url: &Url, accept: &str) -> Result { let host = url .host_str() @@ -211,6 +255,8 @@ async fn send_pinned_request(url: &Url, accept: &str) -> Result Result bool { + if *waited_for_cooldown { + return false; + } + *waited_for_cooldown = true; + tokio::time::sleep(retry_after).await; + true +} + +fn retryable_image_cooldown( + url: &Url, + retry_after: Option, + waited_for_cooldown: &mut bool, +) -> Option { + let retry_after = retry_after?; + if *waited_for_cooldown { + return None; + } + set_image_host_cooldown(url, retry_after); + if retry_after > MAX_INLINE_IMAGE_COOLDOWN { + return None; + } + *waited_for_cooldown = true; + Some(retry_after) +} + async fn fetch_sanitized_image( - mut url: Url, + url: Url, preserve_transparency: bool, ) -> Result<(String, String), ImageFetchError> { - validate_public_https_url(&url) + fetch_sanitized_image_using( + url, + preserve_transparency, + |url| async move { validate_public_https_url(&url).await }, + |url, accept| async move { send_pinned_request(&url, accept).await }, + ) + .await +} + +async fn fetch_sanitized_image_using( + mut url: Url, + preserve_transparency: bool, + mut validate_url: V, + mut send_request: F, +) -> Result<(String, String), ImageFetchError> +where + V: FnMut(Url) -> VFut, + VFut: std::future::Future>, + F: FnMut(Url, &'static str) -> Fut, + Fut: std::future::Future>, +{ + validate_url(url.clone()) .await .map_err(|_| ImageFetchError::Rejected)?; - for redirect_count in 0..=MAX_REDIRECTS { + let mut redirect_count = 0; + let mut waited_for_cooldown = false; + while redirect_count <= MAX_REDIRECTS { if let Some(retry_after) = image_host_cooldown_remaining(&url) { - return Err(ImageFetchError::Transient { - retry_after: Some(retry_after), - retry_inline: false, - }); + if retry_after > MAX_INLINE_IMAGE_COOLDOWN + || !wait_for_image_host_cooldown(&mut waited_for_cooldown, retry_after).await + { + return Err(ImageFetchError::Transient { + retry_after: Some(retry_after), + retry_inline: false, + }); + } + continue; } - let response = send_pinned_request(&url, "image/jpeg,image/png,image/webp") + + let host_gate = image_host_gate(&url); + let host_guard = host_gate.lock().await; + if image_host_cooldown_remaining(&url).is_some() { + continue; + } + let response = send_request(url.clone(), "image/jpeg,image/png,image/webp") .await .map_err(|_| ImageFetchError::Transient { retry_after: None, - retry_inline: true, + retry_inline: !waited_for_cooldown, })?; if response.status().is_redirection() { if redirect_count == MAX_REDIRECTS { @@ -370,9 +479,10 @@ async fn fetch_sanitized_image( .and_then(|value| value.to_str().ok()) .ok_or(ImageFetchError::Rejected)?; url = url.join(location).map_err(|_| ImageFetchError::Rejected)?; - validate_public_https_url(&url) + validate_url(url.clone()) .await .map_err(|_| ImageFetchError::Rejected)?; + redirect_count += 1; continue; } if !response.status().is_success() { @@ -383,12 +493,18 @@ async fn fetch_sanitized_image( || status.is_server_error() { let retry_after = retry_after_duration(&response); - if let Some(retry_after) = retry_after { - set_image_host_cooldown(&url, retry_after); + if let Some(retry_after) = + retryable_image_cooldown(&url, retry_after, &mut waited_for_cooldown) + { + drop(host_guard); + tokio::time::sleep(retry_after).await; + continue; } return Err(ImageFetchError::Transient { retry_after, - retry_inline: status != reqwest::StatusCode::TOO_MANY_REQUESTS, + retry_inline: retry_after.is_none() + && status != reqwest::StatusCode::TOO_MANY_REQUESTS + && !waited_for_cooldown, }); } return Err(ImageFetchError::Rejected); @@ -677,315 +793,5 @@ fn decode_html_entities(value: &str) -> String { } #[cfg(test)] -mod tests { - use super::rate_limit::MAX_IMAGE_RETRY_AFTER; - use super::{ - apply_image_result, declares_animation, extract_favicon_url, extract_image_url, - extract_link_preview_metadata, is_html_response, read_bytes_prefix, retry_after_duration, - sanitize_image, ImageFetchError, LinkPreviewImageFetchState, LinkPreviewMetadata, - MAX_METADATA_DESCRIPTION_CHARS, - }; - use axum::{body::Body, http::Response, routing::get, Router}; - use base64::Engine as _; - use bytes::Bytes; - use futures_util::stream; - use image::{DynamicImage, ImageFormat, Rgb, RgbImage, Rgba, RgbaImage}; - use std::{convert::Infallible, io::Cursor}; - use url::Url; - - async fn test_response(router: Router, path: &str) -> reqwest::Response { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, router).await.unwrap(); - }); - reqwest::get(format!("http://{address}{path}")) - .await - .unwrap() - } - - #[test] - fn metadata_prefers_open_graph_and_reads_site_name() { - let html = r#" - - - Fallback"#; - assert_eq!( - extract_link_preview_metadata(html), - Some(LinkPreviewMetadata { - title: "Rich previews & cards".to_string(), - site_name: Some("Buzz".to_string()), - description: Some("Safe & useful previews".to_string()), - image_data_url: None, - image_domain: None, - image_fetch_state: LinkPreviewImageFetchState::None, - image_retry_after_ms: None, - favicon_data_url: None, - }) - ); - } - - #[test] - fn image_results_preserve_absence_and_classify_recovery() { - let mut metadata = extract_link_preview_metadata("Preview result").unwrap(); - apply_image_result(&mut metadata, None); - assert_eq!(metadata.image_fetch_state, LinkPreviewImageFetchState::None); - - apply_image_result( - &mut metadata, - Some(Err(ImageFetchError::Transient { - retry_after: Some(std::time::Duration::from_secs(15)), - retry_inline: false, - })), - ); - assert_eq!( - metadata.image_fetch_state, - LinkPreviewImageFetchState::TransientFailure - ); - assert_eq!(metadata.image_retry_after_ms, Some(15_000)); - - apply_image_result( - &mut metadata, - Some(Ok(( - "data:image/jpeg;base64,abc".to_string(), - "images.example.com".to_string(), - ))), - ); - assert_eq!( - metadata.image_fetch_state, - LinkPreviewImageFetchState::Image - ); - assert_eq!(metadata.image_domain.as_deref(), Some("images.example.com")); - } - - #[test] - fn metadata_falls_back_to_twitter_then_title() { - assert_eq!( - extract_link_preview_metadata("") - .map(|metadata| metadata.title), - Some("Tweet title".to_string()) - ); - assert_eq!( - extract_link_preview_metadata(" Plain title ") - .map(|metadata| metadata.title), - Some("Plain title".to_string()) - ); - } - - #[test] - fn metadata_preserves_description_line_breaks() { - let html = r#" - "#; - assert_eq!( - extract_link_preview_metadata(html).and_then(|metadata| metadata.description), - Some("First paragraph.\n\nAgents:\n- One\n- Two".to_string()) - ); - } - - #[test] - fn metadata_description_supports_standard_x_posts() { - let description = "x".repeat(MAX_METADATA_DESCRIPTION_CHARS + 1); - let html = format!( - r#""# - ); - let extracted = extract_link_preview_metadata(&html) - .and_then(|metadata| metadata.description) - .unwrap(); - assert_eq!(extracted.chars().count(), MAX_METADATA_DESCRIPTION_CHARS); - } - - #[test] - fn favicon_metadata_resolves_relative_icon_links() { - let page = Url::parse("https://example.com/articles/one").unwrap(); - let html = r#" - "#; - assert_eq!( - extract_favicon_url(html, &page).unwrap().as_str(), - "https://example.com/favicon.png" - ); - } - - #[test] - fn favicon_metadata_prefers_a_supported_raster_candidate() { - let page = Url::parse("https://github.com/block/buzz").unwrap(); - let html = r#" - - "#; - assert_eq!( - extract_favicon_url(html, &page).unwrap().as_str(), - "https://assets.example/favicon.png" - ); - } - - #[test] - fn favicon_metadata_uses_touch_icon_before_unsupported_ico() { - let page = Url::parse("https://twitter.com/tellaho").unwrap(); - let html = r#" - "#; - assert_eq!( - extract_favicon_url(html, &page).unwrap().as_str(), - "https://twitter.com/apple-touch-icon.png" - ); - } - - #[test] - fn image_metadata_resolves_relative_urls_and_prefers_open_graph() { - let page = Url::parse("https://example.com/articles/one").unwrap(); - let html = r#" - "#; - assert_eq!( - extract_image_url(html, &page).unwrap().as_str(), - "https://example.com/preview.png" - ); - } - - #[tokio::test] - async fn oversized_html_uses_metadata_within_the_bounded_prefix() { - const LIMIT: usize = 256; - let metadata = r#""#; - let body = format!("{metadata}{}", "x".repeat(LIMIT)); - let response = test_response( - Router::new().route( - "/declared", - get(move || { - let body = body.clone(); - async move { - Response::builder() - .header("content-type", "text/html") - .body(Body::from(body)) - .unwrap() - } - }), - ), - "/declared", - ) - .await; - assert!(response - .content_length() - .is_some_and(|size| size > LIMIT as u64)); - assert!(is_html_response(&response)); - - let prefix = read_bytes_prefix(response, LIMIT).await.unwrap(); - assert_eq!(prefix.len(), LIMIT); - let html = String::from_utf8_lossy(&prefix); - assert_eq!( - extract_link_preview_metadata(&html).map(|metadata| metadata.title), - Some("Prefix title".to_string()) - ); - assert!(extract_image_url(&html, &Url::parse("https://example.com").unwrap()).is_some()); - } - - #[tokio::test] - async fn image_retry_after_uses_bounded_delta_seconds() { - let response = test_response( - Router::new().route( - "/rate-limited", - get(|| async { - Response::builder() - .status(429) - .header("retry-after", "900") - .body(Body::empty()) - .unwrap() - }), - ), - "/rate-limited", - ) - .await; - assert_eq!( - retry_after_duration(&response), - Some(std::time::Duration::from_secs(900)) - ); - - let response = test_response( - Router::new().route( - "/excessive", - get(|| async { - Response::builder() - .status(429) - .header("retry-after", "7200") - .body(Body::empty()) - .unwrap() - }), - ), - "/excessive", - ) - .await; - assert_eq!(retry_after_duration(&response), Some(MAX_IMAGE_RETRY_AFTER)); - } - - #[tokio::test] - async fn oversized_chunked_html_ignores_metadata_beyond_the_bounded_prefix() { - const LIMIT: usize = 256; - let response = test_response( - Router::new().route( - "/chunked", - get(|| async { - let chunks = stream::iter([ - Ok::<_, Infallible>(Bytes::from(vec![b'x'; LIMIT])), - Ok(Bytes::from_static( - br#""#, - )), - ]); - Response::builder() - .header("content-type", "text/html") - .body(Body::from_stream(chunks)) - .unwrap() - }), - ), - "/chunked", - ) - .await; - assert_eq!(response.content_length(), None); - - let prefix = read_bytes_prefix(response, LIMIT).await.unwrap(); - assert_eq!(prefix.len(), LIMIT); - let html = String::from_utf8_lossy(&prefix); - assert_eq!(extract_link_preview_metadata(&html), None); - assert_eq!( - extract_image_url(&html, &Url::parse("https://example.com").unwrap()), - None - ); - } - - #[test] - fn sanitizer_rejects_mime_mismatch_and_outputs_static_jpeg() { - let source = DynamicImage::ImageRgb8(RgbImage::from_pixel(2, 2, Rgb([10, 20, 30]))); - let mut png = Cursor::new(Vec::new()); - source.write_to(&mut png, ImageFormat::Png).unwrap(); - assert!(sanitize_image(png.get_ref(), "image/jpeg", false).is_err()); - let sanitized = sanitize_image(png.get_ref(), "image/png", false).unwrap(); - assert!(sanitized.starts_with("data:image/jpeg;base64,")); - } - - #[test] - fn favicon_sanitizer_preserves_png_transparency() { - let source = DynamicImage::ImageRgba8(RgbaImage::from_pixel(2, 2, Rgba([36, 41, 47, 0]))); - let mut png = Cursor::new(Vec::new()); - source.write_to(&mut png, ImageFormat::Png).unwrap(); - - let sanitized = sanitize_image(png.get_ref(), "image/png", true).unwrap(); - assert!(sanitized.starts_with("data:image/png;base64,")); - let encoded = sanitized.split_once(',').unwrap().1; - let bytes = base64::engine::general_purpose::STANDARD - .decode(encoded) - .unwrap(); - assert!(image::load_from_memory(&bytes).unwrap().color().has_alpha()); - } - - #[test] - fn animation_markers_are_rejected_before_decode() { - let mut apng = b"\x89PNG\r\n\x1a\n".to_vec(); - apng.extend_from_slice(b"junkacTLjunk"); - assert!(declares_animation(&apng, ImageFormat::Png)); - - let mut webp = b"RIFF\x00\x00\x00\x00WEBPVP8X\x0a\x00\x00\x00".to_vec(); - webp.push(0x02); - assert!(declares_animation(&webp, ImageFormat::WebP)); - } - - #[test] - fn metadata_requires_a_non_empty_title() { - assert_eq!(extract_link_preview_metadata(" "), None); - assert_eq!(extract_link_preview_metadata(""), None); - } -} +#[path = "link_preview_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/link_preview_cancellation.rs b/desktop/src-tauri/src/commands/link_preview_cancellation.rs new file mode 100644 index 0000000000..38be2b3a38 --- /dev/null +++ b/desktop/src-tauri/src/commands/link_preview_cancellation.rs @@ -0,0 +1,89 @@ +use std::{ + collections::HashMap, + sync::{LazyLock, Mutex}, +}; + +use tokio_util::sync::CancellationToken; + +#[derive(Default)] +struct LinkPreviewCancellations { + tokens: HashMap, +} + +impl LinkPreviewCancellations { + fn begin(&mut self, request_id: &str) -> CancellationToken { + if let Some(cancellation) = self.tokens.get(request_id).cloned() { + return cancellation; + } + let cancellation = CancellationToken::new(); + self.tokens + .insert(request_id.to_string(), cancellation.clone()); + cancellation + } + + fn cancel(&mut self, request_id: &str) { + self.tokens + .entry(request_id.to_string()) + .or_default() + .cancel(); + } + + fn finish(&mut self, request_id: &str) { + self.tokens.remove(request_id); + } +} + +static LINK_PREVIEW_CANCELLATIONS: LazyLock> = + LazyLock::new(|| Mutex::new(LinkPreviewCancellations::default())); + +pub(super) fn begin(request_id: Option<&str>) -> Option { + let request_id = request_id?; + LINK_PREVIEW_CANCELLATIONS + .lock() + .ok() + .map(|mut fetches| fetches.begin(request_id)) +} + +pub(super) fn cancel(request_id: &str) { + if let Ok(mut fetches) = LINK_PREVIEW_CANCELLATIONS.lock() { + fetches.cancel(request_id); + } +} + +pub(super) fn finish(request_id: Option<&str>) { + let Some(request_id) = request_id else { + return; + }; + if let Ok(mut fetches) = LINK_PREVIEW_CANCELLATIONS.lock() { + fetches.finish(request_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cancellation_before_begin_is_retained() { + let mut fetches = LinkPreviewCancellations::default(); + fetches.cancel("cancel-before-begin"); + + let cancellation = fetches.begin("cancel-before-begin"); + + assert!(cancellation.is_cancelled()); + fetches.finish("cancel-before-begin"); + assert!(fetches.tokens.is_empty()); + } + + #[test] + fn cancellation_reaches_active_owner() { + let mut fetches = LinkPreviewCancellations::default(); + let cancellation = fetches.begin("active-fetch"); + + fetches.cancel("active-fetch"); + + assert!(cancellation.is_cancelled()); + fetches.finish("active-fetch"); + assert!(fetches.tokens.is_empty()); + } +} diff --git a/desktop/src-tauri/src/commands/link_preview_rate_limit.rs b/desktop/src-tauri/src/commands/link_preview_rate_limit.rs index c3f7ed7188..46032fc5f0 100644 --- a/desktop/src-tauri/src/commands/link_preview_rate_limit.rs +++ b/desktop/src-tauri/src/commands/link_preview_rate_limit.rs @@ -1,16 +1,31 @@ use std::{ - collections::HashMap, - sync::{Mutex, OnceLock}, - time::{Duration, Instant}, + collections::{hash_map::DefaultHasher, HashMap}, + hash::{Hash, Hasher}, + sync::{LazyLock, Mutex, OnceLock}, + time::Duration, }; use reqwest::header::RETRY_AFTER; +use tokio::{sync::Mutex as AsyncMutex, time::Instant}; use url::Url; pub(super) const MAX_IMAGE_RETRY_AFTER: Duration = Duration::from_secs(60 * 60); const MAX_IMAGE_HOST_COOLDOWNS: usize = 128; +const IMAGE_HOST_GATE_COUNT: usize = 64; static IMAGE_HOST_COOLDOWNS: OnceLock>> = OnceLock::new(); +// A bounded stripe table serializes image requests by host without retaining an +// unbounded attacker-controlled hostname map. Hash collisions only make two +// unrelated hosts wait for one another; they never weaken the host boundary. +static IMAGE_HOST_GATES: LazyLock<[AsyncMutex<()>; IMAGE_HOST_GATE_COUNT]> = + LazyLock::new(|| std::array::from_fn(|_| AsyncMutex::new(()))); + +pub(super) fn image_host_gate(url: &Url) -> &'static AsyncMutex<()> { + let mut hasher = DefaultHasher::new(); + url.host_str().unwrap_or_default().hash(&mut hasher); + let index = (hasher.finish() as usize) % IMAGE_HOST_GATE_COUNT; + &IMAGE_HOST_GATES[index] +} pub(super) fn retry_after_duration(response: &reqwest::Response) -> Option { response diff --git a/desktop/src-tauri/src/commands/link_preview_tests.rs b/desktop/src-tauri/src/commands/link_preview_tests.rs new file mode 100644 index 0000000000..05ab0eec2c --- /dev/null +++ b/desktop/src-tauri/src/commands/link_preview_tests.rs @@ -0,0 +1,604 @@ +use super::rate_limit::MAX_IMAGE_RETRY_AFTER; +use super::{ + apply_image_result, cancel_link_preview_metadata, declares_animation, extract_favicon_url, + extract_image_url, extract_link_preview_metadata, fetch_link_preview_metadata, + fetch_sanitized_image_using, is_html_response, read_bytes_prefix, retry_after_duration, + retryable_image_cooldown, sanitize_image, ImageFetchError, LinkPreviewImageFetchState, + LinkPreviewMetadata, MAX_INLINE_IMAGE_COOLDOWN, MAX_METADATA_DESCRIPTION_CHARS, +}; +use axum::{body::Body, http::Response, routing::get, Router}; +use base64::Engine as _; +use bytes::Bytes; +use futures_util::stream; +use image::{DynamicImage, ImageFormat, Rgb, RgbImage, Rgba, RgbaImage}; +use std::{ + convert::Infallible, + io::Cursor, + sync::{Arc, Mutex}, +}; +use tokio::sync::oneshot; +use url::Url; + +async fn start_test_server(router: Router) -> std::net::SocketAddr { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + address +} + +async fn test_response(router: Router, path: &str) -> reqwest::Response { + let address = start_test_server(router).await; + reqwest::get(format!("http://{address}{path}")) + .await + .unwrap() +} + +#[tokio::test(start_paused = true)] +async fn metadata_pipeline_remains_pending_beyond_former_aggregate_deadline() { + let (request_started_tx, request_started_rx) = oneshot::channel::<()>(); + let request_started_tx = Arc::new(Mutex::new(Some(request_started_tx))); + let (release_response_tx, release_response_rx) = oneshot::channel::<()>(); + let release_response_rx = Arc::new(Mutex::new(Some(release_response_rx))); + let address = start_test_server(Router::new().route( + "/preview", + get(move || { + let request_started_tx = Arc::clone(&request_started_tx); + let release_response_rx = Arc::clone(&release_response_rx); + async move { + request_started_tx + .lock() + .unwrap() + .take() + .unwrap() + .send(()) + .unwrap(); + let release_response_rx = release_response_rx.lock().unwrap().take().unwrap(); + release_response_rx.await.unwrap(); + Response::builder() + .header("content-type", "text/html") + .body(Body::from("User-paced metadata")) + .unwrap() + } + }), + )) + .await; + let fetch = tokio::spawn(super::METADATA_TEST_SERVER.scope( + address, + fetch_link_preview_metadata("https://user-paced.example/preview".to_string(), None), + )); + + request_started_rx.await.unwrap(); + tokio::time::advance(std::time::Duration::from_secs(11)).await; + assert!(!fetch.is_finished()); + + release_response_tx.send(()).unwrap(); + let metadata = fetch.await.unwrap().unwrap().unwrap(); + assert_eq!(metadata.title, "User-paced metadata"); +} + +#[tokio::test] +async fn metadata_command_cancellation_drops_an_in_flight_response() { + let (request_started_tx, request_started_rx) = oneshot::channel::<()>(); + let request_started_tx = Arc::new(Mutex::new(Some(request_started_tx))); + let (_release_response_tx, release_response_rx) = oneshot::channel::<()>(); + let release_response_rx = Arc::new(Mutex::new(Some(release_response_rx))); + let address = start_test_server(Router::new().route( + "/preview", + get(move || { + let request_started_tx = Arc::clone(&request_started_tx); + let release_response_rx = Arc::clone(&release_response_rx); + async move { + request_started_tx + .lock() + .unwrap() + .take() + .unwrap() + .send(()) + .unwrap(); + let release_response_rx = release_response_rx.lock().unwrap().take().unwrap(); + let _ = release_response_rx.await; + Response::builder() + .header("content-type", "text/html") + .body(Body::from("Too late")) + .unwrap() + } + }), + )) + .await; + let request_id = "cancel-in-flight".to_string(); + let fetch = tokio::spawn(super::METADATA_TEST_SERVER.scope( + address, + fetch_link_preview_metadata( + "https://cancel.example/preview".to_string(), + Some(request_id.clone()), + ), + )); + + request_started_rx.await.unwrap(); + cancel_link_preview_metadata(request_id); + + assert_eq!( + fetch.await.unwrap(), + Err("link preview request cancelled".to_string()) + ); +} + +#[tokio::test(start_paused = true)] +async fn first_rate_limit_and_queued_host_request_share_one_cooldown_boundary() { + let cooldown = std::time::Duration::from_secs(20); + let rate_limited_path = "/rate-limited.png"; + let success_path = "/success.png"; + let url = Url::parse(&format!( + "https://rate-limit-regression.example{rate_limited_path}" + )) + .unwrap(); + let attempts = Arc::new(Mutex::new(0)); + let collision_attempts = Arc::new(Mutex::new(0)); + let image = DynamicImage::ImageRgb8(RgbImage::from_pixel(2, 2, Rgb([10, 20, 30]))); + let mut png = Cursor::new(Vec::new()); + image.write_to(&mut png, ImageFormat::Png).unwrap(); + let image_bytes = png.into_inner(); + let server_attempts = Arc::clone(&attempts); + let address = start_test_server(Router::new().route( + "/{image}", + get( + move |axum::extract::Path(image): axum::extract::Path| { + let image_bytes = image_bytes.clone(); + let server_attempts = Arc::clone(&server_attempts); + async move { + if image == "rate-limited.png" { + let attempt = { + let mut attempts = server_attempts.lock().unwrap(); + *attempts += 1; + *attempts + }; + if attempt == 1 { + return Response::builder() + .status(429) + .header("retry-after", cooldown.as_secs()) + .body(Body::empty()) + .unwrap(); + } + } + Response::builder() + .header("content-type", "image/png") + .body(Body::from(image_bytes)) + .unwrap() + } + }, + ), + )) + .await; + let test_client = reqwest::Client::new(); + let request = move |url: Url, _accept: &'static str| { + let test_client = test_client.clone(); + async move { + test_client + .get(format!("http://{address}{}", url.path())) + .send() + .await + .map_err(|error| error.to_string()) + } + }; + let collision_request = { + let collision_attempts = Arc::clone(&collision_attempts); + let test_client = reqwest::Client::new(); + move |url: Url, _accept: &'static str| { + let collision_attempts = Arc::clone(&collision_attempts); + let test_client = test_client.clone(); + async move { + *collision_attempts.lock().unwrap() += 1; + test_client + .get(format!("http://{address}{}", url.path())) + .send() + .await + .map_err(|error| error.to_string()) + } + } + }; + let validate = |_url: Url| async { Ok(()) }; + let first = tokio::spawn(fetch_sanitized_image_using( + url.clone(), + false, + validate, + request.clone(), + )); + while super::image_host_cooldown_remaining(&url).is_none() { + tokio::task::yield_now().await; + } + assert!(!first.is_finished()); + assert_eq!(*attempts.lock().unwrap(), 1); + assert_eq!(super::image_host_cooldown_remaining(&url), Some(cooldown)); + + let colliding_url = (0..10_000) + .map(|index| { + Url::parse(&format!("https://collision-{index}.example{success_path}")).unwrap() + }) + .find(|candidate| { + std::ptr::eq( + super::image_host_gate(candidate), + super::image_host_gate(&url), + ) + }) + .expect("a different host sharing the bounded gate stripe"); + + let (collision_started_tx, collision_started_rx) = oneshot::channel(); + tokio::spawn(async move { + let collision = + fetch_sanitized_image_using(colliding_url, false, validate, collision_request); + tokio::pin!(collision); + assert!(futures_util::poll!(&mut collision).is_pending()); + collision_started_tx.send(()).ok(); + assert!(collision.await.is_ok()); + }); + collision_started_rx.await.unwrap(); + assert_eq!(*collision_attempts.lock().unwrap(), 1); + assert_eq!(*attempts.lock().unwrap(), 1); + + let queued = tokio::spawn(fetch_sanitized_image_using(url, false, validate, request)); + tokio::task::yield_now().await; + assert!(!queued.is_finished()); + assert_eq!(*attempts.lock().unwrap(), 1); + + tokio::time::advance(cooldown - std::time::Duration::from_millis(1)).await; + assert!(!first.is_finished()); + assert!(!queued.is_finished()); + assert_eq!(*attempts.lock().unwrap(), 1); + + tokio::time::advance(std::time::Duration::from_millis(1)).await; + let (first, queued) = tokio::join!(first, queued); + assert!(first.unwrap().is_ok()); + assert!(queued.unwrap().is_ok()); + assert_eq!(*attempts.lock().unwrap(), 3); +} + +#[tokio::test(start_paused = true)] +async fn transport_failure_after_cooldown_does_not_renew_wait_on_outer_retry() { + let cooldown = std::time::Duration::from_secs(20); + let url = Url::parse("https://transport-after-cooldown.example/image.png").unwrap(); + super::set_image_host_cooldown(&url, cooldown); + let attempts = Arc::new(Mutex::new(0)); + + let result = super::image_retry::retry_transient_image_fetch(|| { + let url = url.clone(); + let attempts = Arc::clone(&attempts); + async move { + fetch_sanitized_image_using( + url, + false, + |_url| async { Ok(()) }, + move |_url, _accept| { + let attempts = Arc::clone(&attempts); + async move { + *attempts.lock().unwrap() += 1; + Err("connection failed".to_string()) + } + }, + ) + .await + } + }) + .await; + + assert_eq!( + result, + Err(ImageFetchError::Transient { + retry_after: None, + retry_inline: false, + }) + ); + assert_eq!(*attempts.lock().unwrap(), 1); +} + +#[test] +fn image_cooldown_wait_is_short_and_one_shot() { + let url = Url::parse("https://bounded-cooldown.example/image.png").unwrap(); + let mut waited = false; + assert_eq!( + retryable_image_cooldown(&url, Some(MAX_INLINE_IMAGE_COOLDOWN), &mut waited,), + Some(MAX_INLINE_IMAGE_COOLDOWN) + ); + assert!(waited); + assert_eq!( + retryable_image_cooldown(&url, Some(MAX_INLINE_IMAGE_COOLDOWN), &mut waited,), + None + ); + let excessive_url = Url::parse("https://excessive-cooldown.example/image.png").unwrap(); + let mut excessive_waited = false; + assert_eq!( + retryable_image_cooldown( + &excessive_url, + Some(MAX_INLINE_IMAGE_COOLDOWN + std::time::Duration::from_secs(1)), + &mut excessive_waited, + ), + None + ); + assert!(!excessive_waited); +} + +#[test] +fn metadata_prefers_open_graph_and_reads_site_name() { + let html = r#" + + + Fallback"#; + assert_eq!( + extract_link_preview_metadata(html), + Some(LinkPreviewMetadata { + title: "Rich previews & cards".to_string(), + site_name: Some("Buzz".to_string()), + description: Some("Safe & useful previews".to_string()), + image_data_url: None, + image_domain: None, + image_fetch_state: LinkPreviewImageFetchState::None, + image_retry_after_ms: None, + favicon_data_url: None, + }) + ); +} + +#[test] +fn image_results_preserve_absence_and_classify_recovery() { + let mut metadata = extract_link_preview_metadata("Preview result").unwrap(); + apply_image_result(&mut metadata, None); + assert_eq!(metadata.image_fetch_state, LinkPreviewImageFetchState::None); + + apply_image_result( + &mut metadata, + Some(Err(ImageFetchError::Transient { + retry_after: Some(std::time::Duration::from_secs(15)), + retry_inline: false, + })), + ); + assert_eq!( + metadata.image_fetch_state, + LinkPreviewImageFetchState::TransientFailure + ); + assert_eq!(metadata.image_retry_after_ms, Some(15_000)); + + apply_image_result( + &mut metadata, + Some(Ok(( + "data:image/jpeg;base64,abc".to_string(), + "images.example.com".to_string(), + ))), + ); + assert_eq!( + metadata.image_fetch_state, + LinkPreviewImageFetchState::Image + ); + assert_eq!(metadata.image_domain.as_deref(), Some("images.example.com")); +} + +#[test] +fn metadata_falls_back_to_twitter_then_title() { + assert_eq!( + extract_link_preview_metadata("") + .map(|metadata| metadata.title), + Some("Tweet title".to_string()) + ); + assert_eq!( + extract_link_preview_metadata(" Plain title ") + .map(|metadata| metadata.title), + Some("Plain title".to_string()) + ); +} + +#[test] +fn metadata_preserves_description_line_breaks() { + let html = r#" + "#; + assert_eq!( + extract_link_preview_metadata(html).and_then(|metadata| metadata.description), + Some("First paragraph.\n\nAgents:\n- One\n- Two".to_string()) + ); +} + +#[test] +fn metadata_description_supports_standard_x_posts() { + let description = "x".repeat(MAX_METADATA_DESCRIPTION_CHARS + 1); + let html = format!( + r#""# + ); + let extracted = extract_link_preview_metadata(&html) + .and_then(|metadata| metadata.description) + .unwrap(); + assert_eq!(extracted.chars().count(), MAX_METADATA_DESCRIPTION_CHARS); +} + +#[test] +fn favicon_metadata_resolves_relative_icon_links() { + let page = Url::parse("https://example.com/articles/one").unwrap(); + let html = r#" + "#; + assert_eq!( + extract_favicon_url(html, &page).unwrap().as_str(), + "https://example.com/favicon.png" + ); +} + +#[test] +fn favicon_metadata_prefers_a_supported_raster_candidate() { + let page = Url::parse("https://github.com/block/buzz").unwrap(); + let html = r#" + + "#; + assert_eq!( + extract_favicon_url(html, &page).unwrap().as_str(), + "https://assets.example/favicon.png" + ); +} + +#[test] +fn favicon_metadata_uses_touch_icon_before_unsupported_ico() { + let page = Url::parse("https://twitter.com/tellaho").unwrap(); + let html = r#" + "#; + assert_eq!( + extract_favicon_url(html, &page).unwrap().as_str(), + "https://twitter.com/apple-touch-icon.png" + ); +} + +#[test] +fn image_metadata_resolves_relative_urls_and_prefers_open_graph() { + let page = Url::parse("https://example.com/articles/one").unwrap(); + let html = r#" + "#; + assert_eq!( + extract_image_url(html, &page).unwrap().as_str(), + "https://example.com/preview.png" + ); +} + +#[tokio::test] +async fn oversized_html_uses_metadata_within_the_bounded_prefix() { + const LIMIT: usize = 256; + let metadata = r#""#; + let body = format!("{metadata}{}", "x".repeat(LIMIT)); + let response = test_response( + Router::new().route( + "/declared", + get(move || { + let body = body.clone(); + async move { + Response::builder() + .header("content-type", "text/html") + .body(Body::from(body)) + .unwrap() + } + }), + ), + "/declared", + ) + .await; + assert!(response + .content_length() + .is_some_and(|size| size > LIMIT as u64)); + assert!(is_html_response(&response)); + + let prefix = read_bytes_prefix(response, LIMIT).await.unwrap(); + assert_eq!(prefix.len(), LIMIT); + let html = String::from_utf8_lossy(&prefix); + assert_eq!( + extract_link_preview_metadata(&html).map(|metadata| metadata.title), + Some("Prefix title".to_string()) + ); + assert!(extract_image_url(&html, &Url::parse("https://example.com").unwrap()).is_some()); +} + +#[tokio::test] +async fn image_retry_after_uses_bounded_delta_seconds() { + let response = test_response( + Router::new().route( + "/rate-limited", + get(|| async { + Response::builder() + .status(429) + .header("retry-after", "900") + .body(Body::empty()) + .unwrap() + }), + ), + "/rate-limited", + ) + .await; + assert_eq!( + retry_after_duration(&response), + Some(std::time::Duration::from_secs(900)) + ); + + let response = test_response( + Router::new().route( + "/excessive", + get(|| async { + Response::builder() + .status(429) + .header("retry-after", "7200") + .body(Body::empty()) + .unwrap() + }), + ), + "/excessive", + ) + .await; + assert_eq!(retry_after_duration(&response), Some(MAX_IMAGE_RETRY_AFTER)); +} + +#[tokio::test] +async fn oversized_chunked_html_ignores_metadata_beyond_the_bounded_prefix() { + const LIMIT: usize = 256; + let response = test_response( + Router::new().route( + "/chunked", + get(|| async { + let chunks = stream::iter([ + Ok::<_, Infallible>(Bytes::from(vec![b'x'; LIMIT])), + Ok(Bytes::from_static( + br#""#, + )), + ]); + Response::builder() + .header("content-type", "text/html") + .body(Body::from_stream(chunks)) + .unwrap() + }), + ), + "/chunked", + ) + .await; + assert_eq!(response.content_length(), None); + + let prefix = read_bytes_prefix(response, LIMIT).await.unwrap(); + assert_eq!(prefix.len(), LIMIT); + let html = String::from_utf8_lossy(&prefix); + assert_eq!(extract_link_preview_metadata(&html), None); + assert_eq!( + extract_image_url(&html, &Url::parse("https://example.com").unwrap()), + None + ); +} + +#[test] +fn sanitizer_rejects_mime_mismatch_and_outputs_static_jpeg() { + let source = DynamicImage::ImageRgb8(RgbImage::from_pixel(2, 2, Rgb([10, 20, 30]))); + let mut png = Cursor::new(Vec::new()); + source.write_to(&mut png, ImageFormat::Png).unwrap(); + assert!(sanitize_image(png.get_ref(), "image/jpeg", false).is_err()); + let sanitized = sanitize_image(png.get_ref(), "image/png", false).unwrap(); + assert!(sanitized.starts_with("data:image/jpeg;base64,")); +} + +#[test] +fn favicon_sanitizer_preserves_png_transparency() { + let source = DynamicImage::ImageRgba8(RgbaImage::from_pixel(2, 2, Rgba([36, 41, 47, 0]))); + let mut png = Cursor::new(Vec::new()); + source.write_to(&mut png, ImageFormat::Png).unwrap(); + + let sanitized = sanitize_image(png.get_ref(), "image/png", true).unwrap(); + assert!(sanitized.starts_with("data:image/png;base64,")); + let encoded = sanitized.split_once(',').unwrap().1; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + assert!(image::load_from_memory(&bytes).unwrap().color().has_alpha()); +} + +#[test] +fn animation_markers_are_rejected_before_decode() { + let mut apng = b"\x89PNG\r\n\x1a\n".to_vec(); + apng.extend_from_slice(b"junkacTLjunk"); + assert!(declares_animation(&apng, ImageFormat::Png)); + + let mut webp = b"RIFF\x00\x00\x00\x00WEBPVP8X\x0a\x00\x00\x00".to_vec(); + webp.push(0x02); + assert!(declares_animation(&webp, ImageFormat::WebP)); +} + +#[test] +fn metadata_requires_a_non_empty_title() { + assert_eq!(extract_link_preview_metadata(" "), None); + assert_eq!(extract_link_preview_metadata(""), None); +} diff --git a/desktop/src-tauri/src/commands/link_preview_youtube.rs b/desktop/src-tauri/src/commands/link_preview_youtube.rs index a0a5a753dc..b759046d8f 100644 --- a/desktop/src-tauri/src/commands/link_preview_youtube.rs +++ b/desktop/src-tauri/src/commands/link_preview_youtube.rs @@ -5,8 +5,8 @@ use url::Url; use super::{ apply_image_result, fetch_sanitized_image, normalize_metadata_description, - normalize_metadata_text, read_limited_bytes, send_pinned_request, ImageFetchError, - LinkPreviewImageFetchState, LinkPreviewMetadata, PREVIEW_FETCH_TIMEOUT, + normalize_metadata_text, read_limited_bytes, send_pinned_request, LinkPreviewImageFetchState, + LinkPreviewMetadata, }; const MAX_OEMBED_FETCH_BYTES: usize = 64 * 1024; @@ -54,17 +54,7 @@ pub(super) async fn fetch_oembed_metadata( return Ok(None); }; let image_result = match thumbnail_url { - Some(thumbnail_url) => Some( - tokio::time::timeout( - PREVIEW_FETCH_TIMEOUT, - fetch_sanitized_image(thumbnail_url, false), - ) - .await - .unwrap_or(Err(ImageFetchError::Transient { - retry_after: None, - retry_inline: false, - })), - ), + Some(thumbnail_url) => Some(fetch_sanitized_image(thumbnail_url, false).await), None => None, }; apply_image_result(&mut metadata, image_result); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 12082a2a82..e3ac45953d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -600,6 +600,8 @@ pub fn run() { get_relay_http_url, get_media_proxy_port, fetch_link_preview_metadata, + cancel_link_preview_metadata, + release_link_preview_metadata, discover_acp_auth_methods, discover_acp_providers, discover_git_bash_prerequisite, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 531ae335ce..e4b87e7557 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -783,12 +783,14 @@ pub(crate) fn classify_runtime( /// The oldest `codex-acp` version supported by Buzz managed agents. /// /// Older 1.x adapters are detected successfully, but can still bundle a Codex runtime -/// that does not reliably give `buzz` CLI subprocesses outbound relay access. +/// that cannot use newer models. Adapter 1.6.2 bundles Codex 0.148.x, which rejects +/// GPT-6 Astra even when the separately installed Codex CLI has been updated. +/// Published adapter 1.10.0 depends on `@openai/codex ^0.153.3`. /// /// Bump policy: raise this only when a newer adapter fixes a defect that breaks managed /// agents, and only to a version already published on npm — every user below the floor is /// offered a reinstall on their next discovery pass. -pub(crate) const MIN_CODEX_ACP_VERSION: (u64, u64, u64) = (1, 1, 7); +pub(crate) const MIN_CODEX_ACP_VERSION: (u64, u64, u64) = (1, 10, 0); /// Probe the full version of a `codex-acp` binary by running `--version`. /// diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index dc155d82f5..f13a2d20bd 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -684,7 +684,7 @@ fn codex_adapter_availability_available_for_minimum_supported_binary() { let bin = dir.join("codex-acp"); std::fs::write( &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.7'\nexit 0\n", + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.10.0'\nexit 0\n", ) .expect("write script"); std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); @@ -721,27 +721,6 @@ fn codex_adapter_availability_outdated_for_0x_binary() { ); } -#[cfg(unix)] -#[test] -fn codex_adapter_availability_outdated_for_older_1x_binary() { - use std::os::unix::fs::PermissionsExt; - - let dir = tempfile::tempdir().expect("temp dir"); - let bin = dir.path().join("codex-acp"); - std::fs::write( - &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.5'\nexit 0\n", - ) - .expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); - - assert_eq!( - codex_adapter_availability(&bin), - AcpAvailabilityStatus::AdapterOutdated, - "a 1.x adapter below the floor must be offered an upgrade" - ); -} - /// The strict three-component parse fails closed: a version Buzz cannot compare /// against the floor is treated as outdated rather than assumed current. #[cfg(unix)] diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs index 82bfd27f32..19053865cb 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs @@ -46,3 +46,30 @@ fn probe_codex_acp_version_uses_augmented_path_for_env_shebang_interpreter() { "the injected augmented PATH should allow /usr/bin/env to find node" ); } + +#[cfg(unix)] +#[test] +fn codex_adapter_availability_outdated_for_older_1x_binary() { + use super::super::{codex_adapter_availability, codex_adapter_is_outdated}; + use crate::managed_agents::AcpAvailabilityStatus; + use std::os::unix::fs::PermissionsExt; + + for version in ["1.1.5", "1.1.7", "1.6.2", "1.9.0"] { + let dir = tempfile::tempdir().expect("temp dir"); + let bin = dir.path().join("codex-acp"); + std::fs::write( + &bin, + format!("#!/bin/sh\necho '@agentclientprotocol/codex-acp {version}'\nexit 0\n"), + ) + .expect("write script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) + .expect("chmod script"); + + assert_eq!( + codex_adapter_availability(&bin), + AcpAvailabilityStatus::AdapterOutdated, + "adapter {version} must be offered an upgrade" + ); + assert!(codex_adapter_is_outdated(&bin)); + } +} diff --git a/desktop/src/app/useTrayMenu.ts b/desktop/src/app/useTrayMenu.ts index 355c8e5d4f..04ce9256a3 100644 --- a/desktop/src/app/useTrayMenu.ts +++ b/desktop/src/app/useTrayMenu.ts @@ -10,7 +10,7 @@ import { useManagedAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { useNow } from "@/shared/lib/useNow"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import type { Channel } from "@/shared/api/types"; @@ -72,7 +72,7 @@ export function useTrayMenu({ activityId: `${channelTurn.channelId}:${normalizePubkey(pubkey)}`, agentName: agentNames.get(normalizePubkey(pubkey)) ?? - `Agent ${truncatePubkey(pubkey)}`, + `Agent ${truncateNpub(pubkey)}`, channelId: channelTurn.channelId, channelName: channelNames.get(channelTurn.channelId) ?? "Unknown channel", diff --git a/desktop/src/features/agents/lib/respondToAllowlist.test.mjs b/desktop/src/features/agents/lib/respondToAllowlist.test.mjs index bbe07f7204..0d494652e7 100644 --- a/desktop/src/features/agents/lib/respondToAllowlist.test.mjs +++ b/desktop/src/features/agents/lib/respondToAllowlist.test.mjs @@ -6,6 +6,10 @@ import { mergeAllowlist, parsePubkeyInput } from "./respondToAllowlist.ts"; const HEX_A = "a".repeat(64); const HEX_B = "b".repeat(64); const HEX_A_UPPER = "A".repeat(64); +// Handoff vector, round-trip verified with nostr-tools nip19. +const HEX = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328"; +const HEX_NPUB = + "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60"; test("parsePubkeyInput splits on commas, whitespace, and newlines", () => { const input = `${HEX_A}, ${HEX_B}\n${HEX_A_UPPER}`; @@ -25,11 +29,15 @@ test("parsePubkeyInput surfaces invalid entries separately", () => { assert.deepEqual(result.invalid, ["notgood", "z".repeat(64)]); }); -test("parsePubkeyInput rejects npub-style strings (hex only)", () => { - const npub = `npub1${"a".repeat(59)}`; - const result = parsePubkeyInput(npub); - assert.deepEqual(result.valid, []); - assert.deepEqual(result.invalid, [npub]); +test("parsePubkeyInput accepts npub entries, normalizes to hex, and dedupes across spellings", () => { + // One invalid npub-shaped token proves classification at this seam; the + // full codec negative matrix lives at the shared parser (nostrUtils). + const corrupt = `${HEX_NPUB.slice(0, -2)}qq`; + const result = parsePubkeyInput( + `${HEX_NPUB} ${HEX} ${HEX.toUpperCase()} ${corrupt}`, + ); + assert.deepEqual(result.valid, [HEX]); + assert.deepEqual(result.invalid, [corrupt]); }); test("parsePubkeyInput rejects wrong-length entries", () => { @@ -60,3 +68,8 @@ test("mergeAllowlist skips invalid additions silently", () => { const merged = mergeAllowlist([HEX_A], ["not-hex", HEX_B]); assert.deepEqual(merged, [HEX_A, HEX_B]); }); + +test("mergeAllowlist normalizes npub additions to canonical hex and dedupes", () => { + assert.deepEqual(mergeAllowlist([HEX_A], [HEX_NPUB]), [HEX_A, HEX]); + assert.deepEqual(mergeAllowlist([HEX], [HEX_NPUB]), [HEX]); +}); diff --git a/desktop/src/features/agents/lib/respondToAllowlist.ts b/desktop/src/features/agents/lib/respondToAllowlist.ts index c376aa1d1a..237a457cc3 100644 --- a/desktop/src/features/agents/lib/respondToAllowlist.ts +++ b/desktop/src/features/agents/lib/respondToAllowlist.ts @@ -5,9 +5,14 @@ * `desktop/src-tauri/src/managed_agents/types.rs::validate_respond_to_allowlist`). * These helpers exist to give the UI immediate, inline feedback before the * round-trip, and to normalize input so the Rust validator sees clean data. + * + * Entry pieces may be 64-char hex pubkeys or bech32 `npub1…` strings; both are + * normalized to the canonical lowercase hex via the shared + * `parsePubkeyInput`, so npub and hex spellings of the same key dedupe to one + * entry (users copy npubs from profile/verify surfaces elsewhere in the app). */ -const HEX_64 = /^[0-9a-f]{64}$/i; +import { parsePubkeyInput as parseCanonicalPubkey } from "@/shared/lib/nostrUtils"; export type ParsedAllowlist = { /** Successfully parsed entries — lowercase hex, deduplicated, in order. */ @@ -22,9 +27,9 @@ export type ParsedAllowlist = { * pattern used by `ChannelMemberInviteCard` so users have one mental model. * * - Splits on `/[\s,]+/`. - * - Trims and lowercases each entry. - * - Validates each entry is exactly 64 hex chars. - * - Deduplicates while preserving insertion order. + * - Accepts 64-char hex (any case) or `npub1…` bech32 per piece, normalizing + * to the canonical lowercase hex pubkey. + * - Deduplicates the canonical form while preserving insertion order. */ export function parsePubkeyInput(raw: string): ParsedAllowlist { const seen = new Set(); @@ -33,14 +38,14 @@ export function parsePubkeyInput(raw: string): ParsedAllowlist { for (const piece of raw.split(/[\s,]+/)) { const trimmed = piece.trim(); if (trimmed.length === 0) continue; - if (!HEX_64.test(trimmed)) { + const canonical = parseCanonicalPubkey(trimmed); + if (canonical === null) { invalid.push(trimmed); continue; } - const lower = trimmed.toLowerCase(); - if (!seen.has(lower)) { - seen.add(lower); - valid.push(lower); + if (!seen.has(canonical)) { + seen.add(canonical); + valid.push(canonical); } } return { valid, invalid }; @@ -48,16 +53,20 @@ export function parsePubkeyInput(raw: string): ParsedAllowlist { /** * Merge an existing allowlist with newly-added pubkeys, normalizing and - * deduplicating without reordering existing entries. + * deduplicating without reordering existing entries. Both hex and npub + * spellings normalize to the canonical hex, so the same key cannot enter + * twice regardless of the form it was added in. */ export function mergeAllowlist(existing: string[], add: string[]): string[] { - const seen = new Set(existing.map((p) => p.toLowerCase())); - const out = [...existing.map((p) => p.toLowerCase())]; + const normalize = (pubkey: string): string => + parseCanonicalPubkey(pubkey) ?? pubkey.toLowerCase(); + const out = existing.map(normalize); + const seen = new Set(out); for (const candidate of add) { - const lower = candidate.toLowerCase(); - if (!HEX_64.test(lower) || seen.has(lower)) continue; - seen.add(lower); - out.push(lower); + const canonical = parseCanonicalPubkey(candidate); + if (canonical === null || seen.has(canonical)) continue; + seen.add(canonical); + out.push(canonical); } return out; } diff --git a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx index 05441e8618..366a19d7ca 100644 --- a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx @@ -9,7 +9,11 @@ import { useChannelsQuery, } from "@/features/channels/hooks"; import type { Channel, ChannelRole, ManagedAgent } from "@/shared/api/types"; -import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + canonicalNpub, + normalizePubkey, + UNAVAILABLE_KEY_LABEL, +} from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -87,6 +91,10 @@ export function AddAgentToChannelDialog({ ); }, [agent?.pubkey, membersQuery.data]); + // The agent's public key displays as its full canonical npub; an + // unencodable key renders the neutral label and is not copyable. + const agentNpub = agent?.pubkey ? canonicalNpub(agent.pubkey) : null; + const selectedChannel = channels.find((channel) => channel.id === channelId) ?? null; @@ -186,10 +194,12 @@ export function AddAgentToChannelDialog({

- {agent?.pubkey ?? "No agent selected"} + {agent + ? (agentNpub ?? UNAVAILABLE_KEY_LABEL) + : "No agent selected"} - {agent ? ( - + {agent && agentNpub ? ( + ) : null}
diff --git a/desktop/src/features/agents/ui/PersonaShareRecipients.tsx b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx index 7db7c32bfa..89588b5756 100644 --- a/desktop/src/features/agents/ui/PersonaShareRecipients.tsx +++ b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx @@ -15,7 +15,7 @@ import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { SelectedRecipientChip } from "@/features/profile/ui/SelectedRecipientChip"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { UserSearchResult } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; import { Skeleton } from "@/shared/ui/skeleton"; @@ -25,7 +25,7 @@ export function formatShareRecipientName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } diff --git a/desktop/src/features/agents/ui/RespondToField.tsx b/desktop/src/features/agents/ui/RespondToField.tsx index 400f93af7c..d910bfc8a6 100644 --- a/desktop/src/features/agents/ui/RespondToField.tsx +++ b/desktop/src/features/agents/ui/RespondToField.tsx @@ -4,7 +4,8 @@ import { mergeAllowlist, parsePubkeyInput, } from "@/features/agents/lib/respondToAllowlist"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { parsePubkeyInput as parseCanonicalPubkey } from "@/shared/lib/nostrUtils"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { PubKey } from "@/shared/ui/PubKey"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserSearchQuery } from "@/features/profile/hooks"; @@ -59,7 +60,7 @@ function formatSearchUserName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } @@ -69,7 +70,7 @@ function formatSearchUserSecondary(user: UserSearchResult) { if (displayName && nip05Handle) { return nip05Handle; } - return truncatePubkey(user.pubkey); + return truncateNpub(user.pubkey); } const RESPOND_TO_OPTIONS: PersonaDropdownOption[] = [ @@ -278,8 +279,6 @@ export function CreateAgentRespondToField({ ); } -const HEX_64_RE = /^[0-9a-f]{64}$/i; - function AllowlistPicker({ allowlist, deferredQuery, @@ -325,10 +324,12 @@ function AllowlistPicker({ }) { const isPersona = variant === "persona"; - // Detect if the query is a valid hex pubkey that's not already in the list. - const queryIsHexPubkey = - HEX_64_RE.test(deferredQuery) && - !allowlist.some((p) => p.toLowerCase() === deferredQuery.toLowerCase()); + // Detect if the query is a pubkey (npub or hex) not already in the list; + // direct entry offers the canonical hex for storage. + const queryPubkey = parseCanonicalPubkey(deferredQuery); + const queryIsDirectPubkey = + queryPubkey !== null && + !allowlist.some((p) => p.toLowerCase() === queryPubkey); return (
))}
- ) : queryIsHexPubkey ? ( + ) : queryIsDirectPubkey ? (