Skip to content

Commit 777f9ec

Browse files
committed
Bugfix: Fix macOS multi-GB memory leak
A very important fix! Add `autoreleasepool` wrappers around all ObjC/Cocoa API calls on background threads. Without these, autoreleased objects (`NSData`, `NSURL`, `_FileCache`, `NSInvocation`, `FPFrameworkOverridesIterator`) accumulate in a default pool that is never drained — heap analysis showed 50M retained ObjC objects totaling 5 GB after 20h of runtime. - `icons.rs`: wrap `par_iter` closures and per-icon loop in `get_icons` (UTType/Launch Services/NSWorkspace calls) - `macos_metadata.rs`: wrap `get_macos_metadata` body (NSURL resource value queries from `spawn_blocking`) - `sync_status.rs`: wrap `get_ubiquitous_bool` body (NSURL queries from rayon `par_iter`) - `writer.rs`: wrap each `writer_loop` iteration (`app.emit()` serializes through WebKit's Cocoa bridge) - `volumes/mod.rs`: wrap `get_main_volume`, `get_attached_volumes`, `get_volume_space` (NSFileManager from `spawn_blocking`) - `trash.rs`: wrap `move_to_trash_sync` ObjC portion
1 parent 1b554fa commit 777f9ec

9 files changed

Lines changed: 378 additions & 212 deletions

File tree

apps/desktop/src-tauri/src/file_system/macos_metadata.rs

Lines changed: 57 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
77
use std::path::Path;
88

9-
use objc2::rc::Retained;
9+
use objc2::rc::{Retained, autoreleasepool};
1010
use objc2_foundation::{NSDate, NSString, NSURL};
1111

1212
/// Extended macOS metadata for a file.
@@ -22,62 +22,66 @@ pub struct MacOSMetadata {
2222
/// Returns `None` values for individual fields if they are unavailable on the volume
2323
/// or if any error occurs during retrieval.
2424
pub fn get_macos_metadata(path: &Path) -> MacOSMetadata {
25-
// Helper to convert NSDate to Unix timestamp
26-
fn nsdate_to_unix(date: Option<Retained<NSDate>>) -> Option<u64> {
27-
date.and_then(|d| {
28-
// NSDate timeIntervalSince1970 returns seconds since Unix epoch as f64
29-
let interval = d.timeIntervalSince1970();
30-
if interval >= 0.0 { Some(interval as u64) } else { None }
31-
})
32-
}
33-
34-
// Convert path to NSString
35-
let path_str = match path.to_str() {
36-
Some(s) => s,
37-
None => {
38-
return MacOSMetadata {
39-
added_at: None,
40-
opened_at: None,
41-
};
25+
// Drain autoreleased ObjC objects (NSURL, NSString, NSDate) created per call.
26+
// Called from spawn_blocking threads that lack AppKit's autorelease pool.
27+
autoreleasepool(|_| {
28+
// Helper to convert NSDate to Unix timestamp
29+
fn nsdate_to_unix(date: Option<Retained<NSDate>>) -> Option<u64> {
30+
date.and_then(|d| {
31+
// NSDate timeIntervalSince1970 returns seconds since Unix epoch as f64
32+
let interval = d.timeIntervalSince1970();
33+
if interval >= 0.0 { Some(interval as u64) } else { None }
34+
})
4235
}
43-
};
4436

45-
let ns_path = NSString::from_str(path_str);
37+
// Convert path to NSString
38+
let path_str = match path.to_str() {
39+
Some(s) => s,
40+
None => {
41+
return MacOSMetadata {
42+
added_at: None,
43+
opened_at: None,
44+
};
45+
}
46+
};
4647

47-
// Create NSURL from file path
48-
let url = NSURL::fileURLWithPath(&ns_path);
48+
let ns_path = NSString::from_str(path_str);
4949

50-
// Fetch added_at (NSURLAddedToDirectoryDateKey)
51-
let added_at = {
52-
let key = NSString::from_str("NSURLAddedToDirectoryDateKey");
53-
let mut value: Option<Retained<objc2::runtime::AnyObject>> = None;
54-
let success = unsafe { url.getResourceValue_forKey_error(&mut value, &key) };
55-
if success.is_ok() {
56-
// Cast AnyObject to NSDate if it's a date
57-
value.and_then(|obj| {
58-
// Downcast to NSDate - this is safe because we know the key returns NSDate
59-
let retained = obj.downcast::<NSDate>().ok();
60-
nsdate_to_unix(retained)
61-
})
62-
} else {
63-
None
64-
}
65-
};
50+
// Create NSURL from file path
51+
let url = NSURL::fileURLWithPath(&ns_path);
6652

67-
// Fetch opened_at (NSURLContentAccessDateKey)
68-
let opened_at = {
69-
let key = NSString::from_str("NSURLContentAccessDateKey");
70-
let mut value: Option<Retained<objc2::runtime::AnyObject>> = None;
71-
let success = unsafe { url.getResourceValue_forKey_error(&mut value, &key) };
72-
if success.is_ok() {
73-
value.and_then(|obj| {
74-
let retained = obj.downcast::<NSDate>().ok();
75-
nsdate_to_unix(retained)
76-
})
77-
} else {
78-
None
79-
}
80-
};
53+
// Fetch added_at (NSURLAddedToDirectoryDateKey)
54+
let added_at = {
55+
let key = NSString::from_str("NSURLAddedToDirectoryDateKey");
56+
let mut value: Option<Retained<objc2::runtime::AnyObject>> = None;
57+
let success = unsafe { url.getResourceValue_forKey_error(&mut value, &key) };
58+
if success.is_ok() {
59+
// Cast AnyObject to NSDate if it's a date
60+
value.and_then(|obj| {
61+
// Downcast to NSDate - this is safe because we know the key returns NSDate
62+
let retained = obj.downcast::<NSDate>().ok();
63+
nsdate_to_unix(retained)
64+
})
65+
} else {
66+
None
67+
}
68+
};
69+
70+
// Fetch opened_at (NSURLContentAccessDateKey)
71+
let opened_at = {
72+
let key = NSString::from_str("NSURLContentAccessDateKey");
73+
let mut value: Option<Retained<objc2::runtime::AnyObject>> = None;
74+
let success = unsafe { url.getResourceValue_forKey_error(&mut value, &key) };
75+
if success.is_ok() {
76+
value.and_then(|obj| {
77+
let retained = obj.downcast::<NSDate>().ok();
78+
nsdate_to_unix(retained)
79+
})
80+
} else {
81+
None
82+
}
83+
};
8184

82-
MacOSMetadata { added_at, opened_at }
85+
MacOSMetadata { added_at, opened_at }
86+
})
8387
}

apps/desktop/src-tauri/src/file_system/sync_status.rs

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -78,22 +78,26 @@ fn is_downloading(path: &Path) -> bool {
7878

7979
/// Gets a boolean ubiquitous item property from NSURL.
8080
fn get_ubiquitous_bool(path: &Path, key: &str) -> Option<bool> {
81-
use objc2::rc::Retained;
81+
use objc2::rc::{Retained, autoreleasepool};
8282
use objc2_foundation::{NSNumber, NSString, NSURL};
8383

84-
let path_str = path.to_str()?;
85-
let ns_path = NSString::from_str(path_str);
86-
let url = NSURL::fileURLWithPath(&ns_path);
84+
// Drain autoreleased ObjC objects (NSURL, NSString) created per call.
85+
// Called from rayon par_iter threads that lack AppKit's autorelease pool.
86+
autoreleasepool(|_| {
87+
let path_str = path.to_str()?;
88+
let ns_path = NSString::from_str(path_str);
89+
let url = NSURL::fileURLWithPath(&ns_path);
8790

88-
let key = NSString::from_str(key);
89-
let mut value: Option<Retained<objc2::runtime::AnyObject>> = None;
90-
let success = unsafe { url.getResourceValue_forKey_error(&mut value, &key) };
91+
let key = NSString::from_str(key);
92+
let mut value: Option<Retained<objc2::runtime::AnyObject>> = None;
93+
let success = unsafe { url.getResourceValue_forKey_error(&mut value, &key) };
9194

92-
if success.is_ok() {
93-
value.and_then(|obj| obj.downcast::<NSNumber>().ok().map(|n| n.boolValue()))
94-
} else {
95-
None
96-
}
95+
if success.is_ok() {
96+
value.and_then(|obj| obj.downcast::<NSNumber>().ok().map(|n| n.boolValue()))
97+
} else {
98+
None
99+
}
100+
})
97101
}
98102

99103
/// Gets sync status for multiple paths in parallel.

apps/desktop/src-tauri/src/file_system/write_operations/trash.rs

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,23 +26,28 @@ use super::types::{
2626
/// are handled correctly (the link itself exists even if its target doesn't).
2727
#[cfg(target_os = "macos")]
2828
pub fn move_to_trash_sync(path: &Path) -> Result<(), String> {
29+
use objc2::rc::autoreleasepool;
2930
use objc2_foundation::{NSFileManager, NSString, NSURL};
3031

3132
if fs::symlink_metadata(path).is_err() {
3233
return Err(format!("'{}' doesn't exist", path.display()));
3334
}
3435

35-
let path_str = path.to_string_lossy();
36-
let ns_path = NSString::from_str(&path_str);
37-
let url = NSURL::fileURLWithPath(&ns_path);
38-
let file_manager = NSFileManager::defaultManager();
39-
40-
// trashItemAtURL:resultingItemURL:error: moves the item to Trash.
41-
// We pass None for resultingItemURL since we don't need the trash location.
42-
file_manager
43-
.trashItemAtURL_resultingItemURL_error(&url, None)
44-
.map_err(|e| format!("Failed to move to trash: {}", e))?;
45-
Ok(())
36+
// Drain autoreleased ObjC objects (NSURL, NSString, NSFileManager internals).
37+
// Called from spawn_blocking threads that lack AppKit's autorelease pool.
38+
autoreleasepool(|_| {
39+
let path_str = path.to_string_lossy();
40+
let ns_path = NSString::from_str(&path_str);
41+
let url = NSURL::fileURLWithPath(&ns_path);
42+
let file_manager = NSFileManager::defaultManager();
43+
44+
// trashItemAtURL:resultingItemURL:error: moves the item to Trash.
45+
// We pass None for resultingItemURL since we don't need the trash location.
46+
file_manager
47+
.trashItemAtURL_resultingItemURL_error(&url, None)
48+
.map_err(|e| format!("Failed to move to trash: {}", e))?;
49+
Ok(())
50+
})
4651
}
4752

4853
#[cfg(target_os = "linux")]

apps/desktop/src-tauri/src/icons.rs

Lines changed: 78 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
use crate::config::ICON_SIZE;
88
use base64::Engine;
99
use image::{DynamicImage, ImageFormat, imageops::FilterType};
10+
#[cfg(target_os = "macos")]
11+
use objc2::rc::autoreleasepool;
1012
use rayon::prelude::*;
1113
use std::collections::HashMap;
1214
use std::io::Cursor;
@@ -159,36 +161,55 @@ pub fn get_icons(icon_ids: Vec<String>, use_app_icons_for_documents: bool) -> Ha
159161
continue;
160162
}
161163

162-
// Not cached, fetch it
163-
// For extension-based icons on macOS, use fresh fetch if app icons are enabled
164+
// macOS: drain autoreleased ObjC objects per iteration
165+
// (fetch_fresh_extension_icon and fetch_icon_for_path call ObjC APIs)
164166
#[cfg(target_os = "macos")]
165-
if use_app_icons_for_documents
166-
&& let Some(ext) = icon_id.strip_prefix("ext:")
167-
&& let Some(data_url) = fetch_fresh_extension_icon(ext, true)
168-
{
169-
cache_icon(icon_id.clone(), data_url.clone());
170-
result.insert(icon_id, data_url);
171-
continue;
172-
}
167+
let fetched = autoreleasepool(|_| {
168+
if use_app_icons_for_documents
169+
&& let Some(ext) = icon_id.strip_prefix("ext:")
170+
&& let Some(data_url) = fetch_fresh_extension_icon(ext, true)
171+
{
172+
return Some(data_url);
173+
}
174+
175+
if let Some(sample_path) = get_sample_path_for_icon_id(&icon_id)
176+
&& let Some(data_url) = fetch_icon_for_path(&sample_path)
177+
{
178+
return Some(data_url);
179+
}
180+
None
181+
});
173182

174-
// Silence unused variable warning when not on macOS
175183
#[cfg(not(target_os = "macos"))]
176-
let _ = use_app_icons_for_documents;
184+
let fetched = {
185+
// Silence unused variable warning when not on macOS
186+
let _ = use_app_icons_for_documents;
187+
188+
// Linux: look up directly from XDG icon theme (no temp files needed)
189+
#[cfg(target_os = "linux")]
190+
if let Some(img) = crate::linux_icons::get_icon_for_id(&icon_id, ICON_SIZE as u16)
191+
&& let Some(data_url) = image_to_data_url(&img)
192+
{
193+
Some(data_url)
194+
} else if let Some(sample_path) = get_sample_path_for_icon_id(&icon_id)
195+
&& let Some(data_url) = fetch_icon_for_path(&sample_path)
196+
{
197+
Some(data_url)
198+
} else {
199+
None
200+
}
177201

178-
// Linux: look up directly from XDG icon theme (no temp files needed)
179-
#[cfg(target_os = "linux")]
180-
if let Some(img) = crate::linux_icons::get_icon_for_id(&icon_id, ICON_SIZE as u16)
181-
&& let Some(data_url) = image_to_data_url(&img)
182-
{
183-
cache_icon(icon_id.clone(), data_url.clone());
184-
result.insert(icon_id, data_url);
185-
continue;
186-
}
202+
#[cfg(not(target_os = "linux"))]
203+
if let Some(sample_path) = get_sample_path_for_icon_id(&icon_id)
204+
&& let Some(data_url) = fetch_icon_for_path(&sample_path)
205+
{
206+
Some(data_url)
207+
} else {
208+
None
209+
}
210+
};
187211

188-
// macOS/Windows: use sample file approach (Launch Services / Shell)
189-
if let Some(sample_path) = get_sample_path_for_icon_id(&icon_id)
190-
&& let Some(data_url) = fetch_icon_for_path(&sample_path)
191-
{
212+
if let Some(data_url) = fetched {
192213
cache_icon(icon_id.clone(), data_url.clone());
193214
result.insert(icon_id, data_url);
194215
}
@@ -249,9 +270,22 @@ pub fn refresh_icons_for_directory(
249270
let ext_results: Vec<(String, Option<String>)> = extensions
250271
.par_iter()
251272
.map(|ext| {
252-
let icon_id = format!("ext:{}", ext.to_lowercase());
253-
let data_url = fetch_fresh_extension_icon(ext, use_app_icons_for_documents);
254-
(icon_id, data_url)
273+
// macOS: drain autoreleased ObjC objects per rayon thread iteration
274+
// (UTType/Launch Services/NSWorkspace calls accumulate otherwise)
275+
#[cfg(target_os = "macos")]
276+
{
277+
autoreleasepool(|_| {
278+
let icon_id = format!("ext:{}", ext.to_lowercase());
279+
let data_url = fetch_fresh_extension_icon(ext, use_app_icons_for_documents);
280+
(icon_id, data_url)
281+
})
282+
}
283+
#[cfg(not(target_os = "macos"))]
284+
{
285+
let icon_id = format!("ext:{}", ext.to_lowercase());
286+
let data_url = fetch_fresh_extension_icon(ext, use_app_icons_for_documents);
287+
(icon_id, data_url)
288+
}
255289
})
256290
.collect();
257291

@@ -268,10 +302,22 @@ pub fn refresh_icons_for_directory(
268302
let dir_results: Vec<(String, Option<String>)> = directory_paths
269303
.par_iter()
270304
.map(|path| {
271-
let path_buf = PathBuf::from(path);
272-
let data_url = fetch_icon_for_path(&path_buf);
273-
// Use path as the icon ID for directories
274-
(format!("path:{}", path), data_url)
305+
// macOS: drain autoreleased ObjC objects per rayon thread iteration
306+
// (NSWorkspace.iconForFile calls accumulate otherwise)
307+
#[cfg(target_os = "macos")]
308+
{
309+
autoreleasepool(|_| {
310+
let path_buf = PathBuf::from(path);
311+
let data_url = fetch_icon_for_path(&path_buf);
312+
(format!("path:{}", path), data_url)
313+
})
314+
}
315+
#[cfg(not(target_os = "macos"))]
316+
{
317+
let path_buf = PathBuf::from(path);
318+
let data_url = fetch_icon_for_path(&path_buf);
319+
(format!("path:{}", path), data_url)
320+
}
275321
})
276322
.collect();
277323

apps/desktop/src-tauri/src/indexing/CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,5 @@ Key test files are alongside each module (test functions within `#[cfg(test)]` b
187187
**IndexWriter exposes `db_path()`**: The scanner needs the DB path to open a temporary connection for `ScanContext::new()`. This path is stored on the `IndexWriter` handle and accessible via `db_path()`. The temporary connection is short-lived (only used to read `MAX(id)`).
188188

189189
**Verifier tests must avoid `/tmp/` for filesystem roots**: On Linux, `/tmp/` is in `EXCLUDED_PREFIXES`. Tests that create filesystem trees and run `verify_and_correct` must use `test_tempdir()` (creates temp dirs in `CARGO_MANIFEST_DIR`) so `should_exclude` doesn't filter out new entries. The DB temp dir can still use `tempfile::tempdir()` since it's never checked by `should_exclude`.
190+
191+
**macOS: `autoreleasepool` required for ObjC calls on background threads**: The writer thread (`std::thread::spawn`) calls `app.emit()` which serializes through WebKit's Cocoa bridge, creating autoreleased `NSData`/`NSInvocation` objects. Without an `autoreleasepool`, these accumulate in a default pool that is never drained, causing multi-GB memory leaks over hours. The `writer_loop` wraps each message-processing iteration in `objc2::rc::autoreleasepool`. The same pattern applies to any `std::thread::spawn`, `rayon::par_iter`, or `tokio::spawn_blocking` thread that calls ObjC/Cocoa APIs — see `icons.rs`, `macos_metadata.rs`, `sync_status.rs`, `volumes/mod.rs`, and `trash.rs` for examples.

0 commit comments

Comments
 (0)