Skip to content

Commit 95e0cd2

Browse files
authored
Fix test hangs on Linux/macOS CI by mocking system dependencies (#2353)
* Fix flaky integration tests in test_init.py The tests test_create_repo and test_add_existing_repo were failing intermittently with RepoModelDoesNotExist and waitUntil timeout errors. Root cause: The tests used is_worker_running() which returns False as soon as current_job is None, but worker threads may still be alive and Qt signals may not be fully processed yet. Fix: Adopt the same reliable waiting patterns used in unit tests: - Add all_workers_finished() helper that checks worker.is_alive() - Add disconnect_all() helper for proper signal cleanup - Call QCoreApplication.processEvents() after waiting for workers - Update init_db fixture teardown to properly clean up state * Test reliability fixes * Fix pythonpath * Skip D-Bus calls during tests to fix CI hangs D-Bus calls to systemd-logind and NetworkManager can hang indefinitely in CI environments where D-Bus is partially available. Check for sys._called_from_test (set by pytest) and skip these operations. * Add debug logging to diagnose CI hang Print statements at key initialization points to identify where the test hang occurs in Linux Python 3.12 CI environment. * Add more debug logging to init_db fixture and MainWindow Track where hang occurs between VortaApp creation and test execution. * Add debug to test function and fixture yield Check if test function is ever reached after fixture setup. * Add debug logging to init_db teardown Pinpoint where hang occurs in fixture teardown. * Replace qtbot.waitUntil with simple polling in teardown qtbot.waitUntil processes Qt events while waiting, which can trigger D-Bus operations that hang on Linux Python 3.12 CI. Use simple time-based polling instead. * Remove processEvents from teardown to avoid D-Bus hang QCoreApplication.processEvents() in teardown triggers pending Qt events which can include D-Bus operations that hang on Linux Python 3.12 CI. * Add more debug to teardown signal disconnect * Add debug around mock_db.close() * Remove processEvents from load_window to avoid D-Bus hang Replace QCoreApplication.processEvents() with time.sleep() to avoid triggering D-Bus operations that hang on Linux Python 3.12 CI. * Remove debug logging from CI hang investigation Clean up debug print statements added during investigation of the Python 3.12 Linux CI hanging issue. The actual fixes (skipping D-Bus calls during tests, avoiding processEvents in fixtures) are preserved. * Add debug timing to diagnose slow macOS CI tests Adds timing instrumentation to identify the cause of ~20s delays per test on GitHub Actions macOS runners: - VortaApp.__init__: timing for each major initialization step - qapp fixture: timing for session setup - init_db fixture: timing for setup, teardown, and worker wait loop - load_window: timing for MainWindow recreation The debug output includes: - Elapsed time for each operation - Worker thread state (alive, current_job, process info) - Iteration count for the worker wait loop - Warning when wait loop times out This will help identify if the delay is: - In VortaApp/MainWindow initialization - In the worker wait loop (BorgVersionJob not finishing) - Somewhere else in the test infrastructure * Skip CoreWLAN WiFi enumeration during tests to fix slow macOS CI The root cause of ~20s delays per test on macOS CI runners was CoreWLAN system calls hanging on headless runners without WiFi hardware. Call chain causing the delay: MainWindow.__init__ → ScheduleTab → NetworksPage → populate_wifi() → get_sorted_wifis() → get_network_status_monitor().get_known_wifis() → DarwinNetworkStatus._get_wifi_interface() → CWWiFiClient.sharedWiFiClient() ← HANGS ON HEADLESS CI Fix: Skip system WiFi enumeration during tests by checking the sys._called_from_test flag (already set in conftest.py). This is consistent with how D-Bus is already skipped in scheduler.py. Also removes debug timing code that was added to diagnose this issue. * Add granular debug timing to MainWindow.__init__ for CI investigation The previous CoreWLAN fix didn't resolve the 20s delay on macOS CI. Adding timing around each major operation in MainWindow.__init__ to identify the exact source of the delay: - super().__init__ - setupUi - setWindowIcon / LoadingButton - Each tab creation (RepoTab, SourceTab, ArchiveTab, ScheduleTab, MiscTab, AboutTab) - populate_profile_selector - get_network_status_monitor().is_network_status_available() - set_icons * Skip psutil.process_iter() during tests to fix slow macOS CI get_mount_points() iterates over all system processes which takes ~20 seconds on macOS CI runners. Since tests don't have actual borg mount processes, skip this enumeration during test runs. * Add debug timing inside ArchiveTab.__init__ to find slow line * Add more granular debug timing inside ArchiveTab methods * Add timing to final section of populate_from_profile * Fix slow macOS CI tests by mocking DNS lookups The root cause was socket.getaddrinfo() timing out (~10s each) when format_archive_name() called _getfqdn() for archive name templates. This happened twice per MainWindow creation, adding 20s per test. Fix: Mock getaddrinfo in pytest_configure to return immediately for AI_CANONNAME requests, avoiding DNS lookups during tests. Also removes debug timing code and reverts unsuccessful earlier fixes. * Fix DNS mock to handle all getaddrinfo calls, not just AI_CANONNAME * Re-add CoreWLAN skip during tests to prevent hang on CI * Fix CI hang: cache FQDN result instead of global DNS mock The global getaddrinfo mock was breaking other networking code. Instead, cache the FQDN result in _getfqdn() so the slow DNS lookup only happens once per hostname, then returns cached value. * Revert FQDN caching to isolate hang issue * Patch _getfqdn specifically during tests to avoid slow DNS lookups * Skip DNS lookup in _getfqdn during tests to fix CI slowness Move the test check inside _getfqdn() itself instead of patching from conftest.py. This avoids import timing issues that caused test hangs. * Add debug output to trace _getfqdn timing and flag state * Add debug points to find actual hang location DNS skip confirmed working. Adding debug at: - MainWindow.__init__ end - load_window() start/end - init_db fixture yield point * Add debug to test_prune_intervals to verify test starts * Add detailed debug to test_prune_intervals * Add debug to fixture teardown to find hang location * Add debug to archive_env fixture * Add debug at very start of init_db fixture * Disable pytest-qt exception capture to fix macOS hang pytest-qt's _process_events() hook causes hangs on macOS between tests. Setting qt_no_exception_capture = true disables this behavior. See: pytest-dev/pytest-qt#223 * Monkey-patch pytest-qt processEvents to prevent macOS hang * Remove debug print statements after fixing macOS test hang * refactor: Replace sys._called_from_test with pytest mocks Move test-specific behavior out of production code into test fixtures. Instead of checking sys._called_from_test in app code, mock the problematic subsystems in tests/conftest.py: - Mock QtDBus.QDBusConnection.systemBus to prevent D-Bus hangs - Mock socket.getaddrinfo to prevent DNS lookup timeouts - Mock get_network_status_monitor to prevent WiFi enumeration hangs This keeps production code clean and follows Python testing best practices.
1 parent 72d84f0 commit 95e0cd2

File tree

11 files changed

+240
-47
lines changed

11 files changed

+240
-47
lines changed

CLAUDE.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ Vorta is a desktop backup client for macOS and Linux that provides a GUI for Bor
88

99
## Common Commands
1010

11+
This project uses `uv` for Python environment and dependency management. All Python commands should be run via `uv run`.
12+
1113
### Testing
1214
```bash
1315
# Run all tests (uses nox to test against multiple Borg versions)
@@ -20,13 +22,13 @@ make test-unit
2022
make test-integration
2123

2224
# Run tests with a specific Borg version
23-
BORG_VERSION=1.2.4 nox -- tests/unit/test_archives.py
25+
BORG_VERSION=1.2.4 uv run nox -- tests/unit/test_archives.py
2426

2527
# Run a single test file
26-
nox -- tests/unit/test_archives.py
28+
uv run nox -- tests/unit/test_archives.py
2729

2830
# Run a single test
29-
nox -- tests/unit/test_archives.py::test_archive_add
31+
uv run nox -- tests/unit/test_archives.py::test_archive_add
3032
```
3133

3234
### Linting
@@ -42,7 +44,7 @@ make dist/Vorta.dmg # Create notarized macOS DMG
4244

4345
### Development Install
4446
```bash
45-
pip install -e .
47+
uv sync # Install dependencies and project in editable mode
4648
```
4749

4850
## Architecture

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ quote-style = "preserve"
100100
[tool.pytest.ini_options]
101101
addopts = "-vs"
102102
testpaths = ["tests"]
103+
pythonpath = ["tests/unit"]
103104
qt_default_raising = true
104105
filterwarnings = ["ignore::DeprecationWarning"]
105106

src/vorta/views/repo_add_dialog.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -247,10 +247,10 @@ def run(self):
247247
params = BorgInfoRepoJob.prepare(self.values)
248248
if params['ok']:
249249
self.saveButton.setEnabled(False)
250-
thread = BorgInfoRepoJob(params['cmd'], params)
251-
thread.updated.connect(self._set_status)
252-
thread.result.connect(self.run_result)
253-
self.thread = thread # Needs to be connected to self for tests to work.
254-
self.thread.run()
250+
job = BorgInfoRepoJob(params['cmd'], params)
251+
job.updated.connect(self._set_status)
252+
job.result.connect(self.run_result)
253+
self.thread = job # Keep reference for tests
254+
QApplication.instance().jobs_manager.add_job(job)
255255
else:
256256
self._set_status(params['message'])

tests/conftest.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import os
2+
import socket
23
import sys
4+
from unittest.mock import Mock
35

46
import pytest
57
from peewee import SqliteDatabase
@@ -10,10 +12,50 @@
1012

1113

1214
def pytest_configure(config):
13-
sys._called_from_test = True
1415
pytest._wait_defaults = {'timeout': 20000}
1516
os.environ['LANG'] = 'en' # Ensure we test an English UI
1617

18+
# Disable pytest-qt's processEvents() calls on macOS to prevent hangs.
19+
# See: https://github.com/pytest-dev/pytest-qt/issues/223
20+
if sys.platform == 'darwin':
21+
try:
22+
import pytestqt.plugin
23+
24+
pytestqt.plugin._process_events = lambda: None
25+
except (ImportError, AttributeError):
26+
pass
27+
28+
# Mock D-Bus system bus to prevent hangs in CI (scheduler.py, network_manager.py)
29+
try:
30+
from PyQt6 import QtDBus
31+
32+
_original_system_bus = QtDBus.QDBusConnection.systemBus
33+
34+
def _mock_system_bus():
35+
mock_bus = Mock()
36+
mock_bus.isConnected.return_value = False
37+
return mock_bus
38+
39+
QtDBus.QDBusConnection.systemBus = staticmethod(_mock_system_bus)
40+
except ImportError:
41+
pass
42+
43+
# Mock DNS lookups to prevent timeouts in CI (utils._getfqdn)
44+
_original_getaddrinfo = socket.getaddrinfo
45+
socket.getaddrinfo = lambda *args, **kwargs: []
46+
47+
# Mock WiFi enumeration to prevent hangs on headless CI (utils.get_sorted_wifis)
48+
import vorta.utils
49+
50+
_original_get_network_status_monitor = vorta.utils.get_network_status_monitor
51+
52+
def _mock_get_network_status_monitor():
53+
mock_monitor = Mock()
54+
mock_monitor.get_known_wifis.return_value = []
55+
return mock_monitor
56+
57+
vorta.utils.get_network_status_monitor = _mock_get_network_status_monitor
58+
1759

1860
@pytest.fixture(scope='session')
1961
def qapp(tmpdir_factory):

tests/integration/conftest.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pytest
55
from packaging.version import Version
66
from peewee import SqliteDatabase
7+
from PyQt6.QtCore import QCoreApplication
78

89
import vorta
910
import vorta.application
@@ -22,6 +23,34 @@
2223
from vorta.utils import borg_compat
2324
from vorta.views.main_window import ArchiveTab, MainWindow
2425

26+
27+
def disconnect_all(signal):
28+
"""
29+
Disconnect ALL handlers from a Qt signal.
30+
Unlike signal.disconnect() without arguments which only disconnects ONE handler,
31+
this function disconnects all connected handlers by calling disconnect in a loop
32+
until TypeError is raised (indicating no more handlers are connected).
33+
"""
34+
while True:
35+
try:
36+
signal.disconnect()
37+
except TypeError:
38+
# No more handlers connected
39+
break
40+
41+
42+
def all_workers_finished(jobs_manager):
43+
"""
44+
Check if all worker threads have actually exited.
45+
This is more thorough than is_worker_running() which only checks current_job,
46+
because threads may still be alive briefly after current_job is set to None.
47+
"""
48+
for worker in jobs_manager.workers.values():
49+
if worker.is_alive():
50+
return False
51+
return True
52+
53+
2554
models = [
2655
RepoModel,
2756
RepoPassword,
@@ -175,9 +204,24 @@ def init_db(qapp, qtbot, tmpdir_factory, create_test_repo):
175204

176205
yield
177206

207+
# Teardown: cancel jobs and wait for workers to fully exit
178208
qapp.jobs_manager.cancel_all_jobs()
179-
qapp.backup_finished_event.disconnect()
180-
qtbot.waitUntil(lambda: not qapp.jobs_manager.is_worker_running(), **pytest._wait_defaults)
209+
210+
# Wait for all worker threads to actually exit (not just for current_job to be None).
211+
# This is more thorough than is_worker_running() and prevents thread state leakage.
212+
qtbot.waitUntil(lambda: all_workers_finished(qapp.jobs_manager), **pytest._wait_defaults)
213+
214+
# Process any pending events to ensure all queued signals are handled
215+
QCoreApplication.processEvents()
216+
217+
# Disconnect signals after events are processed
218+
disconnect_all(qapp.backup_finished_event)
219+
disconnect_all(qapp.scheduler.schedule_changed)
220+
221+
# Clear the workers dict to prevent accumulation of dead thread references
222+
qapp.jobs_manager.workers.clear()
223+
qapp.jobs_manager.jobs.clear()
224+
181225
mock_db.close()
182226

183227

tests/integration/test_init.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
from pathlib import PurePath
77

88
import pytest
9-
from PyQt6.QtCore import Qt
9+
from PyQt6.QtCore import QCoreApplication, Qt
1010
from PyQt6.QtWidgets import QMessageBox
1111

1212
import vorta.borg
1313
import vorta.utils
1414
import vorta.views.repo_add_dialog
15+
from tests.integration.conftest import all_workers_finished
1516

1617
LONG_PASSWORD = 'long-password-long'
1718
TEST_REPO_NAME = 'TEST - REPONAME'
@@ -43,9 +44,17 @@ def test_create_repo(qapp, qtbot, monkeypatch, choose_file_dialog, tmpdir):
4344
qtbot.keyClicks(add_repo_window.passwordInput.passwordLineEdit, LONG_PASSWORD)
4445
qtbot.keyClicks(add_repo_window.passwordInput.confirmLineEdit, LONG_PASSWORD)
4546

47+
initial_count = main.repoTab.repoSelector.count()
4648
add_repo_window.run()
4749

48-
qtbot.waitUntil(lambda: main.repoTab.repoSelector.count() == 2, **pytest._wait_defaults)
50+
# Wait for all worker threads to fully exit (more thorough than is_worker_running)
51+
qtbot.waitUntil(lambda: all_workers_finished(qapp.jobs_manager), **pytest._wait_defaults)
52+
53+
# Process pending Qt events to ensure signals are delivered and UI is updated
54+
QCoreApplication.processEvents()
55+
56+
# Wait for the new repo to appear in the selector
57+
qtbot.waitUntil(lambda: main.repoTab.repoSelector.count() == initial_count + 1, **pytest._wait_defaults)
4958

5059
# Check if repo was created in tmpdir
5160
repo_url = (
@@ -96,7 +105,16 @@ def test_add_existing_repo(qapp, qtbot, monkeypatch, choose_file_dialog):
96105

97106
add_repo_window.run()
98107

99-
# check that repo was added
100-
qtbot.waitUntil(lambda: tab.repoSelector.count() == 1, **pytest._wait_defaults)
108+
# Wait for all worker threads to fully exit (more thorough than is_worker_running)
109+
qtbot.waitUntil(lambda: all_workers_finished(qapp.jobs_manager), **pytest._wait_defaults)
110+
111+
# Process pending Qt events to ensure signals are delivered and UI is updated
112+
QCoreApplication.processEvents()
113+
114+
# Wait for the repo to appear in the selector
115+
# After unlink, count is 1 (only placeholder). After adding repo, count should be 2.
116+
qtbot.waitUntil(lambda: tab.repoSelector.count() == 2, **pytest._wait_defaults)
117+
118+
# check that repo was added correctly
101119
assert vorta.store.models.RepoModel.select().first().url == str(current_repo_path)
102120
assert vorta.store.models.RepoModel.select().first().name == TEST_REPO_NAME

tests/unit/conftest.py

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import os
2+
import time
23
from datetime import datetime as dt
34

45
import pytest
56
from peewee import SqliteDatabase
7+
from test_constants import TEST_SOURCE_DIR, TEST_TEMP_DIR
68

79
import vorta
810
import vorta.application
@@ -20,6 +22,34 @@
2022
)
2123
from vorta.views.main_window import ArchiveTab, MainWindow
2224

25+
26+
def disconnect_all(signal):
27+
"""
28+
Disconnect ALL handlers from a Qt signal.
29+
Unlike signal.disconnect() without arguments which only disconnects ONE handler,
30+
this function disconnects all connected handlers by calling disconnect in a loop
31+
until TypeError is raised (indicating no more handlers are connected).
32+
"""
33+
while True:
34+
try:
35+
signal.disconnect()
36+
except TypeError:
37+
# No more handlers connected
38+
break
39+
40+
41+
def all_workers_finished(jobs_manager):
42+
"""
43+
Check if all worker threads have actually exited.
44+
This is more thorough than is_worker_running() which only checks current_job,
45+
because threads may still be alive briefly after current_job is set to None.
46+
"""
47+
for worker in jobs_manager.workers.values():
48+
if worker.is_alive():
49+
return False
50+
return True
51+
52+
2353
models = [
2454
RepoModel,
2555
RepoPassword,
@@ -35,10 +65,13 @@
3565

3666
def load_window(qapp: vorta.application.VortaApp):
3767
"""
38-
Reload the main window of the given application
39-
Used to repopulate fields after loading mock data
68+
Reload the main window of the given application.
69+
Used to repopulate fields after loading mock data.
4070
"""
4171
qapp.main_window.deleteLater()
72+
# Skip QCoreApplication.processEvents() - it can trigger D-Bus operations that hang in CI.
73+
# Use a small sleep instead to allow deleteLater to be processed.
74+
time.sleep(0.1)
4275
del qapp.main_window
4376
qapp.main_window = MainWindow(qapp)
4477

@@ -89,14 +122,13 @@ def init_db(qapp, qtbot, tmpdir_factory, request):
89122
test_archive1 = ArchiveModel(snapshot_id='99998', name='test-archive1', time=dt(2000, 1, 1, 0, 0), repo=1)
90123
test_archive1.save()
91124

92-
source_dir = SourceFileModel(dir='/tmp/another', repo=new_repo, dir_size=100, dir_files_count=18, path_isdir=True)
125+
source_dir = SourceFileModel(dir=TEST_SOURCE_DIR, repo=new_repo, dir_size=100, dir_files_count=18, path_isdir=True)
93126
source_dir.save()
94127

95-
# Disconnect signals before destroying main_window to avoid "deleted object" errors
96-
try:
97-
qapp.scheduler.schedule_changed.disconnect()
98-
except TypeError:
99-
pass
128+
# Disconnect ALL signal handlers before destroying main_window to avoid "deleted object" errors
129+
# Using disconnect_all() instead of disconnect() to ensure ALL handlers are removed,
130+
# not just one (which can leave stale connections from previous tests)
131+
disconnect_all(qapp.scheduler.schedule_changed)
100132

101133
# Reload the window to apply the mock data
102134
# If this test has the `window_load` fixture,
@@ -106,10 +138,28 @@ def init_db(qapp, qtbot, tmpdir_factory, request):
106138

107139
yield
108140

141+
# Teardown: cancel jobs and disconnect ALL signal handlers to prevent state leakage
109142
qapp.jobs_manager.cancel_all_jobs()
110-
qapp.backup_finished_event.disconnect()
111-
qapp.scheduler.schedule_changed.disconnect()
112-
qtbot.waitUntil(lambda: not qapp.jobs_manager.is_worker_running(), **pytest._wait_defaults)
143+
144+
# Wait for all worker threads to actually exit (not just for current_job to be None).
145+
# Use simple polling instead of qtbot.waitUntil to avoid Qt event loop hangs in CI.
146+
# qtbot.waitUntil processes Qt events while waiting, which can trigger D-Bus operations.
147+
timeout = pytest._wait_defaults.get('timeout', 20000) / 1000 # Convert ms to seconds
148+
start = time.time()
149+
while not all_workers_finished(qapp.jobs_manager):
150+
if time.time() - start > timeout:
151+
break
152+
time.sleep(0.1)
153+
154+
# Skip QCoreApplication.processEvents() - it can trigger D-Bus operations that hang in CI
155+
156+
# Disconnect signals
157+
disconnect_all(qapp.backup_finished_event)
158+
disconnect_all(qapp.scheduler.schedule_changed)
159+
160+
# Clear the workers dict to prevent accumulation of dead thread references
161+
qapp.jobs_manager.workers.clear()
162+
qapp.jobs_manager.jobs.clear()
113163
mock_db.close()
114164

115165

@@ -123,19 +173,35 @@ def open(self, func):
123173
func()
124174

125175
def selectedFiles(self):
126-
return ['/tmp']
176+
return [TEST_TEMP_DIR]
127177

128178
return MockFileDialog
129179

130180

131181
@pytest.fixture
132182
def borg_json_output():
183+
"""
184+
Returns a function to read borg JSON output files.
185+
Opens real file handles (required for os.set_blocking and select.select),
186+
but tracks them for cleanup when the fixture is torn down.
187+
"""
188+
open_files = []
189+
133190
def _read_json(subcommand):
134191
stdout = open(f'tests/unit/borg_json_output/{subcommand}_stdout.json')
135192
stderr = open(f'tests/unit/borg_json_output/{subcommand}_stderr.json')
193+
open_files.append(stdout)
194+
open_files.append(stderr)
136195
return stdout, stderr
137196

138-
return _read_json
197+
yield _read_json
198+
199+
# Clean up all opened files when the fixture is torn down
200+
for f in open_files:
201+
try:
202+
f.close()
203+
except Exception:
204+
pass
139205

140206

141207
@pytest.fixture

0 commit comments

Comments
 (0)