Reap network clients that fail before reporting readiness
diff --git a/test/hil/net_test.py b/test/hil/net_test.py
index 3b0562a..93c6765 100644
--- a/test/hil/net_test.py
+++ b/test/hil/net_test.py
@@ -20,7 +20,7 @@
 import select
 import subprocess
 
-from helper import hil_util
+from helper import hil_health, hil_util
 
 DEVICE_IP = '192.168.7.1'
 # Default assets in the pinned lwIP dependency's src/apps/http/fsdata.c. Verify
@@ -70,17 +70,30 @@
     return (['sudo', '-n'] if os.geteuid() != 0 else []) + argv
 
 
-def stop_client(proc, pid):
+def stop_client(proc, pid, grace=5):
     # The unshare child runs as us, even when its sudo wrapper belongs to root.
     # Terminate that child directly so sudo can reap it and return normally.
     for sig in (signal.SIGTERM, signal.SIGKILL):
-        if pid is not None:
+        if pid is None:
+            # Startup can fail before Python reports its PID. Walk only this
+            # Popen child's descendants, leaves first, then signal the wrapper.
+            # Never killpg: these processes share the HIL caller's group.
+            children = hil_health.child_procs([proc.pid]).get(proc.pid, [])
+            for child, _pgid in reversed(children):
+                try:
+                    os.kill(child, sig)
+                except (ProcessLookupError, PermissionError):
+                    # sudo relays SIGTERM to a child still running as root before
+                    # unshare drops privileges; the caller can signal sudo itself.
+                    pass
+            proc.send_signal(sig)
+        else:
             try:
                 os.kill(pid, sig)
             except ProcessLookupError:
                 pass
         try:
-            proc.communicate(timeout=5)
+            proc.communicate(timeout=grace)
             return
         except subprocess.TimeoutExpired:
             pass
diff --git a/test/hil/test/test_hil_net.py b/test/hil/test/test_hil_net.py
index 87c1e38..e2a82da 100644
--- a/test/hil/test/test_hil_net.py
+++ b/test/hil/test/test_hil_net.py
@@ -59,7 +59,7 @@
 class NamespaceLifetime(unittest.TestCase):
     def setUp(self):
         self.calls = []
-        self.proc = Mock(returncode=0)
+        self.proc = Mock(returncode=0, pid=1234)
         self.proc.stdout = io.StringIO('4321\n')
         self.proc.poll.return_value = 0
         self.proc.communicate.return_value = ('HTTP verified\n', '')
@@ -126,6 +126,65 @@
         with self.assertRaisesRegex(RuntimeError, 'corrupt data'):
             net.check_device('ABC123')
 
+    def test_readiness_timeout_cleans_up_without_reported_pid(self):
+        net.select.select.return_value = ([], [], [])
+        self.proc.poll.return_value = None
+        with patch.object(net.hil_health, 'child_procs', return_value={1234: [(4321, 1)]}), \
+                patch.object(net.os, 'kill') as kill:
+            with self.assertRaisesRegex(RuntimeError, 'did not become ready'):
+                net.check_device('ABC123')
+        net.command.assert_not_called()
+        kill.assert_called_once_with(4321, net.signal.SIGTERM)
+        self.proc.send_signal.assert_called_once_with(net.signal.SIGTERM)
+        self.proc.communicate.assert_called_once_with(timeout=5)
+
+    def test_root_child_is_left_for_sudo_to_signal(self):
+        with patch.object(net.hil_health, 'child_procs', return_value={1234: [(4321, 1)]}), \
+                patch.object(net.os, 'kill', side_effect=PermissionError()):
+            net.stop_client(self.proc, None)
+        self.proc.send_signal.assert_called_once_with(net.signal.SIGTERM)
+        self.proc.communicate.assert_called_once_with(timeout=5)
+
+
+@unittest.skipUnless(os.name == 'posix', 'Linux process-tree cleanup')
+class UnreportedClient(unittest.TestCase):
+    def test_live_wrapper_and_descendant_without_pid_are_reaped(self):
+        # Both ignore TERM so this also exercises the KILL pass. They inherit our
+        # process group; cleanup must not signal the test runner or its siblings.
+        code = '''import os, signal, subprocess, sys, time
+signal.signal(signal.SIGTERM, signal.SIG_IGN)
+child = subprocess.Popen([sys.executable, '-c',
+    'import signal,time; signal.signal(signal.SIGTERM,signal.SIG_IGN); print("ready",flush=True); time.sleep(60)'],
+    stdout=subprocess.PIPE, text=True)
+child.stdout.readline()
+print(child.pid, flush=True)
+time.sleep(60)
+'''
+        proc = net.subprocess.Popen([sys.executable, '-c', code], stdout=net.subprocess.PIPE,
+                                    stderr=net.subprocess.PIPE, text=True)
+        child = None
+        try:
+            self.assertTrue(net.select.select([proc.stdout], [], [], 5)[0])
+            # The test knows the descendant PID; stop_client deliberately does not.
+            child = int(proc.stdout.readline())
+            with patch.object(net.os, 'killpg') as killpg:
+                net.stop_client(proc, None, grace=0.1)
+            killpg.assert_not_called()
+            self.assertIsNotNone(proc.poll())
+            stat = Path('/proc') / str(child) / 'stat'
+            if stat.exists():
+                self.assertEqual(stat.read_text().rsplit(')', 1)[1].split()[0], 'Z')
+        finally:
+            if proc.poll() is None:
+                proc.kill()
+                proc.wait(timeout=5)
+            if child is not None:
+                try:
+                    os.kill(child, net.signal.SIGKILL)
+                except ProcessLookupError:
+                    pass
+            net.hil_util._close_pipes(proc)
+
 
 class HttpVerification(unittest.TestCase):
     def setUp(self):