diff --git a/evalharness/cli.py b/evalharness/cli.py index 246adbe..0ffc363 100644 --- a/evalharness/cli.py +++ b/evalharness/cli.py @@ -558,7 +558,10 @@ def _cmd_eval_run(args) -> int: # One reporter for the WHOLE run: overall bar (which # benchmark) + sample bar (which sample), reused per # benchmark via reset_samples(). - if _shared_reporter is None: + if _shared_reporter is None and console.is_terminal: + # live bars only on a real terminal: through pipes + # (| grep, > log) rich's refresh thread misbehaves + # and stalls the run -- plain phases instead _shared_reporter = RichTerminalProgress(console=console) _shared_reporter.owned_externally = True progress_reporter = _shared_reporter diff --git a/evalharness/progress/rich_terminal.py b/evalharness/progress/rich_terminal.py index 3da217b..6250c58 100644 --- a/evalharness/progress/rich_terminal.py +++ b/evalharness/progress/rich_terminal.py @@ -18,6 +18,18 @@ class RichTerminalProgress: # accept an EXTERNAL console: CLI phase messages and the live bar must # share one console, or the two writers interleave and repaint wrongly self.console = console or Console() + # NON-TERMINAL (pipes, file redirects): rich's live refresh thread + # misbehaves and can stall the whole run. Disable everything live; + # log() degrades to a plain console print. Checked HERE so every + # creation path is safe regardless of caller logic. + # double check: FORCE_COLOR/FORCE_TERMINAL env can make rich claim + # terminal-ness while stdout is actually a pipe/file -> live thread + # stalls. Require the REAL underlying stream to be a tty. + import sys as _sys + + _tty = getattr(self.console.file, 'isatty', None) + self.disabled = not (self.console.is_terminal + and callable(_tty) and _tty()) self.overall_id = None self.overall_total = 0 self.progress = Progress( @@ -43,6 +55,8 @@ class RichTerminalProgress: self.heartbeat_task = None def set_overall(self, total: int, done: int, label: str = 'benches'): + if self.disabled: + return """Multi-benchmark runs: a bar above the sample bar showing which benchmark we are on (also covers loading/scoring phases).""" self.overall_total = total @@ -58,10 +72,14 @@ class RichTerminalProgress: self.progress.update(self.overall_id, completed=min(done, total)) def advance_overall(self): + if self.disabled: + return if self.overall_id is not None: self.progress.advance(self.overall_id) def start(self, total: int, description: str, completed: int = 0): + if self.disabled: + return self.started = time.monotonic() if self.task_id is not None: return # one live reporter at a time; reuse across benchmarks @@ -74,12 +92,17 @@ class RichTerminalProgress: failed=0, rate="0.00", inflight=0, + cur="0s", + elapsed="0s", + eta="-", waiting="00:00", last_result="restored", ) self.heartbeat_task = asyncio.create_task(self._heartbeat()) def reset_samples(self, total: int, description: str, completed: int = 0): + if self.disabled: + return """Start (or re-target) the per-sample task for the next benchmark.""" self.started = time.monotonic() self.inflight = 0 @@ -90,6 +113,7 @@ class RichTerminalProgress: self.task_id = self.progress.add_task( desc, total=total, completed=min(completed, total), success=completed, failed=0, rate='0.00', inflight=0, + cur='0s', elapsed='0s', eta='-', waiting='00:00', last_result='restored') self.heartbeat_task = asyncio.create_task(self._heartbeat()) else: @@ -100,12 +124,16 @@ class RichTerminalProgress: last_result='restored') def set_bench_tag(self, tag: str): + if self.disabled: + return """Persistent counter shown on the sample bar, e.g. '[1/6]'.""" self.bench_tag = tag + ' ' if tag else '' if self._last_phase: self.set_phase(self._last_phase) def set_phase(self, phase: str): + if self.disabled: + return self._last_phase = phase """Retag the sample bar with what is happening (generating/scoring/ writing) -- the bar alone does not say which stage we are in.""" @@ -115,6 +143,8 @@ class RichTerminalProgress: description=f'[green]{self.bench_tag}{self.bench_name} ยท {phase}[/green]') def begin_sample(self, label: str): + if self.disabled: + return if self.task_id is None: return self.inflight += 1 @@ -123,6 +153,8 @@ class RichTerminalProgress: waiting="00:00", last_result=f"waiting {label}") def rollback(self): + if self.disabled: + return """Pair a begin_sample that will NOT reach advance (retry path): just decrement the in-flight count, no success/fail bookkeeping.""" self.inflight = max(0, self.inflight - 1) @@ -130,6 +162,8 @@ class RichTerminalProgress: self.progress.update(self.task_id, inflight=self.inflight, cur='0s') def advance(self, success: bool = True): + if self.disabled: + return if self.task_id is None: return task = self.progress.tasks[self.task_id] @@ -172,12 +206,10 @@ class RichTerminalProgress: interleaved/repainted output; Progress.print routes through the live region correctly. """ - # highlight=False: rich auto-colorizes numbers, which made narration - # lines look inconsistent (some numbers cyan, some plain) - if self.task_id is not None: - self.progress.print(message, highlight=False) - else: + if self.disabled or self.task_id is None: self.console.print(message, highlight=False) + else: + self.progress.print(message, highlight=False) def pause(self): """Temporarily stop the live display (e.g. while hub downloads print