Skip to content

Segfault in test_key_hold: CanvasHud timer lambda runs after the collector clears it #1614

Open

Description

@kurtwindisch

Summary

make test crashes with a segfault in tests/test_key_hold.py. The crash comes from the lambda on the CanvasHud message timer (negpy/desktop/view/canvas/hud.py:126). It is not a flaky test: 5 of 5 logged runs of pytest tests/ --cov=negpy --cov-report=term-missing crashed at the same line on macOS, and main crashed there too.

tests/test_key_hold.py .Fatal Python error: Segmentation fault
Current thread (most recent call first):
  File "negpy/desktop/view/canvas/hud.py", line ??? in <lambda>
  File "tests/test_key_hold.py", line 43 in test_long_press_holds_until_release

The native stack is QTest::qWait → QTimer::timerEvent → PyQtSlot::call → _PyEval_EvalFrameDefault, with KERN_INVALID_ADDRESS at 0x18.

Cause

The lambda closes over self, and the garbage collector can clear it while the Qt timer that calls it is still alive.

  1. test_hud_message_wrap.py leaves a HUD with a 5 second message timer in a reference cycle (hud._keepalive = parent).
  2. A collection runs on a background thread. In a crashing run it was Thread-85, during test_hue_trim.py, the next file. It clears the lambda's closure, then PyQt releases the wrapper. On the wrong thread, PyQt calls deleteLater() and does not delete the C++ widget.
  3. The next tests do not run the Qt event loop, so the timer expires and the deferred delete waits.
  4. QTest.qWait in test_key_hold.py is the next event loop spin. Due timers run before the deferred delete, so the timer calls the lambda. The lambda reads a NULL closure: 0x18 is the offset of a tuple's items.

--cov only moves the timing, so this can happen without it.

Reproduction

This crashes with exit code 139 in 3 of 3 runs. Replace the lambda with a bound method, or call gc.collect() on the main thread, and it does not crash.

import gc, threading, time
from PyQt6.QtTest import QTest
from PyQt6.QtWidgets import QApplication, QWidget
from negpy.desktop.view.canvas.hud import CanvasHud

app = QApplication([])
gc.disable()

def make():
    parent = QWidget()
    hud = CanvasHud(parent)
    hud.showMessage("x", 20)
    hud._keepalive = parent

make()
thread = threading.Thread(target=gc.collect)
thread.start()
thread.join()
time.sleep(0.05)
QTest.qWait(30)

Fix

Connect the timer to a bound method. PyQt does not hold a closure for a bound method, so the collector has nothing to clear. The _progress_timer beside it does this already. pick_better_window.py:109 has the same lambda on a timer.

I have no report of this crash in the running app. A HUD normally lives as long as the window, but the pattern is unsafe for any object that a background thread can collect.

🤖 Generated with Claude Code

Activity

  1. kurtwindisch commented on Nov 5, 2026

    @kurtwindisch
    ContributorAuthor

    /assign

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions