fix(dashboard): marshal tray menu updates onto the macOS main thread

SerenaDashboardTrayManager._update_menu() called pystray's Icon.update_menu()
on whatever thread reached it. Four call sites are off the main thread: the
Flask handlers for /register, /update_project and /unregister, and
_alive_check_loop.

pystray does no marshalling. Icon.update_menu() calls the backend directly and
pystray/_darwin.py goes straight to NSStatusItem.setMenu_(), which AppKit
requires on the main thread. On macOS versions that enforce it the tray manager
traps with SIGTRAP inside the request handler, so the agent logs "Failed to
register with tray manager: Remote end closed connection without response" and
the tray icon never becomes usable.

Menu refreshes now go through PyObjCTools.AppHelper.callAfter on Darwin, which
is asynchronous so no Flask handler blocks on the main run loop. Other
platforms call through unchanged. The helper is separate from _update_menu
because _open_dashboard and _run_viewer run on the same Flask threads and will
need it too.

No new dependency: PyObjCTools comes from pyobjc-core, already required on
macOS via pystray -> pyobjc-framework-Quartz -> pyobjc-core.

The crash itself could not be reproduced locally (macOS 26.6.2; the reporter is
on 27.0, everything else matching). Verified instead that the AppKit call moves
from a Flask worker thread to the main thread: NSThread.isMainThread() across
the three HTTP routes reads [False, False] before and [True, True, True] after.
This commit is contained in:
Parman Mohammadalizadeh authored and Dr. Dominik Jain committed 2026-09-17 13:28:20 +02:00
1 parent 602837b6e0
commit f8f53b77f0
2 files changed
+28 -1

No files matched your search

+8
View File
@@ -64,6 +64,14 @@ Status of the `main` branch. Changes prior to the next official version change w
successful Serena call. Add a `serena-hooks reset` command and a `PostToolUse` example matched to
Serena's own tools to close the gap (#1852)
* Dashboard:
- Fix: On macOS, the tray manager refreshed the tray menu straight from the Flask request handlers
for `/register`, `/update_project` and `/unregister` and from the alive-check thread. That reaches
`NSStatusItem.setMenu_()` off the main thread, which AppKit forbids and which recent macOS
versions punish with SIGTRAP, so the tray-manager process died within seconds of every agent
start and the tray icon never became usable. Menu refreshes are now marshalled onto the main
thread (#2038)
* Language Servers:
- Fix: Godot's GDScript parser can report a symbol's end column one column past the
line-end convention every other language server follows (closing a node's range from
+20 -1
View File
@@ -1030,9 +1030,28 @@ class SerenaDashboardTrayManager:
log.info("Unregistered instance on port %d", port)
return {"status": "unregistered"}
@staticmethod
def _run_in_ui_thread(fn: Callable[[], None]) -> None:
"""
Runs a UI mutation in the thread in which the platform's UI toolkit requires it to run.
On macOS, AppKit demands that mutations of the status item happen on the main thread, and
recent macOS versions terminate the process with SIGTRAP when they do not. The tray manager
reaches such mutations from Flask request handlers and from the alive-check thread, so the
call has to be marshalled. On other platforms it is made directly.
:param fn: the UI mutation to run
"""
if sys.platform == "darwin":
from PyObjCTools import AppHelper
AppHelper.callAfter(fn)
else:
fn()
def _update_menu(self) -> None:
if self._tray_icon:
self._tray_icon.update_menu()
self._run_in_ui_thread(self._tray_icon.update_menu)
def _build_menu_items(self) -> tuple[Any, ...]:
"""