From 3d0ece413aec3e8b677a7036e9c47001514e7ed2 Mon Sep 17 00:00:00 2001 From: Germain Haugou Date: Wed, 11 Mar 2026 13:54:31 +0100 Subject: [PATCH] fix: Replace PTY with pipes for output capture PTY output capture had race conditions where EIO could fire before buffered data was consumed, causing empty output in Checker commands. Switch to subprocess.PIPE + start_new_session=True: - Pipes give reliable output capture (no EIO races) - start_new_session isolates the process group (prevents terminal corruption from SDL2/ncurses, enables killpg) - stderr merged into stdout via STDOUT - stdin set to DEVNULL 136 tests passing. --- python/gvtest/live_display.py | 7 +++++-- python/gvtest/runner.py | 2 +- python/gvtest/tests.py | 36 ++++++++++++++--------------------- python/gvtest/tui.py | 9 ++++++++- 4 files changed, 28 insertions(+), 26 deletions(-) diff --git a/python/gvtest/live_display.py b/python/gvtest/live_display.py index 7fd498d..da8b813 100644 --- a/python/gvtest/live_display.py +++ b/python/gvtest/live_display.py @@ -98,8 +98,11 @@ def stop(self) -> None: def test_started( self, test_id: int, name: str, config: str ) -> None: - """No-op for progress mode (no running panel).""" - pass + """Log START message above the progress bar.""" + self.log( + f"[blue]{'START'.ljust(8)}[/blue]" + f"[bold]{name}[/bold] {config}" + ) def test_finished( self, test_id: int, status: str diff --git a/python/gvtest/runner.py b/python/gvtest/runner.py index d226708..9f0bacf 100644 --- a/python/gvtest/runner.py +++ b/python/gvtest/runner.py @@ -255,7 +255,7 @@ def run(self) -> None: from gvtest.live_display import LiveDisplay from rich.console import Console self.live_display = LiveDisplay( - Console(highlight=False) + Console(highlight=False, stderr=True) ) # Start with 0, update total after enqueue self.live_display.start(0) diff --git a/python/gvtest/tests.py b/python/gvtest/tests.py index 657099d..ac3b18b 100644 --- a/python/gvtest/tests.py +++ b/python/gvtest/tests.py @@ -40,7 +40,7 @@ from rich.console import Console from rich.table import Table -_console = Console(highlight=False) +_console = Console(highlight=False, stderr=True) class TestRun(object): @@ -268,36 +268,28 @@ def __exec_process(self, command: str, envvars: dict[str, str] | None = None) -> if envvars is not None: env.update(envvars) - # Use a PTY to isolate terminal state — prevents - # tests from corrupting the parent terminal - # (e.g. raw mode, no-echo from SDL2/ncurses) - master_fd, slave_fd = os.openpty() - + # Use pipes + start_new_session for isolation. + # The new session prevents child processes from + # corrupting the parent terminal (SDL2/ncurses), + # and os.killpg() can kill the whole group. proc: subprocess.Popen[bytes] = subprocess.Popen( - command, stdout=slave_fd, - stderr=slave_fd, stdin=slave_fd, + command, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, shell=True, cwd=self.test.path, env=env, start_new_session=True ) - # Close slave in parent — only the child uses it - os.close(slave_fd) - self.current_proc = proc self.lock.release() - # Read from master fd - master_file = os.fdopen(master_fd, 'rb') - try: - for line in io.TextIOWrapper( - master_file, encoding="utf-8", - errors='replace' - ): - self.__dump_test_msg(line) - except IOError: - # PTY closed when process exits - pass + assert proc.stdout is not None + for line in io.TextIOWrapper( + proc.stdout, encoding="utf-8", + errors='replace' + ): + self.__dump_test_msg(line) retval: int = proc.wait() self.current_proc = None diff --git a/python/gvtest/tui.py b/python/gvtest/tui.py index 962dc5d..1834af1 100644 --- a/python/gvtest/tui.py +++ b/python/gvtest/tui.py @@ -198,7 +198,7 @@ def run(self, stdscr: Any) -> None: curses.init_pair(3, curses.COLOR_GREEN, -1) curses.init_pair(4, curses.COLOR_YELLOW, -1) curses.init_pair(5, curses.COLOR_MAGENTA, -1) - curses.init_pair(6, curses.COLOR_CYAN, -1) + curses.init_pair(6, curses.COLOR_BLUE, -1) # Progress bar colors (background) curses.init_pair(7, curses.COLOR_BLACK, curses.COLOR_GREEN) @@ -601,6 +601,9 @@ def _draw_targets( completed / total * bar_w ) filled_w = max(1, min(filled_w, bar_w)) + # Ensure at least 1 empty char when not done + if completed < total and filled_w == bar_w: + filled_w = bar_w - 1 # Distribute within filled_w g = max(1, round( passed / completed * filled_w @@ -753,6 +756,10 @@ def _draw_progress( self.completed / self.total * bar_w ) filled_w = max(1, min(filled_w, bar_w)) + # Ensure at least 1 empty char when not done + if (self.completed < self.total + and filled_w == bar_w): + filled_w = bar_w - 1 g = max(1, round( self.passed / self.completed * filled_w )) if self.passed > 0 else 0