diff --git a/CHANGELOG.md b/CHANGELOG.md index bb3c8397..82a17eb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,14 @@ Status of the `main` branch. Changes prior to the next official version change w * General: - Added `serena init` command + +* Language Servers: - Added Crystal language support (uses [Crystalline](https://github.com/elbywan/crystalline) language server) * Dashboard: - - Add version display + - Add version display + - Dashboard viewer (Windows): Add a parent monitoring thread to ensure termination. + Some clients would terminate the MCP server in a way that did not ensure proper termination. * Fixes: - Fix reactivation of the same project restarting language servers #1280 diff --git a/src/serena/agent.py b/src/serena/agent.py index 31267135..fc4c6b69 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -558,12 +558,12 @@ class SerenaAgent: return self._dashboard_url @staticmethod - def _start_dashboard_viewer_process_function(url: str, minimized: bool) -> None: + def _start_dashboard_viewer_process_function(url: str, minimized: bool, parent_process_id: int) -> None: """ Main function of the subprocess for starting the dashboard viewer """ try: - SerenaDashboardViewer(url, start_minimized=minimized).run() + SerenaDashboardViewer(url, start_minimized=minimized, parent_process_id=parent_process_id).run() except webview.errors.WebViewException as e: log.warning(f"Could not open Serena Dashboard viewer. Cause:\n{e}") # Fall back to opening the browser window if the window was supposed to be shown directly @@ -586,7 +586,7 @@ class SerenaAgent: assert url is not None if SerenaDashboardViewer.is_current_platform_supported(): self._dashboard_viewer_process = multiprocessing.Process( - target=self._start_dashboard_viewer_process_function, args=(url, minimized), daemon=True + target=self._start_dashboard_viewer_process_function, args=(url, minimized, os.getpid()), daemon=True ) self._dashboard_viewer_process.start() else: diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index dbd46504..528af993 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -3,11 +3,13 @@ import os import socket import sys import threading +import time import urllib.error import urllib.request from pathlib import Path from typing import TYPE_CHECKING, Any, Self +import psutil import webview from flask import Flask, Response, redirect, request, send_from_directory from PIL import Image @@ -743,6 +745,8 @@ class SerenaDashboardViewer: Minimal pywebview wrapper with optional system tray. """ + DEBUG = False + def __init__( self, url: str, @@ -750,6 +754,7 @@ class SerenaDashboardViewer: start_minimized: bool = False, width: int = 1400, height: int = 900, + parent_process_id: int | None = None, ): self.url = url # Use system tray to allow hiding to tray and intercepting close to hide instead of quit @@ -758,6 +763,7 @@ class SerenaDashboardViewer: self.width = width self.height = height self.start_minimized = start_minimized + self.parent_process_id = parent_process_id self.window: webview.Window self._tray_icon: Any @@ -786,6 +792,10 @@ class SerenaDashboardViewer: ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("oraios.serena") + if self.DEBUG: + logging.configure(level=logging.DEBUG) + logging.add_file_logger(SerenaPaths().get_next_log_file_path("dashboard-viewer")) + dashboard_path = Path(SERENA_DASHBOARD_DIR) # .ico is Windows-only; macOS expects a PNG for the window/dock icon. icon_filename = "serena.ico" if sys.platform == "win32" else "serena-icon-1024-mac.png" @@ -815,8 +825,27 @@ class SerenaDashboardViewer: else: self._show_window() + # start a thread to monitor the parent process + if self.parent_process_id is not None: + threading.Thread(target=self._monitor_parent_process, daemon=True).start() + webview.start(_start_callback, icon=icon_path) + def _monitor_parent_process(self) -> None: + """ + Monitors the parent process and shuts down the dashboard viewer if the parent process exits. + """ + pid = self.parent_process_id + log.info("Starting parent process monitor thread (parent_pid=%d, pid=%s)", pid, os.getpid()) + try: + parent_process = psutil.Process(pid) + while parent_process.is_running(): + time.sleep(1) + except psutil.NoSuchProcess: + pass # Parent process already exited + log.info("Parent process (pid=%d) has exited, shutting down dashboard viewer", pid) + self._terminate() + def _show_window(self) -> None: if not self.window: return @@ -885,6 +914,18 @@ class SerenaDashboardViewer: self._hide_window() return False # prevent the window from actually closing + def _terminate(self) -> None: + """ + Terminates the viewer application and the tray icon + """ + self._quitting = True + try: + if self._tray_icon: + self._tray_icon.stop() + finally: + if self.window: + self.window.destroy() + def _start_tray(self) -> None: # import pystray locally, because the import fails when there is no display! import pystray @@ -905,12 +946,7 @@ class SerenaDashboardViewer: self._hide_window() def quit_app(_icon: TrayIcon, _item: Item) -> None: - self._quitting = True - try: - _icon.stop() - finally: - if self.window: - self.window.destroy() + self._terminate() menu = pystray.Menu( Item("Open", show, default=True),