git.lucas.co / hou-control
SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git

commitb3c72b3eb0d1c0728551d4d031eac3c850691e49
parente3ac80dba4
authorLucas Galante <[email protected]>
date2026-09-10 13:21
hc: run agent bridge code on Houdini's main thread

uiready starts hrpyc on port 18811, and hrpyc builds an rpyc ThreadedServer:
every request is served on a connection thread. Both agent runners then
eval'd the caller's code there, and houdini_ping reached straight through
conn.modules.hou. All of it touched HOM off Houdini's main thread.

HOM is not thread-safe. That is the same defect that crashed the leader
chord from a threading.Timer -- SWIG reads .this off a HOM wrapper on the
wrong thread and dereferences it -- only reached through the agent instead
of a keypress, and able to fire on any request the bridge or MCP server
sends.

Add hc.hcmainthread: a queue pumped by hou.ui.addEventLoopCallback, so it
drains on the main thread, plus call(fn) which hands work to that pump and
blocks the calling thread for the result, re-raising whatever fn raised.
Calling from the main thread runs inline rather than deadlocking against
the pump it would be waiting on, and a dead pump surfaces as TimeoutError
instead of a permanent hang.

install() publishes call() as hou.session._hc_main_thread_call. The runners
are injected into Houdini as source text over rpyc, so reaching it through
hou.session keeps them from depending on hc/__init__.py importing cleanly;
an ImportError there would have quietly dropped them back onto the
connection thread, which is the bug. No pump published means no UI thread
to protect (hython), so they run inline.

houdini_ping goes through the runner too, instead of evaluating
conn.modules.hou.* on the connection thread.

Co-Authored-By: Claude Opus 5 <[email protected]>

 houdini-agent/bridge.py                |  92 +++++++++++++++++++++++++
 houdini-agent/mcpserver/houdini_mcp.py | 122 +++++++++++++++++++++++++++++++++
 python3.13libs/hc/hcmainthread.py      | 107 +++++++++++++++++++++++++++++
 python3.13libs/uiready.py              |   8 +++
 4 files changed, 329 insertions(+)

