feat(autosave): robuster Autosave mit Max-Wait, Retries, PageStore-Trait und Shutdown-Sicherheit

This commit is contained in:
2026-10-06 17:52:09 +02:00
parent a82402bfce
commit 04666bf389
4 changed files with 1015 additions and 102 deletions
+783 -72
View File
File diff suppressed because it is too large Load Diff
+96 -5
View File
@@ -581,9 +581,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
match event { match event {
WindowEvent::CloseRequested => { WindowEvent::CloseRequested => {
println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte..."); println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte...");
autosave.shutdown(); match autosave.shutdown() {
Ok(()) => {
target.exit(); target.exit();
} }
Err(err) => {
eprintln!("[Shutdown-Fehler] Konnte nicht geordnet beenden: {err}");
ui_state.shutdown_error = Some(err);
window.request_redraw();
}
}
}
WindowEvent::Focused(false) => { WindowEvent::Focused(false) => {
// Bei Fokusverlust Strich abbrechen und 1€-Filter resetten // Bei Fokusverlust Strich abbrechen und 1€-Filter resetten
@@ -651,7 +659,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
match &logical_key { match &logical_key {
Key::Named(NamedKey::Escape) => { Key::Named(NamedKey::Escape) => {
let mut changed = false; 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.is_highlighter_menu_open
|| ui_state.show_page_jump_popup || ui_state.show_page_jump_popup
|| ui_state.is_document_menu_open || ui_state.is_document_menu_open
@@ -745,8 +756,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Jetzt speichern (Disk Flush): Strg + S // Jetzt speichern (Disk Flush): Strg + S
Key::Character(c) if c.eq_ignore_ascii_case("s") && ctrl => { Key::Character(c) if c.eq_ignore_ascii_case("s") && ctrl => {
autosave.flush(); match autosave.flush() {
Ok(()) => {
ui_state.set_export_message("Gespeichert"); ui_state.set_export_message("Gespeichert");
}
Err(err) => {
ui_state.set_export_message(format!(
"Speicherfehler: {}",
err.messages.join(", ")
));
}
}
update_window_title( update_window_title(
&window, &window,
&notebook, &notebook,
@@ -903,6 +923,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
manifest.next_page_id = notebook.next_page_id; manifest.next_page_id = notebook.next_page_id;
autosave.mark_manifest_dirty(&manifest);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_page_dirty(notebook.current_page(), &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
@@ -915,6 +936,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
manifest.next_page_id = notebook.next_page_id; manifest.next_page_id = notebook.next_page_id;
autosave.mark_manifest_dirty(&manifest);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_page_dirty(notebook.current_page(), &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
@@ -928,6 +950,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
autosave.mark_manifest_dirty(&manifest);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_page_dirty(notebook.current_page(), &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
@@ -941,6 +964,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
autosave.mark_manifest_dirty(&manifest);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_page_dirty(notebook.current_page(), &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
@@ -954,6 +978,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
autosave.mark_manifest_dirty(&manifest);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_page_dirty(notebook.current_page(), &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
@@ -994,9 +1019,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Beenden: Strg + Q // Beenden: Strg + Q
Key::Character(c) if c.eq_ignore_ascii_case("q") && ctrl => { Key::Character(c) if c.eq_ignore_ascii_case("q") && ctrl => {
println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte..."); println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte...");
autosave.shutdown(); match autosave.shutdown() {
Ok(()) => {
target.exit(); 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<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
autosave.mark_manifest_dirty(&manifest);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_page_dirty(notebook.current_page(), &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
@@ -1875,6 +1909,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
manifest.next_page_id = notebook.next_page_id; 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); autosave.mark_page_dirty(notebook.current_page(), &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
@@ -1885,7 +1928,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
notebook.add_page(bg); notebook.add_page(bg);
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
manifest.next_page_id = notebook.next_page_id; 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()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_manifest_dirty(&manifest);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_page_dirty(notebook.current_page(), &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
@@ -1897,6 +1949,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, idx); let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, idx);
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = idx; manifest.current_page_index = idx;
autosave.mark_manifest_dirty(&manifest);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_page_dirty(notebook.current_page(), &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
@@ -1980,7 +2033,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
UiAction::CreateNotebook(title) => { UiAction::CreateNotebook(title) => {
autosave.flush(); let _ = autosave.flush();
match storage::create_new_notebook(&storage_base_dir, &title) { match storage::create_new_notebook(&storage_base_dir, &title) {
Ok((new_nb, new_manifest, new_dir)) => { Ok((new_nb, new_manifest, new_dir)) => {
autosave.switch_notebook_dir(new_dir.clone()); autosave.switch_notebook_dir(new_dir.clone());
@@ -2010,6 +2063,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(()) => { Ok(()) => {
ui_state.document_title = manifest.title.clone(); ui_state.document_title = manifest.title.clone();
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
autosave.mark_manifest_dirty(&manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
ui_state.set_export_message(format!("Umbenannt in '{}'", new_title)); ui_state.set_export_message(format!("Umbenannt in '{}'", new_title));
window.request_redraw(); window.request_redraw();
@@ -2114,6 +2168,43 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_page_dirty(notebook.current_page(), &manifest);
window.request_redraw(); 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(&notebook)) {
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();
}
} }
} }
+24 -9
View File
@@ -785,7 +785,8 @@ pub fn delete_notebook(notebook_dir: &Path) -> Result<(), StorageError> {
mod tests { mod tests {
use super::*; use super::*;
use crate::autosave::AutosaveController; use crate::autosave::AutosaveController;
use std::time::Duration; use std::sync::Arc;
use std::time::{Duration, Instant};
#[test] #[test]
fn test_atomic_save_and_reload() { fn test_atomic_save_and_reload() {
@@ -895,16 +896,28 @@ mod tests {
#[test] #[test]
fn test_debounce_timing() { fn test_debounce_timing() {
use crate::autosave::tests::MockClock;
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen"); let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
let notebook_dir = temp_dir.path().to_path_buf(); 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 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<dyn crate::autosave::Clock>,
);
let manifest = NotebookManifest::new("test_nb", "Test Notizbuch"); let manifest = NotebookManifest::new("test_nb", "Test Notizbuch");
let mut page = Page::new(1, PageBackground::Grid5mm); 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 { for i in 0..10 {
let sample = StylusSample { let sample = StylusSample {
position: glam::Vec2::new(10.0 + i as f32, 20.0 + i as f32), position: glam::Vec2::new(10.0 + i as f32, 20.0 + i as f32),
@@ -920,23 +933,24 @@ mod tests {
page.add_stroke(stroke); page.add_stroke(stroke);
controller.mark_page_dirty(&page, &manifest); controller.mark_page_dirty(&page, &manifest);
clock.advance(Duration::from_millis(5));
controller.update(); 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!( assert_eq!(
controller.total_page_writes(), controller.total_page_writes(),
0, 0,
"Vor Ablauf des Debounce-Fensters darf noch kein Disk-Write stattgefunden haben" "Vor Ablauf des Debounce-Fensters darf noch kein Disk-Write stattgefunden haben"
); );
// Jetzt warten wir, bis das Debounce-Fenster abgelaufen ist (> 150 ms) // Jetzt drehen wir die Mock-Uhr um 180 ms vor (> 150 ms Debounce)
std::thread::sleep(Duration::from_millis(180)); clock.advance(Duration::from_millis(180));
controller.update(); controller.update();
// Flush abwarten // 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 // Es darf genau EIN tatsächlicher Schreibvorgang für die Seite stattgefunden haben
assert_eq!( assert_eq!(
@@ -949,7 +963,8 @@ mod tests {
let loaded = load_page(&notebook_dir, 1).expect("Seite muss lesbar sein"); let loaded = load_page(&notebook_dir, 1).expect("Seite muss lesbar sein");
assert_eq!(loaded.strokes.len(), 10); assert_eq!(loaded.strokes.len(), 10);
controller.shutdown(); let shutdown_res = controller.shutdown();
assert!(shutdown_res.is_ok());
} }
#[test] #[test]
+96
View File
@@ -125,6 +125,10 @@ pub enum UiAction {
}, },
// Datenintegrität / Quarantäne // Datenintegrität / Quarantäne
UnlockCorruptedPage(u64), UnlockCorruptedPage(u64),
// Beenden-Sicherheit bei Fehlschlag
RetryShutdown,
ExportRecoveryAndExit,
ForceExit,
} }
/// Zustand der grafischen Benutzeroberfläche. /// Zustand der grafischen Benutzeroberfläche.
@@ -164,6 +168,8 @@ pub struct UiState {
pub update_modal_rect: Option<egui::Rect>, pub update_modal_rect: Option<egui::Rect>,
/// Steuert, ob Touch-Eingaben mit einem Finger zeichnen oder das Dokument verschieben (Default: false = reine Gesten-Navigation & Zoom) /// Steuert, ob Touch-Eingaben mit einem Finger zeichnen oder das Dokument verschieben (Default: false = reine Gesten-Navigation & Zoom)
pub touch_draw_enabled: bool, pub touch_draw_enabled: bool,
/// Fehler beim Flushen vor dem Beenden
pub shutdown_error: Option<crate::autosave::AutosaveError>,
} }
impl Default for UiState { impl Default for UiState {
@@ -195,6 +201,7 @@ impl Default for UiState {
show_updater_config: false, show_updater_config: false,
update_modal_rect: None, update_modal_rect: None,
touch_draw_enabled: false, 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 actions
} }