Skip to content

Commit 1e50913

Browse files
committed
fix: preserve shortcut and window hook callback lifetimes
Clear shortcut callbacks after successful unregistration, outside the manager mutex. Retained handles can no longer invoke stale callbacks. Keep a local copy while invoking shortcuts and desktop window hooks so self-replacement or removal does not destroy executing captures. Add CTest coverage for self-replacement, hook removal, and retained handles.
1 parent 676f518 commit 1e50913

7 files changed

Lines changed: 142 additions & 14 deletions

File tree

‎src/platform/linux/window_manager_linux.cpp‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -901,14 +901,18 @@ bool WindowManager::HasWillHideHook() const {
901901
}
902902

903903
void WindowManager::HandleWillShow(WindowId id) {
904-
if (pimpl_->will_show_hook_) {
905-
(*pimpl_->will_show_hook_)(id);
904+
// The hook may replace or clear itself. Keep this invocation alive.
905+
auto hook = pimpl_->will_show_hook_;
906+
if (hook) {
907+
(*hook)(id);
906908
}
907909
}
908910

909911
void WindowManager::HandleWillHide(WindowId id) {
910-
if (pimpl_->will_hide_hook_) {
911-
(*pimpl_->will_hide_hook_)(id);
912+
// The hook may replace or clear itself. Keep this invocation alive.
913+
auto hook = pimpl_->will_hide_hook_;
914+
if (hook) {
915+
(*hook)(id);
912916
}
913917
}
914918

‎src/platform/macos/window_manager_macos.mm‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -526,14 +526,18 @@ static WindowId ResolveWindowId(NSWindow* ns_window) {
526526
}
527527

528528
void WindowManager::HandleWillShow(WindowId id) {
529-
if (pimpl_->will_show_hook_) {
530-
(*pimpl_->will_show_hook_)(id);
529+
// The hook may replace or clear itself. Keep this invocation alive.
530+
auto hook = pimpl_->will_show_hook_;
531+
if (hook) {
532+
(*hook)(id);
531533
}
532534
}
533535

534536
void WindowManager::HandleWillHide(WindowId id) {
535-
if (pimpl_->will_hide_hook_) {
536-
(*pimpl_->will_hide_hook_)(id);
537+
// The hook may replace or clear itself. Keep this invocation alive.
538+
auto hook = pimpl_->will_hide_hook_;
539+
if (hook) {
540+
(*hook)(id);
537541
}
538542
}
539543

‎src/platform/windows/window_manager_windows.cpp‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -892,14 +892,18 @@ bool WindowManager::HasWillHideHook() const {
892892
}
893893

894894
void WindowManager::HandleWillShow(WindowId id) {
895-
if (pimpl_->will_show_hook_) {
896-
(*pimpl_->will_show_hook_)(id);
895+
// The hook may replace or clear itself. Keep this invocation alive.
896+
auto hook = pimpl_->will_show_hook_;
897+
if (hook) {
898+
(*hook)(id);
897899
}
898900
}
899901

900902
void WindowManager::HandleWillHide(WindowId id) {
901-
if (pimpl_->will_hide_hook_) {
902-
(*pimpl_->will_hide_hook_)(id);
903+
// The hook may replace or clear itself. Keep this invocation alive.
904+
auto hook = pimpl_->will_hide_hook_;
905+
if (hook) {
906+
(*hook)(id);
903907
}
904908
}
905909

‎src/shortcut.cpp‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,10 @@ bool Shortcut::IsEnabled() const {
4949
}
5050

5151
void Shortcut::Invoke() {
52-
if (enabled_ && callback_) {
53-
callback_();
52+
// A callback may replace or clear itself while it is executing.
53+
auto callback = callback_;
54+
if (enabled_ && callback) {
55+
callback();
5456
}
5557
}
5658

‎src/shortcut_manager.cpp‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ bool ShortcutManager::Unregister(ShortcutId id) {
8989
shortcuts_by_id_.erase(it);
9090
shortcuts_by_accelerator_.erase(accelerator);
9191

92+
// Destroying captured objects may re-enter the manager. Do it outside the lock.
93+
lock.unlock();
94+
// Retained handles must not invoke a callback after its registration ends.
95+
shortcut->SetCallback(nullptr);
96+
9297
// Emit event
9398
EmitAsync<ShortcutUnregisteredEvent>(id, accelerator);
9499

‎tests/CMakeLists.txt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ add_executable(shortcut_accelerator_test shortcut_accelerator_test.cpp)
3939
target_link_libraries(shortcut_accelerator_test PRIVATE nativeapi)
4040
add_test(NAME shortcut_accelerator_test COMMAND shortcut_accelerator_test)
4141

42+
add_executable(callback_lifetime_test callback_lifetime_test.cpp)
43+
target_link_libraries(callback_lifetime_test PRIVATE nativeapi)
44+
add_test(NAME callback_lifetime_test COMMAND callback_lifetime_test)
45+
set_tests_properties(callback_lifetime_test PROPERTIES TIMEOUT 20)
46+
4247
# The registry side of launch-at-login: the Run value, the StartupApproved flag that
4348
# Task Manager toggles, and a non-ASCII identifier. Writes only under HKCU, under a
4449
# test-only identifier, and removes what it writes.

‎tests/callback_lifetime_test.cpp‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Callbacks must keep their captured objects alive while replacing or clearing
2+
// themselves. Unregistering a shortcut must release its callback even if the
3+
// caller still owns the shortcut.
4+
//
5+
// The registration case needs a desktop session and skips if the platform cannot
6+
// register the accelerator. The self-replacement cases run without registration.
7+
8+
#include <cstdlib>
9+
#include <iostream>
10+
#include <memory>
11+
12+
#include "../src/shortcut.h"
13+
#include "../src/shortcut_manager.h"
14+
#include "../src/window_manager.h"
15+
16+
namespace {
17+
18+
int g_failures = 0;
19+
20+
void Check(bool condition, const char* message) {
21+
if (!condition) {
22+
std::cerr << "FAIL: " << message << std::endl;
23+
++g_failures;
24+
}
25+
}
26+
27+
void TestShortcutSelfReplacement() {
28+
nativeapi::Shortcut shortcut(1, "Ctrl+A", nullptr);
29+
auto captured = std::make_shared<int>(42);
30+
std::weak_ptr<int> weak = captured;
31+
int replacements = 0;
32+
shortcut.SetCallback([&, captured] {
33+
shortcut.SetCallback([&] { ++replacements; });
34+
Check(!weak.expired(), "active shortcut callback was destroyed during replacement");
35+
});
36+
captured.reset();
37+
shortcut.Invoke();
38+
Check(weak.expired(), "replaced shortcut callback was retained");
39+
shortcut.Invoke();
40+
Check(replacements == 1, "replacement callback was not installed");
41+
shortcut.SetCallback(nullptr);
42+
}
43+
44+
void TestHookSelfRemoval() {
45+
auto& manager = nativeapi::WindowManager::GetInstance();
46+
for (bool show : {true, false}) {
47+
auto captured = std::make_shared<int>(42);
48+
std::weak_ptr<int> weak = captured;
49+
auto callback = [&, captured](nativeapi::WindowId) {
50+
if (show) {
51+
manager.SetWillShowHook(std::nullopt);
52+
} else {
53+
manager.SetWillHideHook(std::nullopt);
54+
}
55+
Check(!weak.expired(), "active window hook was destroyed during removal");
56+
};
57+
if (show) {
58+
manager.SetWillShowHook(std::move(callback));
59+
} else {
60+
manager.SetWillHideHook(std::move(callback));
61+
}
62+
captured.reset();
63+
if (show) {
64+
manager.HandleWillShow(0);
65+
} else {
66+
manager.HandleWillHide(0);
67+
}
68+
Check(weak.expired(), "removed window hook was retained");
69+
}
70+
}
71+
72+
void TestUnregisterClearsRetainedShortcut() {
73+
auto& manager = nativeapi::ShortcutManager::GetInstance();
74+
int calls = 0;
75+
auto captured = std::make_shared<int>(42);
76+
std::weak_ptr<int> weak = captured;
77+
auto shortcut = manager.Register("Ctrl+Alt+Shift+F12", [&, captured] { ++calls; });
78+
captured.reset();
79+
if (!shortcut) {
80+
std::cout << "SKIP: platform could not register a global shortcut" << std::endl;
81+
return;
82+
}
83+
shortcut->Invoke();
84+
Check(calls == 1, "registered shortcut callback did not run");
85+
Check(manager.Unregister(shortcut->GetId()), "shortcut unregister failed");
86+
shortcut->Invoke();
87+
Check(calls == 1, "retained shortcut invoked after unregister");
88+
Check(weak.expired(), "unregistered shortcut retained its captured state");
89+
}
90+
} // namespace
91+
92+
int main() {
93+
std::cout << "callback_lifetime_test" << std::endl;
94+
95+
TestShortcutSelfReplacement();
96+
TestHookSelfRemoval();
97+
TestUnregisterClearsRetainedShortcut();
98+
if (g_failures > 0) {
99+
std::cerr << g_failures << " check(s) failed" << std::endl;
100+
return EXIT_FAILURE;
101+
}
102+
std::cout << "All checks passed." << std::endl;
103+
return EXIT_SUCCESS;
104+
}

0 commit comments

Comments
 (0)