Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ jobs:
qt_plugin_path=$(uv run --no-sync python -c 'from PyQt5.QtCore import QLibraryInfo; print(QLibraryInfo.location(QLibraryInfo.PluginsPath) + "/platforms/libqxcb.so")')
ldd "$qt_plugin_path"
uv run --no-sync pytest -o addopts= scripts/tests/test_diagnose_graphics.py \
-k 'real_gl_controlled_faults or scene_requires_selection_redraw or xcb_glx_requires_matching'
-k 'real_gl_controlled_faults or scene_ or xcb_glx_requires_matching'
env:
DISPLAY: ':98.0'
QT_QPA_PLATFORM: xcb
Expand Down
3 changes: 3 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ behavior they verify rather than listed separately.

### Fixed

- Clean up synthetic scene windows before the graphics diagnostic exits, including
failed probes, instead of leaving native Qt window destruction to interpreter shutdown.

- Fit the Amplitude View to every finite displayed amplitude of the selected
clusters, with padding, instead of clipping the outer percentiles. Background
outliers no longer compress selected traces; existing spike budgets still apply.
Expand Down
12 changes: 12 additions & 0 deletions scripts/diagnose_graphics.py
Original file line number Diff line number Diff line change
Expand Up @@ -1254,6 +1254,7 @@ def scene_probe(app, artifact_dir):

import numpy as np
from phylib.utils import Bunch, connect, unconnect
from PyQt5 import sip
from PyQt5.QtCore import Qt
from PyQt5.QtTest import QTest

Expand Down Expand Up @@ -1425,6 +1426,17 @@ def snapshot(views, name, action=None, selected_clusters=None):
default_state_path=None,
size=(1050, 650),
)

def destroy_scene():
# close() only hides this QWidget. Destroy its native windows while
# QApplication is alive, rather than leaving them to SIP's interpreter
# shutdown. Register before the surface/context observers so their
# callbacks are removed first by run_child's reverse-order cleanup.
emit('stage', stage='scene-destroy-synthetic-gui')
if not sip.isdeleted(gui):
sip.delete(gui)

app._diagnostic_cleanups.append(destroy_scene)
supervisor = Supervisor(
np.repeat(np.arange(3), 6),
similarity=lambda cluster_id: [],
Expand Down
36 changes: 36 additions & 0 deletions scripts/tests/test_diagnose_graphics.py
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,42 @@ def test_scene_requires_selection_redraw_without_capture_request(
assert frames[0]['sha256'] != frames[1]['sha256']


@pytest.mark.skipif(
os.environ.get('PHY_DIAGNOSTIC_GL_TESTS') != '1'
or os.environ.get('QT_QPA_PLATFORM') in ('offscreen', 'minimal'),
reason='Opt-in validation against a real graphical session',
)
@pytest.mark.parametrize('fail_scene', [False, True])
def test_scene_destroys_native_windows_before_child_returns(tmp_path, fail_scene):
code = f"""import importlib.util
from PyQt5 import sip
from PyQt5.QtWidgets import QApplication
spec = importlib.util.spec_from_file_location('kit', {str(SCRIPT)!r})
kit = importlib.util.module_from_spec(spec); spec.loader.exec_module(kit)
canvases = []
original_watch, original_emit = kit.watch_surface, kit.emit
def watch(canvas, label):
canvases.append(canvas)
original_watch(canvas, label)
def emit(event, **fields):
original_emit(event, **fields)
if {fail_scene!r} and event == 'stage' and fields.get('stage') == 'scene-qt-table-click':
raise RuntimeError('injected scene failure')
kit.watch_surface, kit.emit = watch, emit
status = kit.main(['--_probe', 'scene', '--_artifacts', {str(tmp_path)!r}])
assert status == {1 if fail_scene else 0}
assert len(canvases) == 2
assert QApplication.instance() is not None
assert all(sip.isdeleted(canvas) for canvas in canvases), 'native canvases survived cleanup'
assert all(sip.isdeleted(canvas.diagnostic_main_window) for canvas in canvases)
print('native scene windows destroyed before child returns', flush=True)
"""
result, log = diagnostic.run_process([sys.executable, '-c', code], os.environ.copy(), 20)
assert result['returncode'] == 0, log[-4000:]
assert 'native scene windows destroyed before child returns' in log
assert result['details']['status'] == ('FAIL' if fail_scene else 'PASS')


@pytest.mark.skipif(
not sys.platform.startswith('linux')
or os.environ.get('PHY_DIAGNOSTIC_GL_TESTS') != '1'
Expand Down
Loading