fix(macos): release leaked window delegate Box and ObjC class on window close#1913
fix(macos): release leaked window delegate Box and ObjC class on window close#1913ManthanNimodiya wants to merge 4 commits into
Conversation
…elegate class instead of registering a new one per window
|
Superagent didn't find any vulnerabilities or security issues in this PR. |
| // delegate with the same name. | ||
| let delegate_name = format!("windowDelegate_cap_{window_label}_{random_str}"); | ||
| let delegate_class = get_or_register_delegate_class::<R>(); | ||
| let delegate: id = msg_send![delegate_class, alloc]; |
There was a problem hiding this comment.
Nit: consider using new (or alloc + init) here. alloc without init is unusual for NSObject subclasses and can be a footgun.
| let delegate: id = msg_send![delegate_class, alloc]; | |
| let delegate: id = msg_send![delegate_class, new]; |
| // NSWindow does not retain its delegate, so the `alloc` reference taken | ||
| // when this delegate was created is the only owning reference. Release | ||
| // it now that the window is closing. | ||
| let this_id = this as *const Object as id; | ||
| let _: () = msg_send![this_id, release]; |
There was a problem hiding this comment.
One thought: since you release the delegate instance here, it may be safer to restore the previous delegate (or set nil) first, just in case the window emits any late delegate callbacks during teardown.
| // NSWindow does not retain its delegate, so the `alloc` reference taken | |
| // when this delegate was created is the only owning reference. Release | |
| // it now that the window is closing. | |
| let this_id = this as *const Object as id; | |
| let _: () = msg_send![this_id, release]; | |
| // NSWindow does not retain its delegate, so the `alloc` reference taken | |
| // when this delegate was created is the only owning reference. Release | |
| // it now that the window is closing. | |
| let window: id = *this.get_ivar("window"); | |
| let _: () = msg_send![window, setDelegate: super_del]; | |
| let this_id = this as *const Object as id; | |
| let _: () = msg_send![this_id, release]; |
| // Register the delegate class once and reuse it for every window. Previously a brand | ||
| // new class was registered (with a randomized name) on every call to `setup`, which | ||
| // permanently leaked Objective-C class metadata for the lifetime of the process. | ||
| fn get_or_register_delegate_class<R: Runtime>() -> &'static Class { |
There was a problem hiding this comment.
Heads up: since get_or_register_delegate_class is generic, the static CLASS is per-monomorphization, but the ObjC class name is always CapWindowDelegate. If setup() is ever called with a different R, this will try to re-register the same class name and panic.
Probably fine if there’s only ever one runtime type in practice, but if not, you may want a stable per-R class name (e.g., hash type_name::<R>()) or otherwise enforce a single R.
| let delegate_class = get_or_register_delegate_class::<R>(); | ||
| let delegate: id = msg_send![delegate_class, alloc]; | ||
| (*delegate).set_ivar("window", ns_win_id); |
There was a problem hiding this comment.
ObjC convention requires alloc to be followed by init before any instance methods (including ivar access) are used. For a plain NSObject subclass init is a no-op and alloc zeroes memory, so this works today, but it is technically undefined per the ObjC spec and may break with stricter runtimes or future changes.
| let delegate_class = get_or_register_delegate_class::<R>(); | |
| let delegate: id = msg_send![delegate_class, alloc]; | |
| (*delegate).set_ivar("window", ns_win_id); | |
| let delegate_class = get_or_register_delegate_class::<R>(); | |
| let delegate: id = { | |
| let alloc: id = msg_send![delegate_class, alloc]; | |
| msg_send![alloc, init] | |
| }; | |
| (*delegate).set_ivar("window", ns_win_id); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/platform/macos/delegates.rs
Line: 394-396
Comment:
**Missing `init` after `alloc`**
ObjC convention requires `alloc` to be followed by `init` before any instance methods (including ivar access) are used. For a plain `NSObject` subclass `init` is a no-op and `alloc` zeroes memory, so this works today, but it is technically undefined per the ObjC spec and may break with stricter runtimes or future changes.
```suggestion
let delegate_class = get_or_register_delegate_class::<R>();
let delegate: id = {
let alloc: id = msg_send![delegate_class, alloc];
msg_send![alloc, init]
};
(*delegate).set_ivar("window", ns_win_id);
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| fn get_or_register_delegate_class<R: Runtime>() -> &'static Class { | ||
| static CLASS: std::sync::OnceLock<&'static Class> = std::sync::OnceLock::new(); | ||
| *CLASS.get_or_init(|| { | ||
| let mut decl = ClassDecl::new("CapWindowDelegate", class!(NSObject)) | ||
| .expect("CapWindowDelegate class already registered"); | ||
|
|
||
| decl.add_ivar::<id>("window"); | ||
| decl.add_ivar::<*mut c_void>("app_box"); | ||
| decl.add_ivar::<id>("toolbar"); | ||
| decl.add_ivar::<id>("super_delegate"); | ||
|
|
||
| unsafe { | ||
| decl.add_method(sel!(windowShouldClose:), on_window_should_close as extern "C" fn(&Object, Sel, id) -> BOOL); | ||
| decl.add_method(sel!(windowWillClose:), on_window_will_close::<R> as extern "C" fn(&Object, Sel, id)); | ||
| decl.add_method(sel!(windowDidResize:), on_window_did_resize::<R> as extern "C" fn(&Object, Sel, id)); | ||
| decl.add_method(sel!(windowDidMove:), on_window_did_move as extern "C" fn(&Object, Sel, id)); | ||
| decl.add_method(sel!(windowDidChangeBackingProperties:), on_window_did_change_backing_properties as extern "C" fn(&Object, Sel, id)); | ||
| decl.add_method(sel!(windowDidBecomeKey:), on_window_did_become_key as extern "C" fn(&Object, Sel, id)); | ||
| decl.add_method(sel!(windowDidResignKey:), on_window_did_resign_key as extern "C" fn(&Object, Sel, id)); | ||
| decl.add_method(sel!(draggingEntered:), on_dragging_entered as extern "C" fn(&Object, Sel, id) -> BOOL); | ||
| decl.add_method(sel!(prepareForDragOperation:), on_prepare_for_drag_operation as extern "C" fn(&Object, Sel, id) -> BOOL); | ||
| decl.add_method(sel!(performDragOperation:), on_perform_drag_operation as extern "C" fn(&Object, Sel, id) -> BOOL); | ||
| decl.add_method(sel!(concludeDragOperation:), on_conclude_drag_operation as extern "C" fn(&Object, Sel, id)); | ||
| decl.add_method(sel!(draggingExited:), on_dragging_exited as extern "C" fn(&Object, Sel, id)); | ||
| decl.add_method(sel!(window:willUseFullScreenPresentationOptions:), on_window_will_use_full_screen_presentation_options as extern "C" fn(&Object, Sel, id, NSUInteger) -> NSUInteger); | ||
| decl.add_method(sel!(windowDidEnterFullScreen:), on_window_did_enter_full_screen::<R> as extern "C" fn(&Object, Sel, id)); | ||
| decl.add_method(sel!(windowWillEnterFullScreen:), on_window_will_enter_full_screen::<R> as extern "C" fn(&Object, Sel, id)); | ||
| decl.add_method(sel!(windowDidExitFullScreen:), on_window_did_exit_full_screen::<R> as extern "C" fn(&Object, Sel, id)); | ||
| decl.add_method(sel!(windowWillExitFullScreen:), on_window_will_exit_full_screen::<R> as extern "C" fn(&Object, Sel, id)); | ||
| decl.add_method(sel!(windowDidFailToEnterFullScreen:), on_window_did_fail_to_enter_full_screen as extern "C" fn(&Object, Sel, id)); | ||
| decl.add_method(sel!(effectiveAppearanceDidChange:), on_effective_appearance_did_change as extern "C" fn(&Object, Sel, id)); | ||
| decl.add_method(sel!(effectiveAppearanceDidChangedOnMainThread:), on_effective_appearance_did_changed_on_main_thread as extern "C" fn(&Object, Sel, id)); | ||
| } | ||
|
|
||
| decl.register() | ||
| }) | ||
| } |
There was a problem hiding this comment.
Shared
static CLASS captures function pointers for only the first R monomorphization
In Rust, static items inside generic functions are shared across all monomorphizations — there is exactly one CLASS for the entire program regardless of R. get_or_init will only ever run the closure once, registering ObjC method implementations for whichever R type first calls setup. Any subsequent setup::<DifferentR>() reuses that class silently, with the wrong monomorphized function pointers. In a real Tauri application R is always tauri::Wry, so this causes no observable issue today, but the pattern will silently misbehave if the type ever changes.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/platform/macos/delegates.rs
Line: 350-386
Comment:
**Shared `static CLASS` captures function pointers for only the first `R` monomorphization**
In Rust, `static` items inside generic functions are shared across all monomorphizations — there is exactly one `CLASS` for the entire program regardless of `R`. `get_or_init` will only ever run the closure once, registering ObjC method implementations for whichever `R` type first calls `setup`. Any subsequent `setup::<DifferentR>()` reuses that class silently, with the wrong monomorphized function pointers. In a real Tauri application `R` is always `tauri::Wry`, so this causes no observable issue today, but the pattern will silently misbehave if the type ever changes.
How can I resolve this? If you propose a fix, please make it concise.…er review feedback
|
@greptileai please re-review |
| if !app_box.is_null() { | ||
| drop(Box::from_raw(app_box as *mut WindowState<R>)); | ||
| let this_mut = this as *const Object as *mut Object; | ||
| (*this_mut).set_ivar("app_box", std::ptr::null_mut::<c_void>()); |
There was a problem hiding this comment.
Now that app_box can be set to null, any late delegate callback that still hits this object (e.g. an event queued before setDelegate: takes effect) will cause with_window_state to deref a null pointer and SIGSEGV.
Might be worth adding a null guard in with_window_state (or otherwise ensuring no callbacks can run after windowWillClose:).
|
@tembo please re-review |
re-reviewed pr #1913. all of my earlier feedback is now addressed:
the leak fix itself is correct for the normal path. one thing to fix before merge: the Format (Cargo) ci check is failing — the long minor non-blocking note: the drop/release cleanup sits inside the once ci is green it's good to go from my side. left this as a review comment on the pr. |
| extern "C" fn on_window_will_close<R: Runtime>(this: &Object, _cmd: Sel, notification: id) { | ||
| suppress_delegate_panic("windowWillClose:", (), || unsafe { | ||
| let super_del: id = *this.get_ivar("super_delegate"); | ||
| let _: () = msg_send![super_del, windowWillClose: notification]; | ||
|
|
||
| // Drop the boxed `WindowState<R>` (and the `Window<R>` handle it holds) | ||
| // that was leaked via `Box::into_raw` when this delegate was created. | ||
| let app_box: *mut c_void = *this.get_ivar("app_box"); | ||
| if !app_box.is_null() { | ||
| drop(Box::from_raw(app_box as *mut WindowState<R>)); | ||
| let this_mut = this as *const Object as *mut Object; | ||
| (*this_mut).set_ivar("app_box", std::ptr::null_mut::<c_void>()); | ||
| } | ||
|
|
||
| // Restore the previous delegate before releasing this one, so any | ||
| // further delegate callbacks during teardown don't hit a freed object. | ||
| let window: id = *this.get_ivar("window"); | ||
| let _: () = msg_send![window, setDelegate: super_del]; | ||
|
|
||
| // NSWindow does not retain its delegate, so the reference taken when | ||
| // this delegate was created (`new`) is the only owning one. Release | ||
| // it now that the window is closing. | ||
| let this_id = this as *const Object as id; | ||
| let _: () = msg_send![this_id, release]; | ||
| }); | ||
| } |
There was a problem hiding this comment.
Minor robustness nit: right now all of the cleanup lives inside the same suppress_delegate_panic closure as the forwarded super_del call. If msg_send![super_del, windowWillClose: …] ever panics, we’ll skip dropping app_box / restoring the delegate / releasing self, which brings back the leak in the error path.
| extern "C" fn on_window_will_close<R: Runtime>(this: &Object, _cmd: Sel, notification: id) { | |
| suppress_delegate_panic("windowWillClose:", (), || unsafe { | |
| let super_del: id = *this.get_ivar("super_delegate"); | |
| let _: () = msg_send![super_del, windowWillClose: notification]; | |
| // Drop the boxed `WindowState<R>` (and the `Window<R>` handle it holds) | |
| // that was leaked via `Box::into_raw` when this delegate was created. | |
| let app_box: *mut c_void = *this.get_ivar("app_box"); | |
| if !app_box.is_null() { | |
| drop(Box::from_raw(app_box as *mut WindowState<R>)); | |
| let this_mut = this as *const Object as *mut Object; | |
| (*this_mut).set_ivar("app_box", std::ptr::null_mut::<c_void>()); | |
| } | |
| // Restore the previous delegate before releasing this one, so any | |
| // further delegate callbacks during teardown don't hit a freed object. | |
| let window: id = *this.get_ivar("window"); | |
| let _: () = msg_send![window, setDelegate: super_del]; | |
| // NSWindow does not retain its delegate, so the reference taken when | |
| // this delegate was created (`new`) is the only owning one. Release | |
| // it now that the window is closing. | |
| let this_id = this as *const Object as id; | |
| let _: () = msg_send![this_id, release]; | |
| }); | |
| } | |
| extern "C" fn on_window_will_close<R: Runtime>(this: &Object, _cmd: Sel, notification: id) { | |
| let super_del: id = unsafe { *this.get_ivar("super_delegate") }; | |
| // Forward to the previous delegate, but don't let it prevent cleanup. | |
| suppress_delegate_panic("windowWillClose:", (), || unsafe { | |
| let _: () = msg_send![super_del, windowWillClose: notification]; | |
| }); | |
| suppress_delegate_panic("windowWillClose:cleanup", (), || unsafe { | |
| // Drop the boxed `WindowState<R>` (and the `Window<R>` handle it holds) | |
| // that was leaked via `Box::into_raw` when this delegate was created. | |
| let app_box: *mut c_void = *this.get_ivar("app_box"); | |
| if !app_box.is_null() { | |
| drop(Box::from_raw(app_box as *mut WindowState<R>)); | |
| let this_mut = this as *const Object as *mut Object; | |
| (*this_mut).set_ivar("app_box", std::ptr::null_mut::<c_void>()); | |
| } | |
| // Restore the previous delegate before releasing this one, so any | |
| // further delegate callbacks during teardown don't hit a freed object. | |
| let window: id = *this.get_ivar("window"); | |
| let _: () = msg_send![window, setDelegate: super_del]; | |
| // NSWindow does not retain its delegate, so the reference taken when | |
| // this delegate was created (`new`) is the only owning one. Release | |
| // it now that the window is closing. | |
| let this_id = this as *const Object as id; | |
| let _: () = msg_send![this_id, release]; | |
| }); | |
| } |
There was a problem hiding this comment.
Re-review ✅
Thanks for the updates — all of my earlier feedback has been addressed:
alloc→new: the instance is now properly allocated + initialized.- Delegate restore before release:
windowWillClose:now restores the previous delegate (setDelegate: super_del) beforerelease-ingself, so teardown callbacks land on a live object. Reference accounting is correct —newgives a +1, NSWindow doesn't retain its delegate, and the singlereleasebalances it (no leak, no over-release). - Null deref after close:
with_window_statenow early-returns whenapp_boxis null, so any latewindowDidResize:/fullscreen callback afterwindowWillClose:is safely ignored instead of dereferencing null. Combined with the delegate swap this is solid belt-and-suspenders. - Shared
static CLASS/ genericR: documented clearly. Sincesetupis only ever called withR = tauri::Wry, the single process-wide class is fine in practice.
The leak fix itself looks correct for the normal path.
One thing to fix before merge: the Format (Cargo) CI check is failing — the long decl.add_method(...) lines in get_or_register_delegate_class aren't cargo fmt-clean. A quick cargo fmt should clear it.
Minor (non-blocking) note for the record: the drop/release cleanup lives inside the suppress_delegate_panic closure, so if the forwarded super_del windowWillClose: ever panicked, cleanup would be skipped and you'd fall back to the original leak behavior (not a crash). Acceptable as-is given the forwarding call doesn't realistically panic.
Once CI is green this is good to go from my side.
…ardless of super delegate forward
|
@greptileai please re-review |
Summary
setup() in apps/desktop/src-tauri/src/platform/macos/delegates.rs (used for traffic-light positioning on Editor, ScreenshotEditor, Settings, Upgrade and ModeSelect windows) leaked on every call:
Since closing these windows destroys and later rebuilds the underlying webview window, every open/close cycle permanently leaked a window handle plus a new ObjC class. This code has been in place since Nov 2024.
Changes
Test plan
Greptile Summary
This PR fixes two memory leaks that accumulated on every open/close cycle of the traffic-light delegate windows:
Box<WindowState<R>>was never freed becauseBox::into_rawwas never balanced, and a new Objective-C class (with a randomized name) was registered on everysetup()call and never disposed.std::sync::OnceLockand reused for every window, eliminating the per-call ObjC class leak.windowWillClose:now drops the boxedWindowState, nulls the ivar, restores the previous delegate, and callsreleaseto balance thenewallocation — all with careful ordering to avoid use-after-free on late callbacks.with_window_statenow guards against the nullapp_boxleft behind after close, addressing the previously-flagged SIGSEGV path for enqueued post-close delegate callbacks.Confidence Score: 5/5
The change correctly fixes two long-standing leaks and handles the cleanup ordering carefully; the one open question (class registration failure) affects only an edge-case startup path and leaves the rest of the delegate logic intact.
The core leak fixes are well-structured: the
OnceLockclass-registration correctly avoids duplicate registration acrosssetup()calls, thewindowWillClose:cleanup sequence (drop box → null ivar → restore delegate → release) is ordered to prevent dangling-reference callbacks, and the null guard inwith_window_statecloses the previously-flagged crash path. The only concern is a robustness gap in theOnceLockinitializer: a panic there would poison the lock for the process lifetime, but this requires an ObjC class name collision that is very unlikely in practice and does not affect the correctness of the leak fix itself.No files require special attention beyond the single changed file.
Important Files Changed
Box<WindowState>is now properly dropped inwindowWillClose:, and the delegate ObjC class is registered once viaOnceLockinstead of per-call. Theapp_boxnull guard addresses the previously-flagged SIGSEGV path for late callbacks. One robustness gap remains: ifClassDecl::newreturnsNonetheexpectpanic insideOnceLock::get_or_initpoisons the lock, permanently breaking all subsequentsetup()calls for the process lifetime.Comments Outside Diff (1)
apps/desktop/src-tauri/src/platform/macos/delegates.rs, line 87-96 (link)with_window_statedereferencesapp_boxwithout a null checkon_window_will_closesetsapp_boxtonull(line 132) and then callsrelease, potentially freeing the delegate. If any subsequent delegate message (e.g. awindowDidResize:notification enqueued on the main thread before the window fully closed) fires afterwindowWillClose:returns,with_window_statewill cast a null pointer to&mut WindowState<R>— a SIGSEGV thatsuppress_delegate_panic'scatch_unwindwill not catch. Adding a null guard here would prevent the crash without changing normal-path behavior.Prompt To Fix With AI
Reviews (3): Last reviewed commit: "fix(macos): cargo fmt and make windowWil..." | Re-trigger Greptile