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/// runs under `write_lock`: concurrent writers (a synchronous `write`, the
260/// deferred flusher, the loader's write-back) would otherwise interleave on
261/// the same `.tmp` path and the rename could publish torn content.
262fn write_document(inner: &FileInner, document: &Document) -> Result<()> {
263    let _write = crate::lock(&inner.write_lock);
264    if *crate::lock(&inner.suspend) > 0 {
265        return Ok(());
266    }
267    if let Ok(metadata) = fs::metadata(&inner.path) {
268        if metadata.permissions().readonly() {
269            return Err(IncludeError::ReadOnly {
270                path: inner.path.clone(),
271            });
272        }
273    }
274    let content = match inner.format {
275        FileFormat::Yaml => crate::yaml::emit_document(document),
276        FileFormat::Json => {
277            let mut text =
278                serde_json::to_string_pretty(document).map_err(|error| IncludeError::Parse {
279                    format: "json",
280                    source: Box::new(error),
281                })?;
282            text.push('\n');
283            text
284        }
285    };
286    if let Some(parent) = inner.path.parent() {
287        if !parent.as_os_str().is_empty() {
288            fs::create_dir_all(parent)?;
289        }
290    }
291    let file_name = inner
292        .path
293        .file_name()
294        .map(|name| name.to_string_lossy().into_owned())
295        .unwrap_or_default();
296    let tmp = inner.path.with_file_name(format!("{file_name}.tmp"));
297    {
298        let mut file = fs::File::create(&tmp)?;
299        file.write_all(content.as_bytes())?;
300        file.sync_all()?;
301    }
302    fs::rename(&tmp, &inner.path)?;
303    Ok(())
304}
305
306/// The flusher thread: holds only a weak reference so it dies with the last
307/// handle, and loops until the state is closed.
308fn flusher_loop(weak: Weak<FileInner>) {
309    const SUSPEND_RETRY: Duration = Duration::from_millis(50);
310    while let Some(inner) = weak.upgrade() {
311        let state = crate::lock(&inner.deferred);
312        if state.closed {
313            return;
314        }
315        let deadline = match state.pending.as_ref() {
316            Some((_, deadline)) => *deadline,
317            None => {
318                let waited = inner
319                    .deferred_signal
320                    .wait(state)
321                    .unwrap_or_else(|error| error.into_inner());
322                drop(waited);
323                continue;
324            }
325        };
326        drop(state);
327        let now = Instant::now();
328        if now < deadline {
329            // Park until the deadline (or a newer document/close) and
330            // re-decide with fresh state.
331            let state = crate::lock(&inner.deferred);
332            let (guard, _) = inner
333                .deferred_signal
334                .wait_timeout(state, deadline - now)
335                .unwrap_or_else(|error| error.into_inner());
336            drop(guard);
337            continue;
338        }
339        let mut state = crate::lock(&inner.deferred);
340        if state.closed {
341            return;
342        }
343        let Some((document, deadline)) = state.pending.take() else {
344            continue;
345        };
346        if Instant::now() < deadline {
347            state.pending = Some((document, deadline));
348            drop(state);
349            continue;
350        }
351        let queued_at_take = state.queued;
352        state.writing = true;
353        drop(state);
354
355        let suspended = *crate::lock(&inner.suspend) > 0;
356        if suspended {
357            let mut state = crate::lock(&inner.deferred);
358            state.pending = Some((document, Instant::now() + SUSPEND_RETRY));
359            state.writing = false;
360            drop(state);
361            inner.deferred_signal.notify_all();
362            continue;
363        }
364        let result = write_document(&inner, &document);
365        let mut state = crate::lock(&inner.deferred);
366        state.writing = false;
367        state.flushed = state.flushed.max(queued_at_take);
368        if let Err(error) = result {
369            state.last_error = Some(error.to_string());
370        }
371        drop(state);
372        inner.deferred_signal.notify_all();
373    }
374}
375
376impl Drop for FileInner {
377    fn drop(&mut self) {
378        {
379            let mut state = crate::lock(&self.deferred);
380            state.closed = true;
381        }
382        self.deferred_signal.notify_all();
383        if let Some(thread) = self
384            .flusher
385            .lock()
386            .unwrap_or_else(|error| error.into_inner())
387            .take()
388        {
389            let _ = thread.join();
390        }
391        // Anything still pending (e.g. suspended at close time) is written
392        // synchronously so the last state is not lost.
393        if let Some((document, _)) = self
394            .deferred
395            .lock()
396            .unwrap_or_else(|error| error.into_inner())
397            .pending
398            .take()
399        {
400            let _ = write_document(self, &document);
401        }
402    }
403}
404
405/// RAII guard for the file-level suspend counter.
406#[derive(Debug)]
407pub struct FileSuspendGuard {
408    file: LoaderFile,
409}
410
411impl Drop for FileSuspendGuard {
412    fn drop(&mut self) {
413        let mut suspend = lock(&self.file.inner.suspend);
414        *suspend = suspend.saturating_sub(1);
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use std::time::{SystemTime, UNIX_EPOCH};
422
423    fn temp_file(stem: &str) -> LoaderFile {
424        let path = std::env::temp_dir().join(format!(
425            "cordis-include-write-test-{stem}-{}-{}.yml",
426            std::process::id(),
427            randomish()
428        ));
429        let _ = std::fs::remove_file(&path);
430        LoaderFile::open(path).unwrap()
431    }
432
433    fn randomish() -> u64 {
434        SystemTime::now()
435            .duration_since(UNIX_EPOCH)
436            .map(|elapsed| elapsed.as_nanos() as u64)
437            .unwrap_or(0)
438    }
439
440    fn padded_document(tag: u64) -> Document {
441        // A large payload widens the torn-write window.
442        let filler = "x".repeat(4096);
443        let config = Node::String(format!("{tag}-{filler}"));
444        Document::with_entries(vec![
445            EntryOptions::new("w").with_id("w").with_config(config),
446        ])
447    }
448
449    /// Regression: two writers racing on the shared sibling `.tmp` could
450    /// interleave and the rename published torn content. The write lock
451    /// serializes whole write sequences; the final file must always parse
452    /// and equal one of the writers' documents in full.
453    #[test]
454    fn concurrent_writers_never_produce_a_torn_file() {
455        for _ in 0..25 {
456            let file = temp_file("race");
457            let writers: Vec<_> = (0..4_u64)
458                .map(|tag| {
459                    let file = file.clone();
460                    std::thread::spawn(move || {
461                        let document = padded_document(tag);
462                        for _ in 0..5 {
463                            file.write(&document).unwrap();
464                        }
465                    })
466                })
467                .collect();
468            for writer in writers {
469                writer.join().unwrap();
470            }
471            let document = file.read().unwrap();
472            let config = document.entries[0].config.clone().unwrap();
473            let Node::String(text) = &config else {
474                panic!("config is not the string one writer wrote: {config:?}");
475            };
476            let tag: u64 = text.split('-').next().unwrap().parse().unwrap();
477            let expected = padded_document(tag);
478            assert_eq!(document, expected, "torn or mixed content survived");
479            let _ = std::fs::remove_file(file.path());
480        }
481    }
482
483    /// The deferred flusher and a synchronous writer share the same file;
484    /// interleaving them must never leave an unparseable result behind.
485    #[test]
486    fn concurrent_deferred_and_sync_writes_stay_parseable() {
487        for _ in 0..25 {
488            let file = temp_file("deferred-race");
489            let deferred = std::thread::spawn({
490                let file = file.clone();
491                move || {
492                    for tag in 0..20_u64 {
493                        file.write_deferred(padded_document(tag), Duration::from_millis(1));
494                    }
495                    file.flush_deferred();
496                }
497            });
498            for tag in 100..120_u64 {
499                file.write(&padded_document(tag)).unwrap();
500            }
501            deferred.join().unwrap();
502            let document = file.read().unwrap();
503            let config = document.entries[0].config.clone().unwrap();
504            let Node::String(text) = &config else {
505                panic!("config is not the string one writer wrote: {config:?}");
506            };
507            let tag: u64 = text.split('-').next().unwrap().parse().unwrap();
508            assert_eq!(document, padded_document(tag));
509            let _ = std::fs::remove_file(file.path());
510        }
511    }
512}