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    /// reset to `now + delay` (the debounce window restarts on activity). At
593    /// the deadline the whole queue is applied, in order, to the document read
594    /// from disk **under an exclusive lock**, and the result written atomically.
595    ///
596    /// Debouncing therefore coalesces *writes*, never *mutations*: ten `set`s
597    /// inside one window still produce one write, but all ten are applied — and
598    /// applied on top of whatever a peer process wrote in the meantime.
599    pub(crate) fn schedule(&self, patch: Patch) {
600        let _ = pool().send(PoolMsg::Schedule { id: self.id, patch });
601    }
602
603    /// Force any queued patches to disk synchronously. Returns `Ok(())` if
604    /// there was nothing queued.
605    pub fn flush_now(&self) -> Result<(), FlushError> {
606        let (ack_tx, ack_rx) = mpsc::sync_channel(0);
607        pool()
608            .send(PoolMsg::FlushNow {
609                id: self.id,
610                ack: ack_tx,
611            })
612            .map_err(|_| FlushError::Disconnected)?;
613        match ack_rx.recv() {
614            Ok(result) => result,
615            Err(_) => Err(FlushError::Disconnected),
616        }
617    }
618
619    /// Register a sink for this writer's successful-flush stamp (opt-in; a
620    /// writer with none behaves exactly as today). May be called any time
621    /// after construction — including after the writer has already flushed
622    /// once, since the sink is only ever consulted on a *future* successful
623    /// flush.
624    ///
625    /// This is how a caller learns the *real* on-disk stamp resulting from
626    /// its own debounced write, without guessing: `apply()` schedules a
627    /// patch and returns before it lands, so only the worker thread — right
628    /// after the write actually succeeds — knows the resulting `(mtime,
629    /// len)`. See `WindowStateService::reload_from_disk` for the consumer
630    /// side (F11).
631    pub fn set_landed_sink(&self, sink: WriteLandedSink) {
632        let _ = pool().send(PoolMsg::SetLandedSink { id: self.id, sink });
633    }
634
635    /// The destination path this writer flushes to.
636    pub fn path(&self) -> &Path {
637        &self.path
638    }
639
640    /// The debounce window configured at construction; `Duration::ZERO`
641    /// means every scheduled payload is written on the worker's next
642    /// iteration (useful in tests).
643    pub fn delay(&self) -> Duration {
644        self.delay
645    }
646}
647
648impl Drop for DebouncedWriter {
649    fn drop(&mut self) {
650        // Send an Unregister with a sync ack so we don't return from
651        // Drop until the pending payload (if any) has been flushed.
652        // Without this, exit-time data would race with process
653        // teardown.
654        //
655        // The wait is **bounded**, and that bound is load-bearing. A writer
656        // owned by a `thread_local!` (or any other slot whose destructor the
657        // runtime defers) is dropped *after* `main` returns: on Windows the
658        // main thread's TLS destructors run inside `DLL_PROCESS_DETACH`,
659        // which `ExitProcess` reaches only after it has already killed every
660        // other thread — the worker among them. `send` still succeeds there,
661        // because the `Sender` lives in a `OnceLock` and outlives the thread
662        // it fed, so an unbounded `recv` would park the last living thread on
663        // an ack that can never be sent: the process hangs forever, holding
664        // the loader lock, surviving even `TerminateProcess`.
665        //
666        // Losing one final write beats wedging the machine. Owners that care
667        // about that write must drop the writer while the app is still alive
668        // (before `main` returns) — where the ack is immediate and this
669        // timeout never fires.
670        let (ack_tx, ack_rx) = mpsc::sync_channel(0);
671        if pool()
672            .send(PoolMsg::Unregister {
673                id: self.id,
674                ack: ack_tx,
675            })
676            .is_ok()
677            && ack_rx.recv_timeout(DROP_ACK_TIMEOUT) == Err(RecvTimeoutError::Timeout)
678        {
679            eprintln!(
680                "teksilo-settings: timed out waiting for the writer thread to flush {} on drop; \
681                 the last write may be lost. This writer was dropped during process teardown — \
682                 drop it before `main` returns instead.",
683                self.path.display()
684            );
685        }
686    }
687}
688
689impl std::fmt::Debug for DebouncedWriter {
690    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
691        f.debug_struct("DebouncedWriter")
692            .field("path", &self.path)
693            .field("delay", &self.delay)
694            .field("id", &self.id.0)
695            .finish()
696    }
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702    use std::sync::Mutex;
703    use tempfile::tempdir;
704
705    fn read(path: &Path) -> String {
706        fs::read_to_string(path).unwrap()
707    }
708
709    /// A fresh, empty `PoolState`, for tests that drive `flush_writer` /
710    /// `apply_schedule` directly rather than through the process-global
711    /// `pool()` singleton — avoids serializing tests around shared global
712    /// state (per the review's stated preference).
713    fn empty_state() -> PoolState {
714        PoolState {
715            delays: HashMap::new(),
716            paths: HashMap::new(),
717            pending: HashMap::new(),
718            failure_sink: None,
719            landed_sinks: HashMap::new(),
720        }
721    }
722
723    /// A patch that ignores whatever is currently on disk and unconditionally
724    /// writes `s` — the moral equivalent of the old "pre-rendered whole
725    /// document" payload. Useful for tests that only care about which
726    /// payload wins a race, not about merging.
727    fn const_patch(s: impl Into<String>) -> Patch {
728        let s = s.into();
729        Box::new(move |_current: Option<String>| Ok(s.clone()))
730    }
731
732    /// A patch that **appends** `line` to whatever text is already there
733    /// (or starts fresh if the file doesn't exist yet) — a minimal stand-in
734    /// for a real merge patch, used to prove that multiple queued patches
735    /// are *all* applied, in order, rather than the queue being a
736    /// single overwritable slot.
737    fn append_line_patch(line: &'static str) -> Patch {
738        Box::new(move |current: Option<String>| {
739            let mut text = current.unwrap_or_default();
740            text.push_str(line);
741            Ok(text)
742        })
743    }
744
745    #[test]
746    fn flush_now_writes_pending_payload() {
747        let dir = tempdir().unwrap();
748        let path = dir.path().join("out.toml");
749        let writer = DebouncedWriter::new(path.clone(), Duration::from_millis(500));
750
751        writer.schedule(const_patch("alpha = 1\n"));
752        writer.flush_now().unwrap();
753        assert_eq!(read(&path), "alpha = 1\n");
754    }
755
756    #[test]
757    fn schedule_coalesces_rapid_bursts_into_one_write() {
758        let dir = tempdir().unwrap();
759        let path = dir.path().join("burst.toml");
760        let writer = DebouncedWriter::new(path.clone(), Duration::from_millis(50));
761
762        for i in 0..10 {
763            writer.schedule(const_patch(format!("v = {i}\n")));
764        }
765        // Drop -> graceful flush. Each of these patches ignores whatever
766        // came before it (that's the point of `const_patch`), so folding
767        // all 10 in order still nets out to the last one's payload.
768        drop(writer);
769
770        assert_eq!(read(&path), "v = 9\n");
771    }
772
773    /// THE HEADLINE flush.rs TEST. Two patches scheduled inside one
774    /// debounce window must **both** land. The old design's `Schedule`
775    /// overwrote a single pending-payload slot — the second `schedule`
776    /// call would have silently discarded the first payload entirely, a
777    /// real mutation lost, not merely a redundant write coalesced away.
778    #[test]
779    fn two_mutations_in_one_debounce_window_both_land() {
780        let dir = tempdir().unwrap();
781        let path = dir.path().join("both_land.toml");
782        let writer = DebouncedWriter::new(path.clone(), Duration::from_millis(200));
783
784        writer.schedule(append_line_patch("alpha = 1\n"));
785        writer.schedule(append_line_patch("beta = 2\n"));
786        writer.flush_now().unwrap();
787
788        let contents = read(&path);
789        assert!(
790            contents.contains("alpha = 1"),
791            "the first queued patch must not be dropped by the second: {contents:?}"
792        );
793        assert!(
794            contents.contains("beta = 2"),
795            "the second queued patch must also land: {contents:?}"
796        );
797    }
798
799    #[test]
800    fn debounce_window_actually_waits() {
801        // The worker thread is shared with every other writer in the
802        // process, so under `cargo test` parallelism it can be heavily
803        // contended. Generous windows keep the test robust:
804        //   - 300 ms debounce window,
805        //   - up to 3 seconds of polling for the post-window write.
806        // Polling beats a single long sleep because most invocations
807        // finish well within ~400 ms; we only pay the long tail when
808        // the worker is actually backlogged.
809        let dir = tempdir().unwrap();
810        let path = dir.path().join("debounced.toml");
811        let writer = DebouncedWriter::new(path.clone(), Duration::from_millis(300));
812
813        writer.schedule(const_patch("first = 1\n"));
814        // Sleep well under 1/3 of the window — file must not exist.
815        thread::sleep(Duration::from_millis(50));
816        assert!(
817            !path.exists(),
818            "file should not exist before debounce window expires"
819        );
820
821        // Queue a second patch — deadline resets, and since `const_patch`
822        // ignores `current`, the net result is just its own payload.
823        writer.schedule(const_patch("second = 2\n"));
824
825        // Poll up to 3 s for the file to materialize with the second
826        // payload. (We can't read the file mid-write — atomic rename
827        // means it either has the old or new contents, never partial.)
828        let deadline = std::time::Instant::now() + Duration::from_millis(3000);
829        loop {
830            if path.exists() && read(&path) == "second = 2\n" {
831                break;
832            }
833            assert!(
834                std::time::Instant::now() < deadline,
835                "debounced flush did not complete within 3 s",
836            );
837            thread::sleep(Duration::from_millis(25));
838        }
839    }
840
841    #[test]
842    fn drop_flushes_pending_data() {
843        let dir = tempdir().unwrap();
844        let path = dir.path().join("drop.toml");
845
846        {
847            let writer = DebouncedWriter::new(path.clone(), Duration::from_secs(60));
848            writer.schedule(const_patch("survived = true\n"));
849        }
850        assert_eq!(read(&path), "survived = true\n");
851    }
852
853    #[test]
854    fn zero_delay_writes_immediately_after_flush_now() {
855        let dir = tempdir().unwrap();
856        let path = dir.path().join("zero.toml");
857        let writer = DebouncedWriter::new(path.clone(), Duration::ZERO);
858
859        writer.schedule(const_patch("v = 1\n"));
860        writer.flush_now().unwrap();
861        assert_eq!(read(&path), "v = 1\n");
862    }
863
864    #[test]
865    fn write_atomic_creates_parent_dirs() {
866        let dir = tempdir().unwrap();
867        let path = dir.path().join("nested/deeper/out.toml");
868        write_atomic(&path, "ok = true\n").unwrap();
869        assert_eq!(read(&path), "ok = true\n");
870    }
871
872    #[test]
873    fn many_writers_coexist_on_the_shared_thread() {
874        // Stress test: 20 writers, 20 different paths, all served by
875        // the single shared I/O worker. Each gets its own payload and
876        // independent flush; nothing collides.
877        let dir = tempdir().unwrap();
878        let mut writers = Vec::new();
879        for i in 0..20 {
880            let path = dir.path().join(format!("w{i}.toml"));
881            let w = DebouncedWriter::new(path, Duration::ZERO);
882            w.schedule(const_patch(format!("id = {i}\n")));
883            w.flush_now().unwrap();
884            writers.push(w);
885        }
886        for i in 0..20 {
887            let path = dir.path().join(format!("w{i}.toml"));
888            assert_eq!(read(&path), format!("id = {i}\n"));
889        }
890    }
891
892    /// A failing write must **retain** the queued patch(es) and retry —
893    /// never silently drop a mutation just because one attempt hit a
894    /// transient disk error. We force a failure deterministically by
895    /// making the target path an existing *directory*, so the atomic
896    /// rename onto it fails at the OS level; clearing the obstruction
897    /// must let a later retry succeed with the originally-queued payload
898    /// intact.
899    #[test]
900    fn failed_write_retains_the_queue_and_lands_once_unblocked() {
901        let dir = tempdir().unwrap();
902        let path = dir.path().join("obstructed.toml");
903        fs::create_dir_all(&path).unwrap();
904
905        let writer = DebouncedWriter::new(path.clone(), Duration::ZERO);
906        writer.schedule(const_patch("v = 1\n"));
907
908        // Give the worker a moment to hit (and fail) its first attempt.
909        thread::sleep(Duration::from_millis(150));
910        assert!(
911            path.is_dir(),
912            "the write must have failed while the obstruction stood"
913        );
914
915        // Clear the obstruction. The retained patch must land on a later
916        // retry rather than having been dropped after the first failure.
917        fs::remove_dir(&path).unwrap();
918
919        let deadline = std::time::Instant::now() + Duration::from_secs(5);
920        loop {
921            if path.is_file() && fs::read_to_string(&path).ok().as_deref() == Some("v = 1\n") {
922                break;
923            }
924            assert!(
925                std::time::Instant::now() < deadline,
926                "the queued patch was not retried/retained after the obstruction cleared",
927            );
928            thread::sleep(Duration::from_millis(50));
929        }
930    }
931
932    // -- F9: Schedule must reset attempts and never pull the deadline
933    // backward -----------------------------------------------------------
934
935    /// THE HEADLINE F9 test. A zero-delay writer that just failed a write
936    /// has a future `RETRY_BACKOFF` deadline installed by `flush_writer`.
937    /// The OLD `Schedule` arm (`slot.deadline = Instant::now() + delay`)
938    /// unconditionally overwrote that with `now` — pulling the backoff
939    /// back to immediate and spinning the worker at full speed on a
940    /// persistently-failing writer — and never reset `attempts`, so a
941    /// brand-new, unrelated patch inherited the existing failure streak
942    /// and could be discarded on its very first failure. This drives
943    /// `flush_writer` and `apply_schedule` directly against a real
944    /// `PoolState`, without the worker thread, so the assertions are
945    /// exact rather than racing wall-clock polling.
946    #[test]
947    fn schedule_after_a_failure_resets_attempts_and_does_not_rewind_the_backoff_deadline() {
948        let dir = tempdir().unwrap();
949        // An existing directory at the write target makes every write
950        // fail deterministically (the atomic rename onto it fails at the
951        // OS level).
952        let path = dir.path().join("obstructed.toml");
953        fs::create_dir_all(&path).unwrap();
954
955        let id = next_writer_id();
956        let mut state = empty_state();
957        state.delays.insert(id, Duration::ZERO);
958        state.paths.insert(id, path.clone());
959        state.pending.insert(
960            id,
961            Pending {
962                deadline: Instant::now(),
963                patches: vec![const_patch("v = 1\n")],
964                attempts: 0,
965            },
966        );
967
968        // First attempt: fails, `attempts` becomes 1, and a future
969        // RETRY_BACKOFF deadline is installed.
970        let before_backoff = Instant::now();
971        assert!(flush_writer(&mut state, id).is_err());
972        let slot = state
973            .pending
974            .get(&id)
975            .expect("still pending after 1/5 failures");
976        assert_eq!(slot.attempts, 1);
977        assert!(
978            slot.deadline >= before_backoff + RETRY_BACKOFF,
979            "a failed attempt must install a future backoff deadline, got {:?} (now was {:?})",
980            slot.deadline,
981            before_backoff,
982        );
983        let backoff_deadline = slot.deadline;
984
985        // A second, unrelated patch arrives (a genuine new mutation, not
986        // a retry) *before* the backoff elapses — exactly the scenario
987        // `apply_schedule` (the extracted `Schedule` handler) must not
988        // regress.
989        apply_schedule(&mut state, id, const_patch("v = 2\n"));
990
991        let slot = state.pending.get(&id).expect("still pending");
992        assert_eq!(
993            slot.attempts, 0,
994            "new work must reset the failure streak, since the newly queued \
995             patch has never itself failed to write"
996        );
997        assert!(
998            slot.deadline >= backoff_deadline,
999            "an unrelated Schedule must never rewind an already-armed backoff \
1000             deadline back toward `now`: backoff was {backoff_deadline:?}, \
1001             deadline after Schedule was {:?}",
1002            slot.deadline,
1003        );
1004        assert_eq!(slot.patches.len(), 2, "both patches must still be queued");
1005    }
1006
1007    /// Regression guard: ordinary rapid debounce coalescing (no failures
1008    /// involved) must still work exactly as before — each new `Schedule`
1009    /// during a healthy burst still pushes the deadline forward to `now +
1010    /// delay`, so `.max()` must never *shorten* the debounce window
1011    /// relative to the old unconditional-overwrite behaviour.
1012    #[test]
1013    fn schedule_still_coalesces_a_healthy_burst_into_one_forward_moving_deadline() {
1014        let id = next_writer_id();
1015        let mut state = empty_state();
1016        let delay = Duration::from_millis(50);
1017        state.delays.insert(id, delay);
1018
1019        let mut last_deadline = Instant::now();
1020        for i in 0..5 {
1021            let before = Instant::now();
1022            apply_schedule(&mut state, id, const_patch(format!("v = {i}\n")));
1023            let slot = state.pending.get(&id).unwrap();
1024            assert!(
1025                slot.deadline >= before + delay,
1026                "each Schedule in a healthy burst must push the deadline to \
1027                 at least `now + delay`, got {:?} (now + delay was {:?})",
1028                slot.deadline,
1029                before + delay,
1030            );
1031            assert!(
1032                slot.deadline >= last_deadline,
1033                "the deadline must never move backward across a healthy burst"
1034            );
1035            last_deadline = slot.deadline;
1036            thread::sleep(Duration::from_millis(5));
1037        }
1038        assert_eq!(state.pending.get(&id).unwrap().patches.len(), 5);
1039    }
1040
1041    // -- F10: a contended lock on one writer must not stall the shared
1042    // worker thread's service of every other writer -----------------------
1043
1044    /// THE HEADLINE F10 test. Two real `DebouncedWriter`s share the one
1045    /// process-global worker thread. One target's sidecar lock is held
1046    /// externally (as a peer process holding it would). Before the fix,
1047    /// `apply_and_write`'s blocking `FileLock::acquire_exclusive` would
1048    /// stall the *entire* shared worker thread on that single contended
1049    /// lock, so the unrelated, perfectly healthy writer's flush would
1050    /// never land either — this asserts it lands promptly regardless.
1051    #[test]
1052    fn contended_lock_on_one_writer_does_not_stall_others_on_the_shared_thread() {
1053        let dir = tempdir().unwrap();
1054        let locked_path = dir.path().join("locked.toml");
1055        let healthy_path = dir.path().join("healthy.toml");
1056
1057        // Hold the "locked" writer's sidecar lock externally, exactly as
1058        // a peer process would.
1059        let external_lock = crate::lock::FileLock::acquire_exclusive(&locked_path).unwrap();
1060
1061        let locked_writer = DebouncedWriter::new(locked_path.clone(), Duration::ZERO);
1062        let healthy_writer = DebouncedWriter::new(healthy_path.clone(), Duration::ZERO);
1063
1064        locked_writer.schedule(const_patch("v = locked\n"));
1065        healthy_writer.schedule(const_patch("v = healthy\n"));
1066
1067        // The healthy writer must land well within a couple of seconds —
1068        // it shares the worker thread with the contended writer, but must
1069        // not be stuck behind it.
1070        let deadline = std::time::Instant::now() + Duration::from_secs(2);
1071        loop {
1072            if healthy_path.exists() && read(&healthy_path) == "v = healthy\n" {
1073                break;
1074            }
1075            assert!(
1076                std::time::Instant::now() < deadline,
1077                "the healthy writer's write did not land promptly — the shared \
1078                 worker thread appears stalled behind the other writer's \
1079                 contended lock",
1080            );
1081            thread::sleep(Duration::from_millis(20));
1082        }
1083
1084        // And the locked writer correctly has NOT succeeded yet — it is
1085        // still contended, not silently skipped.
1086        assert!(
1087            !locked_path.exists(),
1088            "the locked writer should not have been able to write while its \
1089             lock is still externally held"
1090        );
1091
1092        drop(external_lock);
1093    }
1094
1095    // -- F3: a permanently-discarded write must reach the failure sink ----
1096
1097    /// THE HEADLINE F3 test. Drives `flush_writer` directly (bypassing the
1098    /// process-global `pool()`/`set_write_failure_sink` indirection, since
1099    /// that is process-wide singleton state best kept out of parallel
1100    /// tests) against a target that can never be written (an existing
1101    /// directory), asserting the registered sink fires exactly once, at
1102    /// the give-up point, with the correct attempt count and dropped-patch
1103    /// count. Before F3 this information never left the worker thread at
1104    /// all — only an `eprintln!` recorded it.
1105    #[test]
1106    fn giving_up_after_max_attempts_reports_through_the_failure_sink() {
1107        let dir = tempdir().unwrap();
1108        let path = dir.path().join("obstructed_sink.toml");
1109        fs::create_dir_all(&path).unwrap();
1110
1111        let id = next_writer_id();
1112        let mut state = empty_state();
1113        state.delays.insert(id, Duration::ZERO);
1114        state.paths.insert(id, path.clone());
1115        state.pending.insert(
1116            id,
1117            Pending {
1118                deadline: Instant::now(),
1119                patches: vec![const_patch("v = 1\n")],
1120                attempts: 0,
1121            },
1122        );
1123
1124        // (path, attempts, dropped_patches, message) — one call to the sink.
1125        type FailureCall = (PathBuf, u32, usize, String);
1126        let calls: Arc<Mutex<Vec<FailureCall>>> = Arc::new(Mutex::new(Vec::new()));
1127        let calls_for_sink = calls.clone();
1128        state.failure_sink = Some(Arc::new(move |path, attempts, dropped, message| {
1129            calls_for_sink
1130                .lock()
1131                .unwrap()
1132                .push((path, attempts, dropped, message));
1133        }));
1134
1135        for _ in 0..MAX_WRITE_ATTEMPTS {
1136            let _ = flush_writer(&mut state, id);
1137        }
1138
1139        let recorded = calls.lock().unwrap();
1140        assert_eq!(
1141            recorded.len(),
1142            1,
1143            "the sink must fire exactly once, precisely at the give-up point: {recorded:?}"
1144        );
1145        let (sunk_path, attempts, dropped, message) = &recorded[0];
1146        assert_eq!(sunk_path, &path);
1147        assert_eq!(*attempts, MAX_WRITE_ATTEMPTS);
1148        assert_eq!(*dropped, 1);
1149        assert!(!message.is_empty());
1150        assert!(
1151            !state.pending.contains_key(&id),
1152            "the queue must be gone once the sink has been told about the discard"
1153        );
1154    }
1155
1156    /// A `Schedule`d writer that never fails must never invoke the
1157    /// failure sink at all — it exists only for the give-up path.
1158    #[test]
1159    fn a_healthy_writer_never_invokes_the_failure_sink() {
1160        let dir = tempdir().unwrap();
1161        let path = dir.path().join("healthy_no_sink.toml");
1162        let writer = DebouncedWriter::new(path.clone(), Duration::ZERO);
1163
1164        let fired = Arc::new(Mutex::new(false));
1165        let fired_for_sink = fired.clone();
1166        set_write_failure_sink(Arc::new(move |_, _, _, _| {
1167            *fired_for_sink.lock().unwrap() = true;
1168        }));
1169
1170        writer.schedule(const_patch("v = 1\n"));
1171        writer.flush_now().unwrap();
1172
1173        assert!(
1174            !*fired.lock().unwrap(),
1175            "a successful write must never invoke the failure sink"
1176        );
1177
1178        // Reset the process-global sink so later tests in this binary
1179        // that rely on `set_write_failure_sink`'s default (unset) state
1180        // aren't affected by this test's registration. `pool()`/the sink
1181        // slot are process-global, so the last writer wins; explicitly
1182        // installing a no-op keeps this test's side effect from leaking
1183        // into whichever test happens to run after it.
1184        set_write_failure_sink(Arc::new(|_, _, _, _| {}));
1185    }
1186
1187    // -- F11: WriteLandedSink fires with the real post-write stamp --------
1188
1189    /// THE HEADLINE F11 test. Registers a `WriteLandedSink` on a real
1190    /// `DebouncedWriter`, schedules a patch, forces it to land via
1191    /// `flush_now`, and asserts the sink received a stamp equal to one
1192    /// taken independently (via `crate::file::disk_stamp`) right after —
1193    /// proving the sink's value is the *real*, authoritative post-write
1194    /// stamp, not a guess.
1195    #[test]
1196    fn landed_sink_fires_with_the_real_post_write_stamp() {
1197        let dir = tempdir().unwrap();
1198        let path = dir.path().join("landed.toml");
1199        let writer = DebouncedWriter::new(path.clone(), Duration::ZERO);
1200
1201        let received: Arc<Mutex<Option<LandedStamp>>> = Arc::new(Mutex::new(None));
1202        let received_for_sink = received.clone();
1203        writer.set_landed_sink(Arc::new(move |stamp| {
1204            *received_for_sink.lock().unwrap() = Some(stamp);
1205        }));
1206
1207        writer.schedule(const_patch("v = 1\n"));
1208        writer.flush_now().unwrap();
1209
1210        let expected = crate::file::disk_stamp(&path);
1211        let got = received
1212            .lock()
1213            .unwrap()
1214            .expect("the landed sink must have fired after a successful flush");
1215        assert_eq!(
1216            got, expected,
1217            "the sink's stamp must match a stamp taken independently right \
1218             after the write landed"
1219        );
1220        // Sanity: the stamp is not the "file doesn't exist" placeholder —
1221        // the write really happened.
1222        assert!(expected.0.is_some() || expected.1.is_some());
1223    }
1224
1225    /// A writer with no registered sink must behave exactly as before —
1226    /// no panic, no special-casing.
1227    #[test]
1228    fn writer_without_a_landed_sink_flushes_normally() {
1229        let dir = tempdir().unwrap();
1230        let path = dir.path().join("no_landed_sink.toml");
1231        let writer = DebouncedWriter::new(path.clone(), Duration::ZERO);
1232
1233        writer.schedule(const_patch("v = 1\n"));
1234        writer.flush_now().unwrap();
1235
1236        assert_eq!(read(&path), "v = 1\n");
1237    }
1238}