Skip to content

Commit f6d0439

Browse files
TMHSDigitalclaude
andcommitted
fix(snippets): drop an old file's job results after File > Open in thread-queue-timer
File > Open dropped the plain drain timer, but the worker threads kept running. The next run_in_background() re-registered the drain, and it applied the old file's queued result to the new file's objects. Reproduced headless on 5.2.1: a job started before wm.read_homefile wrote "old-file" onto the new file's Target. Jobs now carry an epoch. A @Persistent load_post handler (registered in register(), removed in unregister()) bumps the epoch and resets the pending count, and the drain discards results from older epochs. With the same probe on 5.2.1 and 4.5.14, the stale result is dropped and a job started after the load still applies. The old snippet fails that probe. The skill explains the trap, and why persistent=True on the drain does not fix it. Closes #492 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
1 parent 98b5a40 commit f6d0439

2 files changed

Lines changed: 32 additions & 23 deletions

File tree

‎skills/timers-modal-and-threading/SKILL.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ Two traps in the one-shot version (`get_nowait()` once, then `return None`):
9191
- **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.
9292
- **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.
9393

94+
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.
95+
9496
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`).
9597

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

‎snippets/thread-queue-timer.py‎

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,11 @@
33
# touches it; a timer (main thread) drains the queue.
44
#
55
# - Timers need the event loop: in `blender --background` they never run.
6-
# - Return None from a timer to stop it, a float to run again; returning 0
7-
# means "again on the next pass", not "stop".
8-
# - Count pending jobs: stop the drain only when every job has reported, or
9-
# a second job's result sits in the queue with no timer to read it.
10-
# - The worker always puts, even when the job raises; otherwise the drain
11-
# waits for a result that never comes and polls forever.
12-
# - persistent=True keeps the timer across File > Open; plain timers are
13-
# dropped by a file load.
6+
# - A timer returning None stops; a float (0 included) runs it again.
7+
# - Count pending jobs and stop the drain only at 0, or a second result is
8+
# stranded; the worker always puts, even on error, or the drain never stops.
9+
# - File > Open drops the plain drain timer but not the threads: jobs carry an
10+
# epoch that load_post bumps, so an old file's results are discarded.
1411
#
1512
# Reference:
1613
# https://docs.blender.org/api/current/bpy.app.timers.html
@@ -20,49 +17,59 @@
2017
import threading
2118

2219
import bpy
20+
from bpy.app.handlers import persistent
2321

2422
_results = queue.Queue()
25-
_pending = 0 # jobs started but not yet drained; read and written on the main thread only
23+
_pending = 0 # this file's jobs not yet drained; main thread only
24+
_epoch = 0 # bumped on every file load
2625

2726

28-
def _worker(job):
29-
# No bpy in here: compute, download, read files.
30-
try:
31-
_results.put((True, job()))
27+
def _worker(job, epoch):
28+
try: # no bpy in here: compute, download, read files
29+
_results.put((epoch, True, job()))
3230
except BaseException as e:
33-
_results.put((False, e)) # report the failure; never leave the drain waiting
31+
_results.put((epoch, False, e)) # never leave the drain waiting
3432

3533

3634
def _drain():
3735
global _pending
3836
while True: # take everything that has landed this tick
3937
try:
40-
ok, value = _results.get_nowait()
38+
epoch, ok, value = _results.get_nowait()
4139
except queue.Empty:
4240
break
41+
if epoch != _epoch:
42+
continue # started for a file that is no longer open
4343
_pending -= 1
44-
if ok:
45-
apply_result(value) # main thread: bpy is safe here
46-
else:
44+
obj = bpy.data.objects.get("Target") # main thread: bpy is safe here
45+
if not ok:
4746
print(f"background job failed: {value!r}")
47+
elif obj is not None:
48+
obj["result"] = value
4849
return 0.1 if _pending else None # unregister only once every job is in
4950

5051

51-
def apply_result(value):
52-
obj = bpy.data.objects.get("Target")
53-
if obj is not None:
54-
obj["result"] = value
52+
@persistent
53+
def _on_load(_):
54+
global _pending, _epoch
55+
_epoch, _pending = _epoch + 1, 0 # old jobs' results are now stale
5556

5657

5758
def run_in_background(job):
5859
"""Start `job` on a worker thread and apply its result when it lands."""
5960
global _pending
6061
_pending += 1
61-
threading.Thread(target=_worker, args=(job,), daemon=True).start()
62+
threading.Thread(target=_worker, args=(job, _epoch), daemon=True).start()
6263
if not bpy.app.timers.is_registered(_drain):
6364
bpy.app.timers.register(_drain, first_interval=0.1)
6465

6566

67+
def register():
68+
bpy.app.handlers.load_post.append(_on_load)
69+
70+
6671
def unregister():
72+
if _on_load in bpy.app.handlers.load_post:
73+
bpy.app.handlers.load_post.remove(_on_load)
6774
if bpy.app.timers.is_registered(_drain):
6875
bpy.app.timers.unregister(_drain)

0 commit comments

Comments
 (0)