Skip to content

tests/ui occasionally wedges instead of failing, with no output and no crash #39

Closed

Description

@sensitron

Context

While diagnosing the intermittent access violation in tests/ui on 2026-09-04, a second, different
failure mode showed up three times: a pytest run wedged indefinitely — no output, no crash, no
progress — rather than terminating. Runs were bounded at 120 s during that work, so each was
recorded as a hang and abandoned rather than waited out.

It happened once on an otherwise unmodified tree, so it is not specific to any branch.

This is filed separately, and deliberately, because the access violation next to it spent a long
time mislabelled as "a known PyAV crash in roughly 3 of 25 runs" and was therefore never
investigated. It turned out to have nothing to do with PyAV: RenderController.shutdown() destroyed
every widget it owns except the job dialog, which outlives its job to show the completion summary
and, having no dialog parent, was left to Python's garbage collector — so a QWidget could be
destroyed from inside a running event loop. That one is fixed (ebf51b2 on
feat/issue-25-transform-stage, measured 1 crash / 9 runs before, 0 / 6 after, 2 / 4 with the
teardown removed again as a control).

The hang has no such explanation, and an unexplained failure mode without an issue tends to become
the next piece of folklore.

What is known

  • Three occurrences during roughly two hours of repeated runs of tests/ui, at least one of them on
    a tree with no source changes.
  • It did not recur in the final eight consecutive runs after ebf51b2. That is not evidence it is
    gone
    — the crash it sits next to also went from 11% to 100% and back purely on allocation
    timing, and eight runs cannot distinguish "fixed" from "rare again".
  • Plausibly the other face of the same shutdown-ordering area, since that is where the widget and
    thread teardown of a job lives: a hang is what a QThread.wait() or an event loop that never
    returns looks like, where a use-after-free looks like the access violation. This is a hypothesis,
    not a finding — nobody has captured a stack from a wedged run.

What would settle it

The cheap first step is to capture the state of a wedged process rather than killing it: a thread
dump (faulthandler.dump_traceback_later, or attaching to the stuck process) would say immediately
whether it is parked in a wait(), in app.exec, or somewhere unrelated. logs.py already enables
faulthandler for the application; the test runs do not.

Running tests/ui in a loop under a timeout that dumps instead of killing would likely collect that
within an hour, given the observed rate.

Why it matters

An intermittent hang is worse than an intermittent crash in CI: a crashed run fails fast and is
retried, a wedged one burns the job's whole time budget before anything is reported, and locally it
costs a person the time before they notice.

