From 7a565f377203ba8e01574952014a23dfa8d94fa3 Mon Sep 17 00:00:00 2001 From: salmonik Date: Thu, 30 Jul 2026 15:09:51 +0200 Subject: fix: auto completion timing out before ready + fix preexisting warns --- src/commands/autocomplete.rs | 249 +++++++++++++++++++++++++++++-------------- 1 file changed, 171 insertions(+), 78 deletions(-) (limited to 'src/commands/autocomplete.rs') diff --git a/src/commands/autocomplete.rs b/src/commands/autocomplete.rs index 97a18ec..434870c 100644 --- a/src/commands/autocomplete.rs +++ b/src/commands/autocomplete.rs @@ -1,13 +1,27 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + use poise::serenity_prelude as serenity; +use tokio::sync::oneshot; +use tracing::{debug, warn}; -use crate::Data; -use crate::copyparty::{html_decode, percent_decode}; +use crate::copyparty::{self, html_decode, percent_decode}; use crate::error::Error; +use crate::Data; type Context<'a> = poise::Context<'a, Data, Error>; const MAX_CHOICE_LEN: usize = 99; +/// Maximum time we wait for a fresh search before returning a mock entry. +/// Discord gives 3 seconds; we use 2 to leave room for the API call. +const AC_SEARCH_TIMEOUT: Duration = Duration::from_secs(2); + +/// Value used for the mock "searching" placeholder — recognisable by the +/// command handler as a no-op. +const MOCK_SEARCHING: &str = "\u{231B}..."; + fn truncate_choice(s: &str) -> String { if s.chars().count() <= MAX_CHOICE_LEN { return s.to_string(); @@ -26,93 +40,173 @@ fn filename_label(raw_path: &str) -> Option { Some(truncate_choice(&decoded)) } -pub async fn audio_file<'a>( +/// Build (label, value) pairs from search hits. Sync because it doesn't stash. +fn build_choices(hits: &[copyparty::SearchHit]) -> Vec<(String, String)> { + let mut out = Vec::new(); + for hit in hits { + let rp = match &hit.rp { + Some(p) => p.clone(), + None => continue, + }; + let Some(label) = filename_label(&rp) else { + continue; + }; + let value = truncate_choice(&rp); + out.push((label, value)); + if out.len() >= 25 { + break; + } + } + out +} + +/// Stash choices into the per-user ac_cache, then wrap into AutocompleteChoice. +async fn stash_and_wrap( ctx: Context<'_>, - partial: &'a str, + hits: &[copyparty::SearchHit], + pairs: Vec<(String, String)>, ) -> Vec { - eprintln!("[DEBUG] autocomplete audio_file(partial={partial:?})"); - let results = ctx - .data() - .copyparty - .search(partial, 25, &["mp3", "wav", "ogg", "flac", "m4a", "opus"]) - .await; - match results { - Ok(hits) => { - eprintln!("[DEBUG] audio_file got {} raw hits", hits.len()); - let mut choices = Vec::new(); - for (i, hit) in hits.into_iter().enumerate() { - let rp = match &hit.rp { - Some(p) => p.clone(), - None => continue, - }; - eprintln!( - "[DEBUG] hit[{i}]: rp={rp:?}, url={:?}, name={:?}, path={:?}", - hit.url, hit.name, hit.path - ); - let Some(label) = filename_label(&rp) else { - continue; - }; - - let value = truncate_choice(&rp); - ctx.data() - .ac_stash(ctx.author().id, value.clone(), hit) - .await; - eprintln!("[DEBUG] -> cached as {value:?}"); - choices.push(serenity::AutocompleteChoice::new(label, value)); - if choices.len() >= 25 { - break; - } - } - eprintln!("[DEBUG] audio_file returning {} choices", choices.len()); - choices - } - Err(e) => { - eprintln!("[DEBUG] audio_file search failed: {e}"); - vec![] + let mut choices = Vec::with_capacity(pairs.len()); + for (label, value) in pairs { + // Re-stash into per-user cache so the command handler can resolve + let hit = hits + .iter() + .find(|h| { + h.rp + .as_deref() + .map(|rp| truncate_choice(rp) == value) + .unwrap_or(false) + }) + .cloned(); + if let Some(h) = hit { + ctx.data() + .ac_stash(ctx.author().id, value.clone(), h) + .await; } + choices.push(serenity::AutocompleteChoice::new(label, value)); } + choices } -pub async fn any_file<'a>(ctx: Context<'_>, partial: &'a str) -> Vec { - eprintln!("[DEBUG] autocomplete any_file(partial={partial:?})"); - let results = ctx.data().copyparty.search(partial, 25, &[]).await; - match results { - Ok(hits) => { - eprintln!("[DEBUG] any_file got {} raw hits", hits.len()); - let mut choices = Vec::new(); - for (i, hit) in hits.into_iter().enumerate() { - let rp = match &hit.rp { - Some(p) => p.clone(), - None => continue, - }; - eprintln!( - "[DEBUG] hit[{i}]: rp={rp:?}, url={:?}, name={:?}, path={:?}", - hit.url, hit.name, hit.path - ); - let Some(label) = filename_label(&rp) else { - continue; - }; - ctx.data() - .ac_stash(ctx.author().id, label.clone(), hit) - .await; - eprintln!("[DEBUG] -> cached as {label:?}"); - choices.push(serenity::AutocompleteChoice::new(label.clone(), label)); - if choices.len() >= 25 { - break; - } +/// Spawn a single Copyparty search task. Returns a oneshot receiver that will +/// receive the results when the search completes. +/// +/// The task always runs to completion even if the receiver is dropped, and +/// writes results into the shared `ac_search_cache` so *future* autocomplete +/// invocations can serve from cache. +type SearchCache = Arc< + tokio::sync::Mutex, std::time::Instant)>>, +>; + +fn spawn_search( + cache: SearchCache, + client: copyparty::CopypartyClient, + query: String, + extensions: Vec, +) -> oneshot::Receiver> { + let (tx, rx) = oneshot::channel(); + + tokio::spawn(async move { + let exts: Vec<&str> = extensions.iter().map(|s| s.as_str()).collect(); + let result = client.search(&query, 25, &exts).await; + + match result { + Ok(hits) => { + debug!(%query, count = hits.len(), "background search completed"); + // Always fill the shared search cache + cache + .lock() + .await + .insert(query, (hits.clone(), std::time::Instant::now())); + // Send to foreground (ignores error if receiver was dropped) + let _ = tx.send(hits); + } + Err(e) => { + debug!(%query, error = %e, "background search failed"); } - eprintln!("[DEBUG] any_file returning {} choices", choices.len()); - choices } - Err(e) => { - eprintln!("[DEBUG] any_file search failed: {e}"); + }); + + rx +} + +/// Run a Copyparty search with mock fallback and background cache fill. +/// +/// 1. Check shared search cache → return instantly if fresh and non-empty. +/// 2. Spawn a *single* search task that always runs to completion. +/// 3. Wait for it up to 2 seconds. +/// 4. On success: stash per-user + return real choices. +/// 5. On timeout: return a mock entry; the background task fills the cache, +/// so the next keystroke's autocomplete will get real results. +async fn timed_search( + ctx: Context<'_>, + partial: &str, + extensions: &[&str], +) -> Vec { + // 1. Shared search cache hit? + if let Some(hits) = ctx.data().ac_search_cache_lookup(partial).await + && !hits.is_empty() { + debug!(%partial, count = hits.len(), "autocomplete cache hit"); + let pairs = build_choices(&hits); + return stash_and_wrap(ctx, &hits, pairs).await; + } + + // 2. Spawn ONE search task (survives timeout → fills cache) + let exts: Vec = extensions.iter().map(|s| s.to_string()).collect(); + let mut rx = spawn_search( + ctx.data().ac_search_cache.clone(), + ctx.data().copyparty.clone(), + partial.to_string(), + exts, + ); + + // 3. Wait for the task with a timeout + match tokio::time::timeout(AC_SEARCH_TIMEOUT, &mut rx).await { + Ok(Ok(hits)) => { + debug!(%partial, count = hits.len(), "autocomplete got results in time"); + let pairs = build_choices(&hits); + stash_and_wrap(ctx, &hits, pairs).await + } + Ok(Err(_)) => { + warn!(%partial, "autocomplete search task finished without sending (sender dropped)"); vec![] } + Err(_elapsed) => { + warn!( + %partial, + "autocomplete search timed out after {AC_SEARCH_TIMEOUT:?}, returning mock" + ); + // The search task is still running in the background and will fill + // the cache when done. Return a mock so the user sees *something* + // and is prompted to type more → fresh autocomplete → cache hit. + vec![serenity::AutocompleteChoice::new( + format!( + "\u{231B} Szukanie \u{201E}{}\u{201D}\u{2026}", + truncate_choice(partial) + ), + MOCK_SEARCHING.to_string(), + )] + } } } -pub async fn trigger_name<'a>(ctx: Context<'_>, partial: &'a str) -> Vec { - eprintln!("[DEBUG] autocomplete trigger_name(partial={partial:?})"); +pub async fn audio_file( + ctx: Context<'_>, + partial: &str, +) -> Vec { + timed_search( + ctx, + partial, + &["mp3", "wav", "ogg", "flac", "m4a", "opus"], + ) + .await +} + +pub async fn any_file(ctx: Context<'_>, partial: &str) -> Vec { + timed_search(ctx, partial, &[]).await +} + +pub async fn trigger_name(ctx: Context<'_>, partial: &str) -> Vec { let db_pool = &ctx.data().db_pool; match crate::database::list_triggers(db_pool).await { Ok(triggers) => { @@ -122,11 +216,10 @@ pub async fn trigger_name<'a>(ctx: Context<'_>, partial: &'a str) -> Vec .filter(|name| partial.is_empty() || name.starts_with(partial)) .take(25) .collect(); - eprintln!("[DEBUG] trigger_name returning {} names", names.len()); names } Err(e) => { - eprintln!("[DEBUG] trigger_name query failed: {e}"); + warn!(%partial, error = %e, "autocomplete trigger_name failed"); vec![] } } -- cgit v1.2.3