diff --git a/src/autosave.rs b/src/autosave.rs index 8125c62..bfd24d2 100644 --- a/src/autosave.rs +++ b/src/autosave.rs @@ -1,18 +1,84 @@ -//! Asynchroner Autosave-Mechanismus mit Debounce-Logik und Hintergrund-Worker-Thread. +//! Asynchroner Autosave-Mechanismus mit Debounce- und Max-Wait-Logik, +//! austauschbarem PageStore-Backend und resilientem Hintergrund-Worker. //! //! Stellt sicher, dass Datei-I/O niemals den 120/240 Hz Render-Loop blockiert. //! Schnelle aufeinanderfolgende Änderungen werden gebündelt (debounced). -use std::collections::HashMap; -use std::path::PathBuf; +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc::{self, Receiver, Sender}; use std::sync::Arc; use std::thread::{self, JoinHandle}; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use crate::document::Page; -use crate::storage::{self, NotebookManifest, PageData}; +use crate::document::{Notebook, Page, PersistState}; +use crate::storage::{self, NotebookManifest, PageData, StorageError}; + +/// Abstraktion für die Zeitmessung (für deterministische Tests ohne Thread-Sleep). +pub trait Clock: Send + Sync + 'static { + fn now(&self) -> Instant; +} + +/// Standard-Uhr basierend auf der Systemzeit. +#[derive(Debug, Clone, Copy, Default)] +pub struct SystemClock; + +impl Clock for SystemClock { + fn now(&self) -> Instant { + Instant::now() + } +} + +/// Austauschbares Speicher-Backend für Unit-Tests und alternative Speicherstrategien. +pub trait PageStore: Send + Sync + 'static { + fn save_page(&self, notebook_dir: &Path, page: &PageData) -> Result<(), StorageError>; + fn save_manifest( + &self, + notebook_dir: &Path, + manifest: &NotebookManifest, + ) -> Result<(), StorageError>; +} + +/// Standard-Implementierung über das lokale Dateisystem (`src/storage.rs`). +#[derive(Debug, Clone, Copy, Default)] +pub struct DiskPageStore; + +impl PageStore for DiskPageStore { + fn save_page(&self, notebook_dir: &Path, page: &PageData) -> Result<(), StorageError> { + storage::save_page(notebook_dir, page) + } + + fn save_manifest( + &self, + notebook_dir: &Path, + manifest: &NotebookManifest, + ) -> Result<(), StorageError> { + storage::save_manifest(notebook_dir, manifest) + } +} + +/// Fehler beim Autosave oder Flush. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AutosaveError { + pub failed_page_ids: Vec, + pub messages: Vec, +} + +impl fmt::Display for AutosaveError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Autosave fehlgeschlagen für Seiten {:?}: {}", + self.failed_page_ids, + self.messages.join("; ") + ) + } +} + +impl std::error::Error for AutosaveError {} /// Aktueller Status des Autosave-Controllers für OSD und UI. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -56,84 +122,174 @@ pub enum AutosaveCommand { pub enum AutosaveResponse { PageSaved { page_id: u64, - result: Result<(), String>, + result: Result<(), (String, Box)>, }, ManifestSaved { - result: Result<(), String>, + result: Result<(), (String, Box)>, }, } +/// Startet einen isolierten Hintergrund-Worker-Thread. +fn spawn_worker( + cmd_rx: Receiver, + resp_tx: Sender, + store: Arc, + worker_writes_counter: Arc, +) -> JoinHandle<()> { + thread::spawn(move || { + while let Ok(cmd) = cmd_rx.recv() { + match cmd { + AutosaveCommand::SavePage { + notebook_dir, + page_data, + } => { + let page_id = page_data.id; + let store_clone = Arc::clone(&store); + let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + store_clone.save_page(¬ebook_dir, &page_data) + })); + match res { + Ok(Ok(())) => { + worker_writes_counter.fetch_add(1, Ordering::SeqCst); + let _ = resp_tx.send(AutosaveResponse::PageSaved { + page_id, + result: Ok(()), + }); + } + Ok(Err(e)) => { + let _ = resp_tx.send(AutosaveResponse::PageSaved { + page_id, + result: Err((e.to_string(), Box::new(page_data))), + }); + } + Err(_panic) => { + let _ = resp_tx.send(AutosaveResponse::PageSaved { + page_id, + result: Err(( + "Worker panic beim Speichern der Seite".to_string(), + Box::new(page_data), + )), + }); + } + } + } + AutosaveCommand::SaveManifest { + notebook_dir, + manifest, + } => { + let store_clone = Arc::clone(&store); + let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + store_clone.save_manifest(¬ebook_dir, &manifest) + })); + match res { + Ok(Ok(())) => { + let _ = + resp_tx.send(AutosaveResponse::ManifestSaved { result: Ok(()) }); + } + Ok(Err(e)) => { + let _ = resp_tx.send(AutosaveResponse::ManifestSaved { + result: Err((e.to_string(), Box::new(manifest))), + }); + } + Err(_panic) => { + let _ = resp_tx.send(AutosaveResponse::ManifestSaved { + result: Err(( + "Worker panic beim Speichern des Manifests".to_string(), + Box::new(manifest), + )), + }); + } + } + } + AutosaveCommand::Flush(ack_tx) => { + let _ = ack_tx.send(()); + } + AutosaveCommand::Shutdown => { + break; + } + } + } + }) +} + +#[derive(Debug, Clone)] +struct PageRetry { + next_retry: Instant, + backoff: Duration, +} + /// Controller zur Verwaltung des asynchronen Autosave-Ablaufs. pub struct AutosaveController { notebook_dir: PathBuf, debounce_duration: Duration, + max_wait_duration: Duration, + first_change_time: Option, last_change_time: Option, pending_pages: HashMap, pending_manifest: Option, + retries: HashMap, + last_errors: Vec, status: AutosaveStatus, in_flight_count: usize, total_page_writes: Arc, + store: Arc, + clock: Arc, cmd_tx: Sender, resp_rx: Receiver, worker_handle: Option>, + worker_restart_count: usize, } impl AutosaveController { - /// Erzeugt einen neuen Controller und startet den Hintergrund-Worker-Thread. + /// Erzeugt einen neuen Controller mit Standardeinstellungen (1.5s Debounce, 10s MaxWait, DiskStore). pub fn new(notebook_dir: PathBuf, debounce_duration: Duration) -> Self { + Self::with_options( + notebook_dir, + debounce_duration, + Duration::from_secs(10), + Arc::new(DiskPageStore), + Arc::new(SystemClock), + ) + } + + /// Voll konfigurierbarer Konstruktor für Tests mit MockClock und FakeStore. + pub fn with_options( + notebook_dir: PathBuf, + debounce_duration: Duration, + max_wait_duration: Duration, + store: Arc, + clock: Arc, + ) -> Self { let (cmd_tx, cmd_rx) = mpsc::channel::(); let (resp_tx, resp_rx) = mpsc::channel::(); let total_page_writes = Arc::new(AtomicUsize::new(0)); - let worker_writes_counter = Arc::clone(&total_page_writes); - let worker_handle = thread::spawn(move || { - while let Ok(cmd) = cmd_rx.recv() { - match cmd { - AutosaveCommand::SavePage { - notebook_dir, - page_data, - } => { - let page_id = page_data.id; - let res = storage::save_page(¬ebook_dir, &page_data); - if res.is_ok() { - worker_writes_counter.fetch_add(1, Ordering::SeqCst); - } - let _ = resp_tx.send(AutosaveResponse::PageSaved { - page_id, - result: res.map_err(|e| e.to_string()), - }); - } - AutosaveCommand::SaveManifest { - notebook_dir, - manifest, - } => { - let res = storage::save_manifest(¬ebook_dir, &manifest); - let _ = resp_tx.send(AutosaveResponse::ManifestSaved { - result: res.map_err(|e| e.to_string()), - }); - } - AutosaveCommand::Flush(ack_tx) => { - let _ = ack_tx.send(()); - } - AutosaveCommand::Shutdown => { - break; - } - } - } - }); + let worker_handle = spawn_worker( + cmd_rx, + resp_tx, + Arc::clone(&store), + Arc::clone(&total_page_writes), + ); Self { notebook_dir, debounce_duration, + max_wait_duration, + first_change_time: None, last_change_time: None, pending_pages: HashMap::new(), pending_manifest: None, + retries: HashMap::new(), + last_errors: Vec::new(), status: AutosaveStatus::Saved, in_flight_count: 0, total_page_writes, + store, + clock, cmd_tx, resp_rx, worker_handle: Some(worker_handle), + worker_restart_count: 0, } } @@ -145,14 +301,68 @@ impl AutosaveController { let page_data = PageData::from(page); self.pending_pages.insert(page.id, page_data); self.pending_manifest = Some(manifest.clone()); - self.last_change_time = Some(Instant::now()); - self.status = AutosaveStatus::Pending; + + let now = self.clock.now(); + if self.first_change_time.is_none() { + self.first_change_time = Some(now); + } + self.last_change_time = Some(now); + + if self.status != AutosaveStatus::Error { + self.status = AutosaveStatus::Pending; + } } - /// Prüft den Worker-Kanal und stößt fällige Schreibvorgänge nach Ablauf des Debounce-Timers an. - /// - /// Sollte regelmäßig im Event-Loop aufgerufen werden. + /// Markiert das Manifest als geändert (z. B. bei Seitenwechsel oder Umbenennen). + pub fn mark_manifest_dirty(&mut self, manifest: &NotebookManifest) { + self.pending_manifest = Some(manifest.clone()); + + let now = self.clock.now(); + if self.first_change_time.is_none() { + self.first_change_time = Some(now); + } + self.last_change_time = Some(now); + + if self.status != AutosaveStatus::Error { + self.status = AutosaveStatus::Pending; + } + } + + /// Startet den Worker-Thread neu, falls er unerwartet beendet wurde. + fn ensure_worker_alive(&mut self) -> bool { + // Prüfen, ob Worker noch reagiert + let is_alive = self + .worker_handle + .as_ref() + .map(|h| !h.is_finished()) + .unwrap_or(false); + + if !is_alive && self.worker_restart_count < 3 { + self.worker_restart_count += 1; + eprintln!( + "[Autosave] Worker beendet! Starte Worker neu (Versuch {}/3)...", + self.worker_restart_count + ); + let (cmd_tx, cmd_rx) = mpsc::channel(); + let (resp_tx, resp_rx) = mpsc::channel(); + let handle = spawn_worker( + cmd_rx, + resp_tx, + Arc::clone(&self.store), + Arc::clone(&self.total_page_writes), + ); + self.cmd_tx = cmd_tx; + self.resp_rx = resp_rx; + self.worker_handle = Some(handle); + return true; + } + is_alive + } + + /// Prüft den Worker-Kanal und stößt fällige Schreibvorgänge nach Ablauf von Debounce oder MaxWait an. pub fn update(&mut self) -> AutosaveStatus { + let now = self.clock.now(); + // 1. Antworten des Hintergrund-Workers verarbeiten while let Ok(response) = self.resp_rx.try_recv() { if self.in_flight_count > 0 { @@ -160,71 +370,143 @@ impl AutosaveController { } match response { - AutosaveResponse::PageSaved { page_id: _, result } => { - if let Err(e) = result { - eprintln!("[Autosave-Fehler] Seite konnte nicht gespeichert werden: {e}"); - self.status = AutosaveStatus::Error; + AutosaveResponse::PageSaved { page_id, result } => match result { + Ok(()) => { + self.retries.remove(&page_id); } - } - AutosaveResponse::ManifestSaved { result } => { - if let Err(e) = result { - eprintln!( - "[Autosave-Fehler] Manifest konnte nicht gespeichert werden: {e}" - ); + Err((err_msg, failed_page)) => { + eprintln!("[Autosave-Fehler] Seite {page_id} fehlgeschlagen: {err_msg}"); self.status = AutosaveStatus::Error; + self.last_errors.push(format!("Seite {page_id}: {err_msg}")); + + // Wieder einreihen, wenn nicht bereits eine neuere Version vorliegt + self.pending_pages.entry(page_id).or_insert(*failed_page); + + // Exponentieller Backoff + let retry = self.retries.entry(page_id).or_insert(PageRetry { + next_retry: now, + backoff: Duration::from_secs(1), + }); + retry.next_retry = now + retry.backoff; + retry.backoff = (retry.backoff * 2).min(Duration::from_secs(30)); + } + }, + AutosaveResponse::ManifestSaved { result } => { + if let Err((err_msg, failed_manifest)) = result { + eprintln!("[Autosave-Fehler] Manifest fehlgeschlagen: {err_msg}"); + self.status = AutosaveStatus::Error; + self.last_errors.push(format!("Manifest: {err_msg}")); + if self.pending_manifest.is_none() { + self.pending_manifest = Some(*failed_manifest); + } } } } if self.in_flight_count == 0 && self.pending_pages.is_empty() - && self.status != AutosaveStatus::Error + && self.pending_manifest.is_none() { + self.last_errors.clear(); self.status = AutosaveStatus::Saved; } } - // 2. Debounce-Timer prüfen: Wenn Zeit abgelaufen, an Worker übergeben - if !self.pending_pages.is_empty() { - if let Some(last_change) = self.last_change_time { - if last_change.elapsed() >= self.debounce_duration { - self.dispatch_pending(); - } + // 2. Prüfen, ob anstehende Änderungen fällig sind (Debounce ODER Max-Wait ODER Backoff-Retry) + let has_pending = !self.pending_pages.is_empty() || self.pending_manifest.is_some(); + if has_pending { + let max_wait_hit = self + .first_change_time + .map(|t| now.duration_since(t) >= self.max_wait_duration) + .unwrap_or(false); + + let debounce_hit = self + .last_change_time + .map(|t| now.duration_since(t) >= self.debounce_duration) + .unwrap_or(false); + + let retry_ready = !self.retries.is_empty() + && self.pending_pages.keys().any(|id| { + self.retries + .get(id) + .map(|r| now >= r.next_retry) + .unwrap_or(false) + }); + + if max_wait_hit || debounce_hit || retry_ready { + self.dispatch_pending(); } } self.status } - /// Übergibt alle anstehenden Änderungen sofort an den Worker-Kanal. + /// Übergibt alle fälligen Änderungen sofort an den Worker-Kanal. fn dispatch_pending(&mut self) { - for (_page_id, page_data) in self.pending_pages.drain() { + let now = self.clock.now(); + self.ensure_worker_alive(); + + // Nur Seiten dispatchen, deren Backoff abgelaufen ist + let mut to_dispatch = Vec::new(); + let page_ids: Vec = self.pending_pages.keys().copied().collect(); + for id in page_ids { + let ready = self + .retries + .get(&id) + .map(|r| now >= r.next_retry) + .unwrap_or(true); + if ready { + if let Some(page_data) = self.pending_pages.remove(&id) { + to_dispatch.push(page_data); + } + } + } + + for page_data in to_dispatch { + let page_id = page_data.id; let cmd = AutosaveCommand::SavePage { notebook_dir: self.notebook_dir.clone(), - page_data, + page_data: page_data.clone(), }; if self.cmd_tx.send(cmd).is_ok() { self.in_flight_count += 1; + } else { + // Senden fehlgeschlagen -> zurück in den Puffer + self.status = AutosaveStatus::Error; + self.pending_pages.insert(page_id, page_data); + self.ensure_worker_alive(); } } if let Some(manifest) = self.pending_manifest.take() { let cmd = AutosaveCommand::SaveManifest { notebook_dir: self.notebook_dir.clone(), - manifest, + manifest: manifest.clone(), }; if self.cmd_tx.send(cmd).is_ok() { self.in_flight_count += 1; + } else { + self.status = AutosaveStatus::Error; + self.pending_manifest = Some(manifest); + self.ensure_worker_alive(); } } - self.status = AutosaveStatus::Saving; - self.last_change_time = None; + if self.in_flight_count > 0 { + if self.status != AutosaveStatus::Error { + self.status = AutosaveStatus::Saving; + } + self.first_change_time = None; + self.last_change_time = None; + } } - /// Führt einen synchronen Flush aller noch ausstehenden Änderungen durch (z. B. vor Beenden). - pub fn flush(&mut self) { - // Falls noch Änderungen im Debounce-Puffer liegen, sofort losschicken + /// Führt einen synchronen Flush aller anstehenden Änderungen durch. + /// Gibt `Result<(), AutosaveError>` mit allen Fehlermeldungen zurück. + pub fn flush(&mut self) -> Result<(), AutosaveError> { + // Backoff für den Flush ignorieren, damit sofort geschrieben wird + self.retries.clear(); + if !self.pending_pages.is_empty() || self.pending_manifest.is_some() { self.dispatch_pending(); } @@ -239,28 +521,119 @@ impl AutosaveController { if self.in_flight_count > 0 { self.in_flight_count -= 1; } - if let AutosaveResponse::PageSaved { result: Err(e), .. } = response { - eprintln!("[Autosave-Flush] Fehler: {e}"); + match response { + AutosaveResponse::PageSaved { page_id, result } => match result { + Ok(()) => { + self.retries.remove(&page_id); + } + Err((e, failed_page)) => { + self.status = AutosaveStatus::Error; + self.last_errors.push(format!("Seite {page_id}: {e}")); + self.pending_pages.insert(page_id, *failed_page); + let now = self.clock.now(); + let retry = self.retries.entry(page_id).or_insert(PageRetry { + next_retry: now, + backoff: Duration::from_secs(1), + }); + retry.next_retry = now + retry.backoff; + retry.backoff = (retry.backoff * 2).min(Duration::from_secs(30)); + } + }, + AutosaveResponse::ManifestSaved { result } => { + if let Err((e, failed_manifest)) = result { + self.status = AutosaveStatus::Error; + self.last_errors.push(format!("Manifest: {e}")); + self.pending_manifest = Some(*failed_manifest); + } + } } } - self.status = AutosaveStatus::Saved; + if self.in_flight_count == 0 + && self.pending_pages.is_empty() + && self.pending_manifest.is_none() + { + self.last_errors.clear(); + self.status = AutosaveStatus::Saved; + Ok(()) + } else { + self.status = AutosaveStatus::Error; + let failed_page_ids: Vec = self.pending_pages.keys().copied().collect(); + let messages = std::mem::take(&mut self.last_errors); + Err(AutosaveError { + failed_page_ids, + messages, + }) + } } - /// Wechselt das Zielverzeichnis für zukünftige Speicheroperationen (z. B. beim Dokumentenwechsel), - /// nachdem alle noch anstehenden Schreibvorgänge des vorherigen Notizbuchs sicher geflusht wurden. + /// Wartet synchron darauf, dass der Worker alle bereits in der Warteschlange befindlichen Aufgaben ausführt. + /// Startet keine neuen Schreibvorgänge (im Unterschied zu `flush()`). + pub fn sync_worker(&self) { + let (ack_tx, ack_rx) = mpsc::channel(); + if self.cmd_tx.send(AutosaveCommand::Flush(ack_tx)).is_ok() { + let _ = ack_rx.recv(); + } + } + + /// Wechselt das Zielverzeichnis für zukünftige Speicheroperationen (z. B. beim Dokumentenwechsel). pub fn switch_notebook_dir(&mut self, new_notebook_dir: PathBuf) { - self.flush(); + let _ = self.flush(); self.notebook_dir = new_notebook_dir; } /// Beendet den Hintergrund-Worker ordnungsgemäß nach einem Flush. - pub fn shutdown(&mut self) { - self.flush(); + pub fn shutdown(&mut self) -> Result<(), AutosaveError> { + let res = self.flush(); let _ = self.cmd_tx.send(AutosaveCommand::Shutdown); if let Some(handle) = self.worker_handle.take() { let _ = handle.join(); } + res + } + + /// Schreibt alle ungespeicherten/ausstehenden Seiten als Notfall-Sicherung + /// in $XDG_DATA_HOME/ink-notes/recovery/ (Dateiname: recovery__.json). + pub fn export_recovery( + &self, + fallback_notebook: Option<&Notebook>, + ) -> Result, std::io::Error> { + let rec_dir = recovery_dir(); + fs::create_dir_all(&rec_dir)?; + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let mut written = Vec::new(); + let mut exported_ids = HashSet::new(); + + // 1. Alle in pending_pages wartenden Seiten sichern + for (&page_id, page_data) in &self.pending_pages { + let filename = format!("recovery_{}_{}.json", page_id, ts); + let file_path = rec_dir.join(filename); + let json = serde_json::to_vec_pretty(page_data)?; + fs::write(&file_path, json)?; + exported_ids.insert(page_id); + written.push(file_path); + } + + // 2. Falls ein Notizbuch übergeben wurde, alle dirty-Seiten sichern, die noch nicht erfasst wurden + if let Some(nb) = fallback_notebook { + for page in &nb.pages { + if !exported_ids.contains(&page.id) + && (page.is_dirty || page.persist == PersistState::Normal) + { + let page_data = PageData::from(page); + let filename = format!("recovery_{}_{}.json", page.id, ts); + let file_path = rec_dir.join(filename); + let json = serde_json::to_vec_pretty(&page_data)?; + fs::write(&file_path, json)?; + written.push(file_path); + } + } + } + + Ok(written) } /// Gibt die Gesamtzahl aller tatsächlich auf die Disk geschriebenen Seiten zurück. @@ -274,10 +647,348 @@ impl AutosaveController { } } +/// Liefert das Verzeichnis für Notfall-Wiederherstellungsdateien ($XDG_DATA_HOME/ink-notes/recovery). +pub fn recovery_dir() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + if !xdg.is_empty() { + return PathBuf::from(xdg).join("ink-notes").join("recovery"); + } + } + if let Ok(home) = std::env::var("HOME") { + if !home.is_empty() { + return PathBuf::from(home) + .join(".local") + .join("share") + .join("ink-notes") + .join("recovery"); + } + } + PathBuf::from(".").join(".recovery") +} + impl Drop for AutosaveController { fn drop(&mut self) { if self.worker_handle.is_some() { - self.shutdown(); + if let Err(e) = self.shutdown() { + eprintln!("[Autosave] Fehler beim geordneten Beenden in Drop: {e}"); + } } } } + +#[cfg(test)] +pub mod tests { + use super::*; + use crate::canvas::InkVertex; + use crate::document::{PageBackground, Stroke, StylusSample}; + use std::sync::Mutex; + + /// Mock-Uhr für deterministische, sofortige Tests ohne thread::sleep. + pub struct MockClock { + now: Mutex, + } + + impl MockClock { + pub fn new(start: Instant) -> Self { + Self { + now: Mutex::new(start), + } + } + + pub fn advance(&self, duration: Duration) { + let mut guard = self.now.lock().unwrap(); + *guard += duration; + } + } + + impl Clock for MockClock { + fn now(&self) -> Instant { + *self.now.lock().unwrap() + } + } + + /// Fake-PageStore zum Simulieren von I/O-Fehlern und Panics. + pub struct FakePageStore { + pub fail_page_writes: Mutex, + pub panic_on_page: Mutex, + pub page_writes: Arc, + pub manifest_writes: Arc, + } + + impl FakePageStore { + pub fn new() -> Self { + Self { + fail_page_writes: Mutex::new(0), + panic_on_page: Mutex::new(false), + page_writes: Arc::new(AtomicUsize::new(0)), + manifest_writes: Arc::new(AtomicUsize::new(0)), + } + } + } + + impl Default for FakePageStore { + fn default() -> Self { + Self::new() + } + } + + impl PageStore for FakePageStore { + fn save_page(&self, _dir: &Path, _page: &PageData) -> Result<(), StorageError> { + if *self.panic_on_page.lock().unwrap() { + panic!("Simulierte Panic im Worker!"); + } + let mut fails = self.fail_page_writes.lock().unwrap(); + if *fails > 0 { + *fails -= 1; + return Err(StorageError::InvalidManifest( + "Simulierter Store-Fehler".to_string(), + )); + } + self.page_writes.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + fn save_manifest( + &self, + _dir: &Path, + _manifest: &NotebookManifest, + ) -> Result<(), StorageError> { + self.manifest_writes.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + #[test] + fn test_debounce_bundles_changes() { + let clock = Arc::new(MockClock::new(Instant::now())); + let store = Arc::new(FakePageStore::new()); + let page_writes_counter = Arc::clone(&store.page_writes); + + let debounce = Duration::from_millis(1500); + let max_wait = Duration::from_secs(10); + let mut controller = AutosaveController::with_options( + PathBuf::from("/tmp/test"), + debounce, + max_wait, + store, + Arc::clone(&clock) as Arc, + ); + + let manifest = NotebookManifest::new("nb1", "Test"); + let mut page = Page::new(1, PageBackground::Grid5mm); + + // 10 schnelle Änderungen, jeweils 100 ms Abstand (unter 1500 ms Debounce) + for i in 0..10 { + page.add_stroke(Stroke::new( + i + 1, + vec![StylusSample { + position: glam::Vec2::new(i as f32, 0.0), + pressure: 0.5, + timestamp: i as f64 * 0.1, + }], + vec![InkVertex::new(i as f32, 0.0, [0.0, 0.0, 0.0, 1.0])], + [0.0, 0.0, 0.0, 1.0], + )); + controller.mark_page_dirty(&page, &manifest); + clock.advance(Duration::from_millis(100)); + controller.update(); + } + + // Während der schnellen Änderungen darf noch nichts geschrieben sein + assert_eq!(page_writes_counter.load(Ordering::SeqCst), 0); + + // Zeit um 1600 ms voranschreiten lassen (> 1500 ms Debounce) + clock.advance(Duration::from_millis(1600)); + controller.update(); + let _ = controller.flush(); + + // Genau 1 gebündelter Schreibvorgang + assert_eq!(page_writes_counter.load(Ordering::SeqCst), 1); + assert_eq!(controller.status(), AutosaveStatus::Saved); + } + + #[test] + fn test_max_wait_forces_flush_during_continuous_edits() { + let clock = Arc::new(MockClock::new(Instant::now())); + let store = Arc::new(FakePageStore::new()); + let page_writes_counter = Arc::clone(&store.page_writes); + + let debounce = Duration::from_millis(1500); + let max_wait = Duration::from_secs(10); + let mut controller = AutosaveController::with_options( + PathBuf::from("/tmp/test"), + debounce, + max_wait, + store, + Arc::clone(&clock) as Arc, + ); + + let manifest = NotebookManifest::new("nb2", "Test"); + let mut page = Page::new(1, PageBackground::Grid5mm); + + // Kontinuierliche Änderungen alle 500 ms über insgesamt 12 Sekunden (Debounce 1500ms würde nie greifen!) + for i in 0..24 { + page.add_stroke(Stroke::new(i + 1, vec![], vec![], [0.0, 0.0, 0.0, 1.0])); + controller.mark_page_dirty(&page, &manifest); + clock.advance(Duration::from_millis(500)); + controller.update(); + } + + // Nach 12s (> 10s max_wait) MUSS mindestens ein Schreibvorgang erzwungen worden sein! + controller.sync_worker(); + let writes = page_writes_counter.load(Ordering::SeqCst); + assert!( + writes >= 1, + "MaxWait muss Schreiben erzwingen, selbst wenn Debounce nie abläuft" + ); + } + + #[test] + fn test_error_retry_with_backoff_and_recovery() { + let clock = Arc::new(MockClock::new(Instant::now())); + let store = Arc::new(FakePageStore::new()); + *store.fail_page_writes.lock().unwrap() = 2; // Die ersten 2 Versuche schlagen fehl + + let mut controller = AutosaveController::with_options( + PathBuf::from("/tmp/test"), + Duration::from_millis(500), + Duration::from_secs(10), + store, + Arc::clone(&clock) as Arc, + ); + + let manifest = NotebookManifest::new("nb3", "Test"); + let mut page = Page::new(1, PageBackground::Grid5mm); + page.add_stroke(Stroke::new(1, vec![], vec![], [0.0, 0.0, 0.0, 1.0])); + + controller.mark_page_dirty(&page, &manifest); + clock.advance(Duration::from_millis(600)); + controller.update(); + + // Erster Versuch fehlgeschlagen -> Status Error + let flush_err = controller.flush(); + assert!( + flush_err.is_err(), + "Flush muss fehlschlagen, solange Store fehlschlägt" + ); + assert_eq!(controller.status(), AutosaveStatus::Error); + + // Nach Ablauf des Backoffs erneuter Versuch (Versuch 2 schlägt fehl) + clock.advance(Duration::from_secs(2)); + controller.update(); + controller.sync_worker(); + controller.update(); + assert_eq!(controller.status(), AutosaveStatus::Error); + + // Nach Ablauf des neuen Backoffs dritter Versuch (Store fehlerfrei -> gelingt): + clock.advance(Duration::from_secs(4)); + let flush_ok = controller.flush(); + assert!( + flush_ok.is_ok(), + "Dritter Versuch muss gelingen und Status auf Saved setzen" + ); + assert_eq!(controller.status(), AutosaveStatus::Saved); + } + + #[test] + fn test_worker_panic_catch_and_restart() { + let clock = Arc::new(MockClock::new(Instant::now())); + let store = Arc::new(FakePageStore::new()); + *store.panic_on_page.lock().unwrap() = true; // Panic auslösen + + let mut controller = AutosaveController::with_options( + PathBuf::from("/tmp/test"), + Duration::from_millis(500), + Duration::from_secs(10), + store, + Arc::clone(&clock) as Arc, + ); + + let manifest = NotebookManifest::new("nb4", "Test"); + let mut page = Page::new(1, PageBackground::Grid5mm); + page.add_stroke(Stroke::new(1, vec![], vec![], [0.0, 0.0, 0.0, 1.0])); + + controller.mark_page_dirty(&page, &manifest); + clock.advance(Duration::from_millis(600)); + controller.update(); + + // Worker fängt Panic ab und meldet Fehler zurück + let res = controller.flush(); + assert!(res.is_err()); + assert_eq!(controller.status(), AutosaveStatus::Error); + } + + #[test] + fn test_manifest_only_dirty_triggers_autosave() { + let clock = Arc::new(MockClock::new(Instant::now())); + let store = Arc::new(FakePageStore::new()); + let manifest_writes_counter = Arc::clone(&store.manifest_writes); + + let mut controller = AutosaveController::with_options( + PathBuf::from("/tmp/test"), + Duration::from_millis(500), + Duration::from_secs(10), + store, + Arc::clone(&clock) as Arc, + ); + + let manifest = NotebookManifest::new("nb5", "Nur Manifest Geändert"); + controller.mark_manifest_dirty(&manifest); + + clock.advance(Duration::from_millis(600)); + controller.update(); + let _ = controller.flush(); + + assert_eq!(manifest_writes_counter.load(Ordering::SeqCst), 1); + assert_eq!(controller.status(), AutosaveStatus::Saved); + } + + #[test] + fn test_export_recovery_on_failure() { + let clock = Arc::new(MockClock::new(Instant::now())); + let store = FakePageStore::new(); + // Alle Schreibvorgänge fehlschlagen lassen + *store.fail_page_writes.lock().unwrap() = 10; + + let temp_recovery_dir = std::env::temp_dir().join(format!( + "ink_notes_rec_test_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::env::set_var("XDG_DATA_HOME", &temp_recovery_dir); + + let mut controller = AutosaveController::with_options( + PathBuf::from("/tmp/test_rec"), + Duration::from_millis(100), + Duration::from_secs(10), + Arc::new(store), + Arc::clone(&clock) as Arc, + ); + + let page = Page::new(42, PageBackground::Grid5mm); + let manifest = NotebookManifest::new("rec_nb", "Recovery Test"); + controller.mark_page_dirty(&page, &manifest); + + // Flush schlägt fehl + let flush_res = controller.flush(); + assert!(flush_res.is_err(), "Flush muss fehlschlagen"); + assert_eq!(controller.status(), AutosaveStatus::Error); + + // Notfall-Export ausführen + let written_files = controller + .export_recovery(None) + .expect("Notfall-Export muss erfolgreich sein"); + assert_eq!(written_files.len(), 1); + let file = &written_files[0]; + assert!(file.exists()); + + let content = std::fs::read_to_string(file).expect("Datei lesbar"); + let parsed: PageData = serde_json::from_str(&content).expect("Gültiges JSON"); + assert_eq!(parsed.id, 42); + + // Aufräumen + let _ = std::fs::remove_dir_all(&temp_recovery_dir); + } +} diff --git a/src/main.rs b/src/main.rs index 352136d..dcb68dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -581,8 +581,16 @@ fn main() -> Result<(), Box> { match event { WindowEvent::CloseRequested => { println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte..."); - autosave.shutdown(); - target.exit(); + match autosave.shutdown() { + Ok(()) => { + target.exit(); + } + Err(err) => { + eprintln!("[Shutdown-Fehler] Konnte nicht geordnet beenden: {err}"); + ui_state.shutdown_error = Some(err); + window.request_redraw(); + } + } } WindowEvent::Focused(false) => { @@ -651,7 +659,10 @@ fn main() -> Result<(), Box> { match &logical_key { Key::Named(NamedKey::Escape) => { let mut changed = false; - if ui_state.is_pen_menu_open + if ui_state.shutdown_error.is_some() { + ui_state.shutdown_error = None; + changed = true; + } else if ui_state.is_pen_menu_open || ui_state.is_highlighter_menu_open || ui_state.show_page_jump_popup || ui_state.is_document_menu_open @@ -745,8 +756,17 @@ fn main() -> Result<(), Box> { // Jetzt speichern (Disk Flush): Strg + S Key::Character(c) if c.eq_ignore_ascii_case("s") && ctrl => { - autosave.flush(); - ui_state.set_export_message("Gespeichert"); + match autosave.flush() { + Ok(()) => { + ui_state.set_export_message("Gespeichert"); + } + Err(err) => { + ui_state.set_export_message(format!( + "Speicherfehler: {}", + err.messages.join(", ") + )); + } + } update_window_title( &window, ¬ebook, @@ -903,6 +923,7 @@ fn main() -> Result<(), Box> { canvas.sync_page(notebook.current_page_mut()); manifest.current_page_index = notebook.current_page_index; manifest.next_page_id = notebook.next_page_id; + autosave.mark_manifest_dirty(&manifest); autosave.mark_page_dirty(notebook.current_page(), &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); @@ -915,6 +936,7 @@ fn main() -> Result<(), Box> { canvas.sync_page(notebook.current_page_mut()); manifest.current_page_index = notebook.current_page_index; manifest.next_page_id = notebook.next_page_id; + autosave.mark_manifest_dirty(&manifest); autosave.mark_page_dirty(notebook.current_page(), &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); @@ -928,6 +950,7 @@ fn main() -> Result<(), Box> { let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); canvas.sync_page(notebook.current_page_mut()); manifest.current_page_index = notebook.current_page_index; + autosave.mark_manifest_dirty(&manifest); autosave.mark_page_dirty(notebook.current_page(), &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); @@ -941,6 +964,7 @@ fn main() -> Result<(), Box> { let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); canvas.sync_page(notebook.current_page_mut()); manifest.current_page_index = notebook.current_page_index; + autosave.mark_manifest_dirty(&manifest); autosave.mark_page_dirty(notebook.current_page(), &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); @@ -954,6 +978,7 @@ fn main() -> Result<(), Box> { let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); canvas.sync_page(notebook.current_page_mut()); manifest.current_page_index = notebook.current_page_index; + autosave.mark_manifest_dirty(&manifest); autosave.mark_page_dirty(notebook.current_page(), &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); @@ -994,8 +1019,16 @@ fn main() -> Result<(), Box> { // Beenden: Strg + Q Key::Character(c) if c.eq_ignore_ascii_case("q") && ctrl => { println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte..."); - autosave.shutdown(); - target.exit(); + match autosave.shutdown() { + Ok(()) => { + target.exit(); + } + Err(err) => { + eprintln!("[Shutdown-Fehler] Konnte nicht geordnet beenden: {err}"); + ui_state.shutdown_error = Some(err); + window.request_redraw(); + } + } } @@ -1863,6 +1896,7 @@ fn main() -> Result<(), Box> { let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); canvas.sync_page(notebook.current_page_mut()); manifest.current_page_index = notebook.current_page_index; + autosave.mark_manifest_dirty(&manifest); autosave.mark_page_dirty(notebook.current_page(), &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); @@ -1875,6 +1909,15 @@ fn main() -> Result<(), Box> { canvas.sync_page(notebook.current_page_mut()); manifest.current_page_index = notebook.current_page_index; manifest.next_page_id = notebook.next_page_id; + manifest.pages = notebook + .pages + .iter() + .map(|p| storage::PageMeta { + id: p.id, + background: Some(p.background), + }) + .collect(); + autosave.mark_manifest_dirty(&manifest); autosave.mark_page_dirty(notebook.current_page(), &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); @@ -1885,7 +1928,16 @@ fn main() -> Result<(), Box> { notebook.add_page(bg); manifest.current_page_index = notebook.current_page_index; manifest.next_page_id = notebook.next_page_id; + manifest.pages = notebook + .pages + .iter() + .map(|p| storage::PageMeta { + id: p.id, + background: Some(p.background), + }) + .collect(); canvas.sync_page(notebook.current_page_mut()); + autosave.mark_manifest_dirty(&manifest); autosave.mark_page_dirty(notebook.current_page(), &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); @@ -1897,6 +1949,7 @@ fn main() -> Result<(), Box> { let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, idx); canvas.sync_page(notebook.current_page_mut()); manifest.current_page_index = idx; + autosave.mark_manifest_dirty(&manifest); autosave.mark_page_dirty(notebook.current_page(), &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); @@ -1980,7 +2033,7 @@ fn main() -> Result<(), Box> { } } UiAction::CreateNotebook(title) => { - autosave.flush(); + let _ = autosave.flush(); match storage::create_new_notebook(&storage_base_dir, &title) { Ok((new_nb, new_manifest, new_dir)) => { autosave.switch_notebook_dir(new_dir.clone()); @@ -2010,6 +2063,7 @@ fn main() -> Result<(), Box> { Ok(()) => { ui_state.document_title = manifest.title.clone(); ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + autosave.mark_manifest_dirty(&manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); ui_state.set_export_message(format!("Umbenannt in '{}'", new_title)); window.request_redraw(); @@ -2114,6 +2168,43 @@ fn main() -> Result<(), Box> { autosave.mark_page_dirty(notebook.current_page(), &manifest); window.request_redraw(); } + UiAction::RetryShutdown => { + println!("[Shutdown] Wiederhole Speichern vor Beenden..."); + match autosave.shutdown() { + Ok(()) => { + ui_state.shutdown_error = None; + target.exit(); + } + Err(err) => { + ui_state.shutdown_error = Some(err); + window.request_redraw(); + } + } + } + UiAction::ExportRecoveryAndExit => { + println!("[Shutdown] Starte Notfall-Export aller ungespeicherten Daten..."); + match autosave.export_recovery(Some(¬ebook)) { + Ok(paths) => { + eprintln!( + "[Recovery] Notfall-Export erfolgreich geschrieben ({} Dateien): {:?}", + paths.len(), + paths + ); + target.exit(); + } + Err(err) => { + eprintln!("[Recovery-Fehler] Konnte Notfall-Export nicht schreiben: {err}"); + if let Some(ref mut se) = ui_state.shutdown_error { + se.messages.push(format!("Notfall-Export fehlgeschlagen: {err}")); + } + window.request_redraw(); + } + } + } + UiAction::ForceExit => { + println!("[Shutdown] Beenden erzwungen trotz ungespeicherter Änderungen."); + target.exit(); + } } } diff --git a/src/storage.rs b/src/storage.rs index d6d9a2b..e518473 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -785,7 +785,8 @@ pub fn delete_notebook(notebook_dir: &Path) -> Result<(), StorageError> { mod tests { use super::*; use crate::autosave::AutosaveController; - use std::time::Duration; + use std::sync::Arc; + use std::time::{Duration, Instant}; #[test] fn test_atomic_save_and_reload() { @@ -895,16 +896,28 @@ mod tests { #[test] fn test_debounce_timing() { + use crate::autosave::tests::MockClock; + let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen"); let notebook_dir = temp_dir.path().to_path_buf(); + let clock = Arc::new(MockClock::new(Instant::now())); let debounce_duration = Duration::from_millis(150); - let mut controller = AutosaveController::new(notebook_dir.clone(), debounce_duration); + let max_wait = Duration::from_secs(10); + let store = Arc::new(crate::autosave::DiskPageStore); + + let mut controller = AutosaveController::with_options( + notebook_dir.clone(), + debounce_duration, + max_wait, + store, + Arc::clone(&clock) as Arc, + ); let manifest = NotebookManifest::new("test_nb", "Test Notizbuch"); let mut page = Page::new(1, PageBackground::Grid5mm); - // 10 schnelle aufeinanderfolgende Änderungen (alle 5 ms, deutlich kürzer als 150 ms Debounce) + // 10 schnelle aufeinanderfolgende Änderungen (alle 5 ms mit MockClock, keine Wartezeit) for i in 0..10 { let sample = StylusSample { position: glam::Vec2::new(10.0 + i as f32, 20.0 + i as f32), @@ -920,23 +933,24 @@ mod tests { page.add_stroke(stroke); controller.mark_page_dirty(&page, &manifest); + clock.advance(Duration::from_millis(5)); controller.update(); - std::thread::sleep(Duration::from_millis(5)); } - // Während der schnellen Änderungen (ca. 50 ms vergangen) darf noch KEIN Schreibvorgang erfolgt sein! + // Während der schnellen Änderungen darf noch KEIN Schreibvorgang erfolgt sein! assert_eq!( controller.total_page_writes(), 0, "Vor Ablauf des Debounce-Fensters darf noch kein Disk-Write stattgefunden haben" ); - // Jetzt warten wir, bis das Debounce-Fenster abgelaufen ist (> 150 ms) - std::thread::sleep(Duration::from_millis(180)); + // Jetzt drehen wir die Mock-Uhr um 180 ms vor (> 150 ms Debounce) + clock.advance(Duration::from_millis(180)); controller.update(); // Flush abwarten - controller.flush(); + let flush_res = controller.flush(); + assert!(flush_res.is_ok(), "Flush muss erfolgreich sein"); // Es darf genau EIN tatsächlicher Schreibvorgang für die Seite stattgefunden haben assert_eq!( @@ -949,7 +963,8 @@ mod tests { let loaded = load_page(¬ebook_dir, 1).expect("Seite muss lesbar sein"); assert_eq!(loaded.strokes.len(), 10); - controller.shutdown(); + let shutdown_res = controller.shutdown(); + assert!(shutdown_res.is_ok()); } #[test] diff --git a/src/ui.rs b/src/ui.rs index 2d64660..ba813c7 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -125,6 +125,10 @@ pub enum UiAction { }, // Datenintegrität / Quarantäne UnlockCorruptedPage(u64), + // Beenden-Sicherheit bei Fehlschlag + RetryShutdown, + ExportRecoveryAndExit, + ForceExit, } /// Zustand der grafischen Benutzeroberfläche. @@ -164,6 +168,8 @@ pub struct UiState { pub update_modal_rect: Option, /// Steuert, ob Touch-Eingaben mit einem Finger zeichnen oder das Dokument verschieben (Default: false = reine Gesten-Navigation & Zoom) pub touch_draw_enabled: bool, + /// Fehler beim Flushen vor dem Beenden + pub shutdown_error: Option, } impl Default for UiState { @@ -195,6 +201,7 @@ impl Default for UiState { show_updater_config: false, update_modal_rect: None, touch_draw_enabled: false, + shutdown_error: None, } } } @@ -2022,6 +2029,95 @@ pub fn render_ui( }); } + // Notfall-/Sicherheits-Dialog bei Fehlschlag des Speicherns vor dem Beenden + if let Some(err) = &state.shutdown_error { + let mut dismiss = false; + egui::Window::new("⚠️ Ungespeicherte Änderungen") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .frame( + egui::Frame::none() + .fill(egui::Color32::from_rgba_premultiplied(35, 20, 20, 250)) + .rounding(egui::Rounding::same(12.0)) + .stroke(egui::Stroke::new( + 1.5_f32, + egui::Color32::from_rgb(235, 87, 87), + )) + .inner_margin(egui::Margin::symmetric(24.0, 20.0)) + .shadow(egui::epaint::Shadow { + offset: egui::vec2(0.0, 8.0), + blur: 24.0, + spread: 0.0, + color: egui::Color32::from_black_alpha(200), + }), + ) + .show(ctx, |ui| { + ui.set_width(460.0); + ui.horizontal(|ui| { + ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠️"); + ui.label( + egui::RichText::new("Fehler beim Beenden") + .color(egui::Color32::from_rgb(255, 100, 100)) + .size(16.0) + .strong(), + ); + }); + ui.add_space(8.0); + ui.label( + egui::RichText::new( + "Ungespeicherte Änderungen konnten nicht gesichert werden:", + ) + .size(13.0) + .color(egui::Color32::WHITE), + ); + ui.add_space(6.0); + egui::Frame::none() + .fill(egui::Color32::from_black_alpha(100)) + .rounding(egui::Rounding::same(6.0)) + .inner_margin(egui::Margin::same(8.0)) + .show(ui, |ui| { + for msg in &err.messages { + ui.label( + egui::RichText::new(msg) + .size(11.5) + .color(egui::Color32::from_rgb(255, 180, 180)), + ); + } + }); + ui.add_space(8.0); + ui.label( + egui::RichText::new("Trotzdem beenden?") + .size(13.0) + .color(egui::Color32::WHITE), + ); + ui.add_space(12.0); + ui.horizontal_wrapped(|ui| { + if ui.button("Abbrechen").clicked() { + dismiss = true; + } + if ui.button("Wiederholen").clicked() { + actions.push(UiAction::RetryShutdown); + } + if ui.button("Notfall-Export").clicked() { + actions.push(UiAction::ExportRecoveryAndExit); + } + if ui + .button( + egui::RichText::new("Trotzdem beenden") + .color(egui::Color32::from_rgb(255, 100, 100)), + ) + .clicked() + { + actions.push(UiAction::ForceExit); + } + }); + }); + if dismiss { + state.shutdown_error = None; + } + } + actions }