Some checks failed
Windows project source guards / test (push) Failing after 38s
Раздел «Маршрутизация» с подпунктами (Обзор, Правила, Наборы правил, DNS, Исходящие, Система, JSON) редактирует тот же активный native JSON. Формы строятся из JSON Schema закреплённого ядра (sing-box schema), своей модели маршрутизации нет; неизвестные ключи и форма Listable сохраняются. «Проверить» запускает sing-box check в фоне. DNS больше не перезаписывается принудительно: при обновлении шаблонов нетронутые верхние секции следуют за стоком, изменённые остаются. Сброс к стоку — вручную, целиком или по разделу.
109 lines
4.8 KiB
Python
109 lines
4.8 KiB
Python
"""Детерминированное завершение Qt: окна разрушаются, пока жив ``QApplication``.
|
||
|
||
Если оставить окна сборщику мусора, их C++-деструкторы срабатывают при
|
||
финализации интерпретатора в неопределённом порядке, иногда уже после
|
||
``QApplication``, и процесс падает на выходе (segfault / access violation).
|
||
Надеяться, что GC успеет раньше, нельзя: qfluentwidgets подключает к
|
||
глобальному ``qconfig.themeChanged`` лямбды, замкнутые на виджет
|
||
(``FluentLabelBase._init``), и такие обёртки живут до разрушения ``qconfig``.
|
||
|
||
Окно, у которого ещё работает дочерний ``QThread`` или ``QProcess``, не
|
||
удаляется: разрушение живого потока — ``qFatal``, а ``~QProcess`` убивает
|
||
процесс и ждёт его. Такое окно остаётся прежнему порядку выхода.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import gc
|
||
import logging
|
||
|
||
from PyQt6 import sip
|
||
from PyQt6.QtCore import QProcess, QThread, QTimer
|
||
from PyQt6.QtWidgets import QApplication, QWidget
|
||
|
||
|
||
def dispose_windows(windows: list[QWidget], logger: logging.Logger | None = None) -> list[QWidget]:
|
||
"""Удаляет окна из ``windows``; возвращает те, что пришлось оставить.
|
||
|
||
На время каскада C++-деструкторов циклический GC выключен: иначе он может
|
||
собрать Python-обёртку виджета, который как раз разрушается (так падали
|
||
Windows-тесты при пакетном удалении страниц, см. test_app_scrollable_page).
|
||
"""
|
||
kept: list[QWidget] = []
|
||
gc_was_enabled = gc.isenabled()
|
||
gc.disable()
|
||
try:
|
||
for window in windows:
|
||
if sip.isdeleted(window):
|
||
continue
|
||
running = [thread for thread in window.findChildren(QThread) if thread.isRunning()]
|
||
running += [
|
||
process for process in window.findChildren(QProcess)
|
||
if process.state() != QProcess.ProcessState.NotRunning
|
||
]
|
||
if running:
|
||
if logger is not None:
|
||
logger.warning(
|
||
"Window kept until interpreter exit: %d background thread(s)/process(es) still running: %s",
|
||
len(running),
|
||
", ".join(type(item).__name__ for item in running),
|
||
)
|
||
kept.append(window)
|
||
continue
|
||
window.hide()
|
||
sip.delete(window)
|
||
finally:
|
||
if gc_was_enabled:
|
||
gc.enable()
|
||
windows[:] = kept
|
||
gc.collect()
|
||
return kept
|
||
|
||
|
||
def dispose_all_widgets(windows: list[QWidget], logger: logging.Logger | None = None) -> list[QWidget]:
|
||
"""Сначала главные окна, затем все оставшиеся виджеты верхнего уровня.
|
||
|
||
Вторым проходом уходят меню трея без родителя, всплывающие подсказки и
|
||
прочие окна qfluentwidgets, которые иначе дожили бы до финализации.
|
||
"""
|
||
kept = dispose_windows(windows, logger)
|
||
app = QApplication.instance()
|
||
if app is not None:
|
||
rest = [widget for widget in app.topLevelWidgets() if not any(widget is k for k in kept)]
|
||
kept += dispose_windows(rest, logger)
|
||
return kept
|
||
|
||
|
||
_disposal_queue: list[QWidget] = []
|
||
|
||
|
||
def dispose_later(widget: QWidget) -> None:
|
||
"""Hide ``widget`` now and destroy it on the next event-loop turn.
|
||
|
||
Replaces ``deleteLater`` for subtrees that hold qfluentwidgets scroll
|
||
delegates (``ScrollArea``, ``PlainTextEdit``, ``RoundMenu``…): those patch
|
||
bound methods onto their widget, so a cyclic-GC pass racing the Qt
|
||
destructors can touch a half-destroyed wrapper (see :func:`dispose_windows`).
|
||
The deferral lets the signal that triggered a rebuild return first; the
|
||
destruction itself runs synchronously with cyclic GC disabled.
|
||
"""
|
||
if sip.isdeleted(widget):
|
||
return
|
||
widget.hide()
|
||
_disposal_queue.append(widget)
|
||
if len(_disposal_queue) == 1:
|
||
QTimer.singleShot(0, _flush_disposals)
|
||
|
||
|
||
def _flush_disposals() -> None:
|
||
pending = list(_disposal_queue)
|
||
_disposal_queue.clear()
|
||
gc_was_enabled = gc.isenabled()
|
||
gc.disable()
|
||
try:
|
||
for widget in pending:
|
||
if not sip.isdeleted(widget):
|
||
sip.delete(widget)
|
||
finally:
|
||
if gc_was_enabled:
|
||
gc.enable()
|