SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
python3.13libs/hc/hcmainthread.py (3.5K)
1 """Run work on Houdini's main thread from a background thread.
2
3 HOM is not thread-safe. Touching ``hou`` from any thread other than the one
4 Houdini runs its UI on is undefined behaviour, and in practice it segfaults
5 inside SWIG -- see the leader-chord crash that ``hcleader`` was rewritten to
6 avoid. The rpyc server ``uiready`` starts on port 18811 is exactly that
7 hazard: ``ThreadedServer`` hands every request to a connection thread, so
8 anything the agent bridge evaluates would run off the main thread.
9
10 This module is the seam. ``install()`` registers a pump on Houdini's event
11 loop, which runs on the main thread; ``call(fn)`` hands ``fn`` to that pump
12 and blocks the calling thread until the main thread has run it, then returns
13 its result or re-raises whatever it raised.
14
15 Calling from the main thread runs ``fn`` inline -- waiting on the pump from
16 the thread that drives it would deadlock. So would any ``call()`` made from
17 inside a function the pump is running; keep the work passed here
18 self-contained.
19
20 Without ``install()`` (hython, or a UI-less session) ``call()`` also runs
21 inline, because there is no event loop to pump and no separate UI thread to
22 protect.
23 """
24
25 import queue
26 import threading
27
28
29 DEFAULT_TIMEOUT = 600.0
30
31 _queue = queue.SimpleQueue()
32 _main_thread_id = None
33 _installed = False
34
35
36 class _Job:
37 __slots__ = ("fn", "args", "kwargs", "done", "result", "error")
38
39 def __init__(self, fn, args, kwargs):
40 self.fn = fn
41 self.args = args
42 self.kwargs = kwargs
43 self.done = threading.Event()
44 self.result = None
45 self.error = None
46
47 def run(self):
48 try:
49 self.result = self.fn(*self.args, **self.kwargs)
50 except BaseException as exc: # handed back to the waiting thread
51 self.error = exc
52 finally:
53 self.done.set()
54
55
56 def _pump():
57 """Drain the queue. Runs on the main thread, once per event loop tick."""
58 while True:
59 try:
60 job = _queue.get_nowait()
61 except queue.Empty:
62 return
63 job.run()
64
65
66 def install():
67 """Start pumping. Must be called from Houdini's main thread."""
68 global _main_thread_id, _installed
69 _main_thread_id = threading.get_ident()
70 if _installed:
71 return
72 import hou
73 hou.ui.addEventLoopCallback(_pump)
74 # Publish the entry point where anything can reach it without importing
75 # this package. The agent runner is injected as source text over rpyc and
76 # has no business depending on hc/__init__.py importing cleanly -- an
77 # ImportError there would silently drop it back to running HOM off the
78 # connection thread, which is the whole bug.
79 hou.session._hc_main_thread_call = call
80 _installed = True
81
82
83 def installed():
84 return _installed
85
86
87 def onMainThread():
88 return _main_thread_id is None or threading.get_ident() == _main_thread_id
89
90
91 def call(fn, /, *args, timeout=DEFAULT_TIMEOUT, **kwargs):
92 """Run ``fn(*args, **kwargs)`` on Houdini's main thread and return its
93 result. Raises whatever ``fn`` raised, or ``TimeoutError`` if the pump
94 did not get to it in ``timeout`` seconds."""
95 if not _installed or onMainThread():
96 return fn(*args, **kwargs)
97
98 job = _Job(fn, args, kwargs)
99 _queue.put(job)
100 if not job.done.wait(timeout):
101 raise TimeoutError(
102 "timed out after %gs waiting for Houdini's main thread; it is "
103 "busy or its event loop is not running" % timeout
104 )
105 if job.error is not None:
106 raise job.error
107 return job.result