Skip to main content

laminar_connectors/files/
discovery.rs

1//! File discovery engine (Ring 2).
2//!
3//! Watches a directory for new files and emits [`DiscoveredFile`] events
4//! to the source connector via an `mpsc` channel. Supports two modes:
5//!
6//! - **Event mode**: `notify::recommended_watcher()` for local filesystems
7//! - **Poll mode**: `notify::PollWatcher` for NFS/CIFS/FUSE mounts
8
9use 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/// A file discovered by the discovery engine.
27#[derive(Debug, Clone)]
28pub struct DiscoveredFile {
29    /// Full local path to the file.
30    pub path: String,
31    /// File size in bytes.
32    pub size: u64,
33    /// Last modification time (millis since epoch).
34    pub modified_ms: u64,
35}
36
37/// Configuration for the discovery engine.
38#[derive(Debug, Clone)]
39pub struct DiscoveryConfig {
40    /// Local directory path to watch.
41    pub path: String,
42    /// Polling interval for poll mode.
43    pub poll_interval: Duration,
44    /// Stabilisation delay after last modify event (event mode).
45    pub stabilisation_delay: Duration,
46    /// Glob pattern for filtering file names.
47    pub glob_pattern: Option<String>,
48}
49
50/// Handle to a running discovery engine.
51///
52/// Drop aborts async discovery. An already-running blocking inventory scan
53/// finishes under its task guard; `abort_and_join_until` is the bounded
54/// shutdown path.
55pub struct FileDiscoveryEngine {
56    /// Channel receiver for discovered files.
57    rx: AsyncRx<mpsc::Array<DiscoveredFile>>,
58    /// Background task. Aborted on Drop so the docstring promise holds —
59    /// plain `JoinHandle::drop` would detach the task, leaking it.
60    handle: Option<tokio::task::JoinHandle<Result<(), ConnectorError>>>,
61    /// Blocking initial inventory task, retained so close cannot detach it.
62    initial_scan_handle: Option<tokio::task::JoinHandle<()>>,
63    /// Persistent diagnostic once the task has terminated unexpectedly.
64    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    /// Starts a discovery engine for the given config.
80    ///
81    /// Files already present in `known_files` are skipped.
82    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    /// Drains available discovered files (non-blocking).
120    ///
121    /// Returns up to `max` files.
122    ///
123    /// # Errors
124    ///
125    /// Returns a non-transient error after the discovery task terminates.
126    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    /// Aborts discovery and joins the task without detaching it on timeout.
140    ///
141    /// # Errors
142    ///
143    /// Returns an error when either owned task fails or misses `deadline`.
144    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    /// Replaces a fully joined discovery task with a controlled test task.
268    ///
269    /// # Panics
270    ///
271    /// Panics if the previous discovery or initial scan task remains installed.
272    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
283// ── Local discovery (event + poll modes) ─────────────────────────────
284
285async 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    // Determine the directory to watch (strip glob from path).
295    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    // Channel from notify watcher → our async loop.
315    let (notify_tx, notify_rx) = mpsc::bounded_async::<String>(512);
316
317    // Start the appropriate watcher.
318    // Both RecommendedWatcher and PollWatcher are Send, but `dyn Watcher` is not.
319    // We hold them as concrete types inside an enum to preserve Send.
320    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                    // Only emit for create/modify/rename-to events.
359                    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    // Stabilisation tracking: path → (size, last_seen_ms).
384    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        // Drain new events.
406        while let Ok(path) = notify_rx.try_recv() {
407            stage_candidate(path, glob_matcher.as_ref(), &known, &mut pending);
408        }
409
410        // Check stabilised files.
411        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                // Verify size hasn't changed.
416                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
501/// Splits a path like `/data/logs/*.csv` into `("/data/logs", Some("*.csv"))`.
502fn split_dir_and_glob(path: &str) -> (String, Option<String>) {
503    // If path contains glob characters, split at the last directory separator before them.
504    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/// Determines if a poll watcher should be used (network filesystem detection).
515#[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          // NFS
529                        | 0x5346_544e   // NTFS (FUSE)
530                        | 0xFF53_4D42   // CIFS/SMB
531                        | 0x0027_e0eb   // ECRYPTFS
532                        | 0x6573_5546   // FUSE (general)
533                        | 0x6e66_7364 // nfsd
534                    );
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        // On non-Linux or local FS, should return false.
579        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}