Skip to content

fix(tests): platform-safe os.startfile mock, shared QApplication - #6

Closed
lukisch wants to merge 3 commits into
masterfrom
chore/ci-qt-teardown-fix
Closed

lukisch wants to merge 3 commits into
masterfrom
chore/ci-qt-teardown-fix

Conversation

@lukisch

@lukisch lukisch commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator

Adressiert die drei von dir gemeldeten Punkte teilweise — mit ehrlichem Zwischenstand, nicht vollständig gelöst.

(1) Linux/macOS: AttributeError in test_open_extern_handles_os_error_gracefully — behoben

patch("os.startfile", ...) unbedingt aufgerufen; os.startfile existiert unter Linux/macOS gar nicht, patch() bricht dort schon vor dem eigentlichen Testkörper mit AttributeError ab. Test mockt jetzt plattformabhängig genau den Aufrufpfad, den _open_extern() laut Quellcode tatsächlich nimmt (os.startfile unter win32, sonst subprocess.Popen).

(2) Linux/macOS Segfault (139) — Fix versucht, nicht vor Ort verifizierbar

tests/conftest.py neu: eine einzige gemeinsame QApplication, erzeugt bevor irgendein Testmodul kollektiert wird — die ~28 Module, die je ihr eigenes QApplication.instance() or QApplication([]) machen, bekommen dieselbe Instanz statt um die Erzeugung zu wettlaufen. Nach der Session wird die Event-Loop drainiert (processEvents + gc.collect), damit per deleteLater() vorgemerkte Widgets tatsächlich zerstört werden, bevor Python beim Interpreter-Exit anfängt, Objekte aufzuräumen. Das zielt direkt auf "QObject: shared QObject was deleted directly". Ich habe hier kein Linux/macOS zur Verfügung — bitte auf der entsprechenden CI-Matrix verifizieren.

(3) Windows Exit ~3s nach "365 passed" — NICHT gelöst, eigenständiges Problem gefunden

Lokal reproduziert: PYTEST_EXIT=-1073740791 (STATUS_STACK_BUFFER_OVERRUN, ein von Windows erkannter GS-Cookie-Stapelschaden) sowohl mit als auch ohne das neue conftest.py — identisch. Das schließt die Qt-Teardown-Reihenfolge als alleinige Ursache aus. Auch ein erzwungener früher Prozess-Exit aus pytest_unconfigure (erst os._exit(), dann Win32 TerminateProcess() via ctypes) hat es nicht verhindert — der Crash verschob sich nur zeitlich, dieselbe native Stapelschaden-Fault trat trotzdem auf. Das deutet auf einen echten Speicherfehler in einer nativen Abhängigkeit (fitz/PyMuPDF oder openpyxl, die beiden C-Extension-lastigen Bibliotheken dieser Suite) statt auf ein reines Qt-Teardown-Problem hin. Eine Bisektion über die 58 Testdateien zur Lokalisierung sprengt den Rahmen dieses PRs — bitte als eigenständiges Ticket führen. Blockiert 1.0.7 laut deiner Einschätzung nicht.

Verifikation

Inhaltlich weiterhin "365 passed, 2 skipped" (mehrfach reproduziert) — nur der Exit-Code-Schwanz auf Windows ist offen, siehe (3).

(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).
@github-actions

Copy link
Copy Markdown

Welcome! Thanks for your first pull request in this repository.

A maintainer will review it soon. Please make sure:

  • Your changes are tested
  • Documentation is updated if needed
  • The PR description explains what changed and why

Thanks for contributing.

…e 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.
@lukisch

lukisch commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator Author

Nachtrag (Commit 2972356): CI-Analyse fand einen ECHTEN, unabhaengigen Bug (nicht das native-Crash-Thema): test_case_only_rename_is_actionable_not_unchanged und test_batch_rename_dialog_case_mode_and_execution schlugen auf macOS fehl (collision statt ok). Ursache: os.path.normcase() ist ein reiner Pfad-STRING-Vergleich nach OS-Konvention (Windows lowercase, POSIX No-Op) und kennt die tatsaechliche Dateisystem-Case-Sensitivitaet nicht -- macOS hat POSIX-Pfadkonvention (normcase No-Op) aber standardmaessig case-insensitives APFS, sodass sample.txt/SAMPLE.txt auf FS-Ebene identisch sind, obwohl ihre normcase-Strings differieren. Fix: zusaetzliche os.path.samefile()-Pruefung (Inode/Device-Identitaet) als Ergaenzung zur normcase-Pruefung in generate_preview(). Windows/Linux unveraendert (dort loeste der alte Check schon korrekt). 22 gezielte + 365 volle Tests lokal gruen.

