From 66be067d2bf59394046724481aa60de677202d24 Mon Sep 17 00:00:00 2001 From: Bastian Krause Date: Wed, 15 Jul 2026 13:05:13 +0200 Subject: [PATCH] tests/conftest: fix concurrent access to pexpect.spawn pexpect/ptyprocess are not thread-safe. But we need a thread to keep reading the Exporter/Coordinator outputs during the tests for debugging purposes. The reader thread (calling `pexpect.spawn.read_nonblocking()`) and the main thread (calling `pexpect.spawn`'s `kill()`/`wait()`/`isalive()`) can access it concurrently. Internally, pexpect calls waitpid(2) in these methods. This behavior leads to errors such as: ptyprocess.util.PtyProcessError: isalive() encountered condition where "terminated" is 0, but there was no child process. Did someone else call waitpid() on our process? Or: pexpect.exceptions.ExceptionPexpect: isalive() encountered condition where "terminated" is 0, but there was no child process. Did someone else call waitpid() on our process? Fix this by serializing access to pexpect.spawn through a lock. Signed-off-by: Bastian Krause --- tests/conftest.py | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 44f9ca76f..78b0df9ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from signal import SIGTERM import sys import threading +import time import pytest import pexpect @@ -58,12 +59,42 @@ def __getattr__(self, name): return getattr(self.__wrapped, name) +class _LockedSpawn: + """ + pexpect/ptyprocess is not thread-safe. + Serialize access to pexpect.spawn through a lock, so the reader thread and the main thread + never call into pexpect (and thus os.waitpid()) concurrently. + """ + + def __init__(self, spawn): + self._spawn = spawn + self._lock = threading.Lock() + + def __getattr__(self, name): + with self._lock: + attr = getattr(self._spawn, name) + if callable(attr): + def locked(*args, **kwargs): + with self._lock: + return attr(*args, **kwargs) + return locked + return attr + + class LabgridComponent: def __init__(self, cwd): self.cwd = str(cwd) self.spawn = None self.reader = None + @property + def spawn(self): + return self._spawn + + @spawn.setter + def spawn(self, spawn): + self._spawn = _LockedSpawn(spawn) if spawn is not None else None + def stop(self): logging.info("stopping %s pid=%s", self.__class__.__name__, self.spawn.pid) @@ -83,16 +114,19 @@ def keep_reading(spawn): "The output from background processes must be read to avoid blocking them." while spawn.isalive(): try: - data = spawn.read_nonblocking(size=1024, timeout=0.1) + data = spawn.read_nonblocking(size=1024, timeout=0) if not data: return except pexpect.TIMEOUT: - continue + pass except pexpect.EOF: return except OSError: return + # give external LabgridComponent.spawn users a chance to acquire the lock + time.sleep(0.001) + def start_reader(self): self.reader = threading.Thread( target=LabgridComponent.keep_reading,