spacr.qt.gc_policy

Collect cyclic garbage on the GUI thread only.

Background: pressing Run in the live preview with cpsam produced, on 2026-09-01:

QObject::killTimer: Timers cannot be stopped from another thread
QObject::~QObject: Timers cannot be stopped from another thread
Segmentation fault (core dumped)

Those two lines, in that order, are reproduced exactly by tests/qt/test_gc_runs_on_the_gui_thread.py with no spaCR code involved at all: build a QObject that owns a running QTimer, drop it into a reference CYCLE so only the collector can free it, then call gc.collect() from a worker thread. The collector runs the destructor on whichever thread it happens to be running on, and Qt cannot stop a timer from there.

The defect is not specific to the preview. CPython runs an automatic collection whenever an allocation pushes a generation past its threshold, so the collection runs on the thread that allocated most recently. A Cellpose pass allocates heavily in a worker thread, so that worker is the likely thread to run the collection and therefore to destroy unrelated widgets the GUI thread released earlier. The resulting crash is timing-dependent, occurs away from the code that created the cycle, and names no file.

Automatic collection is switched off and driven from a QTimer instead. That timer lives on the GUI thread, so every destructor the collector runs is run there – which is the thread that owns the widgets.

Explicit pipeline cleanup uses spacr._gc.collect(), which requests a full sweep on the next GUI timer tick. gc.disable() stops only automatic collection, so third-party code calling the standard-library gc.collect directly can still collect on a worker. spacr.qt.thread_guard() reports wrong-thread widget destruction if it recurs.

Memory is still collected: each tick applies CPython’s own generational policy against the same thresholds, so collections happen about as often as they otherwise would, checked every INTERVAL_MS milliseconds on the GUI thread.

Functions

collect_once(→ int)

Run at most one generation's collection, CPython's own policy.

install(→ bool)

Take cyclic collection off the worker threads.

is_installed(→ bool)

Whether the GUI-thread collection policy is currently in force.

uninstall(→ bool)

Restore the interpreter's own collection policy.

Module Contents

spacr.qt.gc_policy.collect_once() → int[source]

Run at most one generation’s collection, CPython’s own policy.

The interpreter collects generation n when that generation’s count passes its threshold, youngest first, and collecting a generation also clears the younger ones. Reproducing that here – rather than calling a full gc.collect() on every tick – is what keeps the cost the same as it was before this module existed. A full sweep every second would walk every live numpy array in the process.

Explicit worker cleanup requests take precedence and collect all generations, even below their normal thresholds.

Returns:

the generation collected, or -1 when nothing was due.

spacr.qt.gc_policy.install(parent=None) → bool[source]

Take cyclic collection off the worker threads.

Parameters:

parent – a QObject to own the timer, normally the QApplication.

Returns:

whether the policy was installed. False when it already was, or when Qt is unavailable. Never raises; when installation fails the interpreter’s own collection policy stays in place.

spacr.qt.gc_policy.is_installed() → bool[source]

Whether the GUI-thread collection policy is currently in force.

spacr.qt.gc_policy.uninstall() → bool[source]

Restore the interpreter’s own collection policy.

Present for tests and for a clean shutdown: leaving automatic collection off in a process that has stopped pumping the event loop would mean cycles are never collected at all.