Files
kernalix7--winpodx/tests/test_gui_refresh_threading.py
wehub-resource-sync 3e779be6f3
CI / lint (push) Failing after 13m4s
CI / test (3.11, ubuntu-latest) (push) Failing after 2m4s
CI / test (3.13, ubuntu-latest) (push) Successful in 13m30s
CI / test (3.14, ubuntu-latest) (push) Successful in 17m21s
CI / test (3.12, ubuntu-latest) (push) Successful in 17m55s
CI / discover-apps-ps (push) Successful in 1m56s
CI / test (3.9, ubuntu-latest) (push) Successful in 13m17s
CI / test (3.10, ubuntu-latest) (push) Successful in 26m21s
CI / audit (push) Successful in 13m38s
Deploy site / deploy (push) Has been cancelled
CI / test (3.14, ubuntu-24.04-arm) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:37 +08:00

66 lines
2.6 KiB
Python

# SPDX-License-Identifier: MIT
"""Structural guards for the GUI "Refresh Apps" worker-QThread teardown.
This teardown has regressed into a crash twice (v0.2.0.10 → .11 → the
QObject::~QObject SIGSEGV). A live-Qt thread test would be flaky in CI, so we
lock the invariants at the source level instead:
* the parentless, Python-owned DiscoveryWorker must have EXACTLY ONE delete
path — so ``worker.finished.connect(worker.deleteLater)`` must NOT exist
(the second path raced shiboken's main-thread C++ delete -> double-free);
* ``_cleanup_refresh_worker`` must ``thread.wait()`` before dropping the refs,
so the only delete happens after the worker thread is provably dead;
* the start guard must bail on a live ``self._refresh_thread`` (not just the
``_refresh_state`` string), so a rapid re-click can't overwrite the ref and
drop the last Python ref to a still-finishing worker;
* the window must join in-flight worker threads on close.
"""
from __future__ import annotations
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
APPS = REPO_ROOT / "src" / "winpodx" / "gui" / "_main_window_apps.py"
MAIN = REPO_ROOT / "src" / "winpodx" / "gui" / "main_window.py"
def _apps_src() -> str:
return APPS.read_text(encoding="utf-8")
def test_worker_has_single_delete_path() -> None:
src = _apps_src()
# The worker is deleted ONLY via the Python ref-drop in
# _cleanup_refresh_worker; a worker.deleteLater would be the second,
# racing path that double-freed on the worker thread.
assert "worker.finished.connect(worker.deleteLater)" not in src
# thread.deleteLater on thread.finished is still correct (QThread is
# parent-owned, deleted on the main thread after it stops).
assert "thread.finished.connect(thread.deleteLater)" in src
def test_cleanup_waits_before_dropping_refs() -> None:
src = _apps_src()
idx = src.index("def _cleanup_refresh_worker")
body = src[idx : idx + 2200]
wait_at = body.find("thread.wait()")
null_at = body.find("self._refresh_worker = None")
assert wait_at != -1, "cleanup must join the worker thread"
assert null_at != -1
assert wait_at < null_at, "must wait() BEFORE dropping the Python ref"
def test_start_guard_checks_live_thread_ref() -> None:
src = _apps_src()
assert "self._refresh_thread is not None" in src
def test_window_joins_worker_threads_on_close() -> None:
src = MAIN.read_text(encoding="utf-8")
assert "def closeEvent" in src
assert "_join_worker_threads" in src
# Both worker threads are joined.
assert '"_refresh_thread"' in src and '"_info_thread"' in src
assert "thread.wait()" in src