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
+796 -85
View File
File diff suppressed because it is too large Load Diff
+99 -8
View File
@@ -581,8 +581,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
// 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,
&notebook,
@@ -903,6 +923,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
@@ -915,6 +936,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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, &notebook, &manifest, ui_state.active_tool, autosave.status());
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);
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, &notebook, &manifest, ui_state.active_tool, autosave.status());
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);
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, &notebook, &manifest, ui_state.active_tool, autosave.status());
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);
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, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
@@ -994,8 +1019,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
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, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
@@ -1875,6 +1909,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
@@ -1885,7 +1928,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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, &notebook, &manifest, ui_state.active_tool, autosave.status());
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);
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, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
@@ -1980,7 +2033,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
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<dyn std::error::Error>> {
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, &notebook, &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<dyn std::error::Error>> {
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(&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 {
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<dyn crate::autosave::Clock>,
);
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(&notebook_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]
+96
View File
@@ -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<egui::Rect>,
/// 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<crate::autosave::AutosaveError>,
}
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
}