From 7ce9dba85bf633b841fee2f9d15d9b715b937b27 Mon Sep 17 00:00:00 2001 From: careck Date: Sun, 10 May 2026 14:16:23 +1000 Subject: [PATCH 1/4] fix: respect relay 60s rate limit on GET /bundles The relay now enforces a per-account 60s minimum between list_bundles calls (HTTP 429). Two independent React polling hooks both fired immediately on mount and every 60s, causing overlapping requests. - Increase both poll intervals from 60s to 120s (2x safety margin) - Offset identity polling by 60s so the two alternate every 60s - In poll_all_identity_snapshots, run either self-transfer discovery OR invite polling per identity (not both), since each calls list_bundles independently - Downgrade 429 log from warn to debug (non-fatal, self-healing) --- .../src-tauri/src/commands/receive_poll.rs | 175 +++++++++--------- .../src/hooks/useIdentityPolling.ts | 19 +- .../src/hooks/useRelayPolling.ts | 2 +- 3 files changed, 103 insertions(+), 93 deletions(-) diff --git a/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs b/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs index 0424c619..13bed898 100644 --- a/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs +++ b/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs @@ -333,7 +333,7 @@ pub async fn poll_receive_workspace( let all_bundles = match client.list_bundles(&device_id) { Ok(b) => b, Err(e) => { - log::warn!("poll_receive_workspace: list_bundles failed: {e}"); + log::debug!("poll_receive_workspace: list_bundles skipped: {e}"); vec![] } }; @@ -847,7 +847,12 @@ pub async fn poll_receive_identity( /// Poll for snapshot bundles across ALL unlocked identities that have /// relay accounts and accepted invites in WaitingSnapshot status. -/// Called on a global 60-second timer — no workspace needed. +/// Called on a 120 s timer offset 60 s from workspace polling to +/// respect the relay's per-account 60 s rate limit on GET /bundles. +/// +/// Each identity takes exactly ONE path per cycle (never both): +/// - Path A (waiting invites): `receive_poll_identity` → list_bundles +/// - Path B (no waiting): self-device transfer discovery → list_bundles #[tauri::command] pub async fn poll_all_identity_snapshots( state: State<'_, AppState>, @@ -863,7 +868,7 @@ pub async fn poll_all_identity_snapshots( }; for uuid in identity_uuids { - // 1. Check relay accounts FIRST (needed for both discovery and polling). + // 1. Check relay accounts (needed for both discovery and invite polling). let relay_accounts = { let rams = state .relay_account_managers @@ -878,11 +883,86 @@ pub async fn poll_all_identity_snapshots( continue; } - // 2. Discover self-device snapshots on the relay that have no matching invite. - // "Send to My Device" puts a snapshot bundle on the relay but the receiving - // device has no AcceptedInvite for it, so normal polling would never find it. - { - // Collect workspace_ids from ALL existing invites (any status). + // 2. Check for waiting invites BEFORE deciding which path to take. + // Both self-transfer discovery and invite polling call list_bundles, + // so we run only ONE per cycle to respect relay rate limits (60 s min). + let waiting = { + let aim = state + .accepted_invite_managers + .lock() + .expect("Mutex poisoned"); + match aim.get(&uuid) { + Some(mgr) => mgr.list_waiting_snapshot().unwrap_or_default(), + None => vec![], + } + }; + + if !waiting.is_empty() { + // ── Path A: Normal invite-based polling ────────────────────── + // receive_poll_identity calls list_bundles internally. + log::debug!( + "poll_all_identity_snapshots: identity={uuid}, {} waiting invite(s), {} relay account(s)", + waiting.len(), relay_accounts.len() + ); + + let temp_dir = std::env::temp_dir(); + let device_id = { + let short = krillnotes_core::get_device_id().map_err(|e| e.to_string())?; + format!("{}:identity:{}", short, uuid) + }; + let result = tokio::task::spawn_blocking(move || { + use krillnotes_core::core::sync::receive_poll::{ + receive_poll_identity, RelayConnection, + }; + use krillnotes_core::core::sync::relay::client::RelayClient; + + let connections: Vec = relay_accounts + .into_iter() + .map(|account| { + let client = RelayClient::new(&account.relay_url) + .with_session_token(&account.session_token); + RelayConnection { account, client } + }) + .collect(); + + receive_poll_identity(&connections, &waiting, &temp_dir, &device_id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + for snapshot in &result.received_snapshots { + { + let mut aim = state + .accepted_invite_managers + .lock() + .expect("Mutex poisoned"); + if let Some(ai_mgr) = aim.get_mut(&uuid) { + let _ = ai_mgr.update_snapshot_path( + snapshot.invite_id, + snapshot.snapshot_path.to_string_lossy().to_string(), + ); + } + } + let _ = app_handle.emit( + "snapshot-received", + serde_json::json!({ + "workspaceId": snapshot.workspace_id, + "inviteId": snapshot.invite_id.to_string(), + "snapshotPath": snapshot.snapshot_path.to_string_lossy(), + }), + ); + } + for error in &result.errors { + log::warn!( + "poll_all_identity_snapshots: identity={uuid}: {}", + error.error + ); + } + } else { + // ── Path B: Self-device transfer discovery ─────────────────── + // No waiting invites, so check for "Send to My Device" snapshots + // whose workspace_id doesn't match any existing invite. let all_invite_ws_ids: std::collections::HashSet = { let aim = state .accepted_invite_managers @@ -940,7 +1020,7 @@ pub async fn poll_all_identity_snapshots( } } Err(e) => { - log::warn!("list_bundles for self-transfer discovery failed: {e}") + log::debug!("list_bundles for self-transfer discovery: {e}") } } } @@ -949,7 +1029,6 @@ pub async fn poll_all_identity_snapshots( .await .map_err(|e| e.to_string())?; - // Create synthetic accepted invites for discovered self-transfers. for (meta, bytes, relay_url) in self_transfers { let snapshot_path = std::env::temp_dir().join(format!("snapshot-{}.bin", Uuid::new_v4())); @@ -999,82 +1078,6 @@ pub async fn poll_all_identity_snapshots( ); } } - - // 3. Normal invite-based polling (existing logic). - let waiting = { - let aim = state - .accepted_invite_managers - .lock() - .expect("Mutex poisoned"); - match aim.get(&uuid) { - Some(mgr) => mgr.list_waiting_snapshot().unwrap_or_default(), - None => continue, - } - }; - if waiting.is_empty() { - continue; - } - - log::debug!( - "poll_all_identity_snapshots: identity={uuid}, {} waiting invite(s), {} relay account(s)", - waiting.len(), relay_accounts.len() - ); - - let temp_dir = std::env::temp_dir(); - let device_id = { - let short = krillnotes_core::get_device_id().map_err(|e| e.to_string())?; - format!("{}:identity:{}", short, uuid) - }; - let result = tokio::task::spawn_blocking(move || { - use krillnotes_core::core::sync::receive_poll::{ - receive_poll_identity, RelayConnection, - }; - use krillnotes_core::core::sync::relay::client::RelayClient; - - let connections: Vec = relay_accounts - .into_iter() - .map(|account| { - let client = RelayClient::new(&account.relay_url) - .with_session_token(&account.session_token); - RelayConnection { account, client } - }) - .collect(); - - receive_poll_identity(&connections, &waiting, &temp_dir, &device_id) - .map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())??; - - // Update accepted invites with snapshot paths and emit events. - for snapshot in &result.received_snapshots { - { - let mut aim = state - .accepted_invite_managers - .lock() - .expect("Mutex poisoned"); - if let Some(ai_mgr) = aim.get_mut(&uuid) { - let _ = ai_mgr.update_snapshot_path( - snapshot.invite_id, - snapshot.snapshot_path.to_string_lossy().to_string(), - ); - } - } - let _ = app_handle.emit( - "snapshot-received", - serde_json::json!({ - "workspaceId": snapshot.workspace_id, - "inviteId": snapshot.invite_id.to_string(), - "snapshotPath": snapshot.snapshot_path.to_string_lossy(), - }), - ); - } - for error in &result.errors { - log::warn!( - "poll_all_identity_snapshots: identity={uuid}: {}", - error.error - ); - } } Ok(()) diff --git a/krillnotes-desktop/src/hooks/useIdentityPolling.ts b/krillnotes-desktop/src/hooks/useIdentityPolling.ts index a9dbe070..2eade8e0 100644 --- a/krillnotes-desktop/src/hooks/useIdentityPolling.ts +++ b/krillnotes-desktop/src/hooks/useIdentityPolling.ts @@ -1,16 +1,17 @@ import { useEffect, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; -const POLL_INTERVAL_MS = 60_000; +const POLL_INTERVAL_MS = 120_000; +const INITIAL_DELAY_MS = 60_000; /** * Global snapshot polling for ALL unlocked identities. - * The Rust command iterates over every unlocked identity that has - * relay accounts + accepted invites in WaitingSnapshot status. - * No conditions needed on the frontend — just call on a timer. + * Offset by 60 s from workspace polling so the two never hit the + * same relay within the relay's 60 s rate-limit window. */ export function useGlobalSnapshotPolling() { const intervalRef = useRef | null>(null); + const delayRef = useRef | null>(null); useEffect(() => { const poll = async () => { @@ -21,10 +22,16 @@ export function useGlobalSnapshotPolling() { } }; - poll(); // immediate first poll - intervalRef.current = setInterval(poll, POLL_INTERVAL_MS); + delayRef.current = setTimeout(() => { + poll(); + intervalRef.current = setInterval(poll, POLL_INTERVAL_MS); + }, INITIAL_DELAY_MS); return () => { + if (delayRef.current) { + clearTimeout(delayRef.current); + delayRef.current = null; + } if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; diff --git a/krillnotes-desktop/src/hooks/useRelayPolling.ts b/krillnotes-desktop/src/hooks/useRelayPolling.ts index 9cd31259..eb483436 100644 --- a/krillnotes-desktop/src/hooks/useRelayPolling.ts +++ b/krillnotes-desktop/src/hooks/useRelayPolling.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; -const POLL_INTERVAL_MS = 60_000; +const POLL_INTERVAL_MS = 120_000; export function useRelayPolling(hasRelayPeers: boolean) { const intervalRef = useRef | null>(null); From b2753cabfc2a4ec2f840dae2905af15ecc009ef3 Mon Sep 17 00:00:00 2001 From: careck Date: Sun, 10 May 2026 14:26:28 +1000 Subject: [PATCH 2/4] fix: parse Retry-After header and restore 60s intervals With the relay rate limit reduced from 60s to 15s per account, restore the original 60s poll intervals (with 30s stagger). - Parse Retry-After header from 429 responses into RelayRateLimited { message, retry_after_secs } - Log retry_after_secs at debug level in poll commands - Restore 60s intervals with 30s identity-poll offset --- krillnotes-core/src/core/error.rs | 15 ++++++++++--- krillnotes-core/src/core/sync/relay/client.rs | 11 +++++++++- .../src-tauri/src/commands/receive_poll.rs | 21 +++++++++++++++++-- .../src/hooks/useIdentityPolling.ts | 8 +++---- .../src/hooks/useRelayPolling.ts | 2 +- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/krillnotes-core/src/core/error.rs b/krillnotes-core/src/core/error.rs index e4680357..54881057 100644 --- a/krillnotes-core/src/core/error.rs +++ b/krillnotes-core/src/core/error.rs @@ -138,8 +138,11 @@ pub enum KrillnotesError { RelayAuthExpired(String), /// Relay rate limit exceeded (HTTP 429). - #[error("Relay rate limited: {0}")] - RelayRateLimited(String), + #[error("Relay rate limited: {message}")] + RelayRateLimited { + message: String, + retry_after_secs: Option, + }, /// Relay resource not found or expired (HTTP 404/410). #[error("Relay not found: {0}")] @@ -240,7 +243,13 @@ impl KrillnotesError { Self::Zip(e) => format!("Bundle archive error: {e}"), Self::NotOwner => "Only the workspace owner can modify scripts".to_string(), Self::RelayAuthExpired(_) => "Relay session expired. Please log in again.".to_string(), - Self::RelayRateLimited(_) => "Relay is rate limiting requests. Please try again later.".to_string(), + Self::RelayRateLimited { retry_after_secs, .. } => { + if let Some(secs) = retry_after_secs { + format!("Relay is rate limiting requests. Please try again in {secs} seconds.") + } else { + "Relay is rate limiting requests. Please try again later.".to_string() + } + } Self::RelayNotFound(_) => "The requested relay resource was not found or has expired.".to_string(), Self::RelayUnavailable(msg) => format!("Relay server unavailable: {msg}"), Self::Permission(e) => format!("Permission denied: {e}"), diff --git a/krillnotes-core/src/core/sync/relay/client.rs b/krillnotes-core/src/core/sync/relay/client.rs index 5325e821..91371f09 100644 --- a/krillnotes-core/src/core/sync/relay/client.rs +++ b/krillnotes-core/src/core/sync/relay/client.rs @@ -260,6 +260,12 @@ impl RelayClient { fn map_error(resp: reqwest::blocking::Response) -> KrillnotesError { let status = resp.status().as_u16(); + // Parse Retry-After header before consuming the response body. + let retry_after_secs = resp + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()); let body = resp.text().unwrap_or_default(); // Extract human-readable message from {"error":{"message":"..."}} envelope. let message = Self::extract_error_message(&body).unwrap_or_else(|| { @@ -273,7 +279,10 @@ impl RelayClient { 401 => KrillnotesError::RelayAuthExpired(message), 404 | 410 => KrillnotesError::RelayNotFound(message), 409 => KrillnotesError::RelayUnavailable(format!("HTTP 409: {message}")), - 429 => KrillnotesError::RelayRateLimited(message), + 429 => KrillnotesError::RelayRateLimited { + message, + retry_after_secs, + }, _ => KrillnotesError::RelayUnavailable(format!("HTTP {status}: {message}")), } } diff --git a/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs b/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs index 13bed898..509a2309 100644 --- a/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs +++ b/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs @@ -6,6 +6,7 @@ use crate::AppState; use krillnotes_core::core::sync::relay::RelayClient; +use krillnotes_core::KrillnotesError; use krillnotes_core::core::sync::SyncChannel; use serde::Serialize; use std::sync::Arc; @@ -332,6 +333,13 @@ pub async fn poll_receive_workspace( // List all pending relay bundles (single API call). let all_bundles = match client.list_bundles(&device_id) { Ok(b) => b, + Err(ref e @ KrillnotesError::RelayRateLimited { retry_after_secs, .. }) => { + log::debug!( + "poll_receive_workspace: list_bundles rate-limited, retry_after={:?}: {e}", + retry_after_secs + ); + vec![] + } Err(e) => { log::debug!("poll_receive_workspace: list_bundles skipped: {e}"); vec![] @@ -847,8 +855,8 @@ pub async fn poll_receive_identity( /// Poll for snapshot bundles across ALL unlocked identities that have /// relay accounts and accepted invites in WaitingSnapshot status. -/// Called on a 120 s timer offset 60 s from workspace polling to -/// respect the relay's per-account 60 s rate limit on GET /bundles. +/// Called on a 60 s timer offset 30 s from workspace polling to +/// respect the relay's per-account 15 s rate limit on GET /bundles. /// /// Each identity takes exactly ONE path per cycle (never both): /// - Path A (waiting invites): `receive_poll_identity` → list_bundles @@ -1019,6 +1027,15 @@ pub async fn poll_all_identity_snapshots( } } } + Err(krillnotes_core::KrillnotesError::RelayRateLimited { + retry_after_secs, + .. + }) => { + log::debug!( + "list_bundles for self-transfer discovery: rate-limited, retry_after={:?}", + retry_after_secs + ); + } Err(e) => { log::debug!("list_bundles for self-transfer discovery: {e}") } diff --git a/krillnotes-desktop/src/hooks/useIdentityPolling.ts b/krillnotes-desktop/src/hooks/useIdentityPolling.ts index 2eade8e0..8d944f9c 100644 --- a/krillnotes-desktop/src/hooks/useIdentityPolling.ts +++ b/krillnotes-desktop/src/hooks/useIdentityPolling.ts @@ -1,13 +1,13 @@ import { useEffect, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; -const POLL_INTERVAL_MS = 120_000; -const INITIAL_DELAY_MS = 60_000; +const POLL_INTERVAL_MS = 60_000; +const INITIAL_DELAY_MS = 30_000; /** * Global snapshot polling for ALL unlocked identities. - * Offset by 60 s from workspace polling so the two never hit the - * same relay within the relay's 60 s rate-limit window. + * Offset by 30 s from workspace polling so the two never hit the + * same relay within the relay's 15 s per-account rate-limit window. */ export function useGlobalSnapshotPolling() { const intervalRef = useRef | null>(null); diff --git a/krillnotes-desktop/src/hooks/useRelayPolling.ts b/krillnotes-desktop/src/hooks/useRelayPolling.ts index eb483436..9cd31259 100644 --- a/krillnotes-desktop/src/hooks/useRelayPolling.ts +++ b/krillnotes-desktop/src/hooks/useRelayPolling.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; -const POLL_INTERVAL_MS = 120_000; +const POLL_INTERVAL_MS = 60_000; export function useRelayPolling(hasRelayPeers: boolean) { const intervalRef = useRef | null>(null); From 78402448cc6c84d09a55cc7ee5b2f0e714bfdb23 Mon Sep 17 00:00:00 2001 From: careck Date: Sun, 10 May 2026 20:24:45 +1000 Subject: [PATCH 3/4] revert: remove client-side rate limit workarounds With the relay poll rate limiter removed from GET /bundles, revert the client workarounds: - Restore original 60s intervals with immediate first poll - Restore both self-transfer discovery AND invite polling per cycle (no mutual exclusion) Keep the Retry-After parsing in RelayRateLimited as defensive code in case rate limiting is re-enabled in the future. --- .../src-tauri/src/commands/receive_poll.rs | 175 ++++++++---------- .../src/hooks/useIdentityPolling.ts | 17 +- 2 files changed, 85 insertions(+), 107 deletions(-) diff --git a/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs b/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs index 509a2309..e06d836f 100644 --- a/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs +++ b/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs @@ -855,12 +855,7 @@ pub async fn poll_receive_identity( /// Poll for snapshot bundles across ALL unlocked identities that have /// relay accounts and accepted invites in WaitingSnapshot status. -/// Called on a 60 s timer offset 30 s from workspace polling to -/// respect the relay's per-account 15 s rate limit on GET /bundles. -/// -/// Each identity takes exactly ONE path per cycle (never both): -/// - Path A (waiting invites): `receive_poll_identity` → list_bundles -/// - Path B (no waiting): self-device transfer discovery → list_bundles +/// Called on a global 60-second timer — no workspace needed. #[tauri::command] pub async fn poll_all_identity_snapshots( state: State<'_, AppState>, @@ -891,86 +886,10 @@ pub async fn poll_all_identity_snapshots( continue; } - // 2. Check for waiting invites BEFORE deciding which path to take. - // Both self-transfer discovery and invite polling call list_bundles, - // so we run only ONE per cycle to respect relay rate limits (60 s min). - let waiting = { - let aim = state - .accepted_invite_managers - .lock() - .expect("Mutex poisoned"); - match aim.get(&uuid) { - Some(mgr) => mgr.list_waiting_snapshot().unwrap_or_default(), - None => vec![], - } - }; - - if !waiting.is_empty() { - // ── Path A: Normal invite-based polling ────────────────────── - // receive_poll_identity calls list_bundles internally. - log::debug!( - "poll_all_identity_snapshots: identity={uuid}, {} waiting invite(s), {} relay account(s)", - waiting.len(), relay_accounts.len() - ); - - let temp_dir = std::env::temp_dir(); - let device_id = { - let short = krillnotes_core::get_device_id().map_err(|e| e.to_string())?; - format!("{}:identity:{}", short, uuid) - }; - let result = tokio::task::spawn_blocking(move || { - use krillnotes_core::core::sync::receive_poll::{ - receive_poll_identity, RelayConnection, - }; - use krillnotes_core::core::sync::relay::client::RelayClient; - - let connections: Vec = relay_accounts - .into_iter() - .map(|account| { - let client = RelayClient::new(&account.relay_url) - .with_session_token(&account.session_token); - RelayConnection { account, client } - }) - .collect(); - - receive_poll_identity(&connections, &waiting, &temp_dir, &device_id) - .map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())??; - - for snapshot in &result.received_snapshots { - { - let mut aim = state - .accepted_invite_managers - .lock() - .expect("Mutex poisoned"); - if let Some(ai_mgr) = aim.get_mut(&uuid) { - let _ = ai_mgr.update_snapshot_path( - snapshot.invite_id, - snapshot.snapshot_path.to_string_lossy().to_string(), - ); - } - } - let _ = app_handle.emit( - "snapshot-received", - serde_json::json!({ - "workspaceId": snapshot.workspace_id, - "inviteId": snapshot.invite_id.to_string(), - "snapshotPath": snapshot.snapshot_path.to_string_lossy(), - }), - ); - } - for error in &result.errors { - log::warn!( - "poll_all_identity_snapshots: identity={uuid}: {}", - error.error - ); - } - } else { - // ── Path B: Self-device transfer discovery ─────────────────── - // No waiting invites, so check for "Send to My Device" snapshots - // whose workspace_id doesn't match any existing invite. + // 2. Discover self-device snapshots on the relay that have no matching invite. + // "Send to My Device" puts a snapshot bundle on the relay but the receiving + // device has no AcceptedInvite for it, so normal polling would never find it. + { let all_invite_ws_ids: std::collections::HashSet = { let aim = state .accepted_invite_managers @@ -1027,15 +946,6 @@ pub async fn poll_all_identity_snapshots( } } } - Err(krillnotes_core::KrillnotesError::RelayRateLimited { - retry_after_secs, - .. - }) => { - log::debug!( - "list_bundles for self-transfer discovery: rate-limited, retry_after={:?}", - retry_after_secs - ); - } Err(e) => { log::debug!("list_bundles for self-transfer discovery: {e}") } @@ -1095,6 +1005,81 @@ pub async fn poll_all_identity_snapshots( ); } } + + // 3. Normal invite-based polling (existing logic). + let waiting = { + let aim = state + .accepted_invite_managers + .lock() + .expect("Mutex poisoned"); + match aim.get(&uuid) { + Some(mgr) => mgr.list_waiting_snapshot().unwrap_or_default(), + None => continue, + } + }; + if waiting.is_empty() { + continue; + } + + log::debug!( + "poll_all_identity_snapshots: identity={uuid}, {} waiting invite(s), {} relay account(s)", + waiting.len(), relay_accounts.len() + ); + + let temp_dir = std::env::temp_dir(); + let device_id = { + let short = krillnotes_core::get_device_id().map_err(|e| e.to_string())?; + format!("{}:identity:{}", short, uuid) + }; + let result = tokio::task::spawn_blocking(move || { + use krillnotes_core::core::sync::receive_poll::{ + receive_poll_identity, RelayConnection, + }; + use krillnotes_core::core::sync::relay::client::RelayClient; + + let connections: Vec = relay_accounts + .into_iter() + .map(|account| { + let client = RelayClient::new(&account.relay_url) + .with_session_token(&account.session_token); + RelayConnection { account, client } + }) + .collect(); + + receive_poll_identity(&connections, &waiting, &temp_dir, &device_id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + for snapshot in &result.received_snapshots { + { + let mut aim = state + .accepted_invite_managers + .lock() + .expect("Mutex poisoned"); + if let Some(ai_mgr) = aim.get_mut(&uuid) { + let _ = ai_mgr.update_snapshot_path( + snapshot.invite_id, + snapshot.snapshot_path.to_string_lossy().to_string(), + ); + } + } + let _ = app_handle.emit( + "snapshot-received", + serde_json::json!({ + "workspaceId": snapshot.workspace_id, + "inviteId": snapshot.invite_id.to_string(), + "snapshotPath": snapshot.snapshot_path.to_string_lossy(), + }), + ); + } + for error in &result.errors { + log::warn!( + "poll_all_identity_snapshots: identity={uuid}: {}", + error.error + ); + } } Ok(()) diff --git a/krillnotes-desktop/src/hooks/useIdentityPolling.ts b/krillnotes-desktop/src/hooks/useIdentityPolling.ts index 8d944f9c..a9dbe070 100644 --- a/krillnotes-desktop/src/hooks/useIdentityPolling.ts +++ b/krillnotes-desktop/src/hooks/useIdentityPolling.ts @@ -2,16 +2,15 @@ import { useEffect, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; const POLL_INTERVAL_MS = 60_000; -const INITIAL_DELAY_MS = 30_000; /** * Global snapshot polling for ALL unlocked identities. - * Offset by 30 s from workspace polling so the two never hit the - * same relay within the relay's 15 s per-account rate-limit window. + * The Rust command iterates over every unlocked identity that has + * relay accounts + accepted invites in WaitingSnapshot status. + * No conditions needed on the frontend — just call on a timer. */ export function useGlobalSnapshotPolling() { const intervalRef = useRef | null>(null); - const delayRef = useRef | null>(null); useEffect(() => { const poll = async () => { @@ -22,16 +21,10 @@ export function useGlobalSnapshotPolling() { } }; - delayRef.current = setTimeout(() => { - poll(); - intervalRef.current = setInterval(poll, POLL_INTERVAL_MS); - }, INITIAL_DELAY_MS); + poll(); // immediate first poll + intervalRef.current = setInterval(poll, POLL_INTERVAL_MS); return () => { - if (delayRef.current) { - clearTimeout(delayRef.current); - delayRef.current = null; - } if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; From 3daa2ecc11c1420d9a03d1ee423141827d064ddb Mon Sep 17 00:00:00 2001 From: careck Date: Sun, 10 May 2026 20:48:31 +1000 Subject: [PATCH 4/4] fix: clarify attachment blob skip log for deleted notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delta builder's attachment size pre-scan logged a warn for every AddAttachment op whose note was already deleted. This is normal (op log is append-only) — downgrade to debug with a clearer message explaining the blob is unavailable because the note was likely deleted. --- krillnotes-core/src/core/swarm/sync.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/krillnotes-core/src/core/swarm/sync.rs b/krillnotes-core/src/core/swarm/sync.rs index f1717d24..f08a066c 100644 --- a/krillnotes-core/src/core/swarm/sync.rs +++ b/krillnotes-core/src/core/swarm/sync.rs @@ -123,8 +123,8 @@ pub fn generate_delta( attachment_sizes.insert(attachment_id.clone(), size); } Err(e) => { - log::warn!(target: "krillnotes::sync", - "Could not query size for attachment {}, will skip blob: {e}", + log::debug!(target: "krillnotes::sync", + "attachment {} blob unavailable (note likely deleted), skipping sidecar: {e}", attachment_id); } }