Activity

  1. self-assigned this
    on Sep 4, 2026
  2. sensitron commented on Sep 5, 2026

    @sensitron
    CollaboratorAuthor

    Measured, not fixed, per this issue's own framing. Added an opt-in
    faulthandler.dump_traceback_later() switch to tests/ui/conftest.py
    (MATTELOOP_TEST_HANG_DUMP_SECONDS, off by default, normal suite
    unaffected) and ran tests/ui in a loop, so a hang gets a stack dump
    instead of being killed blind the way the three prior sightings were.

    122 runs, Linux (devbox), QT_QPA_PLATFORM=offscreen,
    uv run pytest -q tests/ui, 2026-09-04T22:30:33Z – 2026-09-05T00:43:05Z.

    Result: reproduced, 7/7 identical. Every one of 7 hangs (runs 4, 19, 43,
    105, 110, 114, 122 in the loop) produced the exact same stack, parked in the
    exact same test:

    tests/ui/test_preview_controller.py::test_cancel_keeps_modal_dialog_open_until_the_safe_checkpoint,
    line 289:

    qtbot.waitUntil(lambda: store.state.job.phase.value == "idle", timeout=5000)

    That call's own 5s timeout never fired: the main thread was still parked
    inside qt_compat.py:160 exec() (the Qt event loop pytest-qt pumps while
    waiting) 45s later (faulthandler dump) and 900s later (still there when the
    loop's outer watchdog killed the process). While parked in that pump, a
    queued notification was being delivered synchronously:
    PreviewController._notification -> _terminal_notification ->
    store.dispatch -> MainWindow.render_state -> _render ->
    ResultPlayerCanvas.set_presented_frame ->
    PreviewCanvas.set_presented_frame -> PreviewCanvas.set_frame, where the
    visible Python frame sits at self.clear() / self.setText() (adjacent
    lines 161/162) every single time. faulthandler only shows Python frames, so
    the actual block is one level deeper, inside that Qt/PySide call. A second
    thread is present with <no Python frame> -- i.e. not executing Python at
    the moment of the dump.

    In this issue's own vocabulary: this parks in the "event loop that never
    returns" bucket (app.exec()-equivalent, via pytest-qt's pump) -- not a
    QThread.wait() visible in Python, and not somewhere unrelated to the
    hypothesis.

    Bonus finding -- the known crash, same shape, different test: 2 of the
    122 runs (1, 111) hit the already-known Fatal Python error: Segmentation fault instead (caught by pytest's own always-on faulthandler.enable(),
    independent of the switch above), both in
    test_render_controller.py::test_cancelled_render_closes_without_showing_completion_summary
    -- also parked inside a qtbot.waitUntil pumping qt_compat.py exec() at
    the moment of the fault, also a "cancel this operation" test. The crash's
    visible frame is shallower than the hang's (right at exec(), no deeper
    dispatch frames), so this supports the issue's "same shutdown-ordering bug,
    two faces" hypothesis structurally -- both are a cancel test caught
    mid-pump, paired with a second thread with no Python frame -- without
    proving it's the identical fault.

    Platform caveat, stated plainly: this was measured on Linux; the
    original sighting was on Windows. A Linux reproduction does not prove the
    Windows hang is the same bug -- different Qt platform plugin, different
    thread/signal timing. What it does show: the hang is real, present, and
    reproducible on at least one platform, and it clusters tightly on one
    specific "cancel while an event-loop pump is in flight" test rather than
    being a diffuse, evenly-distributed flake. The observed rate here (7/122,
    ~6%) should not be read as a stable base rate either way -- this issue's
    own AV crash swung from 11% to 100% and back between otherwise-identical
    runs purely from allocation timing, so treat "how often" as noise and "where"
    as the finding.

    Second bonus finding -- a clean, one-off shutdown-ordering exception:
    run 54 neither hung nor crashed -- it failed cleanly, with a full Python
    traceback, in a third test
    (test_preview_controller.py::test_preview_request_uses_the_selected_oriented_crop):

        def shutdown(self) -> None:
            ...
            threads = tuple(self._threads.items())
            for _request_id, (thread, _load_worker) in threads:
    >           thread.quit()
    E           RuntimeError: Internal C++ object (PySide6.QtCore.QThread) already deleted.
    
    src/matteloop/ui/controller.py:338: RuntimeError
    

    SourceController.shutdown() iterates self._threads and calls .quit()
    on each; here one thread's underlying C++ QThread had already been
    deleted by the time this run got there. Happened exactly once in 122 runs,
    not investigated further (out of scope), but it's the same "who tears down
    what, in what order" shape as the hang and the crash above, this time on a
    stale reference surviving an async deletion rather than a stuck event-loop
    pump or a native fault.

    Side note found along the way: the CI windows job never runs
    tests/ui at all (excluded because test_render_controller crashes it
    with the access violation this issue's diagnosis grew out of) -- so a green
    Windows CI has never exercised this code path, in either direction.

    The curated stacks and the per-run protocol follow in the next comment. The raw
    5.5 MB faulthandler dump and the per-run event logs lived on a throwaway
    measurement branch and are not preserved; the switch that produces them is in
    the pull request referencing this issue, so they can be regenerated with
    MATTELOOP_TEST_HANG_DUMP_SECONDS=45.

  3. sensitron commented on Sep 5, 2026

    @sensitron
    CollaboratorAuthor

    Issue #39 — curated hang evidence

    The measurement produced a 5.3 MB faulthandler dump and a per-run protocol on a
    throwaway measurement branch that has since been deleted. This is the distilled
    version: the 9 anomalous runs out of 122, and the actual stack content. The raw
    material can be regenerated with MATTELOOP_TEST_HANG_DUMP_SECONDS=45 using the
    switch added in the pull request that references this issue.

    The hang: 7/7 identical, always the same test

    Every one of the 7 runs that hit the outer 900s timeout produced a
    faulthandler dump, and all seven land on the exact same test and the exact
    same call chain
    — only the thread IDs and one call-site line (161 vs 162,
    two adjacent statements in the same method) differ:

    run start (UTC)
    4 2026-09-04T22:31:12
    19 2026-09-04T22:49:37
    43 2026-09-04T23:10:18
    105 2026-09-04T23:39:50
    110 2026-09-04T23:55:47
    114 2026-09-05T00:11:26
    122 2026-09-05T00:28:05

    Test: tests/ui/test_preview_controller.py::test_cancel_keeps_modal_dialog_open_until_the_safe_checkpoint
    (both [True] and [False] parametrizations occur across the 7).

    Representative stack (run 4, first occurrence):

    Timeout (0:00:45)!
    Thread 0x00007fb149b096c0 (most recent call first):
      <no Python frame>
    
    Thread 0x00007fb173fc7780 (most recent call first):
      File ".../src/matteloop/ui/preview_canvas.py", line 162 in set_frame
      File ".../src/matteloop/ui/preview_canvas.py", line 173 in set_presented_frame
      File ".../src/matteloop/ui/result_player.py", line 244 in set_presented_frame
      File ".../src/matteloop/ui/main_window.py", line 276 in _render
      File ".../src/matteloop/ui/main_window.py", line 252 in render_state
      File ".../src/matteloop/ui/store.py", line 24 in dispatch
      File ".../tests/ui/test_preview_controller.py", line 132 in dispatch
      File ".../src/matteloop/ui/preview_controller/controller.py", line 302 in _terminal_notification
      File ".../src/matteloop/ui/preview_controller/controller.py", line 262 in _notification
      File ".../.venv/lib/python3.13/site-packages/pytestqt/qt_compat.py", line 160 in exec
      File ".../.venv/lib/python3.13/site-packages/pytestqt/wait_signal.py", line 58 in wait
      File ".../.venv/lib/python3.13/site-packages/pytestqt/qtbot.py", line 503 in wait
      File ".../.venv/lib/python3.13/site-packages/pytestqt/qtbot.py", line 600 in waitUntil
      File ".../tests/ui/test_preview_controller.py", line 289 in test_cancel_keeps_modal_dialog_open_until_the_safe_checkpoint
      ... (pytest internals below, identical every time)
    

    Reading this

    test_cancel_keeps_modal_dialog_open_until_the_safe_checkpoint line 289 is:

    qtbot.waitUntil(lambda: store.state.job.phase.value == "idle", timeout=5000)

    That call carries its own 5000ms timeout. The process was still parked here
    45 seconds later (dump) and 900 seconds later (outer kill), which means
    waitUntil's own timeout enforcement never got a chance to run: the call
    chain shows the main thread inside qt_compat.py:160 exec() — the Qt event
    loop pytest-qt pumps while waiting — synchronously delivering a queued
    notification (PreviewController._notification -> _terminal_notification)
    that dispatches through the reducer/store into MainWindow.render_state,
    down to PreviewCanvas.set_frame, where the Python-visible frame sits at
    self.clear() / self.setText() (adjacent lines 161/162, both native
    PySide6/Qt calls) every single time.

    Faulthandler only shows Python frames, so the actual block is one level
    below what's visible here, inside the Qt/PySide call. The second thread
    shows <no Python frame> -- consistent with a native (Qt-internal) thread
    holding whatever the main thread's Qt call is waiting on, but that's an
    inference from the shape of the dump, not something the dump proves by
    itself.

    In the issue's own vocabulary: this is squarely the "event loop that never
    returns" bucket (app.exec()-equivalent via pytest-qt's pump), not a
    QThread.wait() call visible in Python, and not somewhere unrelated to the
    hypothesis.

    Bonus finding: the known crash, same shape, different test

    Two runs (1 and 111) crashed instead of hanging -- Fatal Python error: Segmentation fault, pytest's own built-in faulthandler catching it (this is
    independent of the MATTELOOP_TEST_HANG_DUMP_SECONDS switch; pytest enables
    faulthandler.enable() unconditionally). Both land in
    tests/ui/test_render_controller.py::test_cancelled_render_closes_without_showing_completion_summary,
    also while pumping the event loop inside a qtbot.waitUntil:

    Fatal Python error: Segmentation fault
    
    Current thread 0x00007effba30a6c0 (most recent call first):
      <no Python frame>
    
    Thread 0x00007effec7fe780 (most recent call first):
      File ".../pytestqt/qt_compat.py", line 160 in exec
      File ".../pytestqt/wait_signal.py", line 58 in wait
      File ".../pytestqt/qtbot.py", line 503 in wait
      File ".../pytestqt/qtbot.py", line 600 in waitUntil
      File ".../tests/ui/test_render_controller.py", line 397 in test_cancelled_render_closes_without_showing_completion_summary
      ... (pytest internals below, identical every time)
    

    Note the crash's Python-visible frame is shallower than the hang's --
    it stops right at qt_compat.py:160 exec(), with none of the deeper
    dispatch/render frames the hang shows. So this is not proof of an identical
    root cause, only a structural family resemblance: both anomalies are a
    "cancel this operation" test, both caught mid-pump inside the same
    pytest-qt event-loop-exec call, both paired with a second thread carrying
    no Python frame. That resemblance is exactly what Issue #39 predicted --
    crash and hang as two faces of the same shutdown-ordering problem -- but it
    is evidence for the hypothesis, not proof of it.

    (Runs 1 and 111 in the loop; the raw captures were on the deleted measurement
    branch.)

    Second bonus finding: a clean, one-off shutdown-ordering exception

    Run 54 (2026-09-04T23:27:43Z) neither hung nor crashed -- it failed cleanly,
    with a full Python traceback, in
    tests/ui/test_preview_controller.py::test_preview_request_uses_the_selected_oriented_crop:

    self = <matteloop.ui.controller.SourceController(0x178868f0) at 0x7f8f40210700>
    
        def shutdown(self) -> None:
            ...
            threads = tuple(self._threads.items())
            for _request_id, (thread, _load_worker) in threads:
    >           thread.quit()
    E           RuntimeError: Internal C++ object (PySide6.QtCore.QThread) already deleted.
    
    src/matteloop/ui/controller.py:338: RuntimeError
    

    This happened exactly once across all 122 runs. It's a third, independent
    data point for the same theme: SourceController.shutdown()
    (src/matteloop/ui/controller.py:320-340) iterates self._threads and
    calls .quit() on each, but by the time this particular run got there, one
    thread's underlying C++ QThread had already been deleted -- consistent
    with the thread having already been torn down and removed elsewhere (e.g.
    via a finished-signal callback) while self._threads still held a
    reference to it. Not investigated further -- out of scope for a measurement
    task -- but it's the same "who tears down what, and in what order" shape as
    the hang and the crash, on a third mechanism (a stale reference used after
    async deletion, rather than a stuck event-loop pump or a native fault).

  4. smb commented on Sep 5, 2026

    @smb
    Collaborator

    This reproduced in CI today, not just locally, and the numbers are unusually
    clean — so recording them here as evidence. I am not picking the issue up; it is
    yours.

    Occurrence: run 33952768746, job test (ui), on fix/english-provider-labels.
    Wedged in step 6, uv run pytest -q tests/ui, for 1 h 47 min before I
    cancelled it. Every other job in the same run finished normally (lint 18 s,
    test (core) 41 s, test (jobs) 81 s, test (packaging) 36 s, swscale 148 s,
    windows 228 s).

    Baseline: across the twelve most recent ci.yml runs, test (ui) takes
    between 41 s and 63 s. There is no slow-but-finishing case in that sample — it
    either takes under a minute or it never returns.

    It is not the branch and it is not the code. Two data points, both from
    today:

    • The first run on that same branch passed in 41 s. The wedge was the second
      run, after one more commit.
    • docs/issue-43-screenshots contains both of that branch's commits verbatim,
      plus more, and its test (ui) passed in 50 s an hour and a half later.

    So the same tree passes and wedges depending on nothing observable, which
    matches your note that it happened once on an otherwise unmodified tree.

    No log is retrievable from a wedged job. GET /actions/jobs/<id>/logs
    returns BlobNotFound while the job is in progress, so there is nothing to read
    unless the job is allowed to run into the 6-hour default timeout — at which
    point GitHub kills it and the log still shows only the last line before the
    wedge.

    That is worth fixing regardless of the root cause. A job-level
    timeout-minutes well under six hours would stop a wedge from occupying a
    runner for a working day and blocking a required check. And pytest-timeout
    with --timeout-method=thread would turn the wedge into a failure carrying a
    traceback of every thread, which is exactly the evidence this issue currently
    cannot obtain.

    Separately, and possibly unrelated: a local pytest -q run on macOS failed once
    today, once only, and four immediate re-runs were clean. That was a failure,
    not a wedge, and I could not capture which test — noting it only so it is not
    silently folded into this issue if it turns out to be a third mode.

  5. smb commented on Sep 5, 2026

    @smb
    Collaborator

    The same job crashed instead of wedging, and left a stack behind. First
    concrete evidence in this issue, so recording it here.

    Run: https://github.com/smb/MatteLoop/actions/runs/33990641196/job/101372128654
    (the test (ui) job of a pull request whose change touches neither the UI nor
    the render path). Exit code 139, Fatal Python error: Segmentation fault,
    after roughly 41 seconds.

    Current thread 0x00007fd6bbfff640 (most recent call first):
      <no Python frame>
    
    Thread 0x00007fd6faaee740 (most recent call first):
      pytestqt/qt_compat.py:160 in exec
      pytestqt/wait_signal.py:58 in wait
      pytestqt/qtbot.py:503 in wait
      pytestqt/qtbot.py:600 in waitUntil
      tests/ui/test_render_controller.py:398 in
        test_cancelled_render_closes_without_showing_completion_summary
    

    The crashing thread is not the main thread and carries no Python frame, so the
    fault is inside Qt or a native extension while the main thread sits in the
    event loop of qtbot.waitUntil. Line 398 is the wait that follows the click on
    the cancel button:

    qtbot.mouseClick(dialog.cancel_button, Qt.MouseButton.LeftButton)
    qtbot.waitUntil(
        lambda: store.state.job.phase.value == "idle" and not dialog.isVisible(),
        timeout=5000,
    )

    This narrows the issue in two ways. It is the cancel path, not the test
    suite at large — which lines up with #64, where close() blocks on
    _operation_lock while a worker is still inside an operation. And the failure
    mode is not only a hang: the same site can take the process down. The issue
    title says "with no output and no crash"; that is now known to be one of two
    outcomes.

    Reproduction remains stubborn. The test passed eight consecutive local runs
    (tests/ui/test_render_controller.py, 15 passed each time), the full tests/ui
    suite passes locally (339 passed), and an unchanged rerun of the same commit
    went green. So it stays a race, but it is a race with an address now.

    The job also ran for its full length before dying rather than being killed, and
    no CI job in this repository declares timeout-minutes — a wedged run inherits
    GitHub's six-hour default. A separate change adds per-job limits so the hang
    form of this defect fails in minutes instead.

  6. smb commented on Sep 5, 2026

    @smb
    Collaborator

    Reproduced 27 minutes later, same test, same line, same signal — on a branch
    whose entire diff is four timeout-minutes: lines in .github/workflows/ci.yml.

    https://github.com/smb/MatteLoop/actions/runs/33991848683/job/101375363196

    Fatal Python error: Segmentation fault
    tests/ui/test_render_controller.py:398 in
      test_cancelled_render_closes_without_showing_completion_summary
    Process completed with exit code 139
    

    That branch changes no Python at all, so nothing about the product code can be
    the cause. Together with the earlier crash on an unrelated source-probe branch,
    the failure is now twice in roughly four test (ui) runs on this runner image
    within one hour, against a test that had been passing for weeks. Either the
    race has always been this likely and simply landed on a quiet stretch of green
    runs, or something in the environment changed recently.

    Both occurrences are the same waiting site: after the click on cancel, while
    qtbot.waitUntil runs the event loop and a worker thread is still shutting
    down.

  7. smb commented on Sep 5, 2026

    @smb
    Collaborator

    Reopened. This was closed by the merge of #71, which only added job timeouts and explicitly does not address the defect — a commit body there contained the phrase "does not fix #39", and GitHub's keyword parser matched "fix #39" without reading the negation. No decision was made to close it.

  8. reopened this on Sep 5, 2026
  9. self-assigned this
    on Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions