Skip to main content

cranpose_services/
host.rs

1//! Framework-owned application-host state and controls.
2
3use std::{
4    path::{Path, PathBuf},
5    sync::{
6        Arc, Mutex, OnceLock,
7        atomic::{AtomicU8, AtomicU64, Ordering},
8    },
9};
10
11/// Platform directory roots for application-owned files.
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub struct PlatformDirectories {
14    pub data: PathBuf,
15    pub config: PathBuf,
16    pub cache: PathBuf,
17    pub documents: Option<PathBuf>,
18    pub temporary: PathBuf,
19    pub shared: Option<PathBuf>,
20}
21
22impl PlatformDirectories {
23    fn scoped(&self, application_id: &str) -> Self {
24        Self {
25            data: self.data.join(application_id),
26            config: self.config.join(application_id),
27            cache: self.cache.join(application_id),
28            documents: self
29                .documents
30                .as_ref()
31                .map(|path| path.join(application_id)),
32            temporary: self.temporary.join(application_id),
33            shared: self.shared.as_ref().map(|path| path.join(application_id)),
34        }
35    }
36}
37
38#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
39pub enum PlatformDirectoryError {
40    #[error("application id must be one non-empty path component")]
41    InvalidApplicationId,
42    #[error("no application id has been registered")]
43    NoApplicationId,
44    #[error("platform directories are unavailable")]
45    Unavailable,
46}
47
48/// Android-style lifecycle state exposed to composition observers.
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub enum LifecycleState {
51    Created,
52    Started,
53    Resumed,
54    Paused,
55    Stopped,
56    Destroyed,
57}
58
59/// A lifecycle transition delivered by the platform host.
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub struct LifecycleEvent {
62    pub from: LifecycleState,
63    pub to: LifecycleState,
64}
65
66/// Host operations supplied by the framework platform backend.
67pub trait HostController: Send + Sync {
68    /// Keeps the host window awake while enabled.
69    fn set_keep_screen_on(&self, enabled: bool);
70    /// Returns platform directory roots inside the application's host sandbox.
71    fn platform_directories(&self) -> Option<PlatformDirectories>;
72    /// Finishes/backgrounds the host.
73    fn exit(&self);
74    /// Requests that the app move to the background.
75    fn background(&self);
76    /// How long this host will wait for durable saves before it suspends the
77    /// app. Android's `onPause` and iOS's background transition each allow a
78    /// short, platform-defined budget; work that overruns it keeps running
79    /// under a background-work lease.
80    fn durable_save_deadline(&self) -> std::time::Duration {
81        DEFAULT_DURABLE_SAVE_DEADLINE
82    }
83}
84
85/// The budget used when a host does not state one of its own.
86pub const DEFAULT_DURABLE_SAVE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(2);
87
88/// How long the installed host will wait for durable saves.
89pub fn durable_save_deadline() -> std::time::Duration {
90    host_controller()
91        .map(|host| host.durable_save_deadline())
92        .unwrap_or(DEFAULT_DURABLE_SAVE_DEADLINE)
93}
94
95pub type HostControllerRef = Arc<dyn HostController>;
96
97fn controller() -> &'static Mutex<Option<HostControllerRef>> {
98    static SLOT: OnceLock<Mutex<Option<HostControllerRef>>> = OnceLock::new();
99    SLOT.get_or_init(|| Mutex::new(None))
100}
101
102/// Installs the framework host controller.
103pub fn set_host_controller(value: HostControllerRef) {
104    if let Ok(mut slot) = controller().lock() {
105        *slot = Some(value);
106    }
107    KEEP_SCREEN_ON.store(0, Ordering::Release);
108}
109/// Removes the installed host controller.
110pub fn clear_host_controller() {
111    if let Ok(mut slot) = controller().lock() {
112        *slot = None;
113    }
114    KEEP_SCREEN_ON.store(0, Ordering::Release);
115}
116/// Returns the installed framework host controller.
117pub fn host_controller() -> Option<HostControllerRef> {
118    controller().lock().ok().and_then(|slot| slot.clone())
119}
120/// Enables or disables the host's keep-screen-on flag.
121pub fn set_keep_screen_on(enabled: bool) {
122    let value = if enabled { 2 } else { 1 };
123    if KEEP_SCREEN_ON.swap(value, Ordering::AcqRel) == value {
124        return;
125    }
126    if let Some(host) = host_controller() {
127        host.set_keep_screen_on(enabled);
128    }
129}
130fn valid_application_id(application_id: &str) -> bool {
131    !application_id.is_empty()
132        && Path::new(application_id).components().count() == 1
133        && application_id != "."
134        && application_id != ".."
135}
136
137fn desktop_platform_directories() -> Option<PlatformDirectories> {
138    let base = directories::BaseDirs::new()?;
139    let documents = directories::UserDirs::new()
140        .and_then(|directories| directories.document_dir().map(Path::to_path_buf));
141    Some(PlatformDirectories {
142        data: base.data_dir().to_path_buf(),
143        config: base.config_dir().to_path_buf(),
144        cache: base.cache_dir().to_path_buf(),
145        documents,
146        temporary: std::env::temp_dir(),
147        shared: None,
148    })
149}
150
151fn application_id_slot() -> &'static Mutex<Option<String>> {
152    static SLOT: OnceLock<Mutex<Option<String>>> = OnceLock::new();
153    SLOT.get_or_init(|| Mutex::new(None))
154}
155
156/// Registers the id every framework-owned storage path is scoped by.
157///
158/// Platform backends call this at startup with what the platform packaged —
159/// the Android package name, the iOS bundle identifier, the desktop
160/// application name — so applications never assemble a storage path
161/// themselves.
162pub fn set_application_id(application_id: &str) -> Result<(), PlatformDirectoryError> {
163    if !valid_application_id(application_id) {
164        return Err(PlatformDirectoryError::InvalidApplicationId);
165    }
166    if let Ok(mut slot) = application_id_slot().lock() {
167        *slot = Some(application_id.to_string());
168    }
169    Ok(())
170}
171
172/// Removes the registered application id (tests and teardown).
173pub fn clear_application_id() {
174    if let Ok(mut slot) = application_id_slot().lock() {
175        *slot = None;
176    }
177}
178
179/// The registered application id, if a host has published one.
180pub fn application_id() -> Option<String> {
181    application_id_slot()
182        .lock()
183        .ok()
184        .and_then(|slot| slot.clone())
185}
186
187/// Returns typed directories for the registered application.
188pub fn application_directories() -> Result<PlatformDirectories, PlatformDirectoryError> {
189    let application_id = application_id().ok_or(PlatformDirectoryError::NoApplicationId)?;
190    let roots = host_controller()
191        .and_then(|host| host.platform_directories())
192        .or_else(desktop_platform_directories)
193        .ok_or(PlatformDirectoryError::Unavailable)?;
194    Ok(roots.scoped(&application_id))
195}
196/// Requests that the host finish the app.
197pub fn exit_app() {
198    if let Some(host) = host_controller() {
199        host.exit();
200    }
201}
202/// Requests that the host move the app to the background.
203pub fn background_app() {
204    if let Some(host) = host_controller() {
205        host.background();
206    }
207}
208
209#[cfg(not(target_arch = "wasm32"))]
210type Observer = Arc<dyn Fn(LifecycleEvent) + Send + Sync>;
211#[cfg(target_arch = "wasm32")]
212type Observer = std::rc::Rc<dyn Fn(LifecycleEvent)>;
213
214#[cfg(not(target_arch = "wasm32"))]
215fn observers() -> &'static Mutex<Vec<(u64, Observer)>> {
216    static SLOT: OnceLock<Mutex<Vec<(u64, Observer)>>> = OnceLock::new();
217    SLOT.get_or_init(|| Mutex::new(Vec::new()))
218}
219#[cfg(target_arch = "wasm32")]
220thread_local! {
221    static OBSERVERS: std::cell::RefCell<Vec<(u64, Observer)>> = const { std::cell::RefCell::new(Vec::new()) };
222}
223static NEXT_ID: AtomicU64 = AtomicU64::new(1);
224static LIFECYCLE_STATE: AtomicU8 = AtomicU8::new(LifecycleState::Created as u8);
225static KEEP_SCREEN_ON: AtomicU8 = AtomicU8::new(0);
226
227/// RAII lifecycle observer registration.
228pub struct LifecycleObserver {
229    id: u64,
230}
231impl Drop for LifecycleObserver {
232    fn drop(&mut self) {
233        #[cfg(not(target_arch = "wasm32"))]
234        if let Ok(mut list) = observers().lock() {
235            list.retain(|(id, _)| *id != self.id);
236        }
237        #[cfg(target_arch = "wasm32")]
238        OBSERVERS.with(|list| list.borrow_mut().retain(|(id, _)| *id != self.id));
239    }
240}
241/// Observes host lifecycle transitions until the returned handle is dropped.
242#[cfg(not(target_arch = "wasm32"))]
243pub fn observe_lifecycle(
244    observer: impl Fn(LifecycleEvent) + Send + Sync + 'static,
245) -> LifecycleObserver {
246    let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
247    if let Ok(mut list) = observers().lock() {
248        list.push((id, Arc::new(observer)));
249    }
250    LifecycleObserver { id }
251}
252/// Observes host lifecycle transitions until the returned handle is dropped.
253#[cfg(target_arch = "wasm32")]
254pub fn observe_lifecycle(observer: impl Fn(LifecycleEvent) + 'static) -> LifecycleObserver {
255    let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
256    OBSERVERS.with(|list| list.borrow_mut().push((id, std::rc::Rc::new(observer))));
257    LifecycleObserver { id }
258}
259/// Publishes a lifecycle transition from the platform host.
260pub fn dispatch_lifecycle(event: LifecycleEvent) {
261    LIFECYCLE_STATE.store(event.to as u8, Ordering::Release);
262    crate::media::on_lifecycle(event);
263    #[cfg(not(target_arch = "wasm32"))]
264    let callbacks = observers()
265        .lock()
266        .map(|list| {
267            list.iter()
268                .map(|(_, cb)| Arc::clone(cb))
269                .collect::<Vec<_>>()
270        })
271        .unwrap_or_default();
272    #[cfg(target_arch = "wasm32")]
273    let callbacks = OBSERVERS.with(|list| {
274        list.borrow()
275            .iter()
276            .map(|(_, callback)| std::rc::Rc::clone(callback))
277            .collect::<Vec<_>>()
278    });
279    for callback in callbacks {
280        callback(event);
281    }
282}
283
284/// Returns the latest lifecycle state published by the platform host.
285pub fn current_lifecycle_state() -> LifecycleState {
286    match LIFECYCLE_STATE.load(Ordering::Acquire) {
287        0 => LifecycleState::Created,
288        1 => LifecycleState::Started,
289        2 => LifecycleState::Resumed,
290        3 => LifecycleState::Paused,
291        4 => LifecycleState::Stopped,
292        _ => LifecycleState::Destroyed,
293    }
294}
295
296/// Publishes a platform lifecycle state and derives the transition source from
297/// the framework's previous state.
298///
299/// Leaving the foreground runs every registered durable save first, inside the
300/// budget the host allows, so an application never has to hook the transition
301/// itself to persist its data.
302pub fn dispatch_lifecycle_state(to: LifecycleState) {
303    let from = current_lifecycle_state();
304    if from == to {
305        return;
306    }
307    #[cfg(not(target_arch = "wasm32"))]
308    if matches!(to, LifecycleState::Paused) {
309        let outcome = run_durable_saves(durable_save_deadline());
310        if outcome == DurableSaveOutcome::TimedOut {
311            log::warn!("cranpose: durable saves overran the host deadline; they keep running");
312        }
313    }
314    dispatch_lifecycle(LifecycleEvent { from, to });
315}
316
317// ---- Observable lifecycle ------------------------------------------------
318
319/// The `CompositionLocal` carrying the host's current lifecycle state.
320///
321/// [`ProvideLifecycle`] installs it; descendants read it and recompose on every
322/// transition, so a screen can pause its own work without registering an
323/// observer of its own.
324pub fn local_lifecycle_state() -> cranpose_core::CompositionLocal<LifecycleState> {
325    thread_local! {
326        static LOCAL: std::cell::RefCell<Option<cranpose_core::CompositionLocal<LifecycleState>>> =
327            const { std::cell::RefCell::new(None) };
328    }
329    LOCAL.with(|cell| {
330        cell.borrow_mut()
331            .get_or_insert_with(|| cranpose_core::compositionLocalOf(current_lifecycle_state))
332            .clone()
333    })
334}
335
336/// The host's lifecycle state as observable state.
337#[allow(non_snake_case)]
338#[track_caller]
339pub fn rememberLifecycleState() -> cranpose_core::State<LifecycleState> {
340    let transitions = rememberLifecycleEvents();
341    let state = cranpose_core::collectAsState(
342        transitions,
343        (),
344        LifecycleEvent {
345            from: current_lifecycle_state(),
346            to: current_lifecycle_state(),
347        },
348    );
349    cranpose_core::derivedStateOf(move || state.get().to)
350}
351
352/// Host lifecycle transitions as a composition-scoped stream.
353#[allow(non_snake_case)]
354#[track_caller]
355pub fn rememberLifecycleEvents() -> cranpose_core::EventStream<LifecycleEvent> {
356    cranpose_core::rememberEventStream((), |sender| {
357        observe_lifecycle(move |event| sender.send(event))
358    })
359}
360
361/// Provides the host's lifecycle state to descendant composables.
362///
363/// The application shell wraps its content in this once; screens then read
364/// [`local_lifecycle_state`].
365#[allow(non_snake_case)]
366#[cranpose_macros::composable]
367pub fn ProvideLifecycle(content: impl FnOnce()) {
368    let state = rememberLifecycleState();
369    let local = local_lifecycle_state();
370    cranpose_core::CompositionLocalProvider(vec![local.provides(state.get())], move || {
371        content();
372    });
373}
374
375// ---- Durable saves -------------------------------------------------------
376
377/// What became of the durable saves the host asked for.
378#[derive(Clone, Copy, Debug, Eq, PartialEq)]
379pub enum DurableSaveOutcome {
380    /// Nothing was registered.
381    Nothing,
382    /// Every registered save finished inside the deadline.
383    Completed,
384    /// The deadline expired with saves still running. They keep running under
385    /// a background-work lease, but the host is free to suspend.
386    TimedOut,
387}
388
389type SaveWork = Arc<dyn Fn() + Send + Sync>;
390
391fn durable_saves() -> &'static Mutex<Vec<(u64, SaveWork)>> {
392    static SLOT: OnceLock<Mutex<Vec<(u64, SaveWork)>>> = OnceLock::new();
393    SLOT.get_or_init(|| Mutex::new(Vec::new()))
394}
395
396/// Keeps a durable save registered until it is dropped.
397pub struct DurableSaveRegistration {
398    id: u64,
399}
400
401impl Drop for DurableSaveRegistration {
402    fn drop(&mut self) {
403        if let Ok(mut saves) = durable_saves().lock() {
404            saves.retain(|(id, _)| *id != self.id);
405        }
406    }
407}
408
409/// Registers work that must reach durable storage before the host suspends.
410///
411/// Applications use [`DurableSaveEffect`] so the registration is scoped to the
412/// composition that owns the data.
413pub fn register_durable_save(save: impl Fn() + Send + Sync + 'static) -> DurableSaveRegistration {
414    let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
415    if let Ok(mut saves) = durable_saves().lock() {
416        saves.push((id, Arc::new(save)));
417    }
418    DurableSaveRegistration { id }
419}
420
421/// Registers `save` for as long as this call stays in the composition.
422///
423/// The host runs it when the app is about to be suspended, off the UI thread
424/// and under a background-work lease, so a slow write does not stall the
425/// lifecycle callback the platform is waiting on.
426#[allow(non_snake_case)]
427#[track_caller]
428pub fn DurableSaveEffect<K: PartialEq + 'static>(keys: K, save: impl Fn() + Send + Sync + 'static) {
429    cranpose_core::__disposable_effect_impl(
430        cranpose_core::caller_location_key()
431            ^ cranpose_core::location_key(file!(), line!(), column!()),
432        keys,
433        move |scope| {
434            let registration = register_durable_save(save);
435            scope.on_dispose(move || drop(registration))
436        },
437    );
438}
439
440/// Runs every registered durable save, waiting up to `deadline`.
441///
442/// Platform hosts call this from the lifecycle callback the OS gives them —
443/// Android's `onPause`, iOS's `applicationDidEnterBackground` — passing the
444/// budget that platform allows. Saves run on worker threads under a
445/// background-work lease, so work that overruns the deadline still finishes
446/// while the OS keeps the process alive.
447#[cfg(not(target_arch = "wasm32"))]
448pub fn run_durable_saves(deadline: std::time::Duration) -> DurableSaveOutcome {
449    let saves: Vec<SaveWork> = durable_saves()
450        .lock()
451        .map(|saves| saves.iter().map(|(_, save)| Arc::clone(save)).collect())
452        .unwrap_or_default();
453    if saves.is_empty() {
454        return DurableSaveOutcome::Nothing;
455    }
456
457    let outstanding = Arc::new(std::sync::atomic::AtomicUsize::new(saves.len()));
458    let finished = Arc::new((Mutex::new(false), std::sync::Condvar::new()));
459    for save in saves {
460        let worker_outstanding = Arc::clone(&outstanding);
461        let worker_finished = Arc::clone(&finished);
462        let lease = crate::background::acquire_background_work();
463        let spawned = std::thread::Builder::new()
464            .name("cranpose-durable-save".to_string())
465            .spawn(move || {
466                save();
467                drop(lease);
468                if worker_outstanding.fetch_sub(1, Ordering::AcqRel) == 1 {
469                    let (done, wake) = &*worker_finished;
470                    if let Ok(mut done) = done.lock() {
471                        *done = true;
472                    }
473                    wake.notify_all();
474                }
475            });
476        if spawned.is_err() {
477            // A host that cannot spawn threads is a host under real pressure;
478            // counting the save down keeps the wait from hanging on it.
479            log::warn!("cranpose: a durable save could not be started");
480            if outstanding.fetch_sub(1, Ordering::AcqRel) == 1 {
481                let (done, wake) = &*finished;
482                if let Ok(mut done) = done.lock() {
483                    *done = true;
484                }
485                wake.notify_all();
486            }
487        }
488    }
489
490    let (done, wake) = &*finished;
491    let Ok(mut guard) = done.lock() else {
492        return DurableSaveOutcome::TimedOut;
493    };
494    let mut remaining = deadline;
495    let started = web_time::Instant::now();
496    while !*guard {
497        let Ok((next, timeout)) = wake.wait_timeout(guard, remaining) else {
498            return DurableSaveOutcome::TimedOut;
499        };
500        guard = next;
501        if timeout.timed_out() {
502            break;
503        }
504        remaining = deadline.saturating_sub(started.elapsed());
505        if remaining.is_zero() {
506            break;
507        }
508    }
509    if *guard {
510        DurableSaveOutcome::Completed
511    } else {
512        DurableSaveOutcome::TimedOut
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    fn test_lock() -> std::sync::MutexGuard<'static, ()> {
521        static LOCK: Mutex<()> = Mutex::new(());
522        LOCK.lock().unwrap_or_else(|error| error.into_inner())
523    }
524
525    #[test]
526    fn observer_is_removed_on_drop() {
527        let _guard = test_lock();
528        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
529        let seen = Arc::clone(&calls);
530        let handle = observe_lifecycle(move |_| {
531            seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
532        });
533        dispatch_lifecycle(LifecycleEvent {
534            from: LifecycleState::Created,
535            to: LifecycleState::Started,
536        });
537        assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1);
538        drop(handle);
539        dispatch_lifecycle(LifecycleEvent {
540            from: LifecycleState::Started,
541            to: LifecycleState::Resumed,
542        });
543        assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1);
544    }
545
546    #[test]
547    fn state_dispatch_derives_the_previous_state() {
548        let _guard = test_lock();
549        dispatch_lifecycle_state(LifecycleState::Paused);
550        assert_eq!(current_lifecycle_state(), LifecycleState::Paused);
551        dispatch_lifecycle_state(LifecycleState::Stopped);
552        assert_eq!(current_lifecycle_state(), LifecycleState::Stopped);
553    }
554
555    #[test]
556    fn repeated_keep_screen_value_reaches_the_host_once() {
557        let _guard = test_lock();
558        struct RecordingHost(Arc<std::sync::atomic::AtomicUsize>);
559        impl HostController for RecordingHost {
560            fn set_keep_screen_on(&self, _enabled: bool) {
561                self.0.fetch_add(1, Ordering::Relaxed);
562            }
563            fn platform_directories(&self) -> Option<PlatformDirectories> {
564                Some(PlatformDirectories {
565                    data: PathBuf::from("data"),
566                    config: PathBuf::from("config"),
567                    cache: PathBuf::from("cache"),
568                    documents: Some(PathBuf::from("documents")),
569                    temporary: PathBuf::from("temporary"),
570                    shared: Some(PathBuf::from("shared")),
571                })
572            }
573            fn exit(&self) {}
574            fn background(&self) {}
575        }
576        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
577        set_host_controller(Arc::new(RecordingHost(Arc::clone(&calls))));
578        set_keep_screen_on(true);
579        set_keep_screen_on(true);
580        assert_eq!(calls.load(Ordering::Relaxed), 1);
581        set_application_id("sample").expect("a plain id is valid");
582        assert_eq!(
583            application_directories().unwrap().data,
584            PathBuf::from("data/sample")
585        );
586        clear_application_id();
587        clear_host_controller();
588    }
589
590    #[test]
591    fn durable_saves_run_and_report_completion() {
592        // A durable save holds a background-work lease while it runs, and that
593        // count is one number for the process, so tests that take or read it
594        // take turns.
595        let _services = crate::registry::test_service_guard();
596        let _guard = test_lock();
597        let ran = Arc::new(std::sync::atomic::AtomicUsize::new(0));
598        let first = Arc::clone(&ran);
599        let second = Arc::clone(&ran);
600        let a = register_durable_save(move || {
601            first.fetch_add(1, Ordering::Relaxed);
602        });
603        let b = register_durable_save(move || {
604            second.fetch_add(1, Ordering::Relaxed);
605        });
606        assert_eq!(
607            run_durable_saves(std::time::Duration::from_secs(5)),
608            DurableSaveOutcome::Completed
609        );
610        assert_eq!(ran.load(Ordering::Relaxed), 2);
611        drop((a, b));
612        assert_eq!(
613            run_durable_saves(std::time::Duration::from_secs(1)),
614            DurableSaveOutcome::Nothing
615        );
616    }
617
618    #[test]
619    fn a_save_that_overruns_the_deadline_reports_a_timeout() {
620        let _services = crate::registry::test_service_guard();
621        let _guard = test_lock();
622        let registration = register_durable_save(|| {
623            std::thread::sleep(std::time::Duration::from_millis(400));
624        });
625        assert_eq!(
626            run_durable_saves(std::time::Duration::from_millis(30)),
627            DurableSaveOutcome::TimedOut
628        );
629        drop(registration);
630    }
631
632    #[test]
633    fn a_dropped_registration_is_no_longer_saved() {
634        let _services = crate::registry::test_service_guard();
635        let _guard = test_lock();
636        let ran = Arc::new(std::sync::atomic::AtomicUsize::new(0));
637        let counted = Arc::clone(&ran);
638        let registration = register_durable_save(move || {
639            counted.fetch_add(1, Ordering::Relaxed);
640        });
641        drop(registration);
642        assert_eq!(
643            run_durable_saves(std::time::Duration::from_secs(1)),
644            DurableSaveOutcome::Nothing
645        );
646        assert_eq!(ran.load(Ordering::Relaxed), 0);
647    }
648
649    #[test]
650    fn application_id_must_be_one_component() {
651        let _guard = test_lock();
652        assert_eq!(
653            set_application_id("../sample"),
654            Err(PlatformDirectoryError::InvalidApplicationId)
655        );
656        assert_eq!(
657            set_application_id(""),
658            Err(PlatformDirectoryError::InvalidApplicationId)
659        );
660        clear_application_id();
661        assert_eq!(
662            application_directories(),
663            Err(PlatformDirectoryError::NoApplicationId)
664        );
665    }
666
667    #[test]
668    fn a_surviving_durable_save_keeps_its_registration_when_a_leader_leaves() {
669        let _guard = test_lock();
670        durable_saves()
671            .lock()
672            .unwrap_or_else(|error| error.into_inner())
673            .clear();
674        let ran: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
675        let show_first = std::rc::Rc::new(std::cell::Cell::new(true));
676
677        fn saves(show_first: bool, ran: &Arc<Mutex<Vec<&'static str>>>) {
678            if show_first {
679                let ran = Arc::clone(ran);
680                DurableSaveEffect((), move || {
681                    ran.lock()
682                        .unwrap_or_else(|error| error.into_inner())
683                        .push("first");
684                });
685            }
686            let ran = Arc::clone(ran);
687            DurableSaveEffect((), move || {
688                ran.lock()
689                    .unwrap_or_else(|error| error.into_inner())
690                    .push("tail");
691            });
692        }
693
694        let mut composition = cranpose_core::Composition::new(cranpose_core::MemoryApplier::new());
695        let root_key = cranpose_core::location_key(file!(), line!(), column!());
696        let mut pass = {
697            let ran = Arc::clone(&ran);
698            let show_first = std::rc::Rc::clone(&show_first);
699            move || saves(show_first.get(), &ran)
700        };
701
702        composition
703            .render(root_key, &mut pass)
704            .expect("initial composition");
705        show_first.set(false);
706        composition
707            .render(root_key, &mut pass)
708            .expect("drop the leading save");
709
710        let outcome = run_durable_saves(std::time::Duration::from_secs(5));
711        assert_eq!(outcome, DurableSaveOutcome::Completed);
712        assert_eq!(
713            ran.lock()
714                .unwrap_or_else(|error| error.into_inner())
715                .as_slice(),
716            ["tail"],
717            "the surviving effect must keep its own registration; adopting the \
718             departed leader's group keeps the wrong save alive"
719        );
720    }
721}