[PATCH 2/9] rteval: Fix cyclictest error handling to prevent hangs

John Kacur <[email protected]> Tue, 5 May 2026 13:43:46 -0400
Newsgroups org.kernel.vger.linux-rt-users
Message-ID <[email protected]>
Apply the same error handling improvements from timerlat.py to cyclictest.py
to prevent hangs, crashes, and infinite loops (RHEL-140898).

1. Limit SIGINT attempts to avoid infinite loop
   - Send maximum of 5 SIGINT signals with 2s intervals
   - Force SIGKILL if process doesn't respond after 10s
   - Check and log exit status

2. Handle partial output from cyclictest gracefully
   - Wrap histogram parsing in try/except/finally
   - Catch IndexError/ValueError when parsing bucket data
   - Log warnings instead of crashing on malformed output

3. Ensure _setFinished() is always called
   - Use finally block to signal completion even on errors
   - Prevents hang in WaitForCompletion when parsing fails

4. Add exception handling in _parse_max_latencies()
   - Catch IndexError/ValueError when parsing max latencies line
   - Prevents crashes on malformed header data

5. Add exception handling for break value parsing
   - Protect against malformed breaktrace lines

6. Improve exception specificity
   - Changed bare except to catch ValueError specifically

Like timerlat, cyclictest can produce partial output if killed or crashes
mid-execution. These fixes ensure rteval handles such cases gracefully.

Assisted-by: Claude Sonnet 4.5 <[email protected]>
Signed-off-by: John Kacur <[email protected]>
---
 rteval/modules/measurement/cyclictest.py | 124 +++++++++++++++--------
 1 file changed, 79 insertions(+), 45 deletions(-)

diff --git a/rteval/modules/measurement/cyclictest.py b/rteval/modules/measurement/cyclictest.py
index c3ef4a54677d..8a5123e8af33 100644
--- a/rteval/modules/measurement/cyclictest.py
+++ b/rteval/modules/measurement/cyclictest.py
@@ -306,63 +306,97 @@ class Cyclictest(rtevalModulePrototype):
         if not line.startswith('# Max Latencies: '):
             return
 
-        line = line.split(':')[1]
-        vals = [int(x) for x in line.split()]
+        try:
+            line = line.split(':')[1]
+            vals = [int(x) for x in line.split()]
 
-        for i, core in enumerate(self.__cpus):
-            self.__cyclicdata[core].update_max(vals[i])
-            self.__cyclicdata['system'].update_max(vals[i])
+            for i, core in enumerate(self.__cpus):
+                self.__cyclicdata[core].update_max(vals[i])
+                self.__cyclicdata['system'].update_max(vals[i])
+        except (IndexError, ValueError) as e:
+            self._log(Log.WARN, f"Error parsing max latencies: {e}")
 
 
     def _WorkloadCleanup(self):
         if not self.__started:
             return
-        while self.__cyclicprocess.poll() is None:
-            self._log(Log.DEBUG, "Sending SIGINT")
+
+        # Send SIGINT and wait for graceful exit
+        # Limit attempts to avoid infinite loop (RHEL-140898)
+        max_attempts = 5
+        attempt = 0
+        while self.__cyclicprocess.poll() is None and attempt < max_attempts:
+            self._log(Log.DEBUG, f"Sending SIGINT (attempt {attempt + 1}/{max_attempts})")
             os.kill(self.__cyclicprocess.pid, signal.SIGINT)
             time.sleep(2)
+            attempt += 1
+
+        # Check if process exited
+        exit_code = self.__cyclicprocess.returncode
+        if exit_code is None:
+            # Process still running after max attempts, force kill
+            self._log(Log.WARN, "cyclictest did not respond to SIGINT, sending SIGKILL")
+            os.kill(self.__cyclicprocess.pid, signal.SIGKILL)
+            self.__cyclicprocess.wait()
+            exit_code = self.__cyclicprocess.returncode
+        elif exit_code != 0:
+            self._log(Log.WARN, f"cyclictest exited with non-zero status: {exit_code}")
+
+        # Parse histogram output - use try/finally to ensure _setFinished() is always called
+        # This prevents hangs if parsing fails due to partial output (RHEL-140898)
+        try:
+            self.__cyclicoutput.seek(0)
+            for line in self.__cyclicoutput:
+                line = bytes.decode(line)
+                if line.startswith('#'):
+                    # Catch if cyclictest stopped due to a breaktrace
+                    if line.startswith('# Break value: '):
+                        try:
+                            self.__breaktraceval = int(line.split(':')[1])
+                        except (IndexError, ValueError) as e:
+                            self._log(Log.WARN, f"Error parsing break value: {e}")
+                    elif line.startswith('# Max Latencies: '):
+                        self._parse_max_latencies(line)
+                    continue
 
-        # now parse the histogram output
-        self.__cyclicoutput.seek(0)
-        for line in self.__cyclicoutput:
-            line = bytes.decode(line)
-            if line.startswith('#'):
-                # Catch if cyclictest stopped due to a breaktrace
-                if line.startswith('# Break value: '):
-                    self.__breaktraceval = int(line.split(':')[1])
-                elif line.startswith('# Max Latencies: '):
-                    self._parse_max_latencies(line)
-                continue
-
-            # Skipping blank lines
-            if not line:
-                continue
-
-            vals = line.split()
-            if not vals:
-                # If we don't have any values, don't try parsing
-                continue
-
-            try:
-                index = int(vals[0])
-            except:
-                self._log(Log.DEBUG, f"cyclictest: unexpected output: {line}")
-                continue
+                # Skipping blank lines
+                if not line:
+                    continue
 
-            for i, core in enumerate(self.__cpus):
-                self.__cyclicdata[core].bucket(index, int(vals[i+1]))
-                self.__cyclicdata['system'].bucket(index, int(vals[i+1]))
+                vals = line.split()
+                if not vals:
+                    # If we don't have any values, don't try parsing
+                    continue
 
-        # generate statistics for each RunData object
-        for n in list(self.__cyclicdata.keys()):
-            #print "reducing self.__cyclicdata[%s]" % n
-            self.__cyclicdata[n].reduce()
-            #print self.__cyclicdata[n]
+                try:
+                    index = int(vals[0])
+                except (ValueError, IndexError):
+                    self._log(Log.DEBUG, f"cyclictest: unexpected output: {line}")
+                    continue
 
-        self._setFinished()
-        self.__started = False
-        os.close(self.__nullfp)
-        del self.__nullfp
+                for i, core in enumerate(self.__cpus):
+                    try:
+                        self.__cyclicdata[core].bucket(index, int(vals[i+1]))
+                        self.__cyclicdata['system'].bucket(index, int(vals[i+1]))
+                    except (IndexError, ValueError) as e:
+                        # Handle partial output from cyclictest (can happen on SIGINT during cleanup)
+                        self._log(Log.WARN, f"Error parsing cyclictest bucket data for core {core}: {e}")
+                        continue
+
+            # generate statistics for each RunData object
+            for n in list(self.__cyclicdata.keys()):
+                #print "reducing self.__cyclicdata[%s]" % n
+                self.__cyclicdata[n].reduce()
+                #print self.__cyclicdata[n]
+
+        except Exception as e:
+            self._log(Log.ERR, f"Error parsing cyclictest output: {e}")
+        finally:
+            # Always signal completion to avoid hangs
+            self._setFinished()
+            self.__started = False
+            os.close(self.__nullfp)
+            del self.__nullfp
 
 
     def MakeReport(self):
-- 
2.54.0