timers-modal-and-threading · git:20261012.f6d0439 · 2026-10-12 · sha256 249e042ce294e9f2

timers-modal-and-threading git:20261012.f6d0439B

Immutable. This exact content never changes and is served at /api/v1/blob/249e042ce294e9f2.

---
name: timers-modal-and-threading
description: "Run long or repeated work in a Blender add-on without freezing or crashing it: bpy.app.timers return values and persistent=True, modal operators driven by event_timer_add, and worker threads that hand results to the main thread through a queue. Use when the user polls, animates or downloads from an add-on, calls bpy from a thread, writes a modal operator, sees a timer never fire in --background, or loses a timer after opening a file. Targets 5.2 LTS with 4.5 LTS fallback."
standards-version: 1.10.0
---

# Timers, Modal Operators and Threading

## Trigger

Use this skill when the user:

- Wants something to happen later or repeatedly (polling, autosave, a progress readout)
- Runs slow work (network, file processing, a solver) and the UI freezes
- Calls `bpy` from a `threading.Thread` and gets crashes or corrupted data
- Writes a modal operator, or one that never stops
- Registers a timer in `blender --background` and it never runs
- Loses a timer after File → Open or `wm.read_homefile`

## Required inputs

- **What runs, how often, and for how long**
- **Whether it runs headless** (`--background`) or in a windowed Blender
- **Whether the work must survive file loads** (an add-on service) or belongs to one file

## The event loop is the whole story

Timers, modal operators and `event_timer_add` are all driven by Blender's window-manager event loop. A windowed Blender has one. `blender --background --python script.py` does not: the script runs, Blender exits, and a registered timer never runs (measured on 4.5.11, 5.1.2 and 5.2.1: `is_registered` is `True` and the callback never executes). Headless work must run synchronously in the script; there is no deferred callback to wait for.

## `bpy.app.timers`: the return value schedules the next run

```python
import bpy

def poll():
    if done():
        return None      # unregister: this was the last run
    return 0.5           # run again in 0.5 s

bpy.app.timers.register(poll, first_interval=0.5)
```

- `None` unregisters; a float re-runs after that many seconds. Measured in a windowed child: a `None` timer ran once, and one returning `0.05` until its third call ran exactly three times. Afterwards `is_registered` was `False` for both.
- Returning `0.0` does not mean "stop": it re-runs on the next event-loop pass. A timer meant to run once that returns `0.0` ran 21 times on 4.5.11 and 30 on 5.2.1 before a file load removed it.
- `first_interval` is keyword-only: `register(poll, 0.5)` raises `TypeError: register() takes exactly 1 positional argument (2 given)` on 4.5.11, 5.1.2 and 5.2.1.
- **A file load drops every timer not registered with `persistent=True`.** Measured: after `wm.read_factory_settings` inside a timer, the persistent timer was still registered and the plain one was not. Add-on services that must outlive File → Open need `persistent=True`. Per-file work should be plain, so it does not leak into the next file.
- Unregister in `unregister()`: `if bpy.app.timers.is_registered(poll): bpy.app.timers.unregister(poll)`.

## Threads: compute off the main thread, touch `bpy` on it

`bpy` is not thread-safe. A worker thread may compute, download or read files, but every `bpy` read and write happens on the main thread. Hand results over through a `queue.Queue`, and drain it from a timer:

```python
import queue
import threading
import bpy

results = queue.Queue()
pending = 0                        # jobs started, not yet drained (main thread only)

def worker(url):
    try:
        results.put((True, download(url)))   # no bpy here
    except BaseException as e:
        results.put((False, e))    # always put, or the drain waits forever

def drain():
    global pending
    while True:                    # take everything that landed this tick
        try:
            ok, value = results.get_nowait()
        except queue.Empty:
            break
        pending -= 1
        if ok:
            apply_to_scene(value)  # bpy, on the main thread
        else:
            print(f"download failed: {value!r}")
    return 0.1 if pending else None  # stop only when every job is in

def start(url):
    global pending
    pending += 1
    threading.Thread(target=worker, args=(url,), daemon=True).start()
    if not bpy.app.timers.is_registered(drain):
        bpy.app.timers.register(drain, first_interval=0.1)
```

Two traps in the one-shot version (`get_nowait()` once, then `return None`):

- **Stranded results.** With two jobs in flight, the drain stops after the first result and the second sits in the queue with no timer to read it. Measured: with the drain returning `None` after one result, one of two results was applied. Count pending jobs and unregister only at 0.
- **Endless polling.** A worker whose job raises dies without `put()`. Measured: the pending count stayed at 1 and the drain was still registered seconds later, and no error was reported anywhere but the thread's traceback. Wrap the job and put the exception, so the main thread can report it.

A third trap appears when the user opens another file while jobs run. File → Open drops the plain drain timer, but the worker threads keep running and keep putting results. The next `start()` registers the drain again, and that drain applies every queued result, including the old file's, to the new file's `bpy.data`. Measured on 5.2.1 and 4.5.14: a job started before `wm.read_homefile` wrote its result onto the new file's `Target` object. The snippet tags each job with an epoch, and a `@persistent` `load_post` handler bumps the epoch and resets `pending`. The drain then discards results from older epochs: with the epoch check in place, the stale result was dropped and a job started after the load still applied. Register that handler in `register()` and remove it in `unregister()`. Making the drain `persistent=True` is the wrong fix here: it keeps the timer alive across the load, but still applies the old file's results.

