From d4e5ec23ab3035ac383c2b8b603655a2bdfd4ccd Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Sat, 26 Sep 2026 17:28:33 +0200 Subject: [PATCH 1/3] fix(tests): platform-safe os.startfile mock, shared QApplication (1) test_open_extern_handles_os_error_gracefully patched os.startfile unconditionally. os.startfile does not exist on Linux/macOS at all, so unittest.mock.patch() on it raised AttributeError there before the test body even ran. The test now mocks whichever call _open_extern() actually uses for the current sys.platform (os.startfile on win32, subprocess.Popen everywhere else -- matching the source's own branching). (2) tests/conftest.py creates a single shared QApplication before any test module is collected, so the ~28 modules that each do `QApplication.instance() or QApplication([])` all get the same instance instead of racing to create their own, and drains the event loop (processEvents + gc.collect) after the session so widgets scheduled via deleteLater() are actually destroyed before Python starts tearing down objects at interpreter exit. This is aimed at the Linux/macOS "QObject: shared QObject was deleted directly" + segfault (139). NOT included / still open: the Windows crash (PYTEST_EXIT=-1073740791, STATUS_STACK_BUFFER_OVERRUN, ~3s after "365 passed" prints). Investigated and reproduced identically WITH and WITHOUT this conftest.py, so it is a separate issue, not fixed by (2). Also tried forcing an early process exit from pytest_unconfigure (os._exit(), then Win32 TerminateProcess() via ctypes) -- the crash moved earlier each time but a native stack-corruption fault kept triggering, pointing at a genuine memory-safety bug in a native dependency (fitz/PyMuPDF or openpyxl, the two C-extension-heavy libraries this suite exercises) rather than a Qt teardown-order problem. Bisecting across the 58 test files to localize it is out of scope for this PR; tracking separately. Does not block the 1.0.7 release. Verified: full suite still shows "365 passed, 2 skipped" content-wise (only the exit-code tail is the open Windows issue above). --- tests/conftest.py | 52 +++++++++++++++++++++++++++ tests/test_preview_panel_hardening.py | 12 ++++++- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9a556fd --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,52 @@ +"""Shared pytest setup for ExplorerPro's Qt-based test suite. + +A single QApplication instance is created here, before any test module is +collected/imported, so the many `QApplication.instance() or QApplication([])` +calls scattered across individual test files (28 as of this writing) all +retrieve the SAME instance instead of racing to create their own. + +Without this, widgets created across many test modules get garbage-collected +by Python in an unpredictable order relative to Qt's own C++ object +lifetime at interpreter shutdown -- observed as +"QObject: shared QObject was deleted directly" followed by a segfault +(exit 139) on Linux/macOS. + +Note on the Windows crash (PYTEST_EXIT=-1073740791, STATUS_STACK_BUFFER_OVERRUN, +a few seconds after "365 passed" prints): reproduced this locally BOTH with +and without this conftest.py, so it is not caused by (or fixed by) the +QApplication-sharing/teardown-order issue this file addresses. Forcing an +early process exit (os._exit() / Win32 TerminateProcess) from +pytest_unconfigure was tried and did not prevent it either -- the crash +moved earlier but a native stack-corruption fault (Windows GS-cookie check +failure) still triggered. That points to a genuine memory-safety bug in a +native dependency (fitz/PyMuPDF and openpyxl are the two C-extension-heavy +libraries this suite exercises) rather than a Qt teardown-order problem, +and needs bisection across the 58 test files to localize -- out of scope +for this fix. Tracked separately; does not block release. +""" +from __future__ import annotations + +import gc +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication + +_app = QApplication.instance() or QApplication([]) + + +def pytest_sessionfinish(session, exitstatus): + """Let Qt's event loop actually destroy widgets scheduled via + deleteLater() before Python starts tearing down objects at interpreter + exit. This is what prevents the shared-QObject-deleted warning on + Linux/macOS; see module docstring for the separate, still-open Windows + native crash.""" + app = QApplication.instance() + if app is None: + return + for _ in range(5): + app.processEvents() + gc.collect() + for _ in range(5): + app.processEvents() diff --git a/tests/test_preview_panel_hardening.py b/tests/test_preview_panel_hardening.py index 198fa4d..f9eb4e6 100644 --- a/tests/test_preview_panel_hardening.py +++ b/tests/test_preview_panel_hardening.py @@ -147,12 +147,22 @@ def test_metadata_handles_negative_or_invalid_timestamp_gracefully(self, tmp_pat class TestExcelPreviewExternalLaunch: def test_open_extern_handles_os_error_gracefully(self, tmp_path): + """_open_extern() waehlt den Aufruf je nach sys.platform (os.startfile + unter Windows, subprocess.Popen("open"/"xdg-open") sonst) -- os.startfile + existiert unter Linux/macOS gar nicht, patch() darauf wuerde dort mit + AttributeError abbrechen. Der passende Aufrufpfad fuer die aktuelle + Plattform wird stattdessen gemockt.""" from gui.preview.preview_panel import ExcelPreview widget = ExcelPreview() widget._path = str(tmp_path / "fake.xlsx") - with patch("os.startfile", side_effect=OSError("No application associated")): + if sys.platform == "win32": + target, kwargs = "os.startfile", {"side_effect": OSError("No application associated")} + else: + target, kwargs = "subprocess.Popen", {"side_effect": OSError("No application associated")} + + with patch(target, **kwargs): widget._open_extern() assert not widget.status_label.isHidden() From 2972356f64733488b526bb14d349d035092470d7 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Sat, 26 Sep 2026 17:54:29 +0200 Subject: [PATCH 2/3] fix(batch-rename): recognize case-only self-rename on case-insensitive FS CI on PR#6 revealed a genuine, platform-specific bug unrelated to the Qt/ native-crash investigation (tracked separately as T-20260926-987151525): test_case_only_rename_is_actionable_not_unchanged and test_batch_rename_dialog_case_mode_and_execution failed on macOS (FAILED ... AssertionError: Status sollte 'ok' sein, war aber 'collision'), passing on Linux and Windows. Root cause: generate_preview()'s "is this a case-only self-rename" check compared os.path.normcase(new_path) == os.path.normcase(original_path). os.path.normcase() reflects the OS's PATH-STRING convention (lowercases on Windows, no-op on POSIX) -- it says nothing about the actual filesystem's case sensitivity. macOS is the classic mismatch: its path convention is POSIX/case-sensitive (normcase is a no-op there), but its default filesystem (APFS) is case-INsensitive, so "sample.txt" and "SAMPLE.txt" are the same file on disk despite differing normcase strings. The self-rename bypass therefore never triggered, and the subsequent `os.path.exists(item.new_path)` check found the (same) file already "there" and flagged it as an external collision. Windows never hit this because its normcase() already lowercases both paths to the same string, so the original bypass caught it there; Linux never hits it because ext4 is genuinely case-sensitive, so os.path.exists() correctly returns False for the differently-cased path. Fix: after the normcase check, also compare via os.path.samefile() when both paths exist -- this checks actual inode/device identity and correctly recognizes the case-only self-rename on any case-insensitive filesystem regardless of the OS's path-string convention. Verified: the two previously-failing tests + full batch-rename test files (22 tests) pass; full suite 365 passed, 2 skipped, no regression. --- src/core/batch_rename_service.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/core/batch_rename_service.py b/src/core/batch_rename_service.py index 5ddcb3f..4fe18f5 100644 --- a/src/core/batch_rename_service.py +++ b/src/core/batch_rename_service.py @@ -236,9 +236,23 @@ def generate_preview( norm_target = os.path.normcase(os.path.abspath(item.new_path)) norm_src = os.path.normcase(os.path.abspath(item.original_path)) - # Case-Only Rename der eigenen Datei ist zulässig + # Case-Only Rename der eigenen Datei ist zulässig. os.path.normcase() + # allein reicht dafür nicht: normcase folgt der Pfad-STRING-Konvention + # des Betriebssystems (Windows -> lowercase, POSIX -> No-Op) und weiß + # nichts über das tatsächliche Dateisystem. Unter macOS ist die + # Pfadkonvention case-sensitiv (normcase verändert nichts), das dort + # standardmäßige APFS aber case-insensitiv -- "sample.txt" und + # "SAMPLE.txt" sind auf Dateisystemebene dieselbe Datei, obwohl ihre + # normcase-Strings sich unterscheiden. os.path.samefile() vergleicht + # dagegen tatsächliche Inode/Device-Identität und erkennt das korrekt. if norm_target == norm_src: continue + if os.path.exists(item.new_path) and os.path.exists(item.original_path): + try: + if os.path.samefile(item.new_path, item.original_path): + continue + except OSError: + pass # Ziel wird von einer anderen, nicht wegziehenden Batch-Datei blockiert if norm_target in blocked_original_paths: From 5b8bb0cdf1dda4a9118357e1b2fc1d641e6e52f1 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Sat, 26 Sep 2026 17:58:52 +0200 Subject: [PATCH 3/3] revert: remove tests/conftest.py (Qt-teardown fix confirmed ineffective) The shared-QApplication + event-loop-drain fix in tests/conftest.py (introduced in d4e5ec2 to address the Linux/macOS "QObject: shared QObject was deleted directly" + segfault) did not work: CI on this same branch (commit d4e5ec2) still showed the identical warning and segfault on ubuntu-latest AFTER "365 passed, 2 skipped", with the file in place. Shipping code that demonstrably does not do what it claims to do is worse than shipping nothing -- removing it. The underlying crash (now narrowed to Python 3.11 specifically, on all three OSes, per the CI run on d4e5ec2/2972356 -- 10/11 legs green, only the three 3.11 legs red) stays tracked in T-20260926-987151525, to be investigated as a native-module/cp311-wheel issue rather than a Qt object lifetime problem. Kept from this branch: the platform-safe os.startfile/subprocess.Popen mock (fixes a real Linux/macOS AttributeError) and the case-only-rename os.path.samefile() fix (fixes a real macOS test failure) -- both verified independently effective. --- tests/conftest.py | 52 ----------------------------------------------- 1 file changed, 52 deletions(-) delete mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 9a556fd..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Shared pytest setup for ExplorerPro's Qt-based test suite. - -A single QApplication instance is created here, before any test module is -collected/imported, so the many `QApplication.instance() or QApplication([])` -calls scattered across individual test files (28 as of this writing) all -retrieve the SAME instance instead of racing to create their own. - -Without this, widgets created across many test modules get garbage-collected -by Python in an unpredictable order relative to Qt's own C++ object -lifetime at interpreter shutdown -- observed as -"QObject: shared QObject was deleted directly" followed by a segfault -(exit 139) on Linux/macOS. - -Note on the Windows crash (PYTEST_EXIT=-1073740791, STATUS_STACK_BUFFER_OVERRUN, -a few seconds after "365 passed" prints): reproduced this locally BOTH with -and without this conftest.py, so it is not caused by (or fixed by) the -QApplication-sharing/teardown-order issue this file addresses. Forcing an -early process exit (os._exit() / Win32 TerminateProcess) from -pytest_unconfigure was tried and did not prevent it either -- the crash -moved earlier but a native stack-corruption fault (Windows GS-cookie check -failure) still triggered. That points to a genuine memory-safety bug in a -native dependency (fitz/PyMuPDF and openpyxl are the two C-extension-heavy -libraries this suite exercises) rather than a Qt teardown-order problem, -and needs bisection across the 58 test files to localize -- out of scope -for this fix. Tracked separately; does not block release. -""" -from __future__ import annotations - -import gc -import os - -os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") - -from PySide6.QtWidgets import QApplication - -_app = QApplication.instance() or QApplication([]) - - -def pytest_sessionfinish(session, exitstatus): - """Let Qt's event loop actually destroy widgets scheduled via - deleteLater() before Python starts tearing down objects at interpreter - exit. This is what prevents the shared-QObject-deleted warning on - Linux/macOS; see module docstring for the separate, still-open Windows - native crash.""" - app = QApplication.instance() - if app is None: - return - for _ in range(5): - app.processEvents() - gc.collect() - for _ in range(5): - app.processEvents()