Skip to main content

incular_runtime/
restoration.rs

1//! Versioned, opt-in declarative application restoration.
2//!
3//! This module deliberately stores only application-supplied JSON values and
4//! stable descriptors. Retained widgets, native windows, GPU objects, tasks,
5//! callbacks, gestures, and semantics are never part of a snapshot.
6
7use std::{
8    collections::{BTreeMap, BTreeSet, HashSet},
9    fmt,
10    fs::{self, File},
11    io::{Read, Write},
12    path::{Path, PathBuf},
13    rc::{Rc, Weak},
14    sync::{Arc, Mutex},
15    time::Duration,
16};
17
18use serde::{Deserialize, Serialize, de::DeserializeOwned};
19use serde_json::Value;
20
21use incular_core::{RestorationBackend, RestorationKey, RestorationScope};
22
23use crate::{Signal, tasks};
24
25/// The Incular-owned snapshot format. It is intentionally independent from
26/// the application's own restoration schema version.
27pub const FRAMEWORK_RESTORATION_FORMAT_VERSION: u32 = 1;
28
29/// Normal local-state debounce. Writes are coalesced and happen on Tokio's
30/// blocking pool, never on an ordinary UI mutation.
31pub const DEFAULT_RESTORATION_DEBOUNCE: Duration = Duration::from_millis(250);
32
33/// A deliberately generous bound for small declarative session state. This is
34/// not a database or a place for files, images, credentials, or tokens.
35pub const DEFAULT_RESTORATION_SNAPSHOT_LIMIT: usize = 8 * 1024 * 1024;
36
37/// Maximum number of automatic retry attempts after a failed save. Retries
38/// use bounded exponential backoff; a new mutation always schedules a normal
39/// debounced save regardless of this counter.
40pub const MAX_RESTORATION_SAVE_RETRIES: u32 = 3;
41
42/// Base delay for the bounded retry backoff.
43pub const RESTORATION_RETRY_BACKOFF: Duration = Duration::from_millis(500);
44
45/// Error reported by a persistence store. Store errors are diagnostics, not
46/// application-fatal failures.
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct RestorationStoreError {
49    message: String,
50}
51
52impl RestorationStoreError {
53    #[must_use]
54    pub fn new(message: impl Into<String>) -> Self {
55        Self {
56            message: message.into(),
57        }
58    }
59}
60
61impl fmt::Display for RestorationStoreError {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        formatter.write_str(&self.message)
64    }
65}
66
67impl std::error::Error for RestorationStoreError {}
68
69/// Small persistence boundary for restoration snapshots. Implementations must
70/// leave the previous valid snapshot untouched when `save` returns an error.
71pub trait RestorationStore: Send + Sync + 'static {
72    fn load(&self) -> Result<Option<Vec<u8>>, RestorationStoreError>;
73    fn save(&self, bytes: &[u8]) -> Result<(), RestorationStoreError>;
74    fn remove(&self) -> Result<(), RestorationStoreError>;
75
76    /// Returns a human-readable location when one exists. In-memory stores
77    /// intentionally return `None`.
78    fn location(&self) -> Option<PathBuf> {
79        None
80    }
81}
82
83/// Deterministic store for tests and embedding. It also supports injected
84/// write failures so crash-safety behaviour can be verified without a real
85/// user directory.
86#[derive(Default)]
87pub struct InMemoryRestorationStore {
88    bytes: Mutex<Option<Vec<u8>>>,
89    fail_next_save: Mutex<Option<String>>,
90}
91
92impl InMemoryRestorationStore {
93    #[must_use]
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    pub fn fail_next_save(&self, message: impl Into<String>) {
99        *self
100            .fail_next_save
101            .lock()
102            .expect("restoration test-store failure mutex") = Some(message.into());
103    }
104
105    #[must_use]
106    pub fn bytes(&self) -> Option<Vec<u8>> {
107        self.bytes
108            .lock()
109            .expect("restoration test-store bytes mutex")
110            .clone()
111    }
112
113    pub fn set_bytes(&self, bytes: Option<Vec<u8>>) {
114        *self
115            .bytes
116            .lock()
117            .expect("restoration test-store bytes mutex") = bytes;
118    }
119}
120
121impl RestorationStore for InMemoryRestorationStore {
122    fn load(&self) -> Result<Option<Vec<u8>>, RestorationStoreError> {
123        Ok(self.bytes())
124    }
125
126    fn save(&self, bytes: &[u8]) -> Result<(), RestorationStoreError> {
127        if let Some(message) = self
128            .fail_next_save
129            .lock()
130            .expect("restoration test-store failure mutex")
131            .take()
132        {
133            return Err(RestorationStoreError::new(message));
134        }
135        *self
136            .bytes
137            .lock()
138            .expect("restoration test-store bytes mutex") = Some(bytes.to_vec());
139        Ok(())
140    }
141
142    fn remove(&self) -> Result<(), RestorationStoreError> {
143        *self
144            .bytes
145            .lock()
146            .expect("restoration test-store bytes mutex") = None;
147        Ok(())
148    }
149}
150
151/// File-backed default persistence store. A new snapshot is completely
152/// flushed to a temporary sibling and then renamed into place. On platforms
153/// where replacement cannot be atomic, the rename fails rather than deleting
154/// the only known-good file; the next mutation may retry it. This guarantees
155/// that a reported failed save never overwrites the previous valid snapshot,
156/// while filesystem/power-loss durability remains platform/filesystem-specific.
157#[derive(Clone, Debug)]
158pub struct FileRestorationStore {
159    path: PathBuf,
160}
161
162impl FileRestorationStore {
163    #[must_use]
164    pub fn new(path: impl Into<PathBuf>) -> Self {
165        Self { path: path.into() }
166    }
167
168    /// Derives an application-specific user state path with the operating
169    /// system's standard project-directory conventions. `application_id` must
170    /// be stable across launches and filesystem-safe: empty, path-separator,
171    /// current/parent-directory, NUL, and control-character ids are rejected
172    /// rather than escaping the application data directory.
173    pub fn for_application(application_id: &str) -> Result<Self, RestorationStoreError> {
174        validate_application_id(application_id)?;
175        let base = incular_platform::application_data_local_directory(application_id).ok_or_else(
176            || {
177                RestorationStoreError::new(
178                    "standard application data directory is unavailable for restoration",
179                )
180            },
181        )?;
182        Ok(Self::new(base.join("restoration.json")))
183    }
184
185    #[must_use]
186    pub fn path(&self) -> &Path {
187        &self.path
188    }
189}
190
191impl RestorationStore for FileRestorationStore {
192    fn load(&self) -> Result<Option<Vec<u8>>, RestorationStoreError> {
193        match File::open(&self.path) {
194            Ok(mut file) => {
195                let mut bytes = Vec::new();
196                file.read_to_end(&mut bytes)
197                    .map_err(|error| RestorationStoreError::new(error.to_string()))?;
198                Ok(Some(bytes))
199            }
200            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
201            Err(error) => Err(RestorationStoreError::new(error.to_string())),
202        }
203    }
204
205    fn save(&self, bytes: &[u8]) -> Result<(), RestorationStoreError> {
206        let parent = self.path.parent().ok_or_else(|| {
207            RestorationStoreError::new("restoration snapshot path has no parent directory")
208        })?;
209        fs::create_dir_all(parent)
210            .map_err(|error| RestorationStoreError::new(error.to_string()))?;
211        let mut temporary = tempfile::NamedTempFile::new_in(parent)
212            .map_err(|error| RestorationStoreError::new(error.to_string()))?;
213        temporary
214            .write_all(bytes)
215            .and_then(|()| temporary.flush())
216            .and_then(|()| temporary.as_file().sync_all())
217            .map_err(|error| RestorationStoreError::new(error.to_string()))?;
218        temporary
219            .persist(&self.path)
220            .map_err(|error| RestorationStoreError::new(error.error.to_string()))?;
221        // Syncing the directory is unavailable on some desktop platforms; the
222        // snapshot file itself has been flushed before replacement.
223        Ok(())
224    }
225
226    fn remove(&self) -> Result<(), RestorationStoreError> {
227        match fs::remove_file(&self.path) {
228            Ok(()) => Ok(()),
229            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
230            Err(error) => Err(RestorationStoreError::new(error.to_string())),
231        }
232    }
233
234    fn location(&self) -> Option<PathBuf> {
235        Some(self.path.clone())
236    }
237}
238
239/// Stable configuration for an opt-in application restoration session.
240#[derive(Clone)]
241pub struct RestorationConfig {
242    application_id: String,
243    application_schema_version: u32,
244    store: Arc<dyn RestorationStore>,
245    snapshot_limit: usize,
246    debounce: Duration,
247    migration: Option<RestorationMigration>,
248}
249
250impl fmt::Debug for RestorationConfig {
251    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
252        formatter
253            .debug_struct("RestorationConfig")
254            .field("application_id", &self.application_id)
255            .field(
256                "application_schema_version",
257                &self.application_schema_version,
258            )
259            .field("snapshot_limit", &self.snapshot_limit)
260            .field("debounce", &self.debounce)
261            .field("store_location", &self.store.location())
262            .finish_non_exhaustive()
263    }
264}
265
266/// Application-owned migration invoked before any restored value is exposed to
267/// a UI build. It receives the old schema version and the complete JSON
268/// document; failures safely fall back to default state.
269pub type RestorationMigration = Arc<dyn Fn(u32, &mut Value) -> Result<(), String> + 'static>;
270
271impl RestorationConfig {
272    #[must_use]
273    pub fn new(
274        application_id: impl Into<String>,
275        application_schema_version: u32,
276        store: Arc<dyn RestorationStore>,
277    ) -> Self {
278        Self {
279            application_id: application_id.into(),
280            application_schema_version,
281            store,
282            snapshot_limit: DEFAULT_RESTORATION_SNAPSHOT_LIMIT,
283            debounce: DEFAULT_RESTORATION_DEBOUNCE,
284            migration: None,
285        }
286    }
287
288    pub fn file_backed(
289        application_id: impl Into<String>,
290        application_schema_version: u32,
291    ) -> Result<Self, RestorationStoreError> {
292        let application_id = application_id.into();
293        let store = Arc::new(FileRestorationStore::for_application(&application_id)?);
294        Ok(Self::new(application_id, application_schema_version, store))
295    }
296
297    #[must_use]
298    pub fn with_snapshot_limit(mut self, bytes: usize) -> Self {
299        self.snapshot_limit = bytes.max(1);
300        self
301    }
302
303    #[must_use]
304    pub fn with_debounce(mut self, debounce: Duration) -> Self {
305        self.debounce = debounce;
306        self
307    }
308
309    #[must_use]
310    pub fn with_migration(mut self, migration: RestorationMigration) -> Self {
311        self.migration = Some(migration);
312        self
313    }
314
315    #[must_use]
316    pub fn application_id(&self) -> &str {
317        &self.application_id
318    }
319
320    #[must_use]
321    pub const fn application_schema_version(&self) -> u32 {
322        self.application_schema_version
323    }
324}
325
326/// Native-free, stable data for a restorable window. DPI, physical framebuffer
327/// dimensions, positions, native handles, and runtime `WindowId`s are omitted.
328#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
329pub struct RestoredWindow {
330    pub restoration_id: String,
331    pub kind: String,
332    pub logical_width: f32,
333    pub logical_height: f32,
334    pub maximized: bool,
335    pub fullscreen: bool,
336}
337
338#[derive(Clone, Debug, Serialize, Deserialize)]
339struct SnapshotDocument {
340    framework_format_version: u32,
341    application_schema_version: u32,
342    snapshot_generation: u64,
343    application_id: String,
344    #[serde(default)]
345    state: BTreeMap<String, Value>,
346    #[serde(default)]
347    windows: Vec<RestoredWindow>,
348}
349
350impl SnapshotDocument {
351    fn empty(config: &RestorationConfig) -> Self {
352        Self {
353            framework_format_version: FRAMEWORK_RESTORATION_FORMAT_VERSION,
354            application_schema_version: config.application_schema_version,
355            snapshot_generation: 0,
356            application_id: config.application_id.clone(),
357            state: BTreeMap::new(),
358            windows: Vec::new(),
359        }
360    }
361}
362
363/// Value-free restoration health and write-coalescing diagnostics.
364///
365/// Non-exhaustive so future counters do not break downstream construction;
366/// read the fields and compare with `==` as before.
367#[derive(Clone, Debug, Default, PartialEq, Eq)]
368#[non_exhaustive]
369pub struct RestorationDiagnostics {
370    pub restoration_loads: u64,
371    pub restoration_load_failures: u64,
372    pub restoration_migrations: u64,
373    pub restoration_values: usize,
374    pub restoration_scopes: usize,
375    pub dirty_generation: u64,
376    pub persisted_generation: u64,
377    pub save_requests: u64,
378    pub save_coalesced: u64,
379    pub saves_started: u64,
380    pub saves_completed: u64,
381    pub save_failures: u64,
382    pub snapshot_bytes: usize,
383    pub peak_snapshot_bytes: usize,
384    pub restored_windows: u64,
385    pub skipped_windows: u64,
386    pub restored_routes: u64,
387    pub invalid_routes: u64,
388    pub restored_scroll_positions: u64,
389    pub stale_or_invalid_values: u64,
390    /// Message of the most recent save failure, if any. Cleared after the
391    /// next successful save. This makes permanent conditions such as an
392    /// oversized snapshot diagnosable without a debugger.
393    pub last_save_error: Option<String>,
394}
395
396struct ManagerState {
397    snapshot: SnapshotDocument,
398    diagnostics: RestorationDiagnostics,
399    dirty: bool,
400    debounce_pending: bool,
401    save_in_flight: bool,
402    consecutive_save_failures: u32,
403    retry_pending: bool,
404    persist_migrated_snapshot: bool,
405    active_scope_paths: HashSet<String>,
406}
407
408struct RestorationManagerInner {
409    config: RestorationConfig,
410    state: std::cell::RefCell<ManagerState>,
411    scheduler: std::cell::RefCell<Option<Weak<std::cell::RefCell<tasks::TaskScheduler>>>>,
412}
413
414/// Runtime-owned manager. The core restoration scope adapts to this manager;
415/// applications normally interact through `BuildContext` and `Restorable<T>`.
416#[derive(Clone)]
417pub(crate) struct RestorationManager {
418    inner: Rc<RestorationManagerInner>,
419}
420
421/// Cloneable UI-thread capability for explicit restoration actions. It is safe
422/// to retain in callbacks: operations update only the in-memory declarative
423/// snapshot and schedule normal Tokio-backed persistence work.
424#[derive(Clone)]
425pub struct RestorationHandle {
426    manager: RestorationManager,
427}
428
429impl RestorationHandle {
430    pub(crate) fn new(manager: RestorationManager) -> Self {
431        Self { manager }
432    }
433
434    pub fn reset(&self) {
435        self.manager.reset();
436    }
437
438    pub fn flush(&self) {
439        self.manager.flush();
440    }
441
442    /// Explicitly removes all values below a stable scope. This is suitable
443    /// for a permanently popped restorable route; temporary rebuilds should
444    /// keep their scope intact.
445    pub fn remove_scope(&self, scope: &RestorationScope) -> usize {
446        self.manager.remove_scope(scope.path())
447    }
448
449    /// Records navigation recovery without exposing live navigator internals.
450    pub fn note_navigation_restore(&self, restored_routes: u64, invalid_routes: u64) {
451        self.manager
452            .note_navigation_restore(restored_routes, invalid_routes);
453    }
454
455    /// Claims one stable scope path for the guard's lifetime. A second live
456    /// claim of the same path fails instead of silently sharing persistence
457    /// state; windows already hold claims for their own scopes, so use this
458    /// for application-owned dynamic scopes that must stay unique while
459    /// mounted. Dropping the guard releases the path.
460    pub fn claim_scope(&self, scope: &RestorationScope) -> Result<ClaimedRestorationScope, String> {
461        self.manager
462            .acquire_scope(scope.path())
463            .map(|lease| ClaimedRestorationScope { _lease: lease })
464    }
465
466    #[must_use]
467    pub fn diagnostics(&self) -> RestorationDiagnostics {
468        self.manager.diagnostics()
469    }
470
471    #[must_use]
472    pub fn path(&self) -> Option<PathBuf> {
473        self.manager.store_location()
474    }
475}
476
477/// Live claim on one stable restoration scope path. Dropping releases the
478/// path so a later mount may claim it; see
479/// [`RestorationHandle::claim_scope`].
480pub struct ClaimedRestorationScope {
481    _lease: ScopeLease,
482}
483
484impl RestorationManager {
485    pub(crate) fn load(config: RestorationConfig) -> Self {
486        let mut diagnostics = RestorationDiagnostics::default();
487        let mut snapshot = SnapshotDocument::empty(&config);
488        match config.store.load() {
489            Ok(Some(bytes)) => {
490                diagnostics.restoration_loads = 1;
491                if bytes.len() > config.snapshot_limit {
492                    // Fail safe without destroying data: an oversized snapshot
493                    // cannot be reasoned about in memory, so expose defaults
494                    // and let the next debounced save report the size error.
495                    diagnostics.restoration_load_failures = 1;
496                } else {
497                    diagnostics.snapshot_bytes = bytes.len();
498                    diagnostics.peak_snapshot_bytes = bytes.len();
499                    match decode_snapshot(&config, &bytes, &mut diagnostics) {
500                        Ok(decoded) => snapshot = decoded,
501                        Err(()) => diagnostics.restoration_load_failures += 1,
502                    }
503                }
504            }
505            Ok(None) => diagnostics.restoration_loads = 1,
506            Err(_) => {
507                diagnostics.restoration_loads = 1;
508                diagnostics.restoration_load_failures = 1;
509            }
510        }
511        diagnostics.restoration_values = snapshot.state.len();
512        diagnostics.restoration_scopes = scope_count(&snapshot.state);
513        diagnostics.dirty_generation = snapshot.snapshot_generation;
514        diagnostics.persisted_generation = snapshot.snapshot_generation;
515        let migrated = diagnostics.restoration_migrations > 0;
516        Self {
517            inner: Rc::new(RestorationManagerInner {
518                config,
519                state: std::cell::RefCell::new(ManagerState {
520                    snapshot,
521                    diagnostics,
522                    dirty: false,
523                    debounce_pending: false,
524                    save_in_flight: false,
525                    consecutive_save_failures: 0,
526                    retry_pending: false,
527                    persist_migrated_snapshot: migrated,
528                    active_scope_paths: HashSet::new(),
529                }),
530                scheduler: std::cell::RefCell::new(None),
531            }),
532        }
533    }
534
535    pub(crate) fn attach_scheduler(
536        &self,
537        scheduler: &Rc<std::cell::RefCell<tasks::TaskScheduler>>,
538    ) {
539        *self.inner.scheduler.borrow_mut() = Some(Rc::downgrade(scheduler));
540        // A load-time migration produced a document the store does not yet
541        // hold; persist it once so a write-less session does not re-run the
542        // migration on every launch.
543        if self.inner.state.borrow().persist_migrated_snapshot {
544            self.inner.state.borrow_mut().persist_migrated_snapshot = false;
545            self.mark_dirty();
546        }
547    }
548
549    pub(crate) fn read(&self, path: &[RestorationKey]) -> Option<Value> {
550        self.inner
551            .state
552            .borrow()
553            .snapshot
554            .state
555            .get(&encode_path(path))
556            .cloned()
557    }
558
559    pub(crate) fn write(&self, path: &[RestorationKey], value: Value) {
560        let encoded = encode_path(path);
561        let mut state = self.inner.state.borrow_mut();
562        if state.snapshot.state.get(&encoded) == Some(&value) {
563            return;
564        }
565        state.snapshot.state.insert(encoded, value);
566        refresh_counts(&mut state);
567        drop(state);
568        self.mark_dirty();
569    }
570
571    pub(crate) fn remove(&self, path: &[RestorationKey]) -> bool {
572        let mut state = self.inner.state.borrow_mut();
573        let removed = state.snapshot.state.remove(&encode_path(path)).is_some();
574        if removed {
575            refresh_counts(&mut state);
576        }
577        drop(state);
578        if removed {
579            self.mark_dirty();
580        }
581        removed
582    }
583
584    pub(crate) fn remove_scope(&self, path: &[RestorationKey]) -> usize {
585        let prefix = encode_path(path);
586        let prefix_with_separator = format!("{prefix}/");
587        let mut state = self.inner.state.borrow_mut();
588        let before = state.snapshot.state.len();
589        state
590            .snapshot
591            .state
592            .retain(|key, _| key != &prefix && !key.starts_with(&prefix_with_separator));
593        let removed = before - state.snapshot.state.len();
594        if removed > 0 {
595            refresh_counts(&mut state);
596        }
597        drop(state);
598        if removed > 0 {
599            self.mark_dirty();
600        }
601        removed
602    }
603
604    pub(crate) fn reset(&self) {
605        let mut state = self.inner.state.borrow_mut();
606        state.snapshot.state.clear();
607        state.snapshot.windows.clear();
608        refresh_counts(&mut state);
609        drop(state);
610        self.mark_dirty();
611    }
612
613    pub(crate) fn replace_windows(&self, windows: Vec<RestoredWindow>) {
614        let mut state = self.inner.state.borrow_mut();
615        if state.snapshot.windows == windows {
616            return;
617        }
618        state.snapshot.windows = windows;
619        drop(state);
620        self.mark_dirty();
621    }
622
623    #[must_use]
624    pub(crate) fn windows(&self) -> Vec<RestoredWindow> {
625        self.inner.state.borrow().snapshot.windows.clone()
626    }
627
628    #[must_use]
629    pub(crate) fn diagnostics(&self) -> RestorationDiagnostics {
630        self.inner.state.borrow().diagnostics.clone()
631    }
632
633    #[must_use]
634    pub(crate) fn store_location(&self) -> Option<PathBuf> {
635        self.inner.config.store.location()
636    }
637
638    pub(crate) fn note_restored_window(&self) {
639        self.inner.state.borrow_mut().diagnostics.restored_windows += 1;
640    }
641
642    pub(crate) fn note_skipped_window(&self) {
643        self.inner.state.borrow_mut().diagnostics.skipped_windows += 1;
644    }
645
646    pub(crate) fn note_navigation_restore(&self, restored_routes: u64, invalid_routes: u64) {
647        let mut state = self.inner.state.borrow_mut();
648        state.diagnostics.restored_routes += restored_routes;
649        state.diagnostics.invalid_routes += invalid_routes;
650    }
651
652    pub(crate) fn record_restoration_outcome(&self, restored: u64, invalid: u64) {
653        let mut state = self.inner.state.borrow_mut();
654        state.diagnostics.restored_scroll_positions += restored;
655        state.diagnostics.stale_or_invalid_values += invalid;
656    }
657
658    /// Removes one persisted auxiliary-window descriptor by stable id.
659    /// Returns true when a descriptor was removed and persistence was marked
660    /// dirty; unopened descriptors are otherwise left intact.
661    pub(crate) fn remove_window_descriptor(&self, restoration_id: &str) -> bool {
662        let mut state = self.inner.state.borrow_mut();
663        let before = state.snapshot.windows.len();
664        state
665            .snapshot
666            .windows
667            .retain(|window| window.restoration_id != restoration_id);
668        let removed = state.snapshot.windows.len() != before;
669        drop(state);
670        if removed {
671            self.mark_dirty();
672        }
673        removed
674    }
675
676    pub(crate) fn scope(&self) -> RestorationScope {
677        RestorationScope::root(Rc::new(self.clone()))
678    }
679
680    pub(crate) fn application_scope(&self) -> RestorationScope {
681        self.scope()
682            .child_unchecked(RestorationKey::new("application").expect("static key"))
683    }
684
685    /// Acquires a sibling scope identity for the duration of the returned
686    /// lease. A second concurrently mounted scope at the same stable path is
687    /// a programming error, never an auto-numbered persistence target.
688    pub(crate) fn acquire_scope(&self, path: &[RestorationKey]) -> Result<ScopeLease, String> {
689        let path = encode_path(path);
690        let mut state = self.inner.state.borrow_mut();
691        if !state.active_scope_paths.insert(path.clone()) {
692            return Err(format!("duplicate restoration sibling scope `{path}`"));
693        }
694        Ok(ScopeLease {
695            manager: Rc::downgrade(&self.inner),
696            path,
697        })
698    }
699
700    fn mark_dirty(&self) {
701        {
702            let mut state = self.inner.state.borrow_mut();
703            state.dirty = true;
704            state.diagnostics.dirty_generation = state.diagnostics.dirty_generation.wrapping_add(1);
705            state.snapshot.snapshot_generation = state.diagnostics.dirty_generation;
706            state.diagnostics.save_requests += 1;
707            state.retry_pending = false;
708            if state.save_in_flight || state.debounce_pending {
709                state.diagnostics.save_coalesced += 1;
710                return;
711            }
712            state.debounce_pending = true;
713        }
714        self.schedule_debounce();
715    }
716
717    fn schedule_debounce(&self) {
718        let Some(scheduler) = self
719            .inner
720            .scheduler
721            .borrow()
722            .as_ref()
723            .and_then(Weak::upgrade)
724        else {
725            return;
726        };
727        let manager = self.clone();
728        let debounce = self.inner.config.debounce;
729        tasks::TaskScheduler::spawner(&scheduler).spawn_into(
730            async move { tokio::time::sleep(debounce).await },
731            move |result, _runtime| {
732                if result.is_ok() {
733                    manager.start_save();
734                }
735            },
736        );
737    }
738
739    pub(crate) fn flush(&self) {
740        {
741            let mut state = self.inner.state.borrow_mut();
742            state.debounce_pending = false;
743        }
744        self.start_save();
745    }
746
747    #[must_use]
748    pub(crate) fn is_clean(&self) -> bool {
749        let state = self.inner.state.borrow();
750        !state.dirty && !state.save_in_flight && !state.debounce_pending
751    }
752
753    fn start_save(&self) {
754        let (snapshot, generation) = {
755            let mut state = self.inner.state.borrow_mut();
756            state.debounce_pending = false;
757            if !state.dirty || state.save_in_flight {
758                return;
759            }
760            state.save_in_flight = true;
761            state.diagnostics.saves_started += 1;
762            (state.snapshot.clone(), state.diagnostics.dirty_generation)
763        };
764        let Some(scheduler) = self
765            .inner
766            .scheduler
767            .borrow()
768            .as_ref()
769            .and_then(Weak::upgrade)
770        else {
771            self.finish_save(
772                generation,
773                Err(RestorationStoreError::new(
774                    "runtime scheduler is unavailable",
775                )),
776                0,
777            );
778            return;
779        };
780        let store = self.inner.config.store.clone();
781        let limit = self.inner.config.snapshot_limit;
782        let manager = self.clone();
783        tasks::TaskScheduler::spawner(&scheduler).spawn_blocking(
784            move || {
785                let bytes = serde_json::to_vec(&snapshot)
786                    .map_err(|error| RestorationStoreError::new(error.to_string()))?;
787                if bytes.len() > limit {
788                    return Err(RestorationStoreError::new(format!(
789                        "restoration snapshot is {} bytes, exceeding configured {limit}-byte limit",
790                        bytes.len()
791                    )));
792                }
793                store.save(&bytes)?;
794                Ok(bytes.len())
795            },
796            move |result, _runtime| match result {
797                Ok(Ok(bytes)) => manager.finish_save(generation, Ok(()), bytes),
798                Ok(Err(error)) => manager.finish_save(generation, Err(error), 0),
799                Err(_) => manager.finish_save(
800                    generation,
801                    Err(RestorationStoreError::new(
802                        "restoration save task did not complete",
803                    )),
804                    0,
805                ),
806            },
807        );
808    }
809
810    fn finish_save(
811        &self,
812        generation: u64,
813        result: Result<(), RestorationStoreError>,
814        bytes: usize,
815    ) {
816        let needs_latest;
817        {
818            let mut state = self.inner.state.borrow_mut();
819            state.save_in_flight = false;
820            match result {
821                Ok(()) => {
822                    state.diagnostics.saves_completed += 1;
823                    state.diagnostics.last_save_error = None;
824                    state.consecutive_save_failures = 0;
825                    state.retry_pending = false;
826                    state.diagnostics.snapshot_bytes = bytes;
827                    state.diagnostics.peak_snapshot_bytes =
828                        state.diagnostics.peak_snapshot_bytes.max(bytes);
829                    state.diagnostics.persisted_generation = generation;
830                    state.dirty = state.diagnostics.dirty_generation != generation;
831                    needs_latest = state.dirty;
832                }
833                Err(error) => {
834                    state.diagnostics.save_failures += 1;
835                    state.diagnostics.last_save_error = Some(error.to_string());
836                    state.dirty = true;
837                    // Preserve the last known-good file and retry with bounded
838                    // backoff. A tight retry loop would defeat coalescing and
839                    // obscure crash-safety diagnostics, so a retry is scheduled
840                    // only while the bounded budget remains.
841                    state.consecutive_save_failures += 1;
842                    needs_latest = state.consecutive_save_failures <= MAX_RESTORATION_SAVE_RETRIES;
843                }
844            }
845        }
846        if needs_latest {
847            let retry = {
848                let mut state = self.inner.state.borrow_mut();
849                if !state.dirty || state.debounce_pending || state.save_in_flight {
850                    return;
851                }
852                let failed = state.consecutive_save_failures > 0;
853                state.retry_pending = failed;
854                failed
855            };
856            if retry {
857                self.schedule_retry();
858            } else {
859                self.schedule_after_in_flight();
860            }
861        }
862    }
863
864    fn schedule_retry(&self) {
865        let attempt = self
866            .inner
867            .state
868            .borrow()
869            .consecutive_save_failures
870            .clamp(1, MAX_RESTORATION_SAVE_RETRIES);
871        let delay = RESTORATION_RETRY_BACKOFF * attempt;
872        let Some(scheduler) = self
873            .inner
874            .scheduler
875            .borrow()
876            .as_ref()
877            .and_then(Weak::upgrade)
878        else {
879            return;
880        };
881        let manager = self.clone();
882        tasks::TaskScheduler::spawner(&scheduler).spawn_into(
883            async move { tokio::time::sleep(delay).await },
884            move |result, _runtime| {
885                if result.is_ok() {
886                    let mut state = manager.inner.state.borrow_mut();
887                    state.retry_pending = false;
888                    if state.dirty && !state.save_in_flight && !state.debounce_pending {
889                        drop(state);
890                        manager.start_save();
891                    }
892                }
893            },
894        );
895    }
896
897    fn schedule_after_in_flight(&self) {
898        let mut state = self.inner.state.borrow_mut();
899        if state.debounce_pending || state.save_in_flight {
900            return;
901        }
902        state.debounce_pending = true;
903        state.diagnostics.save_coalesced += 1;
904        drop(state);
905        self.schedule_debounce();
906    }
907
908    pub(crate) fn debug_dump(&self) -> String {
909        let state = self.inner.state.borrow();
910        let diagnostics = &state.diagnostics;
911        let mut lines = vec![format!(
912            "Restoration generation={} persisted={} bytes={}",
913            diagnostics.dirty_generation,
914            diagnostics.persisted_generation,
915            diagnostics.snapshot_bytes
916        )];
917        let mut scopes: BTreeMap<String, usize> = BTreeMap::new();
918        for key in state.snapshot.state.keys() {
919            let mut segments = key.split('/').collect::<Vec<_>>();
920            let _ = segments.pop();
921            let scope = if segments.is_empty() {
922                "application".to_owned()
923            } else {
924                segments.join("/")
925            };
926            *scopes.entry(scope).or_default() += 1;
927        }
928        for (scope, values) in scopes {
929            lines.push(format!("  {scope}/ ({values} values)"));
930        }
931        lines.join("\n")
932    }
933}
934
935/// Duplicate-sibling guard held by a mounted restoration scope.
936pub(crate) struct ScopeLease {
937    manager: Weak<RestorationManagerInner>,
938    path: String,
939}
940
941impl Drop for ScopeLease {
942    fn drop(&mut self) {
943        if let Some(manager) = self.manager.upgrade() {
944            manager
945                .state
946                .borrow_mut()
947                .active_scope_paths
948                .remove(&self.path);
949        }
950    }
951}
952
953fn validate_application_id(application_id: &str) -> Result<(), RestorationStoreError> {
954    let rejected = application_id.trim().is_empty()
955        || application_id.contains(['/', '\\', '\0'])
956        || application_id.chars().any(char::is_control)
957        || application_id
958            .split(['/', '\\'])
959            .any(|segment| segment == "." || segment == "..");
960    if rejected {
961        return Err(RestorationStoreError::new(
962            "restoration application id must be a stable filesystem-safe identifier",
963        ));
964    }
965    Ok(())
966}
967
968fn decode_snapshot(
969    config: &RestorationConfig,
970    bytes: &[u8],
971    diagnostics: &mut RestorationDiagnostics,
972) -> Result<SnapshotDocument, ()> {
973    let mut value: Value = serde_json::from_slice(bytes).map_err(|_| ())?;
974    let document: SnapshotDocument = serde_json::from_value(value.clone()).map_err(|_| ())?;
975    if document.framework_format_version != FRAMEWORK_RESTORATION_FORMAT_VERSION
976        || document.application_id != config.application_id
977    {
978        return Err(());
979    }
980    if document.application_schema_version != config.application_schema_version {
981        let Some(migration) = &config.migration else {
982            return Err(());
983        };
984        migration(document.application_schema_version, &mut value).map_err(|_| ())?;
985        let mut migrated: SnapshotDocument = serde_json::from_value(value).map_err(|_| ())?;
986        migrated.framework_format_version = FRAMEWORK_RESTORATION_FORMAT_VERSION;
987        migrated.application_schema_version = config.application_schema_version;
988        migrated.application_id = config.application_id.clone();
989        diagnostics.restoration_migrations += 1;
990        Ok(migrated)
991    } else {
992        Ok(document)
993    }
994}
995
996fn encode_path(path: &[RestorationKey]) -> String {
997    path.iter()
998        .map(|segment| segment.as_str().replace('~', "~0").replace('/', "~1"))
999        .collect::<Vec<_>>()
1000        .join("/")
1001}
1002
1003fn scope_count(values: &BTreeMap<String, Value>) -> usize {
1004    values
1005        .keys()
1006        .filter_map(|key| {
1007            key.rsplit_once('/')
1008                .map(|(scope, _)| scope)
1009                .or(Some("application"))
1010        })
1011        .collect::<BTreeSet<_>>()
1012        .len()
1013}
1014
1015fn refresh_counts(state: &mut ManagerState) {
1016    state.diagnostics.restoration_values = state.snapshot.state.len();
1017    state.diagnostics.restoration_scopes = scope_count(&state.snapshot.state);
1018}
1019
1020/// A typed, opt-in persistent value built on the existing local [`Signal`]
1021/// primitive. Mutations through this wrapper update the snapshot; an ordinary
1022/// `Signal` stays non-persistent by default.
1023#[derive(Clone)]
1024pub struct Restorable<T> {
1025    signal: Signal<T>,
1026    write: Rc<dyn Fn(&T)>,
1027    remove: Rc<dyn Fn()>,
1028}
1029
1030impl<T> Restorable<T>
1031where
1032    T: Clone + PartialEq + Serialize + DeserializeOwned + 'static,
1033{
1034    pub(crate) fn new(
1035        initial: T,
1036        write: impl Fn(&T) + 'static,
1037        remove: impl Fn() + 'static,
1038    ) -> Self {
1039        Self {
1040            signal: Signal::new(initial),
1041            write: Rc::new(write),
1042            remove: Rc::new(remove),
1043        }
1044    }
1045
1046    /// Reads `key` before returning the normal reactive signal wrapper. A
1047    /// missing, corrupt, or type-incompatible stored value exposes `default`
1048    /// without failing the build that requested it; a present but
1049    /// type-incompatible value is additionally counted as stale.
1050    #[must_use]
1051    pub fn from_scope(scope: RestorationScope, key: RestorationKey, default: T) -> Self {
1052        let stored = scope.get_json(&key);
1053        let initial = match stored {
1054            None => default,
1055            Some(value) => match serde_json::from_value(value) {
1056                Ok(initial) => initial,
1057                Err(_) => {
1058                    scope.note_restoration_outcome(0, 1);
1059                    default
1060                }
1061            },
1062        };
1063        let write_scope = scope.clone();
1064        let write_key = key.clone();
1065        let remove_scope = scope;
1066        Self::new(
1067            initial,
1068            move |value| {
1069                if let Ok(value) = serde_json::to_value(value) {
1070                    write_scope.set_json(&write_key, value);
1071                }
1072            },
1073            move || remove_scope.remove(&key),
1074        )
1075    }
1076
1077    #[must_use]
1078    pub fn get(&self) -> T {
1079        self.signal.get()
1080    }
1081
1082    pub fn set(&self, value: T) -> bool {
1083        if !self.signal.set(value.clone()) {
1084            return false;
1085        }
1086        (self.write)(&value);
1087        true
1088    }
1089
1090    pub fn update(&self, update: impl FnOnce(&mut T)) {
1091        let mut value = self.signal.get();
1092        update(&mut value);
1093        let _ = self.set(value);
1094    }
1095
1096    /// Stops restoring this value and removes its current persisted entry.
1097    pub fn remove(&self) {
1098        (self.remove)();
1099    }
1100
1101    /// Exposes the reactive read primitive. Write through `Restorable::set`
1102    /// or `update` so persistence remains opt-in and observable.
1103    #[must_use]
1104    pub fn signal(&self) -> Signal<T> {
1105        self.signal.clone()
1106    }
1107}
1108
1109impl RestorationBackend for RestorationManager {
1110    fn read_value(&self, path: &[RestorationKey]) -> Option<Value> {
1111        self.read(path)
1112    }
1113
1114    fn write_value(&self, path: &[RestorationKey], value: Value) {
1115        self.write(path, value);
1116    }
1117
1118    fn remove_value(&self, path: &[RestorationKey]) {
1119        let _ = self.remove(path);
1120    }
1121
1122    fn note_restoration_outcome(&self, restored: u64, invalid: u64) {
1123        self.record_restoration_outcome(restored, invalid);
1124    }
1125}