diff --git a/src/app/session.rs b/src/app/session.rs index 9e9e5ecc..e25316d6 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -22,10 +22,17 @@ pub(crate) fn run_session_cli_commands(cli: &Cli) -> anyhow::Result<()> { if outcome.removed_backup { println!(" Removed backup file"); } + if outcome.removed_recovery { + println!(" Removed recovery file"); + } if outcome.removed_lock { println!(" Removed lock file"); } - if !outcome.removed_session && !outcome.removed_backup && !outcome.removed_lock { + if !outcome.removed_session + && !outcome.removed_backup + && !outcome.removed_recovery + && !outcome.removed_lock + { println!(" No session artefacts found"); } return Ok(()); diff --git a/src/backend/wayland/backend/event_loop/session_save.rs b/src/backend/wayland/backend/event_loop/session_save.rs index dc5c25c0..301b96d4 100644 --- a/src/backend/wayland/backend/event_loop/session_save.rs +++ b/src/backend/wayland/backend/event_loop/session_save.rs @@ -10,6 +10,7 @@ use std::time::{Duration, Instant}; const SESSION_SAVE_WARNING_TOAST_MS: u64 = 20_000; const SESSION_SAVE_NOTIFICATION_TIMEOUT_MS: i32 = 15_000; +const AUTOSAVE_ACTIVE_INTERACTION_DEFER_MS: u64 = 500; pub(super) fn persist_session(state: &WaylandState) -> Result<(), anyhow::Error> { let Some(options) = state.session_options() else { @@ -34,7 +35,7 @@ pub(super) fn persist_session(state: &WaylandState) -> Result<(), anyhow::Error> snapshot.as_ref(), snapshot_started.elapsed(), ); - let report = save_snapshot_or_clear(state, options, snapshot)?; + let report = save_snapshot_or_clear(state, options, snapshot, SessionSaveReason::Shutdown)?; log_session_save_result( SessionSaveReason::Shutdown, report.as_ref(), @@ -56,6 +57,12 @@ pub(super) fn autosave_if_due(state: &mut WaylandState, now: Instant) -> Result< let input_dirty = state.input_state.take_session_dirty(); state.session.record_input_dirty(now, input_dirty); + if should_defer_autosave_for_interaction(state) + && defer_pending_autosave_for_interaction(&mut state.session, now, &options) + { + return Ok(()); + } + if !state.session.autosave_due(now, &options) { return Ok(()); } @@ -70,7 +77,7 @@ pub(super) fn autosave_if_due(state: &mut WaylandState, now: Instant) -> Result< snapshot_started.elapsed(), ); - match save_snapshot_or_clear(state, &options, snapshot) { + match save_snapshot_or_clear(state, &options, snapshot, SessionSaveReason::Autosave) { Ok(report) => { log_session_save_result( SessionSaveReason::Autosave, @@ -95,13 +102,14 @@ fn save_snapshot_or_clear( state: &WaylandState, options: &session::SessionOptions, snapshot: Option, + reason: SessionSaveReason, ) -> Result, anyhow::Error> { if should_skip_protected_session_save(state, options) { return Ok(None); } if let Some(snapshot) = snapshot { - return session::save_snapshot_with_report(&snapshot, options); + return save_snapshot_with_reason(&snapshot, options, reason); } if !persistence_enabled(options) { @@ -113,7 +121,57 @@ fn save_snapshot_or_clear( boards: Vec::new(), tool_state: None, }; - session::save_snapshot_with_report(&empty_snapshot, options) + save_snapshot_with_reason(&empty_snapshot, options, reason) +} + +fn save_snapshot_with_reason( + snapshot: &session::SessionSnapshot, + options: &session::SessionOptions, + reason: SessionSaveReason, +) -> Result, anyhow::Error> { + match reason { + SessionSaveReason::Autosave => { + session::save_snapshot_autosave_with_report(snapshot, options) + } + SessionSaveReason::Shutdown => session::save_snapshot_with_report(snapshot, options), + } +} + +fn should_defer_autosave_for_interaction(state: &WaylandState) -> bool { + state.input_state.has_active_pointer_interaction() + || state.toolbar_dragging() + || state.is_move_dragging() + || state.board_panning_active() + || state.zoom_panning_active() + || stylus_tip_down(state) +} + +fn defer_pending_autosave_for_interaction( + session: &mut SessionState, + now: Instant, + options: &session::SessionOptions, +) -> bool { + if session.autosave_timeout(now, options).is_none() { + return false; + } + + let delay = Duration::from_millis(AUTOSAVE_ACTIVE_INTERACTION_DEFER_MS); + session.defer_autosave(now, delay); + log::debug!( + "Deferring autosave for {:?} while pointer/stylus interaction is active", + delay + ); + true +} + +#[cfg(tablet)] +fn stylus_tip_down(state: &WaylandState) -> bool { + state.stylus_tip_down +} + +#[cfg(not(tablet))] +fn stylus_tip_down(_state: &WaylandState) -> bool { + false } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -504,6 +562,101 @@ mod tests { assert!(!state.autosave_due(now + Duration::from_millis(2), &options)); } + #[test] + fn record_autosave_success_without_saved_report_keeps_dirty_state() { + let mut options = session::SessionOptions::new(PathBuf::from("/tmp"), "display-1"); + options.persist_transparent = true; + options.autosave_enabled = true; + options.autosave_idle = Duration::from_millis(1); + options.autosave_interval = Duration::from_millis(1); + + let mut state = SessionState::new(Some(options.clone())); + let now = Instant::now(); + state.record_input_dirty(now, true); + let due_at = now + Duration::from_millis(2); + assert!(state.autosave_due(due_at, &options)); + + record_autosave_success(&mut state, due_at, false); + + assert!(state.autosave_due(due_at, &options)); + } + + #[test] + fn autosave_failure_after_deferral_respects_backoff() { + let mut options = session::SessionOptions::new(PathBuf::from("/tmp"), "display-1"); + options.persist_transparent = true; + options.autosave_enabled = true; + options.autosave_idle = Duration::from_millis(1); + options.autosave_interval = Duration::from_millis(1); + options.autosave_failure_backoff = Duration::from_millis(75); + + let mut state = SessionState::new(Some(options.clone())); + let now = Instant::now(); + state.record_input_dirty(now, true); + let due_at = now + Duration::from_millis(2); + assert!(defer_pending_autosave_for_interaction( + &mut state, due_at, &options + )); + + let after_deferral = due_at + Duration::from_millis(AUTOSAVE_ACTIVE_INTERACTION_DEFER_MS); + assert!(state.autosave_due(after_deferral, &options)); + + assert!(record_autosave_failure( + &mut state, + after_deferral, + &options + )); + + assert!(!state.autosave_due(after_deferral, &options)); + assert_eq!( + state.autosave_timeout(after_deferral, &options), + Some(options.autosave_failure_backoff) + ); + } + + #[test] + fn interaction_deferral_refreshes_existing_autosave_deferral() { + let mut options = session::SessionOptions::new(PathBuf::from("/tmp"), "display-1"); + options.persist_transparent = true; + options.autosave_enabled = true; + options.autosave_idle = Duration::from_millis(1); + options.autosave_interval = Duration::from_millis(1); + + let mut state = SessionState::new(Some(options.clone())); + let now = Instant::now(); + state.record_input_dirty(now, true); + let due_at = now + Duration::from_millis(2); + assert!(state.autosave_due(due_at, &options)); + + let defer_for = Duration::from_millis(AUTOSAVE_ACTIVE_INTERACTION_DEFER_MS); + assert!(defer_pending_autosave_for_interaction( + &mut state, due_at, &options + )); + + let first_deferred_until = due_at + defer_for; + let later_interaction = first_deferred_until - Duration::from_millis(100); + assert_eq!( + state.autosave_timeout(later_interaction, &options), + Some(Duration::from_millis(100)) + ); + + assert!(defer_pending_autosave_for_interaction( + &mut state, + later_interaction, + &options + )); + + assert_eq!( + state.autosave_timeout(later_interaction, &options), + Some(defer_for) + ); + assert!( + !state.autosave_due(first_deferred_until, &options), + "autosave should stay deferred after activity inside the original quiet window" + ); + assert!(state.autosave_due(later_interaction + defer_for, &options)); + } + #[test] fn pending_save_notifications_warns_near_limit_once_per_path() { let mut state = SessionState::new(None); diff --git a/src/backend/wayland/backend/state_init/mod.rs b/src/backend/wayland/backend/state_init/mod.rs index 076eb330..ef33593f 100644 --- a/src/backend/wayland/backend/state_init/mod.rs +++ b/src/backend/wayland/backend/state_init/mod.rs @@ -41,6 +41,7 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul let tablet_manager = tablet::bind_tablet_manager(&setup, &config); let mut input_state = input_state::build_input_state(&config); + input_state.set_session_preflight_options(session_options.clone()); // Set compositor capabilities based on detected Wayland protocols input_state.compositor_capabilities = CompositorCapabilities { diff --git a/src/backend/wayland/session.rs b/src/backend/wayland/session.rs index 36230759..d3c86fa9 100644 --- a/src/backend/wayland/session.rs +++ b/src/backend/wayland/session.rs @@ -17,6 +17,7 @@ pub struct SessionState { last_dirty_at: Option, last_save_at: Option, autosave_retry_at: Option, + autosave_deferred_until: Option, notified_failure: bool, notified_near_limit_paths: HashSet, notified_trimmed_history: bool, @@ -36,6 +37,7 @@ impl SessionState { last_dirty_at: None, last_save_at: None, autosave_retry_at: None, + autosave_deferred_until: None, notified_failure: false, notified_near_limit_paths: HashSet::new(), notified_trimmed_history: false, @@ -82,6 +84,7 @@ impl SessionState { self.last_dirty_at = None; self.last_save_at = Some(now); self.autosave_retry_at = None; + self.autosave_deferred_until = None; self.notified_failure = false; } @@ -90,6 +93,7 @@ impl SessionState { self.dirty_since = None; self.last_dirty_at = None; self.autosave_retry_at = None; + self.autosave_deferred_until = None; } pub fn mark_autosave_failure(&mut self, now: Instant, backoff: Duration) -> bool { @@ -102,6 +106,14 @@ impl SessionState { } } + pub fn defer_autosave(&mut self, now: Instant, delay: Duration) { + let until = now + delay; + self.autosave_deferred_until = Some(match self.autosave_deferred_until { + Some(current) => current.max(until), + None => until, + }); + } + pub fn mark_near_limit_notified(&mut self, path: &Path) -> bool { self.notified_near_limit_paths.insert(path.to_path_buf()) } @@ -145,6 +157,11 @@ impl SessionState { { return false; } + if let Some(deferred_until) = self.autosave_deferred_until + && now < deferred_until + { + return false; + } let Some(last_dirty_at) = self.last_dirty_at else { return false; }; @@ -175,10 +192,13 @@ impl SessionState { } else { periodic_due }; - let next_time = match self.autosave_retry_at { - Some(retry_at) => std::cmp::max(next_due, retry_at), - None => next_due, - }; + let mut next_time = next_due; + if let Some(retry_at) = self.autosave_retry_at { + next_time = next_time.max(retry_at); + } + if let Some(deferred_until) = self.autosave_deferred_until { + next_time = next_time.max(deferred_until); + } Some(next_time.saturating_duration_since(now)) } } @@ -221,6 +241,48 @@ mod tests { ); } + #[test] + fn autosave_deferral_delays_due_without_clearing_dirty() { + let mut options = SessionOptions::new(PathBuf::from("/tmp"), "display"); + options.autosave_enabled = true; + options.persist_transparent = true; + options.autosave_idle = Duration::from_millis(1); + options.autosave_interval = Duration::from_millis(1); + + let mut state = SessionState::new(Some(options.clone())); + let now = Instant::now(); + state.record_input_dirty(now, true); + let due_at = now + Duration::from_millis(2); + assert!(state.autosave_due(due_at, &options)); + + let defer_for = Duration::from_millis(50); + state.defer_autosave(due_at, defer_for); + assert!(!state.autosave_due(due_at, &options)); + assert_eq!(state.autosave_timeout(due_at, &options), Some(defer_for)); + + let later = due_at + defer_for; + assert!(state.autosave_due(later, &options)); + assert_eq!( + state.autosave_timeout(later, &options), + Some(Duration::ZERO) + ); + } + + #[test] + fn mark_saved_clears_autosave_deferral() { + let mut options = SessionOptions::new(PathBuf::from("/tmp"), "display"); + options.autosave_enabled = true; + options.persist_transparent = true; + + let mut state = SessionState::new(Some(options)); + let now = Instant::now(); + state.record_input_dirty(now, true); + state.defer_autosave(now, Duration::from_secs(60)); + state.mark_saved(now); + + assert_eq!(state.autosave_deferred_until, None); + } + #[test] fn protected_session_path_blocks_save_until_session_is_dirty() { let mut options = SessionOptions::new(PathBuf::from("/tmp"), "display"); diff --git a/src/backend/wayland/state/core/output.rs b/src/backend/wayland/state/core/output.rs index 2fe9ccdb..398d47ad 100644 --- a/src/backend/wayland/state/core/output.rs +++ b/src/backend/wayland/state/core/output.rs @@ -185,6 +185,20 @@ impl WaylandState { session::apply_snapshot(&mut self.input_state, *snapshot, &options); } } + session::LoadSnapshotOutcome::LoadedFromRecovery(snapshot) => { + if let Some(options) = self.session_options().cloned() { + debug!( + "Restoring session {} from recovery artifact {}", + context, + options.recovery_file_path().display() + ); + session::apply_snapshot(&mut self.input_state, *snapshot, &options); + self.input_state.set_ui_toast( + UiToastKind::Warning, + "Restored session from recovery file; normal save previously exceeded the size limit.", + ); + } + } session::LoadSnapshotOutcome::Empty => { if let Some(options) = self.session_options() { debug!( diff --git a/src/backend/wayland/state/zoom.rs b/src/backend/wayland/state/zoom.rs index 486f60af..7f4a93ce 100644 --- a/src/backend/wayland/state/zoom.rs +++ b/src/backend/wayland/state/zoom.rs @@ -110,6 +110,10 @@ impl WaylandState { self.apply_zoom_factor(factor, screen_x, screen_y, zoom_in); } + pub(in crate::backend::wayland) fn zoom_panning_active(&self) -> bool { + self.zoom.panning + } + pub(in crate::backend::wayland) fn exit_zoom(&mut self) { if self.zoom.is_engaged() { self.zoom.deactivate(&mut self.input_state); diff --git a/src/draw/frame/core.rs b/src/draw/frame/core.rs index e0f5226e..98fb4529 100644 --- a/src/draw/frame/core.rs +++ b/src/draw/frame/core.rs @@ -95,6 +95,13 @@ impl Frame { || !self.redo_stack.is_empty() } + pub(crate) fn has_persistable_data_after_history_limit(&self, history_limit: usize) -> bool { + !self.shapes.is_empty() + || self.page_name.is_some() + || self.view_offset != (0, 0) + || (history_limit > 0 && (!self.undo_stack.is_empty() || !self.redo_stack.is_empty())) + } + pub fn view_offset(&self) -> (i32, i32) { self.view_offset } diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs index c5a9e823..0771a1e9 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -104,6 +104,7 @@ impl InputState { should_exit: false, needs_redraw: true, session_dirty: false, + session_preflight_options: None, show_help: false, help_overlay_page: 0, help_overlay_search: String::new(), diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs index c3b52133..c146da83 100644 --- a/src/input/state/core/base/state/structs.rs +++ b/src/input/state/core/base/state/structs.rs @@ -31,6 +31,7 @@ use crate::input::{ modifiers::{DragToolBindings, Modifiers}, tool::{EraserMode, PerToolDrawingSettings, Tool}, }; +use crate::session::SessionOptions; use crate::util::Rect; use std::collections::HashMap; use std::path::PathBuf; @@ -121,6 +122,8 @@ pub struct InputState { pub needs_redraw: bool, /// Whether session persistence should capture changes (cleared after autosave check) pub(crate) session_dirty: bool, + /// Runtime session options used to preflight clone-heavy actions before mutation. + pub(crate) session_preflight_options: Option, /// Whether the help overlay is currently visible (toggled with F10) pub show_help: bool, /// Active help overlay page index diff --git a/src/input/state/core/board/pages.rs b/src/input/state/core/board/pages.rs index 7394466e..dca73e9a 100644 --- a/src/input/state/core/board/pages.rs +++ b/src/input/state/core/board/pages.rs @@ -143,6 +143,9 @@ impl InputState { if page_index >= board.pages.page_count() { return false; } + if !self.session_allows_page_duplicate(board_index, page_index) { + return false; + } if is_active_board { self.prepare_active_page_content_change(); } @@ -212,6 +215,11 @@ impl InputState { if page_index >= source.pages.page_count() { return false; } + if copy + && !self.session_allows_page_copy_between_boards(source_board, page_index, target_board) + { + return false; + } let active_board = self.boards.active_index(); let active_involved = source_board == active_board || target_board == active_board; if active_involved { @@ -316,6 +324,10 @@ impl InputState { } pub fn page_duplicate(&mut self) { + let before_page = self.boards.active_page_index(); + if !self.session_allows_page_duplicate(self.boards.active_index(), before_page) { + return; + } self.prepare_active_page_content_change(); self.boards.duplicate_page(); self.finish_active_page_content_change(); diff --git a/src/input/state/core/board/switch.rs b/src/input/state/core/board/switch.rs index e33d0cf4..a4137eb0 100644 --- a/src/input/state/core/board/switch.rs +++ b/src/input/state/core/board/switch.rs @@ -108,6 +108,9 @@ impl InputState { self.set_ui_toast(UiToastKind::Info, "Board limit reached."); return; } + if !self.session_allows_board_duplicate() { + return; + } self.cancel_active_interaction(); let generation_before = self.boards.board_identity_generation(); diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs index 4c4a1de1..7c6429c1 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -13,6 +13,8 @@ pub(crate) mod radial_menu; mod selection; mod selection_actions; mod session; +mod session_preflight; +mod session_preflight_exact; mod tool_controls; mod tour; mod utility; diff --git a/src/input/state/core/session.rs b/src/input/state/core/session.rs index de60a0e2..35e3b2d0 100644 --- a/src/input/state/core/session.rs +++ b/src/input/state/core/session.rs @@ -1,4 +1,4 @@ -use super::base::InputState; +use super::base::{DrawingState, InputState}; impl InputState { /// Marks session data as dirty for autosave tracking. @@ -28,4 +28,130 @@ impl InputState { pub(crate) fn is_session_dirty(&self) -> bool { self.session_dirty } + + /// Returns true while pointer-driven work is in progress and autosave should wait. + #[allow(dead_code)] + pub(crate) fn has_active_pointer_interaction(&self) -> bool { + self.active_drag_button.is_some() + || matches!( + self.state, + DrawingState::Drawing { .. } + | DrawingState::PendingTextClick { .. } + | DrawingState::MovingSelection { .. } + | DrawingState::Selecting { .. } + | DrawingState::ResizingText { .. } + | DrawingState::ResizingSelection { .. } + ) + || self.board_picker_is_dragging() + || self.board_picker_is_page_dragging() + || self.color_picker_popup_is_dragging() + } +} + +#[cfg(test)] +mod tests { + use crate::draw::frame::ShapeSnapshot; + use crate::draw::{BLACK, Shape}; + use crate::input::state::core::board_picker::{BoardPickerDrag, BoardPickerPageDrag}; + use crate::input::state::test_support::make_test_input_state; + use crate::input::{DrawingState, MouseButton, SelectionHandle, Tool}; + use crate::util::Rect; + use std::sync::Arc; + + #[test] + fn active_pointer_interaction_tracks_drag_button() { + let mut state = make_test_input_state(); + assert!(!state.has_active_pointer_interaction()); + + state.begin_pointer_drag(MouseButton::Left, None); + assert!(state.has_active_pointer_interaction()); + + state.end_pointer_drag(); + assert!(!state.has_active_pointer_interaction()); + } + + #[test] + fn active_pointer_interaction_covers_drawing_states() { + let states = vec![ + DrawingState::Drawing { + tool: Tool::Pen, + start_x: 10, + start_y: 20, + points: vec![(10, 20)], + point_thicknesses: vec![2.0], + }, + DrawingState::PendingTextClick { + x: 10, + y: 20, + tool: Tool::Pen, + shape_id: 1, + }, + DrawingState::MovingSelection { + last_x: 10, + last_y: 20, + snapshots: Vec::new(), + moved: false, + }, + DrawingState::Selecting { + start_x: 10, + start_y: 20, + additive: false, + }, + DrawingState::ResizingText { + shape_id: 1, + snapshot: test_shape_snapshot(), + base_x: 10, + size: 12.0, + }, + DrawingState::ResizingSelection { + handle: SelectionHandle::BottomRight, + original_bounds: Rect::new(0, 0, 20, 20).expect("valid rect"), + start_x: 10, + start_y: 20, + snapshots: Arc::new(Vec::new()), + }, + ]; + + for drawing_state in states { + let mut state = make_test_input_state(); + state.state = drawing_state; + assert!(state.has_active_pointer_interaction()); + } + } + + #[test] + fn active_pointer_interaction_covers_picker_drags() { + let mut state = make_test_input_state(); + state.board_picker_drag = Some(BoardPickerDrag { + source_row: 0, + source_board: 0, + current_row: 0, + }); + assert!(state.has_active_pointer_interaction()); + + let mut state = make_test_input_state(); + state.board_picker_page_drag = Some(BoardPickerPageDrag { + source_index: 0, + current_index: 0, + board_index: 0, + target_board: Some(0), + }); + assert!(state.has_active_pointer_interaction()); + + let mut state = make_test_input_state(); + state.open_color_picker_popup(); + state.color_picker_popup_set_dragging(true); + assert!(state.has_active_pointer_interaction()); + } + + fn test_shape_snapshot() -> ShapeSnapshot { + ShapeSnapshot { + shape: Shape::Freehand { + points: vec![(0, 0), (1, 1)], + color: BLACK, + thick: 1.0, + }, + locked: false, + } + } } diff --git a/src/input/state/core/session_preflight.rs b/src/input/state/core/session_preflight.rs new file mode 100644 index 00000000..5459a848 --- /dev/null +++ b/src/input/state/core/session_preflight.rs @@ -0,0 +1,500 @@ +use super::base::{InputState, UiToastKind}; +use super::session_preflight_exact::{ + ClonePreflightAction, duplicate_board_id_for_preflight, exact_visible_save_allows, +}; +use crate::draw::{BoardPages, Frame, Shape}; +use crate::input::boards::BoardState; +use crate::session::{CompressionMode, DEFAULT_MAX_EXPANDED_SESSION_BYTES, SessionOptions}; + +const SESSION_RAW_OVERHEAD_BYTES: u64 = 256; +const BOARD_RAW_OVERHEAD_BYTES: u64 = 96; +const PAGE_RAW_OVERHEAD_BYTES: u64 = 64; +const IMAGE_SHAPE_RAW_OVERHEAD_BYTES: u64 = 1024; +const NON_IMAGE_SHAPE_RAW_OVERHEAD_BYTES: u64 = 2048; +const TOOL_STATE_RAW_OVERHEAD_BYTES: u64 = 512; +const COMPRESSED_IMAGE_OVERHEAD_BYTES: u64 = 128; +const COMPRESSED_IMAGE_PERCENT_OVERHEAD: u64 = 105; +const POINT_JSON_BYTES: u64 = 128; +const PRESSURE_POINT_JSON_BYTES: u64 = 192; +const VISIBLE_HISTORY_LIMIT: usize = 0; + +#[derive(Debug, Clone, Copy, Default)] +struct CloneStorageEstimate { + raw_bytes: u64, + image_encoded_raw_bytes: u64, + image_original_bytes: u64, + image_count: u64, + non_image_shape_count: u64, +} + +impl CloneStorageEstimate { + fn add_raw(&mut self, bytes: u64) { + self.raw_bytes = self.raw_bytes.saturating_add(bytes); + } + + fn add_image(&mut self, bytes: usize, mime_type: &str) { + let image_bytes = usize_to_u64(bytes); + let encoded_bytes = base64_encoded_len(image_bytes); + let raw_bytes = IMAGE_SHAPE_RAW_OVERHEAD_BYTES + .saturating_add(escaped_json_string_len(mime_type)) + .saturating_add(encoded_bytes); + self.add_raw(raw_bytes); + self.image_encoded_raw_bytes = self.image_encoded_raw_bytes.saturating_add(encoded_bytes); + self.image_original_bytes = self.image_original_bytes.saturating_add(image_bytes); + self.image_count = self.image_count.saturating_add(1); + } + + fn add_non_image_shape(&mut self, bytes: u64) { + self.add_raw(bytes); + self.non_image_shape_count = self.non_image_shape_count.saturating_add(1); + } + + fn saturating_add(self, other: Self) -> Self { + Self { + raw_bytes: self.raw_bytes.saturating_add(other.raw_bytes), + image_encoded_raw_bytes: self + .image_encoded_raw_bytes + .saturating_add(other.image_encoded_raw_bytes), + image_original_bytes: self + .image_original_bytes + .saturating_add(other.image_original_bytes), + image_count: self.image_count.saturating_add(other.image_count), + non_image_shape_count: self + .non_image_shape_count + .saturating_add(other.non_image_shape_count), + } + } + + fn estimated_written_bytes(&self, options: &SessionOptions) -> u64 { + match options.compression { + CompressionMode::Off => self.raw_bytes, + CompressionMode::Auto if self.raw_bytes < options.auto_compress_threshold_bytes => { + self.raw_bytes + } + CompressionMode::Auto | CompressionMode::On => { + let non_image_raw = self.raw_bytes.saturating_sub(self.image_encoded_raw_bytes); + self.image_original_bytes + .saturating_mul(COMPRESSED_IMAGE_PERCENT_OVERHEAD) + .div_ceil(100) + .saturating_add( + self.image_count + .saturating_mul(COMPRESSED_IMAGE_OVERHEAD_BYTES), + ) + .saturating_add(non_image_raw) + } + } + } + + fn would_be_compressed(&self, options: &SessionOptions) -> bool { + match options.compression { + CompressionMode::Off => false, + CompressionMode::On => true, + CompressionMode::Auto => self.raw_bytes >= options.auto_compress_threshold_bytes, + } + } + + fn is_near_limit(&self, options: &SessionOptions) -> bool { + let written = self.estimated_written_bytes(options); + if written == 0 { + return false; + } + let threshold = ((options.max_file_size_bytes as u128) * 90).div_ceil(100); + (written as u128) >= threshold + } +} + +impl InputState { + #[allow(dead_code)] + pub(crate) fn set_session_preflight_options(&mut self, options: Option) { + self.session_preflight_options = options; + } + pub(crate) fn session_allows_page_duplicate( + &mut self, + board_index: usize, + page_index: usize, + ) -> bool { + let Some(options) = self.session_preflight_options.as_ref() else { + return true; + }; + if !session_persistence_enabled(options) { + return true; + } + let Some(board) = self.boards.board_states().get(board_index) else { + return true; + }; + if !board_should_persist_for_session(board, options) { + return true; + } + let Some(page) = board.pages.pages().get(page_index) else { + return true; + }; + let mut added = estimate_frame_page_storage(page); + if !board_pages_have_persistable_data(&board.pages, VISIBLE_HISTORY_LIMIT) { + added = added.saturating_add(estimate_board_shell_storage_for_active( + &board.spec.id, + page_index.saturating_add(1), + )); + added = added.saturating_add(estimate_pages_storage(&board.pages)); + } + self.session_allows_clone_heavy_storage( + added, + ClonePreflightAction::PageDuplicate { + board_index, + page_index, + }, + "Page", + "duplicate", + ) + } + pub(crate) fn session_allows_page_copy_between_boards( + &mut self, + source_board_index: usize, + page_index: usize, + target_board_index: usize, + ) -> bool { + if source_board_index == target_board_index { + return true; + } + let Some(options) = self.session_preflight_options.as_ref() else { + return true; + }; + if !session_persistence_enabled(options) { + return true; + } + let Some(source_board) = self.boards.board_states().get(source_board_index) else { + return true; + }; + let Some(target_board) = self.boards.board_states().get(target_board_index) else { + return true; + }; + if !board_should_persist_for_session(target_board, options) { + return true; + } + let Some(page) = source_board.pages.pages().get(page_index) else { + return true; + }; + + let mut added = estimate_frame_page_storage(page); + if !board_pages_have_persistable_data(&target_board.pages, VISIBLE_HISTORY_LIMIT) { + added = added.saturating_add(estimate_board_shell_storage( + &target_board.spec.id, + &target_board.pages, + )); + added = added.saturating_add(estimate_pages_storage(&target_board.pages)); + } + self.session_allows_clone_heavy_storage( + added, + ClonePreflightAction::PageCopy { + source_board_index, + page_index, + target_board_index, + }, + "Page", + "copy", + ) + } + pub(crate) fn session_allows_board_duplicate(&mut self) -> bool { + let Some(options) = self.session_preflight_options.as_ref() else { + return true; + }; + if !session_persistence_enabled(options) { + return true; + } + let source_id = self.boards.active_board().spec.id.clone(); + if !board_should_persist_for_session(self.boards.active_board(), options) { + return true; + } + if !board_pages_have_persistable_data( + &self.boards.active_board().pages, + VISIBLE_HISTORY_LIMIT, + ) { + return true; + } + let duplicate_id = duplicate_board_id_for_preflight(self, &source_id); + let added = estimate_board_storage(&duplicate_id, self.boards.active_board()); + self.session_allows_clone_heavy_storage( + added, + ClonePreflightAction::BoardDuplicate, + "Board", + "duplicate", + ) + } + + fn session_allows_clone_heavy_storage( + &mut self, + added: CloneStorageEstimate, + action: ClonePreflightAction, + action_label: &str, + action_name: &str, + ) -> bool { + let Some(options) = self.session_preflight_options.clone() else { + return true; + }; + let current = estimate_visible_session_storage(self, &options); + let projected = current.saturating_add(added); + let projected_written = projected.estimated_written_bytes(&options); + let near_limit = projected.is_near_limit(&options); + + if projected.image_count == 0 + || near_limit + || (projected.non_image_shape_count > 0 + && projected_written > options.max_file_size_bytes) + { + match exact_visible_save_allows(self, &options, action) { + Some(true) => return true, + Some(false) => { + return self.block_clone_heavy_action( + action_label, + action_name, + "session would exceed save limits.".to_string(), + ); + } + None => { + return self.block_clone_heavy_action( + action_label, + action_name, + "session size check failed.".to_string(), + ); + } + } + } + + if projected_written > options.max_file_size_bytes { + let limit = format_session_limit(options.max_file_size_bytes); + log::warn!( + "Blocking {} {} because estimated visible image-heavy session payload would exceed configured cap: projected_written={} current_written={} added_written={} projected_raw={} images={} image_bytes={} max={}", + action_label.to_lowercase(), + action_name, + projected_written, + current.estimated_written_bytes(&options), + added.estimated_written_bytes(&options), + projected.raw_bytes, + projected.image_count, + projected.image_original_bytes, + options.max_file_size_bytes + ); + return self.block_clone_heavy_action( + action_label, + action_name, + format!( + "session would exceed {limit}. Remove images or raise session.max_file_size_mb." + ), + ); + } + + if exceeds_expanded_safety_limit(&projected, &options, DEFAULT_MAX_EXPANDED_SESSION_BYTES) { + let limit = format_session_limit(DEFAULT_MAX_EXPANDED_SESSION_BYTES); + log::warn!( + "Blocking {} {} because estimated visible image-heavy session payload would exceed expanded safety cap: projected_raw={} current_raw={} added_raw={} projected_written={} compression={:?} images={} image_bytes={} max_expanded={}", + action_label.to_lowercase(), + action_name, + projected.raw_bytes, + current.raw_bytes, + added.raw_bytes, + projected_written, + options.compression, + projected.image_count, + projected.image_original_bytes, + DEFAULT_MAX_EXPANDED_SESSION_BYTES + ); + return self.block_clone_heavy_action( + action_label, + action_name, + format!("session would exceed the {limit} safety limit. Remove images or reduce duplicated content."), + ); + } + true + } + + fn block_clone_heavy_action( + &mut self, + action_label: &str, + action_name: &str, + reason: String, + ) -> bool { + self.set_ui_toast( + UiToastKind::Warning, + format!("{action_label} {action_name} blocked; {reason}"), + ); + self.trigger_blocked_feedback(); + false + } +} + +fn session_persistence_enabled(options: &SessionOptions) -> bool { + options.any_enabled() || options.restore_tool_state || options.persist_history +} + +fn exceeds_expanded_safety_limit( + estimate: &CloneStorageEstimate, + options: &SessionOptions, + max_expanded_size: u64, +) -> bool { + estimate.would_be_compressed(options) && estimate.raw_bytes > max_expanded_size +} + +fn estimate_visible_session_storage( + input: &InputState, + options: &SessionOptions, +) -> CloneStorageEstimate { + let mut estimate = CloneStorageEstimate::default(); + estimate.add_raw(SESSION_RAW_OVERHEAD_BYTES); + if options.restore_tool_state { + estimate.add_raw(TOOL_STATE_RAW_OVERHEAD_BYTES); + } + + for board in input.boards.board_states() { + if !board_should_persist_for_session(board, options) + || !board_pages_have_persistable_data(&board.pages, VISIBLE_HISTORY_LIMIT) + { + continue; + } + estimate = estimate.saturating_add(estimate_board_storage(&board.spec.id, board)); + } + + estimate +} + +fn estimate_board_storage(board_id: &str, board: &BoardState) -> CloneStorageEstimate { + let mut estimate = estimate_board_shell_storage(board_id, &board.pages); + estimate = estimate.saturating_add(estimate_pages_storage(&board.pages)); + estimate +} + +fn estimate_board_shell_storage(board_id: &str, pages: &BoardPages) -> CloneStorageEstimate { + estimate_board_shell_storage_for_active(board_id, pages.active_index()) +} + +fn estimate_board_shell_storage_for_active( + board_id: &str, + active_index: usize, +) -> CloneStorageEstimate { + let mut estimate = CloneStorageEstimate::default(); + estimate.add_raw( + BOARD_RAW_OVERHEAD_BYTES + .saturating_add(escaped_json_string_len(board_id)) + .saturating_add(usize_to_u64(active_index)), + ); + estimate +} + +fn estimate_pages_storage(pages: &BoardPages) -> CloneStorageEstimate { + let mut estimate = CloneStorageEstimate::default(); + for page in pages.pages() { + estimate = estimate.saturating_add(estimate_frame_page_storage(page)); + } + estimate +} + +fn estimate_frame_page_storage(page: &Frame) -> CloneStorageEstimate { + let mut estimate = CloneStorageEstimate::default(); + estimate.add_raw(PAGE_RAW_OVERHEAD_BYTES); + if let Some(name) = page.page_name() { + estimate.add_raw(escaped_json_string_len(name)); + } + let offset = page.view_offset(); + if offset != (0, 0) { + estimate.add_raw(32); + } + for shape in page.shapes.iter().map(|drawn| &drawn.shape) { + estimate = estimate.saturating_add(estimate_shape_storage(shape)); + } + estimate +} + +fn estimate_shape_storage(shape: &Shape) -> CloneStorageEstimate { + let mut estimate = CloneStorageEstimate::default(); + match shape { + Shape::Freehand { points, .. } + | Shape::MarkerStroke { points, .. } + | Shape::EraserStroke { points, .. } => { + estimate.add_non_image_shape( + NON_IMAGE_SHAPE_RAW_OVERHEAD_BYTES + .saturating_add(usize_to_u64(points.len()).saturating_mul(POINT_JSON_BYTES)), + ); + } + Shape::FreehandPressure { points, .. } => { + estimate.add_non_image_shape(NON_IMAGE_SHAPE_RAW_OVERHEAD_BYTES.saturating_add( + usize_to_u64(points.len()).saturating_mul(PRESSURE_POINT_JSON_BYTES), + )); + } + Shape::Image { data, .. } => estimate.add_image(data.bytes.len(), &data.mime_type), + Shape::Text { + text, + font_descriptor, + .. + } + | Shape::StickyNote { + text, + font_descriptor, + .. + } => { + estimate.add_non_image_shape( + NON_IMAGE_SHAPE_RAW_OVERHEAD_BYTES + .saturating_add(escaped_json_string_len(text)) + .saturating_add(escaped_json_string_len(&font_descriptor.family)) + .saturating_add(escaped_json_string_len(&font_descriptor.weight)) + .saturating_add(escaped_json_string_len(&font_descriptor.style)), + ); + } + _ => estimate.add_non_image_shape(NON_IMAGE_SHAPE_RAW_OVERHEAD_BYTES), + } + estimate +} + +pub(super) fn board_should_persist_for_session( + board: &BoardState, + options: &SessionOptions, +) -> bool { + if board.spec.background.is_transparent() { + options.persist_transparent + } else { + (options.persist_whiteboard || options.persist_blackboard) && board.spec.persist + } +} + +fn board_pages_have_persistable_data(pages: &BoardPages, history_limit: usize) -> bool { + pages.page_count() > 1 + || pages.active_index() > 0 + || pages + .pages() + .iter() + .any(|page| page.has_persistable_data_after_history_limit(history_limit)) +} + +fn base64_encoded_len(bytes: u64) -> u64 { + bytes.div_ceil(3).saturating_mul(4) +} + +fn escaped_json_string_len(value: &str) -> u64 { + // Include quotes and conservatively account for JSON escaping. + value + .chars() + .fold(2u64, |len, ch| len.saturating_add(json_char_len(ch))) +} + +fn json_char_len(ch: char) -> u64 { + match ch { + '"' | '\\' => 2, + '\u{08}' | '\u{0C}' | '\n' | '\r' | '\t' => 2, + ch if ch <= '\u{1F}' => 6, + ch => ch.len_utf8() as u64, + } +} + +fn usize_to_u64(value: usize) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) +} + +fn format_session_limit(bytes: u64) -> String { + let mib = bytes as f64 / 1024.0 / 1024.0; + if mib >= 10.0 { + format!("{mib:.0} MiB") + } else { + format!("{mib:.1} MiB") + } +} + +#[cfg(test)] +#[path = "session_preflight_tests.rs"] +mod session_preflight_tests; diff --git a/src/input/state/core/session_preflight_exact.rs b/src/input/state/core/session_preflight_exact.rs new file mode 100644 index 00000000..e17eb19d --- /dev/null +++ b/src/input/state/core/session_preflight_exact.rs @@ -0,0 +1,250 @@ +use super::base::InputState; +use super::session_preflight::board_should_persist_for_session; +use crate::draw::{BoardPages, Frame}; +use crate::session::{self, BoardPagesSnapshot, BoardSnapshot, SessionOptions, SessionSnapshot}; + +#[derive(Debug, Clone, Copy)] +pub(super) enum ClonePreflightAction { + PageDuplicate { + board_index: usize, + page_index: usize, + }, + PageCopy { + source_board_index: usize, + page_index: usize, + target_board_index: usize, + }, + BoardDuplicate, +} + +pub(super) fn exact_visible_save_allows( + input: &InputState, + options: &SessionOptions, + action: ClonePreflightAction, +) -> Option { + let mut snapshot = + session::snapshot_from_input(input, options).unwrap_or_else(|| SessionSnapshot { + active_board_id: input.board_id().to_string(), + boards: Vec::new(), + tool_state: None, + }); + if !apply_action_to_snapshot(input, options, &mut snapshot, action) { + return None; + } + + let visible_can_clear = visible_without_history_is_empty(&snapshot); + match session::estimate_snapshot_save(&snapshot, options) { + Ok(estimate) => { + Some(visible_can_clear || estimate.visible_without_history.limit_exceeded.is_none()) + } + Err(err) => { + log::warn!( + "Session size preflight exact visible-save check failed after conservative estimate reached verification boundary: {err}" + ); + None + } + } +} + +fn apply_action_to_snapshot( + input: &InputState, + options: &SessionOptions, + snapshot: &mut SessionSnapshot, + action: ClonePreflightAction, +) -> bool { + match action { + ClonePreflightAction::PageDuplicate { + board_index, + page_index, + } => duplicate_page_in_snapshot(input, options, snapshot, board_index, page_index), + ClonePreflightAction::PageCopy { + source_board_index, + page_index, + target_board_index, + } => copy_page_between_boards_in_snapshot( + input, + options, + snapshot, + source_board_index, + page_index, + target_board_index, + ), + ClonePreflightAction::BoardDuplicate => duplicate_active_board_in_snapshot(input, snapshot), + } +} + +fn duplicate_page_in_snapshot( + input: &InputState, + options: &SessionOptions, + snapshot: &mut SessionSnapshot, + board_index: usize, + page_index: usize, +) -> bool { + let Some(source_board) = input.boards.board_states().get(board_index) else { + return false; + }; + if !board_should_persist_for_session(source_board, options) { + return false; + } + let Some(cloned_page) = source_board + .pages + .pages() + .get(page_index) + .map(Frame::clone_without_history) + else { + return false; + }; + + if let Some(board) = snapshot + .boards + .iter_mut() + .find(|board| board.id == source_board.spec.id) + { + let insert_at = (page_index + 1).min(board.pages.pages.len()); + board.pages.pages.insert(insert_at, cloned_page); + board.pages.active = insert_at; + return true; + } + + let history_limit = options.effective_history_limit(input.undo_stack_limit); + let mut pages = pages_for_snapshot(&source_board.pages, history_limit); + let insert_at = (page_index + 1).min(pages.len()); + pages.insert(insert_at, cloned_page); + snapshot.boards.push(BoardSnapshot { + id: source_board.spec.id.clone(), + pages: BoardPagesSnapshot { + active: insert_at, + pages, + }, + }); + true +} + +fn copy_page_between_boards_in_snapshot( + input: &InputState, + options: &SessionOptions, + snapshot: &mut SessionSnapshot, + source_board_index: usize, + page_index: usize, + target_board_index: usize, +) -> bool { + if source_board_index == target_board_index { + return false; + } + let Some(source_board) = input.boards.board_states().get(source_board_index) else { + return false; + }; + let Some(target_board) = input.boards.board_states().get(target_board_index) else { + return false; + }; + if !board_should_persist_for_session(target_board, options) { + return false; + } + let Some(cloned_page) = source_board + .pages + .pages() + .get(page_index) + .map(Frame::clone_without_history) + else { + return false; + }; + + if let Some(target_snapshot) = snapshot + .boards + .iter_mut() + .find(|board| board.id == target_board.spec.id) + { + let new_index = target_snapshot.pages.pages.len(); + target_snapshot.pages.pages.push(cloned_page); + target_snapshot.pages.active = new_index; + return true; + } + + let history_limit = options.effective_history_limit(input.undo_stack_limit); + let mut pages = pages_for_snapshot(&target_board.pages, history_limit); + pages.push(cloned_page); + snapshot.boards.push(BoardSnapshot { + id: target_board.spec.id.clone(), + pages: BoardPagesSnapshot { + active: pages.len().saturating_sub(1), + pages, + }, + }); + true +} + +fn duplicate_active_board_in_snapshot(input: &InputState, snapshot: &mut SessionSnapshot) -> bool { + let source_board = input.boards.active_board(); + let Some(source_index) = snapshot + .boards + .iter() + .position(|board| board.id == source_board.spec.id) + else { + return false; + }; + + let mut cloned = BoardSnapshot { + id: duplicate_board_id_for_preflight(input, &source_board.spec.id), + pages: snapshot.boards[source_index].pages.clone(), + }; + cloned.pages.active = source_board.pages.active_index(); + let insert_at = (source_index + 1).min(snapshot.boards.len()); + snapshot.active_board_id = cloned.id.clone(); + snapshot.boards.insert(insert_at, cloned); + true +} + +fn pages_for_snapshot(pages: &BoardPages, history_limit: usize) -> Vec { + let mut cloned_pages = pages.pages().to_vec(); + for page in &mut cloned_pages { + if history_limit == 0 { + page.clamp_history_depth(0); + } else if history_limit < usize::MAX { + page.clamp_history_depth(history_limit); + } + } + cloned_pages +} + +fn visible_without_history_is_empty(snapshot: &SessionSnapshot) -> bool { + snapshot.tool_state.is_none() + && snapshot.boards.iter().all(|board| { + let pages: Vec<_> = board + .pages + .pages + .iter() + .map(Frame::clone_without_history) + .collect(); + board_pages_snapshot_is_empty(&pages, board.pages.active) + }) +} + +fn board_pages_snapshot_is_empty(pages: &[Frame], active: usize) -> bool { + pages.len() <= 1 && active == 0 && pages.iter().all(|page| !page.has_persistable_data()) +} + +pub(super) fn duplicate_board_id_for_preflight(input: &InputState, source_id: &str) -> String { + let base = format!("{source_id}-copy"); + if !input + .boards + .board_states() + .iter() + .any(|board| board.spec.id == base) + { + return base; + } + + let mut suffix = 2; + loop { + let candidate = format!("{base}-{suffix}"); + if !input + .boards + .board_states() + .iter() + .any(|board| board.spec.id == candidate) + { + return candidate; + } + suffix += 1; + } +} diff --git a/src/input/state/core/session_preflight_tests.rs b/src/input/state/core/session_preflight_tests.rs new file mode 100644 index 00000000..d8cada56 --- /dev/null +++ b/src/input/state/core/session_preflight_tests.rs @@ -0,0 +1,52 @@ +use super::*; +use std::path::PathBuf; + +#[test] +fn compressed_estimate_blocks_when_raw_payload_exceeds_expanded_safety_limit() { + let mut options = SessionOptions::new(PathBuf::from("/tmp"), "expanded-preflight"); + options.compression = CompressionMode::On; + options.max_file_size_bytes = u64::MAX; + + let estimate = CloneStorageEstimate { + raw_bytes: 513, + image_encoded_raw_bytes: 512, + image_original_bytes: 384, + image_count: 1, + non_image_shape_count: 0, + }; + + assert!(exceeds_expanded_safety_limit(&estimate, &options, 512)); +} + +#[test] +fn compressed_estimate_counts_non_image_payload_at_raw_size() { + let mut options = SessionOptions::new(PathBuf::from("/tmp"), "compressed-preflight"); + options.compression = CompressionMode::On; + + let estimate = CloneStorageEstimate { + raw_bytes: 1100, + image_encoded_raw_bytes: 400, + image_original_bytes: 300, + image_count: 1, + non_image_shape_count: 1, + }; + + assert_eq!(estimate.estimated_written_bytes(&options), 1143); +} + +#[test] +fn uncompressed_estimate_does_not_apply_expanded_safety_limit() { + let mut options = SessionOptions::new(PathBuf::from("/tmp"), "expanded-preflight"); + options.compression = CompressionMode::Off; + options.max_file_size_bytes = u64::MAX; + + let estimate = CloneStorageEstimate { + raw_bytes: 513, + image_encoded_raw_bytes: 512, + image_original_bytes: 384, + image_count: 1, + non_image_shape_count: 0, + }; + + assert!(!exceeds_expanded_safety_limit(&estimate, &options, 512)); +} diff --git a/src/input/state/tests/boards.rs b/src/input/state/tests/boards.rs index 4699e532..b9492f25 100644 --- a/src/input/state/tests/boards.rs +++ b/src/input/state/tests/boards.rs @@ -1,7 +1,9 @@ use super::*; -use crate::draw::ShapeId; +use crate::draw::{EmbeddedImage, ShapeId}; use crate::input::state::core::board_picker::BoardPickerState; use crate::input::{BOARD_ID_TRANSPARENT, BOARD_ID_WHITEBOARD, BoardManager}; +use crate::session::{CompressionMode, SessionOptions}; +use std::path::PathBuf; fn board_index(state: &InputState, id: &str) -> usize { state @@ -238,6 +240,124 @@ fn duplicate_board_cancels_text_input_through_lifecycle_transition() { assert!(state.needs_redraw); } +#[test] +fn duplicate_board_blocks_when_clone_would_exceed_persisted_session_limit() { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + let before_count = state.boards.board_count(); + add_active_image_shape(&mut state, 2048); + + let mut options = duplicate_preflight_options_base(); + options.max_file_size_bytes = 1024; + state.set_session_preflight_options(Some(options)); + + state.duplicate_board(); + + assert_eq!(state.boards.board_count(), before_count); + assert_eq!(state.board_id(), BOARD_ID_WHITEBOARD); + assert!( + state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Board duplicate blocked")) + ); +} + +#[test] +fn duplicate_board_skips_session_preflight_for_single_empty_page_board() { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + let before_count = state.boards.board_count(); + + let mut options = duplicate_preflight_options_base(); + options.max_file_size_bytes = 1; + state.set_session_preflight_options(Some(options)); + + state.duplicate_board(); + + assert_eq!(state.boards.board_count(), before_count + 1); + assert_ne!(state.board_id(), BOARD_ID_WHITEBOARD); + assert!( + state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Board duplicated")) + ); +} + +#[test] +fn duplicate_board_ignores_history_only_page_when_history_persistence_disabled() { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + let before_count = state.boards.board_count(); + make_active_page_history_only(&mut state); + + let mut options = duplicate_preflight_options_base(); + options.persist_history = false; + options.max_file_size_bytes = 1; + state.set_session_preflight_options(Some(options)); + + state.duplicate_board(); + + assert_eq!(state.boards.board_count(), before_count + 1); + assert_ne!(state.board_id(), BOARD_ID_WHITEBOARD); + assert!( + state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Board duplicated")) + ); +} + +#[test] +fn duplicate_board_ignores_history_only_page_for_visible_save_preflight() { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + let before_count = state.boards.board_count(); + make_active_page_history_only(&mut state); + + let mut options = duplicate_preflight_options_base(); + options.persist_history = true; + options.max_file_size_bytes = 1; + state.set_session_preflight_options(Some(options)); + + state.duplicate_board(); + + assert_eq!(state.boards.board_count(), before_count + 1); + assert_ne!(state.board_id(), BOARD_ID_WHITEBOARD); + assert!( + state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Board duplicated")) + ); +} + +#[test] +fn duplicate_board_preflight_handles_existing_copy_board_when_over_image_limit() { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + state.duplicate_board(); + state.switch_board(BOARD_ID_WHITEBOARD); + let before_count = state.boards.board_count(); + add_active_image_shape(&mut state, 2048); + + let mut options = duplicate_preflight_options_base(); + options.max_file_size_bytes = 1024; + state.set_session_preflight_options(Some(options)); + + state.duplicate_board(); + + assert_eq!(state.boards.board_count(), before_count); + assert_eq!(state.board_id(), BOARD_ID_WHITEBOARD); + assert!( + state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Board duplicate blocked")) + ); +} + #[test] fn duplicate_board_cancels_text_edit_before_cloning_board() { let mut state = create_test_input_state(); @@ -267,6 +387,73 @@ fn duplicate_board_cancels_text_edit_before_cloning_board() { assert_board_text(&state, &duplicated_id, shape_id, "Original"); } +fn add_active_image_shape(state: &mut InputState, bytes: usize) -> ShapeId { + state.boards.active_frame_mut().add_shape(Shape::Image { + x: 10, + y: 20, + w: 120, + h: 90, + data: EmbeddedImage { + mime_type: "image/png".to_string(), + width: 240, + height: 180, + bytes: pseudo_random_bytes(bytes), + }, + }) +} + +fn duplicate_preflight_options_base() -> SessionOptions { + let mut options = SessionOptions::new(PathBuf::from("/tmp"), "board-duplicate-preflight"); + options.persist_transparent = true; + options.persist_whiteboard = true; + options.persist_blackboard = true; + options.persist_history = false; + options.restore_tool_state = false; + options.compression = CompressionMode::Off; + options.max_file_size_bytes = u64::MAX; + options +} + +fn make_active_page_history_only(state: &mut InputState) { + let frame = state.boards.active_frame_mut(); + let id = frame.add_shape(Shape::Line { + x1: 0, + y1: 0, + x2: 20, + y2: 20, + color: Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + thick: 2.0, + }); + let index = frame.find_index(id).expect("shape index"); + let shape = frame.shape(id).expect("shape").clone(); + frame.push_undo_action( + UndoAction::Create { + shapes: vec![(index, shape)], + }, + 100, + ); + frame.undo_last(); + assert!(frame.shapes.is_empty()); + assert_eq!(frame.redo_stack_len(), 1); +} + +fn pseudo_random_bytes(len: usize) -> Vec { + let mut value = 0x2468_ace0_u32; + (0..len) + .map(|_| { + value ^= value << 13; + value ^= value >> 17; + value ^= value << 5; + value as u8 + }) + .collect() +} + #[test] fn create_board_adds_board_queues_config_save_and_emits_toast() { let mut state = create_test_input_state(); diff --git a/src/input/state/tests/mod.rs b/src/input/state/tests/mod.rs index 1b05cbae..e0a38e9b 100644 --- a/src/input/state/tests/mod.rs +++ b/src/input/state/tests/mod.rs @@ -27,6 +27,7 @@ mod pressure_modes; mod properties_panel; mod radial_menu; mod selection; +mod session_preflight; mod step_markers; mod text_edit; mod text_input; diff --git a/src/input/state/tests/pages.rs b/src/input/state/tests/pages.rs index 2a11b588..17e49e4b 100644 --- a/src/input/state/tests/pages.rs +++ b/src/input/state/tests/pages.rs @@ -1,6 +1,10 @@ use super::*; -use crate::draw::{BoardPages, Frame, ShapeId}; -use crate::input::{BOARD_ID_BLACKBOARD, BOARD_ID_WHITEBOARD, BoardBackground}; +use crate::draw::{BoardPages, EmbeddedImage, Frame, ShapeId}; +use crate::input::{ + BOARD_ID_BLACKBOARD, BOARD_ID_TRANSPARENT, BOARD_ID_WHITEBOARD, BoardBackground, +}; +use crate::session::{self, CompressionMode, SessionOptions}; +use std::path::PathBuf; fn board_index(state: &InputState, id: &str) -> usize { state @@ -226,6 +230,231 @@ fn page_duplicate_cancels_text_edit_before_cloning_source_page() { assert_page_text(&state, board, 1, shape_id, "Original"); } +#[test] +fn page_duplicate_blocks_when_clone_would_exceed_persisted_session_limit() { + let mut state = create_test_input_state(); + let board = board_index(&state, BOARD_ID_BLACKBOARD); + state.switch_board(BOARD_ID_BLACKBOARD); + add_active_image_shape(&mut state, 2048); + + let mut options = duplicate_preflight_options_base(); + options.max_file_size_bytes = 1024; + state.set_session_preflight_options(Some(options)); + + state.page_duplicate(); + + assert_eq!(state.boards.board_states()[board].pages.page_count(), 1); + assert!( + state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Page duplicate blocked")) + ); +} + +#[test] +fn page_duplicate_blocks_many_tiny_images_when_real_save_exceeds_limit() { + let image_count = 240; + let image_bytes = 100; + let mut options = duplicate_preflight_options_base(); + options.compression = CompressionMode::Off; + options.max_file_size_bytes = + projected_image_page_duplicate_written_size(image_count, image_bytes, &options) + .saturating_sub(1) as u64; + + let mut state = create_test_input_state(); + let board = board_index(&state, BOARD_ID_BLACKBOARD); + state.switch_board(BOARD_ID_BLACKBOARD); + add_active_image_shapes(&mut state, image_count, image_bytes); + state.set_session_preflight_options(Some(options)); + + state.page_duplicate(); + + assert_eq!(state.boards.board_states()[board].pages.page_count(), 1); + assert!( + state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Page duplicate blocked")) + ); +} + +#[test] +fn page_duplicate_blocks_mixed_image_and_text_when_real_save_exceeds_limit() { + let text_count = 1000; + let mut options = duplicate_preflight_options_base(); + options.compression = CompressionMode::Off; + options.max_file_size_bytes = + projected_mixed_page_duplicate_written_size(text_count, &options).saturating_sub(1) as u64; + + let mut state = create_test_input_state(); + let board = board_index(&state, BOARD_ID_BLACKBOARD); + state.switch_board(BOARD_ID_BLACKBOARD); + add_active_image_shape(&mut state, 0); + add_active_text_shapes(&mut state, text_count, "x"); + state.set_session_preflight_options(Some(options)); + + state.page_duplicate(); + + assert_eq!(state.boards.board_states()[board].pages.page_count(), 1); + assert!( + state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Page duplicate blocked")) + ); +} + +#[test] +fn page_duplicate_preflights_omitted_empty_board_becoming_persisted() { + let mut state = create_test_input_state(); + let board = board_index(&state, BOARD_ID_BLACKBOARD); + state.switch_board(BOARD_ID_BLACKBOARD); + + let mut options = duplicate_preflight_options_base(); + options.max_file_size_bytes = + projected_empty_page_duplicate_written_size(&options).saturating_sub(1) as u64; + state.set_session_preflight_options(Some(options)); + + state.page_duplicate(); + + assert_eq!(state.boards.board_states()[board].pages.page_count(), 1); + assert!( + state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Page duplicate blocked")) + ); +} + +#[test] +fn page_duplicate_allows_compressed_text_when_real_save_fits() { + let mut state = create_test_input_state(); + let board = board_index(&state, BOARD_ID_BLACKBOARD); + state.switch_board(BOARD_ID_BLACKBOARD); + add_active_text_shape(&mut state, &pseudo_random_ascii(4096)); + + let mut options = duplicate_preflight_options_base(); + options.compression = CompressionMode::On; + options.max_file_size_bytes = 7000; + state.set_session_preflight_options(Some(options.clone())); + + state.page_duplicate(); + + assert_eq!(state.boards.board_states()[board].pages.page_count(), 2); + let snapshot = session::snapshot_from_input(&state, &options).expect("snapshot present"); + let estimate = session::estimate_snapshot_save(&snapshot, &options).expect("estimate save"); + assert!(estimate.visible_without_history.limit_exceeded.is_none()); + assert!( + estimate.visible_without_history.written_size <= options.max_file_size_bytes as usize, + "written={} max={}", + estimate.visible_without_history.written_size, + options.max_file_size_bytes + ); +} + +#[test] +fn page_duplicate_blocks_compressed_text_when_real_save_exceeds_limit() { + let mut state = create_test_input_state(); + let board = board_index(&state, BOARD_ID_BLACKBOARD); + state.switch_board(BOARD_ID_BLACKBOARD); + add_active_text_shape(&mut state, &pseudo_random_ascii(4096)); + + let mut options = duplicate_preflight_options_base(); + options.compression = CompressionMode::On; + options.max_file_size_bytes = 512; + state.set_session_preflight_options(Some(options)); + + state.page_duplicate(); + + assert_eq!(state.boards.board_states()[board].pages.page_count(), 1); + assert!( + state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Page duplicate blocked")) + ); +} + +#[test] +fn page_duplicate_blocks_uncompressed_text_when_real_save_exceeds_limit() { + let text = pseudo_random_ascii(4096); + let mut options = duplicate_preflight_options_base(); + options.compression = CompressionMode::Off; + let exact_written = projected_page_duplicate_written_size(&text, &options); + options.max_file_size_bytes = exact_written.saturating_sub(1) as u64; + + let mut state = create_test_input_state(); + let board = board_index(&state, BOARD_ID_BLACKBOARD); + state.switch_board(BOARD_ID_BLACKBOARD); + add_active_text_shape(&mut state, &text); + state.set_session_preflight_options(Some(options)); + + state.page_duplicate(); + + assert_eq!(state.boards.board_states()[board].pages.page_count(), 1); + assert!( + state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Page duplicate blocked")) + ); +} + +#[test] +fn cross_board_page_copy_blocks_when_clone_would_exceed_persisted_session_limit() { + let mut state = create_test_input_state(); + let source = board_index(&state, BOARD_ID_WHITEBOARD); + let target = board_index(&state, BOARD_ID_BLACKBOARD); + state.switch_board(BOARD_ID_WHITEBOARD); + add_active_image_shape(&mut state, 2048); + + let mut options = duplicate_preflight_options_base(); + options.max_file_size_bytes = 1024; + state.set_session_preflight_options(Some(options)); + + assert!(!state.move_page_between_boards_with_activation(source, 0, target, true, false)); + + assert_eq!(state.boards.board_states()[source].pages.page_count(), 1); + assert_eq!(state.boards.board_states()[target].pages.page_count(), 1); + assert!( + state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Page copy blocked")) + ); +} + +#[test] +fn cross_board_page_copy_preflights_when_source_was_not_previously_persisted() { + let mut state = create_test_input_state(); + let source = board_index(&state, BOARD_ID_TRANSPARENT); + let target = board_index(&state, BOARD_ID_BLACKBOARD); + state.switch_board(BOARD_ID_TRANSPARENT); + add_active_image_shape(&mut state, 2048); + + let mut options = SessionOptions::new(PathBuf::from("/tmp"), "copy-preflight"); + options.persist_transparent = false; + options.persist_whiteboard = true; + options.persist_blackboard = true; + options.persist_history = false; + options.restore_tool_state = false; + options.compression = CompressionMode::Off; + options.max_file_size_bytes = 512; + state.set_session_preflight_options(Some(options)); + + assert!(!state.move_page_between_boards_with_activation(source, 0, target, true, false)); + + assert_eq!(state.boards.board_states()[source].pages.page_count(), 1); + assert_eq!(state.boards.board_states()[target].pages.page_count(), 1); + assert!( + state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Page copy blocked")) + ); +} + #[test] fn cross_board_page_copy_cancels_active_source_text_edit_before_cloning() { let mut state = create_test_input_state(); @@ -244,3 +473,121 @@ fn cross_board_page_copy_cancels_active_source_text_edit_before_cloning() { assert_page_text(&state, source, 0, shape_id, "Original"); assert_page_text(&state, target, 1, shape_id, "Original"); } + +fn add_active_image_shape(state: &mut InputState, bytes: usize) -> ShapeId { + state.boards.active_frame_mut().add_shape(Shape::Image { + x: 10, + y: 20, + w: 120, + h: 90, + data: EmbeddedImage { + mime_type: "image/png".to_string(), + width: 240, + height: 180, + bytes: pseudo_random_bytes(bytes), + }, + }) +} + +fn add_active_image_shapes(state: &mut InputState, count: usize, bytes: usize) { + for _ in 0..count { + add_active_image_shape(state, bytes); + } +} + +fn add_active_text_shapes(state: &mut InputState, count: usize, text: &str) { + for _ in 0..count { + add_active_text_shape(state, text); + } +} + +fn duplicate_preflight_options_base() -> SessionOptions { + let mut options = SessionOptions::new(PathBuf::from("/tmp"), "duplicate-preflight"); + options.persist_transparent = true; + options.persist_whiteboard = true; + options.persist_blackboard = true; + options.persist_history = false; + options.restore_tool_state = false; + options.compression = CompressionMode::Off; + options.max_file_size_bytes = u64::MAX; + options +} + +fn pseudo_random_bytes(len: usize) -> Vec { + let mut value = 0x8765_4321_u32; + (0..len) + .map(|_| { + value ^= value << 13; + value ^= value >> 17; + value ^= value << 5; + value as u8 + }) + .collect() +} + +fn pseudo_random_ascii(len: usize) -> String { + let mut value = 0x1357_9bdf_u32; + (0..len) + .map(|_| { + value ^= value << 13; + value ^= value >> 17; + value ^= value << 5; + char::from(33 + (value % 94) as u8) + }) + .collect() +} + +fn projected_page_duplicate_written_size(text: &str, options: &SessionOptions) -> usize { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_BLACKBOARD); + add_active_text_shape(&mut state, text); + state.page_duplicate(); + let snapshot = session::snapshot_from_input(&state, options).expect("snapshot present"); + session::estimate_snapshot_save(&snapshot, options) + .expect("estimate save") + .visible_without_history + .written_size +} + +fn projected_empty_page_duplicate_written_size(options: &SessionOptions) -> usize { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_BLACKBOARD); + state.page_duplicate(); + let snapshot = session::snapshot_from_input(&state, options).expect("snapshot present"); + session::estimate_snapshot_save(&snapshot, options) + .expect("estimate save") + .visible_without_history + .written_size +} + +fn projected_image_page_duplicate_written_size( + image_count: usize, + image_bytes: usize, + options: &SessionOptions, +) -> usize { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_BLACKBOARD); + add_active_image_shapes(&mut state, image_count, image_bytes); + state.page_duplicate(); + let snapshot = session::snapshot_from_input(&state, options).expect("snapshot present"); + session::estimate_snapshot_save(&snapshot, options) + .expect("estimate save") + .visible_without_history + .written_size +} + +fn projected_mixed_page_duplicate_written_size( + text_count: usize, + options: &SessionOptions, +) -> usize { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_BLACKBOARD); + add_active_image_shape(&mut state, 0); + add_active_text_shapes(&mut state, text_count, "x"); + state.page_duplicate(); + let snapshot = session::snapshot_from_input(&state, options).expect("snapshot present"); + session::estimate_snapshot_save(&snapshot, options) + .expect("estimate save") + .visible_without_history + .written_size +} diff --git a/src/input/state/tests/session_preflight.rs b/src/input/state/tests/session_preflight.rs new file mode 100644 index 00000000..805e3791 --- /dev/null +++ b/src/input/state/tests/session_preflight.rs @@ -0,0 +1,160 @@ +use super::*; +use crate::draw::{EmbeddedImage, EraserBrush, ShapeId}; +use crate::input::BOARD_ID_BLACKBOARD; +use crate::session::{self, CompressionMode, SessionOptions}; +use std::path::PathBuf; + +#[derive(Clone, Copy, Debug)] +enum PointShapeKind { + Freehand, + Marker, + Eraser, + Pressure, +} + +fn board_index(state: &InputState, id: &str) -> usize { + state + .boards + .board_states() + .iter() + .position(|board| board.spec.id == id) + .expect("board index") +} + +#[test] +fn page_duplicate_blocks_mixed_image_and_point_strokes_when_real_save_exceeds_limit() { + for kind in [ + PointShapeKind::Freehand, + PointShapeKind::Marker, + PointShapeKind::Eraser, + PointShapeKind::Pressure, + ] { + let points = 10_000; + let mut options = duplicate_preflight_options_base(); + options.max_file_size_bytes = + projected_point_page_duplicate_written_size(kind, points, &options).saturating_sub(1) + as u64; + + let mut state = create_test_input_state(); + let board = board_index(&state, BOARD_ID_BLACKBOARD); + state.switch_board(BOARD_ID_BLACKBOARD); + add_active_image_shape(&mut state); + add_active_point_shape(&mut state, kind, points); + state.set_session_preflight_options(Some(options)); + + state.page_duplicate(); + + assert_eq!( + state.boards.board_states()[board].pages.page_count(), + 1, + "{kind:?}" + ); + assert!( + state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Page duplicate blocked")), + "{kind:?}" + ); + } +} + +fn add_active_image_shape(state: &mut InputState) -> ShapeId { + state.boards.active_frame_mut().add_shape(Shape::Image { + x: 10, + y: 20, + w: 120, + h: 90, + data: EmbeddedImage { + mime_type: "image/png".to_string(), + width: 240, + height: 180, + bytes: Vec::new(), + }, + }) +} + +fn add_active_point_shape(state: &mut InputState, kind: PointShapeKind, points: usize) -> ShapeId { + let color = state.current_color; + let thick = state.current_thickness; + match kind { + PointShapeKind::Freehand => state.boards.active_frame_mut().add_shape(Shape::Freehand { + points: point_path(points), + color, + thick, + }), + PointShapeKind::Marker => state + .boards + .active_frame_mut() + .add_shape(Shape::MarkerStroke { + points: point_path(points), + color, + thick, + }), + PointShapeKind::Eraser => state + .boards + .active_frame_mut() + .add_shape(Shape::EraserStroke { + points: point_path(points), + brush: EraserBrush { + size: thick, + kind: EraserKind::Circle, + }, + }), + PointShapeKind::Pressure => { + state + .boards + .active_frame_mut() + .add_shape(Shape::FreehandPressure { + points: pressure_point_path(points), + color, + }) + } + } +} + +fn duplicate_preflight_options_base() -> SessionOptions { + let mut options = SessionOptions::new(PathBuf::from("/tmp"), "point-preflight"); + options.persist_transparent = true; + options.persist_whiteboard = true; + options.persist_blackboard = true; + options.persist_history = false; + options.restore_tool_state = false; + options.compression = CompressionMode::Off; + options.max_file_size_bytes = u64::MAX; + options +} + +fn point_path(count: usize) -> Vec<(i32, i32)> { + (0..count) + .map(|index| { + let x = i32::try_from(index).unwrap_or(i32::MAX); + (x, x.saturating_mul(13).rem_euclid(997)) + }) + .collect() +} + +fn pressure_point_path(count: usize) -> Vec<(i32, i32, f32)> { + point_path(count) + .into_iter() + .enumerate() + .map(|(index, (x, y))| (x, y, 1.0 + (index % 7) as f32 * 0.25)) + .collect() +} + +fn projected_point_page_duplicate_written_size( + kind: PointShapeKind, + points: usize, + options: &SessionOptions, +) -> usize { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_BLACKBOARD); + add_active_image_shape(&mut state); + add_active_point_shape(&mut state, kind, points); + state.page_duplicate(); + let snapshot = session::snapshot_from_input(&state, options).expect("snapshot present"); + session::estimate_snapshot_save(&snapshot, options) + .expect("estimate save") + .visible_without_history + .written_size +} diff --git a/src/session/mod.rs b/src/session/mod.rs index 9b9c689f..4425ab31 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -20,14 +20,15 @@ pub use snapshot::{ load_snapshot, save_snapshot, snapshot_from_input, }; #[allow(unused_imports)] -pub(crate) use snapshot::{LoadSnapshotOutcome, load_snapshot_with_outcome}; -#[allow(unused_imports)] pub(crate) use snapshot::{ - SaveLimitExceeded, SaveSnapshotOutcome, SaveSnapshotReport, SnapshotPayloadEstimate, - SnapshotSaveEstimate, estimate_snapshot_payload, estimate_snapshot_save, - estimate_snapshot_without_history_payload, save_snapshot_with_report, + DEFAULT_MAX_EXPANDED_SESSION_BYTES, SaveLimitExceeded, SaveSnapshotOutcome, SaveSnapshotReport, + SnapshotPayloadEstimate, SnapshotSaveEstimate, estimate_snapshot_payload, + estimate_snapshot_save, estimate_snapshot_without_history_payload, + save_snapshot_autosave_with_report, save_snapshot_with_report, }; #[allow(unused_imports)] +pub(crate) use snapshot::{LoadSnapshotOutcome, load_snapshot_with_outcome}; +#[allow(unused_imports)] pub use storage::{ClearOutcome, FrameCounts, SessionInspection, clear_session, inspect_session}; #[cfg(test)] diff --git a/src/session/options/types.rs b/src/session/options/types.rs index 10b1344e..f7414e34 100644 --- a/src/session/options/types.rs +++ b/src/session/options/types.rs @@ -96,6 +96,11 @@ impl SessionOptions { .join(format!("{}.json.bak", self.session_file_stem())) } + pub fn recovery_file_path(&self) -> PathBuf { + self.base_dir + .join(format!("{}.json.recovery", self.session_file_stem())) + } + pub fn lock_file_path(&self) -> PathBuf { self.base_dir .join(format!("{}.lock", self.session_file_stem())) diff --git a/src/session/snapshot/compression.rs b/src/session/snapshot/compression.rs index 915bdf34..76b1d48f 100644 --- a/src/session/snapshot/compression.rs +++ b/src/session/snapshot/compression.rs @@ -4,7 +4,7 @@ use std::fmt; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; -pub(super) const DEFAULT_MAX_EXPANDED_SESSION_BYTES: u64 = 128 * 1024 * 1024; +pub(crate) const DEFAULT_MAX_EXPANDED_SESSION_BYTES: u64 = 128 * 1024 * 1024; #[derive(Debug)] pub(super) struct ExpandedSessionTooLarge { diff --git a/src/session/snapshot/load.rs b/src/session/snapshot/load.rs index 86d5f9a3..834c1ed8 100644 --- a/src/session/snapshot/load.rs +++ b/src/session/snapshot/load.rs @@ -30,6 +30,7 @@ pub struct LoadedSnapshot { #[derive(Debug)] pub(crate) enum LoadSnapshotOutcome { Loaded(Box), + LoadedFromRecovery(Box), Empty, ExpandedTooLarge { path: PathBuf, @@ -41,7 +42,8 @@ pub(crate) enum LoadSnapshotOutcome { #[allow(dead_code)] pub fn load_snapshot(options: &SessionOptions) -> Result> { match load_snapshot_with_outcome(options)? { - LoadSnapshotOutcome::Loaded(snapshot) => Ok(Some(*snapshot)), + LoadSnapshotOutcome::Loaded(snapshot) + | LoadSnapshotOutcome::LoadedFromRecovery(snapshot) => Ok(Some(*snapshot)), LoadSnapshotOutcome::Empty | LoadSnapshotOutcome::ExpandedTooLarge { .. } => Ok(None), } } @@ -64,24 +66,70 @@ pub(super) fn load_snapshot_with_expanded_limit( } let session_path = options.session_file_path(); - if !session_path.exists() { + let recovery_path = options.recovery_file_path(); + let session_metadata = fs::metadata(&session_path).ok(); + let recovery_metadata = fs::metadata(&recovery_path).ok(); + + if let Some(recovery_metadata) = recovery_metadata.as_ref() + && should_prefer_recovery(recovery_metadata, session_metadata.as_ref()) + { info!( - "Session file not found at {}; skipping load", + "Loading session recovery artifact {} before normal session {}", + recovery_path.display(), session_path.display() ); - return Ok(LoadSnapshotOutcome::Empty); + let recovery_outcome = load_snapshot_path_with_outcome( + &recovery_path, + options, + max_expanded_size, + false, + "session recovery", + )?; + match recovery_outcome { + LoadSnapshotOutcome::Loaded(snapshot) => { + return Ok(LoadSnapshotOutcome::LoadedFromRecovery(snapshot)); + } + loaded @ LoadSnapshotOutcome::LoadedFromRecovery(_) => return Ok(loaded), + LoadSnapshotOutcome::Empty => { + warn!( + "Session recovery artifact {} did not contain usable session data; falling back to normal session {}", + recovery_path.display(), + session_path.display() + ); + preserve_unloadable_recovery(&recovery_path, "empty"); + } + LoadSnapshotOutcome::ExpandedTooLarge { path, .. } => { + warn!( + "Session recovery artifact {} exceeded the expanded load safety cap; preserving it and falling back to normal session {}", + path.display(), + session_path.display() + ); + preserve_unloadable_recovery(&path, "too-large"); + } + } } - let metadata = fs::metadata(&session_path) + load_normal_session_or_empty(options, &session_path, session_metadata, max_expanded_size) +} + +fn load_snapshot_path_with_outcome( + session_path: &Path, + options: &SessionOptions, + max_expanded_size: u64, + enforce_configured_file_size: bool, + label: &str, +) -> Result { + let metadata = fs::metadata(session_path) .with_context(|| format!("failed to stat session file {}", session_path.display()))?; info!( - "Session file present at {} ({} bytes, per_output={}, output_identity={:?})", + "{} file present at {} ({} bytes, per_output={}, output_identity={:?})", + label, session_path.display(), metadata.len(), options.per_output, options.output_identity() ); - if metadata.len() > options.max_file_size_bytes { + if enforce_configured_file_size && metadata.len() > options.max_file_size_bytes { warn!( "Session file {} is {} bytes which exceeds the configured limit ({} bytes); refusing to load", session_path.display(), @@ -89,6 +137,27 @@ pub(super) fn load_snapshot_with_expanded_limit( options.max_file_size_bytes ); return Ok(LoadSnapshotOutcome::Empty); + } else if !enforce_configured_file_size { + if metadata.len() > max_expanded_size { + warn!( + "Session recovery file {} is {} bytes which exceeds the expanded load safety limit ({} bytes); refusing to read", + session_path.display(), + metadata.len(), + max_expanded_size + ); + return Ok(LoadSnapshotOutcome::ExpandedTooLarge { + path: session_path.to_path_buf(), + max_expanded_size, + }); + } + if metadata.len() > options.max_file_size_bytes { + info!( + "Session recovery file {} is {} bytes, above configured normal session limit {}; loading with expanded safety cap only", + session_path.display(), + metadata.len(), + options.max_file_size_bytes + ); + } } let lock_path = options.lock_file_path(); @@ -102,7 +171,7 @@ pub(super) fn load_snapshot_with_expanded_limit( lock_shared(&lock_file) .with_context(|| format!("failed to acquire shared lock {}", lock_path.display()))?; - let result = load_snapshot_inner_with_expanded_limit(&session_path, options, max_expanded_size); + let result = load_snapshot_inner_with_expanded_limit(session_path, options, max_expanded_size); if let Err(err) = unlock(&lock_file) { warn!( @@ -116,7 +185,8 @@ pub(super) fn load_snapshot_with_expanded_limit( Ok(Some(loaded)) => { let tool_state = loaded.snapshot.tool_state.is_some(); info!( - "Loaded session from {} (version {}, compressed={}, boards={}, active_board={}, tool_state={})", + "Loaded {} from {} (version {}, compressed={}, boards={}, active_board={}, tool_state={})", + label, session_path.display(), loaded.version, loaded.compressed, @@ -128,7 +198,8 @@ pub(super) fn load_snapshot_with_expanded_limit( } Ok(None) => { info!( - "Session file {} contained no usable data; continuing with defaults", + "{} file {} contained no usable data; continuing with defaults", + label, session_path.display() ); Ok(LoadSnapshotOutcome::Empty) @@ -141,17 +212,18 @@ pub(super) fn load_snapshot_with_expanded_limit( err ); Ok(LoadSnapshotOutcome::ExpandedTooLarge { - path: session_path, + path: session_path.to_path_buf(), max_expanded_size, }) } Err(err) => { warn!( - "Failed to load session {}; backing up and continuing with defaults: {}", + "Failed to load {} {}; backing up and continuing with defaults: {}", + label, session_path.display(), err ); - if let Err(backup_err) = backup_corrupt_session(&session_path, options) { + if let Err(backup_err) = backup_corrupt_session(session_path, options) { warn!( "Failed to back up corrupt session {}: {}", session_path.display(), @@ -163,6 +235,78 @@ pub(super) fn load_snapshot_with_expanded_limit( } } +fn load_normal_session_or_empty( + options: &SessionOptions, + session_path: &Path, + session_metadata: Option, + max_expanded_size: u64, +) -> Result { + if session_metadata.is_none() { + info!( + "Session file not found at {}; skipping load", + session_path.display() + ); + return Ok(LoadSnapshotOutcome::Empty); + } + load_snapshot_path_with_outcome(session_path, options, max_expanded_size, true, "session") +} + +fn preserve_unloadable_recovery(path: &Path, reason: &str) { + if !path.exists() { + return; + } + let preserved_path = preserved_recovery_path(path, reason); + match fs::rename(path, &preserved_path) { + Ok(()) => warn!( + "Preserved unloadable session recovery artifact as {}", + preserved_path.display() + ), + Err(err) => warn!( + "Failed to preserve unloadable session recovery artifact {}: {}", + path.display(), + err + ), + } +} + +fn preserved_recovery_path(path: &Path, reason: &str) -> PathBuf { + let base_extension = path + .extension() + .and_then(|extension| extension.to_str()) + .filter(|extension| !extension.is_empty()) + .map_or_else( + || reason.to_string(), + |extension| format!("{extension}.{reason}"), + ); + + for index in 0..100 { + let extension = if index == 0 { + base_extension.clone() + } else { + format!("{base_extension}.{index}") + }; + let candidate = path.with_extension(extension); + if !candidate.exists() { + return candidate; + } + } + + path.with_extension(format!("{base_extension}.100")) +} + +fn should_prefer_recovery( + recovery_metadata: &fs::Metadata, + session_metadata: Option<&fs::Metadata>, +) -> bool { + let Some(session_metadata) = session_metadata else { + return true; + }; + match (recovery_metadata.modified(), session_metadata.modified()) { + (Ok(recovery_modified), Ok(session_modified)) => recovery_modified >= session_modified, + _ => true, + } +} + pub(crate) fn load_snapshot_inner( session_path: &Path, options: &SessionOptions, @@ -365,7 +509,12 @@ fn resolved_active_board_id(requested: Option, boards: &[BoardSnapshot]) fn backup_corrupt_session(session_path: &Path, options: &SessionOptions) -> Result<()> { let bytes = fs::read(session_path) .with_context(|| format!("failed to read corrupt session {}", session_path.display()))?; - let backup_path = options.backup_file_path(); + let primary_path = options.session_file_path(); + let backup_path = if session_path == primary_path.as_path() { + options.backup_file_path() + } else { + session_path.with_extension("recovery.bak") + }; fs::write(&backup_path, &bytes) .with_context(|| format!("failed to write session backup {}", backup_path.display()))?; fs::remove_file(session_path).with_context(|| { diff --git a/src/session/snapshot/mod.rs b/src/session/snapshot/mod.rs index 059a32bc..58a265dd 100644 --- a/src/session/snapshot/mod.rs +++ b/src/session/snapshot/mod.rs @@ -11,13 +11,15 @@ mod tests; pub use apply::apply_snapshot; pub use capture::snapshot_from_input; +pub(crate) use compression::DEFAULT_MAX_EXPANDED_SESSION_BYTES; pub use load::load_snapshot; pub(crate) use load::{LoadSnapshotOutcome, load_snapshot_inner, load_snapshot_with_outcome}; pub use save::save_snapshot; pub(crate) use save::{ SaveLimitExceeded, SaveSnapshotOutcome, SaveSnapshotReport, SnapshotPayloadEstimate, SnapshotSaveEstimate, estimate_snapshot_payload, estimate_snapshot_save, - estimate_snapshot_without_history_payload, save_snapshot_with_report, + estimate_snapshot_without_history_payload, save_snapshot_autosave_with_report, + save_snapshot_with_report, }; #[allow(unused_imports)] pub use types::BoardSnapshot; diff --git a/src/session/snapshot/save.rs b/src/session/snapshot/save.rs index 1ffb7b71..0a6f3519 100644 --- a/src/session/snapshot/save.rs +++ b/src/session/snapshot/save.rs @@ -7,12 +7,22 @@ use crate::session::options::{CompressionMode, SessionOptions}; use crate::time_utils::now_rfc3339; use anyhow::{Context, Result, anyhow}; use log::{debug, info, warn}; +use std::fmt; use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; const NEAR_LIMIT_PERCENT: u64 = 90; +#[allow(dead_code)] +const AUTOSAVE_HISTORY_FALLBACK_DEPTH: usize = 1; + +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HistoryFallbackStrategy { + LargestFitting, + Bounded { max_depth: usize }, +} /// Outcome of a session save after applying configured size fallbacks. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -98,6 +108,29 @@ impl SaveLimitExceeded { } } +#[derive(Debug)] +struct SavePayloadTooLarge { + limit: SaveLimitExceeded, + written_size: usize, + raw_size: usize, + compressed: bool, +} + +impl fmt::Display for SavePayloadTooLarge { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Session data cannot be saved safely ({}; {} bytes written from {} raw bytes, compression={}); skipping save", + self.limit.description(), + self.written_size, + self.raw_size, + self.compressed + ) + } +} + +impl std::error::Error for SavePayloadTooLarge {} + /// Persist the provided snapshot to disk according to the configured options. pub fn save_snapshot(snapshot: &SessionSnapshot, options: &SessionOptions) -> Result<()> { save_snapshot_with_report(snapshot, options).map(|_| ()) @@ -111,6 +144,21 @@ pub(crate) fn save_snapshot_with_report( save_snapshot_with_expanded_limit(snapshot, options, DEFAULT_MAX_EXPANDED_SESSION_BYTES) } +#[allow(dead_code)] +pub(crate) fn save_snapshot_autosave_with_report( + snapshot: &SessionSnapshot, + options: &SessionOptions, +) -> Result> { + save_snapshot_with_expanded_limit_and_strategy( + snapshot, + options, + DEFAULT_MAX_EXPANDED_SESSION_BYTES, + HistoryFallbackStrategy::Bounded { + max_depth: AUTOSAVE_HISTORY_FALLBACK_DEPTH, + }, + ) +} + #[allow(dead_code)] pub(crate) fn estimate_snapshot_save( snapshot: &SessionSnapshot, @@ -184,6 +232,20 @@ pub(super) fn save_snapshot_with_expanded_limit( snapshot: &SessionSnapshot, options: &SessionOptions, max_expanded_size: u64, +) -> Result> { + save_snapshot_with_expanded_limit_and_strategy( + snapshot, + options, + max_expanded_size, + HistoryFallbackStrategy::LargestFitting, + ) +} + +fn save_snapshot_with_expanded_limit_and_strategy( + snapshot: &SessionSnapshot, + options: &SessionOptions, + max_expanded_size: u64, + history_fallback: HistoryFallbackStrategy, ) -> Result> { if !options.any_enabled() && !options.persist_history && snapshot.tool_state.is_none() { debug!("Session persistence disabled for all boards; skipping save"); @@ -215,7 +277,7 @@ pub(super) fn save_snapshot_with_expanded_limit( ); let save_started = Instant::now(); - let result = save_snapshot_inner(snapshot, options, max_expanded_size); + let result = save_snapshot_inner(snapshot, options, max_expanded_size, history_fallback); match &result { Ok(Some(report)) => info!( "Session save pipeline finished for {} in {:?}: outcome={:?}, written={} bytes, raw={} bytes, compression={}", @@ -253,14 +315,20 @@ fn save_snapshot_inner( snapshot: &SessionSnapshot, options: &SessionOptions, max_expanded_size: u64, + history_fallback: HistoryFallbackStrategy, ) -> Result> { let session_path = options.session_file_path(); let backup_path = options.backup_file_path(); let last_modified = now_rfc3339(); let prepare_started = Instant::now(); - let prepared = match payload_within_limit(snapshot, options, &last_modified, max_expanded_size) - { + let prepared = match payload_within_limit( + snapshot, + options, + &last_modified, + max_expanded_size, + history_fallback, + ) { Ok(prepared) => prepared, Err(err) => { if session_path.exists() { @@ -269,6 +337,26 @@ fn save_snapshot_inner( session_path.display() ); } + if err.downcast_ref::().is_some() + && matches!(history_fallback, HistoryFallbackStrategy::LargestFitting) + { + match save_recovery_snapshot(snapshot, options, max_expanded_size, &last_modified) { + Ok(Some(report)) => warn!( + "Wrote oversized session recovery artifact to {} ({} bytes written, raw={} bytes, compression={}, outcome={:?})", + report.path.display(), + report.written_size, + report.raw_size, + report.compressed, + report.outcome + ), + Ok(None) => {} + Err(recovery_err) => warn!( + "Failed to write oversized session recovery artifact {}: {}", + options.recovery_file_path().display(), + recovery_err + ), + } + } return Err(err); } }; @@ -297,6 +385,7 @@ fn save_snapshot_inner( }; if matches!(prepared.outcome, SaveSnapshotOutcome::ClearedEmpty) { remove_session_file(&session_path)?; + remove_recovery_file(options); } log_near_limit(&report); return Ok(Some(report)); @@ -382,6 +471,7 @@ fn save_snapshot_inner( compressed, }; log_near_limit(&report); + remove_recovery_file(options); Ok(Some(report)) } @@ -394,6 +484,122 @@ fn log_near_limit(report: &SaveSnapshotReport) { } } +fn save_recovery_snapshot( + snapshot: &SessionSnapshot, + options: &SessionOptions, + max_expanded_size: u64, + last_modified: &str, +) -> Result> { + let Some((payload, outcome)) = + recovery_payload(snapshot, options, max_expanded_size, last_modified)? + else { + return Ok(None); + }; + + let recovery_path = options.recovery_file_path(); + let tmp_path = temp_path(&recovery_path)?; + let write_started = Instant::now(); + { + let mut tmp_file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp_path) + .with_context(|| { + format!( + "failed to open temporary recovery file {}", + tmp_path.display() + ) + })?; + tmp_file + .write_all(&payload.bytes) + .context("failed to write session recovery payload")?; + tmp_file + .sync_all() + .context("failed to sync temporary recovery file")?; + } + let write_elapsed = write_started.elapsed(); + + let replace_started = Instant::now(); + fs::rename(&tmp_path, &recovery_path).with_context(|| { + format!( + "failed to move temporary recovery file {} -> {}", + tmp_path.display(), + recovery_path.display() + ) + })?; + info!( + "Session recovery file replace completed for {}: write_and_sync={:?}, rename={:?}, final_size={} bytes", + recovery_path.display(), + write_elapsed, + replace_started.elapsed(), + payload.final_size() + ); + + Ok(Some(SaveSnapshotReport { + path: recovery_path, + outcome, + raw_size: payload.raw_size, + written_size: payload.final_size(), + max_file_size_bytes: options.max_file_size_bytes, + compressed: payload.compressed, + })) +} + +fn recovery_payload( + snapshot: &SessionSnapshot, + options: &SessionOptions, + max_expanded_size: u64, + last_modified: &str, +) -> Result> { + if snapshot.is_empty() && snapshot.tool_state.is_none() { + return Ok(None); + } + + let full_started = Instant::now(); + let full_payload = payload_candidate(snapshot, options, last_modified)?; + log_payload_candidate("recovery full", &full_payload, full_started.elapsed()); + if full_payload.fits_expanded_limit(max_expanded_size) { + return Ok(Some((full_payload, SaveSnapshotOutcome::Full))); + } + + let full_limit = full_payload + .expanded_limit_exceeded(max_expanded_size) + .expect("full recovery payload should exceed expanded limit"); + warn!( + "Full session recovery payload cannot be saved safely ({}; {} bytes written from {} raw bytes, compression={}); trying visible data without undo/redo history", + full_limit.description(), + full_payload.final_size(), + full_payload.raw_size, + full_payload.compressed + ); + + let visible_only = snapshot_without_history(snapshot); + if visible_only.is_empty() && visible_only.tool_state.is_none() { + return Ok(None); + } + let visible_started = Instant::now(); + let visible_payload = payload_candidate(&visible_only, options, last_modified)?; + log_payload_candidate( + "recovery visible-only", + &visible_payload, + visible_started.elapsed(), + ); + if visible_payload.fits_expanded_limit(max_expanded_size) { + return Ok(Some((visible_payload, SaveSnapshotOutcome::VisibleOnly))); + } + + let visible_limit = visible_payload + .expanded_limit_exceeded(max_expanded_size) + .expect("visible recovery payload should exceed expanded limit"); + Err(anyhow!( + "Session recovery data cannot be saved safely ({}; {} bytes written from {} raw bytes, compression={}); skipping recovery", + visible_limit.description(), + visible_payload.final_size(), + visible_payload.raw_size, + visible_payload.compressed + )) +} + struct PayloadCandidate { bytes: Vec, raw_size: usize, @@ -428,6 +634,21 @@ impl PayloadCandidate { fn fits_limit(&self, options: &SessionOptions, max_expanded_size: u64) -> bool { self.limit_exceeded(options, max_expanded_size).is_none() } + + fn expanded_limit_exceeded(&self, max_expanded_size: u64) -> Option { + if self.raw_size as u64 > max_expanded_size { + Some(SaveLimitExceeded::ExpandedSize { + raw_size: self.raw_size as u64, + max_expanded_size, + }) + } else { + None + } + } + + fn fits_expanded_limit(&self, max_expanded_size: u64) -> bool { + self.expanded_limit_exceeded(max_expanded_size).is_none() + } } struct PreparedPayload { @@ -462,6 +683,7 @@ fn payload_within_limit( options: &SessionOptions, last_modified: &str, max_expanded_size: u64, + history_fallback: HistoryFallbackStrategy, ) -> Result { if snapshot.is_empty() && snapshot.tool_state.is_none() { return Ok(PreparedPayload::clear(0, false)); @@ -504,13 +726,13 @@ fn payload_within_limit( let visible_limit = visible_payload .limit_exceeded(options, max_expanded_size) .expect("visible payload should exceed a save/load limit"); - return Err(anyhow!( - "Session data cannot be saved safely ({}; {} bytes written from {} raw bytes, compression={}); skipping save", - visible_limit.description(), - visible_payload.final_size(), - visible_payload.raw_size, - visible_payload.compressed - )); + return Err(SavePayloadTooLarge { + limit: visible_limit, + written_size: visible_payload.final_size(), + raw_size: visible_payload.raw_size, + compressed: visible_payload.compressed, + } + .into()); } let history_depth = max_history_depth(snapshot); @@ -539,13 +761,13 @@ fn payload_within_limit( } Some((1, depth_one_payload)) } else { - largest_fitting_history_payload( + fitting_history_payload( snapshot, - 2, history_depth, options, last_modified, max_expanded_size, + history_fallback, )? .or(Some((1, depth_one_payload))) }; @@ -597,6 +819,45 @@ fn payload_within_limit( )) } +fn fitting_history_payload( + snapshot: &SessionSnapshot, + history_depth: usize, + options: &SessionOptions, + last_modified: &str, + max_expanded_size: u64, + history_fallback: HistoryFallbackStrategy, +) -> Result> { + match history_fallback { + HistoryFallbackStrategy::LargestFitting => largest_fitting_history_payload( + snapshot, + 2, + history_depth, + options, + last_modified, + max_expanded_size, + ), + HistoryFallbackStrategy::Bounded { max_depth } => { + let max_depth = max_depth.min(history_depth); + if max_depth < 2 { + debug!( + "Autosave history fallback capped at depth {}; skipping deeper history-depth scan", + max_depth + ); + return Ok(None); + } + + largest_fitting_history_payload( + snapshot, + 2, + max_depth, + options, + last_modified, + max_expanded_size, + ) + } + } +} + fn largest_fitting_history_payload( snapshot: &SessionSnapshot, min_depth: usize, @@ -801,3 +1062,21 @@ fn remove_session_file(session_path: &Path) -> Result<()> { } Ok(()) } + +fn remove_recovery_file(options: &SessionOptions) { + let recovery_path = options.recovery_file_path(); + if !recovery_path.exists() { + return; + } + match fs::remove_file(&recovery_path) { + Ok(()) => info!( + "Removed session recovery artifact after successful normal save: {}", + recovery_path.display() + ), + Err(err) => warn!( + "Failed to remove session recovery artifact {} after successful normal save: {}", + recovery_path.display(), + err + ), + } +} diff --git a/src/session/snapshot/tests.rs b/src/session/snapshot/tests.rs index 61966895..1f932f0d 100644 --- a/src/session/snapshot/tests.rs +++ b/src/session/snapshot/tests.rs @@ -147,6 +147,152 @@ fn load_snapshot_expansion_limit_leaves_primary_file_unchanged() { ); } +#[test] +fn load_snapshot_reports_successful_recovery_source() { + let temp = tempdir().unwrap(); + let snapshot = sample_snapshot(); + + let mut options = SessionOptions::new(temp.path().to_path_buf(), "valid-recovery"); + options.persist_transparent = true; + save_snapshot(&snapshot, &options).expect("normal session should save"); + + let recovery_file = SessionFile { + version: CURRENT_VERSION, + last_modified: now_rfc3339(), + active_board_id: Some("transparent".to_string()), + active_mode: None, + boards: vec![BoardFile { + id: "transparent".to_string(), + pages: vec![sample_frame()], + active_page: 0, + }], + transparent: None, + whiteboard: None, + blackboard: None, + transparent_pages: None, + whiteboard_pages: None, + blackboard_pages: None, + transparent_active_page: None, + whiteboard_active_page: None, + blackboard_active_page: None, + tool_state: None, + }; + std::fs::write( + options.recovery_file_path(), + serde_json::to_vec_pretty(&recovery_file).expect("recovery json"), + ) + .expect("recovery write"); + + let outcome = + load_snapshot_with_expanded_limit(&options, 64 * 1024).expect("valid recovery should load"); + assert!( + matches!(outcome, LoadSnapshotOutcome::LoadedFromRecovery(_)), + "valid recovery should be surfaced in the load outcome" + ); +} + +#[test] +fn load_snapshot_falls_back_to_normal_when_recovery_is_corrupt() { + let temp = tempdir().unwrap(); + let snapshot = sample_snapshot(); + + let mut options = SessionOptions::new(temp.path().to_path_buf(), "corrupt-recovery"); + options.persist_transparent = true; + save_snapshot(&snapshot, &options).expect("normal session should save"); + + let recovery_path = options.recovery_file_path(); + std::fs::write(&recovery_path, b"{not valid json").expect("recovery write"); + + let outcome = load_snapshot_with_expanded_limit(&options, 64 * 1024) + .expect("corrupt recovery should fall back to normal session"); + assert_loaded_sample_snapshot(outcome); + assert!( + !recovery_path.exists(), + "corrupt recovery should be moved out of the recovery path" + ); + assert!( + recovery_path.with_extension("recovery.bak").exists(), + "corrupt recovery should be backed up for inspection" + ); +} + +#[test] +fn load_snapshot_falls_back_to_normal_when_recovery_is_empty() { + let temp = tempdir().unwrap(); + let snapshot = sample_snapshot(); + + let mut options = SessionOptions::new(temp.path().to_path_buf(), "empty-recovery"); + options.persist_transparent = true; + save_snapshot(&snapshot, &options).expect("normal session should save"); + + let empty_file = SessionFile { + version: CURRENT_VERSION, + last_modified: now_rfc3339(), + active_board_id: Some("transparent".to_string()), + active_mode: None, + boards: Vec::new(), + transparent: None, + whiteboard: None, + blackboard: None, + transparent_pages: None, + whiteboard_pages: None, + blackboard_pages: None, + transparent_active_page: None, + whiteboard_active_page: None, + blackboard_active_page: None, + tool_state: None, + }; + let recovery_path = options.recovery_file_path(); + std::fs::write( + &recovery_path, + serde_json::to_vec_pretty(&empty_file).expect("empty recovery json"), + ) + .expect("recovery write"); + + let outcome = load_snapshot_with_expanded_limit(&options, 64 * 1024) + .expect("empty recovery should fall back to normal session"); + assert_loaded_sample_snapshot(outcome); + assert!( + !recovery_path.exists(), + "empty recovery should be moved out of the recovery path" + ); + assert!( + recovery_path.with_extension("recovery.empty").exists(), + "empty recovery should be preserved for inspection" + ); +} + +#[test] +fn load_snapshot_rejects_oversized_plain_recovery_before_falling_back() { + let temp = tempdir().unwrap(); + let snapshot = sample_snapshot(); + const MAX_EXPANDED_SIZE: u64 = 16 * 1024; + + let mut options = SessionOptions::new(temp.path().to_path_buf(), "huge-plain-recovery"); + options.persist_transparent = true; + options.max_file_size_bytes = u64::MAX; + save_snapshot(&snapshot, &options).expect("normal session should save"); + + let recovery_path = options.recovery_file_path(); + std::fs::write( + &recovery_path, + vec![b' '; usize::try_from(MAX_EXPANDED_SIZE + 1).expect("test size fits")], + ) + .expect("recovery write"); + + let outcome = load_snapshot_with_expanded_limit(&options, MAX_EXPANDED_SIZE) + .expect("oversized plain recovery should fall back to normal session"); + assert_loaded_sample_snapshot(outcome); + assert!( + !recovery_path.exists(), + "oversized recovery should be moved out of the recovery path" + ); + assert!( + recovery_path.with_extension("recovery.too-large").exists(), + "oversized recovery should be preserved for inspection" + ); +} + #[test] fn save_snapshot_refuses_compressed_payload_over_expanded_limit() { let temp = tempdir().unwrap(); @@ -195,6 +341,16 @@ fn save_snapshot_refuses_compressed_payload_over_expanded_limit() { ); } +fn assert_loaded_sample_snapshot(outcome: LoadSnapshotOutcome) { + let LoadSnapshotOutcome::Loaded(snapshot) = outcome else { + panic!("expected normal session to load, got {outcome:?}"); + }; + assert_eq!(snapshot.boards.len(), 1); + assert_eq!(snapshot.boards[0].id, "transparent"); + assert_eq!(snapshot.boards[0].pages.pages.len(), 1); + assert_eq!(snapshot.boards[0].pages.pages[0].shapes.len(), 1); +} + #[test] fn load_snapshot_inner_skips_newer_versions() { let temp = tempdir().unwrap(); diff --git a/src/session/storage/clear.rs b/src/session/storage/clear.rs index f4bfdac5..da8034dc 100644 --- a/src/session/storage/clear.rs +++ b/src/session/storage/clear.rs @@ -9,10 +9,12 @@ use crate::session::options::SessionOptions; pub fn clear_session(options: &SessionOptions) -> Result { let session_path = options.session_file_path(); let backup_path = options.backup_file_path(); + let recovery_path = options.recovery_file_path(); let lock_path = options.lock_file_path(); let mut removed_session = remove_file_if_exists(&session_path)?; let mut removed_backup = remove_file_if_exists(&backup_path)?; + let mut removed_recovery = remove_recovery_files(&recovery_path)?; let mut removed_lock = remove_file_if_exists(&lock_path)?; if options.per_output && options.output_identity().is_none() { @@ -28,6 +30,8 @@ pub fn clear_session(options: &SessionOptions) -> Result { remove_matching_files(base_dir, &prefix, ".json.bak")? || removed_backup; } + removed_recovery = remove_matching_recovery_files(base_dir, &prefix)? || removed_recovery; + if !removed_lock { removed_lock = remove_matching_files(base_dir, &prefix, ".lock")? || removed_lock; } @@ -36,6 +40,7 @@ pub fn clear_session(options: &SessionOptions) -> Result { Ok(ClearOutcome { removed_session, removed_backup, + removed_recovery, removed_lock, }) } @@ -49,6 +54,39 @@ fn remove_file_if_exists(path: &Path) -> Result { } } +fn remove_recovery_files(recovery_path: &Path) -> Result { + let Some(recovery_name) = recovery_path + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_string) + else { + return remove_file_if_exists(recovery_path); + }; + let Some(parent) = recovery_path.parent() else { + return remove_file_if_exists(recovery_path); + }; + + let mut removed = false; + if let Ok(entries) = fs::read_dir(parent) { + for entry in entries { + let entry = entry?; + let path = entry.path(); + if !path.is_file() { + continue; + } + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if name == recovery_name || name.starts_with(&format!("{recovery_name}.")) { + fs::remove_file(&path) + .with_context(|| format!("failed to remove {}", path.display()))?; + removed = true; + } + } + } + Ok(removed) +} + fn remove_matching_files(dir: &Path, prefix: &str, suffix: &str) -> Result { let mut removed = false; if let Ok(entries) = fs::read_dir(dir) { @@ -73,3 +111,25 @@ fn remove_matching_files(dir: &Path, prefix: &str, suffix: &str) -> Result } Ok(removed) } + +fn remove_matching_recovery_files(dir: &Path, prefix: &str) -> Result { + let mut removed = false; + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries { + let entry = entry?; + let path = entry.path(); + if !path.is_file() { + continue; + } + if let Some(name) = path.file_name().and_then(|n| n.to_str()) + && name.starts_with(prefix) + && name.contains(".json.recovery") + { + fs::remove_file(&path) + .with_context(|| format!("failed to remove {}", path.display()))?; + removed = true; + } + } + } + Ok(removed) +} diff --git a/src/session/storage/tests.rs b/src/session/storage/tests.rs index ef961073..5bbeac2b 100644 --- a/src/session/storage/tests.rs +++ b/src/session/storage/tests.rs @@ -16,6 +16,13 @@ fn clear_session_removes_all_variants_for_prefix() { std::fs::write(base_dir.join("session-display_1-DP_1.json"), b"{}").unwrap(); std::fs::write(base_dir.join("session-display_1.json.bak"), b"{}").unwrap(); std::fs::write(base_dir.join("session-display_1-DP_1.json.bak"), b"{}").unwrap(); + std::fs::write(base_dir.join("session-display_1.json.recovery"), b"{}").unwrap(); + std::fs::write( + base_dir.join("session-display_1.json.recovery.empty"), + b"{}", + ) + .unwrap(); + std::fs::write(base_dir.join("session-display_1-DP_1.json.recovery"), b"{}").unwrap(); std::fs::write(base_dir.join("session-display_1.lock"), b"").unwrap(); std::fs::write(base_dir.join("session-display_1-DP_1.lock"), b"").unwrap(); @@ -28,6 +35,10 @@ fn clear_session_removes_all_variants_for_prefix() { outcome.removed_backup, "at least one backup variant should be removed" ); + assert!( + outcome.removed_recovery, + "at least one recovery variant should be removed" + ); assert!( outcome.removed_lock, "at least one lock variant should be removed" @@ -42,6 +53,22 @@ fn clear_session_removes_all_variants_for_prefix() { !base_dir.join("session-display_1.json.bak").exists(), "primary backup file should be removed" ); + assert!( + !base_dir.join("session-display_1.json.recovery").exists(), + "primary recovery file should be removed" + ); + assert!( + !base_dir + .join("session-display_1.json.recovery.empty") + .exists(), + "preserved primary recovery file should be removed" + ); + assert!( + !base_dir + .join("session-display_1-DP_1.json.recovery") + .exists(), + "per-output recovery file should be removed even when the primary recovery existed" + ); assert!( !base_dir.join("session-display_1.lock").exists(), "primary lock file should be removed" diff --git a/src/session/storage/types.rs b/src/session/storage/types.rs index 1e0fb03c..b41d1218 100644 --- a/src/session/storage/types.rs +++ b/src/session/storage/types.rs @@ -6,6 +6,7 @@ use std::time::SystemTime; pub struct ClearOutcome { pub removed_session: bool, pub removed_backup: bool, + pub removed_recovery: bool, pub removed_lock: bool, } diff --git a/src/session/tests/limits.rs b/src/session/tests/limits.rs index f2fcdf9b..5b79fec1 100644 --- a/src/session/tests/limits.rs +++ b/src/session/tests/limits.rs @@ -83,6 +83,45 @@ fn load_snapshot_refuses_file_larger_than_max() { ); } +#[test] +fn oversized_save_writes_recovery_and_load_prefers_it() { + let temp = crate::test_temp::tempdir().unwrap(); + let mut options = SessionOptions::new(temp.path().to_path_buf(), "display-recovery"); + options.persist_transparent = true; + options.persist_history = false; + options.restore_tool_state = false; + options.backup_retention = 0; + + let old_snapshot = single_page_snapshot(named_frame("old autosave")); + save_snapshot(&old_snapshot, &options).expect("old snapshot should save"); + + options.max_file_size_bytes = 1; + let recovery_snapshot = single_page_snapshot(image_frame(64 * 1024)); + let err = save_snapshot(&recovery_snapshot, &options) + .expect_err("oversized normal save should still report failure"); + assert!(err.to_string().contains("exceeds the configured limit")); + + let recovery_path = options.recovery_file_path(); + assert!( + recovery_path.exists(), + "recovery artifact should be written" + ); + + let loaded = load_snapshot(&options) + .expect("load should use recovery path") + .expect("recovery snapshot should load"); + assert_eq!(count_images(&loaded), 1); + assert_eq!(loaded.boards[0].pages.pages.len(), 1); + + let mut roomy_options = options.clone(); + roomy_options.max_file_size_bytes = u64::MAX; + save_snapshot(&loaded, &roomy_options).expect("normal save of recovered state should succeed"); + assert!( + !recovery_path.exists(), + "successful normal save should clear recovery artifact" + ); +} + #[test] fn load_snapshot_truncates_shapes_when_exceeding_max_shapes_per_frame() { let temp = crate::test_temp::tempdir().unwrap(); @@ -134,6 +173,65 @@ fn load_snapshot_truncates_shapes_when_exceeding_max_shapes_per_frame() { ); } +fn named_frame(name: &str) -> crate::draw::Frame { + let mut frame = crate::draw::Frame::new(); + frame.set_page_name(Some(name.to_string())); + frame +} + +fn image_frame(bytes: usize) -> crate::draw::Frame { + let mut frame = crate::draw::Frame::new(); + frame.add_shape(Shape::Image { + x: 12, + y: 16, + w: 320, + h: 180, + data: EmbeddedImage { + mime_type: "image/png".to_string(), + width: 640, + height: 360, + bytes: pseudo_random_bytes(bytes), + }, + }); + frame +} + +fn single_page_snapshot(frame: crate::draw::Frame) -> SessionSnapshot { + SessionSnapshot { + active_board_id: "transparent".to_string(), + boards: vec![BoardSnapshot { + id: "transparent".to_string(), + pages: BoardPagesSnapshot { + pages: vec![frame], + active: 0, + }, + }], + tool_state: None, + } +} + +fn count_images(snapshot: &SessionSnapshot) -> usize { + snapshot + .boards + .iter() + .flat_map(|board| board.pages.pages.iter()) + .flat_map(|page| page.shapes.iter()) + .filter(|shape| matches!(shape.shape, Shape::Image { .. })) + .count() +} + +fn pseudo_random_bytes(len: usize) -> Vec { + let mut value = 0x1234_5678_u32; + (0..len) + .map(|_| { + value ^= value << 13; + value ^= value >> 17; + value ^= value << 5; + value as u8 + }) + .collect() +} + #[test] fn save_snapshot_allows_compressed_payload_that_fits_limit() { const PAGE_COUNT: usize = 12; @@ -409,6 +507,89 @@ fn save_snapshot_keeps_largest_recent_history_depth_that_fits() { assert_eq!(frame.redo_stack_len(), 0); } +#[test] +fn autosave_snapshot_uses_bounded_history_fallback() { + let temp = crate::test_temp::tempdir().unwrap(); + let mut input = dummy_input_state(); + let point_count = 600; + + { + let frame = input.boards.active_frame_mut(); + let id = frame.add_shape(large_freehand(point_count, 0)); + for offset in 1..=3 { + let current = frame.shape(id).expect("shape should exist"); + let before = ShapeSnapshot { + shape: current.shape.clone(), + locked: current.locked, + }; + let after_shape = large_freehand(point_count, offset); + frame.shape_mut(id).expect("shape should exist").shape = after_shape.clone(); + frame.push_undo_action( + UndoAction::Modify { + shape_id: id, + before, + after: ShapeSnapshot { + shape: after_shape, + locked: false, + }, + }, + input.undo_stack_limit, + ); + } + } + + let mut depth_two_options = limit_test_options(temp.path(), "autosave-depth-two", true); + depth_two_options.max_file_size_bytes = u64::MAX; + depth_two_options.max_persisted_undo_depth = Some(2); + let depth_two_snapshot = + snapshot_from_input(&input, &depth_two_options).expect("depth two snapshot present"); + save_snapshot(&depth_two_snapshot, &depth_two_options).expect("depth two save should fit"); + let depth_two_size = fs::metadata(depth_two_options.session_file_path()) + .expect("depth two metadata") + .len(); + + let mut depth_three_options = limit_test_options(temp.path(), "autosave-depth-three", true); + depth_three_options.max_file_size_bytes = u64::MAX; + depth_three_options.max_persisted_undo_depth = Some(3); + let depth_three_snapshot = + snapshot_from_input(&input, &depth_three_options).expect("depth three snapshot present"); + save_snapshot(&depth_three_snapshot, &depth_three_options) + .expect("depth three save should fit"); + let depth_three_size = fs::metadata(depth_three_options.session_file_path()) + .expect("depth three metadata") + .len(); + assert!(depth_three_size > depth_two_size); + + let mut options = limit_test_options(temp.path(), "autosave-trimmed", true); + options.max_file_size_bytes = depth_two_size + (depth_three_size - depth_two_size) / 2; + let snapshot = snapshot_from_input(&input, &options).expect("snapshot present"); + + let report = save_snapshot_autosave_with_report(&snapshot, &options) + .expect("autosave should use bounded history fallback") + .expect("session should be written"); + assert_eq!( + report.outcome, + SaveSnapshotOutcome::TrimmedHistory { depth: 1 } + ); + + let loaded = load_snapshot(&options) + .expect("load_snapshot should succeed") + .expect("snapshot should be present"); + let transparent = loaded + .boards + .iter() + .find(|board| board.id == "transparent") + .expect("transparent board should be present"); + let frame = &transparent.pages.pages[0]; + + assert_eq!(frame.shapes.len(), 1, "visible stroke should be saved"); + assert_eq!( + frame.undo_stack_len(), + 1, + "autosave should avoid deeper history-depth scans" + ); +} + #[test] fn save_snapshot_keeps_depth_one_when_visible_payload_is_near_limit() { let temp = crate::test_temp::tempdir().unwrap();