spacr.qt.gc_policy

Collect cyclic garbage on the GUI thread only.

WHY THIS EXISTS, MEASURED. 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.

That is the whole bug, and nothing about it is specific to the preview. CPython runs an automatic collection whenever an allocation pushes a generation past its threshold – so the thread that pays for a collection is simply the thread that allocated most recently. A Cellpose pass allocates enormously in a worker thread, which makes that worker overwhelmingly likely to be the one that inherits the sweep, and therefore the one that destroys some unrelated widget the GUI thread abandoned earlier. The crash lands nowhere near the code that caused it, is timing-dependent, and names no file: it is the shape of defect that survives a long time.

WHAT THIS DOES. 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 NOT left to grow: the tick below reproduces CPython’s own generational policy against the same thresholds, so collection happens at the same frequency it otherwise would, on a different 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, because failing to install a mitigation must not be worse than the defect it mitigates.

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.