Skip to main content

cordis_include/
file.rs

1//! Config files: format detection, ordered round-trips, atomic writes.
2
3use crate::error::{IncludeError, Result};
4use crate::lock;
5use crate::node::Node;
6use crate::options::EntryOptions;
7use indexmap::IndexMap;
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, Condvar, Mutex, Weak};
13use std::time::{Duration, Instant};
14
15/// The serialization format of a [`LoaderFile`], picked from its extension.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum FileFormat {
18    /// `.yml` / `.yaml`
19    Yaml,
20    /// `.json`
21    Json,
22}
23
24/// The parsed content of one config file: the entry list plus any unknown
25/// top-level keys, which are preserved on write-back.
26#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
27pub struct Document {
28    /// The entry tree serialized as a list, in file order.
29    #[serde(default, skip_serializing_if = "Vec::is_empty")]
30    pub entries: Vec<EntryOptions>,
31    /// Unknown top-level keys, round-tripped untouched.
32    #[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
33    pub extra: IndexMap<String, Node>,
34}
35
36impl Document {
37    /// A document holding just the given entries.
38    pub fn with_entries(entries: Vec<EntryOptions>) -> Self {
39        Self {
40            entries,
41            extra: IndexMap::new(),
42        }
43    }
44}
45
46/// Shared state behind a [`LoaderFile`] handle.
47struct FileInner {
48    path: PathBuf,
49    format: FileFormat,
50    suspend: Mutex<usize>,
51    /// Serializes whole write sequences (serialize → tmp write → rename).
52    /// Without it two writers race on the same sibling `.tmp` file and the
53    /// rename can land torn content; see `write_document`.
54    write_lock: Mutex<()>,
55    deferred: Mutex<DeferredState>,
56    deferred_signal: Condvar,
57    flusher: Mutex<Option<std::thread::JoinHandle<()>>>,
58}
59
60/// State of the coalescing writer behind [`LoaderFile::write_deferred`].
61#[derive(Default)]
62struct DeferredState {
63    /// Latest queued document and its flush deadline.
64    pending: Option<(Document, Instant)>,
65    /// Monotonic counters so [`LoaderFile::flush_deferred`] can await a
66    /// specific queue state.
67    queued: u64,
68    flushed: u64,
69    /// A flush is currently running outside the lock.
70    writing: bool,
71    /// The owning file is gone; the flusher exits.
72    closed: bool,
73    /// The last flush error, surfaced through
74    /// [`LoaderFile::last_deferred_error`].
75    last_error: Option<String>,
76}
77
78/// A handle to one config file on disk.
79///
80/// Handles are cheap to clone and share path, format, and suspend state, so
81/// several trees (or the loader and the watcher) can coordinate writes
82/// through the same file. While any [`FileSuspendGuard`] is held,
83/// [`LoaderFile::write`] is a silent no-op — the file-level half of breaking
84/// the write → watch → write feedback loop.
85#[derive(Clone)]
86pub struct LoaderFile {
87    inner: Arc<FileInner>,
88}
89
90impl std::fmt::Debug for LoaderFile {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.debug_struct("LoaderFile")
93            .field("path", &self.inner.path)
94            .field("format", &self.inner.format)
95            .finish_non_exhaustive()
96    }
97}
98
99impl LoaderFile {
100    /// Open a `.yml`, `.yaml`, or `.json` config file. The file does not
101    /// have to exist yet; [`LoaderFile::read`] returns an empty document
102    /// until the first write creates it.
103    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
104        let path = path.into();
105        let format = match path.extension().and_then(|ext| ext.to_str()) {
106            Some("yml" | "yaml") => FileFormat::Yaml,
107            Some("json") => FileFormat::Json,
108            _ => return Err(IncludeError::UnknownFormat { path }),
109        };
110        Ok(Self {
111            inner: Arc::new(FileInner {
112                path,
113                format,
114                suspend: Mutex::new(0),
115                write_lock: Mutex::new(()),
116                deferred: Mutex::new(DeferredState::default()),
117                deferred_signal: Condvar::new(),
118                flusher: Mutex::new(None),
119            }),
120        })
121    }
122
123    /// The file path.
124    pub fn path(&self) -> &Path {
125        &self.inner.path
126    }
127
128    /// The detected format.
129    pub fn format(&self) -> FileFormat {
130        self.inner.format
131    }
132
133    /// Read and parse the file. Missing and empty files yield an empty
134    /// document; `${{ ... }}` templates are *not* expanded here — entries
135    /// keep their raw config.
136    pub fn read(&self) -> Result<Document> {
137        let content = match fs::read_to_string(&self.inner.path) {
138            Ok(content) => content,
139            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
140                return Ok(Document::default());
141            }
142            Err(error) => return Err(error.into()),
143        };
144        if content.trim().is_empty() {
145            return Ok(Document::default());
146        }
147        match self.inner.format {
148            FileFormat::Yaml => {
149                let node = crate::yaml::parse_node(&content)?;
150                if node.is_null() {
151                    return Ok(Document::default());
152                }
153                crate::yaml::document_from_node(node)
154            }
155            FileFormat::Json => {
156                let value =
157                    serde_json::from_str::<serde_json::Value>(&content).map_err(|error| {
158                        IncludeError::Parse {
159                            format: "json",
160                            source: Box::new(error),
161                        }
162                    })?;
163                if value.is_null() {
164                    return Ok(Document::default());
165                }
166                serde_json::from_value(value).map_err(|error| IncludeError::Parse {
167                    format: "json",
168                    source: Box::new(error),
169                })
170            }
171        }
172    }
173
174    /// Serialize the document and replace the file atomically (write to a
175    /// sibling `.tmp` file, fsync, rename). A no-op while suspended.
176    pub fn write(&self, document: &Document) -> Result<()> {
177        write_document(&self.inner, document)
178    }
179
180    /// Schedule a coalesced write: rapid calls replace the pending document
181    /// (latest wins), and the physical write happens once `delay` has passed
182    /// without a newer call. A suspension active at flush time postpones the
183    /// write until it lifts. Errors surface through
184    /// [`LoaderFile::last_deferred_error`] and never propagate to callers.
185    ///
186    /// If the flusher thread cannot be spawned, the write happens
187    /// synchronously instead.
188    pub fn write_deferred(&self, document: Document, delay: Duration) {
189        {
190            let mut flusher = crate::lock(&self.inner.flusher);
191            if flusher.is_none() {
192                let weak = Arc::downgrade(&self.inner);
193                match std::thread::Builder::new()
194                    .name(format!("cordis-flush-{}", self.inner.path.display()))
195                    .spawn(move || flusher_loop(weak))
196                {
197                    Ok(thread) => *flusher = Some(thread),
198                    Err(_) => {
199                        drop(flusher);
200                        let _ = self.write(&document);
201                        return;
202                    }
203                }
204            }
205        }
206        {
207            let mut state = crate::lock(&self.inner.deferred);
208            state.pending = Some((document, Instant::now() + delay));
209            state.queued += 1;
210        }
211        self.inner.deferred_signal.notify_all();
212    }
213
214    /// Block until every [`LoaderFile::write_deferred`] call made before
215    /// this one has been flushed (or skipped by suspension lifting and a
216    /// later flush).
217    pub fn flush_deferred(&self) {
218        let mut state = crate::lock(&self.inner.deferred);
219        let target = state.queued;
220        while state.flushed < target || state.writing || state.pending.is_some() {
221            let (guard, _) = self
222                .inner
223                .deferred_signal
224                .wait_timeout(state, Duration::from_millis(100))
225                .unwrap_or_else(|error| error.into_inner());
226            state = guard;
227            if state.flushed >= target && !state.writing && state.pending.is_none() {
228                return;
229            }
230        }
231    }
232
233    /// The last deferred-write error, if the flusher failed.
234    pub fn last_deferred_error(&self) -> Option<String> {
235        crate::lock(&self.inner.deferred).last_error.clone()
236    }
237
238    /// Increment the suspend counter, returning a guard whose drop resumes
239    /// writes. Hold this while reloading a file so the resulting tree
240    /// patches are not written back.
241    pub fn suspend(&self) -> FileSuspendGuard {
242        {
243            let mut suspend = lock(&self.inner.suspend);
244            *suspend += 1;
245        }
246        FileSuspendGuard { file: self.clone() }
247    }
248
249    /// Whether any suspend guard is currently held for this file.
250    pub fn is_suspended(&self) -> bool {
251        *lock(&self.inner.suspend) > 0
252    }
253}
254
255/// Serialize `document` and atomically replace the file behind `inner`.
256/// A no-op while the file is suspended.
257///
258/// The whole sequence — serialize, write the sibling `.tmp`, fsync, rename,
259/// fsync the parent directory (Unix) — runs under `write_lock`: concurrent
260/// writers (a synchronous `write`, the deferred flusher, the loader's
261/// write-back) would otherwise interleave on the same `.tmp` path and the
262/// rename could publish torn content.
263fn write_document(inner: &FileInner, document: &Document) -> Result<()> {
264    let _write = crate::lock(&inner.write_lock);
265    if *crate::lock(&inner.suspend) > 0 {
266        return Ok(());
267    }
268    if let Ok(metadata) = fs::metadata(&inner.path) {
269        if metadata.permissions().readonly() {
270            return Err(IncludeError::ReadOnly {
271                path: inner.path.clone(),
272            });
273        }
274    }
275    let content = match inner.format {
276        FileFormat::Yaml => crate::yaml::emit_document(document),
277        FileFormat::Json => {
278            let mut text =
279                serde_json::to_string_pretty(document).map_err(|error| IncludeError::Parse {
280                    format: "json",
281                    source: Box::new(error),
282                })?;
283            text.push('\n');
284            text
285        }
286    };
287    if let Some(parent) = inner.path.parent() {
288        if !parent.as_os_str().is_empty() {
289            fs::create_dir_all(parent)?;
290        }
291    }
292    let file_name = inner
293        .path
294        .file_name()
295        .map(|name| name.to_string_lossy().into_owned())
296        .unwrap_or_default();
297    let tmp = inner.path.with_file_name(format!("{file_name}.tmp"));
298    {
299        let mut file = fs::File::create(&tmp)?;
300        file.write_all(content.as_bytes())?;
301        file.sync_all()?;
302    }
303    fs::rename(&tmp, &inner.path)?;
304    // Persist the directory entry too: on some filesystems a crash shortly
305    // after the rename can otherwise lose the new name, defeating the
306    // atomic-write guarantee. POSIX fsyncs a directory through a read-only
307    // fd; failures are best-effort since the rename itself succeeded.
308    #[cfg(unix)]
309    {
310        if let Some(parent) = inner.path.parent() {
311            if let Ok(dir) = fs::File::open(parent) {
312                let _ = dir.sync_all();
313            }
314        }
315    }
316    Ok(())
317}
318
319/// The flusher thread: holds only a weak reference so it dies with the last
320/// handle, and loops until the state is closed.
321fn flusher_loop(weak: Weak<FileInner>) {
322    const SUSPEND_RETRY: Duration = Duration::from_millis(50);
323    while let Some(inner) = weak.upgrade() {
324        let state = crate::lock(&inner.deferred);
325        if state.closed {
326            return;
327        }
328        let deadline = match state.pending.as_ref() {
329            Some((_, deadline)) => *deadline,
330            None => {
331                let waited = inner
332                    .deferred_signal
333                    .wait(state)
334                    .unwrap_or_else(|error| error.into_inner());
335                drop(waited);
336                continue;
337            }
338        };
339        drop(state);
340        let now = Instant::now();
341        if now < deadline {
342            // Park until the deadline (or a newer document/close) and
343            // re-decide with fresh state.
344            let state = crate::lock(&inner.deferred);
345            let (guard, _) = inner
346                .deferred_signal
347                .wait_timeout(state, deadline - now)
348                .unwrap_or_else(|error| error.into_inner());
349            drop(guard);
350            continue;
351        }
352        let mut state = crate::lock(&inner.deferred);
353        if state.closed {
354            return;
355        }
356        let Some((document, deadline)) = state.pending.take() else {
357            continue;
358        };
359        if Instant::now() < deadline {
360            state.pending = Some((document, deadline));
361            drop(state);
362            continue;
363        }
364        let queued_at_take = state.queued;
365        state.writing = true;
366        drop(state);
367
368        let suspended = *crate::lock(&inner.suspend) > 0;
369        if suspended {
370            let mut state = crate::lock(&inner.deferred);
371            state.pending = Some((document, Instant::now() + SUSPEND_RETRY));
372            state.writing = false;
373            drop(state);
374            inner.deferred_signal.notify_all();
375            continue;
376        }
377        let result = write_document(&inner, &document);
378        let mut state = crate::lock(&inner.deferred);
379        state.writing = false;
380        state.flushed = state.flushed.max(queued_at_take);
381        if let Err(error) = result {
382            state.last_error = Some(error.to_string());
383        }
384        drop(state);
385        inner.deferred_signal.notify_all();
386    }
387}
388
389impl Drop for FileInner {
390    fn drop(&mut self) {
391        {
392            let mut state = crate::lock(&self.deferred);
393            state.closed = true;
394        }
395        self.deferred_signal.notify_all();
396        if let Some(thread) = self
397            .flusher
398            .lock()
399            .unwrap_or_else(|error| error.into_inner())
400            .take()
401        {
402            let _ = thread.join();
403        }
404        // Anything still pending (e.g. suspended at close time) is written
405        // synchronously so the last state is not lost.
406        if let Some((document, _)) = self
407            .deferred
408            .lock()
409            .unwrap_or_else(|error| error.into_inner())
410            .pending
411            .take()
412        {
413            let _ = write_document(self, &document);
414        }
415    }
416}
417
418/// RAII guard for the file-level suspend counter.
419#[derive(Debug)]
420pub struct FileSuspendGuard {
421    file: LoaderFile,
422}
423
424impl Drop for FileSuspendGuard {
425    fn drop(&mut self) {
426        let mut suspend = lock(&self.file.inner.suspend);
427        *suspend = suspend.saturating_sub(1);
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use std::time::{SystemTime, UNIX_EPOCH};
435
436    fn temp_file(stem: &str) -> LoaderFile {
437        let path = std::env::temp_dir().join(format!(
438            "cordis-include-write-test-{stem}-{}-{}.yml",
439            std::process::id(),
440            randomish()
441        ));
442        let _ = std::fs::remove_file(&path);
443        LoaderFile::open(path).unwrap()
444    }
445
446    fn randomish() -> u64 {
447        SystemTime::now()
448            .duration_since(UNIX_EPOCH)
449            .map(|elapsed| elapsed.as_nanos() as u64)
450            .unwrap_or(0)
451    }
452
453    fn padded_document(tag: u64) -> Document {
454        // A large payload widens the torn-write window.
455        let filler = "x".repeat(4096);
456        let config = Node::String(format!("{tag}-{filler}"));
457        Document::with_entries(vec![
458            EntryOptions::new("w").with_id("w").with_config(config),
459        ])
460    }
461
462    /// Regression: two writers racing on the shared sibling `.tmp` could
463    /// interleave and the rename published torn content. The write lock
464    /// serializes whole write sequences; the final file must always parse
465    /// and equal one of the writers' documents in full.
466    #[test]
467    fn concurrent_writers_never_produce_a_torn_file() {
468        for _ in 0..25 {
469            let file = temp_file("race");
470            let writers: Vec<_> = (0..4_u64)
471                .map(|tag| {
472                    let file = file.clone();
473                    std::thread::spawn(move || {
474                        let document = padded_document(tag);
475                        for _ in 0..5 {
476                            file.write(&document).unwrap();
477                        }
478                    })
479                })
480                .collect();
481            for writer in writers {
482                writer.join().unwrap();
483            }
484            let document = file.read().unwrap();
485            let config = document.entries[0].config.clone().unwrap();
486            let Node::String(text) = &config else {
487                panic!("config is not the string one writer wrote: {config:?}");
488            };
489            let tag: u64 = text.split('-').next().unwrap().parse().unwrap();
490            let expected = padded_document(tag);
491            assert_eq!(document, expected, "torn or mixed content survived");
492            let _ = std::fs::remove_file(file.path());
493        }
494    }
495
496    /// The deferred flusher and a synchronous writer share the same file;
497    /// interleaving them must never leave an unparseable result behind.
498    #[test]
499    fn concurrent_deferred_and_sync_writes_stay_parseable() {
500        for _ in 0..25 {
501            let file = temp_file("deferred-race");
502            let deferred = std::thread::spawn({
503                let file = file.clone();
504                move || {
505                    for tag in 0..20_u64 {
506                        file.write_deferred(padded_document(tag), Duration::from_millis(1));
507                    }
508                    file.flush_deferred();
509                }
510            });
511            for tag in 100..120_u64 {
512                file.write(&padded_document(tag)).unwrap();
513            }
514            deferred.join().unwrap();
515            let document = file.read().unwrap();
516            let config = document.entries[0].config.clone().unwrap();
517            let Node::String(text) = &config else {
518                panic!("config is not the string one writer wrote: {config:?}");
519            };
520            let tag: u64 = text.split('-').next().unwrap().parse().unwrap();
521            assert_eq!(document, padded_document(tag));
522            let _ = std::fs::remove_file(file.path());
523        }
524    }
525}