spacr.qt.job_runner

One correct way to run a callable off the GUI thread.

The Qt layer had eleven copies of this object – DbBrowserScreen._run_job, PlateViewScreen._run_job, ReportScreen._start_job and so on – and the copies did not agree. Two of them wired thread.finished to a closure, which silently never retires the job (see _retire_finished_jobs() for why), and that bug reached production twice. This module is the shape those screens converged on, written down once, so a screen that needs to stop blocking the GUI thread does not have to re-derive the rules.

The rules, which are not obvious and are all load-bearing:

  • PipelineWorker.finished is emitted on the worker thread. A closure connected to it is invoked there. Touching a widget from it is undefined behaviour. The only safe thing a slot on that signal may do is re-emit a Signal whose receiver is a bound method of a GUI-thread object – Qt then queues the call onto the GUI thread.

  • QThread.finished must be connected to a bound method of a GUI-thread QObject, never a closure. PySide6 makes the QThread itself the receiver for a closure, and spacr.qt.bridge.make_thread() connects thread.finished -> thread.deleteLater first. Slots run in connection order, so the DeferredDelete is posted ahead of the closure’s metacall and Qt discards queued events for a destroyed receiver: the job is never retired and active_jobs() never returns to zero.

  • A strong reference to both the QThread and the worker must be held until thread.finished. A QThread garbage-collected while running aborts the process.

  • The completion handler must run for every job, including one whose result is no longer wanted, or the bookkeeping leaks and the screen is permanently “busy”. Whether the result is used is a separate decision, taken from the generation counter – see cancel().

Everything submitted here goes through make_thread, so it appears in the process-wide spacr.qt.bridge.registry() and turns the background activity spinner (spacr.qt.widgets.activity_spinner) without the caller doing anything.

Classes

JobRunner

Run callables off the GUI thread; deliver their results on it.

Functions

shutdown_all(→ int)

Stop every live JobRunner. Returns how many were asked.

Module Contents

class spacr.qt.job_runner.JobRunner(parent: PySide6.QtCore.QObject | None = None, *, threaded: bool = True, app_key: str = '', user_visible: bool = True)[source]

Bases: PySide6.QtCore.QObject

Run callables off the GUI thread; deliver their results on it.

Parameters:
  • parent – the widget that owns the work. Kept as the runner’s Qt parent so the runner dies with it.

  • threaded – False runs every job inline, emitting the same signals in the same order, so a test can drive a screen synchronously without the behaviour diverging.

  • app_key – the name jobs appear under in the run registry, and so in the activity spinner’s tooltip.

  • user_visible – False for housekeeping the user did not start. Such a job still turns the activity spinner – something IS running – but never claims a run banner. The usage poller submits every two seconds; without this Home flashes “<module> usage - running” on and off for as long as a module screen is open.

Create a runner for one widget’s background work.

It registers itself the moment it exists, so a runner cannot be created and then missed by the quit-time drain; the registration is weak, so it holds nothing alive that Qt would otherwise collect.

Parameters:
  • parent – the owning object, or None.

  • threaded – run jobs on a worker thread. False runs each one inline, emitting the same signals in the same order.

  • app_key – how this runner’s work is named in the run registry.

  • user_visible – whether these jobs count as runs the user started. Set False for housekeeping, or Home’s run banner announces work nobody asked for.

active_jobs() → int[source]

How many worker threads are still winding down.

cancel() → None[source]

Abandon the results of everything in flight.

The threads are asked to stop and are then left to retire themselves; they are not joined, because joining on the GUI thread is the freeze this class exists to remove. Their results are dropped on arrival by the generation check, so nothing reaches a handler that may be about to be destroyed.

is_busy() → bool[source]

True while a submitted job has not yet delivered its result.

pending_jobs() → int[source]

How many results have not been delivered yet.

shutdown(timeout_ms: int = 3000) → None[source]

Cancel, then wait briefly so no QThread outlives the widget.

Call from closeEvent. Qt aborts the process if a running QThread is destroyed, so a bounded wait here is the price of leaving a screen mid-load. Threads that outlast the budget are parked by spacr.qt.bridge.drain_thread() rather than terminated.

submit(fn: Callable[[], Any], on_done: Callable[[Any], None] | None = None) → bool[source]

Run fn() off the GUI thread, then on_done(result) on it.

Parameters:
  • fn – a zero-argument callable. It runs on a worker thread and must not touch any widget – return the data instead.

  • on_done – called on the GUI thread with fn’s return value.

Returns:

True when the job was started (or, unthreaded, ran).

spacr.qt.job_runner.shutdown_all(timeout_ms: int = 3000) → int[source]

Stop every live JobRunner. Returns how many were asked.

Call once on the way out, before Qt starts destroying widgets. Ordering matters more than completeness here: a runner that cannot stop in time is parked by bridge.drain_thread rather than terminated, so this is bounded.