use std::collections::HashMap;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, SyncSender};
use std::sync::{Arc, OnceLock};
use std::thread;
use std::time::{Duration, Instant, SystemTime};
use tempfile::NamedTempFile;
#[derive(Debug, thiserror::Error)]
pub enum FlushError {
#[error("settings I/O thread disconnected")]
Disconnected,
#[error("settings flush failed: {0}")]
Io(#[from] io::Error),
#[error("settings merge failed: {0}")]
Merge(String),
}
pub(crate) type Patch = Box<dyn Fn(Option<String>) -> Result<String, FlushError> + Send + 'static>;
const MAX_WRITE_ATTEMPTS: u32 = 5;
const RETRY_BACKOFF: Duration = Duration::from_millis(250);
const DROP_ACK_TIMEOUT: Duration = Duration::from_secs(5);
pub type WriteFailureSink = Arc<dyn Fn(PathBuf, u32, usize, String) + Send + Sync + 'static>;
pub fn set_write_failure_sink(sink: WriteFailureSink) {
let _ = pool().send(PoolMsg::SetFailureSink(sink));
}
pub type LandedStamp = (Option<SystemTime>, Option<u64>);
pub type WriteLandedSink = Arc<dyn Fn(LandedStamp) + Send + Sync + 'static>;
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
struct WriterId(u64);
fn next_writer_id() -> WriterId {
static COUNTER: AtomicU64 = AtomicU64::new(1);
WriterId(COUNTER.fetch_add(1, Ordering::Relaxed))
}
enum PoolMsg {
Register {
id: WriterId,
path: PathBuf,
delay: Duration,
},
Schedule {
id: WriterId,
patch: Patch,
},
FlushNow {
id: WriterId,
ack: SyncSender<Result<(), FlushError>>,
},
Unregister {
id: WriterId,
ack: SyncSender<()>,
},
SetFailureSink(WriteFailureSink),
SetLandedSink {
id: WriterId,
sink: WriteLandedSink,
},
}
struct Pending {
deadline: Instant,
patches: Vec<Patch>,
attempts: u32,
}
struct PoolState {
delays: HashMap<WriterId, Duration>,
paths: HashMap<WriterId, PathBuf>,
pending: HashMap<WriterId, Pending>,
failure_sink: Option<WriteFailureSink>,
landed_sinks: HashMap<WriterId, WriteLandedSink>,
}
fn pool() -> &'static Sender<PoolMsg> {
static POOL: OnceLock<Sender<PoolMsg>> = OnceLock::new();
POOL.get_or_init(|| {
let (tx, rx) = mpsc::channel();
thread::Builder::new()
.name("teksilo-settings-writer".into())
.spawn(move || worker_loop(rx))
.expect("teksilo-settings: failed to spawn writer thread");
tx
})
}
fn apply_schedule(state: &mut PoolState, id: WriterId, patch: Patch) {
let delay = state.delays.get(&id).copied().unwrap_or(Duration::ZERO);
let slot = state.pending.entry(id).or_insert_with(|| Pending {
deadline: Instant::now() + delay,
patches: Vec::new(),
attempts: 0,
});
slot.patches.push(patch);
slot.attempts = 0;
slot.deadline = slot.deadline.max(Instant::now() + delay);
}
fn worker_loop(rx: Receiver<PoolMsg>) {
let mut state = PoolState {
delays: HashMap::new(),
paths: HashMap::new(),
pending: HashMap::new(),
failure_sink: None,
landed_sinks: HashMap::new(),
};
loop {
let next_deadline = state.pending.values().map(|p| p.deadline).min();
let recv_result = match next_deadline {
None => rx.recv().map_err(|_| RecvTimeoutError::Disconnected),
Some(d) => {
let wait = d.saturating_duration_since(Instant::now());
rx.recv_timeout(wait)
}
};
match recv_result {
Ok(PoolMsg::Register { id, path, delay }) => {
state.delays.insert(id, delay);
state.paths.insert(id, path);
}
Ok(PoolMsg::Schedule { id, patch }) => apply_schedule(&mut state, id, patch),
Ok(PoolMsg::FlushNow { id, ack }) => {
let _ = ack.send(flush_writer(&mut state, id));
}
Ok(PoolMsg::Unregister { id, ack }) => {
if let Err(e) = flush_writer(&mut state, id) {
let path = state.paths.get(&id).cloned();
let path_str = path.as_ref().map(|p| p.display().to_string());
eprintln!(
"teksilo-settings: final flush of {} failed: {e}",
path_str.as_deref().unwrap_or("<unknown>"),
);
if let Some(pending) = state.pending.get(&id) {
let attempts = pending.attempts;
let dropped = pending.patches.len();
if let Some(sink) = &state.failure_sink {
sink(path.unwrap_or_default(), attempts, dropped, e.to_string());
}
}
}
state.pending.remove(&id);
state.delays.remove(&id);
state.paths.remove(&id);
state.landed_sinks.remove(&id);
let _ = ack.send(());
}
Ok(PoolMsg::SetFailureSink(sink)) => {
state.failure_sink = Some(sink);
}
Ok(PoolMsg::SetLandedSink { id, sink }) => {
state.landed_sinks.insert(id, sink);
}
Err(RecvTimeoutError::Timeout) => {
let now = Instant::now();
let due: Vec<WriterId> = state
.pending
.iter()
.filter_map(|(id, p)| if p.deadline <= now { Some(*id) } else { None })
.collect();
for id in due {
if let Err(e) = flush_writer(&mut state, id) {
let path = state.paths.get(&id).map(|p| p.display().to_string());
eprintln!(
"teksilo-settings: write to {} failed: {e}",
path.as_deref().unwrap_or("<unknown>"),
);
}
}
}
Err(RecvTimeoutError::Disconnected) => {
return;
}
}
}
}
fn flush_writer(state: &mut PoolState, id: WriterId) -> Result<(), FlushError> {
let Some(pending) = state.pending.get(&id) else {
return Ok(()); };
if pending.patches.is_empty() {
state.pending.remove(&id);
return Ok(());
}
let Some(path) = state.paths.get(&id).cloned() else {
state.pending.remove(&id);
return Ok(());
};
let result = apply_and_write(&path, &pending.patches);
match result {
Ok(()) => {
state.pending.remove(&id);
if let Some(sink) = state.landed_sinks.get(&id) {
sink(crate::file::disk_stamp(&path));
}
Ok(())
}
Err(e) => {
let delay = state.delays.get(&id).copied().unwrap_or(Duration::ZERO);
let slot = state
.pending
.get_mut(&id)
.expect("pending entry checked above");
slot.attempts += 1;
if slot.attempts >= MAX_WRITE_ATTEMPTS {
let attempts = slot.attempts;
let dropped = slot.patches.len();
eprintln!(
"teksilo-settings: giving up on {} after {} failed attempts; \
{} queued change(s) discarded: {e}",
path.display(),
attempts,
dropped,
);
if let Some(sink) = &state.failure_sink {
sink(path.clone(), attempts, dropped, e.to_string());
}
state.pending.remove(&id);
} else {
slot.deadline = Instant::now() + delay.max(RETRY_BACKOFF);
}
Err(e)
}
}
}
fn apply_and_write(path: &Path, patches: &[Patch]) -> Result<(), FlushError> {
let _guard = crate::lock::FileLock::try_acquire_exclusive(path)?;
let current = match fs::read_to_string(path) {
Ok(s) => Some(s),
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(e.into()),
};
let mut text = current;
for patch in patches {
text = Some(patch(text)?);
}
match text {
Some(t) => write_atomic(path, &t).map_err(FlushError::from),
None => Ok(()),
}
}
pub(crate) fn write_atomic(path: &Path, contents: &str) -> io::Result<()> {
let dir = path.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"DebouncedWriter path has no parent directory",
)
})?;
fs::create_dir_all(dir)?;
let mut tmp = NamedTempFile::new_in(dir)?;
tmp.as_file_mut().write_all(contents.as_bytes())?;
tmp.as_file_mut().sync_all()?;
tmp.persist(path).map_err(|e| e.error)?;
Ok(())
}
pub struct DebouncedWriter {
id: WriterId,
path: PathBuf,
delay: Duration,
}
impl DebouncedWriter {
pub fn new(path: PathBuf, delay: Duration) -> Self {
let id = next_writer_id();
let _ = pool().send(PoolMsg::Register {
id,
path: path.clone(),
delay,
});
Self { id, path, delay }
}
pub(crate) fn schedule(&self, patch: Patch) {
let _ = pool().send(PoolMsg::Schedule { id: self.id, patch });
}
pub fn flush_now(&self) -> Result<(), FlushError> {
let (ack_tx, ack_rx) = mpsc::sync_channel(0);
pool()
.send(PoolMsg::FlushNow {
id: self.id,
ack: ack_tx,
})
.map_err(|_| FlushError::Disconnected)?;
match ack_rx.recv() {
Ok(result) => result,
Err(_) => Err(FlushError::Disconnected),
}
}
pub fn set_landed_sink(&self, sink: WriteLandedSink) {
let _ = pool().send(PoolMsg::SetLandedSink { id: self.id, sink });
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn delay(&self) -> Duration {
self.delay
}
}
impl Drop for DebouncedWriter {
fn drop(&mut self) {
let (ack_tx, ack_rx) = mpsc::sync_channel(0);
if pool()
.send(PoolMsg::Unregister {
id: self.id,
ack: ack_tx,
})
.is_ok()
&& ack_rx.recv_timeout(DROP_ACK_TIMEOUT) == Err(RecvTimeoutError::Timeout)
{
eprintln!(
"teksilo-settings: timed out waiting for the writer thread to flush {} on drop; \
the last write may be lost. This writer was dropped during process teardown — \
drop it before `main` returns instead.",
self.path.display()
);
}
}
}
impl std::fmt::Debug for DebouncedWriter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DebouncedWriter")
.field("path", &self.path)
.field("delay", &self.delay)
.field("id", &self.id.0)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use tempfile::tempdir;
fn read(path: &Path) -> String {
fs::read_to_string(path).unwrap()
}
fn empty_state() -> PoolState {
PoolState {
delays: HashMap::new(),
paths: HashMap::new(),
pending: HashMap::new(),
failure_sink: None,
landed_sinks: HashMap::new(),
}
}
fn const_patch(s: impl Into<String>) -> Patch {
let s = s.into();
Box::new(move |_current: Option<String>| Ok(s.clone()))
}
fn append_line_patch(line: &'static str) -> Patch {
Box::new(move |current: Option<String>| {
let mut text = current.unwrap_or_default();
text.push_str(line);
Ok(text)
})
}
#[test]
fn flush_now_writes_pending_payload() {
let dir = tempdir().unwrap();
let path = dir.path().join("out.toml");
let writer = DebouncedWriter::new(path.clone(), Duration::from_millis(500));
writer.schedule(const_patch("alpha = 1\n"));
writer.flush_now().unwrap();
assert_eq!(read(&path), "alpha = 1\n");
}
#[test]
fn schedule_coalesces_rapid_bursts_into_one_write() {
let dir = tempdir().unwrap();
let path = dir.path().join("burst.toml");
let writer = DebouncedWriter::new(path.clone(), Duration::from_millis(50));
for i in 0..10 {
writer.schedule(const_patch(format!("v = {i}\n")));
}
drop(writer);
assert_eq!(read(&path), "v = 9\n");
}
#[test]
fn two_mutations_in_one_debounce_window_both_land() {
let dir = tempdir().unwrap();
let path = dir.path().join("both_land.toml");
let writer = DebouncedWriter::new(path.clone(), Duration::from_millis(200));
writer.schedule(append_line_patch("alpha = 1\n"));
writer.schedule(append_line_patch("beta = 2\n"));
writer.flush_now().unwrap();
let contents = read(&path);
assert!(
contents.contains("alpha = 1"),
"the first queued patch must not be dropped by the second: {contents:?}"
);
assert!(
contents.contains("beta = 2"),
"the second queued patch must also land: {contents:?}"
);
}
#[test]
fn debounce_window_actually_waits() {
let dir = tempdir().unwrap();
let path = dir.path().join("debounced.toml");
let writer = DebouncedWriter::new(path.clone(), Duration::from_millis(300));
writer.schedule(const_patch("first = 1\n"));
thread::sleep(Duration::from_millis(50));
assert!(
!path.exists(),
"file should not exist before debounce window expires"
);
writer.schedule(const_patch("second = 2\n"));
let deadline = std::time::Instant::now() + Duration::from_millis(3000);
loop {
if path.exists() && read(&path) == "second = 2\n" {
break;
}
assert!(
std::time::Instant::now() < deadline,
"debounced flush did not complete within 3 s",
);
thread::sleep(Duration::from_millis(25));
}
}
#[test]
fn drop_flushes_pending_data() {
let dir = tempdir().unwrap();
let path = dir.path().join("drop.toml");
{
let writer = DebouncedWriter::new(path.clone(), Duration::from_secs(60));
writer.schedule(const_patch("survived = true\n"));
}
assert_eq!(read(&path), "survived = true\n");
}
#[test]
fn zero_delay_writes_immediately_after_flush_now() {
let dir = tempdir().unwrap();
let path = dir.path().join("zero.toml");
let writer = DebouncedWriter::new(path.clone(), Duration::ZERO);
writer.schedule(const_patch("v = 1\n"));
writer.flush_now().unwrap();
assert_eq!(read(&path), "v = 1\n");
}
#[test]
fn write_atomic_creates_parent_dirs() {
let dir = tempdir().unwrap();
let path = dir.path().join("nested/deeper/out.toml");
write_atomic(&path, "ok = true\n").unwrap();
assert_eq!(read(&path), "ok = true\n");
}
#[test]
fn many_writers_coexist_on_the_shared_thread() {
let dir = tempdir().unwrap();
let mut writers = Vec::new();
for i in 0..20 {
let path = dir.path().join(format!("w{i}.toml"));
let w = DebouncedWriter::new(path, Duration::ZERO);
w.schedule(const_patch(format!("id = {i}\n")));
w.flush_now().unwrap();
writers.push(w);
}
for i in 0..20 {
let path = dir.path().join(format!("w{i}.toml"));
assert_eq!(read(&path), format!("id = {i}\n"));
}
}
#[test]
fn failed_write_retains_the_queue_and_lands_once_unblocked() {
let dir = tempdir().unwrap();
let path = dir.path().join("obstructed.toml");
fs::create_dir_all(&path).unwrap();
let writer = DebouncedWriter::new(path.clone(), Duration::ZERO);
writer.schedule(const_patch("v = 1\n"));
thread::sleep(Duration::from_millis(150));
assert!(
path.is_dir(),
"the write must have failed while the obstruction stood"
);
fs::remove_dir(&path).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
if path.is_file() && fs::read_to_string(&path).ok().as_deref() == Some("v = 1\n") {
break;
}
assert!(
std::time::Instant::now() < deadline,
"the queued patch was not retried/retained after the obstruction cleared",
);
thread::sleep(Duration::from_millis(50));
}
}
#[test]
fn schedule_after_a_failure_resets_attempts_and_does_not_rewind_the_backoff_deadline() {
let dir = tempdir().unwrap();
let path = dir.path().join("obstructed.toml");
fs::create_dir_all(&path).unwrap();
let id = next_writer_id();
let mut state = empty_state();
state.delays.insert(id, Duration::ZERO);
state.paths.insert(id, path.clone());
state.pending.insert(
id,
Pending {
deadline: Instant::now(),
patches: vec![const_patch("v = 1\n")],
attempts: 0,
},
);
let before_backoff = Instant::now();
assert!(flush_writer(&mut state, id).is_err());
let slot = state
.pending
.get(&id)
.expect("still pending after 1/5 failures");
assert_eq!(slot.attempts, 1);
assert!(
slot.deadline >= before_backoff + RETRY_BACKOFF,
"a failed attempt must install a future backoff deadline, got {:?} (now was {:?})",
slot.deadline,
before_backoff,
);
let backoff_deadline = slot.deadline;
apply_schedule(&mut state, id, const_patch("v = 2\n"));
let slot = state.pending.get(&id).expect("still pending");
assert_eq!(
slot.attempts, 0,
"new work must reset the failure streak, since the newly queued \
patch has never itself failed to write"
);
assert!(
slot.deadline >= backoff_deadline,
"an unrelated Schedule must never rewind an already-armed backoff \
deadline back toward `now`: backoff was {backoff_deadline:?}, \
deadline after Schedule was {:?}",
slot.deadline,
);
assert_eq!(slot.patches.len(), 2, "both patches must still be queued");
}
#[test]
fn schedule_still_coalesces_a_healthy_burst_into_one_forward_moving_deadline() {
let id = next_writer_id();
let mut state = empty_state();
let delay = Duration::from_millis(50);
state.delays.insert(id, delay);
let mut last_deadline = Instant::now();
for i in 0..5 {
let before = Instant::now();
apply_schedule(&mut state, id, const_patch(format!("v = {i}\n")));
let slot = state.pending.get(&id).unwrap();
assert!(
slot.deadline >= before + delay,
"each Schedule in a healthy burst must push the deadline to \
at least `now + delay`, got {:?} (now + delay was {:?})",
slot.deadline,
before + delay,
);
assert!(
slot.deadline >= last_deadline,
"the deadline must never move backward across a healthy burst"
);
last_deadline = slot.deadline;
thread::sleep(Duration::from_millis(5));
}
assert_eq!(state.pending.get(&id).unwrap().patches.len(), 5);
}
#[test]
fn contended_lock_on_one_writer_does_not_stall_others_on_the_shared_thread() {
let dir = tempdir().unwrap();
let locked_path = dir.path().join("locked.toml");
let healthy_path = dir.path().join("healthy.toml");
let external_lock = crate::lock::FileLock::acquire_exclusive(&locked_path).unwrap();
let locked_writer = DebouncedWriter::new(locked_path.clone(), Duration::ZERO);
let healthy_writer = DebouncedWriter::new(healthy_path.clone(), Duration::ZERO);
locked_writer.schedule(const_patch("v = locked\n"));
healthy_writer.schedule(const_patch("v = healthy\n"));
let deadline = std::time::Instant::now() + Duration::from_secs(2);
loop {
if healthy_path.exists() && read(&healthy_path) == "v = healthy\n" {
break;
}
assert!(
std::time::Instant::now() < deadline,
"the healthy writer's write did not land promptly — the shared \
worker thread appears stalled behind the other writer's \
contended lock",
);
thread::sleep(Duration::from_millis(20));
}
assert!(
!locked_path.exists(),
"the locked writer should not have been able to write while its \
lock is still externally held"
);
drop(external_lock);
}
#[test]
fn giving_up_after_max_attempts_reports_through_the_failure_sink() {
let dir = tempdir().unwrap();
let path = dir.path().join("obstructed_sink.toml");
fs::create_dir_all(&path).unwrap();
let id = next_writer_id();
let mut state = empty_state();
state.delays.insert(id, Duration::ZERO);
state.paths.insert(id, path.clone());
state.pending.insert(
id,
Pending {
deadline: Instant::now(),
patches: vec![const_patch("v = 1\n")],
attempts: 0,
},
);
type FailureCall = (PathBuf, u32, usize, String);
let calls: Arc<Mutex<Vec<FailureCall>>> = Arc::new(Mutex::new(Vec::new()));
let calls_for_sink = calls.clone();
state.failure_sink = Some(Arc::new(move |path, attempts, dropped, message| {
calls_for_sink
.lock()
.unwrap()
.push((path, attempts, dropped, message));
}));
for _ in 0..MAX_WRITE_ATTEMPTS {
let _ = flush_writer(&mut state, id);
}
let recorded = calls.lock().unwrap();
assert_eq!(
recorded.len(),
1,
"the sink must fire exactly once, precisely at the give-up point: {recorded:?}"
);
let (sunk_path, attempts, dropped, message) = &recorded[0];
assert_eq!(sunk_path, &path);
assert_eq!(*attempts, MAX_WRITE_ATTEMPTS);
assert_eq!(*dropped, 1);
assert!(!message.is_empty());
assert!(
!state.pending.contains_key(&id),
"the queue must be gone once the sink has been told about the discard"
);
}
#[test]
fn a_healthy_writer_never_invokes_the_failure_sink() {
let dir = tempdir().unwrap();
let path = dir.path().join("healthy_no_sink.toml");
let writer = DebouncedWriter::new(path.clone(), Duration::ZERO);
let fired = Arc::new(Mutex::new(false));
let fired_for_sink = fired.clone();
set_write_failure_sink(Arc::new(move |_, _, _, _| {
*fired_for_sink.lock().unwrap() = true;
}));
writer.schedule(const_patch("v = 1\n"));
writer.flush_now().unwrap();
assert!(
!*fired.lock().unwrap(),
"a successful write must never invoke the failure sink"
);
set_write_failure_sink(Arc::new(|_, _, _, _| {}));
}
#[test]
fn landed_sink_fires_with_the_real_post_write_stamp() {
let dir = tempdir().unwrap();
let path = dir.path().join("landed.toml");
let writer = DebouncedWriter::new(path.clone(), Duration::ZERO);
let received: Arc<Mutex<Option<LandedStamp>>> = Arc::new(Mutex::new(None));
let received_for_sink = received.clone();
writer.set_landed_sink(Arc::new(move |stamp| {
*received_for_sink.lock().unwrap() = Some(stamp);
}));
writer.schedule(const_patch("v = 1\n"));
writer.flush_now().unwrap();
let expected = crate::file::disk_stamp(&path);
let got = received
.lock()
.unwrap()
.expect("the landed sink must have fired after a successful flush");
assert_eq!(
got, expected,
"the sink's stamp must match a stamp taken independently right \
after the write landed"
);
assert!(expected.0.is_some() || expected.1.is_some());
}
#[test]
fn writer_without_a_landed_sink_flushes_normally() {
let dir = tempdir().unwrap();
let path = dir.path().join("no_landed_sink.toml");
let writer = DebouncedWriter::new(path.clone(), Duration::ZERO);
writer.schedule(const_patch("v = 1\n"));
writer.flush_now().unwrap();
assert_eq!(read(&path), "v = 1\n");
}
}