From dfdc853eeb499775febf22d878735a80beca5a36 Mon Sep 17 00:00:00 2001 From: Mark Jajeh Date: Wed, 5 Aug 2026 13:19:06 -0700 Subject: [PATCH 1/3] fix(ci): pin uv to the matrix interpreter; de-flake the webengine resize test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test job synced with --python ${{ matrix.python }} but ran pytest with a bare 'uv run', which re-resolved against .python-version (3.11) and rebuilt a bare venv without dev deps — 'Failed to spawn: pytest' on 3.12/3.13. UV_PYTHON now pins every uv invocation to the matrix interpreter and pytest runs with --no-sync against the venv the sync step prepared. test_plotly_backend_nudges_after_a_qt_resize flaked on CI at 2 s: a cold runner spends seconds spinning up QtWebEngine on the GUI thread before the resize debounce can be processed. Settle after show; wait up to 10 s. --- .github/workflows/ci.yml | 12 +++++++++--- tests/qtviz/test_resize_tracking.py | 5 ++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e127b7e..0ea5b6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,12 @@ jobs: fail-fast: false matrix: python: ["3.11", "3.12", "3.13"] + env: + # .python-version pins 3.11 for local dev; without this, `uv run` in the + # 3.12/3.13 jobs re-resolves to 3.11 and rebuilds a bare venv without + # dev deps ("Failed to spawn: pytest"). Pin every uv invocation to the + # matrix interpreter instead. + UV_PYTHON: ${{ matrix.python }} steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v4 @@ -52,13 +58,13 @@ jobs: libfontconfig1 libnss3 libasound2t64 libxcomposite1 libxdamage1 \ libxrandr2 libxtst6 libxkbfile1 - name: Install (all extras) - run: uv sync --frozen --all-extras --python ${{ matrix.python }} + run: uv sync --frozen --all-extras - name: pytest (offscreen; coverage floor on 3.11) run: | if [ "${{ matrix.python }}" = "3.11" ]; then - uv run pytest -q --cov + uv run --no-sync pytest -q --cov else - uv run pytest -q + uv run --no-sync pytest -q fi docs: diff --git a/tests/qtviz/test_resize_tracking.py b/tests/qtviz/test_resize_tracking.py index 5680093..4d71dd6 100644 --- a/tests/qtviz/test_resize_tracking.py +++ b/tests/qtviz/test_resize_tracking.py @@ -104,7 +104,10 @@ def test_plotly_backend_nudges_after_a_qt_resize(qtbot): qtbot.addWidget(view) view.resize(300, 200) view.show() + qtbot.wait(50) # let Chromium construction/show settle before the resize view.resize(500, 400) + # 10 s: a cold CI runner spends seconds spinning up QtWebEngine on the GUI + # thread before the debounce timer can even be processed (2 s flaked there). qtbot.waitUntil( lambda: any(n == "plotly.resize" for n, _p in view._command_queue), - timeout=2000) + timeout=10000) From 8b5f65cde8a28db6c5ed4c87b86ad6a4af32f1ee Mon Sep 17 00:00:00 2001 From: Mark Jajeh Date: Wed, 5 Aug 2026 13:23:54 -0700 Subject: [PATCH 2/3] fix(2.0): [D151] link controller survives teardown-race trailing events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A throttled trailing RangeEvent rides a QTimer that outlives its subscription — it can deliver after the render is disposed (CI caught it: DisposedError raised inside the Qt event loop from _propagate). The controller now no-ops on a disposed handle and drops panes that die underneath propagation, alongside the existing KeyError drop. --- src/qtviz/core/_host.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/qtviz/core/_host.py b/src/qtviz/core/_host.py index 2695325..45c825e 100644 --- a/src/qtviz/core/_host.py +++ b/src/qtviz/core/_host.py @@ -98,7 +98,10 @@ def __init__(self, handle, x_groups, y_groups) -> None: self._sub = handle.event_bus.subscribe(RangeEvent, self._on_range) def _on_range(self, ev) -> None: - if self._syncing or ev.pane is None: + # A throttled *trailing* delivery rides a QTimer that outlives the + # subscription — it can land after the render is disposed. Dead + # renders don't link. + if self._syncing or ev.pane is None or self._handle.widget is None: return self._syncing = True try: @@ -118,16 +121,18 @@ def _propagate(self, origin: str, axis: str, rng, groups) -> None: for label in group: if label == origin: continue + from ..errors import DisposedError # noqa: PLC0415 + try: pane = self._handle.pane(label) - except KeyError: # the render changed underneath — drop - continue - cur = getattr(pane.capture(), f"{axis}_range") - if cur is not None and all( - math.isclose(a, b, rel_tol=1e-9, abs_tol=1e-12) - for a, b in zip(cur, rng, strict=True)): - continue # value guard: already there (async echo) - pane.set_range(**{axis: tuple(rng)}) + cur = getattr(pane.capture(), f"{axis}_range") + if cur is not None and all( + math.isclose(a, b, rel_tol=1e-9, abs_tol=1e-12) + for a, b in zip(cur, rng, strict=True)): + continue # value guard: already there (async echo) + pane.set_range(**{axis: tuple(rng)}) + except (KeyError, DisposedError): + continue # the render changed/died underneath — drop return # a pane belongs to exactly one group per axis def dispose(self) -> None: From 2500372d710737606df6f85d3f37be57982b9ec5 Mon Sep 17 00:00:00 2001 From: Mark Jajeh Date: Wed, 5 Aug 2026 13:28:18 -0700 Subject: [PATCH 3/3] fix(test): resize-nudge test observes send(), not the pre-ready queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whether the debounced plotly.resize lands in _command_queue or goes straight over the bridge depends on when Chromium's handshake completes — a race that flaked on CI both ways (queue never populated once the bridge was ready). Record send() calls instead; the wiring under test is resize → debounce → send, regardless of which path carries it. --- tests/qtviz/test_resize_tracking.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/qtviz/test_resize_tracking.py b/tests/qtviz/test_resize_tracking.py index 4d71dd6..22eb620 100644 --- a/tests/qtviz/test_resize_tracking.py +++ b/tests/qtviz/test_resize_tracking.py @@ -102,12 +102,22 @@ def test_plotly_backend_nudges_after_a_qt_resize(qtbot): view = PlotView(PlotlyBackend({"data": [], "layout": {}})) qtbot.addWidget(view) + # Observe the send itself, not the pre-ready queue: whether the command + # lands in `_command_queue` or goes straight over the bridge depends on + # when Chromium's handshake completes — a race that flaked on CI both + # ways. The wiring under test is resize → debounce → send(...). + sent: list[str] = [] + orig_send = view.send + + def send(name, payload=None): + sent.append(name) + orig_send(name, payload) + + view.send = send view.resize(300, 200) view.show() qtbot.wait(50) # let Chromium construction/show settle before the resize view.resize(500, 400) - # 10 s: a cold CI runner spends seconds spinning up QtWebEngine on the GUI - # thread before the debounce timer can even be processed (2 s flaked there). - qtbot.waitUntil( - lambda: any(n == "plotly.resize" for n, _p in view._command_queue), - timeout=10000) + # generous: a cold CI runner spends seconds spinning up QtWebEngine on the + # GUI thread before the debounce timer can even be processed. + qtbot.waitUntil(lambda: "plotly.resize" in sent, timeout=10000)