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

commite3ac80dba41d838773fb80fece8856ae37a15467
parent3aa460a343
authorLucas Galante <[email protected]>
date2026-09-10 13:15
hc: stop the leader chord crashing Houdini from a timer thread

chord() stored the hou.NetworkEditor it was handed as a dict key and then
expired that entry from a threading.Timer. One second later the timer
thread ran _pending.get(editor), which hashes and compares HOM objects.

Two things are wrong with that. HOM is not thread-safe, so no hou call
belongs on a Timer thread at all. And Houdini rebuilds these wrappers on
every script fire, so the key held in the dict is stale by the time the
timer looks at it: SWIG reads .this off a pane tab whose C++ object is
gone and dereferences it. That is a SIGSEGV, caught by Houdini's crash
handler, which then wedges the process -- the UI stops responding, Ctrl+S
silently does nothing, and the window ignores WM_DELETE_WINDOW, which in
turn cancels a compositor logout.

    _wrap_EnumValue___eq__ <_hou.so>
    SWIG_Python_ConvertPtrAndOwn / SWIG_Python_GetSwigThis
    PyObject_GetAttr / _Py_slot_tp_getattr_hook.cold
    ...
    thread_run <libpython3.13.so.1.0>

Key on editor.name() instead -- a plain string, stable across the wrappers
Houdini recreates -- and drop the timer entirely. It only ever deleted an
entry that the elapsed-time check already ignores, so a leftover is
harmless and the next keypress overwrites it. No thread, and no HOM object
held past the callback that produced it.

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

 python3.13libs/hc/hcleader.py | 35 +++++++++++++++++++++++++++++++++++
 1 file changed, 35 insertions(+)

diff --git a/python3.13libs/hc/hcleader.py b/python3.13libs/hc/hcleader.py
new file mode 100644
index 0000000..6c124b2
--- /dev/null
+++ b/python3.13libs/hc/hcleader.py
@@ -0,0 +1,35 @@
+import time
+
+
+_TIMEOUT = 1.0
+_pending = {}  # {pane tab name: timestamp}
+
+
+def chord(editor, action_fn):
+    """Vim-style leader chord. Returns True if action was executed, False if
+    this was the first keypress (leader registered, waiting for second).
+
+    ``editor`` is a ``hou.NetworkEditor`` (or any ``hou.PaneTab``).
+    Houdini creates fresh Python wrappers each time a script fires, so
+    ``id()`` is unreliable and the wrapper itself must not be kept: holding
+    one past the current callback leaves a dangling SWIG pointer, and reading
+    it later segfaults Houdini. ``name()`` is a plain string that stays stable
+    across those wrappers, so that is what gets stored.
+
+    Nothing here may touch HOM off the main thread. An earlier version
+    expired entries from a ``threading.Timer``, whose callback compared the
+    stored ``hou.PaneTab`` keys from a background thread -- HOM is not
+    thread-safe, and that crashed Houdini with SIGSEGV inside SWIG's ``__eq__``.
+    The elapsed-time check below already ignores a stale entry, so no timer is
+    needed: a leftover entry is harmless and the next keypress overwrites it."""
+    now = time.monotonic()
+    key = editor.name()
+    old_ts = _pending.get(key)
+
+    if old_ts and (now - old_ts) < _TIMEOUT:
+        _pending.pop(key, None)
+        action_fn()
+        return True
+
+    _pending[key] = now
+    return False