diff --git a/README.md b/README.md index 8e6d73e..27af44e 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,20 @@ Open your browser at: **[http://localhost:8080](http://localhost:8080)** For complete Arch Linux instructions—including future AUR installation placeholders, system dependencies, running with standard virtual environments, and uninstallation—see the [Arch Linux Installation Guide](docs/INSTALL_ARCH.md). +### Windows Standalone Desktop Application + +Download `MathWeaveStudio-Beta.exe` from GitHub Releases. +Double-click the executable to launch MathWeave Studio directly in a native desktop window without installing Python or uv. + +> **System Prerequisites for Windows Native Mode:** +> MathWeave Studio uses Microsoft Edge WebView2 for its native desktop window. WebView2 is pre-installed on Windows 11 and modern Windows 10 versions. If running on a stripped or offline Windows edition without WebView2, install the official [Microsoft Edge WebView2 Evergreen Runtime](https://go.microsoft.com/fwlink/p/?LinkId=2124703), or launch with `--browser` from the command prompt. + +#### Building Standalone EXE on Windows: +```powershell +uv sync --all-groups +uv run pyinstaller mathweave-studio-onefile.spec --clean --noconfirm +``` + --- ## Documentation @@ -103,11 +117,11 @@ For complete Arch Linux instructions—including future AUR installation placeho MathWeave Studio maintains an automated test suite enforcing Clean Architecture boundary isolation, solver mathematical validity, and UI stability: ```bash -# Run all 206 automated tests +# Run all 229 automated tests uv run pytest ``` -All 206 tests pass with 100% success rate. +All 229 tests pass with 100% success rate. --- diff --git a/README.vi.md b/README.vi.md index 73045e7..40afc1a 100644 --- a/README.vi.md +++ b/README.vi.md @@ -89,6 +89,20 @@ Mở trình duyệt web tại: **[http://localhost:8080](http://localhost:8080)* Để xem hướng dẫn chi tiết trên Arch Linux—bao gồm trạng thái gói AUR/yay, gói hệ thống, chạy với môi trường ảo tiêu chuẩn và cách gỡ cài đặt sạch sẽ—vui lòng xem [Hướng dẫn cài đặt trên Arch Linux](docs/INSTALL_ARCH.md). +### Ứng dụng Desktop Độc lập trên Windows + +Tải tệp `MathWeaveStudio-Beta.exe` từ GitHub Releases. +Nhấp đúp vào tệp EXE để mở MathWeave Studio trực tiếp trong cửa sổ ứng dụng desktop mà không cần cài đặt Python hay uv. + +> **Yêu cầu hệ thống cho chế độ Desktop Windows:** +> MathWeave Studio sử dụng Microsoft Edge WebView2 để tạo cửa sổ ứng dụng native. WebView2 đã được tích hợp sẵn trên Windows 11 và các bản cập nhật Windows 10 tiêu chuẩn. Đối với các phiên bản Windows rút gọn hoặc ngoại tuyến chưa có WebView2, vui lòng cài đặt [Microsoft Edge WebView2 Evergreen Runtime](https://go.microsoft.com/fwlink/p/?LinkId=2124703), hoặc chạy ứng dụng từ dòng lệnh với tham số `--browser`. + +#### Đóng gói tệp EXE trên Windows: +```powershell +uv sync --all-groups +uv run pyinstaller mathweave-studio-onefile.spec --clean --noconfirm +``` + --- ## Tài liệu dự án @@ -103,11 +117,11 @@ Mở trình duyệt web tại: **[http://localhost:8080](http://localhost:8080)* MathWeave Studio duy trì bộ kiểm thử tự động toàn diện kiểm soát ranh giới kiến trúc Clean Architecture, tính chính xác của các thuật toán và độ ổn định của giao diện: ```bash -# Chạy toàn bộ 206 bài kiểm thử tự động +# Chạy toàn bộ 229 bài kiểm thử tự động uv run pytest ``` -Toàn bộ 206 bài kiểm thử đều vượt qua với tỷ lệ thành công 100%. +Toàn bộ 229 bài kiểm thử đều vượt qua với tỷ lệ thành công 100%. --- diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000..2687347 Binary files /dev/null and b/assets/icon.ico differ diff --git a/mathweave-studio-onefile.spec b/mathweave-studio-onefile.spec new file mode 100644 index 0000000..1658871 --- /dev/null +++ b/mathweave-studio-onefile.spec @@ -0,0 +1,100 @@ +# -*- mode: python ; coding: utf-8 -*- + +from PyInstaller.utils.hooks import ( + collect_all, + collect_data_files, + collect_dynamic_libs, + collect_submodules, +) + +block_cipher = None + +datas = [] +binaries = [] +hiddenimports = [] + +# Core UI and rendering packages +for pkg in ["nicegui", "plotly", "latex2mathml"]: + datas += collect_data_files(pkg) + hiddenimports += collect_submodules(pkg) + +# Application modules and assets +hiddenimports += collect_submodules("math_app") +datas += [("assets", "assets")] + +# Pywebview and webview backend +datas += collect_data_files("webview") +binaries += collect_dynamic_libs("webview") +hiddenimports += collect_submodules("webview") + +# Windows-specific native dependencies (pythonnet, clr_loader) +for pkg in ["pythonnet", "clr_loader"]: + try: + pkg_datas, pkg_binaries, pkg_hidden = collect_all(pkg) + datas += pkg_datas + binaries += pkg_binaries + hiddenimports += pkg_hidden + except Exception: + pass + +hiddenimports += [ + # ASGI and WebSocket server + "uvicorn", + "uvicorn.protocols.http.h11_impl", + "uvicorn.protocols.websockets.websockets_impl", + "uvicorn.protocols.websockets.wsproto_impl", + "uvicorn.lifespan.on", + "uvicorn.lifespan.off", + "wsproto", + # Windows pywebview native backend (.NET / WinForms / WebView2) + "clr", + "pythonnet", + "clr_loader", + "clr_loader.ffi", + "webview.platforms.winforms", + "webview.platforms.edgechromium", + "webview.platforms.win32", + # Platform fallback + "qtpy", +] + +a = Analysis( + ["run.py"], + pathex=["src"], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=["tkinter", "matplotlib.tests", "scipy", "pytest", "pytest_cov"], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name="MathWeaveStudio-Beta", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, # Kept True for beta debugging diagnostics + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon="assets/icon.ico", +) diff --git a/mathweave-studio.spec b/mathweave-studio.spec index 4e63c0f..0d25a6e 100644 --- a/mathweave-studio.spec +++ b/mathweave-studio.spec @@ -1,48 +1,67 @@ # -*- mode: python ; coding: utf-8 -*- -from PyInstaller.utils.hooks import collect_data_files, collect_submodules +from PyInstaller.utils.hooks import ( + collect_all, + collect_data_files, + collect_dynamic_libs, + collect_submodules, +) from pathlib import Path block_cipher = None datas = [] -datas += collect_data_files("nicegui") -datas += collect_data_files("plotly") -datas += collect_data_files("latex2mathml") -datas += [("assets", "assets")] - +binaries = [] hiddenimports = [] + +for pkg in ["nicegui", "plotly", "latex2mathml"]: + datas += collect_data_files(pkg) + hiddenimports += collect_submodules(pkg) + hiddenimports += collect_submodules("math_app") -hiddenimports += collect_submodules("nicegui") -hiddenimports += collect_submodules("plotly") -hiddenimports += collect_submodules("latex2mathml") +datas += [("assets", "assets")] + +datas += collect_data_files("webview") +binaries += collect_dynamic_libs("webview") +hiddenimports += collect_submodules("webview") + +for pkg in ["pythonnet", "clr_loader"]: + try: + pkg_datas, pkg_binaries, pkg_hidden = collect_all(pkg) + datas += pkg_datas + binaries += pkg_binaries + hiddenimports += pkg_hidden + except Exception: + pass + hiddenimports += [ - "qtpy", - "PyQt6", - "PyQt6.QtCore", - "PyQt6.QtGui", - "PyQt6.QtWidgets", - "PyQt6.QtWebEngineWidgets", - "PyQt6.QtWebEngineCore", - "PyQt6.QtWebChannel", - "webview", - "webview.platforms.qt", "uvicorn", "uvicorn.protocols.http.h11_impl", "uvicorn.protocols.websockets.websockets_impl", + "uvicorn.protocols.websockets.wsproto_impl", "uvicorn.lifespan.on", + "uvicorn.lifespan.off", + "wsproto", + "clr", + "pythonnet", + "clr_loader", + "clr_loader.ffi", + "webview.platforms.winforms", + "webview.platforms.edgechromium", + "webview.platforms.win32", + "qtpy", ] a = Analysis( ["run.py"], pathex=["src"], - binaries=[], + binaries=binaries, datas=datas, hiddenimports=hiddenimports, hookspath=[], hooksconfig={}, runtime_hooks=[], - excludes=["tkinter", "matplotlib.tests", "scipy"], + excludes=["tkinter", "matplotlib.tests", "scipy", "pytest", "pytest_cov"], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, @@ -62,7 +81,7 @@ exe = EXE( strip=False, upx=True, console=False, - icon="assets/icon.png", + icon="assets/icon.ico", ) coll = COLLECT( diff --git a/pyproject.toml b/pyproject.toml index 27ce622..715f0f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "nicegui>=3.18.0", "numpy>=2.5.4", "plotly>=7.1.0", + "pythonnet>=3.0.0; sys_platform == 'win32'", "pywebview>=6.2.1", "qtpy>=2.4.3", "sympy>=1.14.0", diff --git a/run.py b/run.py index 13821a7..3d412ec 100644 --- a/run.py +++ b/run.py @@ -9,6 +9,7 @@ from __future__ import annotations +import multiprocessing import sys from pathlib import Path @@ -20,5 +21,6 @@ from math_app.main import main -if __name__ in {"__main__", "__mp_main__"}: +if __name__ == "__main__": + multiprocessing.freeze_support() main() diff --git a/src/math_app/main.py b/src/math_app/main.py index 824550f..87dc98c 100644 --- a/src/math_app/main.py +++ b/src/math_app/main.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pathlib import Path + from nicegui import ui from math_app import __title__ @@ -29,6 +31,18 @@ DEFAULT_PORT = 8080 +def get_resource_path(relative_path: str | Path) -> Path: + """Resolve absolute path to a resource, supporting both development and PyInstaller frozen bundles.""" + import sys + from pathlib import Path + + if hasattr(sys, "_MEIPASS"): + base_dir = Path(sys._MEIPASS) + else: + base_dir = Path(__file__).resolve().parent.parent.parent + return (base_dir / relative_path).resolve() + + def build_application() -> SolveProblemUseCase: """Wire together solvers, registry, and application use cases.""" registry = DefaultSolverRegistry() @@ -100,6 +114,7 @@ def get_available_port(host: str = DEFAULT_HOST, default_port: int = DEFAULT_POR import socket with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: try: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, default_port)) return default_port except OSError: @@ -128,6 +143,11 @@ def _handle_signal(signum: int, frame: Any) -> None: sys.stderr.flush() except Exception: pass + try: + from nicegui import run + run.tear_down() + except Exception: + pass os._exit(0) is_shutting_down = True @@ -135,36 +155,88 @@ def _handle_signal(signum: int, frame: Any) -> None: from nicegui import app app.shutdown() except Exception: + try: + from nicegui import run + run.tear_down() + except Exception: + pass os._exit(0) try: signal.signal(signal.SIGINT, _handle_signal) signal.signal(signal.SIGTERM, _handle_signal) + if hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, _handle_signal) except (ValueError, OSError): pass def main() -> None: """Application main entrypoint.""" + import multiprocessing import os import sys + # In Windows / frozen environment, prevent spawned child workers from executing main + if multiprocessing.current_process().name != "MainProcess": + return + use_case = build_application() create_app(use_case) _setup_signal_handlers() port = get_available_port(DEFAULT_HOST, DEFAULT_PORT) - # Allow explicit flag --browser or default to native window when display is available + # Allow explicit flag --browser or default to native desktop window use_native = "--browser" not in sys.argv - if use_native and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")): - use_native = False + if use_native and sys.platform.startswith("linux"): + # On Linux, only activate native mode when an X11 or Wayland display server is running + if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")): + use_native = False - from nicegui import app if use_native: - app.native.start_args["gui"] = "qt" + from nicegui import app + if sys.platform == "win32": + # On Windows, pywebview uses WinForms + EdgeChromium (WebView2). + # Verify EdgeChromium availability or report actionable diagnostics. + try: + from webview.platforms.winforms import _is_chromium + if not _is_chromium(): + msg = ( + "Microsoft Edge WebView2 Runtime chưa được cài đặt trên hệ thống Windows này.\n" + "MathWeave Studio yêu cầu WebView2 Runtime để chạy ở chế độ cửa sổ ứng dụng desktop.\n\n" + "Vui lòng tải và cài đặt Microsoft Edge WebView2 Runtime từ:\n" + "https://go.microsoft.com/fwlink/p/?LinkId=2124703\n\n" + "Hoặc khởi chạy ứng dụng từ dòng lệnh với tham số --browser để mở qua trình duyệt." + ) + sys.stderr.write(f"\n[ERROR] {msg}\n\n") + sys.stderr.flush() + try: + import ctypes + ctypes.windll.user32.MessageBoxW( + 0, + msg, + "MathWeave Studio — Cần WebView2 Runtime", + 0x10, # MB_ICONERROR + ) + except Exception: + pass + sys.exit(1) + except ImportError: + pass + elif sys.platform.startswith("linux"): + app.native.start_args["gui"] = "qt" + app.native.window_args["resizable"] = True + ico_path = get_resource_path("assets/icon.ico") + png_path = get_resource_path("assets/icon.png") + favicon_path: str | None = None + if ico_path.is_file(): + favicon_path = str(ico_path) + elif png_path.is_file(): + favicon_path = str(png_path) + try: ui.run( host=DEFAULT_HOST, @@ -172,14 +244,23 @@ def main() -> None: port=port, reload=False, native=use_native, - window_size=(1200, 800), + window_size=(1200, 800) if use_native else None, + favicon=favicon_path, + timeout_graceful_shutdown=3.0, ) except KeyboardInterrupt: pass finally: + try: + from nicegui import run + run.tear_down() + except Exception: + pass sys.stdout.flush() sys.stderr.flush() -if __name__ in {"__main__", "__mp_main__"}: +if __name__ == "__main__": + import multiprocessing + multiprocessing.freeze_support() main() diff --git a/tests/unit/presentation/test_shutdown_lifecycle.py b/tests/unit/presentation/test_shutdown_lifecycle.py index 557a1fe..b8ca4e5 100644 --- a/tests/unit/presentation/test_shutdown_lifecycle.py +++ b/tests/unit/presentation/test_shutdown_lifecycle.py @@ -1,74 +1,265 @@ """Tests for clean application lifecycle and bounded shutdown.""" +from __future__ import annotations + +import os +import re import signal import subprocess import sys +import tempfile import time +import urllib.error import urllib.request +from pathlib import Path +from typing import IO, Any + import pytest +PROJECT_ROOT = Path(__file__).resolve().parents[3] -def test_clean_shutdown_without_calculation(): - """Verify application starts and shuts down cleanly on SIGINT within 5 seconds.""" - p = subprocess.Popen( +SHUTDOWN_SIGNAL = ( + getattr(signal, "CTRL_BREAK_EVENT", signal.SIGINT) + if sys.platform == "win32" + else signal.SIGINT +) + +VALID_RETURNCODES = ( + (0, 1, 0xC000013A, -1073741510) + if sys.platform == "win32" + else (0, 1, -signal.SIGINT) +) + + +class _StdoutReader: + """Non-blocking reader wrapper around temporary file capture.""" + + def __init__(self, raw_file: IO[bytes]) -> None: + self._raw_file = raw_file + self._pos = 0 + + def readline(self, timeout: float = 5.0) -> bytes: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + self._raw_file.seek(self._pos) + line = self._raw_file.readline() + if line and line.endswith(b"\n"): + self._pos = self._raw_file.tell() + return line + time.sleep(0.05) + self._raw_file.seek(self._pos) + line = self._raw_file.readline() + self._pos = self._raw_file.tell() + return line + + def read(self) -> bytes: + self._raw_file.seek(self._pos) + data = self._raw_file.read() + self._pos = self._raw_file.tell() + return data + + +class ManagedPopen(subprocess.Popen[bytes]): + """Subprocess manager that captures stdout and stderr via temporary files. + + On Windows, when descendant processes (e.g. browser or background workers) + inherit anonymous pipe handles, Popen.communicate() deadlocks in ReadFile + waiting for an EOF that never arrives even after the main application process + exits cleanly. Writing to temporary files decouples process lifecycle from + handle inheritance, ensuring bounded, reliable shutdown verification without + pipe deadlock across all platforms. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self._stdout_file: IO[bytes] = tempfile.TemporaryFile() + self._stderr_file: IO[bytes] = tempfile.TemporaryFile() + kwargs["stdout"] = self._stdout_file + kwargs["stderr"] = self._stderr_file + super().__init__(*args, **kwargs) + self.stdout = _StdoutReader(self._stdout_file) # type: ignore[assignment] + self.stderr = _StdoutReader(self._stderr_file) # type: ignore[assignment] + + def communicate( # type: ignore[override] + self, + input: bytes | None = None, + timeout: float | None = None, + ) -> tuple[bytes, bytes]: + try: + self.wait(timeout=timeout) + except subprocess.TimeoutExpired as exc: + self._stdout_file.seek(0) + self._stderr_file.seek(0) + exc.stdout = self._stdout_file.read() + exc.stderr = self._stderr_file.read() + raise + + self._stdout_file.seek(0) + self._stderr_file.seek(0) + stdout = self._stdout_file.read() + stderr = self._stderr_file.read() + return stdout, stderr + + def close_files(self) -> None: + try: + self._stdout_file.close() + except Exception: + pass + try: + self._stderr_file.close() + except Exception: + pass + + +def _start_app_process() -> ManagedPopen: + env = os.environ.copy() + # NiceGUI checks PYTEST_CURRENT_TEST to enforce test fixtures mode with + # NICEGUI_SCREEN_TEST_PORT. As a standalone subprocess, strip this variable. + env.pop("PYTEST_CURRENT_TEST", None) + + kwargs: dict[str, Any] = { + "cwd": str(PROJECT_ROOT), + "env": env, + } + if sys.platform == "win32": + kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + + return ManagedPopen( [sys.executable, "run.py", "--browser"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + **kwargs, ) - time.sleep(2.5) - p.send_signal(signal.SIGINT) + + +def _ensure_process_terminated(p: subprocess.Popen[bytes]) -> None: + """Reliable cleanup safeguard so failed tests do not leak child processes.""" + if p.poll() is None: + try: + if sys.platform == "win32": + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(p.pid)], + capture_output=True, + check=False, + ) + else: + p.kill() + p.communicate(timeout=3) + except Exception: + pass + if hasattr(p, "close_files"): + p.close_files() + + +def test_clean_shutdown_without_calculation(): + """Verify application starts and shuts down cleanly on signal within deadline.""" + p = _start_app_process() try: - stdout, stderr = p.communicate(timeout=6) - assert p.returncode in (0, 1, -signal.SIGINT) + # Verify application remains alive while starting up + time.sleep(2.0) + assert p.poll() is None, f"Application exited prematurely with code {p.returncode}" + + # Request graceful shutdown + p.send_signal(SHUTDOWN_SIGNAL) + try: + stdout, stderr = p.communicate(timeout=6) + except subprocess.TimeoutExpired as exc: + out_str = exc.stdout.decode("utf-8", errors="replace") if exc.stdout else "" + err_str = exc.stderr.decode("utf-8", errors="replace") if exc.stderr else "" + pytest.fail( + f"Application failed to shut down cleanly within timeout.\n" + f"Process alive: {p.poll() is None}, returncode: {p.returncode}\n" + f"STDOUT:\n{out_str}\n" + f"STDERR:\n{err_str}" + ) + + assert p.returncode in VALID_RETURNCODES assert b"asyncio.exceptions.CancelledError" not in stderr assert b"leaked semaphore objects" not in stderr - except subprocess.TimeoutExpired: - p.kill() - pytest.fail("Application failed to shut down cleanly within timeout") + finally: + _ensure_process_terminated(p) def test_clean_shutdown_after_page_load(): """Verify application starts, responds to HTTP request, and shuts down cleanly.""" - p = subprocess.Popen( - [sys.executable, "run.py", "--browser"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - time.sleep(2.5) - # Ping the server + p = _start_app_process() try: - with urllib.request.urlopen("http://127.0.0.1:8080/", timeout=3) as resp: - assert resp.status == 200 - except Exception: - pass # If port differs or already bound, shutdown still must work + # Bounded readiness loop to verify the server is ready and serving HTTP requests + readiness_deadline = time.monotonic() + 10.0 + server_ready = False + last_error: Exception | None = None + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) - p.send_signal(signal.SIGINT) - try: - stdout, stderr = p.communicate(timeout=6) - assert p.returncode in (0, 1, -signal.SIGINT) + # Dynamically determine the URL NiceGUI bound to + url = "http://127.0.0.1:8080" + if p.stdout is not None: + line = p.stdout.readline().decode("utf-8", errors="replace") + m = re.search(r"http://[^\s]+", line) + if m: + url = m.group(0).rstrip("/") + + target_url = f"{url}/" + while time.monotonic() < readiness_deadline: + assert p.poll() is None, f"Application exited prematurely with code {p.returncode}" + try: + with opener.open(target_url, timeout=1.0) as resp: + if resp.status == 200: + server_ready = True + break + except (urllib.error.URLError, TimeoutError, OSError) as exc: + last_error = exc + time.sleep(0.1) + + assert server_ready, ( + f"Server failed to respond with HTTP 200 at {target_url} within readiness deadline. Last error: {last_error}" + ) + + # Request graceful shutdown + p.send_signal(SHUTDOWN_SIGNAL) + try: + stdout, stderr = p.communicate(timeout=6) + except subprocess.TimeoutExpired as exc: + out_str = exc.stdout.decode("utf-8", errors="replace") if exc.stdout else "" + err_str = exc.stderr.decode("utf-8", errors="replace") if exc.stderr else "" + pytest.fail( + f"Application failed to shut down cleanly after web request.\n" + f"Process alive: {p.poll() is None}, returncode: {p.returncode}\n" + f"STDOUT:\n{out_str}\n" + f"STDERR:\n{err_str}" + ) + + assert p.returncode in VALID_RETURNCODES assert b"asyncio.exceptions.CancelledError" not in stderr assert b"leaked semaphore objects" not in stderr - except subprocess.TimeoutExpired: - p.kill() - pytest.fail("Application failed to shut down cleanly after web request") + finally: + _ensure_process_terminated(p) def test_repeated_sigint_does_not_deadlock(): - """Verify repeated SIGINT triggers immediate exit without 30s timeout hang.""" - p = subprocess.Popen( - [sys.executable, "run.py", "--browser"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - time.sleep(2.5) - p.send_signal(signal.SIGINT) - time.sleep(0.05) - p.send_signal(signal.SIGINT) + """Verify repeated interrupt signals trigger immediate exit without deadlock.""" + p = _start_app_process() try: - stdout, stderr = p.communicate(timeout=6) - assert p.returncode in (0, 1, -signal.SIGINT) + # Verify application remains alive while starting up + time.sleep(2.0) + assert p.poll() is None, f"Application exited prematurely with code {p.returncode}" + + # Request graceful shutdown + p.send_signal(SHUTDOWN_SIGNAL) + time.sleep(0.05) + # Send repeated signal to verify prompt exit without hanging + p.send_signal(SHUTDOWN_SIGNAL) + + try: + stdout, stderr = p.communicate(timeout=6) + except subprocess.TimeoutExpired as exc: + out_str = exc.stdout.decode("utf-8", errors="replace") if exc.stdout else "" + err_str = exc.stderr.decode("utf-8", errors="replace") if exc.stderr else "" + pytest.fail( + f"Repeated interrupt signals deadlocked on shutdown.\n" + f"Process alive: {p.poll() is None}, returncode: {p.returncode}\n" + f"STDOUT:\n{out_str}\n" + f"STDERR:\n{err_str}" + ) + + assert p.returncode in VALID_RETURNCODES assert b"asyncio.exceptions.CancelledError" not in stderr assert b"leaked semaphore objects" not in stderr - except subprocess.TimeoutExpired: - p.kill() - pytest.fail("Repeated SIGINT deadlocked on shutdown") + finally: + _ensure_process_terminated(p) diff --git a/uv.lock b/uv.lock index e9b2b91..e3227f4 100644 --- a/uv.lock +++ b/uv.lock @@ -671,6 +671,7 @@ dependencies = [ { name = "nicegui" }, { name = "numpy" }, { name = "plotly" }, + { name = "pythonnet", marker = "sys_platform == 'win32'" }, { name = "pywebview" }, { name = "qtpy" }, { name = "sympy" }, @@ -690,6 +691,7 @@ requires-dist = [ { name = "nicegui", specifier = ">=3.18.0" }, { name = "numpy", specifier = ">=2.5.4" }, { name = "plotly", specifier = ">=7.1.0" }, + { name = "pythonnet", marker = "sys_platform == 'win32'", specifier = ">=3.0.0" }, { name = "pywebview", specifier = ">=6.2.1" }, { name = "qtpy", specifier = ">=2.4.3" }, { name = "sympy", specifier = ">=1.14.0" },