use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, Weak};
use std::time::Instant;
use nostr_sdk::prelude::*;
use std::sync::LazyLock;
use crate::state::nostr_client;
pub struct EventPublishTracker {
event_id: EventId,
successes: Mutex<Vec<RelayUrl>>,
notify: tokio::sync::Notify,
in_flight: AtomicUsize,
}
impl EventPublishTracker {
fn new(event_id: EventId, initial_in_flight: usize) -> Arc<Self> {
Arc::new(Self {
event_id,
successes: Mutex::new(Vec::new()),
notify: tokio::sync::Notify::new(),
in_flight: AtomicUsize::new(initial_in_flight),
})
}
fn note_success(&self, url: RelayUrl) {
self.successes.lock().unwrap().push(url);
self.notify.notify_waiters();
}
fn note_settled(&self) {
if self.in_flight.fetch_sub(1, Ordering::SeqCst) == 1 {
self.notify.notify_waiters();
PUBLISH_TRACKERS.lock().unwrap().remove(&self.event_id);
}
}
pub async fn next_success(&self, cursor: &mut usize) -> Option<RelayUrl> {
loop {
let notified = self.notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
let (next, done) = {
let successes = self.successes.lock().unwrap();
let next = successes.get(*cursor).cloned();
let done = self.in_flight.load(Ordering::SeqCst) == 0
&& *cursor >= successes.len();
(next, done)
};
if let Some(url) = next {
*cursor += 1;
return Some(url);
}
if done {
return None;
}
notified.await;
}
}
}
static PUBLISH_TRACKERS: LazyLock<Mutex<HashMap<EventId, Arc<EventPublishTracker>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
pub fn get_publish_tracker(event_id: &EventId) -> Option<Arc<EventPublishTracker>> {
PUBLISH_TRACKERS.lock().unwrap().get(event_id).cloned()
}
pub fn spawn_tracked_publish(
resolved: Vec<(RelayUrl, Relay)>,
event: Event,
) -> Vec<tokio::task::JoinHandle<(RelayUrl, Result<EventId, String>)>> {
let event_id = event.id;
if resolved.is_empty() {
return Vec::new();
}
let tracker = EventPublishTracker::new(event_id, resolved.len());
PUBLISH_TRACKERS.lock().unwrap().insert(event_id, tracker.clone());
let mut handles = Vec::with_capacity(resolved.len());
for (url, relay) in resolved {
let event = event.clone();
let tracker = tracker.clone();
handles.push(tokio::spawn(async move {
let result = relay
.send_event(&event)
.await
.map_err(|e| e.to_string());
if result.is_ok() {
tracker.note_success(url.clone());
}
tracker.note_settled();
(url, result)
}));
}
handles
}
const CACHE_TTL_SECS: u64 = 3600;
const CACHE_TTL_ERROR_SECS: u64 = 60;
struct CachedRelays {
relays: Vec<String>,
fetched_at: Instant,
fetch_ok: bool,
}
static INBOX_RELAY_CACHE: LazyLock<Mutex<HashMap<PublicKey, CachedRelays>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
pub fn clear_inbox_relay_cache() {
if let Ok(mut cache) = INBOX_RELAY_CACHE.lock() {
cache.clear();
}
}
static FETCH_LOCKS: LazyLock<Mutex<HashMap<PublicKey, Weak<tokio::sync::Mutex<()>>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
static PRUNE_COUNTER: AtomicU64 = AtomicU64::new(0);
#[cfg(not(test))]
const PRUNE_INTERVAL: u64 = 100;
#[cfg(test)]
const PRUNE_INTERVAL: u64 = 1;
struct FetchLockEntryCleanup {
pubkey: PublicKey,
key_lock: Arc<tokio::sync::Mutex<()>>,
}
impl FetchLockEntryCleanup {
fn new(pubkey: PublicKey, key_lock: Arc<tokio::sync::Mutex<()>>) -> Self {
Self { pubkey, key_lock }
}
}
impl Drop for FetchLockEntryCleanup {
fn drop(&mut self) {
let mut locks = match FETCH_LOCKS.lock() {
Ok(locks) => locks,
Err(_) => return, };
let should_remove = match locks.get(&self.pubkey).and_then(|weak| weak.upgrade()) {
Some(current) => {
Arc::ptr_eq(¤t, &self.key_lock) && Arc::strong_count(¤t) == 2
}
None => false,
};
if should_remove {
locks.remove(&self.pubkey);
}
}
}
struct FetchResult {
relays: Vec<String>,
fetch_ok: bool,
}
async fn fetch_inbox_relays(client: &Client, pubkey: &PublicKey) -> FetchResult {
let filter = Filter::new()
.author(*pubkey)
.kind(Kind::Custom(10050))
.limit(1);
let events = match client
.fetch_events(filter, std::time::Duration::from_secs(5))
.await
{
Ok(events) => events,
Err(e) => {
eprintln!("[InboxRelays] Failed to fetch 10050 for {}: {}", pubkey, e);
return FetchResult { relays: Vec::new(), fetch_ok: false };
}
};
let event = match events.into_iter().next() {
Some(e) => e,
None => return FetchResult { relays: Vec::new(), fetch_ok: true },
};
FetchResult { relays: parse_relay_tags(&event.tags), fetch_ok: true }
}
fn parse_relay_tags(tags: &Tags) -> Vec<String> {
tags.iter()
.filter_map(|tag| {
let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
if values.len() >= 2 && values[0] == "relay" {
Some(values[1].to_string())
} else {
None
}
})
.collect()
}
async fn get_or_fetch_with_lock<F, Fut>(pubkey: &PublicKey, fetch_fn: F) -> Vec<String>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = FetchResult>,
{
{
let cache = INBOX_RELAY_CACHE.lock().unwrap();
if let Some(entry) = cache.get(pubkey) {
let ttl = if entry.fetch_ok { CACHE_TTL_SECS } else { CACHE_TTL_ERROR_SECS };
if entry.fetched_at.elapsed().as_secs() < ttl {
return entry.relays.clone();
}
}
}
let cleanup_guard = {
let mut locks = FETCH_LOCKS.lock().unwrap();
if PRUNE_COUNTER.fetch_add(1, Ordering::Relaxed) % PRUNE_INTERVAL == 0 {
locks.retain(|_, weak| Weak::strong_count(weak) > 0);
}
let weak = locks.entry(*pubkey).or_insert_with(|| Weak::new());
let key_lock = match weak.upgrade() {
Some(arc) => arc,
None => {
let new_arc = Arc::new(tokio::sync::Mutex::new(()));
*weak = Arc::downgrade(&new_arc);
new_arc
}
};
FetchLockEntryCleanup::new(*pubkey, key_lock)
};
let relays = {
let _guard = cleanup_guard.key_lock.lock().await;
let cached_relays = {
let cache = INBOX_RELAY_CACHE.lock().unwrap();
if let Some(entry) = cache.get(pubkey) {
let ttl = if entry.fetch_ok { CACHE_TTL_SECS } else { CACHE_TTL_ERROR_SECS };
if entry.fetched_at.elapsed().as_secs() < ttl {
Some(entry.relays.clone())
} else {
None
}
} else {
None
}
};
match cached_relays {
Some(relays) => relays,
None => {
let result = fetch_fn().await;
{
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
cache.insert(
*pubkey,
CachedRelays {
relays: result.relays.clone(),
fetched_at: Instant::now(),
fetch_ok: result.fetch_ok,
},
);
}
result.relays
}
}
};
drop(cleanup_guard);
relays
}
async fn get_or_fetch_inbox_relays(client: &Client, pubkey: &PublicKey) -> Vec<String> {
get_or_fetch_with_lock(pubkey, || fetch_inbox_relays(client, pubkey)).await
}
static TRUSTED_RELAY_URLS: LazyLock<Vec<RelayUrl>> = LazyLock::new(|| {
crate::state::TRUSTED_RELAYS
.iter()
.filter_map(|s| RelayUrl::parse(s).ok())
.collect()
});
pub fn trusted_relay_urls() -> Vec<RelayUrl> {
TRUSTED_RELAY_URLS.clone()
}
pub async fn send_event_first_ok(
client: &Client,
urls: Vec<RelayUrl>,
event: &Event,
) -> Result<Output<EventId>, nostr_sdk::client::Error> {
let pool = client.pool();
let relays = pool.relays().await;
let event_id = event.id;
let mut resolved: Vec<(RelayUrl, Relay)> = Vec::new();
for url in urls {
if let Some(relay) = relays.get(&url) {
resolved.push((url, relay.clone()));
}
}
if resolved.is_empty() {
return client.send_event(event).await;
}
let handles = spawn_tracked_publish(resolved, event.clone());
let mut output = Output {
val: event_id,
success: std::collections::HashSet::new(),
failed: HashMap::new(),
};
let mut remaining = handles;
while !remaining.is_empty() {
let (result, _index, rest) = futures_util::future::select_all(remaining).await;
remaining = rest;
if let Ok((url, relay_result)) = result {
match relay_result {
Ok(_) => {
output.success.insert(url);
drop(remaining);
return Ok(output);
}
Err(e) => {
output.failed.insert(url, e);
}
}
}
}
Ok(output)
}
pub async fn send_event_pool_first_ok(
client: &Client,
event: &Event,
) -> Result<Output<EventId>, nostr_sdk::client::Error> {
let pool = client.pool();
let relays = pool.relays().await;
let write_urls: Vec<RelayUrl> = relays
.iter()
.filter(|(_, r)| r.flags().has_write())
.map(|(url, _)| url.clone())
.collect();
send_event_first_ok(&client, write_urls, event).await
}
pub fn wrap_with_retained_key(
receiver: &PublicKey,
seal: &Event,
extra_tags: impl IntoIterator<Item = Tag>,
) -> Result<(Event, SecretKey), String> {
use nostr_sdk::nips::nip44;
use nostr_sdk::nips::nip59::RANGE_RANDOM_TIMESTAMP_TWEAK;
if seal.kind != Kind::Seal {
return Err(format!("expected Seal kind, got {:?}", seal.kind));
}
let keys = Keys::generate();
let secret = keys.secret_key().clone();
let content = nip44::encrypt(
keys.secret_key(),
receiver,
seal.as_json(),
nip44::Version::default(),
)
.map_err(|e| format!("nip44 encrypt: {}", e))?;
let mut tags: Vec<Tag> = extra_tags.into_iter().collect();
tags.push(Tag::public_key(*receiver));
let event = EventBuilder::new(Kind::GiftWrap, content)
.tags(tags)
.custom_created_at(Timestamp::tweaked(RANGE_RANDOM_TIMESTAMP_TWEAK))
.sign_with_keys(&keys)
.map_err(|e| format!("sign wrap: {}", e))?;
Ok((event, secret))
}
pub struct GiftWrapSendOutcome {
pub output: Output<EventId>,
pub wrap_event_id: EventId,
pub wrap_secret: SecretKey,
pub targeted_relays: Vec<String>,
}
pub async fn send_gift_wrap_retained(
client: &Client,
recipient: &PublicKey,
rumor: UnsignedEvent,
extra_tags: impl IntoIterator<Item = Tag>,
) -> Result<GiftWrapSendOutcome, String> {
let signer = client.signer().await.map_err(|e| e.to_string())?;
let seal: Event = EventBuilder::seal(&signer, recipient, rumor)
.await
.map_err(|e| e.to_string())?
.sign(&signer)
.await
.map_err(|e| e.to_string())?;
let (event, secret) = wrap_with_retained_key(recipient, &seal, extra_tags)?;
let wrap_event_id = event.id;
let inbox_strs = get_or_fetch_inbox_relays(client, recipient).await;
let targeted_strs: Vec<String> = if !inbox_strs.is_empty() {
inbox_strs.clone()
} else {
let pool = client.pool();
let relays = pool.relays().await;
relays.iter()
.filter(|(_, r)| r.flags().has_write())
.map(|(url, _)| url.to_string())
.collect()
};
fn normalize_url_for_match(s: &str) -> String {
s.trim_end_matches('/').to_ascii_lowercase()
}
let pool = client.pool();
let pool_relays = pool.relays().await;
let pool_norm: Vec<(String, RelayUrl, Relay)> = pool_relays.iter()
.map(|(url, relay)| (
normalize_url_for_match(&url.to_string()),
url.clone(),
relay.clone(),
))
.collect();
let mut resolved: Vec<(RelayUrl, Relay)> = targeted_strs
.iter()
.filter_map(|s| {
let norm = normalize_url_for_match(s);
pool_norm.iter()
.find(|(pnorm, _, _)| pnorm == &norm)
.map(|(_, url, relay)| (url.clone(), relay.clone()))
})
.collect();
let mut transient_added: Vec<RelayUrl> = Vec::new();
if !inbox_strs.is_empty() {
for s in &targeted_strs {
let norm = normalize_url_for_match(s);
let in_pool = pool_norm.iter().any(|(p, _, _)| p == &norm);
let already_added = transient_added.iter()
.any(|u| normalize_url_for_match(&u.to_string()) == norm);
if in_pool || already_added { continue; }
let opts = crate::tor_aware_relay_options(RelayOptions::new().reconnect(false));
if pool.add_relay(s.as_str(), opts).await.is_ok() {
if let Ok(relay) = pool.relay(s.as_str()).await {
let _ = relay.try_connect(std::time::Duration::from_secs(6)).await;
transient_added.push(relay.url().clone());
resolved.push((relay.url().clone(), relay));
}
}
}
if !transient_added.is_empty() {
crate::log_info!(
"[InboxRelays] on-demand connected {} inbox relay(s) for {} (transient)",
transient_added.len(),
recipient,
);
}
}
if resolved.is_empty() {
let output = client
.send_event(&event)
.await
.map_err(|e| e.to_string())?;
return Ok(GiftWrapSendOutcome {
output,
wrap_event_id,
wrap_secret: secret,
targeted_relays: targeted_strs,
});
}
if !inbox_strs.is_empty() {
println!(
"[InboxRelays] Routing gift-wrap to {} inbox relays for {}",
resolved.len(),
recipient
);
}
let handles = spawn_tracked_publish(resolved, event.clone());
let mut output = Output {
val: wrap_event_id,
success: HashSet::new(),
failed: HashMap::new(),
};
let mut remaining = handles;
while !remaining.is_empty() {
let (result, _idx, rest) = futures_util::future::select_all(remaining).await;
remaining = rest;
if let Ok((url, relay_result)) = result {
match relay_result {
Ok(_) => {
output.success.insert(url);
drop(remaining);
break;
}
Err(e) => {
output.failed.insert(url, e.to_string());
}
}
}
}
for url in &transient_added {
let _ = pool.remove_relay(url).await;
}
Ok(GiftWrapSendOutcome {
output,
wrap_event_id,
wrap_secret: secret,
targeted_relays: targeted_strs,
})
}
pub async fn send_gift_wrap(
client: &Client,
recipient: &PublicKey,
rumor: UnsignedEvent,
extra_tags: impl IntoIterator<Item = Tag>,
) -> Result<Output<EventId>, String> {
let outcome = send_gift_wrap_retained(client, recipient, rumor, extra_tags).await?;
Ok(outcome.output)
}
pub async fn publish_inbox_relays(client: &Client) -> Result<(), String> {
let relays: Vec<String> = client
.pool()
.relays()
.await
.iter()
.filter(|(_, relay)| relay.flags().has_read())
.map(|(url, _)| url.to_string())
.collect();
let mut builder = EventBuilder::new(Kind::Custom(10050), "");
for url in &relays {
builder = builder.tag(Tag::custom(TagKind::custom("relay"), vec![url.clone()]));
}
client
.send_event_builder(builder)
.await
.map_err(|e| format!("Failed to publish inbox relays: {}", e))?;
println!(
"[InboxRelays] Published kind 10050 with {} relay(s)",
relays.len()
);
Ok(())
}
static REPUBLISH_GEN: AtomicU64 = AtomicU64::new(0);
#[cfg(test)]
static DEBOUNCE_PASS_COUNT: AtomicU64 = AtomicU64::new(0);
pub fn republish_inbox_relays_debounced() {
let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
let session = crate::state::SessionGuard::capture();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
if REPUBLISH_GEN.load(Ordering::SeqCst) != gen {
return; }
if !session.is_valid() {
return; }
#[cfg(test)]
DEBOUNCE_PASS_COUNT.fetch_add(1, Ordering::SeqCst);
let client = match nostr_client() {
Some(c) => c,
None => return,
};
if let Err(e) = publish_inbox_relays(&client).await {
eprintln!("[InboxRelays] Failed to republish after config change: {}", e);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_relay_tags_extracts_urls() {
let tags = Tags::from_list(vec![
Tag::custom(TagKind::custom("relay"), vec!["wss://relay.example.com"]),
Tag::custom(TagKind::custom("relay"), vec!["wss://other.example.com"]),
]);
let result = parse_relay_tags(&tags);
assert_eq!(result, vec![
"wss://relay.example.com".to_string(),
"wss://other.example.com".to_string(),
]);
}
#[test]
fn parse_relay_tags_ignores_non_relay_tags() {
let tags = Tags::from_list(vec![
Tag::custom(TagKind::custom("relay"), vec!["wss://good.example.com"]),
Tag::custom(TagKind::custom("p"), vec!["deadbeef"]),
Tag::custom(TagKind::custom("e"), vec!["cafebabe"]),
]);
let result = parse_relay_tags(&tags);
assert_eq!(result, vec!["wss://good.example.com".to_string()]);
}
#[test]
fn parse_relay_tags_empty() {
let tags = Tags::new();
let result = parse_relay_tags(&tags);
assert!(result.is_empty());
}
#[test]
fn parse_relay_tags_ignores_relay_tag_without_value() {
let tags = Tags::from_list(vec![
Tag::custom(TagKind::custom("relay"), Vec::<String>::new()),
]);
let result = parse_relay_tags(&tags);
assert!(result.is_empty());
}
fn test_pubkey() -> PublicKey {
let keys = Keys::generate();
keys.public_key()
}
static TEST_GLOBALS_LOCK: LazyLock<tokio::sync::Mutex<()>> =
LazyLock::new(|| tokio::sync::Mutex::new(()));
#[test]
fn cache_stores_and_retrieves() {
let _guard = TEST_GLOBALS_LOCK.blocking_lock();
let pk = test_pubkey();
let relays = vec!["wss://a.example.com".to_string()];
{
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
cache.insert(pk, CachedRelays {
relays: relays.clone(),
fetched_at: Instant::now(),
fetch_ok: true,
});
}
let cache = INBOX_RELAY_CACHE.lock().unwrap();
let entry = cache.get(&pk).unwrap();
assert_eq!(entry.relays, relays);
assert!(entry.fetch_ok);
assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
}
#[test]
fn cache_expires_after_ttl() {
let _guard = TEST_GLOBALS_LOCK.blocking_lock();
let pk = test_pubkey();
{
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
cache.insert(pk, CachedRelays {
relays: vec!["wss://stale.example.com".to_string()],
fetched_at: Instant::now() - std::time::Duration::from_secs(CACHE_TTL_SECS + 1),
fetch_ok: true,
});
}
let cache = INBOX_RELAY_CACHE.lock().unwrap();
let entry = cache.get(&pk).unwrap();
assert!(entry.fetched_at.elapsed().as_secs() >= CACHE_TTL_SECS);
}
#[test]
fn cache_stores_empty_results() {
let _guard = TEST_GLOBALS_LOCK.blocking_lock();
let pk = test_pubkey();
{
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
cache.insert(pk, CachedRelays {
relays: vec![],
fetched_at: Instant::now(),
fetch_ok: true,
});
}
let cache = INBOX_RELAY_CACHE.lock().unwrap();
let entry = cache.get(&pk).unwrap();
assert!(entry.relays.is_empty());
assert!(entry.fetch_ok);
assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
}
#[test]
fn cache_error_uses_short_ttl() {
let _guard = TEST_GLOBALS_LOCK.blocking_lock();
let pk = test_pubkey();
{
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
cache.insert(pk, CachedRelays {
relays: vec![],
fetched_at: Instant::now() - std::time::Duration::from_secs(120),
fetch_ok: false,
});
}
let cache = INBOX_RELAY_CACHE.lock().unwrap();
let entry = cache.get(&pk).unwrap();
assert!(!entry.fetch_ok);
assert!(entry.fetched_at.elapsed().as_secs() >= CACHE_TTL_ERROR_SECS);
assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
}
#[tokio::test]
async fn concurrent_fetches_for_same_pubkey_serialize() {
let _guard = TEST_GLOBALS_LOCK.lock().await;
let pk = test_pubkey();
{
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
cache.remove(&pk);
}
let fetch_counter = Arc::new(AtomicU64::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = fetch_counter.clone();
let handle = tokio::spawn(async move {
get_or_fetch_with_lock(&pk, || async {
counter.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
FetchResult {
relays: vec!["wss://test.example.com".to_string()],
fetch_ok: true,
}
})
.await
});
handles.push(handle);
}
let results = futures_util::future::join_all(handles).await;
for result in &results {
assert!(result.is_ok());
let relays = result.as_ref().unwrap();
assert_eq!(relays, &vec!["wss://test.example.com".to_string()]);
}
assert_eq!(
fetch_counter.load(Ordering::SeqCst),
1,
"Expected exactly 1 fetch for 10 concurrent requests to same pubkey"
);
let locks_after = {
let locks = FETCH_LOCKS.lock().unwrap();
locks.len()
};
assert_eq!(locks_after, 0, "Lock entry should be removed after all waiters complete");
}
#[tokio::test]
async fn fetch_locks_do_not_accumulate_after_calls_complete() {
let _guard = TEST_GLOBALS_LOCK.lock().await;
let pk1 = test_pubkey();
let pk2 = test_pubkey();
let pk3 = test_pubkey();
{
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
cache.clear();
}
{
let mut locks = FETCH_LOCKS.lock().unwrap();
locks.clear();
}
get_or_fetch_with_lock(&pk1, || async {
FetchResult {
relays: vec!["wss://relay1.example.com".to_string()],
fetch_ok: true,
}
})
.await;
let locks_after_pk1 = {
let locks = FETCH_LOCKS.lock().unwrap();
locks.len()
};
assert_eq!(locks_after_pk1, 0, "No lock entries should remain after pk1 call");
get_or_fetch_with_lock(&pk2, || async {
FetchResult {
relays: vec!["wss://relay2.example.com".to_string()],
fetch_ok: true,
}
})
.await;
let locks_after_pk2 = {
let locks = FETCH_LOCKS.lock().unwrap();
locks.len()
};
assert_eq!(locks_after_pk2, 0, "No lock entries should remain after pk2 call");
get_or_fetch_with_lock(&pk3, || async {
FetchResult {
relays: vec!["wss://relay3.example.com".to_string()],
fetch_ok: true,
}
})
.await;
let locks_after_pk3 = {
let locks = FETCH_LOCKS.lock().unwrap();
locks.len()
};
assert_eq!(locks_after_pk3, 0, "No lock entries should remain after pk3 call");
}
#[tokio::test]
async fn cancelled_fetch_cleans_up_lock_entry() {
let _guard = TEST_GLOBALS_LOCK.lock().await;
let pk = test_pubkey();
{
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
cache.clear();
}
{
let mut locks = FETCH_LOCKS.lock().unwrap();
locks.clear();
}
let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
let task_pk = pk;
let handle = tokio::spawn(async move {
get_or_fetch_with_lock(&task_pk, || async move {
let _ = started_tx.send(());
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
FetchResult { relays: Vec::new(), fetch_ok: false }
})
.await
});
started_rx.await.expect("fetch closure should start before abort");
handle.abort();
let _ = handle.await;
tokio::task::yield_now().await;
let locks_after = {
let locks = FETCH_LOCKS.lock().unwrap();
locks.len()
};
assert_eq!(
locks_after, 0,
"Lock entry should be removed even if fetch task is cancelled"
);
}
#[tokio::test(start_paused = true)]
async fn debounce_coalesces_rapid_calls_into_one() {
let gen_before = REPUBLISH_GEN.load(Ordering::SeqCst);
let pass_before = DEBOUNCE_PASS_COUNT.load(Ordering::SeqCst);
republish_inbox_relays_debounced();
republish_inbox_relays_debounced();
republish_inbox_relays_debounced();
let gen_after = REPUBLISH_GEN.load(Ordering::SeqCst);
assert_eq!(gen_after, gen_before + 3);
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
let pass_after = DEBOUNCE_PASS_COUNT.load(Ordering::SeqCst);
assert_eq!(pass_after - pass_before, 1);
}
}