Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/app/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
Expand Down
161 changes: 157 additions & 4 deletions src/backend/wayland/backend/event_loop/session_save.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(),
Expand All @@ -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(());
}
Expand All @@ -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,
Expand All @@ -95,13 +102,14 @@ fn save_snapshot_or_clear(
state: &WaylandState,
options: &session::SessionOptions,
snapshot: Option<session::SessionSnapshot>,
reason: SessionSaveReason,
) -> Result<Option<SaveSnapshotReport>, 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) {
Expand All @@ -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<Option<SaveSnapshotReport>, 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)]
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/backend/wayland/backend/state_init/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
70 changes: 66 additions & 4 deletions src/backend/wayland/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub struct SessionState {
last_dirty_at: Option<Instant>,
last_save_at: Option<Instant>,
autosave_retry_at: Option<Instant>,
autosave_deferred_until: Option<Instant>,
notified_failure: bool,
notified_near_limit_paths: HashSet<PathBuf>,
notified_trimmed_history: bool,
Expand All @@ -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,
Expand Down Expand Up @@ -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;
}

Expand All @@ -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 {
Expand All @@ -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())
}
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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))
}
}
Expand Down Expand Up @@ -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");
Expand Down
14 changes: 14 additions & 0 deletions src/backend/wayland/state/core/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
Loading
Loading