From 31550fc15586f4df9d5437e3a754cd72e9aa102a Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:57:27 +0200 Subject: [PATCH] refactor(session): exercise production transactions through the persistence worker --- src/backend/wayland/session.rs | 6 +- src/backend/wayland/session/runtime.rs | 713 +++++++++++++--------- src/backend/wayland/session/tests.rs | 96 ++- src/backend/wayland/state/core/session.rs | 498 ++------------- 4 files changed, 573 insertions(+), 740 deletions(-) diff --git a/src/backend/wayland/session.rs b/src/backend/wayland/session.rs index 05a86716..2932f9aa 100644 --- a/src/backend/wayland/session.rs +++ b/src/backend/wayland/session.rs @@ -586,15 +586,11 @@ pub(in crate::backend::wayland) use persistence::{ RequestId, SaveCompletion, SaveStrategy, SubmitFailure, }; +pub(in crate::backend::wayland) use runtime::SessionTransaction; pub(in crate::backend::wayland) use runtime::{ RuntimeClearSessionReport, RuntimeClearToolStateReport, RuntimeOpenSessionReport, RuntimeSaveAsSessionReport, }; -#[cfg(test)] -pub(super) use runtime::{ - clear_current_session_runtime, clear_saved_tool_state_runtime, open_named_session_runtime, - save_named_session_as_requires_overwrite, save_named_session_as_runtime, -}; pub(super) use runtime::{has_session_artifact, should_skip_unloaded_contentless_save}; #[cfg(test)] diff --git a/src/backend/wayland/session/runtime.rs b/src/backend/wayland/session/runtime.rs index 3fb12aed..a39316c0 100644 --- a/src/backend/wayland/session/runtime.rs +++ b/src/backend/wayland/session/runtime.rs @@ -1,4 +1,5 @@ use super::*; +use crate::session::{SaveAsOverwrite, SessionSnapshot, ToolStateSnapshot}; #[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] @@ -35,313 +36,387 @@ pub(in crate::backend::wayland) struct RuntimeClearToolStateReport { pub outcome: Option, } -#[allow(dead_code)] -pub(in crate::backend::wayland) fn open_named_session_runtime( - input_state: &mut InputState, - measurer: &crate::draw::TextMeasurer, - session_state: &mut SessionState, - target_path: &Path, - now: Instant, -) -> Result { - let current_options = session_state - .options() - .cloned() - .ok_or_else(|| anyhow!("cannot open session without active session options"))?; - let previous_path = current_options.session_file_path(); - - let saved_current = save_current_session_before_runtime_open( - input_state, - measurer, - session_state, - ¤t_options, - now, - )?; - - let mut candidate_options = current_options; - candidate_options.set_named_file_target(target_path.to_path_buf()); - candidate_options.force_resume_persistence(); - - let outcome = stored_session::load_named_session_candidate(&candidate_options)?; - let candidate_snapshot = match outcome { - LoadSnapshotOutcome::Loaded(snapshot) - | LoadSnapshotOutcome::LoadedFromBackup(snapshot) - | LoadSnapshotOutcome::LoadedFromRecovery(snapshot) => *snapshot, - LoadSnapshotOutcome::Empty => { - return Err(anyhow!( - "named session file contains no usable session data: {}", - candidate_options.session_file_path().display() - )); +pub(in crate::backend::wayland) struct SessionTransaction<'a> { + pub input_state: &'a mut InputState, + pub measurer: &'a crate::draw::TextMeasurer, + pub session: &'a mut SessionState, + pub persistence: &'a mut PersistenceController, +} + +impl SessionTransaction<'_> { + fn run(&mut self, operation: PersistenceOperation) -> Result { + self.persistence.run(self.session.target_epoch(), operation) + } + + pub(in crate::backend::wayland) fn open_named_session_runtime( + &mut self, + target_path: &Path, + ) -> Result { + let current_options = self + .session + .options() + .cloned() + .ok_or_else(|| anyhow!("cannot open session without active session options"))?; + let previous_path = current_options.session_file_path(); + + let validation = self.run(PersistenceOperation::ValidateNamedOpen { + path: target_path.to_path_buf(), + })?; + accept_open_preflight(self.session, validation)?; + + let saved_current = self.save_current_before_explicit_target_change(¤t_options)?; + let mut candidate_options = current_options; + candidate_options.set_named_file_target(target_path.to_path_buf()); + candidate_options.force_resume_persistence(); + + let outcome = self.run(PersistenceOperation::LoadNamedCandidate { + options: candidate_options.clone(), + })?; + let PersistenceOutcome::Load(load_outcome) = outcome else { + return Err(anyhow!("unexpected named-session load outcome")); + }; + let candidate_snapshot = named_candidate_snapshot(load_outcome, &candidate_options)?; + let loaded_board_data = candidate_snapshot.has_board_data(); + stored_session::apply_snapshot_replacing_boards( + self.input_state, + self.measurer, + candidate_snapshot, + &candidate_options, + )?; + self.input_state + .set_session_preflight_options(Some(candidate_options.clone())); + self.input_state.clear_session_dirty(); + let opened_path = candidate_options.session_file_path(); + self.session + .commit_runtime_open(candidate_options.clone(), loaded_board_data); + + match self.run(PersistenceOperation::RecordNamedOpened { + options: candidate_options, + }) { + Ok(PersistenceOutcome::Unit) => {} + Ok(other) => log::warn!( + "Named session opened, but catalog worker returned an unexpected outcome: {other:?}" + ), + Err(err) => log::warn!( + "Named session opened, but recording it in the recent-session catalog failed: {err:#}" + ), } - LoadSnapshotOutcome::EmptyAfterCorruption { backup_path } => { + + Ok(RuntimeOpenSessionReport { + previous_path, + opened_path, + saved_current, + loaded_board_data, + }) + } + + pub(in crate::backend::wayland) fn save_named_session_as_runtime( + &mut self, + target_path: &Path, + overwrite: SaveAsOverwrite, + ) -> Result { + let current_options = self + .session + .options() + .cloned() + .ok_or_else(|| anyhow!("cannot save session as without active session options"))?; + let previous_path = current_options.session_file_path(); + let mut target_options = current_options.clone(); + target_options.set_named_file_target(target_path.to_path_buf()); + target_options.force_resume_persistence(); + let preflight = self.run(PersistenceOperation::SaveAsOverwritePreflight { + current_path: previous_path.clone(), + options: target_options.clone(), + })?; + match accept_save_as_preflight(self.session, preflight, overwrite, target_path)? { + SaveAsPreflightDecision::SameTarget => { + let saved = self.save_current_before_explicit_target_change(¤t_options)?; + return Ok(RuntimeSaveAsSessionReport { + previous_path: previous_path.clone(), + saved_path: previous_path, + switched_target: false, + saved, + saved_board_data: self.session.has_loaded_board_data(), + outcome: None, + written_size: None, + }); + } + SaveAsPreflightDecision::SwitchTarget => {} + } + + let snapshot = self + .input_state + .with_active_interaction_canceled_for_capture_with(self.measurer, |input_state| { + stored_session::snapshot_from_input(input_state, &target_options) + }) + .ok_or_else(|| anyhow!("Save Session As has no session data to write"))?; + let outcome = self.run(PersistenceOperation::SaveAs { + snapshot, + options: target_options.clone(), + overwrite, + })?; + let PersistenceOutcome::SaveAs { + report, + committed_board_data, + } = outcome + else { + return Err(anyhow!("unexpected Save As worker outcome")); + }; + + self.input_state + .set_session_preflight_options(Some(target_options.clone())); + let _ = self.input_state.take_session_dirty(); + self.input_state.clear_session_dirty(); + let saved_path = target_options.session_file_path(); + self.session + .commit_runtime_save_as(target_options, Instant::now(), committed_board_data); + + Ok(RuntimeSaveAsSessionReport { + previous_path, + saved_path, + switched_target: true, + saved: true, + saved_board_data: committed_board_data, + outcome: Some(report.outcome), + written_size: Some(report.written_size), + }) + } + + pub(in crate::backend::wayland) fn save_named_session_as_requires_overwrite( + &mut self, + target_path: &Path, + ) -> Result { + let current_options = self + .session + .options() + .cloned() + .ok_or_else(|| anyhow!("cannot save session as without active session options"))?; + let previous_path = current_options.session_file_path(); + let mut target_options = current_options; + target_options.set_named_file_target(target_path.to_path_buf()); + target_options.force_resume_persistence(); + let outcome = self.run(PersistenceOperation::SaveAsOverwritePreflight { + current_path: previous_path, + options: target_options, + })?; + let PersistenceOutcome::SaveAsPreflight { + same_target, + overwrite_required, + } = outcome + else { + return Err(anyhow!("unexpected Save As preflight outcome")); + }; + Ok(!same_target && overwrite_required) + } + + pub(in crate::backend::wayland) fn clear_current_session_runtime( + &mut self, + ) -> Result { + let options = self + .session + .options() + .cloned() + .ok_or_else(|| anyhow!("cannot clear session without active session options"))?; + let cleared_path = options.session_file_path(); + let empty_snapshot = SessionSnapshot { + active_board_id: self.input_state.board_id().to_string(), + boards: Vec::new(), + tool_state: None, + }; + let outcome = self.run(PersistenceOperation::Save { + snapshot: empty_snapshot.clone(), + options: options.clone(), + strategy: SaveStrategy::Normal, + contentless_clear_boundary: true, + })?; + let PersistenceOutcome::Save(save) = outcome else { + return Err(anyhow!("unexpected clear-session worker outcome")); + }; + if !save.committed() { return Err(anyhow!( - "named session file could not be read: {}; a copy of it was saved to {}", - candidate_options.session_file_path().display(), - backup_path.display() + "current session clear did not write a committed clear boundary" )); } - LoadSnapshotOutcome::NonRegularArtifact { path } => { + stored_session::apply_snapshot_replacing_boards( + self.input_state, + self.measurer, + empty_snapshot, + &options, + )?; + self.input_state + .set_session_preflight_options(Some(options)); + let _ = self.input_state.take_session_dirty(); + self.input_state.clear_session_dirty(); + self.session.commit_runtime_clear(Instant::now()); + Ok(RuntimeClearSessionReport { + cleared_path, + persisted: true, + }) + } + + pub(in crate::backend::wayland) fn clear_saved_tool_state_runtime( + &mut self, + default_tool_state: ToolStateSnapshot, + ) -> Result { + let (session_path, outcome) = if let Some(options) = self.session.options().cloned() { + let path = options.session_file_path(); + let outcome = self.run(PersistenceOperation::ClearToolState { options })?; + let PersistenceOutcome::ToolStateCleared(outcome) = outcome else { + return Err(anyhow!("unexpected clear-tool-state worker outcome")); + }; + (Some(path), Some(outcome)) + } else { + (None, None) + }; + stored_session::apply_tool_state_snapshot( + self.input_state, + self.measurer, + default_tool_state, + ); + self.input_state.mark_session_dirty(); + self.session.record_input_dirty(Instant::now(), true); + + Ok(RuntimeClearToolStateReport { + session_path, + outcome, + }) + } + + fn save_current_before_explicit_target_change( + &mut self, + options: &SessionOptions, + ) -> Result { + if !self.input_state.is_session_dirty() && !self.session.is_dirty() { + return Ok(false); + } + let snapshot = self + .input_state + .with_active_interaction_canceled_for_capture_with(self.measurer, |input_state| { + stored_session::snapshot_from_input(input_state, options) + }); + let snapshot = if let Some(snapshot) = snapshot { + snapshot + } else if session_persistence_enabled(options) { + SessionSnapshot { + active_board_id: self.input_state.board_id().to_string(), + boards: Vec::new(), + tool_state: None, + } + } else { return Err(anyhow!( - "named session file is not a regular file: {}", - path.display() + "current session has unsaved changes but persistence is disabled" )); - } - LoadSnapshotOutcome::ExpandedTooLarge { - path, - max_expanded_size, - } => { + }; + let outcome = self.run(PersistenceOperation::Save { + snapshot, + options: options.clone(), + strategy: SaveStrategy::Normal, + contentless_clear_boundary: self.session.has_loaded_board_data(), + })?; + let PersistenceOutcome::Save(save) = outcome else { + return Err(anyhow!("unexpected save-before-target-change outcome")); + }; + if !save.committed() { return Err(anyhow!( - "named session file expands beyond the {} byte safety limit: {}", - max_expanded_size, - path.display() + "current session had unsaved changes but no session file was written" )); } - }; - - let loaded_board_data = candidate_snapshot.has_board_data(); - stored_session::apply_snapshot_replacing_boards( - input_state, - measurer, - candidate_snapshot, - &candidate_options, - )?; - input_state.set_session_preflight_options(Some(candidate_options.clone())); - input_state.clear_session_dirty(); - let opened_path = candidate_options.session_file_path(); - session_state.commit_runtime_open(candidate_options.clone(), loaded_board_data); - stored_session::catalog::record_named_session_opened(&candidate_options); - - Ok(RuntimeOpenSessionReport { - previous_path, - opened_path, - saved_current, - loaded_board_data, - }) -} - -#[allow(dead_code)] -pub(in crate::backend::wayland) fn save_named_session_as_runtime( - input_state: &mut InputState, - measurer: &crate::draw::TextMeasurer, - session_state: &mut SessionState, - target_path: &Path, - overwrite: stored_session::SaveAsOverwrite, - now: Instant, -) -> Result { - let current_options = session_state - .options() - .cloned() - .ok_or_else(|| anyhow!("cannot save session as without active session options"))?; - let previous_path = current_options.session_file_path(); - - stored_session::validate_named_session_file_for_foreground(target_path)?; - if stored_session::catalog::session_paths_match(&previous_path, target_path) { - let saved = save_current_session_before_runtime_open( - input_state, - measurer, - session_state, - ¤t_options, - now, - )?; - return Ok(RuntimeSaveAsSessionReport { - previous_path: previous_path.clone(), - saved_path: previous_path, - switched_target: false, - saved, - saved_board_data: session_state.has_loaded_board_data(), - outcome: None, - written_size: None, - }); + let _ = self.input_state.take_session_dirty(); + self.session + .mark_saved(Instant::now(), save.committed_board_data); + Ok(true) } +} - let mut target_options = current_options; - target_options.set_named_file_target(target_path.to_path_buf()); - target_options.force_resume_persistence(); - - let snapshot = input_state - .with_active_interaction_canceled_for_capture_with(measurer, |input_state| { - stored_session::snapshot_from_input(input_state, &target_options) - }) - .ok_or_else(|| anyhow!("Save Session As has no session data to write"))?; - let saved_board_data = snapshot.has_board_data(); - let save_report = - stored_session::save_snapshot_as_with_report(&snapshot, &target_options, overwrite)?; - - input_state.set_session_preflight_options(Some(target_options.clone())); - let _ = input_state.take_session_dirty(); - input_state.clear_session_dirty(); - let saved_path = target_options.session_file_path(); - session_state.commit_runtime_save_as(target_options.clone(), now, saved_board_data); - stored_session::catalog::record_named_session_saved(&target_options); - - Ok(RuntimeSaveAsSessionReport { - previous_path, - saved_path, - switched_target: true, - saved: true, - saved_board_data, - outcome: Some(save_report.outcome), - written_size: Some(save_report.written_size), - }) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SaveAsPreflightDecision { + SameTarget, + SwitchTarget, } -#[allow(dead_code)] -pub(in crate::backend::wayland) fn save_named_session_as_requires_overwrite( - session_state: &SessionState, - target_path: &Path, -) -> Result { - let current_options = session_state - .options() - .cloned() - .ok_or_else(|| anyhow!("cannot save session as without active session options"))?; - let previous_path = current_options.session_file_path(); - - stored_session::validate_named_session_file_for_foreground(target_path)?; - if stored_session::catalog::session_paths_match(&previous_path, target_path) { - return Ok(false); +fn accept_open_preflight( + session: &mut crate::backend::wayland::session::SessionState, + validation: PersistenceOutcome, +) -> Result<()> { + if !matches!(validation, PersistenceOutcome::Unit) { + return Err(anyhow!("unexpected named-session validation outcome")); } - - let mut target_options = current_options; - target_options.set_named_file_target(target_path.to_path_buf()); - target_options.force_resume_persistence(); - stored_session::save_snapshot_as_requires_overwrite(&target_options) + cancel_pending_output_transition_for_explicit_target(session, "Open"); + Ok(()) } -#[allow(dead_code)] -pub(in crate::backend::wayland) fn clear_current_session_runtime( - input_state: &mut InputState, - measurer: &crate::draw::TextMeasurer, - session_state: &mut SessionState, - now: Instant, -) -> Result { - let options = session_state - .options() - .cloned() - .ok_or_else(|| anyhow!("cannot clear session without active session options"))?; - let cleared_path = options.session_file_path(); - let empty_snapshot = stored_session::SessionSnapshot { - active_board_id: input_state.board_id().to_string(), - boards: Vec::new(), - tool_state: None, +fn accept_save_as_preflight( + session: &mut crate::backend::wayland::session::SessionState, + preflight: PersistenceOutcome, + overwrite: SaveAsOverwrite, + target_path: &Path, +) -> Result { + let PersistenceOutcome::SaveAsPreflight { + same_target, + overwrite_required, + } = preflight + else { + return Err(anyhow!("unexpected Save As preflight outcome")); }; - let report = stored_session::save_snapshot_with_report_and_clear_boundary( - &empty_snapshot, - &options, - true, - )?; - if report.is_none() { + if same_target { + return Ok(SaveAsPreflightDecision::SameTarget); + } + if overwrite_required && matches!(overwrite, SaveAsOverwrite::Deny) { return Err(anyhow!( - "current session clear did not write a durable clear boundary" + "Save Session As target already has session artifacts; overwrite confirmation required for {}", + target_path.display() )); } - - stored_session::apply_snapshot_replacing_boards( - input_state, - measurer, - empty_snapshot, - &options, - )?; - input_state.set_session_preflight_options(Some(options)); - let _ = input_state.take_session_dirty(); - input_state.clear_session_dirty(); - session_state.commit_runtime_clear(now); - - Ok(RuntimeClearSessionReport { - cleared_path, - persisted: true, - }) + cancel_pending_output_transition_for_explicit_target(session, "Save As"); + Ok(SaveAsPreflightDecision::SwitchTarget) } -#[allow(dead_code)] -pub(in crate::backend::wayland) fn clear_saved_tool_state_runtime( - input_state: &mut InputState, - measurer: &crate::draw::TextMeasurer, - session_state: &mut SessionState, - default_tool_state: stored_session::ToolStateSnapshot, - now: Instant, -) -> Result { - let (session_path, outcome) = if let Some(options) = session_state.options().cloned() { - let session_path = options.session_file_path(); - let outcome = stored_session::clear_tool_state(&options)?; - (Some(session_path), Some(outcome)) - } else { - (None, None) - }; - - stored_session::apply_tool_state_snapshot(input_state, measurer, default_tool_state); - input_state.mark_session_dirty(); - session_state.record_input_dirty(now, true); - - Ok(RuntimeClearToolStateReport { - session_path, - outcome, - }) +fn cancel_pending_output_transition_for_explicit_target( + session: &mut crate::backend::wayland::session::SessionState, + operation: &str, +) { + if let Some(pending) = session.cancel_pending_output_transition() { + log::info!( + "{operation} superseded pending output transition from epoch {} to {:?}", + pending.source_epoch, + pending.physical_output_identity + ); + } } -fn save_current_session_before_runtime_open( - input_state: &mut InputState, - measurer: &crate::draw::TextMeasurer, - session_state: &mut SessionState, +fn named_candidate_snapshot( + outcome: LoadSnapshotOutcome, options: &SessionOptions, - now: Instant, -) -> Result { - if !input_state.is_session_dirty() && !session_state.is_dirty() { - return Ok(false); - } - - let snapshot = input_state - .with_active_interaction_canceled_for_capture_with(measurer, |input_state| { - stored_session::snapshot_from_input(input_state, options) - }); - if should_skip_unloaded_contentless_save( - session_state.has_loaded_board_data(), - session_state.is_dirty(), - input_state.is_session_dirty(), - snapshot - .as_ref() - .is_some_and(stored_session::SessionSnapshot::has_board_data), - has_session_artifact(options), - ) { - return Ok(false); - } - - let saved_board_data = snapshot - .as_ref() - .is_some_and(stored_session::SessionSnapshot::has_board_data); - let report = if let Some(snapshot) = snapshot { - stored_session::save_snapshot_with_report_and_clear_boundary( - &snapshot, - options, - session_state.has_loaded_board_data(), - )? - } else if persistence_enabled(options) { - let empty_snapshot = stored_session::SessionSnapshot { - active_board_id: input_state.board_id().to_string(), - boards: Vec::new(), - tool_state: None, - }; - stored_session::save_snapshot_with_report_and_clear_boundary( - &empty_snapshot, - options, - session_state.has_loaded_board_data(), - )? - } else { - return Err(anyhow!( - "current session has unsaved changes but persistence is disabled" - )); - }; - - if report.is_none() { - return Err(anyhow!( - "current session had unsaved changes but no session file was written" - )); +) -> Result { + match outcome { + LoadSnapshotOutcome::Loaded(snapshot) + | LoadSnapshotOutcome::LoadedFromBackup(snapshot) + | LoadSnapshotOutcome::LoadedFromRecovery(snapshot) => Ok(*snapshot), + LoadSnapshotOutcome::Empty => Err(anyhow!( + "named session file contains no usable session data: {}", + options.session_file_path().display() + )), + LoadSnapshotOutcome::EmptyAfterCorruption { backup_path } => Err(anyhow!( + "named session file could not be read: {}; a copy of it was saved to {}", + options.session_file_path().display(), + backup_path.display() + )), + LoadSnapshotOutcome::NonRegularArtifact { path } => Err(anyhow!( + "named session file is not a regular file: {}", + path.display() + )), + LoadSnapshotOutcome::ExpandedTooLarge { + path, + max_expanded_size, + } => Err(anyhow!( + "named session file expands beyond the {} byte safety limit: {}", + max_expanded_size, + path.display() + )), } - - let _ = input_state.take_session_dirty(); - session_state.mark_saved(now, saved_board_data); - Ok(true) } -fn persistence_enabled(options: &SessionOptions) -> bool { +fn session_persistence_enabled(options: &SessionOptions) -> bool { options.any_enabled() || options.restore_tool_state || options.persist_history } @@ -392,3 +467,83 @@ pub(in crate::backend::wayland) fn should_skip_unloaded_contentless_save( && !input_dirty && session_artifact_exists } + +#[cfg(test)] +mod tests { + use super::*; + fn session_with_pending_transition() -> crate::backend::wayland::session::SessionState { + let options = SessionOptions::new(PathBuf::from("/tmp"), "source-output"); + let mut staged = options.clone(); + staged.set_output_identity(Some("target-output")); + let mut session = crate::backend::wayland::session::SessionState::new(Some(options)); + session.stage_output_transition(staged, Some("target-output".to_string()), Instant::now()); + session + } + + #[test] + fn open_cancels_pending_transition_only_after_accepted_preflight() { + let mut rejected = session_with_pending_transition(); + assert!( + accept_open_preflight(&mut rejected, PersistenceOutcome::HasArtifacts(false)).is_err() + ); + assert!(rejected.pending_output_transition().is_some()); + + let mut accepted = session_with_pending_transition(); + accept_open_preflight(&mut accepted, PersistenceOutcome::Unit).unwrap(); + assert!(accepted.pending_output_transition().is_none()); + } + + #[test] + fn save_as_keeps_pending_transition_until_preflight_is_accepted() { + let target = Path::new("/tmp/target.wayscriber-session"); + let mut denied = session_with_pending_transition(); + assert!( + accept_save_as_preflight( + &mut denied, + PersistenceOutcome::SaveAsPreflight { + same_target: false, + overwrite_required: true, + }, + SaveAsOverwrite::Deny, + target, + ) + .is_err() + ); + assert!(denied.pending_output_transition().is_some()); + + let mut accepted = session_with_pending_transition(); + assert_eq!( + accept_save_as_preflight( + &mut accepted, + PersistenceOutcome::SaveAsPreflight { + same_target: false, + overwrite_required: false, + }, + SaveAsOverwrite::Deny, + target, + ) + .unwrap(), + SaveAsPreflightDecision::SwitchTarget + ); + assert!(accepted.pending_output_transition().is_none()); + } + + #[test] + fn save_as_same_target_keeps_unrelated_pending_output_transition() { + let mut session = session_with_pending_transition(); + assert_eq!( + accept_save_as_preflight( + &mut session, + PersistenceOutcome::SaveAsPreflight { + same_target: true, + overwrite_required: true, + }, + SaveAsOverwrite::Deny, + Path::new("/tmp/current.wayscriber-session"), + ) + .unwrap(), + SaveAsPreflightDecision::SameTarget + ); + assert!(session.pending_output_transition().is_some()); + } +} diff --git a/src/backend/wayland/session/tests.rs b/src/backend/wayland/session/tests.rs index 9bfc6403..7f0e917c 100644 --- a/src/backend/wayland/session/tests.rs +++ b/src/backend/wayland/session/tests.rs @@ -389,25 +389,25 @@ fn runtime_save_as_overwrite_preflight_reports_existing_artifacts() { stored_session::save_snapshot(&snapshot_for_board("transparent", 17), &primary_options) .expect("save existing target"); std::fs::write(sidecar_options.clear_marker_file_path(), b"stale clear").expect("stale clear"); - let session_state = SessionState::new(Some(current_options)); + let mut session_state = SessionState::new(Some(current_options)); assert!( !save_named_session_as_requires_overwrite( - &session_state, + &mut session_state, &fresh_options.session_file_path() ) .expect("fresh preflight") ); assert!( save_named_session_as_requires_overwrite( - &session_state, + &mut session_state, &primary_options.session_file_path() ) .expect("primary preflight") ); assert!( save_named_session_as_requires_overwrite( - &session_state, + &mut session_state, &sidecar_options.session_file_path() ) .expect("sidecar preflight") @@ -419,11 +419,11 @@ fn runtime_save_as_overwrite_preflight_skips_current_target() { let temp = crate::test_temp::tempdir().expect("tempdir"); let current_options = named_options(temp.path(), "current-save-as-preflight-self"); std::fs::write(current_options.backup_file_path(), b"stale backup").expect("stale backup"); - let session_state = SessionState::new(Some(current_options.clone())); + let mut session_state = SessionState::new(Some(current_options.clone())); assert!( !save_named_session_as_requires_overwrite( - &session_state, + &mut session_state, ¤t_options.session_file_path() ) .expect("current target preflight") @@ -2007,3 +2007,87 @@ fn output_transition_deferral_moves_deadline_forward() { Some(Duration::from_millis(500)) ); } + +fn open_named_session_runtime( + input_state: &mut InputState, + measurer: &crate::draw::TextMeasurer, + session_state: &mut SessionState, + target_path: &Path, + _now: Instant, +) -> Result { + let mut persistence = PersistenceController::start_for_test()?; + SessionTransaction { + input_state, + measurer, + session: session_state, + persistence: &mut persistence, + } + .open_named_session_runtime(target_path) +} + +fn save_named_session_as_runtime( + input_state: &mut InputState, + measurer: &crate::draw::TextMeasurer, + session_state: &mut SessionState, + target_path: &Path, + overwrite: stored_session::SaveAsOverwrite, + _now: Instant, +) -> Result { + let mut persistence = PersistenceController::start_for_test()?; + SessionTransaction { + input_state, + measurer, + session: session_state, + persistence: &mut persistence, + } + .save_named_session_as_runtime(target_path, overwrite) +} + +fn save_named_session_as_requires_overwrite( + session_state: &mut SessionState, + target_path: &Path, +) -> Result { + let mut input_state = test_input_state(); + let measurer = crate::draw::TextMeasurer::default(); + let mut persistence = PersistenceController::start_for_test()?; + SessionTransaction { + input_state: &mut input_state, + measurer: &measurer, + session: session_state, + persistence: &mut persistence, + } + .save_named_session_as_requires_overwrite(target_path) +} + +fn clear_current_session_runtime( + input_state: &mut InputState, + measurer: &crate::draw::TextMeasurer, + session_state: &mut SessionState, + _now: Instant, +) -> Result { + let mut persistence = PersistenceController::start_for_test()?; + SessionTransaction { + input_state, + measurer, + session: session_state, + persistence: &mut persistence, + } + .clear_current_session_runtime() +} + +fn clear_saved_tool_state_runtime( + input_state: &mut InputState, + measurer: &crate::draw::TextMeasurer, + session_state: &mut SessionState, + default_tool_state: stored_session::ToolStateSnapshot, + _now: Instant, +) -> Result { + let mut persistence = PersistenceController::start_for_test()?; + SessionTransaction { + input_state, + measurer, + session: session_state, + persistence: &mut persistence, + } + .clear_saved_tool_state_runtime(default_tool_state) +} diff --git a/src/backend/wayland/state/core/session.rs b/src/backend/wayland/state/core/session.rs index 36321e56..4509f814 100644 --- a/src/backend/wayland/state/core/session.rs +++ b/src/backend/wayland/state/core/session.rs @@ -1,6 +1,5 @@ use crate::input::state::{Toast, ToastPriority}; use std::path::{Path, PathBuf}; -use std::time::Instant; use anyhow::{Result, anyhow}; @@ -10,276 +9,94 @@ use crate::backend::wayland::{ session::{ PersistenceOperation, PersistenceOutcome, RuntimeClearSessionReport, RuntimeClearToolStateReport, RuntimeOpenSessionReport, RuntimeSaveAsSessionReport, - SaveStrategy, }, }; use crate::session::{ - self as stored_session, ClearToolStateOutcome, LoadSnapshotOutcome, SaveAsOverwrite, - SessionOptions, SessionSnapshot, ToolStateSnapshot, + self as stored_session, ClearToolStateOutcome, SaveAsOverwrite, ToolStateSnapshot, }; impl WaylandState { - #[allow(dead_code)] pub(in crate::backend::wayland) fn open_named_session_runtime( &mut self, target_path: &Path, ) -> Result { - let current_options = self - .session_options() - .cloned() - .ok_or_else(|| anyhow!("cannot open session without active session options"))?; - let previous_path = current_options.session_file_path(); - - let validation = session_save::run_persistence_operation( - self, - PersistenceOperation::ValidateNamedOpen { - path: target_path.to_path_buf(), - }, - )?; - accept_open_preflight(&mut self.session, validation)?; - - let saved_current = self.save_current_before_explicit_target_change(¤t_options)?; - let mut candidate_options = current_options; - candidate_options.set_named_file_target(target_path.to_path_buf()); - candidate_options.force_resume_persistence(); - - let outcome = session_save::run_persistence_operation( - self, - PersistenceOperation::LoadNamedCandidate { - options: candidate_options.clone(), - }, - )?; - let PersistenceOutcome::Load(load_outcome) = outcome else { - return Err(anyhow!("unexpected named-session load outcome")); - }; - let candidate_snapshot = named_candidate_snapshot(load_outcome, &candidate_options)?; - let loaded_board_data = candidate_snapshot.has_board_data(); - stored_session::apply_snapshot_replacing_boards( - &mut self.input_state, - self.render.text_measurer(), - candidate_snapshot, - &candidate_options, - )?; - self.refresh_runtime_ui_config_seeds(); - self.input_state - .set_session_preflight_options(Some(candidate_options.clone())); - self.input_state.clear_session_dirty(); - let opened_path = candidate_options.session_file_path(); - self.session - .commit_runtime_open(candidate_options.clone(), loaded_board_data); - - match session_save::run_persistence_operation( - self, - PersistenceOperation::RecordNamedOpened { - options: candidate_options, - }, - ) { - Ok(PersistenceOutcome::Unit) => {} - Ok(other) => log::warn!( - "Named session opened, but catalog worker returned an unexpected outcome: {other:?}" - ), - Err(err) => log::warn!( - "Named session opened, but recording it in the recent-session catalog failed: {err:#}" - ), + session_save::persistence_barrier(self)?; + let result = crate::backend::wayland::session::SessionTransaction { + input_state: &mut self.input_state, + measurer: self.render.text_measurer(), + session: &mut self.session, + persistence: &mut self.persistence, } - - Ok(RuntimeOpenSessionReport { - previous_path, - opened_path, - saved_current, - loaded_board_data, - }) + .open_named_session_runtime(target_path); + if result.is_ok() { + self.refresh_runtime_ui_config_seeds(); + } + result } - #[allow(dead_code)] pub(in crate::backend::wayland) fn save_named_session_as_runtime( &mut self, target_path: &Path, overwrite: SaveAsOverwrite, ) -> Result { - let current_options = self - .session_options() - .cloned() - .ok_or_else(|| anyhow!("cannot save session as without active session options"))?; - let previous_path = current_options.session_file_path(); - let mut target_options = current_options.clone(); - target_options.set_named_file_target(target_path.to_path_buf()); - target_options.force_resume_persistence(); - let preflight = session_save::run_persistence_operation( - self, - PersistenceOperation::SaveAsOverwritePreflight { - current_path: previous_path.clone(), - options: target_options.clone(), - }, - )?; - match accept_save_as_preflight(&mut self.session, preflight, overwrite, target_path)? { - SaveAsPreflightDecision::SameTarget => { - let saved = self.save_current_before_explicit_target_change(¤t_options)?; - return Ok(RuntimeSaveAsSessionReport { - previous_path: previous_path.clone(), - saved_path: previous_path, - switched_target: false, - saved, - saved_board_data: self.session.has_loaded_board_data(), - outcome: None, - written_size: None, - }); - } - SaveAsPreflightDecision::SwitchTarget => {} - } - - let snapshot = self - .input_state - .with_active_interaction_canceled_for_capture_with( - self.render.text_measurer(), - |input_state| stored_session::snapshot_from_input(input_state, &target_options), - ) - .ok_or_else(|| anyhow!("Save Session As has no session data to write"))?; - let outcome = session_save::run_persistence_operation( - self, - PersistenceOperation::SaveAs { - snapshot, - options: target_options.clone(), - overwrite, - }, - )?; - let PersistenceOutcome::SaveAs { - report, - committed_board_data, - } = outcome - else { - return Err(anyhow!("unexpected Save As worker outcome")); - }; - - self.input_state - .set_session_preflight_options(Some(target_options.clone())); - let _ = self.input_state.take_session_dirty(); - self.input_state.clear_session_dirty(); - let saved_path = target_options.session_file_path(); - self.session - .commit_runtime_save_as(target_options, Instant::now(), committed_board_data); + session_save::persistence_barrier(self)?; - Ok(RuntimeSaveAsSessionReport { - previous_path, - saved_path, - switched_target: true, - saved: true, - saved_board_data: committed_board_data, - outcome: Some(report.outcome), - written_size: Some(report.written_size), - }) + crate::backend::wayland::session::SessionTransaction { + input_state: &mut self.input_state, + measurer: self.render.text_measurer(), + session: &mut self.session, + persistence: &mut self.persistence, + } + .save_named_session_as_runtime(target_path, overwrite) } - #[allow(dead_code)] pub(in crate::backend::wayland) fn save_named_session_as_requires_overwrite( &mut self, target_path: &Path, ) -> Result { - let current_options = self - .session_options() - .cloned() - .ok_or_else(|| anyhow!("cannot save session as without active session options"))?; - let previous_path = current_options.session_file_path(); - let mut target_options = current_options; - target_options.set_named_file_target(target_path.to_path_buf()); - target_options.force_resume_persistence(); - let outcome = session_save::run_persistence_operation( - self, - PersistenceOperation::SaveAsOverwritePreflight { - current_path: previous_path, - options: target_options, - }, - )?; - let PersistenceOutcome::SaveAsPreflight { - same_target, - overwrite_required, - } = outcome - else { - return Err(anyhow!("unexpected Save As preflight outcome")); - }; - Ok(!same_target && overwrite_required) + session_save::persistence_barrier(self)?; + + crate::backend::wayland::session::SessionTransaction { + input_state: &mut self.input_state, + measurer: self.render.text_measurer(), + session: &mut self.session, + persistence: &mut self.persistence, + } + .save_named_session_as_requires_overwrite(target_path) } - #[allow(dead_code)] pub(in crate::backend::wayland) fn clear_current_session_runtime( &mut self, ) -> Result { ensure_destructive_session_config_available(self.session_config_failed)?; session_save::persistence_barrier(self)?; - let options = self - .session_options() - .cloned() - .ok_or_else(|| anyhow!("cannot clear session without active session options"))?; - let cleared_path = options.session_file_path(); - let empty_snapshot = SessionSnapshot { - active_board_id: self.input_state.board_id().to_string(), - boards: Vec::new(), - tool_state: None, - }; - let outcome = session_save::run_persistence_operation( - self, - PersistenceOperation::Save { - snapshot: empty_snapshot.clone(), - options: options.clone(), - strategy: SaveStrategy::Normal, - contentless_clear_boundary: true, - }, - )?; - let PersistenceOutcome::Save(save) = outcome else { - return Err(anyhow!("unexpected clear-session worker outcome")); - }; - if !save.committed() { - return Err(anyhow!( - "current session clear did not write a committed clear boundary" - )); + let result = crate::backend::wayland::session::SessionTransaction { + input_state: &mut self.input_state, + measurer: self.render.text_measurer(), + session: &mut self.session, + persistence: &mut self.persistence, } - stored_session::apply_snapshot_replacing_boards( - &mut self.input_state, - self.render.text_measurer(), - empty_snapshot, - &options, - )?; - self.refresh_runtime_ui_config_seeds(); - self.input_state - .set_session_preflight_options(Some(options)); - let _ = self.input_state.take_session_dirty(); - self.input_state.clear_session_dirty(); - self.session.commit_runtime_clear(Instant::now()); - Ok(RuntimeClearSessionReport { - cleared_path, - persisted: true, - }) + .clear_current_session_runtime(); + if result.is_ok() { + self.refresh_runtime_ui_config_seeds(); + } + result } - #[allow(dead_code)] pub(in crate::backend::wayland) fn clear_saved_tool_state_runtime( &mut self, ) -> Result { ensure_destructive_session_config_available(self.session_config_failed)?; let default_tool_state = ToolStateSnapshot::from_config(&self.config); - let (session_path, outcome) = if let Some(options) = self.session_options().cloned() { - let path = options.session_file_path(); - let outcome = session_save::run_persistence_operation( - self, - PersistenceOperation::ClearToolState { options }, - )?; - let PersistenceOutcome::ToolStateCleared(outcome) = outcome else { - return Err(anyhow!("unexpected clear-tool-state worker outcome")); - }; - (Some(path), Some(outcome)) - } else { - (None, None) - }; - stored_session::apply_tool_state_snapshot( - &mut self.input_state, - self.render.text_measurer(), - default_tool_state, - ); - self.input_state.mark_session_dirty(); - Ok(RuntimeClearToolStateReport { - session_path, - outcome, - }) + session_save::persistence_barrier(self)?; + + crate::backend::wayland::session::SessionTransaction { + input_state: &mut self.input_state, + measurer: self.render.text_measurer(), + session: &mut self.session, + persistence: &mut self.persistence, + } + .clear_saved_tool_state_runtime(default_tool_state) } pub(in crate::backend::wayland) fn handle_clear_saved_tool_state_action(&mut self) { @@ -332,148 +149,6 @@ impl WaylandState { }; Ok(forgotten) } - - fn save_current_before_explicit_target_change( - &mut self, - options: &SessionOptions, - ) -> Result { - session_save::persistence_barrier(self)?; - if !self.input_state.is_session_dirty() && !self.session.is_dirty() { - return Ok(false); - } - let snapshot = self - .input_state - .with_active_interaction_canceled_for_capture_with( - self.render.text_measurer(), - |input_state| stored_session::snapshot_from_input(input_state, options), - ); - let snapshot = if let Some(snapshot) = snapshot { - snapshot - } else if session_persistence_enabled(options) { - SessionSnapshot { - active_board_id: self.input_state.board_id().to_string(), - boards: Vec::new(), - tool_state: None, - } - } else { - return Err(anyhow!( - "current session has unsaved changes but persistence is disabled" - )); - }; - let outcome = session_save::run_persistence_operation( - self, - PersistenceOperation::Save { - snapshot, - options: options.clone(), - strategy: SaveStrategy::Normal, - contentless_clear_boundary: self.session.has_loaded_board_data(), - }, - )?; - let PersistenceOutcome::Save(save) = outcome else { - return Err(anyhow!("unexpected save-before-target-change outcome")); - }; - if !save.committed() { - return Err(anyhow!( - "current session had unsaved changes but no session file was written" - )); - } - let _ = self.input_state.take_session_dirty(); - self.session - .mark_saved(Instant::now(), save.committed_board_data); - Ok(true) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SaveAsPreflightDecision { - SameTarget, - SwitchTarget, -} - -fn accept_open_preflight( - session: &mut crate::backend::wayland::session::SessionState, - validation: PersistenceOutcome, -) -> Result<()> { - if !matches!(validation, PersistenceOutcome::Unit) { - return Err(anyhow!("unexpected named-session validation outcome")); - } - cancel_pending_output_transition_for_explicit_target(session, "Open"); - Ok(()) -} - -fn accept_save_as_preflight( - session: &mut crate::backend::wayland::session::SessionState, - preflight: PersistenceOutcome, - overwrite: SaveAsOverwrite, - target_path: &Path, -) -> Result { - let PersistenceOutcome::SaveAsPreflight { - same_target, - overwrite_required, - } = preflight - else { - return Err(anyhow!("unexpected Save As preflight outcome")); - }; - if same_target { - return Ok(SaveAsPreflightDecision::SameTarget); - } - if overwrite_required && matches!(overwrite, SaveAsOverwrite::Deny) { - return Err(anyhow!( - "Save Session As target already has session artifacts; overwrite confirmation required for {}", - target_path.display() - )); - } - cancel_pending_output_transition_for_explicit_target(session, "Save As"); - Ok(SaveAsPreflightDecision::SwitchTarget) -} - -fn cancel_pending_output_transition_for_explicit_target( - session: &mut crate::backend::wayland::session::SessionState, - operation: &str, -) { - if let Some(pending) = session.cancel_pending_output_transition() { - log::info!( - "{operation} superseded pending output transition from epoch {} to {:?}", - pending.source_epoch, - pending.physical_output_identity - ); - } -} - -fn named_candidate_snapshot( - outcome: LoadSnapshotOutcome, - options: &SessionOptions, -) -> Result { - match outcome { - LoadSnapshotOutcome::Loaded(snapshot) - | LoadSnapshotOutcome::LoadedFromBackup(snapshot) - | LoadSnapshotOutcome::LoadedFromRecovery(snapshot) => Ok(*snapshot), - LoadSnapshotOutcome::Empty => Err(anyhow!( - "named session file contains no usable session data: {}", - options.session_file_path().display() - )), - LoadSnapshotOutcome::EmptyAfterCorruption { backup_path } => Err(anyhow!( - "named session file could not be read: {}; a copy of it was saved to {}", - options.session_file_path().display(), - backup_path.display() - )), - LoadSnapshotOutcome::NonRegularArtifact { path } => Err(anyhow!( - "named session file is not a regular file: {}", - path.display() - )), - LoadSnapshotOutcome::ExpandedTooLarge { - path, - max_expanded_size, - } => Err(anyhow!( - "named session file expands beyond the {} byte safety limit: {}", - max_expanded_size, - path.display() - )), - } -} - -fn session_persistence_enabled(options: &SessionOptions) -> bool { - options.any_enabled() || options.restore_tool_state || options.persist_history } fn ensure_destructive_session_config_available(section_failed: bool) -> Result<()> { @@ -509,7 +184,6 @@ fn clear_tool_state_runtime_message(report: &RuntimeClearToolStateReport) -> Str #[cfg(test)] mod tests { use super::*; - use std::time::Instant; #[test] fn destructive_session_actions_fail_closed_after_session_config_fallback() { @@ -519,80 +193,4 @@ mod tests { ensure_destructive_session_config_available(false) .expect("a successfully loaded session section permits mutations"); } - - fn session_with_pending_transition() -> crate::backend::wayland::session::SessionState { - let options = SessionOptions::new(PathBuf::from("/tmp"), "source-output"); - let mut staged = options.clone(); - staged.set_output_identity(Some("target-output")); - let mut session = crate::backend::wayland::session::SessionState::new(Some(options)); - session.stage_output_transition(staged, Some("target-output".to_string()), Instant::now()); - session - } - - #[test] - fn open_cancels_pending_transition_only_after_accepted_preflight() { - let mut rejected = session_with_pending_transition(); - assert!( - accept_open_preflight(&mut rejected, PersistenceOutcome::HasArtifacts(false)).is_err() - ); - assert!(rejected.pending_output_transition().is_some()); - - let mut accepted = session_with_pending_transition(); - accept_open_preflight(&mut accepted, PersistenceOutcome::Unit).unwrap(); - assert!(accepted.pending_output_transition().is_none()); - } - - #[test] - fn save_as_keeps_pending_transition_until_preflight_is_accepted() { - let target = Path::new("/tmp/target.wayscriber-session"); - let mut denied = session_with_pending_transition(); - assert!( - accept_save_as_preflight( - &mut denied, - PersistenceOutcome::SaveAsPreflight { - same_target: false, - overwrite_required: true, - }, - SaveAsOverwrite::Deny, - target, - ) - .is_err() - ); - assert!(denied.pending_output_transition().is_some()); - - let mut accepted = session_with_pending_transition(); - assert_eq!( - accept_save_as_preflight( - &mut accepted, - PersistenceOutcome::SaveAsPreflight { - same_target: false, - overwrite_required: false, - }, - SaveAsOverwrite::Deny, - target, - ) - .unwrap(), - SaveAsPreflightDecision::SwitchTarget - ); - assert!(accepted.pending_output_transition().is_none()); - } - - #[test] - fn save_as_same_target_keeps_unrelated_pending_output_transition() { - let mut session = session_with_pending_transition(); - assert_eq!( - accept_save_as_preflight( - &mut session, - PersistenceOutcome::SaveAsPreflight { - same_target: true, - overwrite_required: true, - }, - SaveAsOverwrite::Deny, - Path::new("/tmp/current.wayscriber-session"), - ) - .unwrap(), - SaveAsPreflightDecision::SameTarget - ); - assert!(session.pending_output_transition().is_some()); - } }