SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
hcviewregions: reap the ccectl children instead of leaving zombies
publish() started ccectl with Popen and dropped the handle. CPython only
waits on a dropped Popen when the next Popen is created, and here the
next one is the next layout change -- so on 2026-09-15 a [ccectl]
<defunct> sat under the live houdini-bin for over sixteen minutes.
ccectl answers in about ten milliseconds, but it talks to the compositor
over a socket and a blocking run() with a timeout would stall Houdini's
main thread for the whole timeout if the compositor hung. So keep Popen,
keep the handles, and poll() them on each quarter-second tick; a child
that outlives PUBLISH_TIMEOUT is killed and collected on a later tick.
tools/check.py drives publish/reap against shell stand-ins for ccectl and
checks /proc that the reaped child is not a zombie. It also now drops the
copy of hc that hython imported at startup from the manifest's checkout,
because in a git worktree that is the other tree and every check was
silently exercising the wrong code.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
python3.13libs/hc/hcviewregions.py | 46 +++++++++++++++++++++++--
tools/check.py | 69 ++++++++++++++++++++++++++++++++++++++
2 files changed, 113 insertions(+), 2 deletions(-)
diff --git a/python3.13libs/hc/hcviewregions.py b/python3.13libs/hc/hcviewregions.py
index 548398a..49e964b 100644
--- a/python3.13libs/hc/hcviewregions.py
+++ b/python3.13libs/hc/hcviewregions.py
@@ -21,6 +21,15 @@ when a rectangle changed, so its steady-state cost is a few HOM calls
every quarter second. Without ``ccectl`` on ``PATH`` (or a compositor
that does not know the command) it is inert: every publish is
fire-and-forget and errors are dropped.
+
+Fire-and-forget still has to reap. ``ccectl`` answers in ten milliseconds,
+but a child nobody waits on stays a zombie under houdini-bin until
+something calls ``waitpid`` -- and CPython only does that for a dropped
+``Popen`` when the *next* ``Popen`` is made, which here is the next
+rectangle change, minutes later. So publishes are kept and polled on the
+tick (never waited on: the main thread must not block on the compositor's
+socket), and one that outlives ``PUBLISH_TIMEOUT`` is killed rather than
+left to pile up behind a stuck compositor.
"""
import shutil
import subprocess
@@ -33,6 +42,9 @@ import hou
VIEW_TAB_TYPES = ("SceneViewer", "NetworkEditor")
#: Seconds between polls of the pane layout.
POLL_INTERVAL = 0.25
+#: Seconds a ``ccectl`` child may run before it is killed. It normally
+#: returns in ten milliseconds; this only matters with a hung compositor.
+PUBLISH_TIMEOUT = 5.0
#: Where the running callback is kept, so a reload can find and remove the
#: one installed by the module it is replacing (see ``HCSession.reloadHC``).
_SESSION_ATTR = "_hc_view_regions_callback"
@@ -62,6 +74,8 @@ class HCViewRegions:
self._exe = ccectl()
self._last = {}
self._next = 0.0
+ #: ``[(Popen, monotonic start), ...]`` of publishes not yet reaped.
+ self._pending = []
def start(self):
"""Install the poll; a no-op without ``ccectl``. Returns whether it ran."""
@@ -78,6 +92,7 @@ class HCViewRegions:
if now < self._next:
return
self._next = now + POLL_INTERVAL
+ self.reap(now)
try:
regions = self.collect()
except Exception:
@@ -153,8 +168,35 @@ class HCViewRegions:
return [",".join(str(int(v)) for v in r) for r in rects]
def publish(self, win_id, rects):
+ """Start one ``ccectl`` call; it is reaped by a later ``reap``."""
args = [self._exe, "touchpad-view-regions", f"x11:{win_id}"] + self.wire(rects)
try:
- subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ proc = subprocess.Popen(
+ args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL)
except OSError:
- pass
+ return
+ self._pending.append((proc, time.monotonic()))
+
+ def reap(self, now=None):
+ """Collect finished publishes without blocking; kill the overdue.
+
+ ``poll`` is a ``waitpid(WNOHANG)``, so a finished child is gone
+ from the process table after this and a running one is left alone.
+ A killed child is still running as far as this call is concerned
+ and is collected on the next one. Returns how many are still out.
+ """
+ if now is None:
+ now = time.monotonic()
+ keep = []
+ for proc, started in self._pending:
+ if proc.poll() is not None:
+ continue
+ if now - started > PUBLISH_TIMEOUT:
+ try:
+ proc.kill()
+ except OSError:
+ pass
+ keep.append((proc, started))
+ self._pending = keep
+ return len(keep)
diff --git a/tools/check.py b/tools/check.py
index d64ea31..481c91f 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -25,6 +25,17 @@ os.environ.setdefault("HC_PATH", str(ROOT))
import hou # noqa: E402
+# hython has already imported `hc` from the checkout the package manifest
+# names before this script runs. In a git worktree that is the *other*
+# checkout, and every check below would silently exercise its code instead
+# of the one being edited. Drop that copy so the import below resolves
+# against ROOT.
+for _name, _mod in list(sys.modules.items()):
+ if _name == "hc" or _name.startswith("hc."):
+ _file = getattr(_mod, "__file__", None) or ""
+ if not _file.startswith(str(ROOT / "python3.13libs")):
+ del sys.modules[_name]
+
from hc import ( # noqa: E402
HCNetworkEditor,
HCPane,
@@ -1813,6 +1824,64 @@ def check_view_regions():
check("rect wire format", rects_are_integers)
+ def publishes_are_reaped():
+ """A `ccectl` child nobody waited on sat as a zombie under
+ houdini-bin for sixteen minutes (2026-09-15): CPython reaps a dropped
+ Popen only when the next one is made, and the next publish is the
+ next layout change. Stand in for ccectl with a script that exits and
+ one that hangs, and drive publish/reap by hand."""
+ import os
+ import stat
+ import tempfile
+ import time
+ from hc import hcviewregions
+
+ def stand_in(tmp, name, body):
+ path = os.path.join(tmp, name)
+ with open(path, "w") as f:
+ f.write("#!/bin/sh\n" + body + "\n")
+ os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR)
+ return path
+
+ def zombie(proc):
+ try:
+ with open(f"/proc/{proc.pid}/stat") as f:
+ return f.read().rsplit(")", 1)[1].split()[0] == "Z"
+ except OSError:
+ return False
+
+ def drain(vr):
+ deadline = time.monotonic() + 5
+ while vr.reap() and time.monotonic() < deadline:
+ time.sleep(0.01)
+
+ with tempfile.TemporaryDirectory() as tmp:
+ vr = HCViewRegions()
+ vr._exe = stand_in(tmp, "ccectl-exits", "exit 0")
+ vr.publish(1, [])
+ assert len(vr._pending) == 1, vr._pending
+ proc = vr._pending[0][0]
+ drain(vr)
+ assert not vr._pending, "finished publish not reaped"
+ assert proc.returncode == 0, proc.returncode
+ assert not zombie(proc), "reaped child is still a zombie"
+
+ # A hung child is left alone until it outlives PUBLISH_TIMEOUT,
+ # then killed and collected on a later reap.
+ vr._exe = stand_in(tmp, "ccectl-hangs", "sleep 60")
+ vr.publish(2, [])
+ proc, started = vr._pending[0]
+ assert vr.reap(started + 1) == 1, "live child dropped"
+ assert proc.poll() is None, "live child killed early"
+ vr.reap(started + hcviewregions.PUBLISH_TIMEOUT + 1)
+ drain(vr)
+ assert not vr._pending, "killed child never collected"
+ assert proc.returncode not in (None, 0), proc.returncode
+ assert not zombie(proc), "killed child is still a zombie"
+ return "poll on tick, kill after timeout"
+
+ check("publishes are reaped", publishes_are_reaped)
+
def check_startup_script():
"""scripts/123.py reads hc_settings.json without importing hc.