Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions CleanMarkdown.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions STORE_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ 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 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
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 |
Expand Down
6 changes: 4 additions & 2 deletions build_exe.bat
Original file line number Diff line number Diff line change
Expand Up @@ -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" ^
Expand All @@ -74,14 +75,15 @@ 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%"
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" ^
Expand All @@ -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%"
Expand Down
194 changes: 194 additions & 0 deletions launcher.py
Original file line number Diff line number Diff line change
@@ -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 the save/discard decision and disabling the window."""
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:
# 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"
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())
8 changes: 8 additions & 0 deletions locales/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -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, приложение остановлено. Повторите попытку. Подробности записаны в локальном журнале запуска."
}
}
12 changes: 12 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -2286,7 +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()

Expand Down Expand Up @@ -2570,7 +2578,11 @@ def main() -> int:
break

window = MainWindow(initial_path)
# No editing is possible until the supervisor disarms its startup limit.
window.setEnabled(False)
window.show()
notify_ready()
window.setEnabled(True)
return app.exec()


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down
2 changes: 1 addition & 1 deletion requirements-build.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
pyinstaller==6.14.2
PySide6>=6.10,<7
PySide6>=6.10,<7,!=6.12.0
Markdown>=3.10,<4
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions scripts/startup_guard_hook.py
Original file line number Diff line number Diff line change
@@ -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())
2 changes: 1 addition & 1 deletion start.bat
Original file line number Diff line number Diff line change
Expand Up @@ -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%
Loading
Loading