feat(autosave): robuster Autosave mit Max-Wait, Retries, PageStore-Trait und Shutdown-Sicherheit
This commit is contained in:
+796
-85
File diff suppressed because it is too large
Load Diff
+99
-8
@@ -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,
|
||||
¬ebook,
|
||||
@@ -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, ¬ebook, &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, ¬ebook, &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, ¬ebook, &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, ¬ebook, &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, ¬ebook, &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, ¬ebook, &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, ¬ebook, &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, ¬ebook, &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, ¬ebook, &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, ¬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<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(¬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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-9
@@ -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(¬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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user