diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index 6122200..79771d0 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -561,6 +561,14 @@ TRANSLATION = { "ZH": "测试下行速度", "isReviewed": "True" }, + "Test Download Speed (Multithreaded)": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "RU": "Измерить скорость загрузки (многопоточная)", + "ZH": "测试下行速度(多线程)", + "isReviewed": "True" + }, "Clear Test Results": { "source": [ "Furious.Widget.UserServersQTableWidget" @@ -1588,6 +1596,7 @@ TRANSLATION = { }, "Customize TUN Settings...": { "source": [ + "Furious.TrayActions.Settings", "Furious.Window.AppMainWindow" ], "RU": "Настройте параметры TUN...", diff --git a/Furious/Interface/ConfigurationFactory.py b/Furious/Interface/ConfigurationFactory.py index efe4f87..4195ee2 100644 --- a/Furious/Interface/ConfigurationFactory.py +++ b/Furious/Interface/ConfigurationFactory.py @@ -19,6 +19,7 @@ from __future__ import annotations from Furious.Interface.UserServersTableItem import * +from PySide6 import QtCore from PySide6.QtWidgets import QApplication from abc import ABC @@ -48,6 +49,7 @@ class ConfigurationFactory(UserServersTableItem, dict, ABC): """ self._index = kwargs.pop('index', 0) + self._deleted = kwargs.pop('deleted', False) # Extra attributes self.kwargs = kwargs @@ -98,11 +100,25 @@ class ConfigurationFactory(UserServersTableItem, dict, ABC): def setExtras(self, item, value): self.kwargs[item] = value + @property def index(self) -> int: return self._index - def setIndex(self, index): - self._index = index + @index.setter + def index(self, value: int): + assert isinstance(value, int) + + self._index = value + + @property + def deleted(self) -> bool: + return self._deleted + + @deleted.setter + def deleted(self, value: bool): + assert isinstance(value, bool) + + self._deleted = value @property def itemRemark(self) -> str: diff --git a/Furious/PyFramework/Ancestors.py b/Furious/PyFramework/Ancestors.py index 6d3a08d..b48970e 100644 --- a/Furious/PyFramework/Ancestors.py +++ b/Furious/PyFramework/Ancestors.py @@ -30,7 +30,6 @@ __all__ = [ 'SupportConnectedCallback', 'SupportThemeChangedCallback', 'SupportExitCleanup', - 'GarbageCollector', ] @@ -153,23 +152,3 @@ class SupportExitCleanup: pass else: object_.cleanup() - - -class GarbageCollector: - ObjectsPool = list() - # ObjectsId is used to speed up searching - ObjectsId = dict() - - @staticmethod - def track(object_: Any): - GarbageCollector.ObjectsPool.append(object_) - GarbageCollector.ObjectsId[id(object_)] = True - - @staticmethod - def isTracked(object_: Any) -> bool: - return id(object_) in GarbageCollector.ObjectsId - - @staticmethod - def clear(): - GarbageCollector.ObjectsPool.clear() - GarbageCollector.ObjectsId.clear() diff --git a/Furious/QtFramework/Ancestors.py b/Furious/QtFramework/Ancestors.py index 30739db..e59ac08 100644 --- a/Furious/QtFramework/Ancestors.py +++ b/Furious/QtFramework/Ancestors.py @@ -21,7 +21,7 @@ from Furious.PyFramework import * from PySide6 import QtCore -__all__ = ['QBlockSignals', 'QTranslatable', 'QGarbageCollector'] +__all__ = ['QBlockSignals', 'QTranslatable'] class QProtection: @@ -72,16 +72,3 @@ class QTranslatable(Translatable): ob.retranslate() else: ob.retranslate() - - -class QGarbageCollector(GarbageCollector): - Timer = QtCore.QTimer() - - @staticmethod - def isEnabled(): - return QGarbageCollector.Timer.isActive() - - @staticmethod - def collect(timeout: int): - QGarbageCollector.Timer.timeout.connect(QGarbageCollector.clear) - QGarbageCollector.Timer.start(timeout) diff --git a/Furious/Utility/Constants.py b/Furious/Utility/Constants.py index 6cb06d9..4ddada4 100644 --- a/Furious/Utility/Constants.py +++ b/Furious/Utility/Constants.py @@ -20,6 +20,7 @@ from Furious.Version import __version__ from PySide6 import QtCore, __version__ as _pyside6_version from PySide6.QtWidgets import QApplication +import os import math import pathlib import platform @@ -42,6 +43,8 @@ ORGANIZATION_DOMAIN = 'Furious.GUI' # Tested: Furious supports minimum PySide6 version 6.1.0 PYSIDE6_VERSION = _pyside6_version +OS_CPU_COUNT = os.cpu_count() + PLATFORM = platform.system() PLATFORM_RELEASE = platform.release() PLATFORM_MACHINE = platform.machine() diff --git a/Furious/Widget/Application.py b/Furious/Widget/Application.py index 1a8177c..108e21b 100644 --- a/Furious/Widget/Application.py +++ b/Furious/Widget/Application.py @@ -329,6 +329,7 @@ class Application(ApplicationFactory, SingletonApplication): logger.info( f'Qt version: {QtCore.qVersion()}. PySide6 version: {PYSIDE6_VERSION}' ) + logger.info(f'Qt build info: {QtCore.QLibraryInfo.build()}') logger.info(f'platform: {PLATFORM}') logger.info(f'platform release: {PLATFORM_RELEASE}') logger.info(f'platform machine: {PLATFORM_MACHINE}') diff --git a/Furious/Widget/UserServersQTableWidget.py b/Furious/Widget/UserServersQTableWidget.py index aafdf3c..12ec765 100644 --- a/Furious/Widget/UserServersQTableWidget.py +++ b/Furious/Widget/UserServersQTableWidget.py @@ -118,9 +118,6 @@ class UpdateSubsInfoMBox(AppQMessageBox): class SubscriptionManager(WebGETManager): - # Collect unused references every 30 mins - GC_COLLECT_INTERVAL = 30 * 60 * 1000 - def __init__(self, parent, **kwargs): actionMessage = kwargs.pop('actionMessage', 'update subs') @@ -176,9 +173,6 @@ class SubscriptionManager(WebGETManager): # Trigger connect APP().systemTray.ConnectAction.trigger() - if not QGarbageCollector.isEnabled(): - QGarbageCollector.collect(SubscriptionManager.GC_COLLECT_INTERVAL) - if showMessageBox: mbox = UpdateSubsInfoMBox( successArgs=successArgs, @@ -307,13 +301,9 @@ class TestPingLatencyWorker(QtCore.QObject, QtCore.QRunnable): self.factory = factory def run(self): - index = self.factory.index() + index = self.factory.index - if ( - index < 0 - or index >= len(AS_UserServers()) - or QGarbageCollector.isTracked(self.factory) - ): + if self.factory.deleted or index < 0 or index >= len(AS_UserServers()): # Invalid item. Do nothing return @@ -335,9 +325,7 @@ class TestPingLatencyWorker(QtCore.QObject, QtCore.QRunnable): self.factory.setExtras('delayResult', classname(ex)) # Extra guard - if APP() is None or APP().isExiting(): - return - else: + if APP() is not None and not APP().isExiting(): self.finished.emit() else: # Result address should not be empty @@ -350,9 +338,7 @@ class TestPingLatencyWorker(QtCore.QObject, QtCore.QRunnable): self.factory.setExtras('delayResult', 'Error') # Extra guard - if APP() is None or APP().isExiting(): - return - else: + if APP() is not None and not APP().isExiting(): self.finished.emit() @@ -367,13 +353,9 @@ class TestTcpingLatencyWorker(QtCore.QObject, QtCore.QRunnable): self.factory = factory def run(self): - index = self.factory.index() + index = self.factory.index - if ( - index < 0 - or index >= len(AS_UserServers()) - or QGarbageCollector.isTracked(self.factory) - ): + if self.factory.deleted or index < 0 or index >= len(AS_UserServers()): # Invalid item. Do nothing return @@ -396,9 +378,7 @@ class TestTcpingLatencyWorker(QtCore.QObject, QtCore.QRunnable): self.factory.setExtras('delayResult', classname(ex)) # Extra guard - if APP() is None or APP().isExiting(): - return - else: + if APP() is not None and not APP().isExiting(): self.finished.emit() else: if rtts: @@ -407,22 +387,29 @@ class TestTcpingLatencyWorker(QtCore.QObject, QtCore.QRunnable): self.factory.setExtras('delayResult', 'Timeout') # Extra guard - if APP() is None or APP().isExiting(): - return - else: + if APP() is not None and not APP().isExiting(): self.finished.emit() class TestDownloadSpeedWorker(WebGETManager): progressed = QtCore.Signal() - def __init__(self, factory: ConfigurationFactory, port: int, parent=None, **kwargs): + def __init__( + self, + factory: ConfigurationFactory, + port: int, + timeout: int, + parent=None, + **kwargs, + ): actionMessage = kwargs.pop('actionMessage', 'test download speed') super().__init__(parent, actionMessage=actionMessage) self.factory = factory self.port = port + self.timeout = timeout + self.kwargs = kwargs self.hasSpeedResult = False self.totalBytesRead = 0 @@ -434,6 +421,21 @@ class TestDownloadSpeedWorker(WebGETManager): self.networkReply = None self.elapsedTimer = QtCore.QElapsedTimer() + self.timeoutTimer = QtCore.QTimer() + self.timeoutTimer.setSingleShot(True) + self.timeoutTimer.timeout.connect(self.handleTimeout) + + def releaseSemaphore(self): + parent = self.parent() + + if isinstance(parent, UserServersQTableWidget): + parent.testDownloadSpeedMultiSema.release(1) + + def syncProgress(self): + # Extra guard + if APP() is not None and not APP().isExiting(): + self.progressed.emit() + def isFinished(self) -> bool: if isinstance(self.networkReply, QNetworkReply): return self.networkReply.isFinished() @@ -444,58 +446,26 @@ class TestDownloadSpeedWorker(WebGETManager): if isinstance(self.networkReply, QNetworkReply): self.networkReply.abort() - def run(self): - index = self.factory.index() + def handleTimeout(self): + if not self.isFinished(): + self.abort() - if ( - index < 0 - or index >= len(AS_UserServers()) - or QGarbageCollector.isTracked(self.factory) - ): - # Invalid item. Do nothing - return + def coreExitCallback(self, config: ConfigurationFactory, exitcode: int): + if exitcode == CoreProcess.ExitCode.ConfigurationError: + self.factory.setExtras('speedResult', f'Invalid') + self.syncProgress() + elif exitcode == CoreProcess.ExitCode.ServerStartFailure: + self.factory.setExtras('speedResult', f'Core start failed') + self.syncProgress() + elif exitcode == CoreProcess.ExitCode.SystemShuttingDown: + pass + else: + self.factory.setExtras('speedResult', f'Core exited {exitcode}') + self.syncProgress() - assert isinstance(self.factory, ConfigurationFactory) - - if not self.factory.isValid(): - self.factory.setExtras('speedResult', 'Invalid') - - # Extra guard - if APP() is None or APP().isExiting(): - return - else: - self.progressed.emit() - - # Configuration is not valid. Do nothing - return - - def coreExitCallback(config: ConfigurationFactory, exitcode: int): - if exitcode == CoreProcess.ExitCode.ConfigurationError: - self.factory.setExtras('speedResult', f'Invalid') - - # Extra guard - if APP() is None or APP().isExiting(): - return - else: - self.progressed.emit() - elif exitcode == CoreProcess.ExitCode.ServerStartFailure: - self.factory.setExtras('speedResult', f'Core start failed') - - # Extra guard - if APP() is None or APP().isExiting(): - return - else: - self.progressed.emit() - elif exitcode == CoreProcess.ExitCode.SystemShuttingDown: - pass - else: - self.factory.setExtras('speedResult', f'Core exited {exitcode}') - - # Extra guard - if APP() is None or APP().isExiting(): - return - else: - self.progressed.emit() + def startCore(self) -> bool: + self.factory.setExtras('speedResult', 'Starting') + self.syncProgress() def msgCallback(line: str): try: @@ -505,14 +475,6 @@ class TestDownloadSpeedWorker(WebGETManager): pass - self.factory.setExtras('speedResult', 'Starting') - - # Extra guard - if APP() is None or APP().isExiting(): - return - else: - self.progressed.emit() - itemcopy = self.factory.deepcopy() if isinstance(itemcopy, ConfigurationXray): @@ -551,12 +513,14 @@ class TestDownloadSpeedWorker(WebGETManager): self.coreManager.start( itemcopy, 'Global', - coreExitCallback, + self.coreExitCallback, msgCallbackCore=msgCallback, deepcopy=False, proxyModeOnly=True, log=False, ) + + return True elif isinstance(itemcopy, ConfigurationHysteria1) or isinstance( itemcopy, ConfigurationHysteria2 ): @@ -572,28 +536,49 @@ class TestDownloadSpeedWorker(WebGETManager): self.coreManager.start( itemcopy, 'Global', - coreExitCallback, + self.coreExitCallback, msgCallbackCore=msgCallback, deepcopy=False, proxyModeOnly=True, log=False, ) + + return True else: self.factory.setExtras('speedResult', 'Invalid') - - # Extra guard - if APP() is None or APP().isExiting(): - return - else: - self.progressed.emit() + self.syncProgress() # Unrecognized core. Return + return False + + def start(self): + index = self.factory.index + + if self.factory.deleted or index < 0 or index >= len(AS_UserServers()): + # Invalid item. Do nothing + self.releaseSemaphore() + return - self.configureHttpProxy(f'127.0.0.1:{self.port}') + assert isinstance(self.factory, ConfigurationFactory) - self.networkReply = self.webGET(NETWORK_SPEED_TEST_URL) - self.elapsedTimer.start() + if not self.factory.isValid(): + self.factory.setExtras('speedResult', 'Invalid') + self.syncProgress() + self.releaseSemaphore() + + # Configuration is not valid. Do nothing + return + + if self.startCore(): + self.configureHttpProxy(f'127.0.0.1:{self.port}') + + self.networkReply = self.webGET(NETWORK_SPEED_TEST_URL, **self.kwargs) + self.elapsedTimer.start() + + self.timeoutTimer.start(self.timeout) + else: + self.releaseSemaphore() def successCallback(self, networkReply, **kwargs): if self.coreManager.allRunning(): @@ -608,12 +593,8 @@ class TestDownloadSpeedWorker(WebGETManager): self.factory.setExtras('speedResult', f'Core start failed') self.coreManager.stopAll() - - # Extra guard - if APP() is None or APP().isExiting(): - return - else: - self.progressed.emit() + self.syncProgress() + self.releaseSemaphore() def hasDataCallback(self, networkReply, **kwargs): self.hasDataCounter += 1 @@ -629,18 +610,16 @@ class TestDownloadSpeedWorker(WebGETManager): self.hasSpeedResult = True self.factory.setExtras('speedResult', f'{downloadSpeed:.2f} MiB/s') - # Extra guard - if APP() is None or APP().isExiting(): - return - else: - if self.hasDataCounter % 10 == 0: - # Limited to save CPU resources - self.progressed.emit() + # Limited to save CPU resources + if self.hasDataCounter % 25 == 0: + self.syncProgress() def failureCallback(self, networkReply, **kwargs): if not self.hasSpeedResult: if not self.coreManager.allRunning(): # Core ExitCallback has been called + self.releaseSemaphore() + return if ( @@ -671,12 +650,8 @@ class TestDownloadSpeedWorker(WebGETManager): self.factory.setExtras('speedResult', errorString) self.coreManager.stopAll() - - # Extra guard - if APP() is None or APP().isExiting(): - return - else: - self.progressed.emit() + self.syncProgress() + self.releaseSemaphore() class UserServersQTableWidgetHorizontalHeader(AppQHeaderView): @@ -810,6 +785,15 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): self.testDownloadSpeedTimer = QtCore.QTimer() self.testDownloadSpeedTimer.timeout.connect(self.handleTestDownloadSpeedJob) + self.testDownloadSpeedQueueMulti = queue.Queue() + self.testDownloadSpeedTimerMulti = QtCore.QTimer() + self.testDownloadSpeedTimerMulti.timeout.connect( + self.handleTestDownloadSpeedJobMulti + ) + + self.testDownloadSpeedMultiPort = 30000 + self.testDownloadSpeedMultiSema = QtCore.QSemaphore(OS_CPU_COUNT // 2) + # Text Editor Window self.textEditorWindow = TextEditorWindow(parent=self.parent()) @@ -929,6 +913,14 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): QtCore.Qt.Key.Key_O, ), ), + AppQAction( + _('Test Download Speed (Multithreaded)'), + callback=lambda: self.testSelectedItemDownloadSpeedMulti(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_M, + ), + ), AppQAction( _('Test Download Speed'), callback=lambda: self.testSelectedItemDownloadSpeed(), @@ -1222,13 +1214,9 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): AppSettings.set('ActivatedItemIndex', str(index)) def flushItem(self, row: int, column: int, item: ConfigurationFactory): - itemIndex = item.index() + itemIndex = item.index - if ( - itemIndex < 0 - or itemIndex >= len(AS_UserServers()) - or QGarbageCollector.isTracked(item) - ): + if item.deleted or itemIndex < 0 or itemIndex >= len(AS_UserServers()): # Invalid item. Do nothing return @@ -1239,7 +1227,7 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): if id(item) == id(AS_UserServers()[_index]): itemIndex = _index - item.setIndex(itemIndex) + item.index = itemIndex return True @@ -1367,7 +1355,7 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): def flushAll(self): # Refresh index for index, item in enumerate(AS_UserServers()): - item.setIndex(index) + item.index = index if self.rowCount() == 0: # Should insert row @@ -1388,8 +1376,8 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): swapSequenceItem(AS_UserServers(), index0, index1) # Refresh index - AS_UserServers()[index0].setIndex(index1) - AS_UserServers()[index1].setIndex(index0) + AS_UserServers()[index0].index = index1 + AS_UserServers()[index1].index = index0 self.flushRow(index0, AS_UserServers()[index0]) self.flushRow(index1, AS_UserServers()[index1]) @@ -1484,8 +1472,7 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): with QBlockSignals(self): self.removeRow(deleteIndex) - QGarbageCollector.track(AS_UserServers()[deleteIndex]) - + AS_UserServers()[deleteIndex].deleted = True AS_UserServers().pop(deleteIndex) if not deleteActivated and deleteIndex < AS_UserActivatedItemIndex(): @@ -1614,34 +1601,35 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): AppThreadPool().start(worker) - @QtCore.Slot() - def handleTestDownloadSpeedJob(self): - try: - index, factory, timeout = self.testDownloadSpeedQueue.get_nowait() - except queue.Empty: - # Queue is empty - - # Power Optimization. Timer gets fired only when needed - self.testDownloadSpeedTimer.stop() - - return - - assert isinstance(factory, ConfigurationFactory) - - def testDownloadSpeed(counter=0, step=100): - worker = TestDownloadSpeedWorker( - factory, port=20809, logActionMessage=False + def testDownloadSpeedByFactory( + self, + index: int, + factory: ConfigurationFactory, + port: int, + timeout: int, + isMulti: bool, + counter=0, + step=100, + logActionMessage=False, + ): + worker = TestDownloadSpeedWorker( + factory, + port, + timeout, + parent=self, + logActionMessage=logActionMessage, + ) + worker.progressed.connect( + functools.partial( + self.flushItem, + index, + self.Headers.index('Speed'), + factory, ) - worker.progressed.connect( - functools.partial( - self.flushItem, - index, - self.Headers.index('Speed'), - factory, - ) - ) - worker.run() + ) + worker.start() + if not isMulti: while ( not APP().isExiting() and not worker.isFinished() and counter < timeout ): @@ -1655,13 +1643,85 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): while not worker.isFinished(): PySide6LegacyEventLoopWait(step) - if APP().isExiting(): - # Stop timer - self.testDownloadSpeedTimer.stop() + if APP().isExiting(): + # Stop timer + self.testDownloadSpeedTimer.stop() + self.testDownloadSpeedTimerMulti.stop() - testDownloadSpeed() + def handleTestDownloadSpeedJobXXX( + self, + jobQueue: queue.Queue, + jobTimer: QtCore.QTimer, + isMulti: bool, + ): + try: + if isMulti and not self.testDownloadSpeedMultiSema.tryAcquire(1, 1): + return - def testSelectedItemDownloadSpeedWithTimeout(self, timeout: int): + index, factory, timeout = jobQueue.get_nowait() + except queue.Empty: + # Queue is empty + + # Power Optimization. Timer gets fired only when needed + jobTimer.stop() + + return + else: + if isMulti: + if self.testDownloadSpeedMultiPort >= 40000: + self.testDownloadSpeedMultiPort = 30000 + + testDownloadSpeedPort = self.testDownloadSpeedMultiPort + + self.testDownloadSpeedMultiPort += 1 + + jobTimer.start(1) + else: + testDownloadSpeedPort = 20809 + + assert isinstance(factory, ConfigurationFactory) + + if factory.deleted: + # Invalid item. Do nothing. + # Fetch next job. + self.testDownloadSpeedMultiSema.release(1) + + jobTimer.start(1) + else: + self.testDownloadSpeedByFactory( + index, + factory, + testDownloadSpeedPort, + timeout, + isMulti, + ) + + if not APP().isExiting(): + # Fetch next job. + jobTimer.start(1) + + @QtCore.Slot() + def handleTestDownloadSpeedJob(self): + self.handleTestDownloadSpeedJobXXX( + self.testDownloadSpeedQueue, + self.testDownloadSpeedTimer, + False, + ) + + @QtCore.Slot() + def handleTestDownloadSpeedJobMulti(self): + self.handleTestDownloadSpeedJobXXX( + self.testDownloadSpeedQueueMulti, + self.testDownloadSpeedTimerMulti, + True, + ) + + def testSelectedItemDownloadSpeedWithTimeoutXXX( + self, + jobQueue: queue.Queue, + jobTimer: QtCore.QTimer, + timeout: int, + ): indexes = self.selectedIndex if len(indexes) == 0: @@ -1674,19 +1734,36 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): for index, reference in zip(indexes, references): try: # Served by FIFO - self.testDownloadSpeedQueue.put_nowait((index, reference, timeout)) + jobQueue.put_nowait((index, reference, timeout)) except Exception: # Any non-exit exceptions pass - # Power Optimization - if not self.testDownloadSpeedTimer.isActive(): - self.testDownloadSpeedTimer.start(250) + if not jobTimer.isActive(): + # # Power Optimization. Timer gets fired only when needed + jobTimer.start(250) + + def testSelectedItemDownloadSpeedWithTimeout(self, timeout: int): + self.testSelectedItemDownloadSpeedWithTimeoutXXX( + self.testDownloadSpeedQueue, + self.testDownloadSpeedTimer, + timeout, + ) + + def testSelectedItemDownloadSpeedWithTimeoutMulti(self, timeout: int): + self.testSelectedItemDownloadSpeedWithTimeoutXXX( + self.testDownloadSpeedQueueMulti, + self.testDownloadSpeedTimerMulti, + timeout, + ) def testSelectedItemDownloadSpeed(self): self.testSelectedItemDownloadSpeedWithTimeout(5000) + def testSelectedItemDownloadSpeedMulti(self): + self.testSelectedItemDownloadSpeedWithTimeoutMulti(5000) + def clearSelectedItemTestResult(self): indexes = self.selectedIndex @@ -1714,7 +1791,7 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): index = len(AS_UserServers()) # Set index - factory.setIndex(index) + factory.index = index AS_UserServers().append(factory) diff --git a/Furious/Window/TextEditorWindow.py b/Furious/Window/TextEditorWindow.py index 5e67977..bbdef4c 100644 --- a/Furious/Window/TextEditorWindow.py +++ b/Furious/Window/TextEditorWindow.py @@ -265,7 +265,7 @@ class TextEditorWindow(AppQMainWindow): old = AS_UserServers()[index] new = constructFromDict(jsonObject, **old.kwargs) - QGarbageCollector.track(old) + old.deleted = True AS_UserServers()[index] = new