Skip to main content

teksilo_settings/
flush.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Debounced, **cross-process-safe** atomic file writer.
5//!
6//! `DebouncedWriter` accepts [`Patch`]es — *replayable mutations* — via
7//! [`schedule`](DebouncedWriter::schedule), batches rapid bursts inside a
8//! debounce window, and then applies the whole batch to the document **read from
9//! disk under an exclusive advisory lock**, writing the result atomically
10//! (write-temp + fsync + rename).
11//!
12//! ## Why a patch and not a rendered string
13//!
14//! This writer used to carry a pre-rendered `String`: the caller serialised its
15//! entire in-memory document and the worker blindly wrote those bytes. That is
16//! **last-write-wins by construction** — the worker had nothing to merge *with*,
17//! so any concurrent change a peer process made to another part of the file was
18//! silently destroyed. (A lock alone does not fix this: it serialises the two
19//! writes but does nothing about the stale snapshot one of them was rendered
20//! from.)
21//!
22//! A `Patch` instead says "given the file's current text, produce its new text",
23//! so the merge happens against reality:
24//!
25//! ```text
26//!   lock  ->  read current  ->  apply queued patches  ->  write atomically  ->  unlock
27//! ```
28//!
29//! Patches are built inside this crate from *owned* snapshots of each type's
30//! pending mutations (a `Vec<(key, value)>`, a list of ops), so they capture no
31//! `Rc` and can cross to the worker thread. Callers never see one: they keep
32//! writing `signal.set(v)`, `mru.add(e)`, `file.mutate(|s| ..)`.
33//!
34//! ## Single shared I/O thread
35//!
36//! All `DebouncedWriter`s in a process share **one** background I/O thread
37//! (lazily started on first use). Each writer registers under a unique
38//! [`WriterId`]. The shared thread:
39//!
40//! * keeps a per-id `(deadline, patch queue)`,
41//! * blocks on the next-due deadline (or waits for a message if nothing is
42//!   pending),
43//! * coalesces rapid `Schedule` bursts by **appending** to the queue,
44//!   resetting the failure streak (a just-queued patch has never itself
45//!   failed to write) and moving the deadline **forward, never backward** —
46//!   so debouncing collapses *writes*, never *mutations*, and a live
47//!   `RETRY_BACKOFF` deadline installed after a failed attempt can't be
48//!   clobbered back to "now" by an unrelated new patch on a zero-delay
49//!   writer. (The old design could overwrite the pending payload precisely
50//!   because each payload was a complete, self-superseding rendering.)
51//!
52//! A failed write **retains** the queue and retries with backoff, up to
53//! [`MAX_WRITE_ATTEMPTS`] — the patches replay cleanly against whatever is on
54//! disk then, which is the correct merge rather than a stale overwrite. Once
55//! the cap is reached (or a writer is dropped mid-failure at process
56//! teardown), the queue is discarded for good and reported through the
57//! process-wide [`WriteFailureSink`] (registered via
58//! [`set_write_failure_sink`]) in addition to the existing log — the write
59//! side's analogue of [`crate::reload::Reloadable`]'s read-side contract.
60//! Conversely, every writer may also register a [`WriteLandedSink`] (via
61//! [`DebouncedWriter::set_landed_sink`]) to learn the *real* on-disk stamp
62//! the instant its queued patches land successfully — useful to a caller
63//! whose own `apply()`-style API schedules a write and returns before it's
64//! actually on disk.
65//!
66//! The locked read-merge-write ([`apply_and_write`]) acquires its advisory
67//! lock **non-blocking**: because every writer in the process shares this
68//! one thread, a lock held by a peer process must never stall it — a
69//! contended lock is just another transient [`FlushError::Io`], retried
70//! with the same backoff as any other write failure.
71//!
72//! Application logic stays single-threaded — `SettingsStore` and friends never
73//! block on I/O. `Drop` sends an `Unregister` that synchronously flushes the
74//! queue before returning, so end-of-process state is never lost (unless the
75//! flush itself is still failing, in which case the discard is reported
76//! through `WriteFailureSink` exactly as above).
77//!
78//! ## Why one thread, not one-per-writer
79//!
80//! An app that opens the K/V store + recents + window state already has 3
81//! writers; a richer app might have 5–10, each idle ~99% of the time. One shared
82//! worker is leaner and has identical semantics from the caller's point of view.
83
84use std::collections::HashMap;
85use std::fs;
86use std::io::{self, Write};
87use std::path::{Path, PathBuf};
88use std::sync::atomic::{AtomicU64, Ordering};
89use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, SyncSender};
90use std::sync::{Arc, OnceLock};
91use std::thread;
92use std::time::{Duration, Instant, SystemTime};
93
94use tempfile::NamedTempFile;
95
96/// Errors surfaced by [`DebouncedWriter::flush_now`].
97#[derive(Debug, thiserror::Error)]
98pub enum FlushError {
99    /// The shared I/O worker thread has panicked or shut down; writes
100    /// can no longer be delivered.
101    #[error("settings I/O thread disconnected")]
102    Disconnected,
103    /// The atomic write (temp-file + rename) failed at the OS level.
104    #[error("settings flush failed: {0}")]
105    Io(#[from] io::Error),
106    /// A `Patch` could not be applied to the document currently on
107    /// disk — e.g. a peer wrote something this process cannot parse or
108    /// migrate.
109    #[error("settings merge failed: {0}")]
110    Merge(String),
111}
112
113/// A **replayable** mutation of a settings file.
114///
115/// "Given the file's current raw text on disk (`None` if it does not
116/// exist yet), produce its new raw text."
117///
118/// This is the heart of cross-process correctness. The worker applies a
119/// patch to the document it *just read, under the lock* — never to a
120/// snapshot this process cached minutes ago — so a peer's concurrent
121/// changes to other parts of the document survive. Contrast the previous
122/// design, where the payload was a pre-rendered `String`: the worker had
123/// nothing to merge *with*, so every write was a blind whole-document
124/// overwrite, i.e. last-write-wins.
125///
126/// ## Why `Fn`, not `FnOnce`
127///
128/// A patch may need to be applied more than once: if the write fails
129/// (disk full, a transient network mount), the queue is **retained** and
130/// replayed on the next tick against whatever is on disk *then*. That
131/// re-application is exactly the right merge, and it is only possible if
132/// a patch can be called again. `FnOnce` would force us to either drop
133/// the mutation (data loss) or cache a pre-rendered string (defeating
134/// the merge).
135///
136/// ## Why this never leaks into the public API
137///
138/// Patches are built **inside** this crate by each persisted type, from
139/// *owned snapshots* of its own pending mutations (a `Vec<(key, value)>`,
140/// a list of ops). They therefore capture no `Rc`, which is what lets
141/// them cross to the writer thread. Callers keep writing
142/// `signal.set(v)` / `mru.add(e)` / `file.mutate(|s| ...)` and never see
143/// a `Patch`.
144pub(crate) type Patch = Box<dyn Fn(Option<String>) -> Result<String, FlushError> + Send + 'static>;
145
146/// How many times a failing write is retried before the queued patches
147/// are dropped (with a loud log). Without a cap, a permanently
148/// unwritable file (read-only mount, revoked permissions) would spin the
149/// worker forever.
150const MAX_WRITE_ATTEMPTS: u32 = 5;
151
152/// Backoff floor between write retries, so a failing disk does not spin
153/// the worker at the debounce interval.
154const RETRY_BACKOFF: Duration = Duration::from_millis(250);
155
156/// How long [`DebouncedWriter::drop`] waits for the worker to acknowledge
157/// its final flush before giving up. Generous — the ack normally lands in
158/// microseconds, and a slow disk must not cost us the last write — but
159/// finite, because a writer dropped *after* the runtime has begun tearing
160/// the process down has no worker left to answer it. See the `Drop` impl.
161const DROP_ACK_TIMEOUT: Duration = Duration::from_secs(5);
162
163/// Invoked (off the caller's thread — on the shared worker thread) when a
164/// `DebouncedWriter`'s queued patches are **permanently** discarded: either
165/// `flush_writer` gave up after `MAX_WRITE_ATTEMPTS`, or the writer was
166/// dropped (`Unregister`) while its final flush was still failing. This is
167/// the write-side analogue of [`crate::reload::Reloadable`]'s read-side
168/// contract — the previous behaviour was a bare `eprintln!` that never left
169/// the worker thread, so a permanently unwritable settings file (read-only
170/// mount, revoked permissions, disk full) silently ate every change for the
171/// rest of the session with zero signal to the application. Registered
172/// process-wide via [`set_write_failure_sink`].
173pub type WriteFailureSink = Arc<dyn Fn(PathBuf, u32, usize, String) + Send + Sync + 'static>;
174
175/// Register a process-wide sink invoked whenever any `DebouncedWriter`
176/// permanently discards a queued write (see [`WriteFailureSink`]). There is
177/// only one slot: a later call replaces an earlier one. `teksilo-app` uses
178/// this to forward the failure to the UI thread as a typed `AppEvent`.
179pub fn set_write_failure_sink(sink: WriteFailureSink) {
180    let _ = pool().send(PoolMsg::SetFailureSink(sink));
181}
182
183/// The `(mtime, len)` stamp `disk_stamp` computes for a settings file —
184/// named so every `Arc<Mutex<...>>` wrapping it (here and in
185/// `WindowStateService`) reads as one term instead of clippy's
186/// `type_complexity`-tripping nested-generics spelling.
187pub type LandedStamp = (Option<SystemTime>, Option<u64>);
188
189/// Invoked on the shared worker thread the instant a `DebouncedWriter`'s
190/// queued patches land successfully, with the fresh on-disk `(mtime, len)`
191/// stamp (one extra `fs::metadata`, computed once, right after the write —
192/// negligible cost). The write-side analogue of `WriteFailureSink`. `Send +
193/// Sync` because it runs off the caller's thread — a consumer that needs to
194/// update `!Send` state (an `Rc<Cell<_>>`) must copy the value out on its
195/// own thread the next time it looks (see
196/// `WindowStateService::reload_from_disk`).
197pub type WriteLandedSink = Arc<dyn Fn(LandedStamp) + Send + Sync + 'static>;
198
199// ---------------------------------------------------------------------------
200// Shared worker pool
201// ---------------------------------------------------------------------------
202
203/// Opaque per-writer identifier minted by [`next_writer_id`]. Used by
204/// the shared worker thread to key pending payloads.
205#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
206struct WriterId(u64);
207
208fn next_writer_id() -> WriterId {
209    static COUNTER: AtomicU64 = AtomicU64::new(1);
210    WriterId(COUNTER.fetch_add(1, Ordering::Relaxed))
211}
212
213/// Messages exchanged with the shared worker thread.
214enum PoolMsg {
215    Register {
216        id: WriterId,
217        path: PathBuf,
218        delay: Duration,
219    },
220    Schedule {
221        id: WriterId,
222        patch: Patch,
223    },
224    FlushNow {
225        id: WriterId,
226        ack: SyncSender<Result<(), FlushError>>,
227    },
228    Unregister {
229        id: WriterId,
230        ack: SyncSender<()>,
231    },
232    /// Register the process-wide sink invoked when a write is permanently
233    /// discarded (see [`WriteFailureSink`]). Replaces any previous sink.
234    SetFailureSink(WriteFailureSink),
235    /// Register `id`'s sink invoked with the fresh on-disk stamp whenever
236    /// its queued patches land successfully (see [`WriteLandedSink`]).
237    /// Replaces any previous sink for the same `id`.
238    SetLandedSink {
239        id: WriterId,
240        sink: WriteLandedSink,
241    },
242}
243
244/// A writer's queued-but-not-yet-written mutations.
245struct Pending {
246    deadline: Instant,
247    /// **A queue, not a slot.** Patches are deltas, so a later one does
248    /// not supersede an earlier one — dropping any of them silently
249    /// loses a mutation. (The previous design could overwrite the
250    /// pending payload precisely because each payload was a complete
251    /// whole-document rendering.) Debouncing still coalesces *writes*:
252    /// the whole queue is applied in one locked read-merge-write.
253    patches: Vec<Patch>,
254    /// Consecutive failed write attempts, for backoff + the drop cap.
255    attempts: u32,
256}
257
258struct PoolState {
259    delays: HashMap<WriterId, Duration>,
260    paths: HashMap<WriterId, PathBuf>,
261    pending: HashMap<WriterId, Pending>,
262    /// Process-wide sink for permanently-discarded writes (F3). At most
263    /// one at a time — a later `SetFailureSink` replaces an earlier one.
264    failure_sink: Option<WriteFailureSink>,
265    /// Per-writer sinks for successful-flush stamps (F11 /
266    /// `WriteLandedSink`), keyed by the same `WriterId` the writer was
267    /// registered under.
268    landed_sinks: HashMap<WriterId, WriteLandedSink>,
269}
270
271/// Lazily-started shared I/O worker. Returns the sender side of the
272/// pool channel. The receiver lives inside the worker thread and the
273/// thread itself is detached: the process exit teardown closes any
274/// in-flight resources, but every `DebouncedWriter::Drop` synchronously
275/// flushes its own payload first via the `Unregister` ack, so no data
276/// is lost in normal teardown.
277fn pool() -> &'static Sender<PoolMsg> {
278    static POOL: OnceLock<Sender<PoolMsg>> = OnceLock::new();
279    POOL.get_or_init(|| {
280        let (tx, rx) = mpsc::channel();
281        thread::Builder::new()
282            .name("teksilo-settings-writer".into())
283            .spawn(move || worker_loop(rx))
284            .expect("teksilo-settings: failed to spawn writer thread");
285        tx
286    })
287}
288
289/// Handle a `Schedule` message: append `patch` to `id`'s queue, resetting
290/// the failure streak and (monotonically) extending the deadline forward.
291/// Factored out of `worker_loop`'s match arm so tests can drive exactly
292/// this logic without going through the channel/thread machinery.
293fn apply_schedule(state: &mut PoolState, id: WriterId, patch: Patch) {
294    let delay = state.delays.get(&id).copied().unwrap_or(Duration::ZERO);
295    let slot = state.pending.entry(id).or_insert_with(|| Pending {
296        deadline: Instant::now() + delay,
297        patches: Vec::new(),
298        attempts: 0,
299    });
300    // APPEND. A patch is a delta; replacing the slot (as the
301    // old whole-document design did) would silently drop the
302    // earlier mutation.
303    slot.patches.push(patch);
304    // New work resets the failure streak: the patch just
305    // appended has never itself failed to write, and
306    // MAX_WRITE_ATTEMPTS is meant to police a *persistent*
307    // failure with no new work arriving — the plain
308    // per-tick retry path (the `Err(RecvTimeoutError::Timeout)`
309    // arm in `worker_loop`) still enforces that cap unaffected by
310    // this reset, since it only re-flushes ids already in
311    // `pending` without going through `Schedule` again.
312    slot.attempts = 0;
313    // The deadline only ever moves *forward*, never back.
314    // Ordinary debounce coalescing during an active burst is
315    // unaffected (the slot's deadline was already `<= now +
316    // delay` in that case, since it was armed by an earlier
317    // `Schedule` in the same burst), but a zero-delay writer
318    // that just had `flush_writer` install a future
319    // `RETRY_BACKOFF` deadline after a failed attempt can no
320    // longer have that backoff clobbered back to `now` by an
321    // unrelated new `Schedule` arriving before the backoff
322    // elapses.
323    slot.deadline = slot.deadline.max(Instant::now() + delay);
324}
325
326fn worker_loop(rx: Receiver<PoolMsg>) {
327    let mut state = PoolState {
328        delays: HashMap::new(),
329        paths: HashMap::new(),
330        pending: HashMap::new(),
331        failure_sink: None,
332        landed_sinks: HashMap::new(),
333    };
334
335    loop {
336        // Compute how long to wait. With no pending payload we block
337        // indefinitely on the next message; otherwise we wait until
338        // the next-due deadline (or for a new message, whichever
339        // comes first).
340        let next_deadline = state.pending.values().map(|p| p.deadline).min();
341        let recv_result = match next_deadline {
342            None => rx.recv().map_err(|_| RecvTimeoutError::Disconnected),
343            Some(d) => {
344                let wait = d.saturating_duration_since(Instant::now());
345                rx.recv_timeout(wait)
346            }
347        };
348
349        match recv_result {
350            Ok(PoolMsg::Register { id, path, delay }) => {
351                state.delays.insert(id, delay);
352                state.paths.insert(id, path);
353            }
354            Ok(PoolMsg::Schedule { id, patch }) => apply_schedule(&mut state, id, patch),
355            Ok(PoolMsg::FlushNow { id, ack }) => {
356                let _ = ack.send(flush_writer(&mut state, id));
357            }
358            Ok(PoolMsg::Unregister { id, ack }) => {
359                if let Err(e) = flush_writer(&mut state, id) {
360                    let path = state.paths.get(&id).cloned();
361                    let path_str = path.as_ref().map(|p| p.display().to_string());
362                    eprintln!(
363                        "teksilo-settings: final flush of {} failed: {e}",
364                        path_str.as_deref().unwrap_or("<unknown>"),
365                    );
366                    // If `flush_writer`'s own give-up branch already fired
367                    // (MAX_WRITE_ATTEMPTS reached), it already removed
368                    // `pending` and invoked `failure_sink` itself — no
369                    // entry remains here, so nothing more to report.
370                    // Otherwise this `Unregister` is the *second* discard
371                    // site F3 calls out: process teardown can't wait for
372                    // further retries, so it forces the drop here, below
373                    // the attempt cap, and must report it itself.
374                    if let Some(pending) = state.pending.get(&id) {
375                        let attempts = pending.attempts;
376                        let dropped = pending.patches.len();
377                        if let Some(sink) = &state.failure_sink {
378                            sink(path.unwrap_or_default(), attempts, dropped, e.to_string());
379                        }
380                    }
381                }
382                state.pending.remove(&id);
383                state.delays.remove(&id);
384                state.paths.remove(&id);
385                state.landed_sinks.remove(&id);
386                let _ = ack.send(());
387            }
388            Ok(PoolMsg::SetFailureSink(sink)) => {
389                state.failure_sink = Some(sink);
390            }
391            Ok(PoolMsg::SetLandedSink { id, sink }) => {
392                state.landed_sinks.insert(id, sink);
393            }
394            Err(RecvTimeoutError::Timeout) => {
395                let now = Instant::now();
396                // Collect every id whose deadline has expired. We can't
397                // mutate the map while iterating it, so collect first,
398                // drain second.
399                let due: Vec<WriterId> = state
400                    .pending
401                    .iter()
402                    .filter_map(|(id, p)| if p.deadline <= now { Some(*id) } else { None })
403                    .collect();
404                for id in due {
405                    if let Err(e) = flush_writer(&mut state, id) {
406                        let path = state.paths.get(&id).map(|p| p.display().to_string());
407                        eprintln!(
408                            "teksilo-settings: write to {} failed: {e}",
409                            path.as_deref().unwrap_or("<unknown>"),
410                        );
411                    }
412                }
413            }
414            Err(RecvTimeoutError::Disconnected) => {
415                // Channel closed (only happens at process tear-down,
416                // since the sender is held in a static OnceLock).
417                return;
418            }
419        }
420    }
421}
422
423/// Apply `id`'s queued patches to the document **currently on disk**, under an
424/// exclusive advisory lock, and write the result.
425///
426/// This is the whole point of the design: the read, the merge and the write are
427/// one critical section, so a peer process cannot interleave a write between our
428/// read and our write and have it silently overwritten.
429///
430/// On success the queue is cleared. On failure it is **retained** and retried
431/// (with backoff) on a later tick — patches are `Fn`, so replaying them against
432/// whatever is on disk *then* is a correct merge, not a stale overwrite. After
433/// [`MAX_WRITE_ATTEMPTS`] the queue is dropped with a loud log, so a permanently
434/// unwritable file cannot spin the worker forever.
435fn flush_writer(state: &mut PoolState, id: WriterId) -> Result<(), FlushError> {
436    let Some(pending) = state.pending.get(&id) else {
437        return Ok(()); // nothing queued
438    };
439    if pending.patches.is_empty() {
440        state.pending.remove(&id);
441        return Ok(());
442    }
443    let Some(path) = state.paths.get(&id).cloned() else {
444        // Unregistered mid-flight; nothing sensible to write to.
445        state.pending.remove(&id);
446        return Ok(());
447    };
448
449    let result = apply_and_write(&path, &pending.patches);
450
451    match result {
452        Ok(()) => {
453            state.pending.remove(&id);
454            // F11: report the fresh post-write stamp to whoever
455            // registered a `WriteLandedSink` for this writer (one extra
456            // `fs::metadata`, computed once, right after the write —
457            // negligible next to the write itself). Consumers that need
458            // to mutate `!Send` state off this thread (e.g.
459            // `WindowStateService`'s `Rc<Cell<_>>`) stash the value and
460            // pick it up next time they run on their own thread.
461            if let Some(sink) = state.landed_sinks.get(&id) {
462                sink(crate::file::disk_stamp(&path));
463            }
464            Ok(())
465        }
466        Err(e) => {
467            let delay = state.delays.get(&id).copied().unwrap_or(Duration::ZERO);
468            let slot = state
469                .pending
470                .get_mut(&id)
471                .expect("pending entry checked above");
472            slot.attempts += 1;
473            if slot.attempts >= MAX_WRITE_ATTEMPTS {
474                let attempts = slot.attempts;
475                let dropped = slot.patches.len();
476                eprintln!(
477                    "teksilo-settings: giving up on {} after {} failed attempts; \
478                     {} queued change(s) discarded: {e}",
479                    path.display(),
480                    attempts,
481                    dropped,
482                );
483                // F3, discard site 1: the queue is about to be dropped
484                // for good — report it through the process-wide sink (if
485                // one is registered) in addition to the log above.
486                if let Some(sink) = &state.failure_sink {
487                    sink(path.clone(), attempts, dropped, e.to_string());
488                }
489                state.pending.remove(&id);
490            } else {
491                slot.deadline = Instant::now() + delay.max(RETRY_BACKOFF);
492            }
493            Err(e)
494        }
495    }
496}
497
498/// The locked read-merge-write itself.
499fn apply_and_write(path: &Path, patches: &[Patch]) -> Result<(), FlushError> {
500    // Hold the lock across read + merge + write, so this is atomic with
501    // respect to every other `FileLock` holder (this process's other
502    // handles, and any peer process using the same primitive).
503    //
504    // Non-blocking (F10): ALL `DebouncedWriter`s in the process share this
505    // one worker thread, so a *blocking* acquire here would stall every
506    // other writer's flush (and `flush_now`'s synchronous ack, called on
507    // the UI thread at shutdown) for as long as some peer holds the lock —
508    // including hanging process exit. A contended lock is just another
509    // transient `FlushError::Io`: `flush_writer`'s existing retry+backoff
510    // loop already handles any `Err` from this function uniformly, so
511    // losing this one immediate attempt to a peer costs nothing but a
512    // retry on the next scheduled tick.
513    let _guard = crate::lock::FileLock::try_acquire_exclusive(path)?;
514
515    let current = match fs::read_to_string(path) {
516        Ok(s) => Some(s),
517        Err(e) if e.kind() == io::ErrorKind::NotFound => None,
518        Err(e) => return Err(e.into()),
519    };
520
521    let mut text = current;
522    for patch in patches {
523        text = Some(patch(text)?);
524    }
525
526    match text {
527        Some(t) => write_atomic(path, &t).map_err(FlushError::from),
528        // Unreachable: `patches` is non-empty and every patch yields a
529        // `String`. Guard rather than unwrap.
530        None => Ok(()),
531    }
532}
533
534// ---------------------------------------------------------------------------
535// Atomic write
536// ---------------------------------------------------------------------------
537
538/// Write `contents` to `path` atomically: create a temp file in the
539/// same directory, write + `sync_all`, then rename over the target.
540/// Same-directory rename is atomic on every supported POSIX filesystem
541/// and on NTFS (Windows).  Parent directories are created if absent.
542pub(crate) fn write_atomic(path: &Path, contents: &str) -> io::Result<()> {
543    let dir = path.parent().ok_or_else(|| {
544        io::Error::new(
545            io::ErrorKind::InvalidInput,
546            "DebouncedWriter path has no parent directory",
547        )
548    })?;
549    fs::create_dir_all(dir)?;
550    let mut tmp = NamedTempFile::new_in(dir)?;
551    tmp.as_file_mut().write_all(contents.as_bytes())?;
552    tmp.as_file_mut().sync_all()?;
553    tmp.persist(path).map_err(|e| e.error)?;
554    Ok(())
555}
556
557// ---------------------------------------------------------------------------
558// DebouncedWriter — public handle
559// ---------------------------------------------------------------------------
560
561/// Atomic, debounced single-file writer.
562///
563/// All writers in a process share one background I/O thread (see
564/// module docs). Each writer is identified by an opaque `WriterId`;
565/// dropping a writer synchronously flushes its pending payload before
566/// returning.
567pub struct DebouncedWriter {
568    id: WriterId,
569    path: PathBuf,
570    delay: Duration,
571}
572
573impl DebouncedWriter {
574    /// Create a writer that will atomically write to `path`, coalescing
575    /// rapid `schedule` bursts inside `delay`.
576    ///
577    /// `delay = Duration::ZERO` makes every `schedule` flush on the
578    /// worker's very next iteration — useful for tests.
579    pub fn new(path: PathBuf, delay: Duration) -> Self {
580        let id = next_writer_id();
581        let _ = pool().send(PoolMsg::Register {
582            id,
583            path: path.clone(),
584            delay,
585        });
586        Self { id, path, delay }
587    }
588
589    /// Queue a [`Patch`] — a replayable mutation of the file.
590    ///
591    /// The patch is **appended** to this writer's queue, and the deadline is
592    /// moved **forward** to `now + delay` (the debounce window restarts on
593    /// activity; an already-armed retry backoff is never pulled back). At
594    /// the deadline the whole queue is applied, in order, to the document read
595    /// from disk **under an exclusive lock**, and the result written atomically.
596    ///
597    /// Debouncing therefore coalesces *writes*, never *mutations*: ten `set`s
598    /// inside one window still produce one write, but all ten are applied — and
599    /// applied on top of whatever a peer process wrote in the meantime.
600    pub(crate) fn schedule(&self, patch: Patch) {
601        let _ = pool().send(PoolMsg::Schedule { id: self.id, patch });
602    }
603
604    /// Force any queued patches to disk synchronously. Returns `Ok(())` if
605    /// there was nothing queued.
606    pub fn flush_now(&self) -> Result<(), FlushError> {
607        let (ack_tx, ack_rx) = mpsc::sync_channel(0);
608        pool()
609            .send(PoolMsg::FlushNow {
610                id: self.id,
611                ack: ack_tx,
612            })
613            .map_err(|_| FlushError::Disconnected)?;
614        match ack_rx.recv() {
615            Ok(result) => result,
616            Err(_) => Err(FlushError::Disconnected),
617        }
618    }
619
620    /// Register a sink for this writer's successful-flush stamp (opt-in; a
621    /// writer with none behaves exactly as today). May be called any time
622    /// after construction — including after the writer has already flushed
623    /// once, since the sink is only ever consulted on a *future* successful
624    /// flush.
625    ///
626    /// This is how a caller learns the *real* on-disk stamp resulting from
627    /// its own debounced write, without guessing: `apply()` schedules a
628    /// patch and returns before it lands, so only the worker thread — right
629    /// after the write actually succeeds — knows the resulting `(mtime,
630    /// len)`. See `WindowStateService::reload_from_disk` for the consumer
631    /// side (F11).
632    pub fn set_landed_sink(&self, sink: WriteLandedSink) {
633        let _ = pool().send(PoolMsg::SetLandedSink { id: self.id, sink });
634    }
635
636    /// The destination path this writer flushes to.
637    pub fn path(&self) -> &Path {
638        &self.path
639    }
640
641    /// The debounce window configured at construction; `Duration::ZERO`
642    /// means every scheduled payload is written on the worker's next
643    /// iteration (useful in tests).
644    pub fn delay(&self) -> Duration {
645        self.delay
646    }
647}
648
649impl Drop for DebouncedWriter {
650    fn drop(&mut self) {
651        // Send an Unregister with a sync ack so we don't return from
652        // Drop until the pending payload (if any) has been flushed.
653        // Without this, exit-time data would race with process
654        // teardown.
655        //
656        // The wait is **bounded**, and that bound is load-bearing. A writer
657        // owned by a `thread_local!` (or any other slot whose destructor the
658        // runtime defers) is dropped *after* `main` returns: on Windows the
659        // main thread's TLS destructors run inside `DLL_PROCESS_DETACH`,
660        // which `ExitProcess` reaches only after it has already killed every
661        // other thread — the worker among them. `send` still succeeds there,
662        // because the `Sender` lives in a `OnceLock` and outlives the thread
663        // it fed, so an unbounded `recv` would park the last living thread on
664        // an ack that can never be sent: the process hangs forever, holding
665        // the loader lock, surviving even `TerminateProcess`.
666        //
667        // Losing one final write beats wedging the machine. Owners that care
668        // about that write must drop the writer while the app is still alive
669        // (before `main` returns) — where the ack is immediate and this
670        // timeout never fires.
671        let (ack_tx, ack_rx) = mpsc::sync_channel(0);
672        if pool()
673            .send(PoolMsg::Unregister {
674                id: self.id,
675                ack: ack_tx,
676            })
677            .is_ok()
678            && ack_rx.recv_timeout(DROP_ACK_TIMEOUT) == Err(RecvTimeoutError::Timeout)
679        {
680            eprintln!(
681                "teksilo-settings: timed out waiting for the writer thread to flush {} on drop; \
682                 the last write may be lost. This writer was dropped during process teardown — \
683                 drop it before `main` returns instead.",
684                self.path.display()
685            );
686        }
687    }
688}
689
690impl std::fmt::Debug for DebouncedWriter {
691    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
692        f.debug_struct("DebouncedWriter")
693            .field("path", &self.path)
694            .field("delay", &self.delay)
695            .field("id", &self.id.0)
696            .finish()
697    }
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703    use std::sync::Mutex;
704    use tempfile::tempdir;
705
706    fn read(path: &Path) -> String {
707        fs::read_to_string(path).unwrap()
708    }
709
710    /// A fresh, empty `PoolState`, for tests that drive `flush_writer` /
711    /// `apply_schedule` directly rather than through the process-global
712    /// `pool()` singleton — avoids serializing tests around shared global
713    /// state (per the review's stated preference).
714    fn empty_state() -> PoolState {
715        PoolState {
716            delays: HashMap::new(),
717            paths: HashMap::new(),
718            pending: HashMap::new(),
719            failure_sink: None,
720            landed_sinks: HashMap::new(),
721        }
722    }
723
724    /// A patch that ignores whatever is currently on disk and unconditionally
725    /// writes `s` — the moral equivalent of the old "pre-rendered whole
726    /// document" payload. Useful for tests that only care about which
727    /// payload wins a race, not about merging.
728    fn const_patch(s: impl Into<String>) -> Patch {
729        let s = s.into();
730        Box::new(move |_current: Option<String>| Ok(s.clone()))
731    }
732
733    /// A patch that **appends** `line` to whatever text is already there
734    /// (or starts fresh if the file doesn't exist yet) — a minimal stand-in
735    /// for a real merge patch, used to prove that multiple queued patches
736    /// are *all* applied, in order, rather than the queue being a
737    /// single overwritable slot.
738    fn append_line_patch(line: &'static str) -> Patch {
739        Box::new(move |current: Option<String>| {
740            let mut text = current.unwrap_or_default();
741            text.push_str(line);
742            Ok(text)
743        })
744    }
745
746    #[test]
747    fn flush_now_writes_pending_payload() {
748        let dir = tempdir().unwrap();
749        let path = dir.path().join("out.toml");
750        let writer = DebouncedWriter::new(path.clone(), Duration::from_millis(500));
751
752        writer.schedule(const_patch("alpha = 1\n"));
753        writer.flush_now().unwrap();
754        assert_eq!(read(&path), "alpha = 1\n");
755    }
756
757    #[test]
758    fn schedule_coalesces_rapid_bursts_into_one_write() {
759        let dir = tempdir().unwrap();
760        let path = dir.path().join("burst.toml");
761        let writer = DebouncedWriter::new(path.clone(), Duration::from_millis(50));
762
763        for i in 0..10 {
764            writer.schedule(const_patch(format!("v = {i}\n")));
765        }
766        // Drop -> graceful flush. Each of these patches ignores whatever
767        // came before it (that's the point of `const_patch`), so folding
768        // all 10 in order still nets out to the last one's payload.
769        drop(writer);
770
771        assert_eq!(read(&path), "v = 9\n");
772    }
773
774    /// THE HEADLINE flush.rs TEST. Two patches scheduled inside one
775    /// debounce window must **both** land. The old design's `Schedule`
776    /// overwrote a single pending-payload slot — the second `schedule`
777    /// call would have silently discarded the first payload entirely, a
778    /// real mutation lost, not merely a redundant write coalesced away.
779    #[test]
780    fn two_mutations_in_one_debounce_window_both_land() {
781        let dir = tempdir().unwrap();
782        let path = dir.path().join("both_land.toml");
783        let writer = DebouncedWriter::new(path.clone(), Duration::from_millis(200));
784
785        writer.schedule(append_line_patch("alpha = 1\n"));
786        writer.schedule(append_line_patch("beta = 2\n"));
787        writer.flush_now().unwrap();
788
789        let contents = read(&path);
790        assert!(
791            contents.contains("alpha = 1"),
792            "the first queued patch must not be dropped by the second: {contents:?}"
793        );
794        assert!(
795            contents.contains("beta = 2"),
796            "the second queued patch must also land: {contents:?}"
797        );
798    }
799
800    #[test]
801    fn debounce_window_actually_waits() {
802        // The worker thread is shared with every other writer in the
803        // process, so under `cargo test` parallelism it can be heavily
804        // contended. Generous windows keep the test robust:
805        //   - 300 ms debounce window,
806        //   - up to 3 seconds of polling for the post-window write.
807        // Polling beats a single long sleep because most invocations
808        // finish well within ~400 ms; we only pay the long tail when
809        // the worker is actually backlogged.
810        let dir = tempdir().unwrap();
811        let path = dir.path().join("debounced.toml");
812        let writer = DebouncedWriter::new(path.clone(), Duration::from_millis(300));
813
814        writer.schedule(const_patch("first = 1\n"));
815        // Sleep well under 1/3 of the window — file must not exist.
816        thread::sleep(Duration::from_millis(50));
817        assert!(
818            !path.exists(),
819            "file should not exist before debounce window expires"
820        );
821
822        // Queue a second patch — deadline resets, and since `const_patch`
823        // ignores `current`, the net result is just its own payload.
824        writer.schedule(const_patch("second = 2\n"));
825
826        // Poll up to 3 s for the file to materialize with the second
827        // payload. (We can't read the file mid-write — atomic rename
828        // means it either has the old or new contents, never partial.)
829        let deadline = std::time::Instant::now() + Duration::from_millis(3000);
830        loop {
831            if path.exists() && read(&path) == "second = 2\n" {
832                break;
833            }
834            assert!(
835                std::time::Instant::now() < deadline,
836                "debounced flush did not complete within 3 s",
837            );
838            thread::sleep(Duration::from_millis(25));
839        }
840    }
841
842    #[test]
843    fn drop_flushes_pending_data() {
844        let dir = tempdir().unwrap();
845        let path = dir.path().join("drop.toml");
846
847        {
848            let writer = DebouncedWriter::new(path.clone(), Duration::from_secs(60));
849            writer.schedule(const_patch("survived = true\n"));
850        }
851        assert_eq!(read(&path), "survived = true\n");
852    }
853
854    #[test]
855    fn zero_delay_writes_immediately_after_flush_now() {
856        let dir = tempdir().unwrap();
857        let path = dir.path().join("zero.toml");
858        let writer = DebouncedWriter::new(path.clone(), Duration::ZERO);
859
860        writer.schedule(const_patch("v = 1\n"));
861        writer.flush_now().unwrap();
862        assert_eq!(read(&path), "v = 1\n");
863    }
864
865    #[test]
866    fn write_atomic_creates_parent_dirs() {
867        let dir = tempdir().unwrap();
868        let path = dir.path().join("nested/deeper/out.toml");
869        write_atomic(&path, "ok = true\n").unwrap();
870        assert_eq!(read(&path), "ok = true\n");
871    }
872
873    #[test]
874    fn many_writers_coexist_on_the_shared_thread() {
875        // Stress test: 20 writers, 20 different paths, all served by
876        // the single shared I/O worker. Each gets its own payload and
877        // independent flush; nothing collides.
878        let dir = tempdir().unwrap();
879        let mut writers = Vec::new();
880        for i in 0..20 {
881            let path = dir.path().join(format!("w{i}.toml"));
882            let w = DebouncedWriter::new(path, Duration::ZERO);
883            w.schedule(const_patch(format!("id = {i}\n")));
884            w.flush_now().unwrap();
885            writers.push(w);
886        }
887        for i in 0..20 {
888            let path = dir.path().join(format!("w{i}.toml"));
889            assert_eq!(read(&path), format!("id = {i}\n"));
890        }
891    }
892
893    /// A failing write must **retain** the queued patch(es) and retry —
894    /// never silently drop a mutation just because one attempt hit a
895    /// transient disk error. We force a failure deterministically by
896    /// making the target path an existing *directory*, so the atomic
897    /// rename onto it fails at the OS level; clearing the obstruction
898    /// must let a later retry succeed with the originally-queued payload
899    /// intact.
900    #[test]
901    fn failed_write_retains_the_queue_and_lands_once_unblocked() {
902        let dir = tempdir().unwrap();
903        let path = dir.path().join("obstructed.toml");
904        fs::create_dir_all(&path).unwrap();
905
906        let writer = DebouncedWriter::new(path.clone(), Duration::ZERO);
907        writer.schedule(const_patch("v = 1\n"));
908
909        // Give the worker a moment to hit (and fail) its first attempt.
910        thread::sleep(Duration::from_millis(150));
911        assert!(
912            path.is_dir(),
913            "the write must have failed while the obstruction stood"
914        );
915
916        // Clear the obstruction. The retained patch must land on a later
917        // retry rather than having been dropped after the first failure.
918        fs::remove_dir(&path).unwrap();
919
920        let deadline = std::time::Instant::now() + Duration::from_secs(5);
921        loop {
922            if path.is_file() && fs::read_to_string(&path).ok().as_deref() == Some("v = 1\n") {
923                break;
924            }
925            assert!(
926                std::time::Instant::now() < deadline,
927                "the queued patch was not retried/retained after the obstruction cleared",
928            );
929            thread::sleep(Duration::from_millis(50));
930        }
931    }
932
933    // -- F9: Schedule must reset attempts and never pull the deadline
934    // backward -----------------------------------------------------------
935
936    /// THE HEADLINE F9 test. A zero-delay writer that just failed a write
937    /// has a future `RETRY_BACKOFF` deadline installed by `flush_writer`.
938    /// The OLD `Schedule` arm (`slot.deadline = Instant::now() + delay`)
939    /// unconditionally overwrote that with `now` — pulling the backoff
940    /// back to immediate and spinning the worker at full speed on a
941    /// persistently-failing writer — and never reset `attempts`, so a
942    /// brand-new, unrelated patch inherited the existing failure streak
943    /// and could be discarded on its very first failure. This drives
944    /// `flush_writer` and `apply_schedule` directly against a real
945    /// `PoolState`, without the worker thread, so the assertions are
946    /// exact rather than racing wall-clock polling.
947    #[test]
948    fn schedule_after_a_failure_resets_attempts_and_does_not_rewind_the_backoff_deadline() {
949        let dir = tempdir().unwrap();
950        // An existing directory at the write target makes every write
951        // fail deterministically (the atomic rename onto it fails at the
952        // OS level).
953        let path = dir.path().join("obstructed.toml");
954        fs::create_dir_all(&path).unwrap();
955
956        let id = next_writer_id();
957        let mut state = empty_state();
958        state.delays.insert(id, Duration::ZERO);
959        state.paths.insert(id, path.clone());
960        state.pending.insert(
961            id,
962            Pending {
963                deadline: Instant::now(),
964                patches: vec![const_patch("v = 1\n")],
965                attempts: 0,
966            },
967        );
968
969        // First attempt: fails, `attempts` becomes 1, and a future
970        // RETRY_BACKOFF deadline is installed.
971        let before_backoff = Instant::now();
972        assert!(flush_writer(&mut state, id).is_err());
973        let slot = state
974            .pending
975            .get(&id)
976            .expect("still pending after 1/5 failures");
977        assert_eq!(slot.attempts, 1);
978        assert!(
979            slot.deadline >= before_backoff + RETRY_BACKOFF,
980            "a failed attempt must install a future backoff deadline, got {:?} (now was {:?})",
981            slot.deadline,
982            before_backoff,
983        );
984        let backoff_deadline = slot.deadline;
985
986        // A second, unrelated patch arrives (a genuine new mutation, not
987        // a retry) *before* the backoff elapses — exactly the scenario
988        // `apply_schedule` (the extracted `Schedule` handler) must not
989        // regress.
990        apply_schedule(&mut state, id, const_patch("v = 2\n"));
991
992        let slot = state.pending.get(&id).expect("still pending");
993        assert_eq!(
994            slot.attempts, 0,
995            "new work must reset the failure streak, since the newly queued \
996             patch has never itself failed to write"
997        );
998        assert!(
999            slot.deadline >= backoff_deadline,
1000            "an unrelated Schedule must never rewind an already-armed backoff \
1001             deadline back toward `now`: backoff was {backoff_deadline:?}, \
1002             deadline after Schedule was {:?}",
1003            slot.deadline,
1004        );
1005        assert_eq!(slot.patches.len(), 2, "both patches must still be queued");
1006    }
1007
1008    /// Regression guard: ordinary rapid debounce coalescing (no failures
1009    /// involved) must still work exactly as before — each new `Schedule`
1010    /// during a healthy burst still pushes the deadline forward to `now +
1011    /// delay`, so `.max()` must never *shorten* the debounce window
1012    /// relative to the old unconditional-overwrite behaviour.
1013    #[test]
1014    fn schedule_still_coalesces_a_healthy_burst_into_one_forward_moving_deadline() {
1015        let id = next_writer_id();
1016        let mut state = empty_state();
1017        let delay = Duration::from_millis(50);
1018        state.delays.insert(id, delay);
1019
1020        let mut last_deadline = Instant::now();
1021        for i in 0..5 {
1022            let before = Instant::now();
1023            apply_schedule(&mut state, id, const_patch(format!("v = {i}\n")));
1024            let slot = state.pending.get(&id).unwrap();
1025            assert!(
1026                slot.deadline >= before + delay,
1027                "each Schedule in a healthy burst must push the deadline to \
1028                 at least `now + delay`, got {:?} (now + delay was {:?})",
1029                slot.deadline,
1030                before + delay,
1031            );
1032            assert!(
1033                slot.deadline >= last_deadline,
1034                "the deadline must never move backward across a healthy burst"
1035            );
1036            last_deadline = slot.deadline;
1037            thread::sleep(Duration::from_millis(5));
1038        }
1039        assert_eq!(state.pending.get(&id).unwrap().patches.len(), 5);
1040    }
1041
1042    // -- F10: a contended lock on one writer must not stall the shared
1043    // worker thread's service of every other writer -----------------------
1044
1045    /// THE HEADLINE F10 test. Two real `DebouncedWriter`s share the one
1046    /// process-global worker thread. One target's sidecar lock is held
1047    /// externally (as a peer process holding it would). Before the fix,
1048    /// `apply_and_write`'s blocking `FileLock::acquire_exclusive` would
1049    /// stall the *entire* shared worker thread on that single contended
1050    /// lock, so the unrelated, perfectly healthy writer's flush would
1051    /// never land either — this asserts it lands promptly regardless.
1052    #[test]
1053    fn contended_lock_on_one_writer_does_not_stall_others_on_the_shared_thread() {
1054        let dir = tempdir().unwrap();
1055        let locked_path = dir.path().join("locked.toml");
1056        let healthy_path = dir.path().join("healthy.toml");
1057
1058        // Hold the "locked" writer's sidecar lock externally, exactly as
1059        // a peer process would.
1060        let external_lock = crate::lock::FileLock::acquire_exclusive(&locked_path).unwrap();
1061
1062        let locked_writer = DebouncedWriter::new(locked_path.clone(), Duration::ZERO);
1063        let healthy_writer = DebouncedWriter::new(healthy_path.clone(), Duration::ZERO);
1064
1065        locked_writer.schedule(const_patch("v = locked\n"));
1066        healthy_writer.schedule(const_patch("v = healthy\n"));
1067
1068        // The healthy writer must land well within a couple of seconds —
1069        // it shares the worker thread with the contended writer, but must
1070        // not be stuck behind it.
1071        let deadline = std::time::Instant::now() + Duration::from_secs(2);
1072        loop {
1073            if healthy_path.exists() && read(&healthy_path) == "v = healthy\n" {
1074                break;
1075            }
1076            assert!(
1077                std::time::Instant::now() < deadline,
1078                "the healthy writer's write did not land promptly — the shared \
1079                 worker thread appears stalled behind the other writer's \
1080                 contended lock",
1081            );
1082            thread::sleep(Duration::from_millis(20));
1083        }
1084
1085        // And the locked writer correctly has NOT succeeded yet — it is
1086        // still contended, not silently skipped.
1087        assert!(
1088            !locked_path.exists(),
1089            "the locked writer should not have been able to write while its \
1090             lock is still externally held"
1091        );
1092
1093        drop(external_lock);
1094    }
1095
1096    // -- F3: a permanently-discarded write must reach the failure sink ----
1097
1098    /// THE HEADLINE F3 test. Drives `flush_writer` directly (bypassing the
1099    /// process-global `pool()`/`set_write_failure_sink` indirection, since
1100    /// that is process-wide singleton state best kept out of parallel
1101    /// tests) against a target that can never be written (an existing
1102    /// directory), asserting the registered sink fires exactly once, at
1103    /// the give-up point, with the correct attempt count and dropped-patch
1104    /// count. Before F3 this information never left the worker thread at
1105    /// all — only an `eprintln!` recorded it.
1106    #[test]
1107    fn giving_up_after_max_attempts_reports_through_the_failure_sink() {
1108        let dir = tempdir().unwrap();
1109        let path = dir.path().join("obstructed_sink.toml");
1110        fs::create_dir_all(&path).unwrap();
1111
1112        let id = next_writer_id();
1113        let mut state = empty_state();
1114        state.delays.insert(id, Duration::ZERO);
1115        state.paths.insert(id, path.clone());
1116        state.pending.insert(
1117            id,
1118            Pending {
1119                deadline: Instant::now(),
1120                patches: vec![const_patch("v = 1\n")],
1121                attempts: 0,
1122            },
1123        );
1124
1125        // (path, attempts, dropped_patches, message) — one call to the sink.
1126        type FailureCall = (PathBuf, u32, usize, String);
1127        let calls: Arc<Mutex<Vec<FailureCall>>> = Arc::new(Mutex::new(Vec::new()));
1128        let calls_for_sink = calls.clone();
1129        state.failure_sink = Some(Arc::new(move |path, attempts, dropped, message| {
1130            calls_for_sink
1131                .lock()
1132                .unwrap()
1133                .push((path, attempts, dropped, message));
1134        }));
1135
1136        for _ in 0..MAX_WRITE_ATTEMPTS {
1137            let _ = flush_writer(&mut state, id);
1138        }
1139
1140        let recorded = calls.lock().unwrap();
1141        assert_eq!(
1142            recorded.len(),
1143            1,
1144            "the sink must fire exactly once, precisely at the give-up point: {recorded:?}"
1145        );
1146        let (sunk_path, attempts, dropped, message) = &recorded[0];
1147        assert_eq!(sunk_path, &path);
1148        assert_eq!(*attempts, MAX_WRITE_ATTEMPTS);
1149        assert_eq!(*dropped, 1);
1150        assert!(!message.is_empty());
1151        assert!(
1152            !state.pending.contains_key(&id),
1153            "the queue must be gone once the sink has been told about the discard"
1154        );
1155    }
1156
1157    /// A `Schedule`d writer that never fails must never invoke the
1158    /// failure sink at all — it exists only for the give-up path.
1159    #[test]
1160    fn a_healthy_writer_never_invokes_the_failure_sink() {
1161        let dir = tempdir().unwrap();
1162        let path = dir.path().join("healthy_no_sink.toml");
1163        let writer = DebouncedWriter::new(path.clone(), Duration::ZERO);
1164
1165        let fired = Arc::new(Mutex::new(false));
1166        let fired_for_sink = fired.clone();
1167        set_write_failure_sink(Arc::new(move |_, _, _, _| {
1168            *fired_for_sink.lock().unwrap() = true;
1169        }));
1170
1171        writer.schedule(const_patch("v = 1\n"));
1172        writer.flush_now().unwrap();
1173
1174        assert!(
1175            !*fired.lock().unwrap(),
1176            "a successful write must never invoke the failure sink"
1177        );
1178
1179        // Reset the process-global sink so later tests in this binary
1180        // that rely on `set_write_failure_sink`'s default (unset) state
1181        // aren't affected by this test's registration. `pool()`/the sink
1182        // slot are process-global, so the last writer wins; explicitly
1183        // installing a no-op keeps this test's side effect from leaking
1184        // into whichever test happens to run after it.
1185        set_write_failure_sink(Arc::new(|_, _, _, _| {}));
1186    }
1187
1188    // -- F11: WriteLandedSink fires with the real post-write stamp --------
1189
1190    /// THE HEADLINE F11 test. Registers a `WriteLandedSink` on a real
1191    /// `DebouncedWriter`, schedules a patch, forces it to land via
1192    /// `flush_now`, and asserts the sink received a stamp equal to one
1193    /// taken independently (via `crate::file::disk_stamp`) right after —
1194    /// proving the sink's value is the *real*, authoritative post-write
1195    /// stamp, not a guess.
1196    #[test]
1197    fn landed_sink_fires_with_the_real_post_write_stamp() {
1198        let dir = tempdir().unwrap();
1199        let path = dir.path().join("landed.toml");
1200        let writer = DebouncedWriter::new(path.clone(), Duration::ZERO);
1201
1202        let received: Arc<Mutex<Option<LandedStamp>>> = Arc::new(Mutex::new(None));
1203        let received_for_sink = received.clone();
1204        writer.set_landed_sink(Arc::new(move |stamp| {
1205            *received_for_sink.lock().unwrap() = Some(stamp);
1206        }));
1207
1208        writer.schedule(const_patch("v = 1\n"));
1209        writer.flush_now().unwrap();
1210
1211        let expected = crate::file::disk_stamp(&path);
1212        let got = received
1213            .lock()
1214            .unwrap()
1215            .expect("the landed sink must have fired after a successful flush");
1216        assert_eq!(
1217            got, expected,
1218            "the sink's stamp must match a stamp taken independently right \
1219             after the write landed"
1220        );
1221        // Sanity: the stamp is not the "file doesn't exist" placeholder —
1222        // the write really happened.
1223        assert!(expected.0.is_some() || expected.1.is_some());
1224    }
1225
1226    /// A writer with no registered sink must behave exactly as before —
1227    /// no panic, no special-casing.
1228    #[test]
1229    fn writer_without_a_landed_sink_flushes_normally() {
1230        let dir = tempdir().unwrap();
1231        let path = dir.path().join("no_landed_sink.toml");
1232        let writer = DebouncedWriter::new(path.clone(), Duration::ZERO);
1233
1234        writer.schedule(const_patch("v = 1\n"));
1235        writer.flush_now().unwrap();
1236
1237        assert_eq!(read(&path), "v = 1\n");
1238    }
1239}