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}