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 value =
150                    serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&content).map_err(|error| {
151                        IncludeError::Parse {
152                            format: "yaml",
153                            source: Box::new(error),
154                        }
155                    })?;
156                if value.is_null() {
157                    return Ok(Document::default());
158                }
159                serde_yaml_ng::from_value(value).map_err(|error| IncludeError::Parse {
160                    format: "yaml",
161                    source: Box::new(error),
162                })
163            }
164            FileFormat::Json => {
165                let value =
166                    serde_json::from_str::<serde_json::Value>(&content).map_err(|error| {
167                        IncludeError::Parse {
168                            format: "json",
169                            source: Box::new(error),
170                        }
171                    })?;
172                if value.is_null() {
173                    return Ok(Document::default());
174                }
175                serde_json::from_value(value).map_err(|error| IncludeError::Parse {
176                    format: "json",
177                    source: Box::new(error),
178                })
179            }
180        }
181    }
182
183    /// Serialize the document and replace the file atomically (write to a
184    /// sibling `.tmp` file, fsync, rename). A no-op while suspended.
185    pub fn write(&self, document: &Document) -> Result<()> {
186        write_document(&self.inner, document)
187    }
188
189    /// Schedule a coalesced write: rapid calls replace the pending document
190    /// (latest wins), and the physical write happens once `delay` has passed
191    /// without a newer call. A suspension active at flush time postpones the
192    /// write until it lifts. Errors surface through
193    /// [`LoaderFile::last_deferred_error`] and never propagate to callers.
194    ///
195    /// If the flusher thread cannot be spawned, the write happens
196    /// synchronously instead.
197    pub fn write_deferred(&self, document: Document, delay: Duration) {
198        {
199            let mut flusher = crate::lock(&self.inner.flusher);
200            if flusher.is_none() {
201                let weak = Arc::downgrade(&self.inner);
202                match std::thread::Builder::new()
203                    .name(format!("cordis-flush-{}", self.inner.path.display()))
204                    .spawn(move || flusher_loop(weak))
205                {
206                    Ok(thread) => *flusher = Some(thread),
207                    Err(_) => {
208                        drop(flusher);
209                        let _ = self.write(&document);
210                        return;
211                    }
212                }
213            }
214        }
215        {
216            let mut state = crate::lock(&self.inner.deferred);
217            state.pending = Some((document, Instant::now() + delay));
218            state.queued += 1;
219        }
220        self.inner.deferred_signal.notify_all();
221    }
222
223    /// Block until every [`LoaderFile::write_deferred`] call made before
224    /// this one has been flushed (or skipped by suspension lifting and a
225    /// later flush).
226    pub fn flush_deferred(&self) {
227        let mut state = crate::lock(&self.inner.deferred);
228        let target = state.queued;
229        while state.flushed < target || state.writing || state.pending.is_some() {
230            let (guard, _) = self
231                .inner
232                .deferred_signal
233                .wait_timeout(state, Duration::from_millis(100))
234                .unwrap_or_else(|error| error.into_inner());
235            state = guard;
236            if state.flushed >= target && !state.writing && state.pending.is_none() {
237                return;
238            }
239        }
240    }
241
242    /// The last deferred-write error, if the flusher failed.
243    pub fn last_deferred_error(&self) -> Option<String> {
244        crate::lock(&self.inner.deferred).last_error.clone()
245    }
246
247    /// Increment the suspend counter, returning a guard whose drop resumes
248    /// writes. Hold this while reloading a file so the resulting tree
249    /// patches are not written back.
250    pub fn suspend(&self) -> FileSuspendGuard {
251        {
252            let mut suspend = lock(&self.inner.suspend);
253            *suspend += 1;
254        }
255        FileSuspendGuard { file: self.clone() }
256    }
257
258    /// Whether any suspend guard is currently held for this file.
259    pub fn is_suspended(&self) -> bool {
260        *lock(&self.inner.suspend) > 0
261    }
262}
263
264/// Serialize `document` and atomically replace the file behind `inner`.
265/// A no-op while the file is suspended.
266///
267/// The whole sequence — serialize, write the sibling `.tmp`, fsync, rename —
268/// runs under `write_lock`: concurrent writers (a synchronous `write`, the
269/// deferred flusher, the loader's write-back) would otherwise interleave on
270/// the same `.tmp` path and the rename could publish torn content.
271fn write_document(inner: &FileInner, document: &Document) -> Result<()> {
272    let _write = crate::lock(&inner.write_lock);
273    if *crate::lock(&inner.suspend) > 0 {
274        return Ok(());
275    }
276    if let Ok(metadata) = fs::metadata(&inner.path) {
277        if metadata.permissions().readonly() {
278            return Err(IncludeError::ReadOnly {
279                path: inner.path.clone(),
280            });
281        }
282    }
283    let content = match inner.format {
284        FileFormat::Yaml => {
285            serde_yaml_ng::to_string(document).map_err(|error| IncludeError::Parse {
286                format: "yaml",
287                source: Box::new(error),
288            })?
289        }
290        FileFormat::Json => {
291            let mut text =
292                serde_json::to_string_pretty(document).map_err(|error| IncludeError::Parse {
293                    format: "json",
294                    source: Box::new(error),
295                })?;
296            text.push('\n');
297            text
298        }
299    };
300    if let Some(parent) = inner.path.parent() {
301        if !parent.as_os_str().is_empty() {
302            fs::create_dir_all(parent)?;
303        }
304    }
305    let file_name = inner
306        .path
307        .file_name()
308        .map(|name| name.to_string_lossy().into_owned())
309        .unwrap_or_default();
310    let tmp = inner.path.with_file_name(format!("{file_name}.tmp"));
311    {
312        let mut file = fs::File::create(&tmp)?;
313        file.write_all(content.as_bytes())?;
314        file.sync_all()?;
315    }
316    fs::rename(&tmp, &inner.path)?;
317    Ok(())
318}
319
320/// The flusher thread: holds only a weak reference so it dies with the last
321/// handle, and loops until the state is closed.
322fn flusher_loop(weak: Weak<FileInner>) {
323    const SUSPEND_RETRY: Duration = Duration::from_millis(50);
324    while let Some(inner) = weak.upgrade() {
325        let state = crate::lock(&inner.deferred);
326        if state.closed {
327            return;
328        }
329        let deadline = match state.pending.as_ref() {
330            Some((_, deadline)) => *deadline,
331            None => {
332                let waited = inner
333                    .deferred_signal
334                    .wait(state)
335                    .unwrap_or_else(|error| error.into_inner());
336                drop(waited);
337                continue;
338            }
339        };
340        drop(state);
341        let now = Instant::now();
342        if now < deadline {
343            // Park until the deadline (or a newer document/close) and
344            // re-decide with fresh state.
345            let state = crate::lock(&inner.deferred);
346            let (guard, _) = inner
347                .deferred_signal
348                .wait_timeout(state, deadline - now)
349                .unwrap_or_else(|error| error.into_inner());
350            drop(guard);
351            continue;
352        }
353        let mut state = crate::lock(&inner.deferred);
354        if state.closed {
355            return;
356        }
357        let Some((document, deadline)) = state.pending.take() else {
358            continue;
359        };
360        if Instant::now() < deadline {
361            state.pending = Some((document, deadline));
362            drop(state);
363            continue;
364        }
365        let queued_at_take = state.queued;
366        state.writing = true;
367        drop(state);
368
369        let suspended = *crate::lock(&inner.suspend) > 0;
370        if suspended {
371            let mut state = crate::lock(&inner.deferred);
372            state.pending = Some((document, Instant::now() + SUSPEND_RETRY));
373            state.writing = false;
374            drop(state);
375            inner.deferred_signal.notify_all();
376            continue;
377        }
378        let result = write_document(&inner, &document);
379        let mut state = crate::lock(&inner.deferred);
380        state.writing = false;
381        state.flushed = state.flushed.max(queued_at_take);
382        if let Err(error) = result {
383            state.last_error = Some(error.to_string());
384        }
385        drop(state);
386        inner.deferred_signal.notify_all();
387    }
388}
389
390impl Drop for FileInner {
391    fn drop(&mut self) {
392        {
393            let mut state = crate::lock(&self.deferred);
394            state.closed = true;
395        }
396        self.deferred_signal.notify_all();
397        if let Some(thread) = self
398            .flusher
399            .lock()
400            .unwrap_or_else(|error| error.into_inner())
401            .take()
402        {
403            let _ = thread.join();
404        }
405        // Anything still pending (e.g. suspended at close time) is written
406        // synchronously so the last state is not lost.
407        if let Some((document, _)) = self
408            .deferred
409            .lock()
410            .unwrap_or_else(|error| error.into_inner())
411            .pending
412            .take()
413        {
414            let _ = write_document(self, &document);
415        }
416    }
417}
418
419/// RAII guard for the file-level suspend counter.
420#[derive(Debug)]
421pub struct FileSuspendGuard {
422    file: LoaderFile,
423}
424
425impl Drop for FileSuspendGuard {
426    fn drop(&mut self) {
427        let mut suspend = lock(&self.file.inner.suspend);
428        *suspend = suspend.saturating_sub(1);
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use std::time::{SystemTime, UNIX_EPOCH};
436
437    fn temp_file(stem: &str) -> LoaderFile {
438        let path = std::env::temp_dir().join(format!(
439            "cordis-include-write-test-{stem}-{}-{}.yml",
440            std::process::id(),
441            randomish()
442        ));
443        let _ = std::fs::remove_file(&path);
444        LoaderFile::open(path).unwrap()
445    }
446
447    fn randomish() -> u64 {
448        SystemTime::now()
449            .duration_since(UNIX_EPOCH)
450            .map(|elapsed| elapsed.as_nanos() as u64)
451            .unwrap_or(0)
452    }
453
454    fn padded_document(tag: u64) -> Document {
455        // A large payload widens the torn-write window.
456        let filler = "x".repeat(4096);
457        let config = Node::String(format!("{tag}-{filler}"));
458        Document::with_entries(vec![
459            EntryOptions::new("w").with_id("w").with_config(config),
460        ])
461    }
462
463    /// Regression: two writers racing on the shared sibling `.tmp` could
464    /// interleave and the rename published torn content. The write lock
465    /// serializes whole write sequences; the final file must always parse
466    /// and equal one of the writers' documents in full.
467    #[test]
468    fn concurrent_writers_never_produce_a_torn_file() {
469        for _ in 0..25 {
470            let file = temp_file("race");
471            let writers: Vec<_> = (0..4_u64)
472                .map(|tag| {
473                    let file = file.clone();
474                    std::thread::spawn(move || {
475                        let document = padded_document(tag);
476                        for _ in 0..5 {
477                            file.write(&document).unwrap();
478                        }
479                    })
480                })
481                .collect();
482            for writer in writers {
483                writer.join().unwrap();
484            }
485            let document = file.read().unwrap();
486            let config = document.entries[0].config.clone().unwrap();
487            let Node::String(text) = &config else {
488                panic!("config is not the string one writer wrote: {config:?}");
489            };
490            let tag: u64 = text.split('-').next().unwrap().parse().unwrap();
491            let expected = padded_document(tag);
492            assert_eq!(document, expected, "torn or mixed content survived");
493            let _ = std::fs::remove_file(file.path());
494        }
495    }
496
497    /// The deferred flusher and a synchronous writer share the same file;
498    /// interleaving them must never leave an unparseable result behind.
499    #[test]
500    fn concurrent_deferred_and_sync_writes_stay_parseable() {
501        for _ in 0..25 {
502            let file = temp_file("deferred-race");
503            let deferred = std::thread::spawn({
504                let file = file.clone();
505                move || {
506                    for tag in 0..20_u64 {
507                        file.write_deferred(padded_document(tag), Duration::from_millis(1));
508                    }
509                    file.flush_deferred();
510                }
511            });
512            for tag in 100..120_u64 {
513                file.write(&padded_document(tag)).unwrap();
514            }
515            deferred.join().unwrap();
516            let document = file.read().unwrap();
517            let config = document.entries[0].config.clone().unwrap();
518            let Node::String(text) = &config else {
519                panic!("config is not the string one writer wrote: {config:?}");
520            };
521            let tag: u64 = text.split('-').next().unwrap().parse().unwrap();
522            assert_eq!(document, padded_document(tag));
523            let _ = std::fs::remove_file(file.path());
524        }
525    }
526}