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

commit3782b47edaecae180c40cae4ca8a69c2463f0ccc
parent9290430202
authorLucas Galante <[email protected]>
date2026-09-16 08:14
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.