Add multithreaded download speed test

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2025-06-13 17:41:46 +08:00
parent 553708bf83
commit 81e7db66f0
8 changed files with 277 additions and 205 deletions
+9
View File
@@ -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...",
+18 -2
View File
@@ -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:
-21
View File
@@ -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()
+1 -14
View File
@@ -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)
+3
View File
@@ -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()
+1
View File
@@ -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}')
+244 -167
View File
@@ -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)
+1 -1
View File
@@ -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