66//!
77//! Poll intervals are per-volume-type via `Volume::space_poll_interval()`:
88//! local volumes poll every 2 s, network/MTP every 5 s.
9-
10- use log:: { debug, warn} ;
9+ //!
10+ //! Also owns the low-disk-space warning: a permanent, backend-owned watcher on
11+ //! the boot volume (so the check works even when neither pane shows it) feeds
12+ //! a hysteresis detector that emits a `low-disk-space` event when free space
13+ //! crosses below the user-configured percent threshold. The poll loop already
14+ //! deduplicates by volume id, so a pane watching the boot volume shares the
15+ //! same single `statfs` per tick with the permanent watcher.
16+
17+ use log:: { debug, info, warn} ;
1118use serde:: Serialize ;
1219use std:: collections:: HashMap ;
13- use std:: sync:: atomic:: { AtomicU64 , Ordering } ;
20+ use std:: sync:: atomic:: { AtomicBool , AtomicU64 , Ordering } ;
1421use std:: sync:: { Mutex , OnceLock } ;
1522use std:: time:: Duration ;
1623use tauri:: { AppHandle , Emitter } ;
1724
1825use crate :: file_system:: get_volume_manager;
26+ use crate :: file_system:: volume:: DEFAULT_VOLUME_ID ;
1927
2028/// Global app handle for emitting events.
2129static APP_HANDLE : OnceLock < AppHandle > = OnceLock :: new ( ) ;
@@ -33,6 +41,30 @@ static LAST_SPACE: OnceLock<Mutex<HashMap<String, CachedSpace>>> = OnceLock::new
3341/// Change threshold in bytes. Updated at runtime from settings.
3442static THRESHOLD_BYTES : AtomicU64 = AtomicU64 :: new ( 1_048_576 ) ; // 1 MB default
3543
44+ /// Whether the low-disk-space warning is on. Mirrors the
45+ /// `behavior.fileSystemWatching.lowDiskSpaceNotifications` setting
46+ /// (`true` for any mode but "off"; the registry default is "in-app").
47+ static LOW_SPACE_ENABLED : AtomicBool = AtomicBool :: new ( true ) ;
48+
49+ /// Free-space percent threshold for the low-disk-space warning. Mirrors the
50+ /// `behavior.fileSystemWatching.lowDiskSpaceThresholdPercent` setting.
51+ static LOW_SPACE_THRESHOLD_PERCENT : AtomicU64 = AtomicU64 :: new ( 5 ) ;
52+
53+ /// Hysteresis state: `true` means the detector may fire on the next crossing
54+ /// below the threshold. Disarmed after firing; re-armed once free space climbs
55+ /// back above threshold + [`LOW_SPACE_REARM_MARGIN_PERCENT`].
56+ static LOW_SPACE_ARMED : AtomicBool = AtomicBool :: new ( true ) ;
57+
58+ /// Re-arm margin in percentage points. Without it, free space oscillating
59+ /// around the exact threshold (a download writing and deleting temp files)
60+ /// would fire a warning on every dip.
61+ const LOW_SPACE_REARM_MARGIN_PERCENT : f64 = 1.0 ;
62+
63+ /// Watcher id for the permanent backend-owned boot-volume entry. Lives in the
64+ /// same `WATCHED` map as the pane watchers so the dedup-by-volume-id logic
65+ /// merges it with a pane that happens to show the boot volume.
66+ const LOW_SPACE_BOOT_WATCHER_ID : & str = "low-space:boot" ;
67+
3668/// Default poll interval for volumes not registered in VolumeManager.
3769const DEFAULT_POLL_INTERVAL : Duration = Duration :: from_secs ( 2 ) ;
3870
@@ -61,6 +93,17 @@ struct VolumeSpaceChangedPayload {
6193 available_bytes : u64 ,
6294}
6395
96+ /// Payload for the `low-disk-space` Tauri event.
97+ #[ derive( Clone , Serialize ) ]
98+ #[ serde( rename_all = "camelCase" ) ]
99+ struct LowDiskSpacePayload {
100+ volume_id : String ,
101+ total_bytes : u64 ,
102+ available_bytes : u64 ,
103+ free_percent : f64 ,
104+ threshold_percent : u64 ,
105+ }
106+
64107/// Stores the app handle. Call once during setup.
65108pub fn init ( app : & AppHandle ) {
66109 let _ = APP_HANDLE . set ( app. clone ( ) ) ;
@@ -73,6 +116,27 @@ pub fn set_threshold_mb(mb: u64) {
73116 THRESHOLD_BYTES . store ( mb. saturating_mul ( 1_048_576 ) , Ordering :: Relaxed ) ;
74117}
75118
119+ /// Applies the low-disk-space warning config (at startup and live from Settings).
120+ ///
121+ /// Registers or removes the permanent boot-volume watcher so the extra
122+ /// `statfs` goes away entirely when the warning is off. Always re-arms the
123+ /// detector: a changed threshold should re-evaluate against the current free
124+ /// space on the next poll.
125+ pub fn configure_low_disk_space ( enabled : bool , threshold_percent : u64 ) {
126+ LOW_SPACE_ENABLED . store ( enabled, Ordering :: Relaxed ) ;
127+ LOW_SPACE_THRESHOLD_PERCENT . store ( threshold_percent. clamp ( 1 , 99 ) , Ordering :: Relaxed ) ;
128+ LOW_SPACE_ARMED . store ( true , Ordering :: Relaxed ) ;
129+ if enabled {
130+ watch (
131+ LOW_SPACE_BOOT_WATCHER_ID . to_string ( ) ,
132+ DEFAULT_VOLUME_ID . to_string ( ) ,
133+ "/" . to_string ( ) ,
134+ ) ;
135+ } else {
136+ unwatch ( LOW_SPACE_BOOT_WATCHER_ID ) ;
137+ }
138+ }
139+
76140/// Registers (or updates) a watcher for live space updates.
77141///
78142/// `watcher_id` is typically a pane ID ("left"/"right"). Multiple watchers
@@ -125,6 +189,13 @@ pub fn set_disk_space_threshold(mb: u64) {
125189 set_threshold_mb ( mb) ;
126190}
127191
192+ /// Updates the low-disk-space warning config at runtime (from settings).
193+ #[ tauri:: command]
194+ #[ specta:: specta]
195+ pub fn set_low_disk_space_config ( enabled : bool , threshold_percent : u64 ) {
196+ configure_low_disk_space ( enabled, threshold_percent) ;
197+ }
198+
128199/// The core loop. Ticks every second; each volume is polled at its own cadence.
129200async fn poll_loop ( ) {
130201 let mut tick: u64 = 0 ;
@@ -184,6 +255,13 @@ async fn poll_loop() {
184255 _ => continue , // timeout or no data: skip this tick
185256 } ;
186257
258+ // The low-space check sees every fetch, not just the ones that
259+ // pass the change-threshold gate below: a slow leak smaller than
260+ // the 1 MB emit threshold must still trip the warning.
261+ if volume_id == DEFAULT_VOLUME_ID {
262+ check_low_space ( & volume_id, & space) ;
263+ }
264+
187265 if exceeds_threshold ( & volume_id, & space, threshold) {
188266 update_cache ( & volume_id, & space) ;
189267 emit ( & volume_id, & space) ;
@@ -192,6 +270,64 @@ async fn poll_loop() {
192270 }
193271}
194272
273+ /// Runs the hysteresis detector on a fresh boot-volume space fetch and emits
274+ /// `low-disk-space` when the free percent crosses below the threshold.
275+ fn check_low_space ( volume_id : & str , space : & CachedSpace ) {
276+ if !LOW_SPACE_ENABLED . load ( Ordering :: Relaxed ) {
277+ return ;
278+ }
279+ let threshold = LOW_SPACE_THRESHOLD_PERCENT . load ( Ordering :: Relaxed ) ;
280+ let free = free_percent ( space. total_bytes , space. available_bytes ) ;
281+ let armed = LOW_SPACE_ARMED . load ( Ordering :: Relaxed ) ;
282+ let ( new_armed, fire) = low_space_transition ( armed, free, threshold as f64 ) ;
283+ LOW_SPACE_ARMED . store ( new_armed, Ordering :: Relaxed ) ;
284+ if fire {
285+ emit_low_disk_space ( volume_id, space, free, threshold) ;
286+ }
287+ }
288+
289+ /// Free space as a percent of total. Treats an unknown total (0) as not-low
290+ /// so a bogus fetch can't fire a false warning.
291+ fn free_percent ( total_bytes : u64 , available_bytes : u64 ) -> f64 {
292+ if total_bytes == 0 {
293+ return 100.0 ;
294+ }
295+ available_bytes as f64 / total_bytes as f64 * 100.0
296+ }
297+
298+ /// The pure hysteresis step: `(armed, free, threshold)` → `(new_armed, fire)`.
299+ ///
300+ /// Fires exactly once per crossing below the threshold; re-arms only after
301+ /// free space recovers above threshold + [`LOW_SPACE_REARM_MARGIN_PERCENT`],
302+ /// so oscillation around the boundary can't re-fire.
303+ fn low_space_transition ( armed : bool , free_percent : f64 , threshold_percent : f64 ) -> ( bool , bool ) {
304+ if armed && free_percent < threshold_percent {
305+ return ( false , true ) ;
306+ }
307+ if !armed && free_percent >= threshold_percent + LOW_SPACE_REARM_MARGIN_PERCENT {
308+ return ( true , false ) ;
309+ }
310+ ( armed, false )
311+ }
312+
313+ fn emit_low_disk_space ( volume_id : & str , space : & CachedSpace , free_percent : f64 , threshold_percent : u64 ) {
314+ let Some ( app) = APP_HANDLE . get ( ) else { return } ;
315+ let payload = LowDiskSpacePayload {
316+ volume_id : volume_id. to_string ( ) ,
317+ total_bytes : space. total_bytes ,
318+ available_bytes : space. available_bytes ,
319+ free_percent,
320+ threshold_percent,
321+ } ;
322+ info ! (
323+ "low-disk-space: {} at {:.1}% free ({} of {} bytes), threshold {}%" ,
324+ volume_id, free_percent, space. available_bytes, space. total_bytes, threshold_percent
325+ ) ;
326+ if let Err ( e) = app. emit ( "low-disk-space" , & payload) {
327+ warn ! ( "Failed to emit low-disk-space: {}" , e) ;
328+ }
329+ }
330+
195331/// Fetches space info for a filesystem path using the platform API.
196332/// Used as a fallback when the volume is not registered in VolumeManager.
197333fn fetch_space_for_path ( path : & str ) -> Option < CachedSpace > {
@@ -259,3 +395,74 @@ fn emit(volume_id: &str, space: &CachedSpace) {
259395 warn ! ( "Failed to emit volume-space-changed: {}" , e) ;
260396 }
261397}
398+
399+ #[ cfg( test) ]
400+ mod tests {
401+ use super :: * ;
402+
403+ #[ test]
404+ fn fires_once_when_crossing_below_threshold ( ) {
405+ let ( armed, fire) = low_space_transition ( true , 4.9 , 5.0 ) ;
406+ assert ! ( !armed) ;
407+ assert ! ( fire) ;
408+ }
409+
410+ #[ test]
411+ fn does_not_fire_above_threshold ( ) {
412+ let ( armed, fire) = low_space_transition ( true , 5.0 , 5.0 ) ;
413+ assert ! ( armed) ;
414+ assert ! ( !fire) ;
415+ }
416+
417+ #[ test]
418+ fn does_not_refire_while_disarmed ( ) {
419+ let ( armed, fire) = low_space_transition ( false , 3.0 , 5.0 ) ;
420+ assert ! ( !armed) ;
421+ assert ! ( !fire) ;
422+ }
423+
424+ #[ test]
425+ fn stays_disarmed_inside_rearm_margin ( ) {
426+ // Recovered above the threshold but not past the margin: no re-arm,
427+ // so a dip back under 5% can't fire again.
428+ let ( armed, fire) = low_space_transition ( false , 5.5 , 5.0 ) ;
429+ assert ! ( !armed) ;
430+ assert ! ( !fire) ;
431+ }
432+
433+ #[ test]
434+ fn rearms_past_the_margin_then_fires_on_next_crossing ( ) {
435+ let ( armed, fire) = low_space_transition ( false , 6.0 , 5.0 ) ;
436+ assert ! ( armed) ;
437+ assert ! ( !fire) ;
438+ let ( armed, fire) = low_space_transition ( armed, 4.0 , 5.0 ) ;
439+ assert ! ( !armed) ;
440+ assert ! ( fire) ;
441+ }
442+
443+ #[ test]
444+ fn oscillation_around_threshold_fires_once ( ) {
445+ // 5.2 → 4.8 → 5.2 → 4.8: one warning, not two.
446+ let mut armed = true ;
447+ let mut fires = 0 ;
448+ for free in [ 5.2 , 4.8 , 5.2 , 4.8 ] {
449+ let ( next, fire) = low_space_transition ( armed, free, 5.0 ) ;
450+ armed = next;
451+ if fire {
452+ fires += 1 ;
453+ }
454+ }
455+ assert_eq ! ( fires, 1 ) ;
456+ }
457+
458+ #[ test]
459+ fn free_percent_handles_zero_total ( ) {
460+ // Unknown total must read as not-low (no false warning on a bogus fetch).
461+ assert_eq ! ( free_percent( 0 , 0 ) , 100.0 ) ;
462+ }
463+
464+ #[ test]
465+ fn free_percent_computes_fraction ( ) {
466+ assert ! ( ( free_percent( 1000 , 50 ) - 5.0 ) . abs( ) < f64 :: EPSILON ) ;
467+ }
468+ }
0 commit comments