Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion plugins/_code_execution/helpers/shell_local.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import platform
import select
import subprocess
Expand All @@ -8,14 +9,34 @@
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
self.full_output = ''
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)

Expand Down
8 changes: 7 additions & 1 deletion plugins/_code_execution/helpers/shell_ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<==@@"
Expand Down Expand Up @@ -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())
Expand Down
11 changes: 9 additions & 2 deletions plugins/_code_execution/helpers/tty_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────
Expand Down
32 changes: 32 additions & 0 deletions tests/test_code_execution_pager.py
Original file line number Diff line number Diff line change
@@ -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