Skip to main content

termesh_filesystem/
worker.rs

1//! The filesystem worker thread (ADR-0005 §1).
2//!
3//! Owns a [`FileSystemService`] and does every blocking call on its own thread, emitting
4//! [`FsEvent`]s back into the state loop. This is the half of the concurrency model that
5//! makes the synchronous trait safe: the methods block, but never on the render loop.
6//!
7//! The worker is deliberately dumb — it reads what it is told to read and reports what it
8//! found. All the decisions (what to expand, how to reconcile a re-read) live in
9//! [`crate::tree::FileTree`], where they are pure and unit-testable.
10
11use std::sync::mpsc::{self, Sender};
12use std::sync::Arc;
13use std::thread::JoinHandle;
14use std::time::Duration;
15
16use termesh_core::{FsEvent, FsRequest};
17
18use crate::ignore_rules::{IgnoreOptions, IgnoreRules};
19use crate::reader::DirReader;
20use crate::service::FileSystemService;
21use crate::watch::{is_relevant, RelevanceFilter, RootWatcher, DEFAULT_WINDOW};
22
23/// Turn a mutation outcome into the event the state loop expects.
24fn mutation_result(outcome: crate::service::FsResult<()>, path: &std::path::Path) -> FsEvent {
25    match outcome {
26        Ok(()) => FsEvent::Changed(vec![path.to_path_buf()]),
27        Err(e) => FsEvent::MutationFailed(e),
28    }
29}
30
31/// Handle to a running worker thread. Dropping it shuts the thread down and joins it.
32pub struct FsWorker {
33    tx: Sender<FsRequest>,
34    handle: Option<JoinHandle<()>>,
35}
36
37impl FsWorker {
38    /// Start a worker over `fs`, delivering results through `sink`.
39    ///
40    /// `sink` is a callback rather than a channel so the caller decides how events are
41    /// wrapped — `app` turns them into `AppMessage::Fs`, tests collect them directly.
42    pub fn spawn<S, F>(fs: S, options: IgnoreOptions, sink: F) -> Self
43    where
44        S: FileSystemService + 'static,
45        F: Fn(FsEvent) + Send + Sync + 'static,
46    {
47        Self::spawn_with_window(fs, options, DEFAULT_WINDOW, sink)
48    }
49
50    /// As [`Self::spawn`], with an explicit debounce window. Tests use a short one.
51    pub fn spawn_with_window<S, F>(
52        fs: S,
53        options: IgnoreOptions,
54        watch_window: Duration,
55        sink: F,
56    ) -> Self
57    where
58        S: FileSystemService + 'static,
59        F: Fn(FsEvent) + Send + Sync + 'static,
60    {
61        let sink = Arc::new(sink);
62        let (tx, rx) = mpsc::channel::<FsRequest>();
63        let handle = std::thread::Builder::new()
64            .name("termesh-fs".into())
65            .spawn(move || {
66                // Both are built on the first Watch, which is also when we learn the
67                // root the ignore chain has to be anchored to.
68                let mut reader: Option<DirReader> = None;
69                let mut watcher: Option<RootWatcher> = None;
70
71                while let Ok(req) = rx.recv() {
72                    match req {
73                        FsRequest::ReadDir { id, path } => {
74                            let result = match reader.as_mut() {
75                                Some(r) => r.read(&path),
76                                // Unreachable in practice: `Model::open_workspace`
77                                // queues `Watch` before any `ReadDir`, and that ordering
78                                // is enforced by a test. Degrade to an unfiltered read
79                                // rather than stalling if that ever stops holding.
80                                None => DirReader::unfiltered(&fs).read(&path),
81                            };
82                            sink(match result {
83                                Ok(entries) => FsEvent::DirLoaded { id, entries },
84                                Err(error) => FsEvent::DirFailed { id, error },
85                            });
86                        }
87                        // Opening and saving are blocking I/O like any other, so they
88                        // run here rather than on the render loop. Both report against a
89                        // BufferId, since by the time the answer arrives the user may
90                        // have opened something else.
91                        FsRequest::ReadFile { buffer, path } => {
92                            sink(match fs.read_file(&path) {
93                                Ok(contents) => FsEvent::FileLoaded { buffer, path, contents },
94                                Err(error) => FsEvent::FileFailed { buffer, error },
95                            });
96                        }
97                        FsRequest::ReadPreview { request, path, line, context } => {
98                            sink(match preview(&fs, &path, line, context.min(10)) {
99                                Ok((start_line, text)) => {
100                                    FsEvent::PreviewLoaded { request, path, start_line, text }
101                                }
102                                Err(error) => FsEvent::PreviewFailed { request, path, error },
103                            });
104                        }
105                        FsRequest::ResolvePath { request, path } => {
106                            sink(match fs.canonicalize(&path) {
107                                Ok(path) => FsEvent::PathResolved { request, path },
108                                Err(error) => FsEvent::PathResolveFailed { request, path, error },
109                            });
110                        }
111                        FsRequest::WriteFile { buffer, path, contents, version } => {
112                            sink(match fs.write_file(&path, &contents) {
113                                Ok(()) => FsEvent::FileSaved { buffer, version },
114                                Err(error) => FsEvent::FileFailed { buffer, error },
115                            });
116                        }
117                        FsRequest::Watch(root) => {
118                            let r = DirReader::new(&fs, &root, options);
119
120                            // The watch thread needs an owned, Send predicate, and
121                            // `IgnoreRules` is neither — so snapshot the decision as a
122                            // closure over a fresh rules set anchored at the same root.
123                            let rules = IgnoreRules::for_root(&fs, &root, options);
124                            let filter = RelevanceFilter::new(move |p| is_relevant(p, &rules));
125
126                            // Stop any previous watch before starting the new one: two
127                            // recursive watchers over overlapping roots would report the
128                            // same change twice. Dropping joins the old debounce thread.
129                            drop(watcher.take());
130
131                            let sink_for_watch = sink.clone();
132                            // A root we cannot watch is degraded, not broken: the tree
133                            // still works, it just will not update by itself.
134                            watcher =
135                                RootWatcher::start(&root, watch_window, filter, move |paths| {
136                                    sink_for_watch(FsEvent::Changed(paths))
137                                });
138                            reader = Some(r);
139                        }
140                        // Mutations report success as `Changed`, not as a bespoke "done"
141                        // event. That way exactly one code path brings disk state back
142                        // into the tree, whether the change came from us or from an
143                        // external editor — and it works with no watcher running.
144                        FsRequest::CreateFile(path) => {
145                            sink(mutation_result(fs.create_file(&path), &path));
146                        }
147                        FsRequest::CreateDir(path) => {
148                            sink(mutation_result(fs.create_dir(&path), &path));
149                        }
150                        FsRequest::Rename { from, to } => {
151                            // Report both ends: the source directory loses an entry and
152                            // the destination gains one, and they may differ.
153                            sink(match fs.rename(&from, &to) {
154                                Ok(()) => FsEvent::Changed(vec![from, to]),
155                                Err(e) => FsEvent::MutationFailed(e),
156                            });
157                        }
158                        FsRequest::Remove { path, recursive } => {
159                            let outcome = if recursive {
160                                fs.remove_dir_all(&path)
161                            } else {
162                                fs.remove_file(&path)
163                            };
164                            sink(mutation_result(outcome, &path));
165                        }
166                        FsRequest::Shutdown => break,
167                    }
168                }
169            })
170            .expect("spawning the filesystem worker thread");
171
172        Self { tx, handle: Some(handle) }
173    }
174
175    /// Queue work. Returns `false` if the worker has already stopped — callers treat a
176    /// dead worker as "no result will arrive", never as a reason to block or panic.
177    pub fn request(&self, req: FsRequest) -> bool {
178        self.tx.send(req).is_ok()
179    }
180}
181
182fn preview(
183    fs: &dyn FileSystemService,
184    path: &std::path::Path,
185    line: usize,
186    context: usize,
187) -> crate::service::FsResult<(usize, String)> {
188    let bytes = fs.read_file(path)?;
189    let contents = String::from_utf8(bytes).map_err(|_| crate::service::FsError::Other {
190        path: path.to_path_buf(),
191        message: "not a UTF-8 text file".into(),
192    })?;
193    let lines: Vec<&str> = contents.split_inclusive('\n').collect();
194    let target = line.saturating_sub(1).min(lines.len().saturating_sub(1));
195    let start = target.saturating_sub(context);
196    let end = (target + context + 1).min(lines.len());
197    Ok((start + 1, lines[start..end].concat()))
198}
199
200impl Drop for FsWorker {
201    fn drop(&mut self) {
202        // Ask politely, then wait: the thread may be mid-`read_dir` on a slow disk and
203        // we would rather join it than leave it writing into a dropped sink.
204        let _ = self.tx.send(FsRequest::Shutdown);
205        if let Some(h) = self.handle.take() {
206            let _ = h.join();
207        }
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use std::path::{Path, PathBuf};
215    use std::sync::mpsc::Receiver;
216    use std::time::Duration;
217
218    use termesh_core::{
219        DirEntryInfo, EntryKind, FsError, FsResult, LocationRequestId, NodeId, PreviewRequestId,
220    };
221
222    /// A minimal in-crate fake. `test-support`'s richer one cannot be used here: it
223    /// depends on this crate, so using it would be a dependency cycle.
224    struct StubFs(Vec<DirEntryInfo>, Vec<u8>);
225
226    impl FileSystemService for StubFs {
227        fn read_dir(&self, path: &Path) -> FsResult<Vec<DirEntryInfo>> {
228            if path == Path::new("/denied") {
229                return Err(FsError::PermissionDenied(path.to_path_buf()));
230            }
231            Ok(self.0.clone())
232        }
233        fn read_file(&self, _: &Path) -> FsResult<Vec<u8>> {
234            Ok(self.1.clone())
235        }
236        fn create_file(&self, _: &Path) -> FsResult<()> {
237            Ok(())
238        }
239        fn write_file(&self, _: &Path, _: &[u8]) -> FsResult<()> {
240            Ok(())
241        }
242        fn create_dir(&self, _: &Path) -> FsResult<()> {
243            Ok(())
244        }
245        fn rename(&self, _: &Path, _: &Path) -> FsResult<()> {
246            Ok(())
247        }
248        fn remove_file(&self, _: &Path) -> FsResult<()> {
249            Ok(())
250        }
251        fn remove_dir_all(&self, _: &Path) -> FsResult<()> {
252            Ok(())
253        }
254        fn canonicalize(&self, p: &Path) -> FsResult<PathBuf> {
255            if p == Path::new("/missing") {
256                Err(FsError::NotFound(p.to_path_buf()))
257            } else {
258                Ok(p.to_path_buf())
259            }
260        }
261    }
262
263    fn stub() -> StubFs {
264        StubFs(
265            vec![DirEntryInfo {
266                name: "main.rs".into(),
267                path: PathBuf::from("/src/main.rs"),
268                kind: EntryKind::File,
269            }],
270            Vec::new(),
271        )
272    }
273
274    fn worker_with_channel() -> (FsWorker, Receiver<FsEvent>) {
275        let (tx, rx) = mpsc::channel();
276        let worker = FsWorker::spawn(stub(), IgnoreOptions::show_all(), move |e| {
277            let _ = tx.send(e);
278        });
279        (worker, rx)
280    }
281
282    fn recv(rx: &Receiver<FsEvent>) -> FsEvent {
283        rx.recv_timeout(Duration::from_secs(5)).expect("worker should answer")
284    }
285
286    #[test]
287    fn a_read_request_comes_back_as_dir_loaded() {
288        let (worker, rx) = worker_with_channel();
289        assert!(
290            worker.request(FsRequest::ReadDir { id: NodeId::new(7), path: PathBuf::from("/src") })
291        );
292
293        match recv(&rx) {
294            FsEvent::DirLoaded { id, entries } => {
295                assert_eq!(id, NodeId::new(7), "the answer names the node that asked");
296                assert_eq!(entries.len(), 1);
297            }
298            other => panic!("unexpected event: {other:?}"),
299        }
300    }
301
302    #[test]
303    fn preview_returns_only_the_requested_window() {
304        let (tx, rx) = mpsc::channel();
305        let worker = FsWorker::spawn(
306            StubFs(Vec::new(), b"one\ntwo\nneedle\nfour\nfive\n".to_vec()),
307            IgnoreOptions::show_all(),
308            move |event| {
309                let _ = tx.send(event);
310            },
311        );
312        worker.request(FsRequest::ReadPreview {
313            request: PreviewRequestId::new(3),
314            path: PathBuf::from("/p/src/lib.rs"),
315            line: 3,
316            context: 1,
317        });
318        assert!(matches!(
319            recv(&rx),
320            FsEvent::PreviewLoaded {
321                request,
322                start_line: 2,
323                text,
324                ..
325            } if request == PreviewRequestId::new(3) && text == "two\nneedle\nfour\n"
326        ));
327    }
328
329    #[test]
330    fn path_resolution_preserves_the_request_id_on_success_and_failure() {
331        let (worker, rx) = worker_with_channel();
332        worker.request(FsRequest::ResolvePath {
333            request: LocationRequestId::new(4),
334            path: PathBuf::from("/src/main.rs"),
335        });
336        assert!(matches!(
337            recv(&rx),
338            FsEvent::PathResolved { request, path }
339                if request == LocationRequestId::new(4) && path == Path::new("/src/main.rs")
340        ));
341        worker.request(FsRequest::ResolvePath {
342            request: LocationRequestId::new(5),
343            path: PathBuf::from("/missing"),
344        });
345        assert!(matches!(
346            recv(&rx),
347            FsEvent::PathResolveFailed { request, .. }
348                if request == LocationRequestId::new(5)
349        ));
350    }
351
352    #[test]
353    fn a_failed_read_comes_back_as_dir_failed_and_the_worker_survives() {
354        let (worker, rx) = worker_with_channel();
355        worker.request(FsRequest::ReadDir { id: NodeId::new(1), path: PathBuf::from("/denied") });
356        assert!(matches!(recv(&rx), FsEvent::DirFailed { .. }));
357
358        // The worker must keep serving after an error rather than tearing down.
359        worker.request(FsRequest::ReadDir { id: NodeId::new(2), path: PathBuf::from("/src") });
360        assert!(matches!(recv(&rx), FsEvent::DirLoaded { .. }));
361    }
362
363    #[test]
364    fn requests_are_answered_in_order() {
365        let (worker, rx) = worker_with_channel();
366        for i in 0..5 {
367            worker.request(FsRequest::ReadDir { id: NodeId::new(i), path: PathBuf::from("/src") });
368        }
369        for i in 0..5 {
370            match recv(&rx) {
371                FsEvent::DirLoaded { id, .. } => assert_eq!(id, NodeId::new(i)),
372                other => panic!("unexpected event: {other:?}"),
373            }
374        }
375    }
376
377    #[test]
378    fn dropping_the_worker_stops_the_thread() {
379        let (worker, rx) = worker_with_channel();
380        worker.request(FsRequest::ReadDir { id: NodeId::new(0), path: PathBuf::from("/src") });
381        let _ = recv(&rx);
382
383        drop(worker); // joins the thread; the sink is dropped with it
384        assert!(rx.recv().is_err(), "no further events once the worker is gone");
385    }
386
387    /// The interactive path in `app::run` sends `Watch` and then `ReadDir`, and the very
388    /// first listing is the one the user sees on launch. This proves ignore rules are
389    /// already live for it — the arrangement the app's ordering test guards from the
390    /// other side.
391    #[test]
392    fn the_first_listing_after_watch_is_already_ignore_filtered() {
393        use crate::real::RealFileSystem;
394
395        let stamp =
396            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
397        let root = std::env::temp_dir().join(format!("termesh-firstread-{stamp}"));
398        std::fs::create_dir_all(root.join("target")).unwrap();
399        std::fs::create_dir_all(root.join("src")).unwrap();
400        std::fs::write(root.join(".gitignore"), b"target\n").unwrap();
401
402        let (tx, rx) = mpsc::channel();
403        let worker = FsWorker::spawn(RealFileSystem::new(), IgnoreOptions::default(), move |e| {
404            let _ = tx.send(e);
405        });
406        worker.request(FsRequest::Watch(root.clone()));
407        worker.request(FsRequest::ReadDir { id: NodeId::new(0), path: root.clone() });
408
409        let names: Vec<String> = loop {
410            match rx.recv_timeout(Duration::from_secs(5)).expect("worker should answer") {
411                FsEvent::DirLoaded { entries, .. } => {
412                    break entries.iter().map(|e| e.name.to_string_lossy().into_owned()).collect()
413                }
414                _ => continue,
415            }
416        };
417
418        drop(worker);
419        let _ = std::fs::remove_dir_all(&root);
420
421        assert_eq!(names, ["src"], "target/ and .gitignore must be filtered from the first read");
422    }
423
424    /// End-to-end over the real filesystem: watching a directory and creating a file in
425    /// it must wake the loop with a coalesced `Changed` batch. This is the one test that
426    /// proves the OS half works; everything about *policy* is unit-tested in `watch`.
427    #[test]
428    fn creating_a_file_under_a_watched_root_emits_a_changed_batch() {
429        use crate::real::RealFileSystem;
430
431        let stamp =
432            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
433        let root = std::env::temp_dir().join(format!("termesh-watch-{stamp}"));
434        std::fs::create_dir_all(&root).unwrap();
435
436        let (tx, rx) = mpsc::channel();
437        let worker = FsWorker::spawn_with_window(
438            RealFileSystem::new(),
439            IgnoreOptions::show_all(),
440            Duration::from_millis(50),
441            move |e| {
442                let _ = tx.send(e);
443            },
444        );
445        worker.request(FsRequest::Watch(root.clone()));
446
447        // Sleeping a fixed span here was flaky: under parallel test load the worker
448        // thread had not always reached `RootWatcher::start` before we wrote the file,
449        // so the event was never generated and the deadline below waited on nothing.
450        // Requests are answered in order, so a `ReadDir` reply proves `Watch` is done.
451        worker.request(FsRequest::ReadDir { id: NodeId::new(0), path: root.clone() });
452        let handshake = std::time::Instant::now() + Duration::from_secs(10);
453        while std::time::Instant::now() < handshake {
454            if let Ok(FsEvent::DirLoaded { .. }) = rx.recv_timeout(Duration::from_millis(500)) {
455                break;
456            }
457        }
458
459        // Registering the watch and the OS actually delivering for it are still two
460        // different moments (FSEvents in particular arms asynchronously), so keep
461        // re-touching the file instead of betting the whole test on the first write.
462        let created = root.join("created.rs");
463        let deadline = std::time::Instant::now() + Duration::from_secs(10);
464        let mut saw_change = false;
465        while std::time::Instant::now() < deadline && !saw_change {
466            std::fs::write(&created, b"fn main() {}").unwrap();
467            while let Ok(event) = rx.recv_timeout(Duration::from_millis(250)) {
468                if let FsEvent::Changed(paths) = event {
469                    if paths.iter().any(|p| p.ends_with("created.rs")) {
470                        saw_change = true;
471                        break;
472                    }
473                }
474            }
475        }
476
477        drop(worker);
478        let _ = std::fs::remove_dir_all(&root);
479        assert!(saw_change, "a new file under the watched root should reach the loop");
480    }
481
482    #[test]
483    fn requesting_after_shutdown_reports_failure_instead_of_panicking() {
484        let (worker, _rx) = worker_with_channel();
485        worker.request(FsRequest::Shutdown);
486        // The thread has stopped; the channel may still accept one buffered send, so we
487        // only assert the call returns rather than unwinding.
488        let _ =
489            worker.request(FsRequest::ReadDir { id: NodeId::new(0), path: PathBuf::from("/src") });
490    }
491}