git.lucas.co / hou-control
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