Skip to main content

day_core/
lifecycle.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! App-lifecycle callbacks (docs/lifecycle.md). An app registers closures for [`day_spec::Lifecycle`]
5//! phases with [`on_lifecycle`]; each backend, at the matching moment in its native app/activity
6//! delegate, emits `Event::Lifecycle(phase)` (or day-core dispatches the launch phases uniformly),
7//! and the event pump routes it here to run the closures inside a reactive batch, the same rails
8//! as `Event::MenuAction`, so a lifecycle handler that writes signals updates the UI like any
9//! callback.
10//!
11//! Not every platform has every phase (a desktop app doesn't really enter the background), so a
12//! handler registered for a phase the running backend doesn't deliver gets a one-time warning, and
13//! apps can guard with [`lifecycle_supported`] (runtime) or `day::require_lifecycle!` (compile time).
14
15use std::cell::RefCell;
16use std::collections::HashMap;
17use std::rc::Rc;
18
19use day_spec::Lifecycle;
20
21/// The registered handlers for one phase.
22type Handlers = Vec<Rc<dyn Fn()>>;
23
24day_reactive::tls_slots! {
25    lifecycle;
26    static HANDLERS: RefCell<HashMap<Lifecycle, Handlers>> =
27        RefCell::new(HashMap::new());
28    /// Phases we've already warned about being unsupported (warn once, not per-handler).
29    static WARNED: RefCell<std::collections::HashSet<Lifecycle>> =
30        RefCell::new(std::collections::HashSet::new());
31}
32
33/// `DidExit` was delivered (it is delivered once). Process-global rather than thread-local:
34/// the delivery from `atexit` can run after the thread-locals are gone.
35static EXITED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
36/// What the exit line names: the app's display name, its version, and when it launched.
37/// Process-global for the same reason.
38static LAUNCHED: std::sync::Mutex<Option<Launched>> = std::sync::Mutex::new(None);
39
40struct Launched {
41    name: String,
42    version: Option<String>,
43    #[cfg(not(target_arch = "wasm32"))]
44    at: std::time::Instant,
45}
46
47/// Record the app's name and version for the exit line, and start its clock. Called by
48/// `launch_with` before `WillLaunch`.
49pub fn note_launch(name: String, version: Option<String>) {
50    crate::memory_profile::start();
51    if let Ok(mut l) = LAUNCHED.lock() {
52        *l = Some(Launched {
53            name,
54            version,
55            #[cfg(not(target_arch = "wasm32"))]
56            at: std::time::Instant::now(),
57        });
58    }
59}
60
61/// Day's last line: `Clean exit for <app> v<version> (<profile>) after HH:MM:SS. Memory usage:
62/// <n> MB`. The version is the app's (`WindowOptions::version`), else the `DAY_APP_VERSION` the
63/// CLI sets on a launch; the profile is this build's; the time is since `launch_with`; the memory
64/// is the process's resident set, where the platform tells (`resident_memory`).
65fn exit_summary() -> String {
66    let (name, version, elapsed) = {
67        let l = LAUNCHED.lock().ok();
68        let l = l.as_deref().and_then(Option::as_ref);
69        let name = l
70            .map(|l| l.name.clone())
71            .filter(|n| !n.is_empty())
72            .unwrap_or_else(|| "the app".to_string());
73        let version = l
74            .and_then(|l| l.version.clone())
75            .or_else(|| std::env::var("DAY_APP_VERSION").ok())
76            .filter(|v| !v.is_empty());
77        #[cfg(not(target_arch = "wasm32"))]
78        let elapsed = l.map(|l| l.at.elapsed().as_secs());
79        #[cfg(target_arch = "wasm32")]
80        let elapsed = crate::frame::uptime_secs();
81        (name, version, elapsed)
82    };
83    let memory = resident_memory();
84    let mut summary = format_exit_summary(
85        &name,
86        version.as_deref(),
87        cfg!(debug_assertions),
88        elapsed,
89        memory,
90    );
91    if let Some(profile) = crate::memory_profile::finish(memory) {
92        summary.push_str(". ");
93        summary.push_str(&profile);
94    }
95    summary
96}
97
98/// The exit line's text; see `exit_summary`. Factored for the test below.
99fn format_exit_summary(
100    name: &str,
101    version: Option<&str>,
102    debug: bool,
103    elapsed_secs: Option<u64>,
104    memory_bytes: Option<u64>,
105) -> String {
106    let version = match version {
107        Some(v) => format!(" v{v}"),
108        None => String::new(),
109    };
110    let profile = if debug { "debug" } else { "release" };
111    let elapsed = match elapsed_secs {
112        Some(s) => format!("{:02}:{:02}:{:02}", s / 3600, s / 60 % 60, s % 60),
113        None => "an unknown time".to_string(),
114    };
115    let memory = match memory_bytes {
116        Some(b) => format!("{:.2} MB", b as f64 / 1e6),
117        None => "unknown".to_string(),
118    };
119    format!("Clean exit for {name}{version} ({profile}) after {elapsed}. Memory usage: {memory}")
120}
121
122/// The process's resident memory in bytes, from the platform's own accounting: the task's
123/// physical footprint on Apple platforms, the resident set from `/proc` on Linux and its
124/// relatives, the working set on Windows, the linear memory on wasm. `None` where none is
125/// readable, which the exit line reports as unknown.
126pub fn resident_memory() -> Option<u64> {
127    #[cfg(any(target_os = "macos", target_os = "ios"))]
128    {
129        // task_info(TASK_VM_INFO): `phys_footprint` is what Xcode's memory gauge shows.
130        #[repr(C)]
131        struct TaskVmInfo {
132            // The struct is long; only the fields up to `phys_footprint` matter here, and
133            // `task_info` fills as many bytes as asked for.
134            virtual_size: u64,
135            region_count: i32,
136            page_size: i32,
137            resident_size: u64,
138            resident_size_peak: u64,
139            device: u64,
140            device_peak: u64,
141            internal: u64,
142            internal_peak: u64,
143            external: u64,
144            external_peak: u64,
145            reusable: u64,
146            reusable_peak: u64,
147            purgeable_volatile_pmap: u64,
148            purgeable_volatile_resident: u64,
149            purgeable_volatile_virtual: u64,
150            compressed: u64,
151            compressed_peak: u64,
152            compressed_lifetime: u64,
153            phys_footprint: u64,
154        }
155        unsafe extern "C" {
156            fn mach_task_self() -> u32;
157            fn task_info(task: u32, flavor: u32, info: *mut TaskVmInfo, count: *mut u32) -> i32;
158        }
159        const TASK_VM_INFO: u32 = 22;
160        let mut info = std::mem::MaybeUninit::<TaskVmInfo>::zeroed();
161        let mut count = (std::mem::size_of::<TaskVmInfo>() / std::mem::size_of::<u32>()) as u32;
162        // SAFETY: `task_info` writes at most `count` 32-bit words into `info`, which is sized to
163        // hold them, and reports what it wrote back in `count`.
164        let ok = unsafe {
165            task_info(
166                mach_task_self(),
167                TASK_VM_INFO,
168                info.as_mut_ptr(),
169                &mut count,
170            )
171        };
172        if ok != 0 {
173            return None;
174        }
175        // SAFETY: a zero return means the kernel filled the structure.
176        let info = unsafe { info.assume_init() };
177        return Some(if info.phys_footprint > 0 {
178            info.phys_footprint
179        } else {
180            info.resident_size
181        });
182    }
183    #[cfg(any(target_os = "linux", target_os = "android", target_env = "ohos"))]
184    {
185        // /proc/self/statm: size resident shared text lib data dt, in pages.
186        let statm = std::fs::read_to_string("/proc/self/statm").ok()?;
187        let resident: u64 = statm.split_whitespace().nth(1)?.parse().ok()?;
188        unsafe extern "C" {
189            fn sysconf(name: i32) -> i64;
190        }
191        #[cfg(target_os = "android")]
192        const SC_PAGESIZE: i32 = 40;
193        #[cfg(not(target_os = "android"))]
194        const SC_PAGESIZE: i32 = 30;
195        // SAFETY: sysconf reads a system constant and has no preconditions.
196        let page = unsafe { sysconf(SC_PAGESIZE) };
197        return Some(resident * u64::try_from(page).unwrap_or(4096));
198    }
199    #[cfg(windows)]
200    {
201        #[repr(C)]
202        struct ProcessMemoryCounters {
203            cb: u32,
204            page_fault_count: u32,
205            peak_working_set_size: usize,
206            working_set_size: usize,
207            quota_peak_paged_pool_usage: usize,
208            quota_paged_pool_usage: usize,
209            quota_peak_non_paged_pool_usage: usize,
210            quota_non_paged_pool_usage: usize,
211            pagefile_usage: usize,
212            peak_pagefile_usage: usize,
213        }
214        #[link(name = "kernel32")]
215        unsafe extern "system" {
216            fn GetCurrentProcess() -> isize;
217            fn K32GetProcessMemoryInfo(
218                process: isize,
219                counters: *mut ProcessMemoryCounters,
220                cb: u32,
221            ) -> i32;
222        }
223        let mut counters = std::mem::MaybeUninit::<ProcessMemoryCounters>::zeroed();
224        let cb = std::mem::size_of::<ProcessMemoryCounters>() as u32;
225        // SAFETY: the counters structure is sized by `cb`, and the pseudo-handle needs no
226        // closing.
227        let ok = unsafe { K32GetProcessMemoryInfo(GetCurrentProcess(), counters.as_mut_ptr(), cb) };
228        if ok == 0 {
229            return None;
230        }
231        // SAFETY: a non-zero return means the structure was filled.
232        return Some(unsafe { counters.assume_init() }.working_set_size as u64);
233    }
234    #[cfg(target_arch = "wasm32")]
235    {
236        // The linear memory's size in 64 KiB pages: what the module has asked the host for.
237        return Some(core::arch::wasm32::memory_size(0) as u64 * 65536);
238    }
239    #[allow(unreachable_code)]
240    None
241}
242
243/// Forget every registered handler: a re-mount (docs/settings.md "Appearance on Android").
244/// The app's `root()` is about to run again and re-register, and handlers left from the
245/// previous mount would fire a second time for every phase.
246pub fn reset_handlers() {
247    HANDLERS.with(|h| h.borrow_mut().clear());
248    WARNED.with(|w| w.borrow_mut().clear());
249}
250
251/// Register `f` to run whenever the app reaches `phase`. Handlers run in registration order, in a
252/// reactive batch (signal writes coalesce into one UI update). Register early (before `launch`, or
253/// at the top of the root builder) so `WillLaunch`/`DidLaunch` handlers are in place when they
254/// fire.
255///
256/// If the running backend doesn't deliver `phase` (e.g. `DidEnterBackground` on desktop), the handler
257/// is kept but will never run, and a one-time warning is logged. Prefer guarding the registration with
258/// [`lifecycle_supported`] or the `day::require_lifecycle!` compile-time check.
259pub fn on_lifecycle(phase: Lifecycle, f: impl Fn() + 'static) {
260    HANDLERS.with(|h| h.borrow_mut().entry(phase).or_default().push(Rc::new(f)));
261    // If the backend is already up we can check support now; otherwise `launch_with` sweeps
262    // pre-registered phases once the tree exists (see `warn_unsupported_registrations`).
263    if crate::tree::has_tree() {
264        warn_if_unsupported(phase);
265    }
266}
267
268/// Run every handler registered for `phase`, in a reactive batch. Called by the event pump on
269/// `Event::Lifecycle`, and directly by `launch_with` for the launch phases and for `DidExit`.
270///
271/// `DidExit` is delivered once: the first delivery runs the handlers and then writes Day's exit
272/// line (`exit_summary`), and any later one is dropped, since a backend may emit it at its last
273/// native moment and `launch_with` emits it again when the loop returns.
274pub fn dispatch_lifecycle(phase: Lifecycle) {
275    // The last moment the windows still answer for their frames (docs/windows.md
276    // "Remembered frames"); a platform that ends the app itself (AppKit's terminate) passes here.
277    if phase == Lifecycle::WillTerminate {
278        crate::windows::save_remembered_frames();
279        crate::present::shutdown_tasks();
280    }
281    if phase == Lifecycle::DidExit {
282        if EXITED.swap(true, std::sync::atomic::Ordering::SeqCst) {
283            return;
284        }
285        // From `atexit` the thread-locals may already be gone (Rust tears them down at exit
286        // too, in an order nobody controls); the handlers need them, the line does not.
287        if HANDLERS.try_with(|_| ()).is_ok() {
288            crate::present::shutdown_tasks();
289            dispatch_handlers(phase);
290        }
291        log::info!("{}", exit_summary());
292        return;
293    }
294    dispatch_handlers(phase);
295}
296
297fn dispatch_handlers(phase: Lifecycle) {
298    crate::frame::lifecycle(phase);
299    let handlers = HANDLERS.with(|h| h.borrow().get(&phase).cloned().unwrap_or_default());
300    if handlers.is_empty() {
301        return;
302    }
303    let mut any_panicked = false;
304    day_reactive::batch(|| {
305        for f in &handlers {
306            // Lifecycle callbacks run inside native trampolines (applicationWillTerminate,
307            // GApplication::shutdown, the Android onDestroy JNI frame, …) that abort the process on
308            // unwind. Contain each handler like the event pump does (DESIGN.md §8.5): a panic here
309            // (classically an `eprintln!` hitting a closed stderr pipe during teardown) would
310            // otherwise turn a clean exit into a spurious crash. `notify_contained_panic` runs
311            // per-panic so a crash reporter (day-piece-crash-reporter) downgrades that handler's report to
312            // contained, not fatal.
313            if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f())).is_err() {
314                any_panicked = true;
315                log::warn!(
316                    "an on_lifecycle({}) handler panicked and was contained — the app continues.",
317                    phase.name()
318                );
319                crate::notify_contained_panic();
320            }
321        }
322    });
323    // Reset the reactive runtime after the batch closes: `recover_from_panic` rewrites the batch
324    // depth, so calling it mid-batch underflows the close.
325    if any_panicked {
326        day_reactive::recover_from_panic();
327    }
328}
329
330/// Does the running backend deliver `phase`? Use this to guard registration at runtime:
331/// `if day::lifecycle_supported(Lifecycle::DidEnterBackground) { on_lifecycle(...) }`.
332///
333/// Returns the universal answer (`phase.is_universal()`) if called before the backend is up.
334pub fn lifecycle_supported(phase: Lifecycle) -> bool {
335    if crate::tree::has_tree() {
336        crate::with_tree(|t| t.supports_lifecycle(phase))
337    } else {
338        phase.is_universal()
339    }
340}
341
342fn warn_if_unsupported(phase: Lifecycle) {
343    if lifecycle_supported(phase) {
344        return;
345    }
346    let first = WARNED.with(|w| w.borrow_mut().insert(phase));
347    if first {
348        log::warn!(
349            "an `on_lifecycle({})` handler was registered, but this backend never delivers \
350             that phase, so it will not run. Guard it with `day::lifecycle_supported(..)` or a \
351             `day::require_lifecycle!(..)` compile-time check (docs/lifecycle.md).",
352            phase.name()
353        );
354    }
355}
356
357/// Warn once for every ALREADY-registered phase the (now-known) backend doesn't deliver. Called by
358/// `launch_with` right after the backend/tree is installed, covering handlers registered before launch.
359pub fn warn_unsupported_registrations() {
360    let phases: Vec<Lifecycle> = HANDLERS.with(|h| h.borrow().keys().copied().collect());
361    for p in phases {
362        warn_if_unsupported(p);
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use std::cell::Cell;
370
371    #[test]
372    fn the_exit_line_reads_as_documented() {
373        assert_eq!(
374            format_exit_summary(
375                "Day Rise",
376                Some("0.4.5"),
377                true,
378                Some(3 * 3600 + 7 * 60 + 9),
379                Some(12_345_678)
380            ),
381            "Clean exit for Day Rise v0.4.5 (debug) after 03:07:09. Memory usage: 12.35 MB"
382        );
383        assert_eq!(
384            format_exit_summary("", None, false, None, None),
385            "Clean exit for  (release) after an unknown time. Memory usage: unknown"
386        );
387    }
388
389    #[test]
390    fn resident_memory_is_readable_here() {
391        // Every platform the tests run on has a probe; the test process is at least a megabyte.
392        assert!(resident_memory().is_some_and(|b| b > 1 << 20));
393    }
394
395    #[test]
396    fn did_exit_runs_its_handlers_once() {
397        thread_local! {
398            static N: Cell<u32> = const { Cell::new(0) };
399        }
400        let task = crate::task(std::future::pending());
401        on_lifecycle(Lifecycle::DidExit, move || {
402            assert!(task.is_finished());
403            N.with(|c| c.set(c.get() + 1));
404        });
405        dispatch_lifecycle(Lifecycle::DidExit);
406        dispatch_lifecycle(Lifecycle::DidExit);
407        assert_eq!(N.with(Cell::get), 1);
408        EXITED.store(false, std::sync::atomic::Ordering::SeqCst);
409    }
410
411    #[test]
412    fn dispatch_runs_handlers_for_the_phase_only() {
413        thread_local! {
414            static A: Cell<u32> = const { Cell::new(0) };
415            static B: Cell<u32> = const { Cell::new(0) };
416        }
417        on_lifecycle(Lifecycle::DidLaunch, || A.with(|c| c.set(c.get() + 1)));
418        on_lifecycle(Lifecycle::DidLaunch, || A.with(|c| c.set(c.get() + 1)));
419        on_lifecycle(Lifecycle::WillTerminate, || B.with(|c| c.set(c.get() + 1)));
420
421        dispatch_lifecycle(Lifecycle::DidLaunch);
422        assert_eq!(A.with(Cell::get), 2, "both DidLaunch handlers ran");
423        assert_eq!(B.with(Cell::get), 0, "WillTerminate handler did not run");
424
425        dispatch_lifecycle(Lifecycle::WillTerminate);
426        assert_eq!(B.with(Cell::get), 1);
427
428        // A phase with no handlers is a silent no-op.
429        dispatch_lifecycle(Lifecycle::DidReceiveMemoryWarning);
430    }
431
432    #[test]
433    fn panicking_handler_is_contained_and_siblings_still_run() {
434        thread_local! {
435            static RAN: Cell<u32> = const { Cell::new(0) };
436        }
437        // A handler that panics (e.g. an `eprintln!` on a broken stderr pipe during teardown) must
438        // not propagate; dispatch runs inside a native trampoline that would abort on unwind.
439        on_lifecycle(Lifecycle::WillResignActive, || {
440            panic!("boom in a lifecycle handler")
441        });
442        on_lifecycle(Lifecycle::WillResignActive, || {
443            RAN.with(|c| c.set(c.get() + 1))
444        });
445
446        // Returns normally (containment) rather than unwinding, and the sibling still ran.
447        dispatch_lifecycle(Lifecycle::WillResignActive);
448        assert_eq!(
449            RAN.with(Cell::get),
450            1,
451            "the non-panicking sibling handler still ran"
452        );
453    }
454}