Measured with three concurrent jobs, one raising `ValueError`: both results were applied, the error was reported, the pending count reached 0, and the drain unregistered itself. Every result was handled on the script's main-thread `threading.get_ident()`, which no worker shared. Compare idents to prove that: `threading.current_thread() is threading.main_thread()` checked inside a timer is always true, because timers only run on the main thread. Use `daemon=True` so a hung worker cannot keep Blender from quitting. For network work, check `bpy.app.online_access` first (see `extension-runtime-and-packaging`).

## Modal operators: `event_timer_add` plus `modal()`

```python
class MYADDON_OT_watch(bpy.types.Operator):
    bl_idname = "myaddon.watch"
    bl_label = "Watch"

    _timer = None

    def invoke(self, context, event):
        wm = context.window_manager
        self._timer = wm.event_timer_add(0.1, window=context.window)
        wm.modal_handler_add(self)
        return {'RUNNING_MODAL'}

    def modal(self, context, event):
        if event.type == 'ESC':
            self.cancel(context)
            return {'CANCELLED'}
        if event.type != 'TIMER':
            return {'PASS_THROUGH'}    # let the UI keep working
        if step(context):              # True when the job is done
            self.cancel(context)
            return {'FINISHED'}
        return {'RUNNING_MODAL'}

    def cancel(self, context):
        if self._timer is not None:
            context.window_manager.event_timer_remove(self._timer)
            self._timer = None
```

- Return `{'PASS_THROUGH'}` for events you do not handle, or the operator swallows all input and the UI looks frozen.
- `bpy.types.Event` has **no `timer` attribute** (absent from its RNA on 4.5.11 and 5.2.1). `event.timer is self._timer` raises `AttributeError` inside `modal()`. Test `event.type == 'TIMER'`; if several timers feed one operator, count or time the ticks yourself.
- Remove the timer on every exit path: your own `FINISHED` and `CANCELLED` returns, and `cancel()`, which Blender calls when it ends a running modal operator itself.
- Measured in a windowed child: `invoke` returned `{'RUNNING_MODAL'}`, `modal()` received three `TIMER` events, then returned `{'FINISHED'}` and removed its timer.
- To start a modal operator from a timer or a script, there is no window in context; supply one with `with bpy.context.temp_override(window=bpy.context.window_manager.windows[0]): bpy.ops.myaddon.watch('INVOKE_DEFAULT')`.

For a progress readout, call `context.window_manager.progress_begin(0, total)`, `progress_update(i)` and `progress_end()` from the main thread: the modal step or the drain timer, never the worker.

## Common AI mistakes

1. **Calling `bpy` from a worker thread** (`bpy.data.objects.new` inside `Thread.run`). Compute in the thread; apply on the main thread from a timer.
2. **`time.sleep()` or a `while` loop in an operator** to wait for work. It blocks the event loop. Use a timer or a modal operator.
3. **Expecting timers to fire in `--background`**. There is no event loop; run the work directly in the script.
4. **A run-once timer that returns `0`**. Zero means "again on the next pass"; return `None` to stop.
5. **`bpy.app.timers.register(fn, 1.0)`**. `first_interval` is keyword-only on every supported version.
6. **Add-on timers without `persistent=True`**. They vanish on the first File → Open.
7. **`event.timer` in `modal()`**. The attribute does not exist; test `event.type == 'TIMER'`.
8. **Never removing the modal timer**. Remove it in `cancel()` and on every return that ends the operator.

## Compatibility paths

The behaviour above was measured identically on 4.5 LTS, 5.1 and 5.2 LTS. The only visible difference is cosmetic: 5.2's docstring shows `register(function, *, first_interval=0, persistent=False)`, while 4.5 documents the same keyword-only call without the `*`. No version branch is needed.

## Related

- `operators`: operator lifecycle, `bl_options`, `invoke` versus `execute`
- `drivers-and-app-handlers`: `@persistent` application handlers, the other way to react to file loads
- `headless-batch-scripting`: what to do instead when there is no event loop
- `extension-runtime-and-packaging`: `bpy.app.online_access` before network work in a worker
- Snippet: [`snippets/thread-queue-timer.py`](https://github.com/TMHSDigital/Blender-Developer-Tools/blob/main/snippets/thread-queue-timer.py)

<!-- examples:begin (generated by scripts/build_examples_index.py from examples/skills.json; do not edit) -->
## Runnable examples

Each example runs headless, asserts the contract, and exits non-zero when it breaks. Run one with `blender --background --python <script> --`; pass a falsifier flag to watch the check fail.

- [`timers-modal-threading`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/timers-modal-threading): Proves the event-loop contracts behind long-running add-on work. Falsify: `--windowed-background-child` (exit 3).

<!-- examples:end -->

## References

- `bpy.app.timers`: https://docs.blender.org/api/current/bpy.app.timers.html
- `WindowManager.event_timer_add`: https://docs.blender.org/api/current/bpy.types.WindowManager.html#bpy.types.WindowManager.event_timer_add
- Modal operators: https://docs.blender.org/api/current/bpy.types.Operator.html#modal-execution
- Thread safety: https://docs.blender.org/api/current/info_gotchas_threading.html
- 4.5 LTS reference: https://docs.blender.org/api/4.5/bpy.app.timers.html