spacr.qt.stall_watch

Name the call that freezes the interface.

A stalled GUI thread leaves no traceback. It is not an exception and not a fault, so faulthandler does not report it and the log stops. Only a sample of the main thread’s stack, taken from another thread while it is stuck, names the call.

SPACR_WATCH_GUI_STALLS=1 spacr

Every time the event loop stops answering for longer than STALL_SECONDS, the exact Python stack of the GUI thread is written to stderr and appended to LOG_PATH. The last frame of that stack is the blocking call.

This lives in the package rather than in tools/. tools/watch_the_gui_thread.py does the same thing but has to build the QApplication itself to attach before any screen exists, which no longer works because spacr.qt.app.launch() constructs its own and Qt refuses a second.

Two other ways of attaching from outside do not work either. Hosting qt.run() inside another process makes Home itself time out at thirty seconds.

The other way was a sitecustomize on the startup benchmark path. It is imported, and what it changes is never the object that runs.

A flag read inside launch is the one place that cannot be missed, and it composes with every other driver – the benchmark included, which is what this was written for.

Cost when off: one os.environ.get. When on: a 100 ms timer on the GUI thread that increments an integer, and a daemon thread that compares two integers four times a second.

Functions

watch_this_application(app, *[, stall_seconds, echo])

Report the GUI thread's stack whenever it stops answering.

Module Contents

spacr.qt.stall_watch.watch_this_application(app, *, stall_seconds: float | None = None, echo: bool = True)[source]

Report the GUI thread’s stack whenever it stops answering.

Parameters:
  • app – the live QApplication. The heartbeat timer is parented to it so the timer lives exactly as long as the application does.

  • stall_seconds – override STALL_SECONDS for one call.

  • echo – also write each report to sys.stderr. Pass False under a harness that captures streams. The file is the record and stderr is optional: this writes from a daemon thread, and writing into a stream that a test harness is replacing crashed pytest inside its own capture.py.

Returns:

the watcher thread, or None if Qt could not be reached.

The heartbeat is the measurement. A QTimer on the GUI thread bumps an integer; a daemon thread watches the integer. If it stops moving the GUI thread is not running the event loop, which is exactly the condition being hunted – and the reason a timer cannot report it itself: a wedged loop does not deliver the timer either.

Nested helpers

watch_this_application._flush_samples() → None

Write what held the thread through the stall that just ended.

spacr/qt/stall_watch.py:199

watch_this_application._flush_when_the_stall_ends() → None

Summarise a stall the moment the loop answers again.

Summarising when the stall ends, rather than when the next stall begins, means the last stall of a session is summarised even if the process is then killed, for example after a hang on a sleeping autofs mount. The summary separates the call holding the thread from one that happened to be running when a sample was taken.

The heartbeat resuming is the end of the stall, so that is where the summary belongs. tick cannot do it, because it runs on the GUI thread, which was not running during the stall.

spacr/qt/stall_watch.py:216

watch_this_application._main_stack()

The GUI thread’s stack as text-only frame summaries, or None.

No frame object outlives this call. A frame held by the watcher keeps its function’s locals alive after the GUI thread has returned from it, and the watcher then drops them on its own thread: a QPainter made in a paintEvent stayed active past the paint (“QBackingStore::endPaint() called with active painter”) and was destroyed off the GUI thread, a segmentation fault in the next window repaint. Measured on the lesson 14 driver with a watcher that kept its last frame: 4 crashes in 6 runs.

spacr/qt/stall_watch.py:173

watch_this_application._where(stack) → str

The innermost frame of stack, as file:line function.

spacr/qt/stall_watch.py:191

watch_this_application.tick() → None

Record that the event loop is still turning.

spacr/qt/stall_watch.py:103

watch_this_application.watch() → None

Sample the GUI thread through each stall and summarise it.

One snapshot shows where the thread was, not where the time went; the two differ when the stall is a loop rather than a single blocking call. Measured: four stalls of the same module sweep gave four different last frames – an event filter, a screen constructor, a settings-search install, another event filter – which is a list of suspects rather than an answer.

So the stack is sampled every POLL_SECONDS for as long as the stall lasts, and the last frames are counted. A call that holds the thread appears in most samples; one that merely happened to be running appears in one.

spacr/qt/stall_watch.py:124

watch_this_application.watch_and_flush() → None

Run the watcher, and summarise whatever is pending on the way out.

spacr/qt/stall_watch.py:232