diff --git a/houdini-agent/bridge.py b/houdini-agent/bridge.py
new file mode 100755
index 0000000..1e72b38
--- /dev/null
+++ b/houdini-agent/bridge.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+import os
+import sys
+import rpyc
+import traceback
+import io
+import contextlib
+
+HOUDINI_HOST = os.environ.get("HOUDINI_HOST", "localhost")
+HOUDINI_PORT = int(os.environ.get("HOUDINI_PORT", "18811"))
+
+_REMOTE_RUNNER_SRC = r'''
+def _hcrun(code, globals_dict=None):
+    import io, contextlib, traceback, sys, os
+    if globals_dict is None:
+        globals_dict = sys.modules["__main__"].__dict__
+
+    workspace = "''' + os.getcwd() + r'''"
+    if workspace not in sys.path:
+        sys.path.append(workspace)
+
+    try:
+        import hou
+        if "hou" not in globals_dict:
+            globals_dict["hou"] = hou
+    except ImportError:
+        pass
+
+    def _work():
+        buf = io.StringIO()
+        result_repr = None
+        error = None
+        try:
+            with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
+                # Compile to handle multiline exec/eval correctly
+                try:
+                    tree = compile(code, "<agent>", "eval")
+                    result = eval(tree, globals_dict)
+                    if result is not None:
+                        result_repr = repr(result)
+                except SyntaxError:
+                    tree = compile(code, "<agent>", "exec")
+                    exec(tree, globals_dict)
+        except Exception:
+            error = traceback.format_exc()
+        return buf.getvalue(), result_repr, error
+
+    # rpyc's ThreadedServer serves this call on a connection thread, and HOM
+    # is not thread-safe: a hou call off Houdini's main thread segfaults it
+    # inside SWIG. Hand the work to the main-thread pump uiready installs,
+    # published on hou.session so this injected source needs no import of
+    # its own. No pump (hython, no UI, startup not finished) means no UI
+    # thread to protect, so run inline.
+    _hc_call = getattr(
+        getattr(sys.modules.get("hou"), "session", None),
+        "_hc_main_thread_call",
+        None,
+    )
+    if _hc_call is None:
+        return _work()
+    return _hc_call(_work)
+'''
+
+def run_in_houdini(code):
+    try:
+        conn = rpyc.classic.connect(HOUDINI_HOST, HOUDINI_PORT)
+    except ConnectionRefusedError:
+        return f"ERROR: Could not connect to Houdini at {HOUDINI_HOST}:{HOUDINI_PORT}. Is Houdini running?"
+
+    try:
+        conn.execute(_REMOTE_RUNNER_SRC)
+        stdout, result_repr, error = conn.namespace["_hcrun"](code)
+        
+        output = []
+        if stdout: output.append(stdout.strip())
+        if result_repr: output.append(f"=> {result_repr}")
+        if error: output.append(f"ERROR:\n{error}")
+        
+        return "\n".join(output) if output else "(no output)"
+    finally:
+        conn.close()
+
+if __name__ == "__main__":
+    if len(sys.argv) < 2:
+        print("Usage: bridge.py \"python code\"")
+        sys.exit(1)
+    
+    code = sys.argv[1]
+    if code == "--ping":
+        code = "hou.applicationVersionString()"
+    
+    print(run_in_houdini(code))
diff --git a/houdini-agent/mcpserver/houdini_mcp.py b/houdini-agent/mcpserver/houdini_mcp.py
new file mode 100644
index 0000000..37fd5a2
--- /dev/null
+++ b/houdini-agent/mcpserver/houdini_mcp.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+import os
+import rpyc
+from mcp.server.fastmcp import FastMCP
+
+HOUDINI_HOST = os.environ.get("HOUDINI_MCP_HOST", "localhost")
+HOUDINI_PORT = int(os.environ.get("HOUDINI_MCP_PORT", "18811"))
+
+mcp = FastMCP("houdini")
+
+
+_REMOTE_RUNNER_SRC = r'''
+def _hcrun(code):
+    import io, contextlib, traceback, sys
+    main_ns = sys.modules["__main__"].__dict__
+    try:
+        import hou
+        if "hou" not in main_ns:
+            main_ns["hou"] = hou
+    except ImportError:
+        pass
+
+    def _work():
+        buf = io.StringIO()
+        result_repr = None
+        error = None
+        try:
+            with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
+                try:
+                    result = eval(compile(code, "<mcp>", "eval"), main_ns)
+                    if result is not None:
+                        try:
+                            result_repr = repr(result)
+                        except Exception as _e:
+                            result_repr = "<unrepr-able: " + repr(_e) + ">"
+                except SyntaxError:
+                    exec(compile(code, "<mcp>", "exec"), main_ns)
+        except Exception:
+            error = traceback.format_exc()
+        return buf.getvalue(), result_repr, error
+
+    # rpyc's ThreadedServer serves this call on a connection thread, and HOM
+    # is not thread-safe: a hou call off Houdini's main thread segfaults it
+    # inside SWIG. Hand the work to the main-thread pump uiready installs,
+    # published on hou.session so this injected source needs no import of
+    # its own. No pump (hython, no UI, startup not finished) means no UI
+    # thread to protect, so run inline.
+    _hc_call = getattr(
+        getattr(sys.modules.get("hou"), "session", None),
+        "_hc_main_thread_call",
+        None,
+    )
+    if _hc_call is None:
+        return _work()
+    return _hc_call(_work)
+'''
+
+
[email protected]()
+def houdini_eval(code: str) -> str:
+    """Run Python code inside the running Houdini session.
+
+    The `hou` module and anything in __main__ is available. Captures stdout/stderr.
+    If the code is a single expression, its repr is returned too.
+    Requires Houdini to be running with hrpyc.start_server() called (happens
+    automatically in uiready.py for this project).
+    """
+    try:
+        conn = rpyc.classic.connect(HOUDINI_HOST, HOUDINI_PORT)
+    except ConnectionRefusedError:
+        return (
+            f"ERROR: Could not connect to Houdini at {HOUDINI_HOST}:{HOUDINI_PORT}. "
+            "Is Houdini running with hrpyc started?"
+        )
+
+    try:
+        conn.execute(_REMOTE_RUNNER_SRC)
+        stdout, result_repr, error = conn.namespace["_hcrun"](code)
+        stdout = str(stdout) if stdout else ""
+        result_repr = str(result_repr) if result_repr else None
+        error = str(error) if error else None
+    finally:
+        try:
+            conn.close()
+        except Exception:
+            pass
+
+    parts = []
+    if stdout:
+        parts.append(stdout.rstrip())
+    if result_repr is not None:
+        parts.append(f"=> {result_repr}")
+    if error:
+        parts.append(error.rstrip())
+    return "\n".join(parts) if parts else "(no output)"
+
+
[email protected]()
+def houdini_ping() -> str:
+    """Check whether the Houdini RPC server is reachable."""
+    try:
+        conn = rpyc.classic.connect(HOUDINI_HOST, HOUDINI_PORT)
+        try:
+            # conn.modules.hou.* would evaluate on the connection thread;
+            # go through the runner so it lands on Houdini's main thread.
+            conn.execute(_REMOTE_RUNNER_SRC)
+            _out, result_repr, error = conn.namespace["_hcrun"](
+                "(hou.applicationVersionString(), hou.hipFile.path())"
+            )
+            if error:
+                return f"Error: {str(error).rstrip()}"
+            return f"OK — {str(result_repr)}"
+        finally:
+            conn.close()
+    except ConnectionRefusedError:
+        return f"Unreachable at {HOUDINI_HOST}:{HOUDINI_PORT}"
+    except Exception as e:
+        return f"Error: {e!r}"
+
+
+if __name__ == "__main__":
+    mcp.run()
diff --git a/python3.13libs/hc/hcmainthread.py b/python3.13libs/hc/hcmainthread.py
new file mode 100644
index 0000000..1370786
--- /dev/null
+++ b/python3.13libs/hc/hcmainthread.py
@@ -0,0 +1,107 @@
+"""Run work on Houdini's main thread from a background thread.
+
+HOM is not thread-safe. Touching ``hou`` from any thread other than the one
+Houdini runs its UI on is undefined behaviour, and in practice it segfaults
+inside SWIG -- see the leader-chord crash that ``hcleader`` was rewritten to
+avoid. The rpyc server ``uiready`` starts on port 18811 is exactly that
+hazard: ``ThreadedServer`` hands every request to a connection thread, so
+anything the agent bridge evaluates would run off the main thread.
+
+This module is the seam. ``install()`` registers a pump on Houdini's event
+loop, which runs on the main thread; ``call(fn)`` hands ``fn`` to that pump
+and blocks the calling thread until the main thread has run it, then returns
+its result or re-raises whatever it raised.
+
+Calling from the main thread runs ``fn`` inline -- waiting on the pump from
+the thread that drives it would deadlock. So would any ``call()`` made from
+inside a function the pump is running; keep the work passed here
+self-contained.
+
+Without ``install()`` (hython, or a UI-less session) ``call()`` also runs
+inline, because there is no event loop to pump and no separate UI thread to
+protect.
+"""
+
+import queue
+import threading
+
+
+DEFAULT_TIMEOUT = 600.0
+
+_queue = queue.SimpleQueue()
+_main_thread_id = None
+_installed = False
+
+
+class _Job:
+    __slots__ = ("fn", "args", "kwargs", "done", "result", "error")
+
+    def __init__(self, fn, args, kwargs):
+        self.fn = fn
+        self.args = args
+        self.kwargs = kwargs
+        self.done = threading.Event()
+        self.result = None
+        self.error = None
+
+    def run(self):
+        try:
+            self.result = self.fn(*self.args, **self.kwargs)
+        except BaseException as exc:  # handed back to the waiting thread
+            self.error = exc
+        finally:
+            self.done.set()
+
+
+def _pump():
+    """Drain the queue. Runs on the main thread, once per event loop tick."""
+    while True:
+        try:
+            job = _queue.get_nowait()
+        except queue.Empty:
+            return
+        job.run()
+
+
+def install():
+    """Start pumping. Must be called from Houdini's main thread."""
+    global _main_thread_id, _installed
+    _main_thread_id = threading.get_ident()
+    if _installed:
+        return
+    import hou
+    hou.ui.addEventLoopCallback(_pump)
+    # Publish the entry point where anything can reach it without importing
+    # this package. The agent runner is injected as source text over rpyc and
+    # has no business depending on hc/__init__.py importing cleanly -- an
+    # ImportError there would silently drop it back to running HOM off the
+    # connection thread, which is the whole bug.
+    hou.session._hc_main_thread_call = call
+    _installed = True
+
+
+def installed():
+    return _installed
+
+
+def onMainThread():
+    return _main_thread_id is None or threading.get_ident() == _main_thread_id
+
+
+def call(fn, /, *args, timeout=DEFAULT_TIMEOUT, **kwargs):
+    """Run ``fn(*args, **kwargs)`` on Houdini's main thread and return its
+    result. Raises whatever ``fn`` raised, or ``TimeoutError`` if the pump
+    did not get to it in ``timeout`` seconds."""
+    if not _installed or onMainThread():
+        return fn(*args, **kwargs)
+
+    job = _Job(fn, args, kwargs)
+    _queue.put(job)
+    if not job.done.wait(timeout):
+        raise TimeoutError(
+            "timed out after %gs waiting for Houdini's main thread; it is "
+            "busy or its event loop is not running" % timeout
+        )
+    if job.error is not None:
+        raise job.error
+    return job.result
diff --git a/python3.13libs/uiready.py b/python3.13libs/uiready.py
index 932ce90..adba96c 100644
--- a/python3.13libs/uiready.py
+++ b/python3.13libs/uiready.py
@@ -30,6 +30,14 @@ else:
     from hc import HCStatusBar
     HCStatusBar().show()
 
+# rpyc's ThreadedServer runs every request on a connection thread, and HOM
+# is not thread-safe -- a hou call off the main thread segfaults Houdini
+# inside SWIG. Pump a main-thread dispatch queue before the server can accept
+# anything, so the agent bridge has somewhere to hand its work; see
+# hc.hcmainthread and the runner in houdini-agent/bridge.py.
+from hc import hcmainthread
+hcmainthread.install()
+
 try:
     hrpyc.start_server(port=18811)
 except OSError: