FlushError
Debounced, cross-process-safe atomic file writer.
DebouncedWriter accepts Patches — replayable mutations — via
schedule, batches rapid bursts inside a
debounce window, and then applies the whole batch to the document read from
disk under an exclusive advisory lock, writing the result atomically
(write-temp + fsync + rename).
Why a patch and not a rendered string
This writer used to carry a pre-rendered String: the caller serialised its
entire in-memory document and the worker blindly wrote those bytes. That is
last-write-wins by construction — the worker had nothing to merge with,
so any concurrent change a peer process made to another part of the file was
silently destroyed. (A lock alone does not fix this: it serialises the two
writes but does nothing about the stale snapshot one of them was rendered
from.)
A Patch instead says "given the file's current text, produce its new text",
so the merge happens against reality:
lock -> read current -> apply queued patches -> write atomically -> unlock
Patches are built inside this crate from owned snapshots of each type's
pending mutations (a Vec<(key, value)>, a list of ops), so they capture no
Rc and can cross to the worker thread. Callers never see one: they keep
writing signal.set(v), mru.add(e), file.mutate(|s| ..).
Single shared I/O thread
All DebouncedWriters in a process share one background I/O thread
(lazily started on first use). Each writer registers under a unique
WriterId. The shared thread:
- keeps a per-id
(deadline, patch queue), - blocks on the next-due deadline (or waits for a message if nothing is pending),
- coalesces rapid
Schedulebursts by appending to the queue, resetting the failure streak (a just-queued patch has never itself failed to write) and moving the deadline forward, never backward — so debouncing collapses writes, never mutations, and a liveRETRY_BACKOFFdeadline installed after a failed attempt can't be clobbered back to "now" by an unrelated new patch on a zero-delay writer. (The old design could overwrite the pending payload precisely because each payload was a complete, self-superseding rendering.)
A failed write retains the queue and retries with backoff, up to
MAX_WRITE_ATTEMPTS — the patches replay cleanly against whatever is on
disk then, which is the correct merge rather than a stale overwrite. Once
the cap is reached (or a writer is dropped mid-failure at process
teardown), the queue is discarded for good and reported through the
process-wide WriteFailureSink (registered via
set_write_failure_sink) in addition to the existing log — the write
side's analogue of crate::reload::Reloadable's read-side contract.
Conversely, every writer may also register a WriteLandedSink (via
DebouncedWriter::set_landed_sink) to learn the real on-disk stamp
the instant its queued patches land successfully — useful to a caller
whose own apply()-style API schedules a write and returns before it's
actually on disk.
The locked read-merge-write (apply_and_write) acquires its advisory
lock non-blocking: because every writer in the process shares this
one thread, a lock held by a peer process must never stall it — a
contended lock is just another transient FlushError::Io, retried
with the same backoff as any other write failure.
Application logic stays single-threaded — SettingsStore and friends never
block on I/O. Drop sends an Unregister that synchronously flushes the
queue before returning, so end-of-process state is never lost (unless the
flush itself is still failing, in which case the discard is reported
through WriteFailureSink exactly as above).
Why one thread, not one-per-writer
An app that opens the K/V store + recents + window state already has 3 writers; a richer app might have 5–10, each idle ~99% of the time. One shared worker is leaner and has identical semantics from the caller's point of view.
Builder methods at a glance
flush_now, set_landed_sink, path, delay
API reference
📖 Full rustdoc API for this module
pub enum FlushError
Errors surfaced by DebouncedWriter::flush_now.
#![allow(unused)] fn main() { pub enum FlushError { /* variants */ } }
Variants
Disconnected— The shared I/O worker thread has panicked or shut down; writes can no longer be delivered.Io— The atomic write (temp-file + rename) failed at the OS level.Merge— APatchcould not be applied to the document currently on disk — e.g. a peer wrote something this process cannot parse or migrate.
pub type WriteFailureSink
Invoked (off the caller's thread — on the shared worker thread) when a
DebouncedWriter's queued patches are permanently discarded: either
flush_writer gave up after MAX_WRITE_ATTEMPTS, or the writer was
dropped (Unregister) while its final flush was still failing. This is
the write-side analogue of crate::reload::Reloadable's read-side
contract — the previous behaviour was a bare eprintln! that never left
the worker thread, so a permanently unwritable settings file (read-only
mount, revoked permissions, disk full) silently ate every change for the
rest of the session with zero signal to the application. Registered
process-wide via set_write_failure_sink.
#![allow(unused)] fn main() { pub type WriteFailureSink = Arc<dyn Fn(PathBuf, u32, usize, String) + Send + Sync + 'static>; }
pub fn set_write_failure_sink(...)
Register a process-wide sink invoked whenever any DebouncedWriter
permanently discards a queued write (see WriteFailureSink). There is
only one slot: a later call replaces an earlier one. teksilo-app uses
this to forward the failure to the UI thread as a typed AppEvent.
#![allow(unused)] fn main() { pub fn set_write_failure_sink(sink: WriteFailureSink); }
pub type LandedStamp
The (mtime, len) stamp disk_stamp computes for a settings file —
named so every Arc<Mutex<...>> wrapping it (here and in
WindowStateService) reads as one term instead of clippy's
type_complexity-tripping nested-generics spelling.
#![allow(unused)] fn main() { pub type LandedStamp = (Option<SystemTime>, Option<u64>); }
pub type WriteLandedSink
Invoked on the shared worker thread the instant a DebouncedWriter's
queued patches land successfully, with the fresh on-disk (mtime, len)
stamp (one extra fs::metadata, computed once, right after the write —
negligible cost). The write-side analogue of WriteFailureSink. Send + Sync because it runs off the caller's thread — a consumer that needs to
update !Send state (an Rc<Cell<_>>) must copy the value out on its
own thread the next time it looks (see
WindowStateService::reload_from_disk).
#![allow(unused)] fn main() { pub type WriteLandedSink = Arc<dyn Fn(LandedStamp) + Send + Sync + 'static>; }
pub struct DebouncedWriter
Atomic, debounced single-file writer.
All writers in a process share one background I/O thread (see
module docs). Each writer is identified by an opaque WriterId;
dropping a writer synchronously flushes its pending payload before
returning.
#![allow(unused)] fn main() { pub struct DebouncedWriter { /* fields */ } }
Methods
pub fn new(path: PathBuf, delay: Duration) -> Self
Create a writer that will atomically write to path, coalescing
rapid schedule bursts inside delay.
delay = Duration::ZERO makes every schedule flush on the
worker's very next iteration — useful for tests.
pub fn flush_now(&self) -> Result<(), FlushError>
Force any queued patches to disk synchronously. Returns Ok(()) if
there was nothing queued.
pub fn set_landed_sink(&self, sink: WriteLandedSink)
Register a sink for this writer's successful-flush stamp (opt-in; a writer with none behaves exactly as today). May be called any time after construction — including after the writer has already flushed once, since the sink is only ever consulted on a future successful flush.
This is how a caller learns the real on-disk stamp resulting from
its own debounced write, without guessing: apply() schedules a
patch and returns before it lands, so only the worker thread — right
after the write actually succeeds — knows the resulting (mtime, len). See WindowStateService::reload_from_disk for the consumer
side (F11).
pub fn path(&self) -> &Path
The destination path this writer flushes to.
pub fn delay(&self) -> Duration
The debounce window configured at construction; Duration::ZERO
means every scheduled payload is written on the worker's next
iteration (useful in tests).