From 9d7a3f8e7eef8db9850abb342c77fe39c430498b Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Thu, 22 Oct 2026 01:37:39 +0200 Subject: [PATCH 1/4] fix: bound stalled GUI startup and confirmed shutdown Generated-with: gpt-6.1-sol (Codex) --- CHANGELOG.md | 6 + CleanMarkdown.spec | 4 +- STORE_ISSUES.md | 19 +++ build_exe.bat | 6 +- launcher.py | 194 ++++++++++++++++++++++++++++++ locales/translations.json | 8 ++ main.py | 13 +- scripts/startup_guard_hook.py | 8 ++ start.bat | 2 +- tests/test_startup_guard.py | 216 ++++++++++++++++++++++++++++++++++ 10 files changed, 470 insertions(+), 6 deletions(-) create mode 100644 launcher.py create mode 100644 scripts/startup_guard_hook.py create mode 100644 tests/test_startup_guard.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d530095..3761a1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ## [Unreleased] ### Behoben / Fixed +- Ein separater Starter begrenzt blockierte GUI-Starts auf 60 Sekunden und + beendet hängen gebliebene Qt-Aufräumarbeiten nach bestätigtem Fensterschluss. + Ein Bereitschafts-Handshake schaltet die Startfrist ab, bevor der Editor + bedienbar wird. Laufende Bearbeitungssitzungen erhalten keine Zeitbegrenzung. +- A separate launcher bounds stalled GUI startup and confirmed Qt shutdown; + editable sessions are never terminated by an elapsed-time limit. - Der PDF-Export lehnt das aktive Markdown-Original und dessen Pfad- oder Datei-Aliase als Ziel ab. Eine erneute Prüfung vor der Veröffentlichung schützt auch während des Druckens neu entstandene Verknüpfungen. Bei einem diff --git a/CleanMarkdown.spec b/CleanMarkdown.spec index 82d775a..b4bb2bc 100644 --- a/CleanMarkdown.spec +++ b/CleanMarkdown.spec @@ -2,14 +2,14 @@ a = Analysis( - ['main.py'], + ['launcher.py'], pathex=[], binaries=[], datas=[('assets', 'assets'), ('locales', 'locales')], hiddenimports=[], hookspath=[], hooksconfig={}, - runtime_hooks=[], + runtime_hooks=['scripts/startup_guard_hook.py'], excludes=[], noarchive=False, optimize=0, diff --git a/STORE_ISSUES.md b/STORE_ISSUES.md index 098a8ff..b10b625 100644 --- a/STORE_ISSUES.md +++ b/STORE_ISSUES.md @@ -10,6 +10,25 @@ Sicht-OK Icon-Collage (Policy §7a.2): Nutzer, 2026-09-27, woertlich „sieht gu ## Offene Issues +### Start-/Beendigungsabsicherung (2026-10-22, T-20261022-397249193) + +Drei unsichtbare Store-1.0.5-Prozesse verursachten über Tage CPU-Last. Der +historische Hänger ist mit den beiden betroffenen Dateien bislang nicht erneut +aufgetreten; Dateiladen, Vorschau und normaler Fensterschluss funktionieren in +den aktuellen Gegenproben. Eine konkrete Qt-/Markdown-Ursache ist nicht belegt. + +Eine separate, vor den Qt-Laufzeithooks gestartete Überwachung begrenzt jetzt +blockierte Starts auf 60 Sekunden. Der Editor bleibt bis zum bestätigten +Bereitschafts-Handshake deaktiviert. Danach gilt **keine** Sitzungsfrist. +Erst nach Rückkehr aus der GUI-Ereignisschleife darf ein hängender +Aufräumprozess nach weiteren 15 Sekunden beendet werden. Das betrifft nur +den vom Starter selbst erzeugten Kindprozess. + +Status: lokale Umsetzung und Prüfung; im ausgelieferten Store-Paket noch nicht +enthalten. Die ursprüngliche Ursache bleibt zur weiteren Diagnose offen. +Das begrenzte lokale Protokoll liegt unter +`%LOCALAPPDATA%\CleanMarkdown\logs\launcher.log` (ohne Dokumentinhalte). + | # | Severity | Beschreibung | Datei:Zeile | Gefunden | Status | |---|----------|-------------|-------------|----------|--------| | 1 | P1 | Store-Paket registriert keinen `.md`-Handler: `AppxManifest.xml` deklarierte weder `uap:FileTypeAssociation` noch `uap3:AppExecutionAlias`. Nach Store-Installation existiert keine Dateizuordnung; die App erscheint nicht im Dialog „Öffnen mit". Betrifft alle Store-Kunden. | `store_package/CleanMarkdown/AppxManifest.xml` | 2026-08-23 | GEFIXT (Store, 1.0.5.0, Submission `1152921505701985229`) — war WIEDER OFFEN in 1.0.3 (gemessen 2026-09-27), siehe #6/#9 | diff --git a/build_exe.bat b/build_exe.bat index e119cfa..3fc7798 100644 --- a/build_exe.bat +++ b/build_exe.bat @@ -66,6 +66,7 @@ if exist "%SCANNER%" ( echo [build] Auto-Excludes: %EXCLUDES% "%BUILD_PY%" -m PyInstaller --noconfirm --clean --windowed --onefile --noupx ^ + --runtime-hook "%PROJECT_ROOT%\scripts\startup_guard_hook.py" ^ --name "%APP_NAME%" ^ --icon "%ICON_PATH%" ^ --add-data "%ICON_PATH%;assets" ^ @@ -74,7 +75,7 @@ echo [build] Auto-Excludes: %EXCLUDES% --distpath "%DIST_DIR%" ^ --workpath "%WORK_DIR%" ^ --specpath "%SPEC_DIR%" ^ - "%PROJECT_ROOT%\main.py" + "%PROJECT_ROOT%\launcher.py" if errorlevel 1 ( pause & exit /b 1 ) if not exist "%RELEASE_DIR%" mkdir "%RELEASE_DIR%" @@ -82,6 +83,7 @@ copy /y "%DIST_DIR%\%APP_NAME%.exe" "%RELEASE_EXE%" >nul if errorlevel 1 ( pause & exit /b 1 ) "%BUILD_PY%" -m PyInstaller --noconfirm --clean --windowed --onedir --noupx ^ + --runtime-hook "%PROJECT_ROOT%\scripts\startup_guard_hook.py" ^ --name "%APP_NAME%" ^ --icon "%ICON_PATH%" ^ --add-data "%ICON_PATH%;assets" ^ @@ -90,7 +92,7 @@ if errorlevel 1 ( pause & exit /b 1 ) --distpath "%FAST_DIST_DIR%" ^ --workpath "%FAST_WORK_DIR%" ^ --specpath "%FAST_SPEC_DIR%" ^ - "%PROJECT_ROOT%\main.py" + "%PROJECT_ROOT%\launcher.py" if errorlevel 1 ( pause & exit /b 1 ) if exist "%FAST_RELEASE_DIR%" rmdir /s /q "%FAST_RELEASE_DIR%" diff --git a/launcher.py b/launcher.py new file mode 100644 index 0000000..825def3 --- /dev/null +++ b/launcher.py @@ -0,0 +1,194 @@ +"""Bound GUI startup and teardown, without timing out an editable session. + +The supervisor imports only the standard library. It can therefore stop its +own child even if importing Qt or native text layout holds Python's GIL. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass +import logging +from logging.handlers import RotatingFileHandler +import os +from pathlib import Path +import subprocess +import sys +from tempfile import TemporaryDirectory +import time + +GUARD_ENV = "_CLEANMARKDOWN_GUARD_DIR" +WORKER_FLAG = "--cleanmarkdown-guard-worker" +STARTUP_SECONDS = 60.0 +SHUTDOWN_SECONDS = 15.0 + + +@dataclass +class GuardState: + # This decision must survive IPC deletion/failure and supervisor errors. + editable: bool = False + + +def _publish(directory: Path, state: str, content: str = "") -> None: + temporary = directory / (state + ".tmp") + temporary.write_text(content, encoding="utf-8") + temporary.replace(directory / state) + + +def notify_ready() -> None: + """Called with the window disabled, after show(), before the event loop. + + The supervisor must acknowledge (and permanently disarm its startup + timeout) before the window becomes editable. Failed IPC never enables it. + """ + raw = os.environ.get(GUARD_ENV) + if not raw: + return + directory = Path(raw) + _publish(directory, "ready") + deadline = time.monotonic() + 5.0 + while not (directory / "ack").exists(): + if time.monotonic() >= deadline: + raise RuntimeError("Startup supervisor did not acknowledge readiness") + time.sleep(0.02) + + +def notify_ui_closed(code: int) -> None: + """Only called after an accepted close AND the GUI event loop returned.""" + raw = os.environ.get(GUARD_ENV) + if raw: + try: + _publish(Path(raw), "finished", str(code)) + except OSError: + _record("shutdown_notification_failed", code) + + +def supervise(child, directory: Path, *, startup_seconds=STARTUP_SECONDS, + shutdown_seconds=SHUTDOWN_SECONDS, clock=time.monotonic, + sleep=time.sleep, state: GuardState | None = None) -> tuple[int, str]: + """Only kill before ACK or after an explicitly accepted GUI close.""" + startup_deadline = clock() + startup_seconds + state = state if state is not None else GuardState() + acknowledged = False + shutdown_deadline = None + finished_code = 0 + while True: + code = child.poll() + if code is not None: + return code, "exited" if acknowledged or code == 0 else "startup_failed" + if not acknowledged: + if (directory / "ready").exists(): + # Once this file is published, no elapsed-time limit may + # terminate this session while main() is still running. + state.editable = True # fail closed even if publishing fails + _publish(directory, "ack") + acknowledged = True + elif clock() >= startup_deadline: + child.kill() + child.wait(timeout=5) + return 124, "startup_timeout" + if (directory / "finished").exists(): + if shutdown_deadline is None: + finished_code = int((directory / "finished").read_text(encoding="utf-8")) + shutdown_deadline = clock() + shutdown_seconds + elif clock() >= shutdown_deadline: + # The event loop returned after an accepted close/save decision. Native Qt + # destructors can no longer leave a windowless child spinning. + child.kill() + child.wait(timeout=5) + return finished_code, "shutdown_timeout" + sleep(0.25 if acknowledged else 0.05) + + +def _record(reason: str, code: int) -> None: + try: + base = Path(os.environ.get("LOCALAPPDATA", Path.home() / ".local" / "state")) + path = base / "CleanMarkdown" / "logs" / "launcher.log" + path.parent.mkdir(parents=True, exist_ok=True) + handler = RotatingFileHandler(path, maxBytes=262144, backupCount=1, encoding="utf-8") + handler.setFormatter(logging.Formatter("%(asctime)s %(message)s")) + logger = logging.getLogger("cleanmarkdown.launcher") + logger.setLevel(logging.INFO) + logger.addHandler(handler) + try: + logger.info("%s exit=%s", reason, code) + finally: + logger.removeHandler(handler) + handler.close() + except OSError: + pass + + +def _startup_error_message() -> str: + message = "CleanMarkdown could not finish starting. Please try again." + try: + base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) + translations = json.loads((base / "locales" / "translations.json").read_text(encoding="utf-8")) + settings_path = Path(os.environ.get("APPDATA", Path.home() / ".config")) / "CleanMarkdown" / "settings.json" + settings = json.loads(settings_path.read_text(encoding="utf-8")) if settings_path.is_file() else {} + if not isinstance(settings, dict): + settings = {} + message = translations["startup_failed_guard"].get(settings.get("language", "de"), message) + except (OSError, ValueError, KeyError, TypeError): + pass + return message + + +def _show_startup_error() -> None: + message = _startup_error_message() + if os.name == "nt": + import ctypes + ctypes.windll.user32.MessageBoxW(None, message, "CleanMarkdown", 0x10) + elif sys.stderr is not None: + print(message, file=sys.stderr) + + +def _run_worker() -> int: + # Import only inside the supervised child, never in the guardian process. + import main + return main.main() + + +def run() -> int: + if len(sys.argv) > 1 and sys.argv[1] == WORKER_FLAG and os.environ.get(GUARD_ENV): + del sys.argv[1] + return _run_worker() + command = [sys.executable] + if not getattr(sys, "frozen", False): + command.append(str(Path(__file__).resolve())) + command += [WORKER_FLAG, *sys.argv[1:]] + with TemporaryDirectory(prefix="cleanmarkdown-startup-") as temporary: + directory = Path(temporary) + env = dict(os.environ, **{GUARD_ENV: temporary}) + child = subprocess.Popen( + command, env=env, stdin=subprocess.DEVNULL, + stdout=None if sys.stdout is not None else subprocess.DEVNULL, + stderr=None if sys.stderr is not None else subprocess.DEVNULL, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + state = GuardState() + try: + code, reason = supervise(child, directory, state=state) + except BaseException: + if state.editable: + # Keep the guardian (and onefile extraction tree) alive until + # its child exits. IPC state cannot revoke editing permission. + _record("supervisor_error_after_readiness", 1) + while True: + try: + return child.wait() + except BaseException: + # A failed wait also cannot revoke editing permission + # or release a onefile child's shared extraction tree. + time.sleep(0.25) + if child.poll() is None: + child.kill() + child.wait(timeout=5) + raise + _record(reason, code) + if reason in {"startup_timeout", "startup_failed"}: + _show_startup_error() + return code + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/locales/translations.json b/locales/translations.json index d74edfa..6998b85 100644 --- a/locales/translations.json +++ b/locales/translations.json @@ -1151,5 +1151,13 @@ "zh": "图像已复制到剪贴板", "ja": "画像をクリップボードにコピーしました", "ru": "Изображение скопировано в буфер обмена" + }, + "startup_failed_guard": { + "de": "CleanMarkdown konnte den Start nicht abschließen und wurde beendet. Bitte versuche es erneut. Details stehen im lokalen Starter-Protokoll.", + "en": "CleanMarkdown could not finish starting and was stopped. Please try again. Details are in the local launcher log.", + "es": "CleanMarkdown no pudo completar el inicio y se ha detenido. Inténtalo de nuevo. Los detalles están en el registro local del iniciador.", + "zh": "CleanMarkdown 未能完成启动,已停止。请重试。详细信息见本地启动日志。", + "ja": "CleanMarkdown の起動が完了しなかったため、停止しました。もう一度お試しください。詳細はローカルの起動ログに記録されています。", + "ru": "Не удалось завершить запуск CleanMarkdown, приложение остановлено. Повторите попытку. Подробности записаны в локальном журнале запуска." } } diff --git a/main.py b/main.py index 4f351da..52ed748 100644 --- a/main.py +++ b/main.py @@ -11,6 +11,7 @@ from tempfile import TemporaryDirectory import markdown +from launcher import notify_ready, notify_ui_closed from PySide6.QtCore import QIODevice, QMarginsF, QSaveFile, QSize, QStandardPaths, Qt, QTimer, QUrl from PySide6.QtGui import ( QAction, @@ -836,6 +837,7 @@ def __init__(self, initial_path: Path | None = None) -> None: self._session_asset_dir: Path | None = None self.session_display_name: str | None = None self.is_modified = False + self._close_confirmed = False self._autosave_notice_sent = False self._last_tab_index = 0 @@ -2277,6 +2279,7 @@ def _insert_footnote(self) -> None: self.editor.setFocus() def closeEvent(self, event: QCloseEvent) -> None: + self._close_confirmed = False self.settings.window_width = self.width() self.settings.window_height = self.height() try: @@ -2287,6 +2290,7 @@ def closeEvent(self, event: QCloseEvent) -> None: return if self._confirm_discard(): event.accept() + self._close_confirmed = True else: event.ignore() @@ -2570,8 +2574,15 @@ def main() -> int: break window = MainWindow(initial_path) + # No editing is possible until the supervisor disarms its startup limit. + window.setEnabled(False) window.show() - return app.exec() + notify_ready() + window.setEnabled(True) + code = app.exec() + if window._close_confirmed: + notify_ui_closed(code) + return code if __name__ == "__main__": diff --git a/scripts/startup_guard_hook.py b/scripts/startup_guard_hook.py new file mode 100644 index 0000000..c5e7c5c --- /dev/null +++ b/scripts/startup_guard_hook.py @@ -0,0 +1,8 @@ +"""Custom PyInstaller runtime hook: run BEFORE the bundled Qt runtime hook.""" +import os +import sys + +from launcher import GUARD_ENV, WORKER_FLAG, run + +if not (len(sys.argv) > 1 and sys.argv[1] == WORKER_FLAG and os.environ.get(GUARD_ENV)): + raise SystemExit(run()) diff --git a/start.bat b/start.bat index df8915d..2cf3523 100644 --- a/start.bat +++ b/start.bat @@ -11,7 +11,7 @@ if errorlevel 1 ( pause exit /b 1 ) -"%APP_PYTHON%" "%~dp0main.py" %* +"%APP_PYTHON%" "%~dp0launcher.py" %* set "APP_EXIT=%ERRORLEVEL%" if not "%APP_EXIT%"=="0" pause exit /b %APP_EXIT% diff --git a/tests/test_startup_guard.py b/tests/test_startup_guard.py new file mode 100644 index 0000000..cf9f852 --- /dev/null +++ b/tests/test_startup_guard.py @@ -0,0 +1,216 @@ +"""Bound only startup/confirmed shutdown; never a user's editable session.""" +import os +from pathlib import Path +import subprocess +import sys +import time + +import pytest + +import launcher + + +class FakeChild: + def __init__(self, poll): + self._poll = poll + self.killed = False + + def poll(self): + return -9 if self.killed else self._poll() + + def kill(self): + self.killed = True + + def wait(self, timeout=None): + return -9 if self.killed else 0 + + +def test_importing_supervisor_does_not_import_gui(): + result = subprocess.run( + [sys.executable, "-c", "import launcher,sys; assert not any(x.startswith(('PySide6','shiboken6')) for x in sys.modules)"], + cwd=Path(launcher.__file__).parent, capture_output=True, timeout=10, + ) + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize("payload", ["[]", "null", "42", '"bad settings"']) +def test_failure_message_survives_wrongly_typed_settings(tmp_path, monkeypatch, payload): + monkeypatch.setenv("APPDATA", str(tmp_path)) + path = tmp_path / "CleanMarkdown" / "settings.json" + path.parent.mkdir() + path.write_text(payload, encoding="utf-8") + assert "CleanMarkdown" in launcher._startup_error_message() + + +def test_all_packaged_entries_install_guard_before_qt_runtime_hooks(): + root = Path(launcher.__file__).parent + assert "runtime_hooks=['scripts/startup_guard_hook.py']" in (root / "CleanMarkdown.spec").read_text() + bat = (root / "build_exe.bat").read_text() + assert bat.count('--runtime-hook "%PROJECT_ROOT%\\scripts\\startup_guard_hook.py"') == 2 + assert bat.count('"%PROJECT_ROOT%\\launcher.py"') == 2 + + +def test_startup_hang_is_terminated(tmp_path): + now = [0.0] + child = FakeChild(lambda: None) + result = launcher.supervise( + child, tmp_path, startup_seconds=1, + clock=lambda: now[0], sleep=lambda step: now.__setitem__(0, now[0] + step), + ) + assert result == (124, "startup_timeout") + assert child.killed + assert not (tmp_path / "ack").exists() + + +def test_acknowledged_editable_session_has_no_timeout(tmp_path): + launcher._publish(tmp_path, "ready") + now = [0.0] + child = FakeChild(lambda: 0 if now[0] > 48 * 3600 else None) + result = launcher.supervise( + child, tmp_path, startup_seconds=0.1, shutdown_seconds=0.1, + clock=lambda: now[0], sleep=lambda step: now.__setitem__(0, now[0] + 3600), + ) + assert (tmp_path / "ack").is_file() + assert result == (0, "exited") + assert not child.killed + + +@pytest.mark.parametrize("code", [0, 7]) +def test_only_confirmed_main_return_allows_teardown_timeout(tmp_path, code): + launcher._publish(tmp_path, "ready") + launcher._publish(tmp_path, "finished", str(code)) + now = [0.0] + child = FakeChild(lambda: None) + result = launcher.supervise( + child, tmp_path, shutdown_seconds=0.2, + clock=lambda: now[0], sleep=lambda step: now.__setitem__(0, now[0] + step), + ) + assert result == (code, "shutdown_timeout") + assert child.killed + + +def test_ack_published_even_if_readiness_arrives_at_deadline(tmp_path): + launcher._publish(tmp_path, "ready") + polls = [0] + + def poll(): + polls[0] += 1 + return 0 if polls[0] > 1 else None + + child = FakeChild(poll) + assert launcher.supervise(child, tmp_path, startup_seconds=0) == (0, "exited") + assert not child.killed + + +def test_failed_readiness_publication_does_not_enter_editable_ui(tmp_path, monkeypatch): + monkeypatch.setenv(launcher.GUARD_ENV, str(tmp_path / "missing")) + with pytest.raises(OSError): + launcher.notify_ready() + assert not (tmp_path / "ack").exists() + + +def test_supervisor_failure_after_deleted_ack_does_not_kill_session(monkeypatch): + child = FakeChild(lambda: None) + monkeypatch.setattr(launcher.subprocess, "Popen", lambda *args, **kwargs: child) + + def fail_after_ack(child, directory, state): + state.editable = True + launcher._publish(directory, "ack") + (directory / "ack").unlink() + raise OSError("simulated supervisor failure") + + monkeypatch.setattr(launcher, "supervise", fail_after_ack) + monkeypatch.setattr(sys, "argv", ["launcher.py"]) + assert launcher.run() == 0 + assert not child.killed + + +@pytest.mark.parametrize("error", [RuntimeError("callback failed"), SystemExit(1), KeyboardInterrupt()]) +def test_worker_exception_never_confirms_saved_or_closed_ui(tmp_path, monkeypatch, error): + from types import SimpleNamespace + + def fail(): + raise error + + monkeypatch.setitem(sys.modules, "main", SimpleNamespace(main=fail)) + monkeypatch.setenv(launcher.GUARD_ENV, str(tmp_path)) + with pytest.raises(type(error)): + launcher._run_worker() + assert not (tmp_path / "finished").exists() + + +def test_window_stays_disabled_until_readiness_is_acknowledged(main_module, monkeypatch): + events = [] + + class App: + def exec(self): + events.append("exec") + return 0 + + class Window: + def __init__(self, path): + events.append("construct") + self._close_confirmed = False + + def setEnabled(self, value): + events.append(("enabled", value)) + + def show(self): + events.append("show") + + monkeypatch.setattr(sys, "argv", ["launcher.py"]) + monkeypatch.setattr(main_module, "QApplication", lambda args: App()) + monkeypatch.setattr(main_module, "configure_application", lambda app: None) + monkeypatch.setattr(main_module, "MainWindow", Window) + monkeypatch.setattr(main_module, "notify_ready", lambda: events.append("ack")) + assert main_module.main() == 0 + assert events == ["construct", ("enabled", False), "show", "ack", ("enabled", True), "exec"] + + +@pytest.mark.parametrize("accepted", [False, True]) +def test_main_only_reports_confirmed_close_after_event_loop(main_module, monkeypatch, accepted): + from types import SimpleNamespace + events = [] + + def event_loop(): + events.append("event_loop_returned") + return 0 + + window = SimpleNamespace( + _close_confirmed=accepted, setEnabled=lambda enabled: None, show=lambda: None, + ) + monkeypatch.setattr(sys, "argv", ["launcher.py"]) + monkeypatch.setattr(main_module, "QApplication", lambda args: SimpleNamespace(exec=event_loop)) + monkeypatch.setattr(main_module, "configure_application", lambda app: None) + monkeypatch.setattr(main_module, "MainWindow", lambda path: window) + monkeypatch.setattr(main_module, "notify_ready", lambda: None) + monkeypatch.setattr(main_module, "notify_ui_closed", lambda code: events.append("closed")) + assert main_module.main() == 0 + assert events == (["event_loop_returned", "closed"] if accepted else ["event_loop_returned"]) + + +@pytest.mark.parametrize("phase", ["startup", "shutdown"]) +def test_real_child_hang_leaves_no_live_process(tmp_path, phase): + script = tmp_path / "child.py" + prefix = "" + if phase == "shutdown": + prefix = "(directory/'ready').touch(); (directory/'finished').write_text('0')\n" + script.write_text( + "from pathlib import Path\nimport sys,time\ndirectory=Path(sys.argv[1])\n" + + prefix + "while True: time.sleep(0.01)\n", encoding="utf-8", + ) + child = subprocess.Popen( + [sys.executable, str(script), str(tmp_path)], + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + try: + start = time.monotonic() + code, reason = launcher.supervise(child, tmp_path, startup_seconds=2, shutdown_seconds=0.1) + assert reason == phase + "_timeout" + assert code == (124 if phase == "startup" else 0) + assert child.poll() is not None + assert time.monotonic() - start < 5 + finally: + if child.poll() is None: + child.kill() + child.wait(timeout=5) From 7a2796b8ee6f9fc85b4be1ab43db9f911df63bbb Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Thu, 22 Oct 2026 01:38:55 +0200 Subject: [PATCH 2/4] fix: arm teardown guard only after accepted close Generated-with: Codex --- STORE_ISSUES.md | 5 +++-- launcher.py | 6 +++--- main.py | 9 +++++---- tests/test_startup_guard.py | 33 +++++++++++++++------------------ 4 files changed, 26 insertions(+), 27 deletions(-) diff --git a/STORE_ISSUES.md b/STORE_ISSUES.md index b10b625..3cab8a4 100644 --- a/STORE_ISSUES.md +++ b/STORE_ISSUES.md @@ -20,8 +20,9 @@ den aktuellen Gegenproben. Eine konkrete Qt-/Markdown-Ursache ist nicht belegt. Eine separate, vor den Qt-Laufzeithooks gestartete Überwachung begrenzt jetzt blockierte Starts auf 60 Sekunden. Der Editor bleibt bis zum bestätigten Bereitschafts-Handshake deaktiviert. Danach gilt **keine** Sitzungsfrist. -Erst nach Rückkehr aus der GUI-Ereignisschleife darf ein hängender -Aufräumprozess nach weiteren 15 Sekunden beendet werden. Das betrifft nur +Erst nach bestätigter Speichern-/Verwerfen-Entscheidung und deaktiviertem +Hauptfenster darf ein hängender Schließ-/Aufräumprozess nach weiteren +15 Sekunden beendet werden. Das betrifft nur den vom Starter selbst erzeugten Kindprozess. Status: lokale Umsetzung und Prüfung; im ausgelieferten Store-Paket noch nicht diff --git a/launcher.py b/launcher.py index 825def3..6b3d2bf 100644 --- a/launcher.py +++ b/launcher.py @@ -53,7 +53,7 @@ def notify_ready() -> None: def notify_ui_closed(code: int) -> None: - """Only called after an accepted close AND the GUI event loop returned.""" + """Only called after the save/discard decision and disabling the window.""" raw = os.environ.get(GUARD_ENV) if raw: try: @@ -91,8 +91,8 @@ def supervise(child, directory: Path, *, startup_seconds=STARTUP_SECONDS, finished_code = int((directory / "finished").read_text(encoding="utf-8")) shutdown_deadline = clock() + shutdown_seconds elif clock() >= shutdown_deadline: - # The event loop returned after an accepted close/save decision. Native Qt - # destructors can no longer leave a windowless child spinning. + # Save/discard was confirmed and the window was disabled. + # A stalled native close/event loop or destructor may be stopped. child.kill() child.wait(timeout=5) return finished_code, "shutdown_timeout" diff --git a/main.py b/main.py index 52ed748..d8a8a4d 100644 --- a/main.py +++ b/main.py @@ -2289,8 +2289,12 @@ def closeEvent(self, event: QCloseEvent) -> None: QMessageBox.critical(self, self.t("error"), self.t("cannot_save")) return if self._confirm_discard(): + # Closing was explicitly accepted; no further editing is allowed. + # Notify before returning into Qt, whose close/teardown may hang. + self.setEnabled(False) event.accept() self._close_confirmed = True + notify_ui_closed(0) else: event.ignore() @@ -2579,10 +2583,7 @@ def main() -> int: window.show() notify_ready() window.setEnabled(True) - code = app.exec() - if window._close_confirmed: - notify_ui_closed(code) - return code + return app.exec() if __name__ == "__main__": diff --git a/tests/test_startup_guard.py b/tests/test_startup_guard.py index cf9f852..fa30236 100644 --- a/tests/test_startup_guard.py +++ b/tests/test_startup_guard.py @@ -168,25 +168,22 @@ def show(self): @pytest.mark.parametrize("accepted", [False, True]) -def test_main_only_reports_confirmed_close_after_event_loop(main_module, monkeypatch, accepted): - from types import SimpleNamespace +def test_only_accepted_close_disables_window_and_notifies_guard(main_module, monkeypatch, accepted): + app = main_module.QApplication.instance() or main_module.QApplication([]) + window = main_module.MainWindow() events = [] - - def event_loop(): - events.append("event_loop_returned") - return 0 - - window = SimpleNamespace( - _close_confirmed=accepted, setEnabled=lambda enabled: None, show=lambda: None, - ) - monkeypatch.setattr(sys, "argv", ["launcher.py"]) - monkeypatch.setattr(main_module, "QApplication", lambda args: SimpleNamespace(exec=event_loop)) - monkeypatch.setattr(main_module, "configure_application", lambda app: None) - monkeypatch.setattr(main_module, "MainWindow", lambda path: window) - monkeypatch.setattr(main_module, "notify_ready", lambda: None) - monkeypatch.setattr(main_module, "notify_ui_closed", lambda code: events.append("closed")) - assert main_module.main() == 0 - assert events == (["event_loop_returned", "closed"] if accepted else ["event_loop_returned"]) + monkeypatch.setattr(window, "_confirm_discard", lambda: accepted) + monkeypatch.setattr(main_module, "notify_ui_closed", lambda code: events.append((code, window.isEnabled()))) + try: + result = window.close() + assert result is accepted + assert window._close_confirmed is accepted + assert window.isEnabled() is not accepted + assert events == ([(0, False)] if accepted else []) + finally: + monkeypatch.setattr(window, "_confirm_discard", lambda: True) + window.close() + app.processEvents() @pytest.mark.parametrize("phase", ["startup", "shutdown"]) From 1c6c2d927f3b6fea3009091771f645568259a3bd Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Thu, 22 Oct 2026 01:40:20 +0200 Subject: [PATCH 3/4] test: keep failed saves outside shutdown timeout Generated-with: Codex --- tests/test_startup_guard.py | 47 +++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_startup_guard.py b/tests/test_startup_guard.py index fa30236..7c9ea7a 100644 --- a/tests/test_startup_guard.py +++ b/tests/test_startup_guard.py @@ -186,6 +186,53 @@ def test_only_accepted_close_disables_window_and_notifies_guard(main_module, mon app.processEvents() +def test_failed_document_save_keeps_editor_enabled_without_shutdown_marker(main_module, monkeypatch): + app = main_module.QApplication.instance() or main_module.QApplication([]) + window = main_module.MainWindow() + window.editor.setPlainText("unsaved document") + events = [] + monkeypatch.setattr(main_module, "notify_ui_closed", lambda code: events.append(code)) + monkeypatch.setattr(main_module.QMessageBox, "exec", lambda self: None) + monkeypatch.setattr(main_module.QMessageBox, "clickedButton", lambda self: next( + button for button in self.buttons() + if self.buttonRole(button) == main_module.QMessageBox.ButtonRole.AcceptRole + )) + monkeypatch.setattr(window, "save_file", lambda: False) + try: + assert window.close() is False + assert window.isEnabled() + assert window.is_modified + assert not window._close_confirmed + assert events == [] + finally: + monkeypatch.setattr(window, "_confirm_discard", lambda: True) + window.close() + app.processEvents() + + +def test_failed_settings_save_keeps_editor_enabled_without_shutdown_marker(main_module, monkeypatch): + app = main_module.QApplication.instance() or main_module.QApplication([]) + window = main_module.MainWindow() + save = window.store.save + events = [] + monkeypatch.setattr(main_module, "notify_ui_closed", lambda code: events.append(code)) + monkeypatch.setattr(main_module.QMessageBox, "critical", lambda *args: None) + + def fail(settings): + raise OSError("settings cannot be saved") + + monkeypatch.setattr(window.store, "save", fail) + try: + assert window.close() is False + assert window.isEnabled() + assert not window._close_confirmed + assert events == [] + finally: + monkeypatch.setattr(window.store, "save", save) + window.close() + app.processEvents() + + @pytest.mark.parametrize("phase", ["startup", "shutdown"]) def test_real_child_hang_leaves_no_live_process(tmp_path, phase): script = tmp_path / "child.py" From c37f974be41df6938f5d1d43ffea1c4abad11e6a Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Thu, 15 Oct 2026 20:53:51 +0200 Subject: [PATCH 4/4] fix(deps): exclude Qt 6.12.0 highlighter refcount regression Generated-with: gpt-6.1-sol (Codex) --- pyproject.toml | 2 +- requirements-build.txt | 2 +- requirements.txt | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 96978cf..b0b67d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ "Topic :: Text Processing :: Markup :: Markdown", ] dependencies = [ - "PySide6>=6.5.0", + "PySide6>=6.5.0,!=6.12.0", "markdown>=3.4.0", ] diff --git a/requirements-build.txt b/requirements-build.txt index 8ca9689..0c83945 100644 --- a/requirements-build.txt +++ b/requirements-build.txt @@ -1,3 +1,3 @@ pyinstaller==6.14.2 -PySide6>=6.10,<7 +PySide6>=6.10,<7,!=6.12.0 Markdown>=3.10,<4 diff --git a/requirements.txt b/requirements.txt index d859bed..10e0643 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -PySide6>=6.10,<7 +# 6.12.0 corrupts None refcounts in virtual highlighter callbacks. +PySide6>=6.10,<7,!=6.12.0 Markdown>=3.10,<4