1use std::path::Path;
10use std::sync::Arc;
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
13use crossfire::{mpsc, AsyncRx, MAsyncTx, MTx};
14use tracing::{debug, error, info};
15
16use super::manifest::FileInventorySnapshot;
17use crate::connector::ConnectorTaskGuard;
18use crate::error::ConnectorError;
19
20struct InitialScanInput {
21 watch_dir: String,
22 glob_matcher: Option<globset::GlobMatcher>,
23 known: Arc<FileInventorySnapshot>,
24}
25
26#[derive(Debug, Clone)]
28pub struct DiscoveredFile {
29 pub path: String,
31 pub size: u64,
33 pub modified_ms: u64,
35}
36
37#[derive(Debug, Clone)]
39pub struct DiscoveryConfig {
40 pub path: String,
42 pub poll_interval: Duration,
44 pub stabilisation_delay: Duration,
46 pub glob_pattern: Option<String>,
48}
49
50pub struct FileDiscoveryEngine {
56 rx: AsyncRx<mpsc::Array<DiscoveredFile>>,
58 handle: Option<tokio::task::JoinHandle<Result<(), ConnectorError>>>,
61 initial_scan_handle: Option<tokio::task::JoinHandle<()>>,
63 terminal_error: Option<String>,
65}
66
67impl Drop for FileDiscoveryEngine {
68 fn drop(&mut self) {
69 if let Some(handle) = &self.handle {
70 handle.abort();
71 }
72 if let Some(handle) = &self.initial_scan_handle {
73 handle.abort();
74 }
75 }
76}
77
78impl FileDiscoveryEngine {
79 pub(super) fn start(
83 config: DiscoveryConfig,
84 known_files: Arc<FileInventorySnapshot>,
85 task_guard: ConnectorTaskGuard,
86 initial_scan_guard: ConnectorTaskGuard,
87 ) -> Self {
88 let (tx, rx) = mpsc::bounded_async::<DiscoveredFile>(256);
89 let (initial_scan_tx, initial_scan_rx) = std::sync::mpsc::channel::<InitialScanInput>();
90 let (initial_result_tx, initial_result_rx) = tokio::sync::oneshot::channel();
91 let initial_scan_handle = tokio::task::spawn_blocking(move || {
92 let _scan_guard = initial_scan_guard;
93 let Ok(input) = initial_scan_rx.recv() else {
94 return;
95 };
96 let result =
97 scan_initial_inventory(&input.watch_dir, input.glob_matcher.as_ref(), &input.known);
98 let _ = initial_result_tx.send(result);
99 });
100 let handle = tokio::spawn(async move {
101 let _task_guard = task_guard;
102 let result =
103 local_discovery_loop(config, tx, known_files, initial_scan_tx, initial_result_rx)
104 .await;
105 if let Err(error) = &result {
106 error!(%error, "file discovery loop failed");
107 }
108 result
109 });
110
111 Self {
112 rx,
113 handle: Some(handle),
114 initial_scan_handle: Some(initial_scan_handle),
115 terminal_error: None,
116 }
117 }
118
119 pub async fn drain(&mut self, max: usize) -> Result<Vec<DiscoveredFile>, ConnectorError> {
127 self.check_terminal_failure().await?;
128 let mut files = Vec::with_capacity(max.min(64));
129 for _ in 0..max {
130 match self.rx.try_recv() {
131 Ok(file) => files.push(file),
132 Err(_) => break,
133 }
134 }
135 self.check_terminal_failure().await?;
136 Ok(files)
137 }
138
139 pub(super) async fn abort_and_join_until(
145 &mut self,
146 deadline: tokio::time::Instant,
147 ) -> Result<(), ConnectorError> {
148 let discovery_result = self.abort_discovery_until(deadline).await;
149 let initial_scan_result = self.abort_initial_scan_until(deadline).await;
150
151 initial_scan_result?;
152 discovery_result?;
153 if let Some(error) = &self.terminal_error {
154 return Err(Self::terminal_connector_error(error.clone()));
155 }
156 Ok(())
157 }
158
159 async fn abort_discovery_until(
160 &mut self,
161 deadline: tokio::time::Instant,
162 ) -> Result<(), ConnectorError> {
163 let Some(handle) = self.handle.as_mut() else {
164 return Ok(());
165 };
166
167 handle.abort();
168 let wait = deadline.saturating_duration_since(tokio::time::Instant::now());
169 let result = match tokio::time::timeout_at(deadline, handle).await {
170 Ok(result) => result,
171 Err(_) => {
172 return Err(ConnectorError::Timeout(wait.as_millis() as u64));
173 }
174 };
175 self.handle.take();
176 if tokio::time::Instant::now() >= deadline {
177 return Err(ConnectorError::Timeout(wait.as_millis() as u64));
178 }
179
180 match result {
181 Err(error) if error.is_cancelled() => Ok(()),
182 Ok(Err(error)) => Err(self.remember_terminal_error(&error)),
183 Ok(Ok(())) => Err(self.remember_terminal_error(&ConnectorError::ReadError(
184 "file discovery task terminated unexpectedly".into(),
185 ))),
186 Err(error) => Err(
187 self.remember_terminal_error(&ConnectorError::Internal(format!(
188 "file discovery task failed: {error}"
189 ))),
190 ),
191 }
192 }
193
194 async fn abort_initial_scan_until(
195 &mut self,
196 deadline: tokio::time::Instant,
197 ) -> Result<(), ConnectorError> {
198 let Some(handle) = self.initial_scan_handle.as_mut() else {
199 return Ok(());
200 };
201
202 handle.abort();
203 let wait = deadline.saturating_duration_since(tokio::time::Instant::now());
204 let result = match tokio::time::timeout_at(deadline, handle).await {
205 Ok(result) => result,
206 Err(_) => {
207 return Err(ConnectorError::Timeout(wait.as_millis() as u64));
208 }
209 };
210 self.initial_scan_handle.take();
211 if tokio::time::Instant::now() >= deadline {
212 return Err(ConnectorError::Timeout(wait.as_millis() as u64));
213 }
214
215 match result {
216 Ok(()) => Ok(()),
217 Err(error) if error.is_cancelled() => Ok(()),
218 Err(error) => Err(ConnectorError::Internal(format!(
219 "file discovery initial scan task failed: {error}"
220 ))),
221 }
222 }
223
224 async fn check_terminal_failure(&mut self) -> Result<(), ConnectorError> {
225 if let Some(error) = &self.terminal_error {
226 return Err(Self::terminal_connector_error(error.clone()));
227 }
228 if !self
229 .handle
230 .as_ref()
231 .is_some_and(tokio::task::JoinHandle::is_finished)
232 {
233 return Ok(());
234 }
235
236 let result = self
237 .handle
238 .take()
239 .expect("finished discovery task handle was present")
240 .await;
241 let error = match result {
242 Ok(Err(error)) => error,
243 Ok(Ok(())) => {
244 ConnectorError::ReadError("file discovery task terminated unexpectedly".into())
245 }
246 Err(error) => ConnectorError::Internal(format!(
247 "file discovery task failed unexpectedly: {error}"
248 )),
249 };
250 Err(self.remember_terminal_error(&error))
251 }
252
253 fn remember_terminal_error(&mut self, error: &ConnectorError) -> ConnectorError {
254 let error = error.to_string();
255 self.terminal_error = Some(error.clone());
256 Self::terminal_connector_error(error)
257 }
258
259 fn terminal_connector_error(actual: String) -> ConnectorError {
260 ConnectorError::InvalidState {
261 expected: "running file discovery task".into(),
262 actual,
263 }
264 }
265
266 #[cfg(test)]
267 pub(super) fn install_task_for_test(
273 &mut self,
274 handle: tokio::task::JoinHandle<Result<(), ConnectorError>>,
275 ) {
276 assert!(self.handle.is_none());
277 assert!(self.initial_scan_handle.is_none());
278 self.terminal_error = None;
279 self.handle = Some(handle);
280 }
281}
282
283async fn local_discovery_loop(
286 config: DiscoveryConfig,
287 tx: MAsyncTx<mpsc::Array<DiscoveredFile>>,
288 known: Arc<FileInventorySnapshot>,
289 initial_scan_tx: std::sync::mpsc::Sender<InitialScanInput>,
290 initial_result_rx: tokio::sync::oneshot::Receiver<Result<Vec<(String, u64)>, ConnectorError>>,
291) -> Result<(), ConnectorError> {
292 use notify::{RecursiveMode, Watcher};
293
294 let (watch_dir, glob_from_path) = split_dir_and_glob(&config.path);
296
297 let effective_glob = config.glob_pattern.as_deref().or(glob_from_path.as_deref());
298
299 let glob_matcher = effective_glob
300 .and_then(|p| globset::Glob::new(p).ok())
301 .map(|g| g.compile_matcher());
302
303 if !Path::new(&watch_dir).is_dir() {
304 return Err(ConnectorError::ConfigurationError(format!(
305 "file discovery: path '{watch_dir}' is not a directory",
306 )));
307 }
308
309 let use_poll = should_use_poll_watcher(&watch_dir);
310 if use_poll {
311 info!("file discovery: using poll watcher for '{watch_dir}' (network filesystem detected)");
312 }
313
314 let (notify_tx, notify_rx) = mpsc::bounded_async::<String>(512);
316
317 enum WatcherHolder {
321 Recommended {
322 _watcher: notify::RecommendedWatcher,
323 },
324 Poll {
325 _watcher: notify::PollWatcher,
326 },
327 }
328
329 let _watcher: WatcherHolder = if use_poll {
330 let notify_tx_clone: MTx<_> = notify_tx.clone().into_blocking();
331 let poll_config = notify::Config::default().with_poll_interval(config.poll_interval);
332 let mut watcher = notify::PollWatcher::new(
333 move |result: Result<notify::Event, notify::Error>| {
334 if let Ok(event) = result {
335 for path in event.paths {
336 if let Some(s) = path.to_str() {
337 let _ = notify_tx_clone.send(s.to_string());
338 }
339 }
340 }
341 },
342 poll_config,
343 )
344 .map_err(|e| {
345 ConnectorError::ConfigurationError(format!("failed to create PollWatcher: {e}"))
346 })?;
347 watcher
348 .watch(Path::new(&watch_dir), RecursiveMode::NonRecursive)
349 .map_err(|e| {
350 ConnectorError::ConfigurationError(format!("failed to watch directory: {e}"))
351 })?;
352 WatcherHolder::Poll { _watcher: watcher }
353 } else {
354 let notify_tx_clone: MTx<_> = notify_tx.clone().into_blocking();
355 let mut watcher =
356 notify::recommended_watcher(move |result: Result<notify::Event, notify::Error>| {
357 if let Ok(event) = result {
358 use notify::EventKind;
360 match event.kind {
361 EventKind::Create(_) | EventKind::Modify(_) => {
362 for path in event.paths {
363 if let Some(s) = path.to_str() {
364 let _ = notify_tx_clone.send(s.to_string());
365 }
366 }
367 }
368 _ => {}
369 }
370 }
371 })
372 .map_err(|e| {
373 ConnectorError::ConfigurationError(format!("failed to create watcher: {e}"))
374 })?;
375 watcher
376 .watch(Path::new(&watch_dir), RecursiveMode::NonRecursive)
377 .map_err(|e| {
378 ConnectorError::ConfigurationError(format!("failed to watch directory: {e}"))
379 })?;
380 WatcherHolder::Recommended { _watcher: watcher }
381 };
382
383 let mut pending: std::collections::HashMap<String, (u64, u64)> =
385 std::collections::HashMap::new();
386
387 initial_scan_tx
388 .send(InitialScanInput {
389 watch_dir: watch_dir.clone(),
390 glob_matcher: glob_matcher.clone(),
391 known: Arc::clone(&known),
392 })
393 .map_err(|_| ConnectorError::Internal("file discovery initial scan task stopped".into()))?;
394 let initial_inventory = initial_result_rx.await.map_err(|_| {
395 ConnectorError::Internal("file discovery initial scan result was lost".into())
396 })??;
397 let observed_at = now_millis();
398 for (path, size) in initial_inventory {
399 pending.insert(path, (size, observed_at));
400 }
401
402 let stabilise_ms = config.stabilisation_delay.as_millis() as u64;
403
404 loop {
405 while let Ok(path) = notify_rx.try_recv() {
407 stage_candidate(path, glob_matcher.as_ref(), &known, &mut pending);
408 }
409
410 let now = now_millis();
412 let mut ready: Vec<String> = Vec::new();
413 for (path, (size, last_seen)) in &pending {
414 if now.saturating_sub(*last_seen) >= stabilise_ms {
415 let current_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
417 if current_size == *size && current_size > 0 {
418 ready.push(path.clone());
419 }
420 }
421 }
422
423 for path in ready {
424 if let Some((size, _)) = pending.remove(&path) {
425 debug!("file discovery: file ready: {path} ({size} bytes)");
426 let _ = tx
427 .send(DiscoveredFile {
428 path,
429 size,
430 modified_ms: now,
431 })
432 .await;
433 }
434 }
435
436 tokio::time::sleep(Duration::from_millis(200)).await;
437 }
438}
439
440fn scan_initial_inventory(
441 watch_dir: &str,
442 glob_matcher: Option<&globset::GlobMatcher>,
443 known: &FileInventorySnapshot,
444) -> Result<Vec<(String, u64)>, ConnectorError> {
445 let entries = std::fs::read_dir(watch_dir).map_err(|error| {
446 ConnectorError::ReadError(format!(
447 "file discovery: failed to scan directory '{watch_dir}': {error}"
448 ))
449 })?;
450 let mut inventory = Vec::new();
451 for entry in entries {
452 let entry = entry.map_err(|error| {
453 ConnectorError::ReadError(format!(
454 "file discovery: failed to read an entry in '{watch_dir}': {error}"
455 ))
456 })?;
457 if let Some(path) = entry.path().to_str() {
458 let path = path.to_owned();
459 if let Some(size) = candidate_size(&path, glob_matcher, known) {
460 inventory.push((path, size));
461 }
462 }
463 }
464 Ok(inventory)
465}
466
467fn stage_candidate(
468 path: String,
469 glob_matcher: Option<&globset::GlobMatcher>,
470 known: &FileInventorySnapshot,
471 pending: &mut std::collections::HashMap<String, (u64, u64)>,
472) {
473 if let Some(size) = candidate_size(&path, glob_matcher, known) {
474 pending.insert(path, (size, now_millis()));
475 }
476}
477
478fn candidate_size(
479 path: &str,
480 glob_matcher: Option<&globset::GlobMatcher>,
481 known: &FileInventorySnapshot,
482) -> Option<u64> {
483 if let Some(matcher) = glob_matcher {
484 let filename = Path::new(path)
485 .file_name()
486 .and_then(|name| name.to_str())
487 .unwrap_or("");
488 if !matcher.is_match(filename) {
489 return None;
490 }
491 }
492 if known.contains(path) {
493 return None;
494 }
495 let Ok(metadata) = std::fs::metadata(path) else {
496 return None;
497 };
498 metadata.is_file().then_some(metadata.len())
499}
500
501fn split_dir_and_glob(path: &str) -> (String, Option<String>) {
503 if path.contains('*') || path.contains('?') || path.contains('[') {
505 if let Some(sep) = path.rfind(['/', '\\']) {
506 let dir = &path[..sep];
507 let pattern = &path[sep + 1..];
508 return (dir.to_string(), Some(pattern.to_string()));
509 }
510 }
511 (path.to_string(), None)
512}
513
514#[allow(clippy::unnecessary_wraps)]
516fn should_use_poll_watcher(path: &str) -> bool {
517 #[cfg(target_os = "linux")]
518 {
519 use std::ffi::CString;
520 if let Ok(c_path) = CString::new(path) {
521 unsafe {
522 let mut buf: libc::statfs = std::mem::zeroed();
523 if libc::statfs(c_path.as_ptr(), &raw mut buf) == 0 {
524 #[allow(clippy::cast_sign_loss)]
525 let fs_type = buf.f_type as u64;
526 return matches!(
527 fs_type,
528 0x6969 | 0x5346_544e | 0xFF53_4D42 | 0x0027_e0eb | 0x6573_5546 | 0x6e66_7364 );
535 }
536 }
537 }
538 false
539 }
540 #[cfg(not(target_os = "linux"))]
541 {
542 let _ = path;
543 false
544 }
545}
546
547#[allow(clippy::cast_possible_truncation)]
548fn now_millis() -> u64 {
549 SystemTime::now()
550 .duration_since(UNIX_EPOCH)
551 .unwrap_or_default()
552 .as_millis() as u64
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558 use crate::connector::ConnectorTaskOwner;
559 use crate::files::manifest::FileIngestionManifest;
560
561 #[test]
562 fn test_split_dir_and_glob() {
563 let (dir, glob) = split_dir_and_glob("/data/logs/*.csv");
564 assert_eq!(dir, "/data/logs");
565 assert_eq!(glob.as_deref(), Some("*.csv"));
566
567 let (dir, glob) = split_dir_and_glob("/data/logs");
568 assert_eq!(dir, "/data/logs");
569 assert!(glob.is_none());
570
571 let (dir, glob) = split_dir_and_glob("/data/logs/events_*.json");
572 assert_eq!(dir, "/data/logs");
573 assert_eq!(glob.as_deref(), Some("events_*.json"));
574 }
575
576 #[test]
577 fn test_should_use_poll_on_local() {
578 assert!(!should_use_poll_watcher("/tmp"));
580 }
581
582 #[tokio::test]
583 async fn terminal_discovery_failure_is_observable() {
584 let directory = tempfile::tempdir().unwrap();
585 let missing = directory.path().join("missing");
586 let config = DiscoveryConfig {
587 path: missing.to_string_lossy().into_owned(),
588 poll_interval: Duration::from_secs(60),
589 stabilisation_delay: Duration::from_secs(1),
590 glob_pattern: None,
591 };
592 let known = Arc::new(FileIngestionManifest::new().snapshot_for_dedup());
593 let (owner, _tracker) = ConnectorTaskOwner::new();
594 let guard = owner.track().unwrap();
595 let mut engine = FileDiscoveryEngine::start(config, known, guard, owner.track().unwrap());
596 let error = tokio::time::timeout(Duration::from_secs(1), async {
597 loop {
598 match engine.drain(10).await {
599 Ok(files) => {
600 assert!(files.is_empty());
601 tokio::task::yield_now().await;
602 }
603 Err(error) => break error,
604 }
605 }
606 })
607 .await
608 .expect("discovery failure was not published");
609 assert!(matches!(error, ConnectorError::InvalidState { .. }));
610 assert!(!error.is_transient());
611 assert!(error.to_string().contains("is not a directory"));
612 }
613
614 #[tokio::test]
615 async fn generation_stays_live_until_discovery_task_exits() {
616 let directory = tempfile::tempdir().unwrap();
617 let config = DiscoveryConfig {
618 path: directory.path().to_string_lossy().into_owned(),
619 poll_interval: Duration::from_secs(60),
620 stabilisation_delay: Duration::from_secs(60),
621 glob_pattern: None,
622 };
623 let known = Arc::new(FileIngestionManifest::new().snapshot_for_dedup());
624 let (owner, tracker) = ConnectorTaskOwner::new();
625 let guard = owner.track().unwrap();
626 let mut engine = FileDiscoveryEngine::start(config, known, guard, owner.track().unwrap());
627
628 tokio::task::yield_now().await;
629 drop(owner);
630 assert!(
631 !tracker.is_terminated(),
632 "the live discovery child must retain its generation"
633 );
634
635 engine
636 .abort_and_join_until(tokio::time::Instant::now() + Duration::from_secs(1))
637 .await
638 .unwrap();
639 tokio::time::timeout(Duration::from_secs(1), tracker.wait_terminated())
640 .await
641 .expect("discovery task did not release its generation guard");
642 }
643
644 #[tokio::test]
645 async fn initial_scan_larger_than_event_channel_does_not_deadlock() {
646 const FILE_COUNT: usize = 600;
647
648 let directory = tempfile::tempdir().unwrap();
649 for index in 0..FILE_COUNT {
650 std::fs::write(directory.path().join(format!("{index:04}.txt")), b"x").unwrap();
651 }
652 let config = DiscoveryConfig {
653 path: directory.path().to_string_lossy().into_owned(),
654 poll_interval: Duration::from_secs(60),
655 stabilisation_delay: Duration::ZERO,
656 glob_pattern: Some("*.txt".into()),
657 };
658 let known = Arc::new(FileIngestionManifest::new().snapshot_for_dedup());
659 let (owner, _tracker) = ConnectorTaskOwner::new();
660 let guard = owner.track().unwrap();
661 let mut engine = FileDiscoveryEngine::start(config, known, guard, owner.track().unwrap());
662
663 let discovered = tokio::time::timeout(Duration::from_secs(5), async {
664 let mut paths = std::collections::BTreeSet::new();
665 while paths.len() < FILE_COUNT {
666 for file in engine.drain(128).await.unwrap() {
667 paths.insert(file.path);
668 }
669 tokio::task::yield_now().await;
670 }
671 paths
672 })
673 .await
674 .expect("initial scan stalled above the former 512-entry channel bound");
675 assert_eq!(discovered.len(), FILE_COUNT);
676 engine
677 .abort_and_join_until(tokio::time::Instant::now() + Duration::from_secs(1))
678 .await
679 .unwrap();
680 }
681
682 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
683 async fn timed_out_join_retains_handle_and_generation_guard() {
684 let (_tx, rx) = mpsc::bounded_async::<DiscoveredFile>(1);
685 let (owner, tracker) = ConnectorTaskOwner::new();
686 let guard = owner.track().unwrap();
687 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
688 let handle = tokio::spawn(async move {
689 let _guard = guard;
690 let _ = started_tx.send(());
691 std::thread::sleep(Duration::from_millis(100));
692 Ok(())
693 });
694 let mut engine = FileDiscoveryEngine {
695 rx,
696 handle: Some(handle),
697 initial_scan_handle: None,
698 terminal_error: None,
699 };
700 started_rx.await.unwrap();
701 drop(owner);
702
703 let error = engine
704 .abort_and_join_until(tokio::time::Instant::now() + Duration::from_millis(5))
705 .await
706 .expect_err("blocked task must exceed the join deadline");
707 assert!(matches!(error, ConnectorError::Timeout(_)));
708 assert!(
709 engine.handle.is_some(),
710 "timed-out join handle was detached"
711 );
712 assert!(!tracker.is_terminated(), "active task guard was lost");
713
714 drop(engine);
715 tokio::time::timeout(Duration::from_secs(1), tracker.wait_terminated())
716 .await
717 .expect("blocked discovery task did not release its generation guard");
718 }
719
720 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
721 async fn timed_out_initial_scan_retains_handle_and_generation_guard() {
722 let (_tx, rx) = mpsc::bounded_async::<DiscoveredFile>(1);
723 let (owner, tracker) = ConnectorTaskOwner::new();
724 let scan_guard = owner.track().unwrap();
725 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
726 let initial_scan_handle = tokio::task::spawn_blocking(move || {
727 let _scan_guard = scan_guard;
728 let _ = started_tx.send(());
729 std::thread::sleep(Duration::from_millis(100));
730 });
731 let handle = tokio::spawn(async {
732 std::future::pending::<()>().await;
733 Ok(())
734 });
735 let mut engine = FileDiscoveryEngine {
736 rx,
737 handle: Some(handle),
738 initial_scan_handle: Some(initial_scan_handle),
739 terminal_error: None,
740 };
741 started_rx.await.unwrap();
742 drop(owner);
743
744 let error = engine
745 .abort_and_join_until(tokio::time::Instant::now() + Duration::from_millis(5))
746 .await
747 .expect_err("blocked initial scan must exceed the join deadline");
748 assert!(matches!(error, ConnectorError::Timeout(_)));
749 assert!(
750 engine.initial_scan_handle.is_some(),
751 "timed-out initial scan handle was detached"
752 );
753 assert!(!tracker.is_terminated(), "initial scan guard was lost");
754
755 drop(engine);
756 tokio::time::timeout(Duration::from_secs(1), tracker.wait_terminated())
757 .await
758 .expect("blocked initial scan did not release its generation guard");
759 }
760
761 #[tokio::test(flavor = "current_thread")]
762 async fn late_blocking_completion_exceeds_the_absolute_close_deadline() {
763 let (_tx, rx) = mpsc::bounded_async::<DiscoveredFile>(1);
764 let (owner, _tracker) = ConnectorTaskOwner::new();
765 let guard = owner.track().unwrap();
766 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
767 let deadline = tokio::time::Instant::now() + Duration::from_millis(5);
768 let handle = tokio::spawn(async move {
769 let _guard = guard;
770 let _ = started_tx.send(());
771 std::thread::sleep(Duration::from_millis(25));
772 Ok(())
773 });
774 let mut engine = FileDiscoveryEngine {
775 rx,
776 handle: Some(handle),
777 initial_scan_handle: None,
778 terminal_error: None,
779 };
780 started_rx.await.unwrap();
781 assert!(tokio::time::Instant::now() >= deadline);
782
783 let error = engine
784 .abort_and_join_until(deadline)
785 .await
786 .expect_err("late discovery completion must miss its absolute deadline");
787 assert!(matches!(error, ConnectorError::Timeout(_)));
788 }
789}