From 7b46422693cdbe1b0551d01039243dba1482c0db Mon Sep 17 00:00:00 2001 From: Orinks Date: Thu, 28 May 2026 17:25:41 -0400 Subject: [PATCH 1/3] fix(single-instance): dedupe fallback handoff and relax activation polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small cleanups to the single-instance activation path: - request_existing_instance_show: on the IPC-fallback path, the handoff file was always written *and* the window was poked directly, so a primary instance could act on the same request twice (once via the 750ms handoff poll, once via the direct restore). Now the handoff is skipped for a plain generic_fallback that a successful window restore already satisfied, while discussion/ alert_details intents — which the window poke cannot carry — are still handed off. Window restore logic extracted into _show_existing_window. - Relax the handoff poll from 750ms to 2000ms via a named constant. The file is only a fallback to the named-pipe IPC, so it doesn't need sub-second latency and shouldn't run a perpetual high-frequency timer for the whole session. - Document the "allow startup on any failure" policy in try_acquire_lock. Co-Authored-By: Claude Opus 4.8 --- src/accessiweather/app_activation.py | 7 +++- src/accessiweather/single_instance.py | 19 ++++++++++- tests/test_single_instance.py | 46 +++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/accessiweather/app_activation.py b/src/accessiweather/app_activation.py index b15135d3..83b5d7da 100644 --- a/src/accessiweather/app_activation.py +++ b/src/accessiweather/app_activation.py @@ -11,6 +11,11 @@ logger = logging.getLogger(__name__) +# The handoff file is only a fallback for when the named-pipe IPC channel is +# unavailable, so it doesn't need sub-second latency. Poll on a relaxed +# interval to avoid a perpetual high-frequency wakeup for the whole session. +_ACTIVATION_HANDOFF_POLL_MS = 2000 + def show_alert_dialog(parent, alert, settings=None) -> None: """Lazy wrapper for the single-alert details dialog.""" @@ -90,7 +95,7 @@ def _start_activation_handoff_polling(self) -> None: self._on_activation_handoff_timer, self._activation_handoff_timer, ) - self._activation_handoff_timer.Start(750) + self._activation_handoff_timer.Start(_ACTIVATION_HANDOFF_POLL_MS) def _start_activation_ipc_server(self) -> None: """Start duplicate-launch IPC and route requests onto the UI thread.""" diff --git a/src/accessiweather/single_instance.py b/src/accessiweather/single_instance.py index 893521a3..6657aac9 100644 --- a/src/accessiweather/single_instance.py +++ b/src/accessiweather/single_instance.py @@ -99,6 +99,10 @@ def try_acquire_lock(self) -> bool: self._lock_acquired = True return True + # Policy: on any unexpected failure of the mutex check we return True + # ("allow startup"). A guard that occasionally permits a second instance + # is far better than one that can wedge the app shut, so every error + # path below intentionally falls through to launching. try: kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) _configure_kernel32_signatures(kernel32) @@ -140,10 +144,23 @@ def request_existing_instance_show( if sys.platform == "win32" and _send_activation_request_ipc(handoff_request): return True - self.write_activation_handoff(handoff_request) if sys.platform != "win32": + self.write_activation_handoff(handoff_request) return False + shown = self._show_existing_window() + # The direct window restore only raises the window; it cannot act on a + # specific request. Write the handoff so the primary instance still + # routes discussion/alert_details intents — but skip it for a plain + # generic_fallback that a successful restore already satisfied, so the + # primary doesn't handle the same generic request a second time when it + # polls for handoffs. + if not shown or handoff_request.kind != "generic_fallback": + self.write_activation_handoff(handoff_request) + return shown + + def _show_existing_window(self) -> bool: + """Restore and foreground the existing main window; True if one was shown.""" try: user32 = ctypes.WinDLL("user32", use_last_error=True) _configure_user32_signatures(user32) diff --git a/tests/test_single_instance.py b/tests/test_single_instance.py index b8855720..2f6d671e 100644 --- a/tests/test_single_instance.py +++ b/tests/test_single_instance.py @@ -192,6 +192,52 @@ def test_second_windows_launch_restores_window_with_location_title(monkeypatch, assert user32.foreground_calls == [2468] +def test_generic_fallback_skips_handoff_when_window_restore_succeeds(monkeypatch, tmp_path): + runtime_paths = RuntimeStoragePaths(config_root=tmp_path / "config") + app = SimpleNamespace(runtime_paths=runtime_paths, formal_name="AccessiWeather") + kernel32 = _FakeKernel32(last_error=ERROR_ALREADY_EXISTS) + user32 = _FakeUser32(hwnd=2468) + + import accessiweather.single_instance as single_instance + + monkeypatch.setattr(single_instance.sys, "platform", "win32") + monkeypatch.setattr(single_instance, "ctypes", _FakeCtypes(kernel32, user32)) + monkeypatch.setattr(single_instance, "_send_activation_request_ipc", lambda request: False) + + manager = SingleInstanceManager(app, runtime_paths=runtime_paths) + + assert manager.try_acquire_lock() is False + assert manager.request_existing_instance_show() is True + assert user32.foreground_calls == [2468] + # A plain generic restore that succeeded must not also leave a handoff for + # the primary to consume and act on a second time. + assert manager.consume_activation_handoff() is None + + +def test_discussion_request_still_writes_handoff_when_window_restore_succeeds( + monkeypatch, tmp_path +): + runtime_paths = RuntimeStoragePaths(config_root=tmp_path / "config") + app = SimpleNamespace(runtime_paths=runtime_paths, formal_name="AccessiWeather") + kernel32 = _FakeKernel32(last_error=ERROR_ALREADY_EXISTS) + user32 = _FakeUser32(hwnd=2468) + + import accessiweather.single_instance as single_instance + + monkeypatch.setattr(single_instance.sys, "platform", "win32") + monkeypatch.setattr(single_instance, "ctypes", _FakeCtypes(kernel32, user32)) + monkeypatch.setattr(single_instance, "_send_activation_request_ipc", lambda request: False) + + manager = SingleInstanceManager(app, runtime_paths=runtime_paths) + + assert manager.try_acquire_lock() is False + assert manager.request_existing_instance_show(NotificationActivationRequest(kind="discussion")) + assert user32.foreground_calls == [2468] + # The window poke can't open the discussion dialog, so the intent must still + # be handed off for the primary to route. + assert manager.consume_activation_handoff() == NotificationActivationRequest(kind="discussion") + + def test_second_windows_launch_writes_generic_handoff_when_window_lookup_fails( monkeypatch, tmp_path ): From e1a0548430d6e6b8c07dc72d3b86e6debe16d328 Mon Sep 17 00:00:00 2001 From: Orinks Date: Thu, 28 May 2026 17:50:06 -0400 Subject: [PATCH 2/3] docs(changelog): note the relaunch double-activation fix Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7e89bc2..163d4e39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ All notable changes to this project will be documented in this file. - Default AI summaries now stay grounded in the provided weather text or data, so they are less likely to invent forecast details when summarizing reports. - Relaunching AccessiWeather on Windows now reliably restores the already-running window from desktop shortcuts, direct EXE launches, and portable copies without relying on the window title. - Restoring the running AccessiWeather window on relaunch, and bringing it to the front from a notification, now work reliably on 64-bit Windows where an internal window-handle bug could previously leave the window in the background. +- Relaunching AccessiWeather no longer briefly activates the already-running window twice in the rare case its primary relaunch signal can't be delivered. - Alt+F4 now stays routed through the normal close-to-tray behavior after switching between All Locations and saved locations. - Automatic Windows startup now stays in the background when AccessiWeather is already running, and startup shortcuts are recreated with a stable AccessiWeather target for portable copies. - The Windows installer now closes running AccessiWeather copies automatically before installing, then can launch the updated app normally when setup exits. From 63b2363c8bc4adf4ede42e831d638c553cc2fa4a Mon Sep 17 00:00:00 2001 From: Orinks Date: Thu, 28 May 2026 18:04:12 -0400 Subject: [PATCH 3/3] test(activation): cover relaxed handoff poll interval Exercises _start_activation_handoff_polling with wx mocked so the changed Timer.Start call is covered, and pins the interval constant at 2000ms. Co-Authored-By: Claude Opus 4.8 --- tests/test_app_notification_activation.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_app_notification_activation.py b/tests/test_app_notification_activation.py index d5677359..dd83885b 100644 --- a/tests/test_app_notification_activation.py +++ b/tests/test_app_notification_activation.py @@ -351,3 +351,19 @@ def fake_windll(name, use_last_error=True): user32.ShowWindow.assert_called_once_with(0x1_0000_4242, 9) user32.SetForegroundWindow.assert_called_once_with(0x1_0000_4242) frame.Raise.assert_not_called() + + +def test_start_activation_handoff_polling_uses_relaxed_interval() -> None: + """Handoff polling arms the timer at the relaxed fallback interval.""" + from accessiweather import app_activation + + app = AccessiWeatherApp.__new__(AccessiWeatherApp) + app._activation_handoff_timer = None + app.Bind = MagicMock() + + with patch("accessiweather.app_activation.wx") as mock_wx: + app._start_activation_handoff_polling() + timer = mock_wx.Timer.return_value + + assert app_activation._ACTIVATION_HANDOFF_POLL_MS == 2000 + timer.Start.assert_called_once_with(app_activation._ACTIVATION_HANDOFF_POLL_MS)