From 528c33b7ef0166e1ef31c98e9b3d290d37e732cc Mon Sep 17 00:00:00 2001 From: shisan Date: Sat, 13 Jun 2026 21:17:57 +0800 Subject: [PATCH 1/2] Disable pagers in non-interactive code execution shells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The code execution tool runs commands inside TTY-backed shells (local PTY and remote SSH). Commands like `git diff`/`git log` detect the TTY and pipe output through a pager (more/less). These shells never receive interactive input, so the pager blocks forever and spins at 100% CPU per process — on a 16-core host 5 pager processes pegged 5 cores for 8+ hours (#1697). Disable pagers in both session types: - LocalInteractiveSession: inject PAGER=cat / GIT_PAGER=cat into the TTY env - SSHInteractiveSession: export the same in the initial shell command `cat` streams the output through instead of blocking, and also covers other pager-using tools (man, systemctl, journalctl). Adds regression tests. Fixes #1697 --- .../_code_execution/helpers/shell_local.py | 23 ++++++++++++- plugins/_code_execution/helpers/shell_ssh.py | 8 ++++- tests/test_code_execution_pager.py | 32 +++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 tests/test_code_execution_pager.py diff --git a/plugins/_code_execution/helpers/shell_local.py b/plugins/_code_execution/helpers/shell_local.py index d10c4d3c7f..515f30916f 100644 --- a/plugins/_code_execution/helpers/shell_local.py +++ b/plugins/_code_execution/helpers/shell_local.py @@ -1,3 +1,4 @@ +import os import platform import select import subprocess @@ -8,6 +9,22 @@ from plugins._code_execution.helpers import tty_session from plugins._code_execution.helpers.shell_ssh import clean_string + +def disable_pagers_in_env(env: dict | None = None) -> dict: + """Return a copy of ``env`` with terminal pagers disabled. + + Commands such as ``git diff``/``git log`` detect a TTY and pipe their output + through a pager (``more``/``less``). The non-interactive shells created by + the code execution tool never receive any user input, so the pager blocks + forever and spins at 100% CPU per process. Pointing the pager variables at + ``cat`` lets the output stream through instead. See issue #1697. + """ + env = dict(env if env is not None else os.environ) + env["PAGER"] = "cat" + env["GIT_PAGER"] = "cat" + return env + + class LocalInteractiveSession: def __init__(self, cwd: str|None = None): self.session: tty_session.TTYSession|None = None @@ -15,7 +32,11 @@ def __init__(self, cwd: str|None = None): self.cwd = cwd async def connect(self): - self.session = tty_session.TTYSession(runtime.get_terminal_executable(), cwd=self.cwd) + self.session = tty_session.TTYSession( + runtime.get_terminal_executable(), + cwd=self.cwd, + env=disable_pagers_in_env(), + ) await self.session.start() await self.session.read_full_until_idle(idle_timeout=1, total_timeout=1) diff --git a/plugins/_code_execution/helpers/shell_ssh.py b/plugins/_code_execution/helpers/shell_ssh.py index 9aa9b4706a..52a23bade4 100644 --- a/plugins/_code_execution/helpers/shell_ssh.py +++ b/plugins/_code_execution/helpers/shell_ssh.py @@ -8,6 +8,12 @@ # from helpers.strings import calculate_valid_match_lengths +# Injected into every new SSH shell to keep it safe for non-interactive use. +# Pagers (more/less) would otherwise block forever waiting for input that +# never arrives and spin at 100% CPU; see issue #1697. +PAGER_DISABLE_COMMAND = "export GIT_PAGER=cat; export PAGER=cat" + + class SSHInteractiveSession: # end_comment = "# @@==>> SSHInteractiveSession End-of-Command <<==@@" @@ -63,7 +69,7 @@ async def connect(self, keepalive_interval: int = 5): self.shell = self.client.invoke_shell(width=100, height=50) # disable systemd/OSC prompt metadata and disable local echo - initial_command = "unset PROMPT_COMMAND PS0; stty -echo" + initial_command = f"unset PROMPT_COMMAND PS0; stty -echo; {PAGER_DISABLE_COMMAND}" if self.cwd: initial_command = f"cd {self.cwd}; {initial_command}" self.shell.send(f"{initial_command}\n".encode()) diff --git a/tests/test_code_execution_pager.py b/tests/test_code_execution_pager.py new file mode 100644 index 0000000000..3e80d457de --- /dev/null +++ b/tests/test_code_execution_pager.py @@ -0,0 +1,32 @@ +"""Regression tests for issue #1697. + +Pagers (more/less) must be disabled in the non-interactive shells created by the +code execution tool: without user input they block forever and spin at 100% CPU. +""" + +from plugins._code_execution.helpers import shell_local, shell_ssh + + +def test_local_env_disables_pagers_and_preserves_existing(): + env = shell_local.disable_pagers_in_env({"PATH": "/usr/bin", "PAGER": "less"}) + assert env["PAGER"] == "cat" + assert env["GIT_PAGER"] == "cat" + # pre-existing keys are preserved + assert env["PATH"] == "/usr/bin" + + +def test_local_env_defaults_to_environ(): + env = shell_local.disable_pagers_in_env() + assert env["PAGER"] == "cat" + assert env["GIT_PAGER"] == "cat" + + +def test_local_env_does_not_mutate_input(): + src = {"PATH": "/usr/bin"} + shell_local.disable_pagers_in_env(src) + assert src == {"PATH": "/usr/bin"} + + +def test_ssh_command_disables_pagers(): + assert "GIT_PAGER=cat" in shell_ssh.PAGER_DISABLE_COMMAND + assert "PAGER=cat" in shell_ssh.PAGER_DISABLE_COMMAND From a18a35977a0e7dd4c91e216b5a5bec1b1665d9a1 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:13:42 +0200 Subject: [PATCH 2/2] Fix pytest capture import in TTY session Guard stream reconfiguration so pytest capture objects without reconfigure support can import the code execution TTY helper while preserving UTF-8 error handling for normal streams. --- plugins/_code_execution/helpers/tty_session.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/plugins/_code_execution/helpers/tty_session.py b/plugins/_code_execution/helpers/tty_session.py index 444b11816a..986e63ecd4 100644 --- a/plugins/_code_execution/helpers/tty_session.py +++ b/plugins/_code_execution/helpers/tty_session.py @@ -6,9 +6,16 @@ import msvcrt +def _reconfigure_stream_errors(stream) -> None: + reconfigure = getattr(stream, "reconfigure", None) + if not callable(reconfigure): + return + reconfigure(errors="replace") + + # Make stdin / stdout tolerant to broken UTF-8 so input() never aborts -sys.stdin.reconfigure(errors="replace") # type: ignore -sys.stdout.reconfigure(errors="replace") # type: ignore +_reconfigure_stream_errors(sys.stdin) +_reconfigure_stream_errors(sys.stdout) # ──────────────────────────── PUBLIC CLASS ────────────────────────────