Zum nativen Windows/Linux/macOS-Crash (STATUS_STACK_BUFFER_OVERRUN bzw. Qt-Teardown-Segfault): eigenes Ticket T-20260926-987151525 angelegt, keine Bisektion in diesem PR. Ubuntu-Log dieses PRs zeigt weiterhin 'QObject: shared QObject was deleted directly' + Segfault NACH '365 passed' trotz tests/conftest.py -- mein Fix dort ist also nicht wirksam, das gehoert ebenfalls ins Ticket.

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.
@lukisch

lukisch commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator Author

Nachtrag (Commit 5b8bb0c): tests/conftest.py entfernt (Revert-Commit, kein Force-Push) -- der Qt-Teardown-Fix war nachweislich wirkungslos (CI auf d4e5ec2/2972356 zeigte den identischen Segfault trotz Datei). Drin bleiben: platform-safe os.startfile/subprocess.Popen-Mock und der os.path.samefile()-Fix fuer Case-only-Renames -- beide unabhaengig als wirksam verifiziert. samefile() ist bereits gegen fehlende Zieldatei abgesichert (doppelter os.path.exists()-Guard vor dem Aufruf plus try/except OSError als zusaetzliches Netz).

CI-Nachlauf nach dem Revert (36253822709): alle 9 Legs rot, aber jedes einzeln mit '365 passed, 2 skipped' gefolgt vom bekannten, bereits auf master vorbestehenden Crash (Segfault/Abort/exit 1 je nach OS) -- keine inhaltlichen Testfehler, keine neue Regression durch den Revert. Beleg dass der Crash auf master vorbesteht: Lauf 36249100516 (PR#5-Merge, vor jedem meiner Fixes) zeigt denselben Crash z. B. auf ubuntu-latest/Python 3.12. Der Crash ist flakig (schwankende Anzahl betroffener Legs zwischen Laeufen derselben Commits) -- die fruehere Vermutung einer reinen Python-3.11-Signatur war eine Momentaufnahme und ist widerlegt; siehe Ticket T-20260926-987151525 fuer den vollstaendigen, korrigierten Befund.

Damit: Motiv-/Icon-Fix (#4), Kontextmenue-Fix (#1), CI-Pillow-Fix (#5) und dieser PR (#6, os.startfile-Portabilitaet + macOS-Case-Rename-Fix) sind inhaltlich vollstaendig; der verbleibende Crash gehoert zu T-20260926-987151525 und existiert unabhaengig von diesem PR bereits auf master. @merge-reviewer bitte pruefen.

@lukisch

lukisch commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator Author

Auf Team-Lead-Entscheid aufgeteilt (merge-reviewer hatte fuer PR#6 unter Windows eine stabile Verschlechterung gemessen, 0/9 vs. 6/9 gruen auf master, p≈0,01 -- zurueckgefuehrt auf den bereits revertierten conftest.py-Versuch): #7 (os.startfile-Mock) und #8 (samefile-Fix). Schliesse diesen PR.

@lukisch lukisch closed this Sep 26, 2026
lukisch added a commit that referenced this pull request Sep 26, 2026
Split out of PR #6 at team-lead's request (measured Windows regression
there was traced to the conftest.py Qt-teardown attempt, reverted; this
fix is unrelated and independently verified).

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).

Co-authored-by: Lukas Geiger <lukas@um-bruch.org>
lukisch added a commit that referenced this pull request Sep 26, 2026
…e FS (#8)

Split out of PR #6 at team-lead's request (measured Windows regression
there was traced to the conftest.py Qt-teardown attempt, reverted; this
fix is unrelated and independently verified).

test_case_only_rename_is_actionable_not_unchanged and
test_batch_rename_dialog_case_mode_and_execution failed on macOS
(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.

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. Guarded
against a not-yet-existing target with a double os.path.exists() check
before the call plus try/except OSError as a safety net.

Co-authored-by: Lukas Geiger <lukas@um-bruch.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant