From 46bdb44cfbe0a8ee317774c4b6fcf96e04856b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Tue, 5 May 2026 09:20:53 +0200 Subject: [PATCH] Route py_func/lua_func subprocess stdio into the parent log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stdout/stderr of the subprocesses were going to DEVNULL — early-startup errors (lua require failures, exceptions before stdio_redir kicks in) were lost. New helper proc_drain.drain_to_log spawns a daemon thread per pipe that print()s each line through stdio_redir, so it reaches the log + live output. Used by py_process and lua_process with [py_func]/[lua_func] prefixes. Co-Authored-By: Claude Opus 4.7 --- src/testium/interpreter/utils/lua_process.py | 9 +++- src/testium/interpreter/utils/proc_drain.py | 48 ++++++++++++++++++++ src/testium/interpreter/utils/py_process.py | 10 +++- 3 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 src/testium/interpreter/utils/proc_drain.py diff --git a/src/testium/interpreter/utils/lua_process.py b/src/testium/interpreter/utils/lua_process.py index e24688a..9aa1ece 100644 --- a/src/testium/interpreter/utils/lua_process.py +++ b/src/testium/interpreter/utils/lua_process.py @@ -8,6 +8,7 @@ from runtime.jrpc import JsonRpcClient from interpreter.utils.paths import subproc_path from runtime.tum_except import ETUMRuntimeError from interpreter.utils import bins +from interpreter.utils.proc_drain import drain_to_log class LuaProcessBase: @@ -93,10 +94,14 @@ class LuaProcessBase: self._process = subprocess.Popen( params, env=env, cwd=func_proc_path, stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, restore_signals=False, ) + # Route subprocess stdout/stderr (lua require failures, syntax + # errors, anything written to fd 1/2 before the in-script + # remote_print is set up) into the parent's log. + drain_to_log(self._process, prefix="[lua_func] ") self._rpc = JsonRpcClient( "localhost", self._port, req_handler=self._req_handler diff --git a/src/testium/interpreter/utils/proc_drain.py b/src/testium/interpreter/utils/proc_drain.py new file mode 100644 index 0000000..4dd0ccf --- /dev/null +++ b/src/testium/interpreter/utils/proc_drain.py @@ -0,0 +1,48 @@ +"""Drain a subprocess stdout/stderr into testium's print pipeline. + +Captured lines go through the parent's stdio_redir, so they reach the +test log AND the live output (terminal in batch mode, GUI text panel +in -r mode). This is essential for diagnosing early-startup errors +of py_func / lua_func subprocesses (missing modules, unhandled +exceptions before the in-process redirection kicks in, lua +``require`` failures, anything written to fd 1/2 directly). +""" +import threading + + +def _drain_pipe(pipe, prefix): + try: + for raw in iter(pipe.readline, b""): + line = raw.decode("utf-8", errors="replace").rstrip("\r\n") + if not line: + continue + if prefix: + print(f"{prefix}{line}") + else: + print(line) + finally: + try: + pipe.close() + except Exception: + pass + + +def drain_to_log(process, prefix=""): + """Spawn daemon threads that read ``process.stdout`` and + ``process.stderr`` line by line and print each line through the + parent's stdout (so it reaches the log + live output). + + Each thread exits cleanly when the subprocess closes the + corresponding pipe (i.e. when it exits). Daemon flag ensures they + do not block testium exit. + """ + threads = [] + for pipe in (process.stdout, process.stderr): + if pipe is None: + continue + t = threading.Thread( + target=_drain_pipe, args=(pipe, prefix), daemon=True, + ) + t.start() + threads.append(t) + return threads diff --git a/src/testium/interpreter/utils/py_process.py b/src/testium/interpreter/utils/py_process.py index 9383945..595c1f5 100644 --- a/src/testium/interpreter/utils/py_process.py +++ b/src/testium/interpreter/utils/py_process.py @@ -7,6 +7,7 @@ import api.testium as tm from runtime.tum_except import ETUMRuntimeError from interpreter.utils.paths import testium_path, subproc_path from interpreter.utils import bins +from interpreter.utils.proc_drain import drain_to_log class PyProcessBase: @@ -77,10 +78,15 @@ class PyProcessBase: self._process = subprocess.Popen( params, env=env, cwd=func_proc_path, stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, restore_signals=False, ) + # Route subprocess stdout/stderr (early-startup errors, + # unhandled exceptions, anything written to fd 1/2 before the + # in-process JSON-RPC stdio_redir kicks in) into the parent's + # log. + drain_to_log(self._process, prefix="[py_func] ") self._rpc = JsonRpcClient( "localhost", self._port, req_handler=self._req_handler