From 4fd2a1e75b2fc23e55ae60a51c63ffc8ae86b0f6 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sat, 15 Aug 2026 20:48:55 +0200 Subject: [PATCH 01/83] Add PTY characterization harness for stale controlling-TTY topology The harness reconstructs the topology from ADR-001 - a child spawned into a new session whose fd 0 still points at the terminal of the session it left - using only synthetic processes, so it documents the defect independently of any fix and never validates one. The Node probe is a manual, non-gating diagnostic whose outcome varies by host. --- tests/diagnostics/node_tty_probe.py | 215 ++++++++++++++++++++++ tests/test_pty_characterization.py | 266 ++++++++++++++++++++++++++++ 2 files changed, 481 insertions(+) create mode 100644 tests/diagnostics/node_tty_probe.py create mode 100644 tests/test_pty_characterization.py diff --git a/tests/diagnostics/node_tty_probe.py b/tests/diagnostics/node_tty_probe.py new file mode 100644 index 00000000..688c76ff --- /dev/null +++ b/tests/diagnostics/node_tty_probe.py @@ -0,0 +1,215 @@ +"""Manual diagnostic: run `node --version` under the ADR-001 stale controlling-TTY topology. + +Usage: + + python tests/diagnostics/node_tty_probe.py + +Runs Node with fd 0 bound to a terminal owned by a session the process has just left — +the same topology `tests/test_pty_characterization.py` constructs — and prints platform, +Node version, exit status or terminating signal, and captured stderr. + +This is not collected by pytest and never gates anything: the observed outcome varies by +macOS and Node version. A green result does not invalidate ADR-001; it only narrows the +blast radius to specific macOS/Node combinations. The script always exits 0, including +when Node is not installed. +""" + +import json +import os +import platform +import re +import shutil +import signal +import subprocess +import sys +import time + +NODE_TIMEOUT_SECONDS = 30 +HARNESS_TIMEOUT_SECONDS = 60 +CLEANUP_TIMEOUT_SECONDS = 10 +KILL_POLL_TIMEOUT_SECONDS = 5 + +# Node leaves the harness's session, so the outer timeout path has to kill it by pid; +# the harness announces the pid on stderr before waiting on it. +NODE_PID_PATTERN = re.compile(r"^node_pid=(\d+)$", re.MULTILINE) + +# Source of the middle process: it owns the terminal, then spawns Node into a new +# session with the terminal still on fd 0. +HARNESS_SOURCE = """ +import fcntl +import json +import os +import subprocess +import sys +import termios + +NODE_TIMEOUT_SECONDS = %d +CLEANUP_TIMEOUT_SECONDS = %d + + +def reap(process): + process.kill() + try: + process.communicate(timeout=CLEANUP_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + pass + + +def main(): + node_path = sys.argv[1] + + if os.getsid(0) != os.getpid(): + os.setsid() + + report = {"harness_sid": os.getsid(0), "error": None} + master_fd, slave_fd = os.openpty() + process = None + try: + fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0) + report["controlling_tty"] = os.ttyname(slave_fd) + + process = subprocess.Popen( + [node_path, "--version"], + stdin=slave_fd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True, + ) + sys.stderr.write("node_pid=%%d\\n" %% process.pid) + sys.stderr.flush() + + stdout, stderr = process.communicate(timeout=NODE_TIMEOUT_SECONDS) + report["returncode"] = process.returncode + report["stdout"] = stdout.decode(errors="replace") + report["stderr"] = stderr.decode(errors="replace") + except subprocess.TimeoutExpired: + reap(process) + report["error"] = "node did not exit within %%d seconds" %% NODE_TIMEOUT_SECONDS + except Exception as exc: + if process is not None and process.poll() is None: + reap(process) + report["error"] = "%%s: %%s" %% (type(exc).__name__, exc) + finally: + # The master is held open for the lifetime of the child so reads on the slave + # cannot fail with EIO. + os.close(slave_fd) + os.close(master_fd) + + sys.stdout.write(json.dumps(report)) + sys.stdout.flush() + + +main() +""" % ( + NODE_TIMEOUT_SECONDS, + CLEANUP_TIMEOUT_SECONDS, +) + + +def kill_process_group(pid): + """Best-effort teardown of the orphaned child; it is a session leader, so pgid == pid.""" + for sig in (signal.SIGTERM, signal.SIGKILL): + try: + os.killpg(pid, sig) + except (ProcessLookupError, PermissionError): + return + deadline = time.monotonic() + KILL_POLL_TIMEOUT_SECONDS + while time.monotonic() < deadline: + try: + os.killpg(pid, 0) + except (ProcessLookupError, PermissionError): + return + time.sleep(0.05) + + +def reap(process): + """Terminates the harness, escalating to SIGKILL, and returns whatever it had written.""" + if process.poll() is None: + process.terminate() + try: + return process.communicate(timeout=CLEANUP_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + try: + return process.communicate(timeout=CLEANUP_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + return b"", b"" + + +def describe_platform(): + if sys.platform == "darwin": + completed = subprocess.run(["sw_vers"], capture_output=True, timeout=30) + return completed.stdout.decode(errors="replace").strip() + return " ".join(platform.uname()) + + +def node_version(node_path): + completed = subprocess.run([node_path, "--version"], capture_output=True, timeout=NODE_TIMEOUT_SECONDS) + return completed.stdout.decode(errors="replace").strip() or "unknown" + + +def describe_exit(report): + if report.get("error"): + return report["error"] + + returncode = report.get("returncode") + if returncode is None: + return "unknown" + if returncode < 0: + return "terminated by signal %d (%s)" % (-returncode, signal.Signals(-returncode).name) + return "exit status %d" % returncode + + +def run_probe(node_path): + process = subprocess.Popen( + [sys.executable, "-c", HARNESS_SOURCE, node_path], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + stdout, stderr = process.communicate(timeout=HARNESS_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + stdout, stderr = reap(process) + match = NODE_PID_PATTERN.search(stderr.decode(errors="replace")) + if match: + kill_process_group(int(match.group(1))) + return {"error": "harness did not finish within %d seconds" % HARNESS_TIMEOUT_SECONDS} + + if process.returncode != 0 or not stdout.strip(): + return {"error": "harness failed: %s" % stderr.decode(errors="replace").strip()} + return json.loads(stdout.decode()) + + +def main(): + print("=== ADR-001 stale controlling-TTY probe (diagnostic, non-gating) ===") + print("platform:") + print(describe_platform()) + + if sys.platform == "win32": + print("node: not probed - the topology relies on setsid() and TIOCSCTTY, which are POSIX-only") + return 0 + + node_path = shutil.which("node") + if node_path is None: + print("node: not installed - nothing to probe") + return 0 + + print("node path: %s" % node_path) + print("node version: %s" % node_version(node_path)) + + report = run_probe(node_path) + print("controlling tty: %s" % report.get("controlling_tty", "n/a")) + print("result: %s" % describe_exit(report)) + print("stdout: %s" % (report.get("stdout", "").strip() or "")) + print("stderr: %s" % (report.get("stderr", "").strip() or "")) + print("A green result does not invalidate ADR-001; it narrows the blast radius.") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as exc: # the diagnostic must never fail the caller + print("probe could not run: %s: %s" % (type(exc).__name__, exc)) + sys.exit(0) diff --git a/tests/test_pty_characterization.py b/tests/test_pty_characterization.py new file mode 100644 index 00000000..14d3764d --- /dev/null +++ b/tests/test_pty_characterization.py @@ -0,0 +1,266 @@ +"""Characterization of the stale controlling-TTY topology described in ADR-001. + +The harness rebuilds the process topology that `execute_script()` produces today — +a child spawned with `start_new_session=True` whose fd 0 still points at a terminal +owned by the session the child has just left — without touching any production code. +It documents the defect; it never validates a fix. + +Three levels are needed. `os.openpty()` alone yields a terminal owned by no session, +which is a weaker state than the one under study, so a middle process takes the slave +as its controlling terminal via TIOCSCTTY. That process is a subprocess rather than +the test runner itself because `setsid()` in pytest would detach the runner. + + pytest process + └── harness subprocess setsid(), then TIOCSCTTY on the slave + └── probe grandchild start_new_session=True, fd 0 = slave + +The same two-level spawn shape with fd 0 on `/dev/null` is exercised as a control +case, documenting why the defect is reachable only from an interactive terminal. + +Only the constructed topology is asserted. The `termios.tcgetattr(0)` outcome is +recorded rather than asserted, because it varies by host. +""" + +import json +import os +import re +import signal +import subprocess +import sys +import time + +import pytest + +HARNESS_TIMEOUT_SECONDS = 30 +CLEANUP_TIMEOUT_SECONDS = 10 +KILL_POLL_TIMEOUT_SECONDS = 5 + +# The grandchild leaves the harness's session, so the outer timeout path has to kill it +# by pid; the harness announces the pid on stderr before waiting on it. +GRANDCHILD_PID_PATTERN = re.compile(r"^grandchild_pid=(\d+)$", re.MULTILINE) + +# Source of the grandchild. Reports the state of fd 0 as JSON on stdout. +PROBE_SOURCE = """ +import errno +import json +import os +import sys +import termios + +report = { + "pid": os.getpid(), + "sid": os.getsid(0), + "isatty_stdin": os.isatty(0), +} +report["is_session_leader"] = report["sid"] == report["pid"] + +try: + termios.tcgetattr(0) + report["tcgetattr"] = "ok" + report["tcgetattr_errno"] = None + report["tcgetattr_errno_name"] = None +except (OSError, termios.error) as exc: + # termios.error is not an OSError and carries its errno only in args[0]. + code = getattr(exc, "errno", None) + if code is None and exc.args: + code = exc.args[0] + report["tcgetattr"] = "error" + report["tcgetattr_errno"] = code + report["tcgetattr_errno_name"] = errno.errorcode.get(code, str(code)) + +sys.stdout.write(json.dumps(report)) +sys.stdout.flush() +""" + +# Source of the middle process. Owns the terminal, then spawns the grandchild. +HARNESS_SOURCE = """ +import fcntl +import json +import os +import subprocess +import sys +import termios + +PROBE_TIMEOUT_SECONDS = 20 +CLEANUP_TIMEOUT_SECONDS = 10 + + +def reap(process): + process.kill() + try: + process.communicate(timeout=CLEANUP_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + pass + + +def main(): + mode = sys.argv[1] + probe_source = sys.argv[2] + + if os.getsid(0) != os.getpid(): + os.setsid() + + report = {"harness_pid": os.getpid(), "harness_sid": os.getsid(0), "mode": mode, "error": None} + open_fds = [] + process = None + try: + if mode == "pty": + master_fd, slave_fd = os.openpty() + open_fds.extend([slave_fd, master_fd]) + # The slave becomes this session's controlling terminal; the grandchild + # then leaves the session while keeping the slave on fd 0. + fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0) + report["controlling_tty"] = os.ttyname(slave_fd) + stdin_fd = slave_fd + else: + stdin_fd = os.open(os.devnull, os.O_RDONLY) + open_fds.append(stdin_fd) + report["controlling_tty"] = None + + process = subprocess.Popen( + [sys.executable, "-c", probe_source], + stdin=stdin_fd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True, + ) + sys.stderr.write("grandchild_pid=%d\\n" % process.pid) + sys.stderr.flush() + + stdout, stderr = process.communicate(timeout=PROBE_TIMEOUT_SECONDS) + report["probe_exit_code"] = process.returncode + report["probe_stderr"] = stderr.decode(errors="replace") + report["probe"] = json.loads(stdout.decode()) if stdout.strip() else None + except subprocess.TimeoutExpired: + reap(process) + report["error"] = "probe did not report within %d seconds" % PROBE_TIMEOUT_SECONDS + except Exception as exc: + if process is not None and process.poll() is None: + reap(process) + report["error"] = "%s: %s" % (type(exc).__name__, exc) + finally: + # The PTY master is held open for the lifetime of the grandchild so reads on + # the slave cannot fail with EIO. + for fd in open_fds: + os.close(fd) + + sys.stdout.write(json.dumps(report)) + sys.stdout.flush() + + +main() +""" + +pytestmark = pytest.mark.skipif( + sys.platform == "win32", + reason="The topology relies on setsid() and TIOCSCTTY, which are POSIX-only.", +) + + +def _kill_process_group(pid): + """Best-effort teardown of the orphaned grandchild; it is a session leader, so pgid == pid.""" + for sig in (signal.SIGTERM, signal.SIGKILL): + try: + os.killpg(pid, sig) + except (ProcessLookupError, PermissionError): + return + deadline = time.monotonic() + KILL_POLL_TIMEOUT_SECONDS + while time.monotonic() < deadline: + try: + os.killpg(pid, 0) + except (ProcessLookupError, PermissionError): + return + time.sleep(0.05) + + +def _reap(process): + """Terminates the harness, escalating to SIGKILL, and returns whatever it had written.""" + if process.poll() is None: + process.terminate() + try: + return process.communicate(timeout=CLEANUP_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + try: + return process.communicate(timeout=CLEANUP_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + return b"", b"" + + +def _run_harness(mode): + process = subprocess.Popen( + [sys.executable, "-c", HARNESS_SOURCE, mode, PROBE_SOURCE], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + stdout, stderr = process.communicate(timeout=HARNESS_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + stdout, stderr = _reap(process) + match = GRANDCHILD_PID_PATTERN.search(stderr.decode(errors="replace")) + if match: + _kill_process_group(int(match.group(1))) + pytest.fail("harness did not finish within %d seconds" % HARNESS_TIMEOUT_SECONDS) + + stderr_text = stderr.decode(errors="replace") + assert process.returncode == 0, "harness failed: %s" % stderr_text + + stdout_text = stdout.decode(errors="replace") + assert stdout_text.strip(), "harness produced no report; stderr: %s" % stderr_text + + report = json.loads(stdout_text) + assert report["error"] is None, "harness could not build the topology: %s" % report["error"] + assert report["probe"] is not None, "grandchild produced no report: %s" % report.get("probe_stderr") + return report + + +@pytest.fixture(scope="module") +def stale_controlling_tty_report(): + """Runs the three-level harness once and returns the grandchild's report.""" + return _run_harness("pty") + + +@pytest.fixture(scope="module") +def non_tty_stdin_report(): + """Runs the same spawn shape with fd 0 on /dev/null and returns the grandchild's report.""" + return _run_harness("devnull") + + +def test_grandchild_stdin_is_a_terminal(stale_controlling_tty_report): + probe = stale_controlling_tty_report["probe"] + + assert probe["isatty_stdin"] is True + assert stale_controlling_tty_report["probe_exit_code"] == 0 + + +def test_grandchild_left_the_session_that_owns_the_terminal(stale_controlling_tty_report): + probe = stale_controlling_tty_report["probe"] + + assert probe["is_session_leader"] is True + assert probe["sid"] != stale_controlling_tty_report["harness_sid"] + + +def test_grandchild_tcgetattr_outcome_is_recorded(stale_controlling_tty_report, record_property): + probe = stale_controlling_tty_report["probe"] + + record_property("platform", sys.platform) + record_property("tcgetattr", probe["tcgetattr"]) + record_property("tcgetattr_errno", probe["tcgetattr_errno_name"]) + print( + "stale controlling TTY on %s: tcgetattr=%s errno=%s" + % (sys.platform, probe["tcgetattr"], probe["tcgetattr_errno_name"]) + ) + + # The outcome varies by host, so only its presence is asserted. + assert probe["tcgetattr"] in ("ok", "error") + + +def test_grandchild_stdin_is_not_a_terminal_without_a_pty(non_tty_stdin_report): + """Control case: non-interactive callers give the child a non-TTY fd 0.""" + probe = non_tty_stdin_report["probe"] + + assert probe["isatty_stdin"] is False + assert probe["is_session_leader"] is True + assert probe["sid"] != non_tty_stdin_report["harness_sid"] + assert non_tty_stdin_report["probe_exit_code"] == 0 From b52da2cf4fa55b561a06cf34005dad7f1abf1679 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sat, 15 Aug 2026 21:06:22 +0200 Subject: [PATCH 02/83] Add characterization tests for execute_script Locks in today's exit-code passthrough, merged stderr, timeout result, cancellation, large-output drain, and output sanitizing, so the PTY backend rewrite can be proven equivalent. --- tests/test_render_utils.py | 189 +++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 tests/test_render_utils.py diff --git a/tests/test_render_utils.py b/tests/test_render_utils.py new file mode 100644 index 00000000..4314444d --- /dev/null +++ b/tests/test_render_utils.py @@ -0,0 +1,189 @@ +"""Characterization of `render_machine.render_utils.execute_script()`. + +The behaviour asserted here is the behaviour of the pipe-based implementation as it +stands today: exit-code passthrough, stderr merged into stdout, the timeout result, +cancellation, and the pipe-buffer case the drain thread exists for. The PTY backend +that replaces the pipe path has to reproduce all of it. + +Scripts are executed for real, so every case that runs one is POSIX-only; the Windows +branch of `execute_script()` accepts `.ps1` files only. `_sanitize_script_output()` is +platform-neutral and is exercised everywhere. +""" + +import contextlib +import os +import shlex +import stat +import sys +import textwrap +import threading +from pathlib import Path + +import pytest + +from plain2code_exceptions import RenderCancelledError +from render_machine import render_utils + +posix_only = pytest.mark.skipif( + sys.platform == "win32", + reason="execute_script() runs .ps1 scripts on Windows; these cases use shell scripts.", +) + +SCRIPT_TYPE = "Characterization" + +# Larger than the 64KB macOS pipe buffer, so the child blocks on write unless drained. +LARGE_OUTPUT_BYTES = 512 * 1024 + +CLEAR_SCREEN = "\033[2J" + + +def _make_shell_script(directory: Path, name: str, body: str) -> str: + """Writes an executable /bin/sh script and returns its absolute path.""" + script_path = directory / f"{name}.sh" + script_path.write_text("#!/bin/sh\n" + textwrap.dedent(body)) + script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR) + return str(script_path) + + +def _make_python_script(directory: Path, name: str, program: str) -> str: + """Writes a shell wrapper around a Python program and returns the wrapper's path.""" + program_path = directory / f"{name}.py" + program_path.write_text(textwrap.dedent(program)) + return _make_shell_script( + directory, + name, + f'exec {shlex.quote(sys.executable)} {shlex.quote(str(program_path))} "$@"\n', + ) + + +@pytest.fixture +def run_script(): + """Calls execute_script() and removes the output files it leaves behind.""" + output_files = [] + + def _run(*args, **kwargs): + exit_code, output, output_file = render_utils.execute_script(*args, **kwargs) + if output_file: + output_files.append(output_file) + return exit_code, output, output_file + + yield _run + + for output_file in output_files: + with contextlib.suppress(OSError): + os.remove(output_file) + + +@posix_only +def test_successful_script_returns_zero_with_its_output(tmp_path, run_script): + script = _make_shell_script(tmp_path, "success", 'echo "ran with $1 $2"\n') + + exit_code, output, output_file = run_script(script, ["first", "second"], SCRIPT_TYPE, timeout=30) + + assert exit_code == 0 + assert "ran with first second" in output + assert os.path.isfile(output_file) + + +@posix_only +@pytest.mark.parametrize("expected_exit_code", [1, 3, 69]) +def test_failing_script_exit_code_is_returned_verbatim(tmp_path, run_script, expected_exit_code): + script = _make_shell_script( + tmp_path, + f"exit_{expected_exit_code}", + f'echo "failing"\nexit {expected_exit_code}\n', + ) + + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == expected_exit_code + assert "failing" in output + + +@posix_only +def test_stderr_is_merged_into_the_captured_output(tmp_path, run_script): + script = _make_shell_script( + tmp_path, + "both_streams", + 'echo "on stdout"\necho "on stderr" >&2\n', + ) + + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == 0 + assert "on stdout" in output + assert "on stderr" in output + + +@posix_only +def test_output_larger_than_the_pipe_buffer_is_captured_without_deadlock(tmp_path, run_script): + script = _make_python_script( + tmp_path, + "large_output", + f""" + import sys + + sys.stdout.write("x" * {LARGE_OUTPUT_BYTES}) + sys.stdout.write("\\nEND-OF-OUTPUT\\n") + """, + ) + + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=60) + + assert exit_code == 0 + assert output.count("x") == LARGE_OUTPUT_BYTES + assert output.rstrip().endswith("END-OF-OUTPUT") + + +@posix_only +def test_script_exceeding_the_timeout_returns_124_and_keeps_partial_output(tmp_path, run_script): + script = _make_shell_script( + tmp_path, + "slow", + 'echo "printed before the timeout"\nsleep 30\n', + ) + + exit_code, output, output_file = run_script(script, [], SCRIPT_TYPE, timeout=2) + + assert exit_code == render_utils.TIMEOUT_ERROR_EXIT_CODE + assert exit_code == 124 + assert "did not finish in 2 seconds" in output + assert "printed before the timeout" in output + assert "printed before the timeout" in Path(output_file).read_text() + + +@posix_only +def test_set_stop_event_cancels_the_script(tmp_path): + script = _make_shell_script(tmp_path, "cancellable", "sleep 30\n") + stop_event = threading.Event() + stop_event.set() + + with pytest.raises(RenderCancelledError): + render_utils.execute_script(script, [], SCRIPT_TYPE, timeout=30, stop_event=stop_event) + + +@posix_only +def test_script_without_a_path_is_resolved_against_the_working_directory(tmp_path, run_script, monkeypatch): + _make_shell_script(tmp_path, "bare_name", 'echo "resolved from the working directory"\n') + monkeypatch.chdir(tmp_path) + + exit_code, output, _ = run_script("bare_name.sh", [], SCRIPT_TYPE, timeout=30) + + assert exit_code == 0 + assert "resolved from the working directory" in output + + +@pytest.mark.parametrize( + "script_output, expected", + [ + ("plain output", "plain output"), + ("", ""), + (f"before{CLEAR_SCREEN}after", "after"), + (f"first{CLEAR_SCREEN}second{CLEAR_SCREEN}third", "third"), + (f"before\033[H{CLEAR_SCREEN}\033[3Jafter", "after"), + (f"trailing{CLEAR_SCREEN}", ""), + ("\033[31mred\033[0m", "\033[31mred\033[0m"), + ], +) +def test_sanitize_script_output_keeps_only_what_follows_the_last_screen_clear(script_output, expected): + assert render_utils._sanitize_script_output(script_output) == expected From b8c890246fecbe3b51851cd0f68c823101716b35 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sat, 15 Aug 2026 21:21:13 +0200 Subject: [PATCH 03/83] Redirect script stdin to /dev/null A rendered script inherited fd 0 pointing at the renderer's terminal, and a child in a new session bypasses SIGTTIN for a terminal it does not own, so reads succeeded and consumed the user's keystrokes. Pointing fd 0 at /dev/null ends that and gives an immediate EOF instead; interim until the PTY backend lands. --- render_machine/render_utils.py | 1 + tests/test_render_utils.py | 121 ++++++++++++++++++++++++++++++--- 2 files changed, 113 insertions(+), 9 deletions(-) diff --git a/render_machine/render_utils.py b/render_machine/render_utils.py index 790ec61e..b6b83b02 100644 --- a/render_machine/render_utils.py +++ b/render_machine/render_utils.py @@ -109,6 +109,7 @@ def execute_script( # noqa: C901 start_time = time.time() proc = subprocess.Popen( cmd, + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, diff --git a/tests/test_render_utils.py b/tests/test_render_utils.py index 4314444d..f227ea02 100644 --- a/tests/test_render_utils.py +++ b/tests/test_render_utils.py @@ -11,12 +11,14 @@ """ import contextlib +import json import os -import shlex import stat +import subprocess import sys import textwrap import threading +import time from pathlib import Path import pytest @@ -46,14 +48,17 @@ def _make_shell_script(directory: Path, name: str, body: str) -> str: def _make_python_script(directory: Path, name: str, program: str) -> str: - """Writes a shell wrapper around a Python program and returns the wrapper's path.""" - program_path = directory / f"{name}.py" - program_path.write_text(textwrap.dedent(program)) - return _make_shell_script( - directory, - name, - f'exec {shlex.quote(sys.executable)} {shlex.quote(str(program_path))} "$@"\n', - ) + """Writes an executable Python script and returns its absolute path. + + The interpreter is named in the shebang rather than wrapped in a shell, so no shell + ever sits between the caller and the program. A shell that inherits a terminal with + pending input wedges on exit on macOS, which the terminal-isolation cases below rely + on not happening. + """ + script_path = directory / f"{name}.py" + script_path.write_text(f"#!{sys.executable}\n" + textwrap.dedent(program)) + script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR) + return str(script_path) @pytest.fixture @@ -187,3 +192,101 @@ def test_script_without_a_path_is_resolved_against_the_working_directory(tmp_pat ) def test_sanitize_script_output_keeps_only_what_follows_the_last_screen_clear(script_output, expected): assert render_utils._sanitize_script_output(script_output) == expected + + +# --- The terminal-isolation guard ------------------------------------------------ +# +# A rendered script must never be able to read the terminal Codeplain itself is +# attached to. The harness therefore has to hold a real terminal: it puts a PTY slave +# on its own fd 0 and writes to the master, which is what a user typing into the TUI +# does. Without that, pytest's fd 0 is not a terminal and the assertion would hold for +# the wrong reason. + +KEYSTROKES = "secret-keystrokes\n" +CONTROL_PROBE_TIMEOUT_SECONDS = 20 +STDIN_READ_LIMIT = 1024 +IMMEDIATE_EOF_SECONDS = 5 + +# Reports what fd 0 is and what a read of it yields. +STDIN_PROBE_PROGRAM = f""" +import json +import os +import sys +import time + +started = time.monotonic() +data = os.read(0, {STDIN_READ_LIMIT}) +report = {{ + "isatty": os.isatty(0), + "data": data.decode(errors="replace"), + "read_seconds": time.monotonic() - started, +}} +sys.stdout.write(json.dumps(report)) +sys.stdout.flush() +""" + + +@pytest.fixture +def terminal_on_stdin(): + """Puts a PTY slave on the test process's fd 0 and yields the master fd.""" + try: + saved_stdin_fd = os.dup(0) + except OSError as exc: + pytest.skip(f"fd 0 cannot be duplicated in this environment: {exc}") + + master_fd, slave_fd = os.openpty() + os.dup2(slave_fd, 0) + try: + yield master_fd + finally: + os.dup2(saved_stdin_fd, 0) + for fd in (saved_stdin_fd, slave_fd, master_fd): + with contextlib.suppress(OSError): + os.close(fd) + + +def _probe_report(output): + return json.loads(output.strip()) + + +@posix_only +def test_terminal_bytes_reach_a_child_that_inherits_stdin(tmp_path, terminal_on_stdin): + """Control case: proves the harness's terminal really does deliver keystrokes.""" + script = _make_python_script(tmp_path, "inheriting_probe", STDIN_PROBE_PROGRAM) + os.write(terminal_on_stdin, KEYSTROKES.encode()) + + # The spawn shape execute_script() uses, minus the stdin redirection under test. + process = subprocess.Popen( + [script], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + start_new_session=True, + ) + try: + output, _ = process.communicate(timeout=CONTROL_PROBE_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + process.communicate(timeout=CONTROL_PROBE_TIMEOUT_SECONDS) + pytest.fail("the control probe never returned from its read of fd 0") + + report = _probe_report(output) + assert report["isatty"] is True + assert report["data"] == KEYSTROKES + + +@posix_only +def test_script_stdin_is_at_eof_and_never_reads_the_terminal(tmp_path, run_script, terminal_on_stdin): + script = _make_python_script(tmp_path, "stdin_probe", STDIN_PROBE_PROGRAM) + os.write(terminal_on_stdin, KEYSTROKES.encode()) + + started = time.monotonic() + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + elapsed = time.monotonic() - started + + assert exit_code == 0 + report = _probe_report(output) + assert report["isatty"] is False + assert report["data"] == "" + assert report["read_seconds"] < IMMEDIATE_EOF_SECONDS + assert elapsed < CONTROL_PROBE_TIMEOUT_SECONDS From c73034e1207cd54877ac395a97c6d144012e9da3 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sat, 15 Aug 2026 21:22:26 +0200 Subject: [PATCH 04/83] Run tests on macOS and Windows and type-check per platform The macOS bug this branch addresses had no macOS runner, and the unit suite had no Windows runner at all. Per-platform mypy plus per-module strict error codes protect the platform-split modules that follow. The removed test step sourced .env.dev.example, which is not in the repo; coverage still uploads from Ubuntu. --- .github/workflows/lint-and-test.yml | 28 ++++++++++++++++++---------- pyproject.toml | 13 +++++++++++++ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index c757e5e1..082798e3 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -72,8 +72,15 @@ jobs: run: flake8 . mypy: - name: MyPy Type Checking + name: MyPy Type Checking (${{ matrix.platform }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # --platform is a static narrowing setting, so every target is checked from + # one runner: code is verified under the platform where it runs and proven + # guarded under the platforms where it does not. + platform: [linux, darwin, win32] steps: - uses: actions/checkout@v4 - name: Set up Python @@ -90,27 +97,28 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt - name: Type check with mypy - run: mypy . --check-untyped-defs + run: mypy . --check-untyped-defs --platform ${{ matrix.platform }} tests: - name: Run Tests - runs-on: ubuntu-latest + name: Run Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} + cache: pip + cache-dependency-path: requirements.txt - name: Configure git for tests run: | git config --global user.email "test@example.com" git config --global user.name "Test Runner" git config --global init.defaultBranch main - - name: Cache pip - uses: actions/cache@v4 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }} - name: Install dependencies run: | python -m pip install --upgrade pip @@ -118,9 +126,9 @@ jobs: pip install coverage - name: Run tests with coverage run: | - export $(cat .env.dev.example | xargs) coverage run -m pytest tests/ -v coverage xml coverage report - name: Upload coverage reports + if: matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@v3 diff --git a/pyproject.toml b/pyproject.toml index 254ff05c..82f56879 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,6 +110,19 @@ exclude = [ "^examples/", ] +# The global disable_error_code list hides every platform-symbol error, which is +# exactly what the platform-split modules need reported: under the --platform matrix +# a reference to a symbol the target platform lacks must fail. Per-module +# enable_error_code takes precedence over the global disable. +[[tool.mypy.overrides]] +module = [ + "render_machine.terminal_process", + "render_machine._posix_pty", + "render_machine._conpty", + "render_machine.pty_exec", +] +enable_error_code = ["attr-defined", "unreachable", "misc"] + [tool.pytest.ini_options] pythonpath = ["src"] testpaths = ["tests"] From 0955af0591bd6f9914b76da289ccc695b6e24815 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sat, 15 Aug 2026 21:22:26 +0200 Subject: [PATCH 05/83] Run tests on macOS and Windows and type-check per platform The macOS bug this branch addresses had no macOS runner, and the unit suite had no Windows runner at all. Per-platform mypy plus per-module strict error codes protect the platform-split modules that follow. The removed test step sourced .env.dev.example, which is not in the repo; coverage still uploads from Ubuntu. The tests job runs one command per step so a failing command fails the job on Windows, where PowerShell otherwise continues past a failed native command. Tilde-expansion tests set USERPROFILE alongside HOME, since expanduser reads USERPROFILE on Windows.  Conflicts:  .github/workflows/lint-and-test.yml --- .github/workflows/lint-and-test.yml | 35 +++++++++++++++++------------ tests/test_path_resolution.py | 4 ++++ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 082798e3..0b1c78d0 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -114,21 +114,28 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} cache: pip cache-dependency-path: requirements.txt - - name: Configure git for tests - run: | - git config --global user.email "test@example.com" - git config --global user.name "Test Runner" - git config --global init.defaultBranch main - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install coverage + # Every step below runs a single command. Windows runners default to + # PowerShell, where a failing native command does not abort the remaining + # commands of a multi-command run block; one command per step makes a + # failure fail the job on every OS without shell-specific workarounds. + - name: Configure git user email + run: git config --global user.email "test@example.com" + - name: Configure git user name + run: git config --global user.name "Test Runner" + - name: Configure git default branch + run: git config --global init.defaultBranch main + - name: Upgrade pip + run: python -m pip install --upgrade pip + - name: Install requirements + run: pip install -r requirements.txt + - name: Install coverage + run: pip install coverage - name: Run tests with coverage - run: | - coverage run -m pytest tests/ -v - coverage xml - coverage report + run: coverage run -m pytest tests/ -v + - name: Generate coverage XML + run: coverage xml + - name: Show coverage report + run: coverage report - name: Upload coverage reports if: matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@v3 diff --git a/tests/test_path_resolution.py b/tests/test_path_resolution.py index 9ee9790d..f0e21fd5 100644 --- a/tests/test_path_resolution.py +++ b/tests/test_path_resolution.py @@ -36,13 +36,17 @@ def test_dotdot_segments_are_normalized(): def test_leading_tilde_is_expanded(monkeypatch): + # os.path.expanduser reads HOME on POSIX and USERPROFILE on Windows. monkeypatch.setenv("HOME", "/home/alice") + monkeypatch.setenv("USERPROFILE", "/home/alice") result = resolve_path("~/scripts/run.sh", "cli", cwd=CWD, config_dir=CONFIG_DIR, spec_dir=SPEC_DIR) assert result == os.path.normpath("/home/alice/scripts/run.sh") def test_tilde_expansion_applies_regardless_of_source(monkeypatch): + # os.path.expanduser reads HOME on POSIX and USERPROFILE on Windows. monkeypatch.setenv("HOME", "/home/alice") + monkeypatch.setenv("USERPROFILE", "/home/alice") for source in ("cli", "config", "default"): result = resolve_path("~/x", source, cwd=CWD, config_dir=CONFIG_DIR, spec_dir=SPEC_DIR) assert result == os.path.normpath("/home/alice/x") From 5c4053e05e3d7afec0edbebcb6d23f988fb6f111 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sat, 15 Aug 2026 21:46:44 +0200 Subject: [PATCH 06/83] Add PTY launcher with framed handshake The launcher attaches a PTY slave to fds 0, 1 and 2, verifies the terminal invariants before exec, and reports progress as typed length-framed records. An acknowledgment barrier holds the target until the parent has recorded its process group, and -I -S keeps anything else from running before that point. --- render_machine/pty_exec.py | 136 +++++++++++++ tests/test_terminal_process.py | 338 +++++++++++++++++++++++++++++++++ 2 files changed, 474 insertions(+) create mode 100644 render_machine/pty_exec.py create mode 100644 tests/test_terminal_process.py diff --git a/render_machine/pty_exec.py b/render_machine/pty_exec.py new file mode 100644 index 00000000..785297a7 --- /dev/null +++ b/render_machine/pty_exec.py @@ -0,0 +1,136 @@ +"""Launcher that gives a command its own terminal and then becomes that command. + +Spawned by the POSIX backend as ``python -I -S pty_exec.py + -- ``, it attaches the PTY slave to fds 0, 1 and 2, verifies the +terminal invariants that only a pre-exec child can verify, reports progress to the +parent over a framed status pipe, waits for the parent's acknowledgment, and execs. + +The acknowledgment is a barrier: the parent records the process group before releasing +the target, so the target cannot create a descendant the parent does not know how to +terminate. The proof holds only while nothing but this file runs before the ack, which +is what ``-I -S`` guarantees — hence the deliberately tiny import set (os, sys, select, +signal, all builtin C modules) and the absolute-path spawn. +""" + +import os +import select +import signal +import sys + +if sys.platform == "win32": # pragma: no cover - the launcher is POSIX-only + raise ImportError("render_machine.pty_exec is POSIX-only") + +STARTED = 0x01 # record type: the interpreter reached our code +SESSION_READY = 0x02 # record type: setsid done, pgid == pid; parent may record it +FAILED = 0x03 # record type: payload is the framed error text + +HEADER_SIZE = 5 # one type byte + 4-byte big-endian length +MAX_PAYLOAD = 8192 # bound the error text so a record can never be unbounded + +LAUNCH_FAILURE_EXIT_CODE = 127 + +# Backstop against a parent that is alive but never acknowledges — a bug, not an +# operating condition. It sits comfortably above the parent's own handshake bound so +# the parent's deadline expires first in every realistic failure, leaving one deadline +# owner and one diagnostic path. Tests lower it through the environment to drive the +# launcher-times-out-first boundary deterministically. +ACK_TIMEOUT = 60.0 +ACK_TIMEOUT_ENV = "CODEPLAIN_PTY_ACK_TIMEOUT" + +# The set Popen(restore_signals=True) resets. CPython sets SIGPIPE to SIG_IGN at +# startup and an ignored disposition survives execvpe, so without this the target +# would inherit an ignored SIGPIPE where the pipe backend delivers the default. +RESTORED_SIGNALS = ("SIGPIPE", "SIGXFZ", "SIGXFSZ") + + +def _write_record(fd: int, kind: int, payload: bytes = b"") -> None: + """One type byte + 4-byte big-endian length + payload. Never a bare marker.""" + payload = payload[:MAX_PAYLOAD] + buf = bytes([kind]) + len(payload).to_bytes(4, "big") + payload + while buf: # os.write may write fewer bytes than asked; a short write would + buf = buf[os.write(fd, buf) :] # leave a valid header followed by a truncated payload + + +def _ack_timeout() -> float: + raw = os.environ.get(ACK_TIMEOUT_ENV) + if not raw: + return ACK_TIMEOUT + try: + return float(raw) + except ValueError: + return ACK_TIMEOUT + + +def _await_ack(ack_fd: int, timeout: float) -> None: + """Blocks until the parent acknowledges. EOF or timeout is a launch failure. + + The parent holds the only write end, so a dead parent surfaces as an immediate EOF + rather than as a wait for the timeout. + """ + readable, _, _ = select.select([ack_fd], [], [], timeout) + if not readable: + raise RuntimeError(f"parent did not acknowledge within {timeout} seconds") + if not os.read(ack_fd, 1): + raise RuntimeError("parent closed the acknowledgment pipe without acknowledging") + + +def _assert_invariants() -> None: + pid = os.getpid() + if not (os.isatty(0) and os.isatty(1) and os.isatty(2)): + raise RuntimeError("PTY is not attached to all three descriptors") + if os.getsid(0) != pid or os.getpgrp() != pid: + raise RuntimeError("login_tty did not make this process session and group leader") + if os.tcgetpgrp(0) != os.getpgrp(): + raise RuntimeError("PTY foreground process group is not this process") + tty_fd = os.open("/dev/tty", os.O_RDWR | getattr(os, "O_CLOEXEC", 0)) + try: # proves a controlling terminal exists, not merely that fd 0 + if not os.isatty(tty_fd): # happens to name some terminal device + raise RuntimeError("/dev/tty is not a terminal") + finally: + os.close(tty_fd) + + +def _restore_signals() -> None: + for name in RESTORED_SIGNALS: + if hasattr(signal, name): + signal.signal(getattr(signal, name), signal.SIG_DFL) + + +def _format_launch_error(exc: BaseException) -> bytes: + return f"{type(exc).__name__}: {exc}".encode("utf-8", "replace") + + +def main(slave_fd: int, status_fd: int, ack_fd: int, command: list) -> None: + try: + _write_record(status_fd, STARTED) # before anything that can fail + os.login_tty(slave_fd) # setsid + TIOCSCTTY + dup2 onto 0,1,2 + close slave_fd + if os.tcgetpgrp(0) != os.getpgrp(): + os.tcsetpgrp(0, os.getpgrp()) + _assert_invariants() # in the child, pre-exec — the parent cannot do this + _write_record(status_fd, SESSION_READY) + _await_ack(ack_fd, _ack_timeout()) # barrier: the target must not run before the parent records pgid + os.set_inheritable(status_fd, False) # successful exec closes it -> parent sees EOF + os.set_inheritable(ack_fd, False) + _restore_signals() # SIG_IGN survives exec + os.environ.pop(ACK_TIMEOUT_ENV, None) # a test hook never reaches the target + os.execvpe(command[0], command, os.environ) + except BaseException as exc: + try: + _write_record(status_fd, FAILED, _format_launch_error(exc)) + except BaseException: + pass # the parent falls back to EOF-without-marker plus the stderr pipe + os._exit(LAUNCH_FAILURE_EXIT_CODE) + + +def _run(argv: list) -> None: + slave_fd, status_fd, ack_fd = (int(argv[0]), int(argv[1]), int(argv[2])) + if argv[3] != "--": + raise ValueError(f"expected '--' before the command, got {argv[3]!r}") + main(slave_fd, status_fd, ack_fd, argv[4:]) + + +if __name__ == "__main__": + try: + _run(sys.argv[1:]) + except BaseException: # argv is malformed, so there is no status fd to report on + os._exit(LAUNCH_FAILURE_EXIT_CODE) diff --git a/tests/test_terminal_process.py b/tests/test_terminal_process.py new file mode 100644 index 00000000..dc885fc8 --- /dev/null +++ b/tests/test_terminal_process.py @@ -0,0 +1,338 @@ +"""Tests for the PTY launcher and the POSIX terminal backend. + +Everything here spawns real processes and allocates real terminals, so the whole module +is POSIX-only. Each helper is responsible for leaving no descriptor and no process +behind — the suite runs against a bounded system PTY limit. +""" + +import os +import select +import signal +import subprocess +import sys +import threading +import time +from contextlib import contextmanager +from pathlib import Path + +import pytest + +posix_only = pytest.mark.skipif(sys.platform == "win32", reason="The POSIX PTY backend is not built on Windows.") + +pytestmark = posix_only + +if sys.platform != "win32": + from render_machine import pty_exec + +REPO_ROOT = Path(__file__).resolve().parent.parent +LAUNCHER = str(REPO_ROOT / "render_machine" / "pty_exec.py") + +# Every wait in this module is bounded. These are generous relative to the operations +# they cover, so a failure means a hang rather than a slow machine. +LAUNCH_TIMEOUT = 20.0 +SHORT_TIMEOUT = 5.0 + + +def _read_records(fd, timeout): + """Reads the status pipe to EOF and splits it into (kind, payload) records. + + Deliberately independent of the backend's parser: these cases assert what the + launcher puts on the wire, not what the parent makes of it. + """ + deadline = time.monotonic() + timeout + buffer = b"" + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise AssertionError(f"status pipe did not reach EOF within {timeout}s; buffered {buffer!r}") + readable, _, _ = select.select([fd], [], [], min(remaining, 0.2)) + if not readable: + continue + chunk = os.read(fd, 65536) + if not chunk: + break + buffer += chunk + + records = [] + offset = 0 + while offset < len(buffer): + kind = buffer[offset] + length = int.from_bytes(buffer[offset + 1 : offset + 5], "big") + payload = buffer[offset + 5 : offset + 5 + length] + assert len(payload) == length, f"truncated record in {buffer!r}" + records.append((kind, payload)) + offset += 5 + length + return records + + +class _LauncherSession: + """Parent side of the launcher protocol, reduced to what these cases need.""" + + def __init__(self, proc, master_fd, status_r, ack_w): + self.proc = proc + self.master_fd = master_fd + self.status_r = status_r + self.ack_w = ack_w + self.output = bytearray() + self._drain = threading.Thread(target=self._drain_master, daemon=True) + self._drain.start() + + def _drain_master(self): + while True: + try: + chunk = os.read(self.master_fd, 65536) + except OSError: + return + if not chunk: + return + self.output += chunk + + def ack(self): + os.write(self.ack_w, b"\x01") + + def close_ack(self): + if self.ack_w is not None: + os.close(self.ack_w) + self.ack_w = None + + def records(self, timeout=LAUNCH_TIMEOUT): + return _read_records(self.status_r, timeout) + + def wait(self, timeout=LAUNCH_TIMEOUT): + return self.proc.wait(timeout=timeout) + + def stderr_text(self): + return self.proc.stderr.read().decode("utf-8", "replace") + + +@contextmanager +def launcher_session(command, python=None, env=None): + """Spawns the launcher exactly as the backend does and cleans up unconditionally.""" + master_fd, slave_fd = os.openpty() + status_r, status_w = os.pipe() + ack_r, ack_w = os.pipe() + proc = None + try: + proc = subprocess.Popen( + [ + python or sys.executable, + "-I", + "-S", + LAUNCHER, + str(slave_fd), + str(status_w), + str(ack_r), + "--", + *command, + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + pass_fds=(slave_fd, status_w, ack_r), + close_fds=True, + env=env, + ) + finally: + for fd in (slave_fd, status_w, ack_r): + os.close(fd) + + session = _LauncherSession(proc, master_fd, status_r, ack_w) + try: + yield session + finally: + session.close_ack() + if proc.poll() is None: + proc.kill() + proc.wait(timeout=SHORT_TIMEOUT) + proc.stderr.close() + for fd in (master_fd, status_r): + try: + os.close(fd) + except OSError: + pass + + +def test_write_record_frames_payloads_that_look_like_markers(): + """A payload that begins with — or equals — a marker byte stays a framed payload.""" + for payload in (b"\x01", b"\x02", b"\x03", b"\x02session ready", b"\x01started", b""): + read_fd, write_fd = os.pipe() + try: + pty_exec._write_record(write_fd, pty_exec.FAILED, payload) + os.close(write_fd) + write_fd = None + framed = os.read(read_fd, 65536) + finally: + if write_fd is not None: + os.close(write_fd) + os.close(read_fd) + + assert framed == bytes([pty_exec.FAILED]) + len(payload).to_bytes(4, "big") + payload + + +def test_write_record_bounds_the_payload(): + read_fd, write_fd = os.pipe() + try: + pty_exec._write_record(write_fd, pty_exec.FAILED, b"x" * (pty_exec.MAX_PAYLOAD * 2)) + os.close(write_fd) + write_fd = None + framed = os.read(read_fd, 65536) + finally: + if write_fd is not None: + os.close(write_fd) + os.close(read_fd) + + assert int.from_bytes(framed[1:5], "big") == pty_exec.MAX_PAYLOAD + assert len(framed) == pty_exec.MAX_PAYLOAD + pty_exec.HEADER_SIZE + + +def test_write_record_completes_across_short_writes(monkeypatch): + """A short os.write() must not leave a valid header with a truncated payload.""" + read_fd, write_fd = os.pipe() + real_write = os.write + chunks = [] + + def short_write(fd, data): + if fd != write_fd: + return real_write(fd, data) + count = real_write(fd, data[:1]) + chunks.append(data[:count]) + return count + + payload = b"\x02boom" + try: + monkeypatch.setattr(os, "write", short_write) + pty_exec._write_record(write_fd, pty_exec.FAILED, payload) + monkeypatch.undo() + os.close(write_fd) + write_fd = None + framed = os.read(read_fd, 65536) + finally: + if write_fd is not None: + os.close(write_fd) + os.close(read_fd) + + expected = bytes([pty_exec.FAILED]) + len(payload).to_bytes(4, "big") + payload + assert len(chunks) == len(expected), "the write was not actually fragmented" + assert b"".join(chunks) == expected + assert framed == expected + + +def test_launcher_reports_failure_when_the_ack_pipe_reaches_eof(): + """A parent that dies before acknowledging releases the launcher immediately.""" + with launcher_session(["/bin/sh", "-c", "exit 0"]) as session: + started = time.monotonic() + session.close_ack() + assert session.wait(timeout=SHORT_TIMEOUT) == pty_exec.LAUNCH_FAILURE_EXIT_CODE + elapsed = time.monotonic() - started + records = session.records() + + assert elapsed < SHORT_TIMEOUT + assert [kind for kind, _ in records] == [pty_exec.STARTED, pty_exec.SESSION_READY, pty_exec.FAILED] + assert b"acknowledgment pipe" in records[-1][1] + + +def test_launcher_reports_failure_when_the_ack_timeout_expires(): + """A parent that is alive but wedged must not block the launcher forever.""" + env = dict(os.environ, **{pty_exec.ACK_TIMEOUT_ENV: "0.2"}) + with launcher_session(["/bin/sh", "-c", "exit 0"], env=env) as session: + started = time.monotonic() + assert session.wait(timeout=SHORT_TIMEOUT) == pty_exec.LAUNCH_FAILURE_EXIT_CODE + elapsed = time.monotonic() - started + records = session.records() + + assert elapsed < SHORT_TIMEOUT + assert [kind for kind, _ in records] == [pty_exec.STARTED, pty_exec.SESSION_READY, pty_exec.FAILED] + assert b"did not acknowledge" in records[-1][1] + + +def test_launcher_execs_the_target_after_the_ack(): + with launcher_session(["/bin/sh", "-c", "printf ready; exit 7"]) as session: + records = [] + deadline = time.monotonic() + LAUNCH_TIMEOUT + # The ack is written as soon as SESSION_READY has been observed, exactly as the + # backend does; the status pipe then reaches EOF because exec closes it. + while time.monotonic() < deadline: + readable, _, _ = select.select([session.status_r], [], [], 0.2) + if readable: + break + session.ack() + records = session.records() + assert session.wait(timeout=SHORT_TIMEOUT) == 7 + + assert [kind for kind, _ in records] == [pty_exec.STARTED, pty_exec.SESSION_READY] + assert b"ready" in bytes(session.output) + + +def test_target_receives_restored_signal_dispositions(): + """CPython ignores SIGPIPE and that survives exec; _restore_signals() undoes it.""" + with launcher_session(["/bin/sh", "-c", "kill -PIPE $$; exit 0"]) as session: + deadline = time.monotonic() + LAUNCH_TIMEOUT + while time.monotonic() < deadline: + readable, _, _ = select.select([session.status_r], [], [], 0.2) + if readable: + break + session.ack() + session.records() + returncode = session.wait(timeout=SHORT_TIMEOUT) + + assert returncode == -signal.SIGPIPE + + +def _plant_startup_hooks(tmp_path): + """Builds a throwaway venv whose site-packages runs code at interpreter startup. + + Returns (interpreter, marker_prefix). The venv's own site-packages is used because + a virtual environment disables the user site directory, so PYTHONUSERBASE cannot + carry the plant. + """ + venv_dir = tmp_path / "planted" + subprocess.run( + [sys.executable, "-m", "venv", "--without-pip", str(venv_dir)], + check=True, + capture_output=True, + timeout=LAUNCH_TIMEOUT, + ) + interpreter = venv_dir / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") + site_packages = subprocess.run( + [str(interpreter), "-c", "import sysconfig; print(sysconfig.get_paths()['purelib'])"], + check=True, + capture_output=True, + text=True, + timeout=LAUNCH_TIMEOUT, + ).stdout.strip() + + marker_prefix = tmp_path / "startup" + Path(site_packages, "sitecustomize.py").write_text( + f"open({str(marker_prefix)!r} + '.sitecustomize', 'w').write('ran')\n" + ) + Path(site_packages, "zzz_probe.pth").write_text( + f"import builtins; open({str(marker_prefix)!r} + '.pth', 'w').write('ran')\n" + ) + return str(interpreter), marker_prefix + + +def test_launcher_runs_no_startup_customization(tmp_path): + """`-I -S` is part of the ack barrier's proof: nothing may run before STARTED.""" + interpreter, marker_prefix = _plant_startup_hooks(tmp_path) + sitecustomize_marker = Path(f"{marker_prefix}.sitecustomize") + pth_marker = Path(f"{marker_prefix}.pth") + + subprocess.run([interpreter, "-c", "pass"], check=True, capture_output=True, timeout=LAUNCH_TIMEOUT) + assert sitecustomize_marker.exists(), "the planted sitecustomize.py never ran, so the test proves nothing" + assert pth_marker.exists(), "the planted .pth never ran, so the test proves nothing" + sitecustomize_marker.unlink() + pth_marker.unlink() + + with launcher_session(["/bin/sh", "-c", "exit 0"], python=interpreter) as session: + deadline = time.monotonic() + LAUNCH_TIMEOUT + while time.monotonic() < deadline: + readable, _, _ = select.select([session.status_r], [], [], 0.2) + if readable: + break + session.ack() + records = session.records() + assert session.wait(timeout=SHORT_TIMEOUT) == 0 + + assert [kind for kind, _ in records] == [pty_exec.STARTED, pty_exec.SESSION_READY] + assert not sitecustomize_marker.exists() + assert not pth_marker.exists() From b7a1bc7626188512babc14466bc6c205f88c2a90 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sat, 15 Aug 2026 21:54:39 +0200 Subject: [PATCH 07/83] Add TerminalProcess with POSIX PTY backend One pseudoterminal backs the target's three standard descriptors. The spawn sequence completes a strict framed handshake and an acknowledgment barrier that records the process group before the target can run. A single reader thread owns the master descriptor and services a bounded, byte-accounted input queue. --- render_machine/_posix_pty.py | 978 +++++++++++++++++++++++++++++ render_machine/terminal_process.py | 145 +++++ tests/test_terminal_process.py | 225 +++++++ 3 files changed, 1348 insertions(+) create mode 100644 render_machine/_posix_pty.py create mode 100644 render_machine/terminal_process.py diff --git a/render_machine/_posix_pty.py b/render_machine/_posix_pty.py new file mode 100644 index 00000000..d5f93a05 --- /dev/null +++ b/render_machine/_posix_pty.py @@ -0,0 +1,978 @@ +"""POSIX PTY backend for `TerminalProcess`. + +One pseudoterminal backs the target's fds 0, 1 and 2. `spawn()` allocates it, launches +`pty_exec.py`, and completes a framed handshake that ends with an acknowledgment barrier: +the parent records the target's process group before the target is allowed to run, so +there is never a moment where a descendant exists that termination cannot reach. + +A single reader thread owns `master_fd` for its whole lifetime. It is the only code that +reads from, writes to, or changes the terminal mode of that descriptor; every producer of +input enqueues a whole logical item and rings a doorbell instead of borrowing the fd. +""" + +import codecs +import collections +import errno +import fcntl +import os +import select +import signal +import struct +import subprocess +import sys +import termios +import threading +import time +from typing import Callable, Deque, List, Optional, Sequence, Tuple + +from plain2code_console import console +from plain2code_exceptions import RenderCancelledError +from render_machine import pty_exec +from render_machine.terminal_process import ( + DEFAULT_TERM, + DRAIN_DEADLINE_SECONDS, + DRAIN_MAX_BYTES, + DRAIN_QUIET_PERIOD_SECONDS, + GRACE_TICK_SECONDS, + HANDSHAKE_TIMEOUT_SECONDS, + INPUT_WRITE_BUDGET_BYTES, + LAUNCHER_STDERR_CAP_BYTES, + MAX_INPUT_ITEM_BYTES, + MAX_PENDING_INPUT_BYTES, + POLL_INTERVAL_SECONDS, + READ_CHUNK_BYTES, + REAP_DEADLINE_SECONDS, + RESERVED_INPUT_BYTES, + SIGTERM_GRACE_PERIOD_SECONDS, + TERMINAL_COLUMNS, + TERMINAL_ROWS, + InputDisposition, + InputWriteResult, + TerminalEnvironmentError, + TerminalLaunchError, + TerminalProcess, + TerminalReaderError, +) + +if sys.platform == "win32": # pragma: no cover - the PTY backend is POSIX-only + raise ImportError("render_machine._posix_pty is POSIX-only") + +_LAUNCHER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pty_exec.py") + +# Grace given to a launcher that never reached the target. It has not exec'd and never +# forks, so termination is immediate and the full grace would only slow failures down. +ROLLBACK_GRACE_SECONDS = 0.1 + +_OWNER_PARENT = "parent" +_OWNER_READER = "reader" + + +class _ProtocolError(Exception): + """The launcher's status stream did not follow the handshake protocol.""" + + +def _close_quietly(fd: Optional[int]) -> None: + if fd is None: + return + try: + os.close(fd) + except OSError: + pass + + +def _signal_group(pgid: int, sig: int) -> None: + """The only killpg site in this module. + + ESRCH: the group is gone. + EPERM: verified on macOS — killpg() returns EPERM, not ESRCH, when the group's only + remaining member is our own unreaped zombie leader. That is the NORMAL state after a + graceful exit, so it must not raise. + """ + try: + os.killpg(pgid, sig) + except ProcessLookupError: # ESRCH — nothing left + return + except PermissionError: # EPERM — zombie-only group + console.debug(f"killpg({pgid}, {sig}): EPERM, treating as terminal") + + +def _background_reap(proc: subprocess.Popen) -> None: + try: + proc.wait() + except BaseException: # nothing here can be reported anywhere useful + pass + + +def _reap(proc: subprocess.Popen, deadline_seconds: float) -> None: + """Bounded reap. SIGKILL is not instantaneous, so the foreground wait cannot be open-ended.""" + try: + proc.wait(timeout=deadline_seconds) + except subprocess.TimeoutExpired: + console.debug(f"process {proc.pid} outlived the reap deadline; reaping it in the background") + threading.Thread(target=_background_reap, args=(proc,), daemon=True).start() + + +class _Receipt: + """Resolution of one queued input item. Resolved exactly once, by whoever retires it.""" + + def __init__(self) -> None: + self._event = threading.Event() + self.error: Optional[BaseException] = None + self.disposition: Optional[InputDisposition] = None + self.resolutions = 0 + + def resolve(self, disposition: InputDisposition, error: Optional[BaseException] = None) -> None: + self.resolutions += 1 + if self._event.is_set(): + return + self.disposition = disposition + self.error = error + self._event.set() + + @property + def resolved(self) -> bool: + return self._event.is_set() + + +class _InputItem: + """One whole logical write, plus the optional transaction that must bracket it.""" + + def __init__( + self, + data: bytes, + receipt: _Receipt, + reserved: bool, + prepare: Optional[Callable[[], None]], + finish: Optional[Callable[[], None]], + sequence: int, + ) -> None: + self.data = data + self.receipt = receipt + self.reserved = reserved + self.prepare = prepare + self.finish = finish + self.sequence = sequence + self.cursor = 0 + self.prepared = False + + +class _InputQueue: + """Bounded, byte-accounted, ordered input queue. + + Admission, the accepting flag, byte accounting, and sequence assignment share one + lock. Dequeue is not completion: the item under the reader's cursor stays accounted + for and keeps its receipt attached until its last native byte completes or teardown + fails it, so capacity is released exactly once at that terminal transition. + """ + + def __init__( + self, + max_item_bytes: int = MAX_INPUT_ITEM_BYTES, + max_pending_bytes: int = MAX_PENDING_INPUT_BYTES, + reserved_bytes: int = RESERVED_INPUT_BYTES, + ) -> None: + self._lock = threading.Lock() + self._items: Deque[_InputItem] = collections.deque() + self._current: Optional[_InputItem] = None + self._pending_bytes = 0 + self._sequence = 0 + self._accepting = True + self._max_item_bytes = max_item_bytes + self._max_pending_bytes = max_pending_bytes + self._reserved_bytes = reserved_bytes + + def submit( + self, + data: bytes, + reserved: bool = False, + prepare: Optional[Callable[[], None]] = None, + finish: Optional[Callable[[], None]] = None, + ) -> Tuple[InputWriteResult, _Receipt]: + receipt = _Receipt() + payload = bytes(data) + with self._lock: + if not self._accepting: + result = InputWriteResult(InputDisposition.CLOSED, 0) + elif len(payload) > self._max_item_bytes: + result = InputWriteResult(InputDisposition.BACKPRESSURE, 0) + elif self._pending_bytes + len(payload) > self._budget(reserved): + result = InputWriteResult(InputDisposition.BACKPRESSURE, 0) + else: + self._sequence += 1 + self._items.append(_InputItem(payload, receipt, reserved, prepare, finish, self._sequence)) + self._pending_bytes += len(payload) + result = InputWriteResult(InputDisposition.ACCEPTED, len(payload)) + if result.disposition is not InputDisposition.ACCEPTED: + receipt.resolve(result.disposition) + return result, receipt + + def _budget(self, reserved: bool) -> int: + return self._max_pending_bytes if reserved else self._max_pending_bytes - self._reserved_bytes + + def has_pending(self) -> bool: + with self._lock: + return self._current is not None or bool(self._items) + + def pending_bytes(self) -> int: + with self._lock: + return self._pending_bytes + + def current(self) -> Optional[_InputItem]: + """The item under the cursor, promoting the next waiting item when there is none.""" + with self._lock: + if self._current is None and self._items: + self._current = self._items.popleft() + return self._current + + def complete_current(self, error: Optional[BaseException] = None) -> None: + with self._lock: + item = self._current + if item is None: + return + self._current = None + self._pending_bytes -= len(item.data) + item.receipt.resolve(InputDisposition.CLOSED if error is not None else InputDisposition.ACCEPTED, error) + + def stop_accepting(self, closing: threading.Event) -> None: + """Marks the queue non-accepting and signals shutdown under the same lock. + + No producer can then enqueue behind the reader's fail_all(). + """ + with self._lock: + self._accepting = False + closing.set() + + def close_and_fail_all(self, error: Optional[BaseException] = None) -> List[_InputItem]: + with self._lock: + self._accepting = False + items = list(self._items) + self._items.clear() + if self._current is not None: + items.append(self._current) + self._current = None + self._pending_bytes = 0 + for item in items: # callbacks run outside the lock and cannot re-enter the queue + try: + item.receipt.resolve(InputDisposition.CLOSED, error) + except BaseException as exc: # a receipt must never strand its siblings + console.debug(f"input receipt callback raised: {exc!r}") + return items + + +class _ReaderBundle: + """The descriptors whose ownership moves from the parent to the reader in one step. + + `owner` is the single field that decides. Rollback and reader consult it, so they can + never disagree and there is no state in which a descriptor has left one owner without + reaching the other. + """ + + def __init__(self, master_fd: int, wakeup_r: int, err_w: int) -> None: + self.owner = _OWNER_PARENT + self.master_fd: Optional[int] = master_fd + self.wakeup_r: Optional[int] = wakeup_r + self.err_w: Optional[int] = err_w + self._lock = threading.Lock() + + def _take(self, name: str) -> Optional[int]: + with self._lock: # swap first, close only what the swap returned + fd = getattr(self, name) + setattr(self, name, None) + return fd + + def take_master(self) -> Optional[int]: + return self._take("master_fd") + + def take_wakeup_r(self) -> Optional[int]: + return self._take("wakeup_r") + + def take_err_w(self) -> Optional[int]: + return self._take("err_w") + + def close_all(self) -> None: + for name in ("master_fd", "wakeup_r", "err_w"): + _close_quietly(self._take(name)) + + +class _CappedDiagnostic: + """Keeps the head and the tail of a stream while the middle keeps being discarded.""" + + def __init__(self, cap: int = LAUNCHER_STDERR_CAP_BYTES) -> None: + self._cap = cap + self._head = bytearray() + self._tail = bytearray() + self.total = 0 + + def feed(self, chunk: bytes) -> None: + self.total += len(chunk) + if len(self._head) < self._cap: + room = self._cap - len(self._head) + self._head += chunk[:room] + chunk = chunk[room:] + if chunk: + self._tail += chunk + del self._tail[: max(0, len(self._tail) - self._cap)] + + def text(self) -> str: + head = bytes(self._head).decode("utf-8", "replace") + if not self._tail: + return head + omitted = self.total - len(self._head) - len(self._tail) + return f"{head}\n...[{omitted} bytes omitted]...\n" + bytes(self._tail).decode("utf-8", "replace") + + +class _HandshakeParser: + """Strict bounded state machine over the launcher's framed status records. + + Accepts exactly STARTED -> SESSION_READY -> EOF as success. Everything else — unknown + kinds, duplicate or out-of-order markers, a marker carrying a payload, a declared + length above the cap, a truncated record at EOF, trailing bytes after FAILED — is a + protocol failure on the environment-error channel. + """ + + def __init__(self) -> None: + self._buffer = bytearray() + self.started = False + self.session_ready = False + self.failure_payload: Optional[bytes] = None + + def feed(self, chunk: bytes) -> None: + self._buffer += chunk + while True: + if self.failure_payload is not None: + if self._buffer: + raise _ProtocolError("the launcher wrote trailing bytes after its failure record") + return + if len(self._buffer) < pty_exec.HEADER_SIZE: + return + kind = self._buffer[0] + length = int.from_bytes(bytes(self._buffer[1:5]), "big") + self._validate_header(kind, length) + if len(self._buffer) < pty_exec.HEADER_SIZE + length: + return + payload = bytes(self._buffer[pty_exec.HEADER_SIZE : pty_exec.HEADER_SIZE + length]) + del self._buffer[: pty_exec.HEADER_SIZE + length] + self._accept(kind, payload) + + def _validate_header(self, kind: int, length: int) -> None: + if kind not in (pty_exec.STARTED, pty_exec.SESSION_READY, pty_exec.FAILED): + raise _ProtocolError(f"unknown handshake record type 0x{kind:02x}") + if length > pty_exec.MAX_PAYLOAD: # rejected before allocating or waiting for a body + raise _ProtocolError(f"handshake record declares {length} bytes, above the {pty_exec.MAX_PAYLOAD} cap") + if kind != pty_exec.FAILED and length: + raise _ProtocolError("a handshake marker record must carry no payload") + + def _accept(self, kind: int, payload: bytes) -> None: + if kind == pty_exec.STARTED: + if self.started: + raise _ProtocolError("duplicate STARTED record") + self.started = True + elif kind == pty_exec.SESSION_READY: + if not self.started or self.session_ready: + raise _ProtocolError("out-of-order SESSION_READY record") + self.session_ready = True + else: + if not self.started: + raise _ProtocolError("FAILED record before STARTED") + self.failure_payload = payload + + def eof(self) -> None: + if self._buffer: + raise _ProtocolError("the launcher's status stream ended mid-record") + if not self.started: + raise _ProtocolError("the interpreter died before running the launcher") + if not self.session_ready: + raise _ProtocolError("the launcher exited after STARTED without a ready session") + + +class PosixPtyProcess(TerminalProcess): + """One command, one pseudoterminal, one reader thread.""" + + def __init__(self) -> None: + self.reader_failed = threading.Event() + self.reader_exc: Optional[BaseException] = None + + self._proc: Optional[subprocess.Popen] = None + self._pgid: Optional[int] = None + self._reaped = False + self._spawned = False + self._closed = False + self._acked = False + self._input_driver: Optional[object] = None + self._stop_event = threading.Event() + + self._bundle: Optional[_ReaderBundle] = None + self._reader: Optional[threading.Thread] = None + self._gate = threading.Event() + self._closing = threading.Event() + self._input_queue = _InputQueue() + self._drain_deadline: Optional[float] = None + self._veof_byte = b"\x04" + self._veof_saved: Optional[list] = None + + self._fd_lock = threading.Lock() + self._pending_master_fd: Optional[int] = None + self._pending_slave_fd: Optional[int] = None + self._child_fds: Tuple[int, ...] = () + self._wakeup_w: Optional[int] = None + self._err_r: Optional[int] = None + self._status_r: Optional[int] = None + self._ack_w: Optional[int] = None + + self._output_lock = threading.Lock() + self._decoded: List[str] = [] + self._raw = bytearray() + self.launcher_stderr = _CappedDiagnostic() + + # ---------------------------------------------------------------- public API + + def spawn( + self, + command: Sequence[str], + cwd: Optional[str] = None, + env: Optional[dict] = None, + terminal_size: Tuple[int, int] = (TERMINAL_COLUMNS, TERMINAL_ROWS), + stop_event: Optional[threading.Event] = None, + input_driver: Optional[object] = None, + handshake_timeout: float = HANDSHAKE_TIMEOUT_SECONDS, + pre_ack_delay: float = 0.0, + ) -> None: + """Allocates the terminal, launches the target, and returns once it is running. + + `pre_ack_delay` holds the parent's acknowledgment for a bounded time. It exists so + the barrier's window can be driven deterministically from tests; production + callers leave it at zero. + """ + if self._spawned: + raise RuntimeError("PosixPtyProcess instances are single-use") + self._spawned = True + self._stop_event = stop_event if stop_event is not None else threading.Event() + self._input_driver = input_driver + deadline = time.monotonic() + handshake_timeout + try: + self._check_cancelled() + self._open_terminal(terminal_size) + self._open_channels() + self._start_child(command, cwd, env) + self._hand_over_to_reader() + self._run_handshake(deadline, pre_ack_delay) + self._close_owned("_status_r") # the handshake has resolved + except BaseException: + self._rollback() + raise + + def poll(self) -> Optional[int]: + if self._proc is None: + return None + returncode = self._proc.poll() + if returncode is not None: + # Popen.poll() reaps, so the pgid may now be recycled; no group signal is + # ever sent again. + self._reaped = True + return returncode + + def read_output(self) -> str: + with self._output_lock: + text = "".join(self._decoded) + self._decoded.clear() + return text + + def read_raw_output(self) -> bytes: + with self._output_lock: + data = bytes(self._raw) + self._raw.clear() + return data + + def write_input(self, data: bytes) -> InputWriteResult: + result, _ = self._input_queue.submit(data) + if result.disposition is InputDisposition.ACCEPTED: + self._ring_doorbell() + return result + + def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: + """Signals the recorded group, escalates on the clock, and reaps last. + + A reader failure observed here is recorded by the reader and deliberately not + acted on: returning early would skip the SIGKILL escalation the sequence exists + for. The caller inspects `reader_failed` afterwards. + """ + proc = self._proc + if proc is None or self._reaped: + return + pgid = self._pgid + try: + try: + self._deliver(proc, pgid, signal.SIGTERM) + self._deliver(proc, pgid, signal.SIGCONT) + deadline = time.monotonic() + grace # independent clock — NOT stop_event + while time.monotonic() < deadline: # never waits on the leader either + self._grace_tick() + finally: + # Unconditional: an interruption mid-grace must still escalate. + self._deliver(proc, pgid, signal.SIGKILL) + finally: + _reap(proc, REAP_DEADLINE_SECONDS) + self._reaped = True + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._drain_deadline = time.monotonic() + DRAIN_DEADLINE_SECONDS + self._input_queue.stop_accepting(self._closing) + self._ring_doorbell() + if self._reader is not None: + self._reader.join(timeout=DRAIN_DEADLINE_SECONDS + REAP_DEADLINE_SECONDS) + self._close_owned("_wakeup_w") + self._close_owned("_err_r") + self._close_owned("_status_r") + self._close_owned("_ack_w") + if self._proc is not None and self._proc.stderr is not None: + self._proc.stderr.close() + if self._bundle is not None and self._bundle.owner == _OWNER_PARENT: + self._bundle.close_all() # no reader ever took them + + # ------------------------------------------------------------- spawn helpers + + def _open_terminal(self, terminal_size: Tuple[int, int]) -> None: + try: + master_fd, slave_fd = os.openpty() + except OSError as exc: + raise TerminalEnvironmentError(f"Could not allocate a pseudoterminal: {exc}") from exc + try: + columns, rows = terminal_size + fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, columns, 0, 0)) + self._configure_slave(slave_fd) + os.set_blocking(master_fd, False) + except BaseException: + _close_quietly(master_fd) + _close_quietly(slave_fd) + raise + self._pending_master_fd = master_fd + self._pending_slave_fd = slave_fd + + def _configure_slave(self, slave_fd: int) -> None: + """Sane termios with echo on. ONLCR is left at its default: Option A means real + terminal semantics, and the \\r\\n is dealt with in normalization.""" + attrs = termios.tcgetattr(slave_fd) + attrs[0] |= termios.ICRNL + attrs[1] |= termios.OPOST | termios.ONLCR + attrs[3] |= termios.ICANON | termios.ISIG | termios.ECHO | termios.IEXTEN + termios.tcsetattr(slave_fd, termios.TCSANOW, attrs) + self._veof_byte = bytes([attrs[6][termios.VEOF][0]]) + + def _open_channels(self) -> None: + """Pre-registers every parent-side owner before the API that fills it.""" + status_r, status_w = os.pipe() + ack_r, ack_w = os.pipe() + wakeup_r, wakeup_w = os.pipe() + err_r, err_w = os.pipe() + os.set_blocking(wakeup_r, False) + os.set_blocking(wakeup_w, False) + + self._status_r = status_r + self._ack_w = ack_w + self._wakeup_w = wakeup_w + self._err_r = err_r + master_fd = self._pending_master_fd + assert master_fd is not None + self._pending_master_fd = None # the bundle owns it from here + self._bundle = _ReaderBundle(master_fd, wakeup_r, err_w) + self._reader = threading.Thread(target=self._reader_main, name="codeplain-pty-reader", daemon=True) + self._child_fds = (status_w, ack_r) + + def _start_child(self, command: Sequence[str], cwd: Optional[str], env: Optional[dict]) -> None: + status_w, ack_r = self._child_fds + slave_fd = self._pending_slave_fd + assert slave_fd is not None + argv = [ + sys.executable, + "-I", # isolated: no PYTHONPATH, no user site + "-S", # no site processing, so no sitecustomize and no .pth can fork before STARTED + _LAUNCHER, + str(slave_fd), + str(status_w), + str(ack_r), + "--", + *command, + ] + try: + self._proc = subprocess.Popen( + argv, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + pass_fds=(slave_fd, status_w, ack_r), + close_fds=True, + cwd=cwd, + env=self._child_env(env), + ) + except OSError as exc: + raise TerminalEnvironmentError(f"Could not start the terminal launcher: {exc}") from exc + finally: + # Correctness for the first two: holding them means the master never reaches + # EOF and the launcher's exec is never observable. + for fd in (slave_fd, status_w, ack_r): + _close_quietly(fd) + self._child_fds = () + self._pending_slave_fd = None + + def _child_env(self, env: Optional[dict]) -> dict: + child_env = dict(os.environ if env is None else env) + term = child_env.get("TERM") + child_env["TERM"] = term if term else DEFAULT_TERM + # git reads /dev/tty directly, so neither the VEOF nor a redirected stdin can + # reach a credential prompt; failing is the only bounded outcome. + child_env["GIT_TERMINAL_PROMPT"] = "0" + return child_env + + def _hand_over_to_reader(self) -> None: + """Starts the gated reader and commits ownership in a single field assignment.""" + assert self._bundle is not None and self._reader is not None + try: + self._reader.start() + self._bundle.owner = _OWNER_READER + finally: + self._gate.set() # an unreleased gate is unrecoverable, so this is never conditional + self._check_reader_failed() + + # ---------------------------------------------------------------- handshake + + def _run_handshake(self, deadline: float, pre_ack_delay: float) -> None: + parser = _HandshakeParser() + assert self._proc is not None and self._proc.stderr is not None + status_r, err_r = self._status_r, self._err_r + assert status_r is not None and err_r is not None + stderr_fd = self._proc.stderr.fileno() + watched = {status_r, err_r, stderr_fd} + while True: + self._check_cancelled() + self._check_reader_failed() + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TerminalLaunchError(self._launch_message("the launcher hung before exec")) + readable, _, _ = select.select(sorted(watched), [], [], min(remaining, POLL_INTERVAL_SECONDS)) + if stderr_fd in readable and not self._drain_launcher_stderr(stderr_fd): + watched.discard(stderr_fd) + if err_r in readable: + self._consume_reader_edge(watched, err_r) + if status_r in readable and self._advance_handshake(parser, status_r, deadline, pre_ack_delay): + return + + def _advance_handshake( + self, parser: _HandshakeParser, status_r: int, deadline: float, pre_ack_delay: float + ) -> bool: + """Feeds one status chunk. Returns True once exec has been observed.""" + chunk = os.read(status_r, READ_CHUNK_BYTES) + try: + if not chunk: + parser.eof() + return True + parser.feed(chunk) + except _ProtocolError as exc: + raise TerminalLaunchError(self._launch_message(str(exc))) from exc + if parser.failure_payload is not None: + reason = parser.failure_payload.decode("utf-8", "replace") + raise TerminalLaunchError(self._launch_message(f"the launcher failed: {reason}")) + if parser.session_ready and not self._acked: + self._acknowledge(deadline, pre_ack_delay) + return False + + def _acknowledge(self, deadline: float, pre_ack_delay: float) -> None: + """Records the group, delivers the no-driver VEOF, and only then releases the target.""" + assert self._proc is not None + self._pgid = self._proc.pid # recorded BEFORE the target can run + if self._input_driver is None: + self._inject_veof(deadline) + if pre_ack_delay > 0: + self._wait_pre_ack(pre_ack_delay, deadline) + self._acked = True + ack_w = self._ack_w + assert ack_w is not None + try: + os.write(ack_w, b"\x01") + except BrokenPipeError: + # The launcher gave up first and its own reason is already on the status + # pipe; recovery continues under the same deadline, never a fresh budget. + console.debug("the launcher closed the acknowledgment pipe before the parent acknowledged") + self._close_owned("_ack_w") + + def _wait_pre_ack(self, delay: float, deadline: float) -> None: + until = min(time.monotonic() + delay, deadline) + while time.monotonic() < until: + self._check_cancelled() + self._check_reader_failed() + time.sleep(min(POLL_INTERVAL_SECONDS, max(0.0, until - time.monotonic()))) + + def _inject_veof(self, deadline: float) -> None: + result, receipt = self._input_queue.submit( + self._veof_byte, reserved=True, prepare=self._veof_prepare, finish=self._veof_restore + ) + if result.disposition is not InputDisposition.ACCEPTED: + raise TerminalEnvironmentError(f"The spawn-time EOF was not admitted: {result.disposition.value}") + self._ring_doorbell() + self._await_receipt(receipt, deadline) + + def _await_receipt(self, receipt: _Receipt, deadline: float) -> None: + while not receipt.resolved: + self._check_cancelled() + self._check_reader_failed() + if time.monotonic() >= deadline: + raise TerminalEnvironmentError("The spawn-time EOF was not delivered before the handshake deadline") + time.sleep(POLL_INTERVAL_SECONDS / 4) + if receipt.error is not None: + raise TerminalEnvironmentError(f"The spawn-time EOF failed: {receipt.error!r}") from receipt.error + if receipt.disposition is not InputDisposition.ACCEPTED: + # Teardown resolves receipts before it publishes the reader's failure, so a + # discarded item usually means the reader died; let it publish, then classify. + if self._reader is not None: + self._reader.join(timeout=POLL_INTERVAL_SECONDS * 4) + self._check_reader_failed() + raise TerminalEnvironmentError("The spawn-time EOF was discarded before delivery") + + def _drain_launcher_stderr(self, stderr_fd: int) -> bool: + """Keeps the launcher from blocking on a full stderr pipe. False once it is at EOF.""" + chunk = os.read(stderr_fd, READ_CHUNK_BYTES) + if not chunk: + return False + self.launcher_stderr.feed(chunk) # reads continue after the cap; only retention stops + return True + + def _consume_reader_edge(self, watched: set, err_r: int) -> None: + """A readable err_r means 'consult reader_failed', not 'the reader failed'.""" + try: + os.read(err_r, READ_CHUNK_BYTES) + except OSError: + pass + self._check_reader_failed() + watched.discard(err_r) # EOF is level-triggered and permanent + self._close_owned("_err_r") # ownership transfer, so it needs the swap + + def _launch_message(self, reason: str) -> str: + diagnostic = self.launcher_stderr.text() + if diagnostic: + return f"{reason}. Launcher output:\n{diagnostic}" + return f"{reason}." + + def _check_cancelled(self) -> None: + if self._stop_event.is_set(): + raise RenderCancelledError() + + def _check_reader_failed(self) -> None: + if self.reader_failed.is_set(): + raise TerminalReaderError(f"The terminal output reader failed: {self.reader_exc!r}") + + # ------------------------------------------------------------------- reader + + def _reader_main(self) -> None: + self._gate.wait() + assert self._bundle is not None + if self._bundle.owner != _OWNER_READER: + return # the parent still owns everything; touch nothing, publish nothing + reader_exc: Optional[BaseException] = None + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + try: + self._reader_loop(decoder) + except BaseException as exc: # nothing reaches threading.excepthook + reader_exc = exc + finally: + try: + self._input_queue.close_and_fail_all() + for fd in (self._bundle.take_master(), self._bundle.take_wakeup_r()): + _close_quietly(fd) # independent: one failing close cannot skip the rest + self._flush_decoder(decoder) + except BaseException as exc: # finalization can fail too + reader_exc = reader_exc or exc + finally: + self.reader_exc = reader_exc # stored while still unobservable + if reader_exc is not None: + self.reader_failed.set() + # LAST — the single edge that publishes "the reader is done and owns nothing" + _close_quietly(self._bundle.take_err_w()) + + def _reader_loop(self, decoder) -> None: + assert self._bundle is not None + master_fd = self._bundle.master_fd + wakeup_r = self._bundle.wakeup_r + assert master_fd is not None and wakeup_r is not None + while True: + want_write = [master_fd] if self._input_queue.has_pending() else [] + readable, writable = self._select([master_fd, wakeup_r], want_write, POLL_INTERVAL_SECONDS) + if wakeup_r in readable: + _drain_doorbell(wakeup_r) # bytes coalesce; state carries the meaning + if self._closing.is_set(): + self._input_queue.close_and_fail_all() + self._drain_remaining(master_fd) + return + if master_fd in readable and not self._read_once(master_fd, decoder): + return # output always wins over queued input + if master_fd in writable or self._input_queue.has_pending(): + self._flush_input(master_fd, INPUT_WRITE_BUDGET_BYTES) + + def _select(self, rlist, wlist, timeout): + readable, writable, _ = select.select(rlist, wlist, [], timeout) + return readable, writable + + def _read_master(self, fd: int, size: int) -> bytes: + return os.read(fd, size) + + def _write_master(self, fd: int, data: bytes) -> int: + return os.write(fd, data) + + def _read_once(self, master_fd: int, decoder) -> bool: + try: + chunk = self._read_master(master_fd, READ_CHUNK_BYTES) + except BlockingIOError: + return True + except OSError as exc: + if exc.errno == errno.EIO: # normal PTY EOF on Linux once the last slave closes + return False + raise + if not chunk: # normal EOF elsewhere + return False + self._feed_output(chunk, decoder) + return True + + def _feed_output(self, chunk: bytes, decoder) -> None: + text = decoder.decode(chunk) + with self._output_lock: + self._raw += chunk + if text: + self._decoded.append(text) + + def _flush_decoder(self, decoder) -> None: + tail = decoder.decode(b"", final=True) # a trailing partial sequence becomes U+FFFD + if tail: + with self._output_lock: + self._decoded.append(tail) + + def _flush_input(self, master_fd: int, budget: int) -> None: + """Services the FIFO through one retained cursor, bounded so input cannot starve output.""" + written = 0 + while written < budget: + item = self._input_queue.current() + if item is None: + return + try: + if item.prepare is not None and not item.prepared: + item.prepare() + item.prepared = True + while item.cursor < len(item.data): + try: + count = self._write_master(master_fd, item.data[item.cursor :]) + except BlockingIOError: + return # EAGAIN retains the tail and returns to select() + item.cursor += count + written += count + if written >= budget and item.cursor < len(item.data): + return # a short write retains the suffix for the next iteration + except BaseException as exc: + self._complete_item(item, exc) + raise + error = self._complete_item(item, None) + if error is not None: + raise error + + def _complete_item(self, item: _InputItem, error: Optional[BaseException]) -> Optional[BaseException]: + if item.prepared and item.finish is not None: + try: + item.finish() # the restore is part of the item's contract, so it runs from here too + except BaseException as exc: + error = error or exc + self._input_queue.complete_current(error) + return error + + def _drain_remaining(self, master_fd: int) -> None: + """Catches output already in flight. Bounded by time, by bytes, and by a quiet period.""" + deadline = self._drain_deadline or (time.monotonic() + DRAIN_DEADLINE_SECONDS) + drained = 0 + while drained < DRAIN_MAX_BYTES: + remaining = deadline - time.monotonic() + if remaining <= 0: + return + readable, _ = self._select([master_fd], [], min(remaining, DRAIN_QUIET_PERIOD_SECONDS)) + if not readable: + return # nothing more is in flight + try: + chunk = self._read_master(master_fd, READ_CHUNK_BYTES) + except (BlockingIOError, OSError): + return + if not chunk: + return + with self._output_lock: + self._raw += chunk + drained += len(chunk) + + # ----------------------------------------------------------- VEOF injection + + def _veof_prepare(self) -> None: + """Snapshots the terminal mode and clears echo, executed by the reader alone.""" + assert self._bundle is not None and self._bundle.master_fd is not None + fd = self._bundle.master_fd + self._veof_saved = termios.tcgetattr(fd) + attrs = termios.tcgetattr(fd) + attrs[3] &= ~(termios.ECHO | getattr(termios, "ECHOCTL", 0)) + termios.tcsetattr(fd, termios.TCSANOW, attrs) # TCSAFLUSH could discard the byte + + def _veof_restore(self) -> None: + saved, self._veof_saved = self._veof_saved, None + if saved is None: + return + assert self._bundle is not None and self._bundle.master_fd is not None + termios.tcsetattr(self._bundle.master_fd, termios.TCSANOW, saved) + + # ------------------------------------------------------------ teardown bits + + def _deliver(self, proc: subprocess.Popen, pgid: Optional[int], sig: int) -> None: + if pgid is not None: + _signal_group(pgid, sig) + return + try: # pre-ack: the launcher has not exec'd and never forks, so the PID suffices + proc.send_signal(sig) + except (ProcessLookupError, PermissionError, ValueError): + pass + + def _grace_tick(self) -> None: + time.sleep(GRACE_TICK_SECONDS) + + def _rollback(self) -> None: + try: + if self._proc is not None: + self.terminate_tree(ROLLBACK_GRACE_SECONDS) + finally: + try: + self.close() + finally: # nothing reached an owner yet on the earliest failure paths + _close_quietly(self._take_owned("_pending_master_fd")) + _close_quietly(self._take_owned("_pending_slave_fd")) + + def _ring_doorbell(self) -> None: + """A notification, not a message: producers mutate state first, then ring.""" + with self._fd_lock: # held so close() cannot free the number under the write + fd = self._wakeup_w + if fd is None: + return + try: + os.write(fd, b"\x01") + except OSError: + # EAGAIN means the pipe is already readable, EPIPE/EBADF mean the reader + # is gone — which is the outcome the write was asking for. + pass + + def _take_owned(self, name: str) -> Optional[int]: + with self._fd_lock: + fd = getattr(self, name) + setattr(self, name, None) + return fd + + def _close_owned(self, name: str) -> None: + _close_quietly(self._take_owned(name)) + + +def _drain_doorbell(fd: int) -> None: + while True: + try: + if not os.read(fd, READ_CHUNK_BYTES): + return + except OSError: + return diff --git a/render_machine/terminal_process.py b/render_machine/terminal_process.py new file mode 100644 index 00000000..5059d631 --- /dev/null +++ b/render_machine/terminal_process.py @@ -0,0 +1,145 @@ +"""Platform-neutral terminal-process interface, shared constants, and backend dispatch. + +A `TerminalProcess` runs one command with a terminal behind all three of its standard +descriptors and owns every handle that arrangement needs. The POSIX implementation lives +in `render_machine._posix_pty`; the Windows ConPTY implementation will live in +`render_machine._conpty`. Only this module is imported by callers. +""" + +import sys +import threading +from dataclasses import dataclass +from enum import Enum +from typing import List, Optional, Sequence, Tuple + +# Launch, reader, and writer infrastructure failures surface on the renderer's existing +# environment-error channel rather than being handed to the LLM patcher as a test failure. +ENVIRONMENT_ERROR_EXIT_CODE = 69 + +# The terminal the child sees. Fixed rather than inherited: execution behaviour must not +# depend on the size of the window Codeplain happens to be running in. +TERMINAL_COLUMNS = 120 +TERMINAL_ROWS = 40 + +DEFAULT_TERM = "xterm-256color" + +# Every duration below is a monotonic budget, never wall time. +HANDSHAKE_TIMEOUT_SECONDS = 20.0 +SIGTERM_GRACE_PERIOD_SECONDS = 3.0 +GRACE_TICK_SECONDS = 0.05 +REAP_DEADLINE_SECONDS = 5.0 +DRAIN_DEADLINE_SECONDS = 2.0 +DRAIN_QUIET_PERIOD_SECONDS = 0.1 +POLL_INTERVAL_SECONDS = 0.05 + +# The final drain is bounded by bytes as well as by time: a descendant that escaped the +# process group can keep the master readable forever. +DRAIN_MAX_BYTES = 4 * 1024 * 1024 +READ_CHUNK_BYTES = 65536 + +# Bounds on the ordered input queue. Reserved capacity is an admission partition for +# spawn/control items, never a way to jump the FIFO order. +MAX_INPUT_ITEM_BYTES = 64 * 1024 +MAX_PENDING_INPUT_BYTES = 256 * 1024 +RESERVED_INPUT_BYTES = 8 * 1024 +INPUT_WRITE_BUDGET_BYTES = 64 * 1024 + +# Head and tail retained from the launcher's stderr, so a flooding launcher cannot hand +# the parent an unbounded buffer while the reads continue. +LAUNCHER_STDERR_CAP_BYTES = 16 * 1024 + + +class InputDisposition(Enum): + """Immediate whole-item backend admission — never a delivery receipt.""" + + ACCEPTED = "accepted" + BACKPRESSURE = "backpressure" + CLOSED = "closed" + + +@dataclass(frozen=True) +class InputWriteResult: + disposition: InputDisposition + accepted_bytes: int + + +class TerminalProcessError(Exception): + """Base class for failures the terminal backend reports to the renderer.""" + + +class TerminalEnvironmentError(TerminalProcessError): + """Infrastructure failure — reported on the environment-error channel.""" + + exit_code = ENVIRONMENT_ERROR_EXIT_CODE + + +class TerminalLaunchError(TerminalEnvironmentError): + """The launcher never reached the target command.""" + + +class TerminalReaderError(TerminalEnvironmentError): + """The output reader failed, so the target's output is no longer being drained.""" + + +class TerminalProcess: + """Interface implemented by every backend. + + `spawn()` is bounded and cancellable; `close()` is idempotent and releases every + handle the backend owns. Instances are single-use. + """ + + reader_failed: threading.Event + reader_exc: Optional[BaseException] + + def spawn( + self, + command: Sequence[str], + cwd: Optional[str] = None, + env: Optional[dict] = None, + terminal_size: Tuple[int, int] = (TERMINAL_COLUMNS, TERMINAL_ROWS), + stop_event: Optional[threading.Event] = None, + input_driver: Optional[object] = None, + ) -> None: + raise NotImplementedError + + def poll(self) -> Optional[int]: + """Non-blocking exit status, or None while the target runs. Reaps on completion.""" + raise NotImplementedError + + def read_output(self) -> str: + """Decoded output accumulated since the previous call.""" + raise NotImplementedError + + def read_raw_output(self) -> bytes: + """Raw output bytes accumulated since the previous call.""" + raise NotImplementedError + + def write_input(self, data: bytes) -> InputWriteResult: + raise NotImplementedError + + def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: + raise NotImplementedError + + def close(self) -> None: + raise NotImplementedError + + def __enter__(self) -> "TerminalProcess": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + +def create_terminal_process() -> TerminalProcess: + """Returns the backend for the running platform.""" + if sys.platform == "win32": + raise TerminalEnvironmentError("The ConPTY backend is not implemented yet.") + + from render_machine._posix_pty import PosixPtyProcess + + return PosixPtyProcess() + + +def available_backends() -> List[str]: + """Names the backends this build can construct. Used by diagnostics and tests.""" + return [] if sys.platform == "win32" else ["posix-pty"] diff --git a/tests/test_terminal_process.py b/tests/test_terminal_process.py index dc885fc8..c6128292 100644 --- a/tests/test_terminal_process.py +++ b/tests/test_terminal_process.py @@ -336,3 +336,228 @@ def test_launcher_runs_no_startup_customization(tmp_path): assert [kind for kind, _ in records] == [pty_exec.STARTED, pty_exec.SESSION_READY] assert not sitecustomize_marker.exists() assert not pth_marker.exists() + + +# --------------------------------------------------------------------- backend + +if sys.platform != "win32": + from render_machine import _posix_pty + from render_machine.terminal_process import ( + InputDisposition, + TerminalEnvironmentError, + TerminalLaunchError, + ) + +SPAWN_TIMEOUT = 10.0 + + +@contextmanager +def terminal(**spawn_kwargs): + """Spawns a command through the backend and always tears it down.""" + command = spawn_kwargs.pop("command") + process = _posix_pty.PosixPtyProcess() + try: + process.spawn(command, **spawn_kwargs) + yield process + finally: + try: + process.terminate_tree(grace=0.05) + finally: + process.close() + + +def wait_for_exit(process, timeout=SPAWN_TIMEOUT): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + returncode = process.poll() + if returncode is not None: + return returncode + time.sleep(0.02) + raise AssertionError(f"the target did not exit within {timeout}s") + + +def wait_for_output(process, needle, timeout=SPAWN_TIMEOUT): + deadline = time.monotonic() + timeout + collected = "" + while time.monotonic() < deadline: + collected += process.read_output() + if needle in collected: + return collected + time.sleep(0.02) + raise AssertionError(f"{needle!r} never appeared in {collected!r}") + + +def write_launcher(tmp_path, name, source): + path = tmp_path / name + path.write_text(source) + return str(path) + + +def stub_launcher(tmp_path, name, body): + """A launcher that runs the real protocol with `body` applied to the module first.""" + source = ( + "import sys\n" + f"sys.path.insert(0, {str(REPO_ROOT)!r})\n" + "from render_machine import pty_exec\n" + f"{body}\n" + "pty_exec._run(sys.argv[1:])\n" + ) + return write_launcher(tmp_path, name, source) + + +def test_spawn_runs_the_target_and_reports_its_exit_code(): + with terminal(command=["/bin/sh", "-c", "printf hello; exit 3"]) as process: + wait_for_output(process, "hello") + assert wait_for_exit(process) == 3 + + +def test_read_output_round_trip(): + with terminal(command=["/bin/sh", "-c", "printf 'one\\ntwo\\n'"]) as process: + collected = wait_for_output(process, "two") + assert wait_for_exit(process) == 0 + + assert "one" in collected and "two" in collected + # ONLCR is left at its default, so the terminal supplies the carriage returns. + assert "\r\n" in collected + + +def test_poll_returns_none_until_the_target_exits(): + with terminal(command=["/bin/sh", "-c", "sleep 0.3"]) as process: + assert process.poll() is None + assert wait_for_exit(process) == 0 + assert process.poll() == 0 + + +def test_handshake_reports_a_launcher_that_reached_our_code_and_failed(): + process = _posix_pty.PosixPtyProcess() + try: + with pytest.raises(TerminalLaunchError) as failure: + process.spawn(["/nonexistent/command/for/tests"]) + finally: + process.close() + + assert failure.value.exit_code == 69 + assert "the launcher failed" in str(failure.value) + + +def test_handshake_reports_launcher_invariant_failures(tmp_path, monkeypatch): + launcher = stub_launcher( + tmp_path, + "invariant_launcher.py", + "def _fail():\n" + " raise RuntimeError('PTY is not attached to all three descriptors')\n" + "pty_exec._assert_invariants = _fail", + ) + monkeypatch.setattr(_posix_pty, "_LAUNCHER", launcher) + + process = _posix_pty.PosixPtyProcess() + try: + with pytest.raises(TerminalLaunchError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"]) + finally: + process.close() + + assert failure.value.exit_code == 69 + assert "PTY is not attached to all three descriptors" in str(failure.value) + + +def test_handshake_reports_an_interpreter_that_died_before_our_code(tmp_path, monkeypatch): + launcher = write_launcher(tmp_path, "unparseable_launcher.py", "def broken(:\n") + monkeypatch.setattr(_posix_pty, "_LAUNCHER", launcher) + + process = _posix_pty.PosixPtyProcess() + try: + with pytest.raises(TerminalLaunchError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"]) + finally: + process.close() + + assert failure.value.exit_code == 69 + assert "the interpreter died before running the launcher" in str(failure.value) + assert "SyntaxError" in str(failure.value) + + +def test_handshake_reports_a_launcher_that_hangs_before_exec(tmp_path, monkeypatch): + launcher = write_launcher(tmp_path, "hanging_launcher.py", "import time\ntime.sleep(120)\n") + monkeypatch.setattr(_posix_pty, "_LAUNCHER", launcher) + + process = _posix_pty.PosixPtyProcess() + started = time.monotonic() + try: + with pytest.raises(TerminalLaunchError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"], handshake_timeout=1.0) + finally: + process.close() + + assert time.monotonic() - started < SPAWN_TIMEOUT + assert "hung before exec" in str(failure.value) + + +def test_handshake_rejects_records_whose_payload_looks_like_a_marker(tmp_path, monkeypatch): + """A framed error payload equal to a marker byte is still a failure, never a success.""" + for payload in ("b'\\x02'", "b'\\x01'", "b'\\x02 looks like a marker'"): + launcher = write_launcher( + tmp_path, + f"marker_launcher_{abs(hash(payload))}.py", + "import os, sys\n" + f"sys.path.insert(0, {str(REPO_ROOT)!r})\n" + "from render_machine import pty_exec\n" + "status_fd = int(sys.argv[2])\n" + "pty_exec._write_record(status_fd, pty_exec.STARTED)\n" + f"pty_exec._write_record(status_fd, pty_exec.FAILED, {payload})\n" + "os._exit(127)\n", + ) + monkeypatch.setattr(_posix_pty, "_LAUNCHER", launcher) + + process = _posix_pty.PosixPtyProcess() + try: + with pytest.raises(TerminalLaunchError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"]) + finally: + process.close() + + assert failure.value.exit_code == 69 + assert "the launcher failed" in str(failure.value) + + +def test_spawn_and_close_leak_no_descriptors(): + def open_fd_count(): + return len(os.listdir("/dev/fd")) + + with terminal(command=["/bin/sh", "-c", "printf warmup"]) as process: + wait_for_exit(process) + + baseline = open_fd_count() + for _ in range(3): + with terminal(command=["/bin/sh", "-c", "printf run"]) as process: + wait_for_exit(process) + assert open_fd_count() == baseline + + +def test_openpty_failure_is_an_environment_error(monkeypatch): + monkeypatch.setattr(_posix_pty.os, "openpty", lambda: (_ for _ in ()).throw(OSError(23, "too many open files"))) + process = _posix_pty.PosixPtyProcess() + try: + with pytest.raises(TerminalEnvironmentError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"]) + finally: + process.close() + + assert failure.value.exit_code == 69 + assert "too many open files" in str(failure.value) + + +def test_write_input_reports_whole_item_admission(): + with terminal(command=["/bin/sh", "-c", "read line; printf 'got:%s' \"$line\""], input_driver=object()) as process: + result = process.write_input(b"payload\n") + assert result.disposition is InputDisposition.ACCEPTED + assert result.accepted_bytes == len(b"payload\n") + wait_for_output(process, "got:payload") + assert wait_for_exit(process) == 0 + + +def test_write_input_reports_backpressure_for_an_oversized_item(): + with terminal(command=["/bin/sh", "-c", "sleep 5"], input_driver=object()) as process: + result = process.write_input(b"x" * (_posix_pty.MAX_INPUT_ITEM_BYTES + 1)) + assert result.disposition is InputDisposition.BACKPRESSURE + assert result.accepted_bytes == 0 From 2442a55183fe5eb7fcae12e7cc7e544823dc588e Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sat, 15 Aug 2026 22:04:49 +0200 Subject: [PATCH 08/83] Cover TerminalProcess lifecycle and failure paths Adds the fault-injection and lifecycle suite: reader shutdown, ack-barrier cancellation on both sides, descriptor ownership, escalation and reap ordering, VEOF delivery, input-queue receipts, and the bounded final drain. Two fixes fell out: the reader's select unpacking and joining a reader that never started. --- render_machine/_posix_pty.py | 4 +- tests/test_terminal_process.py | 907 +++++++++++++++++++++++++++++++++ 2 files changed, 909 insertions(+), 2 deletions(-) diff --git a/render_machine/_posix_pty.py b/render_machine/_posix_pty.py index d5f93a05..d6a55cdc 100644 --- a/render_machine/_posix_pty.py +++ b/render_machine/_posix_pty.py @@ -521,7 +521,7 @@ def close(self) -> None: self._drain_deadline = time.monotonic() + DRAIN_DEADLINE_SECONDS self._input_queue.stop_accepting(self._closing) self._ring_doorbell() - if self._reader is not None: + if self._reader is not None and self._reader.ident is not None: # None when it never started self._reader.join(timeout=DRAIN_DEADLINE_SECONDS + REAP_DEADLINE_SECONDS) self._close_owned("_wakeup_w") self._close_owned("_err_r") @@ -725,7 +725,7 @@ def _await_receipt(self, receipt: _Receipt, deadline: float) -> None: if receipt.disposition is not InputDisposition.ACCEPTED: # Teardown resolves receipts before it publishes the reader's failure, so a # discarded item usually means the reader died; let it publish, then classify. - if self._reader is not None: + if self._reader is not None and self._reader.ident is not None: self._reader.join(timeout=POLL_INTERVAL_SECONDS * 4) self._check_reader_failed() raise TerminalEnvironmentError("The spawn-time EOF was discarded before delivery") diff --git a/tests/test_terminal_process.py b/tests/test_terminal_process.py index c6128292..6451e5f0 100644 --- a/tests/test_terminal_process.py +++ b/tests/test_terminal_process.py @@ -5,11 +5,13 @@ behind — the suite runs against a bounded system PTY limit. """ +import errno import os import select import signal import subprocess import sys +import termios import threading import time from contextlib import contextmanager @@ -341,6 +343,7 @@ def test_launcher_runs_no_startup_customization(tmp_path): # --------------------------------------------------------------------- backend if sys.platform != "win32": + from plain2code_exceptions import RenderCancelledError from render_machine import _posix_pty from render_machine.terminal_process import ( InputDisposition, @@ -561,3 +564,907 @@ def test_write_input_reports_backpressure_for_an_oversized_item(): result = process.write_input(b"x" * (_posix_pty.MAX_INPUT_ITEM_BYTES + 1)) assert result.disposition is InputDisposition.BACKPRESSURE assert result.accepted_bytes == 0 + + +# ------------------------------------------------------------------- lifecycle + + +def make_script(directory, name, body): + """Writes an executable /bin/sh script and returns its absolute path.""" + path = Path(directory) / f"{name}.sh" + path.write_text("#!/bin/sh\n" + body) + path.chmod(0o755) + return str(path) + + +def wait_until_gone(pid, timeout=SPAWN_TIMEOUT): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + os.kill(pid, 0) + except ProcessLookupError: + return True + time.sleep(0.02) + return False + + +def reported_pid(process, label, timeout=SPAWN_TIMEOUT): + collected = wait_for_output(process, f"{label}:", timeout) + for line in collected.replace("\r", "").splitlines(): + if line.startswith(f"{label}:"): + return int(line.split(":", 1)[1]) + raise AssertionError(f"no {label} pid in {collected!r}") + + +def test_close_returns_while_the_reader_is_parked_and_a_descendant_holds_the_slave(tmp_path): + """The regression guard for the verified macOS close()-on-a-blocked-read hang. + + The failure mode is a hang rather than an exception, so the assertion is on elapsed + time: `close()` must return and the reader must join while both the leader and a + descendant still hold the slave open. + """ + script = make_script(tmp_path, "holder", "sleep 20 &\nprintf 'descendant:%s\\n' \"$!\"\nsleep 20\n") + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script]) + descendant = reported_pid(process, "descendant") + assert process.poll() is None + assert process._reader is not None and process._reader.is_alive() + + started = time.monotonic() + process.close() + elapsed = time.monotonic() - started + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert elapsed < _posix_pty.DRAIN_DEADLINE_SECONDS + SHORT_TIMEOUT + assert not process._reader.is_alive() + assert process.reader_exc is None + assert wait_until_gone(descendant) + + +def test_reader_exits_cleanly_when_the_leader_exits_with_a_descendant_on_the_slave(tmp_path): + """Either the hangup or the last slave close ends the stream; neither may raise.""" + script = make_script(tmp_path, "leaver", "sleep 20 &\nprintf 'descendant:%s\\n' \"$!\"\nexit 0\n") + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script]) + descendant = reported_pid(process, "descendant") + assert wait_for_exit(process) == 0 + started = time.monotonic() + process.close() + elapsed = time.monotonic() - started + finally: + process.terminate_tree(grace=0.05) + process.close() + if not wait_until_gone(descendant, timeout=0.5): + os.kill(descendant, signal.SIGKILL) + + assert elapsed < _posix_pty.DRAIN_DEADLINE_SECONDS + SHORT_TIMEOUT + assert process.reader_exc is None + + +def test_cancellation_inside_the_ack_window_leaves_nothing_behind(): + """Deterministic through the delayed-ack hook: the window is opened, not raced.""" + stop_event = threading.Event() + process = _posix_pty.PosixPtyProcess() + threading.Timer(0.2, stop_event.set).start() + try: + with pytest.raises(RenderCancelledError): + process.spawn(["/bin/sh", "-c", "sleep 30"], stop_event=stop_event, pre_ack_delay=5.0) + finally: + process.close() + + launcher_pid = process._proc.pid + assert process._proc.returncode is not None + assert wait_until_gone(launcher_pid) + + +def test_cancellation_after_the_ack_reaps_a_forked_descendant(tmp_path): + script = make_script( + tmp_path, + "forker", + "sleep 30 &\nprintf 'descendant:%s\\n' \"$!\"\nsleep 30\n", + ) + stop_event = threading.Event() + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script], stop_event=stop_event) + descendant = reported_pid(process, "descendant") + stop_event.set() + process.terminate_tree(grace=0.2) + finally: + process.close() + + assert wait_until_gone(descendant) + + +def test_launcher_ack_timeout_beats_the_parents_ack(): + """The parent's write hits a closed pipe; the launcher's own reason must surface.""" + env = dict(os.environ, **{pty_exec.ACK_TIMEOUT_ENV: "0.2"}) + process = _posix_pty.PosixPtyProcess() + try: + with pytest.raises(TerminalLaunchError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"], env=env, pre_ack_delay=2.0, handshake_timeout=10.0) + finally: + process.close() + + assert failure.value.exit_code == 69 + assert "did not acknowledge" in str(failure.value) + + +def test_codeplains_own_process_group_is_never_signalled(tmp_path, monkeypatch): + signalled = [] + real_killpg = os.killpg + + def recording_killpg(pgid, sig): + signalled.append(pgid) + return real_killpg(pgid, sig) + + monkeypatch.setattr(_posix_pty.os, "killpg", recording_killpg) + own_pgid = os.getpgrp() + + # Cancellation before the handshake completes, where no group has been recorded yet. + hanging = write_launcher(tmp_path, "hang.py", "import time\ntime.sleep(120)\n") + monkeypatch.setattr(_posix_pty, "_LAUNCHER", hanging) + stop_event = threading.Event() + threading.Timer(0.2, stop_event.set).start() + process = _posix_pty.PosixPtyProcess() + try: + with pytest.raises(RenderCancelledError): + process.spawn(["/bin/sh", "-c", "sleep 30"], stop_event=stop_event, handshake_timeout=10.0) + finally: + process.close() + + # Cancellation inside the ack window, and termination after a normal spawn. + monkeypatch.undo() + monkeypatch.setattr(_posix_pty.os, "killpg", recording_killpg) + stop_event = threading.Event() + threading.Timer(0.2, stop_event.set).start() + process = _posix_pty.PosixPtyProcess() + try: + with pytest.raises(RenderCancelledError): + process.spawn(["/bin/sh", "-c", "sleep 30"], stop_event=stop_event, pre_ack_delay=5.0) + finally: + process.close() + + with terminal(command=["/bin/sh", "-c", "sleep 30"]) as running: + running.terminate_tree(grace=0.1) + + assert own_pgid not in signalled + assert signalled, "the recorded group should still be signalled on the ordinary path" + + +def test_killpg_has_exactly_one_call_site(): + """A bare os.killpg(os.getpgid(...)) anywhere is the F1 defect returning.""" + source = Path(_posix_pty.__file__).read_text() + assert source.count("os.killpg(") == 1 + assert "getpgid" not in source + + +def test_spawn_without_a_stop_event_runs_end_to_end(): + process = _posix_pty.PosixPtyProcess() + try: + process.spawn(["/bin/sh", "-c", "printf done; exit 0"]) + wait_for_output(process, "done") + assert wait_for_exit(process) == 0 + finally: + process.close() + + +def test_spawn_time_veof_lets_a_single_read_script_exit_promptly(tmp_path): + script = make_script(tmp_path, "single_read", "read line\nprintf 'read-returned:%s\\n' \"$?\"\nexit 0\n") + started = time.monotonic() + with terminal(command=[script]) as process: + wait_for_output(process, "read-returned:") + assert wait_for_exit(process) == 0 + assert time.monotonic() - started < SHORT_TIMEOUT + + +def test_spawn_time_veof_leaves_no_trace(tmp_path): + """Echo is disabled around the injection, so a silent command stays byte-empty.""" + script = make_script(tmp_path, "silent", "exit 0\n") + with terminal(command=[script]) as process: + assert wait_for_exit(process) == 0 + deadline = time.monotonic() + 1.0 + while time.monotonic() < deadline: + time.sleep(0.05) + raw = process.read_raw_output() + decoded = process.read_output() + + assert raw == b"" + assert decoded == "" + + +def test_a_slow_silent_script_runs_to_completion_untouched(tmp_path): + """The regression guard against the rejected silence timer.""" + script = make_script(tmp_path, "slow_silent", "sleep 1.5\nprintf finished\nexit 0\n") + with terminal(command=[script]) as process: + wait_for_output(process, "finished") + assert wait_for_exit(process) == 0 + + +def arm_fault(process, method, error=None): + """Wraps one reader entry point so a failure can be injected at a chosen moment.""" + real = getattr(process, method) + state = {"armed": False, "calls": 0} + + def faulty(*args, **kwargs): + state["calls"] += 1 + if state["armed"]: + raise error if error is not None else OSError(errno.EBADF, "injected reader failure") + return real(*args, **kwargs) + + setattr(process, method, faulty) + return state + + +def open_fd_count(): + return len(os.listdir("/dev/fd")) + + +def test_close_is_idempotent_and_survives_a_partial_spawn(monkeypatch): + baseline = open_fd_count() + + monkeypatch.setattr( + _posix_pty.subprocess, "Popen", lambda *a, **k: (_ for _ in ()).throw(OSError(2, "no interpreter")) + ) + process = _posix_pty.PosixPtyProcess() + with pytest.raises(TerminalEnvironmentError): + process.spawn(["/bin/sh", "-c", "exit 0"]) + process.close() + process.close() + + assert open_fd_count() == baseline + + +def test_failure_before_the_reader_starts_closes_the_parent_owned_descriptors(monkeypatch): + """No reader exists to close them, so spawn()'s except path has to.""" + baseline = open_fd_count() + monkeypatch.setattr( + _posix_pty.subprocess, "Popen", lambda *a, **k: (_ for _ in ()).throw(OSError(2, "no interpreter")) + ) + process = _posix_pty.PosixPtyProcess() + with pytest.raises(TerminalEnvironmentError): + process.spawn(["/bin/sh", "-c", "exit 0"]) + + assert process._bundle is not None + assert process._bundle.owner == "parent" + assert process._bundle.master_fd is None + assert process._bundle.wakeup_r is None + assert process._bundle.err_w is None + assert open_fd_count() == baseline + + +def test_closing_err_r_transfers_ownership_rather_than_sharing_it(tmp_path): + """A descriptor number is reusable the instant it is freed, so the field is swapped + to None before the close and only what the swap returned is closed.""" + process = _posix_pty.PosixPtyProcess() + unrelated = None + unrelated_path = tmp_path / "unrelated.txt" + try: + process.spawn(["/bin/sh", "-c", "sleep 5"]) + process._close_owned("_err_r") # the transfer the handshake performs on a reader edge + unrelated = os.open(str(unrelated_path), os.O_CREAT | os.O_RDWR, 0o600) + process.terminate_tree(grace=0.05) + process.close() + process.close() + os.write(unrelated, b"still mine") # close() must not have taken this number + finally: + if unrelated is not None: + os.close(unrelated) + process.close() + + assert unrelated_path.read_bytes() == b"still mine" + + +def test_descriptor_counts_are_stable_across_failing_spawns(tmp_path, monkeypatch): + """Covers the ack pair and the launcher's stderr as well as the reader bundle.""" + hanging = write_launcher(tmp_path, "hang_fd.py", "import time\ntime.sleep(120)\n") + process = _posix_pty.PosixPtyProcess() + try: + process.spawn(["/bin/sh", "-c", "exit 0"]) + wait_for_exit(process) + finally: + process.close() + + baseline = open_fd_count() + for _ in range(2): + failing = _posix_pty.PosixPtyProcess() + with pytest.raises(TerminalLaunchError): + failing.spawn(["/nonexistent/command/for/tests"]) + failing.close() + assert open_fd_count() == baseline + + monkeypatch.setattr(_posix_pty, "_LAUNCHER", hanging) + hung = _posix_pty.PosixPtyProcess() + with pytest.raises(TerminalLaunchError): + hung.spawn(["/bin/sh", "-c", "exit 0"], handshake_timeout=0.5) + hung.close() + monkeypatch.undo() + assert open_fd_count() == baseline + + +def test_a_launcher_that_floods_stderr_does_not_stall_the_handshake(tmp_path, monkeypatch): + flood = write_launcher( + tmp_path, + "flood.py", + "import os\n" + "payload = b'HEAD' + b'x' * (512 * 1024) + b'TAIL'\n" + "while payload:\n" + " payload = payload[os.write(2, payload):]\n" + "os._exit(3)\n", + ) + monkeypatch.setattr(_posix_pty, "_LAUNCHER", flood) + + process = _posix_pty.PosixPtyProcess() + started = time.monotonic() + try: + with pytest.raises(TerminalLaunchError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"], handshake_timeout=SPAWN_TIMEOUT) + finally: + process.close() + + assert time.monotonic() - started < SPAWN_TIMEOUT + assert "the interpreter died before running the launcher" in str(failure.value) + diagnostic = process.launcher_stderr + assert diagnostic.total > 512 * 1024, "the flood was not read to completion" + text = diagnostic.text() + assert text.startswith("HEAD") and text.endswith("TAIL") + assert len(text) < 2 * _posix_pty.LAUNCHER_STDERR_CAP_BYTES + 128 + + +def test_escalation_is_driven_by_the_clock_not_by_the_leaders_exit(tmp_path): + """The leader dies on SIGTERM at once; the descendant that ignores it must still go.""" + script = make_script( + tmp_path, + "escalation", + "( trap '' TERM; printf 'descendant:%s\\n' \"$$\"; sleep 30 ) &\nsleep 30\n", + ) + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script]) + descendant = reported_pid(process, "descendant") + process.terminate_tree(grace=0.3) + finally: + process.close() + + assert wait_until_gone(descendant) + + +def test_teardown_tolerates_a_zombie_only_group(tmp_path): + """The graceful path: the leader has exited and only our unreaped zombie remains.""" + script = make_script(tmp_path, "quick", "printf bye\nexit 0\n") + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script]) + wait_for_output(process, "bye") + time.sleep(0.3) # let the leader exit without reaping it through poll() + process.terminate_tree(grace=0.1) + finally: + process.close() + + assert process._proc.returncode is not None # reaped despite the EPERM answer + + +def test_teardown_tolerates_a_permission_error_from_killpg(tmp_path, monkeypatch): + """macOS answers EPERM, not ESRCH, for a group holding only our zombie leader.""" + script = make_script(tmp_path, "quick_eperm", "sleep 30\n") + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script]) + + def denying_killpg(pgid, sig): + raise PermissionError(1, "Operation not permitted") + + monkeypatch.setattr(_posix_pty.os, "killpg", denying_killpg) + process.terminate_tree(grace=0.05) + monkeypatch.undo() + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert process._reaped + + +def test_the_grace_period_survives_cancellation(tmp_path): + """stop_event is already set when teardown begins, so the grace runs off its own clock.""" + script = make_script( + tmp_path, + "graceful", + "trap 'printf handled; exit 0' TERM\nprintf ready\nwhile true; do sleep 0.05; done\n", + ) + stop_event = threading.Event() + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script], stop_event=stop_event) + wait_for_output(process, "ready") + stop_event.set() + process.terminate_tree(grace=2.0) + collected = process.read_output() + finally: + process.close() + + assert "handled" in collected + assert process._proc.returncode == 0 + + +def test_an_exception_mid_grace_still_escalates(tmp_path): + script = make_script( + tmp_path, + "interrupted_grace", + "( trap '' TERM; printf 'descendant:%s\\n' \"$$\"; sleep 30 ) &\nsleep 30\n", + ) + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script]) + descendant = reported_pid(process, "descendant") + + def interrupting_tick(): + raise KeyboardInterrupt() + + process._grace_tick = interrupting_tick + with pytest.raises(KeyboardInterrupt): + process.terminate_tree(grace=1.0) + finally: + process.close() + + assert wait_until_gone(descendant) + assert process._proc.returncode is not None + + +def test_a_reader_failure_during_teardown_still_escalates(tmp_path): + """Teardown records the error and runs the sequence to completion before reporting.""" + script = make_script( + tmp_path, + "reader_fault_grace", + "trap '' TERM\n( trap '' TERM; printf 'descendant:%s\\n' \"$$\"; sleep 30 ) &\nsleep 30\n", + ) + process = _posix_pty.PosixPtyProcess() + fault = arm_fault(process, "_select") + try: + process.spawn([script]) + descendant = reported_pid(process, "descendant") + + real_tick = process._grace_tick + + def failing_tick(): + fault["armed"] = True + real_tick() + + process._grace_tick = failing_tick + process.terminate_tree(grace=0.5) + finally: + process.close() + + assert wait_until_gone(descendant) + assert process.reader_failed.is_set() + with pytest.raises(_posix_pty.TerminalReaderError) as failure: + process._check_reader_failed() + assert failure.value.exit_code == 69 + + +def test_a_reader_failure_during_the_handshake_aborts_it_promptly(): + process = _posix_pty.PosixPtyProcess() + fault = arm_fault(process, "_select") + fault["armed"] = True + started = time.monotonic() + try: + with pytest.raises(_posix_pty.TerminalReaderError) as failure: + process.spawn(["/bin/sh", "-c", "sleep 30"], handshake_timeout=SPAWN_TIMEOUT) + finally: + process.close() + + assert time.monotonic() - started < SPAWN_TIMEOUT # not at the deadline + assert failure.value.exit_code == 69 + assert process._bundle.master_fd is None and process._bundle.wakeup_r is None + assert process._bundle.err_w is None + assert process._proc.returncode is not None # the child was terminated + + +def test_a_failing_read_closes_the_descriptors_and_is_classified(tmp_path): + script = make_script(tmp_path, "chatty", "while true; do printf tick; sleep 0.05; done\n") + process = _posix_pty.PosixPtyProcess() + fault = arm_fault(process, "_read_master") + try: + process.spawn([script]) + wait_for_output(process, "tick") + fault["armed"] = True + deadline = time.monotonic() + SPAWN_TIMEOUT + while not process.reader_failed.is_set() and time.monotonic() < deadline: + time.sleep(0.02) + assert process.reader_failed.is_set() + with pytest.raises(_posix_pty.TerminalReaderError) as failure: + process._check_reader_failed() + process.terminate_tree(grace=0.05) + finally: + process.close() + + assert failure.value.exit_code == 69 + assert process._bundle.master_fd is None and process._bundle.wakeup_r is None + assert process._bundle.err_w is None + assert process._proc.returncode is not None + + +def test_a_failing_final_flush_is_published_with_err_w_closed_last(tmp_path): + script = make_script(tmp_path, "brief", "printf bye\nexit 0\n") + process = _posix_pty.PosixPtyProcess() + process._flush_decoder = lambda decoder: (_ for _ in ()).throw(RuntimeError("injected flush failure")) + try: + process.spawn([script]) + wait_for_output(process, "bye") + deadline = time.monotonic() + SPAWN_TIMEOUT + while not process.reader_failed.is_set() and time.monotonic() < deadline: + time.sleep(0.02) + finally: + process.close() + + assert process.reader_failed.is_set() + assert isinstance(process.reader_exc, RuntimeError) + assert process._bundle.err_w is None # closed last, after everything else was released + + +def test_the_veof_transaction_runs_on_the_reader_and_restores_the_terminal_mode(tmp_path): + script = make_script(tmp_path, "veof_owner", "sleep 5\n") + process = _posix_pty.PosixPtyProcess() + threads = [] + receipts = [] + real_prepare = process._veof_prepare + real_submit = process._input_queue.submit + + def recording_prepare(): + threads.append(threading.current_thread().name) + real_prepare() + + def recording_submit(*args, **kwargs): + result, receipt = real_submit(*args, **kwargs) + receipts.append(receipt) + return result, receipt + + process._veof_prepare = recording_prepare + process._input_queue.submit = recording_submit + try: + process.spawn([script]) + attributes = termios.tcgetattr(process._bundle.master_fd) # read-only probe + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert threads == ["codeplain-pty-reader"], "no parent helper may touch the raw master" + assert receipts and receipts[0].resolutions == 1 + assert attributes[3] & termios.ECHO, "the snapshot was not restored" + + +def test_a_failing_veof_snapshot_prevents_the_ack(): + process = _posix_pty.PosixPtyProcess() + process._veof_prepare = lambda: (_ for _ in ()).throw(OSError(errno.EIO, "injected snapshot failure")) + try: + with pytest.raises(TerminalEnvironmentError) as failure: + process.spawn(["/bin/sh", "-c", "printf ran"]) + finally: + process.close() + + assert failure.value.exit_code == 69 + assert not process._acked + assert process.read_raw_output() == b"" # the target never ran + + +def test_a_failing_veof_restore_is_attempted_and_reported(): + process = _posix_pty.PosixPtyProcess() + attempts = [] + + def failing_restore(): + attempts.append("restore") + raise OSError(errno.EIO, "injected restore failure") + + process._veof_restore = failing_restore + try: + with pytest.raises(TerminalEnvironmentError) as failure: + process.spawn(["/bin/sh", "-c", "printf ran"]) + finally: + process.close() + + assert attempts == ["restore"] # every path that changed the mode attempts the restore + assert failure.value.exit_code == 69 + assert not process._acked + + +def test_the_veof_survives_an_eagain_mid_item(tmp_path): + script = make_script(tmp_path, "veof_eagain", "read line\nprintf 'read-returned:%s\\n' \"$?\"\n") + process = _posix_pty.PosixPtyProcess() + real_write = process._write_master + state = {"blocked": False} + + def blocking_once(fd, data): + if not state["blocked"]: + state["blocked"] = True + raise BlockingIOError(errno.EAGAIN, "injected EAGAIN") + return real_write(fd, data) + + process._write_master = blocking_once + try: + process.spawn([script]) + wait_for_output(process, "read-returned:") + assert wait_for_exit(process) == 0 + finally: + process.close() + + assert state["blocked"] + + +def test_close_during_an_in_flight_fragmented_item_fails_its_receipt_once(tmp_path): + script = make_script(tmp_path, "fragmented_close", "sleep 10\n") + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script], input_driver=object()) + real_write = process._write_master + state = {"calls": 0} + + def stalling_write(fd, data): + state["calls"] += 1 + if state["calls"] == 1: + return real_write(fd, data[:1]) + raise BlockingIOError(errno.EAGAIN, "held mid-item") + + process._write_master = stalling_write + payload = b"abcdef" + result, receipt = process._input_queue.submit(payload) + assert result.disposition is InputDisposition.ACCEPTED + process._ring_doorbell() + + deadline = time.monotonic() + SPAWN_TIMEOUT + while state["calls"] < 2 and time.monotonic() < deadline: + time.sleep(0.02) + assert state["calls"] >= 2 + # Dequeue is not completion: the retained cursor still counts against the cap. + assert process._input_queue.pending_bytes() == len(payload) + + process.close() + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert receipt.resolutions == 1 + assert receipt.disposition is InputDisposition.CLOSED + assert process._input_queue.pending_bytes() == 0 + + +def test_a_saturated_doorbell_is_only_a_coalesced_notification(tmp_path): + script = make_script(tmp_path, "doorbell", "sleep 10\n") + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script], input_driver=object()) + while True: # fill the doorbell to EAGAIN + try: + os.write(process._wakeup_w, b"\x01" * 4096) + except BlockingIOError: + break + + result = process.write_input(b"after saturation\n") + assert result.disposition is InputDisposition.ACCEPTED + + started = time.monotonic() + process.close() + elapsed = time.monotonic() - started + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert elapsed < _posix_pty.DRAIN_DEADLINE_SECONDS + SHORT_TIMEOUT + assert process._input_queue.pending_bytes() == 0 + + +def test_a_fragmented_logical_write_keeps_its_suffix_ahead_of_later_items(tmp_path): + """The public result stays whole-item; no PARTIAL and no interleaving escape.""" + script = make_script(tmp_path, "ordering", "sleep 10\n") + process = _posix_pty.PosixPtyProcess() + written = [] + released = threading.Event() + try: + process.spawn([script], input_driver=object()) + real_write = process._write_master + state = {"held": False} + + def fragmenting_write(fd, data): + count = real_write(fd, data[:2]) + written.append(data[:count]) + if not state["held"]: + state["held"] = True + released.wait(SHORT_TIMEOUT) # hold the reader inside the first item + return count + + process._write_master = fragmenting_write + first = process.write_input(b"AAAAAAAA") + deadline = time.monotonic() + SPAWN_TIMEOUT + while not state["held"] and time.monotonic() < deadline: + time.sleep(0.02) + second = process.write_input(b"BBBB") + third = process.write_input(b"CCCC") + released.set() + + deadline = time.monotonic() + SPAWN_TIMEOUT + while process._input_queue.has_pending() and time.monotonic() < deadline: + time.sleep(0.02) + finally: + released.set() + process.terminate_tree(grace=0.05) + process.close() + + assert [r.disposition for r in (first, second, third)] == [InputDisposition.ACCEPTED] * 3 + assert [r.accepted_bytes for r in (first, second, third)] == [8, 4, 4] + assert b"".join(written) == b"AAAAAAAABBBBCCCC" + + +def test_the_final_drain_is_bounded_against_a_continuously_writing_escapee(tmp_path): + escapee = tmp_path / "escapee.py" + escapee.write_text( + "import os, sys, time\n" + "os.setpgid(0, 0)\n" + "sys.stdout.write('escapee:%d\\n' % os.getpid())\n" + "sys.stdout.flush()\n" + "end = time.monotonic() + 30\n" + "while time.monotonic() < end:\n" + " sys.stdout.write('x' * 4096)\n" + " sys.stdout.flush()\n" + ) + script = make_script(tmp_path, "escaper", f'"{sys.executable}" "{escapee}" &\nsleep 30\n') + + process = _posix_pty.PosixPtyProcess() + escapee_pid = None + try: + process.spawn([script]) + escapee_pid = reported_pid(process, "escapee") + process.terminate_tree(grace=0.1) # the escapee left the group and survives + started = time.monotonic() + process.close() + elapsed = time.monotonic() - started + finally: + process.close() + if escapee_pid is not None and not wait_until_gone(escapee_pid, timeout=0.5): + os.kill(escapee_pid, signal.SIGKILL) + + assert elapsed < _posix_pty.DRAIN_DEADLINE_SECONDS + SHORT_TIMEOUT + + +def test_write_input_after_the_reader_closed_the_master_touches_nothing(tmp_path): + unrelated_path = tmp_path / "unrelated.txt" + process = _posix_pty.PosixPtyProcess() + unrelated = None + try: + process.spawn(["/bin/sh", "-c", "printf bye"], input_driver=object()) + assert wait_for_exit(process) == 0 + deadline = time.monotonic() + SPAWN_TIMEOUT + while process._bundle.master_fd is not None and time.monotonic() < deadline: + time.sleep(0.02) + assert process._bundle.master_fd is None + process.close() + + unrelated = os.open(str(unrelated_path), os.O_CREAT | os.O_RDWR, 0o600) + result = process.write_input(b"nowhere") + os.write(unrelated, b"untouched") + finally: + if unrelated is not None: + os.close(unrelated) + process.close() + + assert result.disposition is InputDisposition.CLOSED + assert result.accepted_bytes == 0 + assert unrelated_path.read_bytes() == b"untouched" + + +def test_a_jumping_wall_clock_changes_nothing(monkeypatch, tmp_path): + """Every budget is monotonic; wall time is only ever a human timestamp.""" + assert "time.time(" not in Path(_posix_pty.__file__).read_text() + + jumps = iter([10_000.0, -10_000.0]) + real_time = time.time + + def jumping_time(): + try: + return real_time() + next(jumps) + except StopIteration: + return real_time() + + monkeypatch.setattr(time, "time", jumping_time) + script = make_script(tmp_path, "clock", "printf steady\nsleep 30\n") + started = time.monotonic() + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script]) + wait_for_output(process, "steady") + assert process.poll() is None # not terminated early + process.terminate_tree(grace=0.2) + finally: + process.close() + + assert time.monotonic() - started < SPAWN_TIMEOUT + assert process._proc.returncode is not None + + +def test_the_interpreter_exits_while_the_reader_and_the_reaper_are_still_blocked(tmp_path): + """Every thread this design starts is a daemon; a non-daemon one hangs shutdown.""" + driver = tmp_path / "daemon_threads.py" + driver.write_text( + "import subprocess, sys, threading\n" + f"sys.path.insert(0, {str(REPO_ROOT)!r})\n" + "from render_machine import _posix_pty\n" + "never_set = threading.Event()\n" + "class StuckProc:\n" + " pid = -1\n" + " def wait(self, timeout=None):\n" + " if timeout is not None:\n" + " raise subprocess.TimeoutExpired('stuck', timeout)\n" + " never_set.wait()\n" + "process = _posix_pty.PosixPtyProcess()\n" + "process.spawn(['/bin/sh', '-c', 'sleep 5'])\n" + "_posix_pty._reap(StuckProc(), 0.01)\n" + "assert process._reader.is_alive()\n" + "sys.stdout.write('ready\\n')\n" + "sys.stdout.flush()\n" + ) + started = time.monotonic() + completed = subprocess.run([sys.executable, str(driver)], capture_output=True, text=True, timeout=SPAWN_TIMEOUT) + elapsed = time.monotonic() - started + + assert "ready" in completed.stdout, completed.stderr + assert completed.returncode == 0 + assert elapsed < SPAWN_TIMEOUT + + +def framed(kind, payload=b""): + return bytes([kind]) + len(payload).to_bytes(4, "big") + payload + + +def test_the_handshake_parser_accepts_only_started_then_session_ready_then_eof(): + parser = _posix_pty._HandshakeParser() + parser.feed(framed(pty_exec.STARTED)) + parser.feed(framed(pty_exec.SESSION_READY)) + assert parser.session_ready + parser.eof() # the only success case + + +@pytest.mark.parametrize( + "chunks", + [ + [bytes([0x7F]) + (0).to_bytes(4, "big")], # unknown record type + [bytes([pty_exec.STARTED]) + (pty_exec.MAX_PAYLOAD + 1).to_bytes(4, "big")], # oversized length + [framed(pty_exec.STARTED, b"payload")], # a marker carrying a payload + [framed(pty_exec.SESSION_READY)], # SESSION_READY before STARTED + [framed(pty_exec.STARTED), framed(pty_exec.STARTED)], # duplicate marker + [framed(pty_exec.STARTED), framed(pty_exec.SESSION_READY), framed(pty_exec.SESSION_READY)], + [framed(pty_exec.STARTED), framed(pty_exec.FAILED, b"boom"), b"trailing"], + ], +) +def test_the_handshake_parser_rejects_malformed_frames(chunks): + """An oversized length is rejected before its body is allocated or waited for.""" + parser = _posix_pty._HandshakeParser() + with pytest.raises(_posix_pty._ProtocolError): + for chunk in chunks: + parser.feed(chunk) + + +@pytest.mark.parametrize( + "chunks", + [ + [framed(pty_exec.STARTED), b"\x02\x00"], # truncated header at EOF + [framed(pty_exec.STARTED), bytes([pty_exec.FAILED]) + (8).to_bytes(4, "big") + b"half"], + [framed(pty_exec.STARTED)], # EOF after only STARTED + [], # EOF with no marker at all + ], +) +def test_the_handshake_parser_rejects_incomplete_streams_at_eof(chunks): + parser = _posix_pty._HandshakeParser() + for chunk in chunks: + parser.feed(chunk) + with pytest.raises(_posix_pty._ProtocolError): + parser.eof() + + +def test_the_handshake_parser_reassembles_fragmented_records(): + parser = _posix_pty._HandshakeParser() + stream = framed(pty_exec.STARTED) + framed(pty_exec.SESSION_READY) + for index in range(len(stream)): + parser.feed(stream[index : index + 1]) + assert parser.started and parser.session_ready + parser.eof() From 5d597ec3817a79afbb8d62f014a1a8fb0a66da02 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sat, 15 Aug 2026 22:22:48 +0200 Subject: [PATCH 09/83] Add VT output normalizer backed by pyte Renders the terminal stream into scrollback plus final screen instead of stripping escape bytes, so repaints and progress rewrites collapse the way a terminal collapses them, and caps the transcript head and tail. Fixtures are real recordings of npm, pytest, a spinner, a full-screen repainter, and a detached run. --- pyproject.toml | 1 + render_machine/output_normalizer.py | 206 +++++++++++++ requirements.txt | 1 + tests/fixtures/terminal_output/fullscreen.raw | 145 +++++++++ .../fixtures/terminal_output/nohup_build.raw | 3 + .../fixtures/terminal_output/npm_install.raw | 3 + .../fixtures/terminal_output/pytest_color.raw | 19 ++ tests/fixtures/terminal_output/spinner.raw | 2 + tests/test_output_normalizer.py | 287 ++++++++++++++++++ 9 files changed, 667 insertions(+) create mode 100644 render_machine/output_normalizer.py create mode 100644 tests/fixtures/terminal_output/fullscreen.raw create mode 100644 tests/fixtures/terminal_output/nohup_build.raw create mode 100644 tests/fixtures/terminal_output/npm_install.raw create mode 100644 tests/fixtures/terminal_output/pytest_color.raw create mode 100644 tests/fixtures/terminal_output/spinner.raw create mode 100644 tests/test_output_normalizer.py diff --git a/pyproject.toml b/pyproject.toml index 82f56879..710db10d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "rich==15.0.0", "python-frontmatter==1.3.0", "networkx==3.6.1", + "pyte==0.8.2", "sentry-sdk==2.66.1", ] diff --git a/render_machine/output_normalizer.py b/render_machine/output_normalizer.py new file mode 100644 index 00000000..10118508 --- /dev/null +++ b/render_machine/output_normalizer.py @@ -0,0 +1,206 @@ +"""Renders a terminal byte stream instead of stripping bytes out of it. + +Under a PTY `isatty()` is true, so toolchains emit colour, cursor movement, progress-line +rewrites and full-screen repaints. Deleting those bytes would delete the *instruction* +without performing the *operation*: every stale frame would survive and be concatenated. A +tool repainting a status block 200 times would yield 200 stacked copies where a terminal +shows one. + +So the normalizer runs a VT state machine (pyte) over the raw bytes and emits what a +terminal would have shown: the lines that scrolled off, then the final screen. `\\r\\n` +collapses to `\\n` and no SGR survives, because the output is rendered from the screen +buffer rather than filtered out of the stream. + +The parser also runs live in the reader, because terminals answer queries: a target may +emit `ESC[5n`, `ESC[6n` or `ESC[c` and block until the terminal replies. `reply_handler` +receives those replies; a normalizer constructed without one simply renders. +""" + +import collections +import threading +from typing import Callable, Deque, Dict, List, Optional + +import pyte +from pyte.screens import Char, Margins + +from render_machine.terminal_process import TERMINAL_COLUMNS, TERMINAL_ROWS + +# Head and tail of the scrolled-off transcript. Blind truncation would drop whichever end +# happens to matter; a failing run needs its invocation (head) and its error (tail). +SCROLLBACK_HEAD_LINES = 300 +SCROLLBACK_TAIL_LINES = 1700 + +# Private DEC modes that swap in the alternate screen buffer. +ALTERNATE_SCREEN_MODES = (47, 1047, 1049) + +# Query kinds reported to the reply handler. +QUERY_DEVICE_STATUS = "device-status" +QUERY_CURSOR_POSITION = "cursor-position" +QUERY_DEVICE_ATTRIBUTES = "device-attributes" + + +def render_line(line: Dict[int, Char], columns: int) -> str: + """One buffer line as plain text. + + The cell after a double-width character holds an empty stub, so a plain join over the + row reproduces what the screen shows without consulting character widths. + """ + return "".join(line[x].data for x in range(columns)).rstrip() + + +def _trim_trailing_blanks(lines: List[str]) -> List[str]: + while lines and not lines[-1]: + lines.pop() + return lines + + +class _RetainedLines: + """Keeps the head and the tail of the scrolled-off transcript.""" + + def __init__(self, head_lines: int, tail_lines: int) -> None: + self._head: List[str] = [] + self._tail: Deque[str] = collections.deque(maxlen=tail_lines) + self._head_lines = head_lines + self.total = 0 + + def append(self, line: str) -> None: + self.total += 1 + if len(self._head) < self._head_lines: + self._head.append(line) + else: + self._tail.append(line) + + def lines(self) -> List[str]: + omitted = self.total - len(self._head) - len(self._tail) + if omitted <= 0: + return self._head + list(self._tail) + return self._head + [f"...[{omitted} lines omitted]..."] + list(self._tail) + + +class _RenderingScreen(pyte.Screen): + """A pyte screen that retains what scrolls off and answers device queries. + + pyte keeps only the visible screen and its `write_process_input()` is a no-op, so both + behaviours are supplied here. + """ + + def __init__( + self, + columns: int, + lines: int, + scrollback: _RetainedLines, + reply_handler: Optional[Callable[[str, bytes], None]], + ) -> None: + # Set before super().__init__, which resets the screen and can reach these. + self._scrollback = scrollback + self._reply_handler = reply_handler + self._alternate = False + self._query_kind = QUERY_DEVICE_ATTRIBUTES + super().__init__(columns, lines) + + # ------------------------------------------------------------------ scrollback + + def index(self) -> None: + """Overloaded to retain the line the scroll pushes off the top.""" + top, bottom = self.margins or Margins(0, self.lines - 1) + if self.cursor.y == bottom and not self._alternate: + self._scrollback.append(render_line(self.buffer[top], self.columns)) + super().index() + + # ------------------------------------------------------------ alternate screen + + def set_mode(self, *modes: int, **kwargs) -> None: + if kwargs.get("private") and any(mode in ALTERNATE_SCREEN_MODES for mode in modes): + self._switch_screen(alternate=True) + super().set_mode(*modes, **kwargs) + + def reset_mode(self, *modes: int, **kwargs) -> None: + if kwargs.get("private") and any(mode in ALTERNATE_SCREEN_MODES for mode in modes): + self._switch_screen(alternate=False) + super().reset_mode(*modes, **kwargs) + + def _switch_screen(self, alternate: bool) -> None: + """Flushes the outgoing screen into the scrollback and starts the incoming one clear. + + A terminal restores the primary screen verbatim and discards the alternate one. The + transcript is a linear log instead, so each switch appends the frame that is leaving + and continues below it — chronological, and still free of every repaint that frame + replaced. + """ + if alternate == self._alternate: + return + self._alternate = alternate + for line in _trim_trailing_blanks(self.screen_lines()): + self._scrollback.append(line) + self.buffer.clear() + self.dirty.update(range(self.lines)) + self.cursor_position() + + # --------------------------------------------------------------- device queries + + def report_device_status(self, mode: int = 0, **kwargs) -> None: + if kwargs.get("private"): + return # DECDSR, which this terminal does not claim to implement + self._query_kind = QUERY_DEVICE_STATUS if mode == 5 else QUERY_CURSOR_POSITION + super().report_device_status(mode) + + def report_device_attributes(self, mode: int = 0, **kwargs) -> None: + self._query_kind = QUERY_DEVICE_ATTRIBUTES + super().report_device_attributes(mode, **kwargs) + + def write_process_input(self, data: str) -> None: + """pyte's reply hook. The reply is terminal protocol, never caller input.""" + handler = self._reply_handler + if handler is None: + return + handler(self._query_kind, data.encode("utf-8")) + + # ------------------------------------------------------------------- rendering + + def screen_lines(self) -> List[str]: + return [render_line(self.buffer[y], self.columns) for y in range(self.lines)] + + +class OutputNormalizer: + """Renders a target's terminal output and answers the queries it emits. + + Fed by the reader thread and read by the foreground, so both entry points take one + lock. `feed()` never raises: a malformed sequence must not take the reader down. + """ + + def __init__( + self, + columns: int = TERMINAL_COLUMNS, + lines: int = TERMINAL_ROWS, + head_lines: int = SCROLLBACK_HEAD_LINES, + tail_lines: int = SCROLLBACK_TAIL_LINES, + reply_handler: Optional[Callable[[str, bytes], None]] = None, + ) -> None: + self._lock = threading.Lock() + self._scrollback = _RetainedLines(head_lines, tail_lines) + self._screen = _RenderingScreen(columns, lines, self._scrollback, reply_handler) + self._stream = pyte.ByteStream(self._screen) + self.parse_failures = 0 + self.fed_bytes = 0 + + def feed(self, data: bytes) -> None: + if not data: + return + with self._lock: + self.fed_bytes += len(data) + try: + self._stream.feed(data) + except Exception: + # pyte reinitializes its parser before propagating, so the next chunk is + # parsed from a clean state. Rendering continues with what was already + # drawn rather than costing the reader its life. + self.parse_failures += 1 + + def text(self) -> str: + """The rendered scrollback followed by the final screen, as plain text.""" + with self._lock: + lines = self._scrollback.lines() + self._screen.screen_lines() + while lines and not lines[0]: + del lines[0] + _trim_trailing_blanks(lines) + return "\n".join(lines) + "\n" if lines else "" diff --git a/requirements.txt b/requirements.txt index e2f10c9c..3acded30 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ gitpython==3.1.55 pytest==9.1.1 textual>=7.5.0 networkx==3.6.1 +pyte==0.8.2 transitions==0.9.3 sentry-sdk==2.66.1 diff --git a/tests/fixtures/terminal_output/fullscreen.raw b/tests/fixtures/terminal_output/fullscreen.raw new file mode 100644 index 00000000..01b842b5 --- /dev/null +++ b/tests/fixtures/terminal_output/fullscreen.raw @@ -0,0 +1,145 @@ + BUILD DASHBOARD frame 00 + ---------------------------------------------- + suite-01 running 0/10 cases + suite-02 running 0/10 cases + suite-03 running 0/10 cases + suite-04 running 0/10 cases + suite-05 running 0/10 cases + suite-06 running 0/10 cases + suite-07 running 0/10 cases + suite-08 running 0/10 cases + ---------------------------------------------- + elapsed 0s + BUILD DASHBOARD frame 01 + ---------------------------------------------- + suite-01 running 1/10 cases + suite-02 running 2/10 cases + suite-03 running 3/10 cases + suite-04 running 4/10 cases + suite-05 running 5/10 cases + suite-06 running 6/10 cases + suite-07 passed 7/10 cases + suite-08 passed 8/10 cases + ---------------------------------------------- + elapsed 1s + BUILD DASHBOARD frame 02 + ---------------------------------------------- + suite-01 running 2/10 cases + suite-02 running 4/10 cases + suite-03 running 6/10 cases + suite-04 passed 8/10 cases + suite-05 running 0/10 cases + suite-06 running 2/10 cases + suite-07 running 4/10 cases + suite-08 running 6/10 cases + ---------------------------------------------- + elapsed 2s + BUILD DASHBOARD frame 03 + ---------------------------------------------- + suite-01 running 3/10 cases + suite-02 running 6/10 cases + suite-03 passed 9/10 cases + suite-04 running 2/10 cases + suite-05 running 5/10 cases + suite-06 passed 8/10 cases + suite-07 running 1/10 cases + suite-08 running 4/10 cases + ---------------------------------------------- + elapsed 3s + BUILD DASHBOARD frame 04 + ---------------------------------------------- + suite-01 running 4/10 cases + suite-02 passed 8/10 cases + suite-03 running 2/10 cases + suite-04 running 6/10 cases + suite-05 running 0/10 cases + suite-06 running 4/10 cases + suite-07 passed 8/10 cases + suite-08 running 2/10 cases + ---------------------------------------------- + elapsed 4s + BUILD DASHBOARD frame 05 + ---------------------------------------------- + suite-01 running 5/10 cases + suite-02 running 0/10 cases + suite-03 running 5/10 cases + suite-04 running 0/10 cases + suite-05 running 5/10 cases + suite-06 running 0/10 cases + suite-07 running 5/10 cases + suite-08 running 0/10 cases + ---------------------------------------------- + elapsed 5s + BUILD DASHBOARD frame 06 + ---------------------------------------------- + suite-01 running 6/10 cases + suite-02 running 2/10 cases + suite-03 passed 8/10 cases + suite-04 running 4/10 cases + suite-05 running 0/10 cases + suite-06 running 6/10 cases + suite-07 running 2/10 cases + suite-08 passed 8/10 cases + ---------------------------------------------- + elapsed 6s + BUILD DASHBOARD frame 07 + ---------------------------------------------- + suite-01 passed 7/10 cases + suite-02 running 4/10 cases + suite-03 running 1/10 cases + suite-04 passed 8/10 cases + suite-05 running 5/10 cases + suite-06 running 2/10 cases + suite-07 passed 9/10 cases + suite-08 running 6/10 cases + ---------------------------------------------- + elapsed 7s + BUILD DASHBOARD frame 08 + ---------------------------------------------- + suite-01 passed 8/10 cases + suite-02 running 6/10 cases + suite-03 running 4/10 cases + suite-04 running 2/10 cases + suite-05 running 0/10 cases + suite-06 passed 8/10 cases + suite-07 running 6/10 cases + suite-08 running 4/10 cases + ---------------------------------------------- + elapsed 8s + BUILD DASHBOARD frame 09 + ---------------------------------------------- + suite-01 passed 9/10 cases + suite-02 passed 8/10 cases + suite-03 passed 7/10 cases + suite-04 running 6/10 cases + suite-05 running 5/10 cases + suite-06 running 4/10 cases + suite-07 running 3/10 cases + suite-08 running 2/10 cases + ---------------------------------------------- + elapsed 9s + BUILD DASHBOARD frame 10 + ---------------------------------------------- + suite-01 running 0/10 cases + suite-02 running 0/10 cases + suite-03 running 0/10 cases + suite-04 running 0/10 cases + suite-05 running 0/10 cases + suite-06 running 0/10 cases + suite-07 running 0/10 cases + suite-08 running 0/10 cases + ---------------------------------------------- + elapsed 10s + BUILD DASHBOARD frame 11 + ---------------------------------------------- + suite-01 running 1/10 cases + suite-02 running 2/10 cases + suite-03 running 3/10 cases + suite-04 running 4/10 cases + suite-05 running 5/10 cases + suite-06 running 6/10 cases + suite-07 passed 7/10 cases + suite-08 passed 8/10 cases + ---------------------------------------------- + elapsed 11s +BUILD FAILED: suite-03 case 7 timed out diff --git a/tests/fixtures/terminal_output/nohup_build.raw b/tests/fixtures/terminal_output/nohup_build.raw new file mode 100644 index 00000000..2976617d --- /dev/null +++ b/tests/fixtures/terminal_output/nohup_build.raw @@ -0,0 +1,3 @@ +stdout is a terminal, colour enabled + compiling module 01/12 compiling module 02/12 compiling module 03/12 compiling module 04/12 compiling module 05/12 compiling module 06/12 compiling module 07/12 compiling module 08/12 compiling module 09/12 compiling module 10/12 compiling module 11/12 compiling module 12/12 compiled 12 modules +warning: 1 deprecated call in module 07 diff --git a/tests/fixtures/terminal_output/npm_install.raw b/tests/fixtures/terminal_output/npm_install.raw new file mode 100644 index 00000000..59f79041 --- /dev/null +++ b/tests/fixtures/terminal_output/npm_install.raw @@ -0,0 +1,3 @@ +⠙⠹⠸⠼⠴⠦⠧⠇⠏⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⠋⠙ +added 69 packages in 3s +⠙ \ No newline at end of file diff --git a/tests/fixtures/terminal_output/pytest_color.raw b/tests/fixtures/terminal_output/pytest_color.raw new file mode 100644 index 00000000..d2225edf --- /dev/null +++ b/tests/fixtures/terminal_output/pytest_color.raw @@ -0,0 +1,19 @@ +....F [100%] +======================================================= FAILURES ======================================================= +________________________________________________ test_reports_a_failure ________________________________________________ + + def test_reports_a_failure(): + expected = {"name": "widget", "count": 3} + actual = {"name": "widget", "count": 4} +> assert actual == expected +E AssertionError: assert {'name': 'widget', 'count': 4} == {'name': 'widget', 'count': 3} +E  +E Omitting 1 identical items, use -vv to show +E Differing items: +E {'count': 4} != {'count': 3} +E Use -v to get more diff + +test_sample.py:16: AssertionError +=============================================== short test summary info ================================================ +FAILED test_sample.py::test_reports_a_failure - AssertionError: assert {'name': 'widget', 'count': 4} == {'name': 'widget', 'count': 3} +1 failed, 4 passed in 0.02s diff --git a/tests/fixtures/terminal_output/spinner.raw b/tests/fixtures/terminal_output/spinner.raw new file mode 100644 index 00000000..bf0779c2 --- /dev/null +++ b/tests/fixtures/terminal_output/spinner.raw @@ -0,0 +1,2 @@ + [# ] 0% downloading package-00 [# ] 1% downloading package-01 [# ] 3% downloading package-02 [## ] 5% downloading package-03 [## ] 6% downloading package-04 [## ] 8% downloading package-05 [### ] 10% downloading package-06 [### ] 11% downloading package-07 [### ] 13% downloading package-08 [#### ] 15% downloading package-09 [#### ] 16% downloading package-10 [#### ] 18% downloading package-11 [##### ] 20% downloading package-12 [##### ] 21% downloading package-13 [##### ] 23% downloading package-14 [###### ] 25% downloading package-15 [###### ] 26% downloading package-16 [###### ] 28% downloading package-17 [####### ] 30% downloading package-18 [####### ] 31% downloading package-19 [####### ] 33% downloading package-20 [######## ] 35% downloading package-21 [######## ] 36% downloading package-22 [######## ] 38% downloading package-23 [######### ] 40% downloading package-24 [######### ] 41% downloading package-25 [######### ] 43% downloading package-26 [########## ] 45% downloading package-27 [########## ] 46% downloading package-28 [########## ] 48% downloading package-29 [########### ] 50% downloading package-30 [########### ] 51% downloading package-31 [########### ] 53% downloading package-32 [############ ] 55% downloading package-33 [############ ] 56% downloading package-34 [############ ] 58% downloading package-35 [############# ] 60% downloading package-36 [############# ] 61% downloading package-37 [############# ] 63% downloading package-38 [############## ] 65% downloading package-39 [############## ] 66% downloading package-40 [############## ] 68% downloading package-41 [############### ] 70% downloading package-42 [############### ] 71% downloading package-43 [############### ] 73% downloading package-44 [################ ] 75% downloading package-45 [################ ] 76% downloading package-46 [################ ] 78% downloading package-47 [################# ] 80% downloading package-48 [################# ] 81% downloading package-49 [################# ] 83% downloading package-50 [################## ] 85% downloading package-51 [################## ] 86% downloading package-52 [################## ] 88% downloading package-53 [################### ] 90% downloading package-54 [################### ] 91% downloading package-55 [################### ] 93% downloading package-56 [####################] 95% downloading package-57 [####################] 96% downloading package-58 [####################] 98% downloading package-59 [####################] 100% done +installed 60 packages diff --git a/tests/test_output_normalizer.py b/tests/test_output_normalizer.py new file mode 100644 index 00000000..7b5f71bb --- /dev/null +++ b/tests/test_output_normalizer.py @@ -0,0 +1,287 @@ +"""Tests for the terminal output normalizer. + +The fixtures under `tests/fixtures/terminal_output/` are real recordings, not synthesized +escape soup: each one is the verbatim byte stream a real tool wrote to the master side of +a pseudoterminal allocated by this project's own PTY backend, at 120x40 under +`TERM=xterm-256color`. + +Every fixture case asserts the rendered result *and* the compression ratio, so a +regression that reintroduces noise shows up as a number rather than as a diff nobody +reads. +""" + +import re +from pathlib import Path + +import pytest + +from render_machine.output_normalizer import ( + QUERY_CURSOR_POSITION, + QUERY_DEVICE_ATTRIBUTES, + QUERY_DEVICE_STATUS, + OutputNormalizer, +) + +FIXTURES = Path(__file__).resolve().parent / "fixtures" / "terminal_output" + +# What stripping bytes out of the stream would leave behind, used to show the difference +# between deleting the instruction and performing the operation. +STRIP_PATTERN = re.compile(rb"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b[@-Z\\-_]") + + +def strip_escapes(raw: bytes) -> str: + return STRIP_PATTERN.sub(b"", raw).decode("utf-8", "replace") + + +def normalize(raw: bytes, chunk_size: int = 512, **kwargs) -> OutputNormalizer: + normalizer = OutputNormalizer(**kwargs) + for offset in range(0, len(raw), chunk_size): + normalizer.feed(raw[offset : offset + chunk_size]) + return normalizer + + +def read_fixture(name: str) -> bytes: + return (FIXTURES / name).read_bytes() + + +# name, max compression ratio, expected present, expected absent +FIXTURE_CASES = [ + pytest.param( + "npm_install.raw", + 0.10, + ["added 69 packages"], + ["⠹"], # every braille spinner frame is erased by the frame after it + id="npm-install", + ), + pytest.param( + "pytest_color.raw", + 0.65, + ["1 failed, 4 passed", "AssertionError", "test_reports_a_failure"], + [], + id="pytest-colour", + ), + pytest.param( + "spinner.raw", + 0.03, + ["[####################] 100% done", "installed 60 packages"], + ["downloading package-30", "downloading package-59"], + id="progress-rewrite", + ), + pytest.param( + "fullscreen.raw", + 0.10, + ["frame 11", "BUILD FAILED: suite-03 case 7 timed out", "suite-08"], + ["frame 00", "frame 05", "frame 10"], + id="full-screen-repaint", + ), + pytest.param( + "nohup_build.raw", + 0.30, + ["stdout is a terminal, colour enabled", "compiled 12 modules", "warning"], + ["compiling module 05"], + id="nohup-detached", + ), +] + + +@pytest.mark.parametrize("name, max_ratio, present, absent", FIXTURE_CASES) +def test_recorded_output_renders_to_plain_text(name, max_ratio, present, absent): + raw = read_fixture(name) + normalizer = normalize(raw) + text = normalizer.text() + + assert normalizer.parse_failures == 0 + assert "\x1b" not in text, "an escape sequence survived rendering" + assert "\r" not in text, "a carriage return survived rendering" + for needle in present: + assert needle in text, f"{needle!r} missing from:\n{text}" + for needle in absent: + assert needle not in text, f"{needle!r} should have been overwritten:\n{text}" + + +@pytest.mark.parametrize("name, max_ratio, present, absent", FIXTURE_CASES) +def test_recorded_output_is_compressed_to_what_a_terminal_would_show(name, max_ratio, present, absent): + raw = read_fixture(name) + ratio = len(normalize(raw).text()) / len(raw) + assert ratio <= max_ratio, f"{name} normalized to {ratio:.3f} of its raw size, above {max_ratio}" + + +@pytest.mark.parametrize("name, max_ratio, present, absent", FIXTURE_CASES) +def test_chunk_boundaries_do_not_change_the_rendering(name, max_ratio, present, absent): + """The reader feeds whatever `read()` returns, so a split sequence must still render.""" + raw = read_fixture(name) + assert normalize(raw, chunk_size=1).text() == normalize(raw, chunk_size=len(raw) + 1).text() + + +def test_stripping_keeps_every_repaint_that_rendering_collapses(): + """The case stripping cannot handle: a tool that repaints the whole screen in place.""" + raw = read_fixture("fullscreen.raw") + stripped = strip_escapes(raw) + rendered = normalize(raw).text() + + assert stripped.count("BUILD DASHBOARD") == 12 + assert rendered.count("BUILD DASHBOARD") == 1 + assert len(rendered) < len(stripped) / 8 + + +def test_a_progress_line_rewrite_collapses_to_its_last_frame(): + raw = read_fixture("spinner.raw") + stripped = strip_escapes(raw) + rendered = normalize(raw).text() + + assert stripped.count("downloading package-") == 60 + assert "downloading package-" not in rendered + assert rendered.count("installed 60 packages") == 1 + + +def test_scrollback_keeps_the_head_and_the_tail_of_a_long_run(): + raw = b"".join(f"line {index:04d}\r\n".encode() for index in range(1000)) + text = normalize(raw, head_lines=5, tail_lines=7).text() + lines = text.splitlines() + + assert lines[:5] == [f"line {index:04d}" for index in range(5)] + assert lines[5].startswith("...[") and lines[5].endswith("lines omitted]...") + assert lines[-1] == "line 0999" + assert len(lines) < 60, "retention must cap the transcript, not just trim its tail" + + +def test_the_final_screen_is_kept_whole_alongside_the_retained_scrollback(): + raw = b"".join(f"line {index:04d}\r\n".encode() for index in range(100)) + text = normalize(raw, lines=10, head_lines=3, tail_lines=3).text() + lines = text.splitlines() + + assert lines[:3] == ["line 0000", "line 0001", "line 0002"] + assert "...[" in lines[3] + assert lines[-1] == "line 0099" + + +def test_cursor_movement_and_erase_are_performed_rather_than_deleted(): + normalizer = OutputNormalizer(columns=20, lines=5) + normalizer.feed(b"first\r\nsecond\r\nthird\r\n") + normalizer.feed(b"\x1b[3A\x1b[Kreplaced\r\n") # up three lines, erase it, rewrite + + assert normalizer.text() == "replaced\nsecond\nthird\n" + + +def test_a_repaint_from_the_home_position_leaves_one_frame(): + normalizer = OutputNormalizer(columns=20, lines=4) + for frame in range(30): + normalizer.feed(f"\x1b[H\x1b[2Jframe {frame}\r\nstill working\r\n".encode()) + + assert normalizer.text() == "frame 29\nstill working\n" + + +def test_the_alternate_screen_is_flushed_in_order_and_left_clear(): + normalizer = OutputNormalizer(columns=40, lines=6) + normalizer.feed(b"primary one\r\nprimary two\r\n") + normalizer.feed(b"\x1b[?1049h") + for frame in range(1, 6): + normalizer.feed(f"\x1b[H\x1b[2Jalt frame {frame}\r\n".encode()) + normalizer.feed(b"\x1b[?1049l") + normalizer.feed(b"back on the primary\r\n") + + assert normalizer.text() == "primary one\nprimary two\nalt frame 5\nback on the primary\n" + + +def test_output_is_kept_when_the_target_never_leaves_the_alternate_screen(): + normalizer = OutputNormalizer(columns=40, lines=6) + normalizer.feed(b"\x1b[?1049h\x1b[H\x1b[2Jonly frame\r\n") + + assert normalizer.text() == "only frame\n" + + +def test_crlf_collapses_and_a_trailing_partial_line_is_kept(): + normalizer = OutputNormalizer(columns=20, lines=5) + normalizer.feed(b"one\r\ntwo\r\nno newline here") + + assert normalizer.text() == "one\ntwo\nno newline here\n" + + +def test_nothing_fed_renders_to_nothing(): + normalizer = OutputNormalizer(columns=20, lines=5) + normalizer.feed(b"") + + assert normalizer.text() == "" + + +def test_split_utf8_across_chunks_renders_one_character(): + normalizer = OutputNormalizer(columns=20, lines=3) + encoded = "héllo wörld".encode("utf-8") + for index in range(len(encoded)): + normalizer.feed(encoded[index : index + 1]) + + assert normalizer.text() == "héllo wörld\n" + + +def test_device_queries_are_answered_as_a_terminal_answers_them(): + replies = [] + normalizer = OutputNormalizer(columns=20, lines=5, reply_handler=lambda kind, data: replies.append((kind, data))) + normalizer.feed(b"abc\x1b[6n") + normalizer.feed(b"\x1b[5n") + normalizer.feed(b"\x1b[c") + + assert replies == [ + (QUERY_CURSOR_POSITION, b"\x1b[1;4R"), + (QUERY_DEVICE_STATUS, b"\x1b[0n"), + (QUERY_DEVICE_ATTRIBUTES, b"\x1b[?6c"), + ] + + +def test_the_cursor_position_report_follows_the_rendered_cursor(): + replies = [] + normalizer = OutputNormalizer(columns=20, lines=5, reply_handler=lambda kind, data: replies.append((kind, data))) + normalizer.feed(b"one\r\ntwo\r\nthr\x1b[6n") + + assert replies == [(QUERY_CURSOR_POSITION, b"\x1b[3;4R")] + + +def test_a_query_leaves_no_trace_in_the_rendered_text(): + replies = [] + normalizer = OutputNormalizer(columns=20, lines=5, reply_handler=lambda kind, data: replies.append((kind, data))) + normalizer.feed(b"before\x1b[6n\x1b[5n\x1b[cafter\r\n") + + assert replies + assert normalizer.text() == "beforeafter\n" + + +def test_a_normalizer_without_a_reply_handler_only_renders(): + normalizer = OutputNormalizer(columns=20, lines=5) + normalizer.feed(b"quiet\x1b[6n\x1b[c\r\n") + + assert normalizer.text() == "quiet\n" + + +def test_a_private_device_status_request_is_not_answered(): + replies = [] + normalizer = OutputNormalizer(columns=20, lines=5, reply_handler=lambda kind, data: replies.append((kind, data))) + normalizer.feed(b"x\x1b[?6n\r\n") + + assert replies == [] + assert normalizer.text() == "x\n" + + +def test_a_parser_failure_is_counted_and_rendering_continues(monkeypatch): + """A malformed stream must cost a chunk, never the reader that feeds it.""" + normalizer = OutputNormalizer(columns=20, lines=5) + normalizer.feed(b"before\r\n") + + original = normalizer._stream.feed + calls = [] + + def failing_feed(data): + calls.append(data) + raise ValueError("malformed") + + monkeypatch.setattr(normalizer._stream, "feed", failing_feed) + normalizer.feed(b"poison") + monkeypatch.setattr(normalizer._stream, "feed", original) + normalizer.feed(b"after\r\n") + + assert calls == [b"poison"] + assert normalizer.parse_failures == 1 + assert normalizer.text() == "before\nafter\n" + + +def test_fed_bytes_counts_every_byte_handed_to_the_parser(): + raw = read_fixture("spinner.raw") + assert normalize(raw).fed_bytes == len(raw) From ebb611c66dd7adc9de9e2bd4b709e74667645c31 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sat, 15 Aug 2026 22:32:11 +0200 Subject: [PATCH 10/83] Answer terminal queries live from the reader The parser now runs in the reader through its byte-feed hook, so a target that emits a device-status, cursor-position or attributes query gets an answer instead of hanging to the timeout. Each reply is one non-blocking admission into the ordered input queue, and its obligation is tracked to completion. --- render_machine/_posix_pty.py | 74 +++++- render_machine/output_normalizer.py | 5 + render_machine/terminal_process.py | 20 ++ render_machine/terminal_queries.py | 136 +++++++++++ tests/test_terminal_queries.py | 364 ++++++++++++++++++++++++++++ 5 files changed, 596 insertions(+), 3 deletions(-) create mode 100644 render_machine/terminal_queries.py create mode 100644 tests/test_terminal_queries.py diff --git a/render_machine/_posix_pty.py b/render_machine/_posix_pty.py index d6a55cdc..4458cbbc 100644 --- a/render_machine/_posix_pty.py +++ b/render_machine/_posix_pty.py @@ -28,6 +28,7 @@ from plain2code_console import console from plain2code_exceptions import RenderCancelledError from render_machine import pty_exec +from render_machine.output_normalizer import OutputNormalizer from render_machine.terminal_process import ( DEFAULT_TERM, DRAIN_DEADLINE_SECONDS, @@ -53,6 +54,7 @@ TerminalProcess, TerminalReaderError, ) +from render_machine.terminal_queries import REASON_DISCARDED, REASON_WRITE_FAILED, TerminalQueryResponder if sys.platform == "win32": # pragma: no cover - the PTY backend is POSIX-only raise ImportError("render_machine._posix_pty is POSIX-only") @@ -66,6 +68,9 @@ _OWNER_PARENT = "parent" _OWNER_READER = "reader" +# Completion callback for one queued input item, resolved by whoever retires it. +ResolveCallback = Callable[[InputDisposition, Optional[BaseException]], None] + class _ProtocolError(Exception): """The launcher's status stream did not follow the handshake protocol.""" @@ -113,13 +118,18 @@ def _reap(proc: subprocess.Popen, deadline_seconds: float) -> None: class _Receipt: - """Resolution of one queued input item. Resolved exactly once, by whoever retires it.""" + """Resolution of one queued input item. Resolved exactly once, by whoever retires it. - def __init__(self) -> None: + `on_resolve` lets a producer observe that terminal transition without ever waiting for + it, which is what the reader needs when it is the producer. + """ + + def __init__(self, on_resolve: Optional[ResolveCallback] = None) -> None: self._event = threading.Event() self.error: Optional[BaseException] = None self.disposition: Optional[InputDisposition] = None self.resolutions = 0 + self._on_resolve = on_resolve def resolve(self, disposition: InputDisposition, error: Optional[BaseException] = None) -> None: self.resolutions += 1 @@ -128,6 +138,11 @@ def resolve(self, disposition: InputDisposition, error: Optional[BaseException] self.disposition = disposition self.error = error self._event.set() + if self._on_resolve is not None: + try: + self._on_resolve(disposition, error) + except BaseException as exc: # a completion callback must never strand the queue + console.debug(f"input completion callback raised: {exc!r}") @property def resolved(self) -> bool: @@ -187,8 +202,9 @@ def submit( reserved: bool = False, prepare: Optional[Callable[[], None]] = None, finish: Optional[Callable[[], None]] = None, + on_resolve: Optional[ResolveCallback] = None, ) -> Tuple[InputWriteResult, _Receipt]: - receipt = _Receipt() + receipt = _Receipt(on_resolve) payload = bytes(data) with self._lock: if not self._accepting: @@ -424,6 +440,12 @@ def __init__(self) -> None: self._raw = bytearray() self.launcher_stderr = _CappedDiagnostic() + # The parser runs live in the reader through this byte-feed hook, because terminals + # answer queries: a render-afterwards parser would leave a querying target hanging. + self.query_responder = TerminalQueryResponder(self._admit_reply) + self.normalizer = OutputNormalizer(reply_handler=self.query_responder.answer) + self._byte_sink: Callable[[bytes], None] = self.normalizer.feed + # ---------------------------------------------------------------- public API def spawn( @@ -469,6 +491,8 @@ def poll(self) -> Optional[int]: # Popen.poll() reaps, so the pgid may now be recycled; no group signal is # ever sent again. self._reaped = True + # The execution outcome is observed, so no client is left to answer. + self.query_responder.quiesce() return returncode def read_output(self) -> str: @@ -489,6 +513,17 @@ def write_input(self, data: bytes) -> InputWriteResult: self._ring_doorbell() return result + def normalized_output(self) -> str: + """The rendered transcript so far. Cumulative, unlike `read_output()`.""" + return self.normalizer.text() + + @property + def terminal_reply_failed(self) -> bool: + return self.query_responder.reply_failed + + def terminal_reply_detail(self) -> str: + return self.query_responder.failure_detail() + def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: """Signals the recorded group, escalates on the clock, and reaps last. @@ -496,6 +531,7 @@ def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: acted on: returning early would skip the SIGKILL escalation the sequence exists for. The caller inspects `reader_failed` afterwards. """ + self.query_responder.quiesce() proc = self._proc if proc is None or self._reaped: return @@ -518,6 +554,7 @@ def close(self) -> None: if self._closed: return self._closed = True + self.query_responder.quiesce() # before either input pump stops self._drain_deadline = time.monotonic() + DRAIN_DEADLINE_SECONDS self._input_queue.stop_accepting(self._closing) self._ring_doorbell() @@ -541,6 +578,7 @@ def _open_terminal(self, terminal_size: Tuple[int, int]) -> None: raise TerminalEnvironmentError(f"Could not allocate a pseudoterminal: {exc}") from exc try: columns, rows = terminal_size + self.normalizer.resize(columns, rows) fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, columns, 0, 0)) self._configure_slave(slave_fd) os.set_blocking(master_fd, False) @@ -839,6 +877,7 @@ def _feed_output(self, chunk: bytes, decoder) -> None: self._raw += chunk if text: self._decoded.append(text) + self._byte_sink(chunk) # outside the output lock: parsing must not block read_output() def _flush_decoder(self, decoder) -> None: tail = decoder.decode(b"", final=True) # a trailing partial sequence becomes U+FFFD @@ -901,8 +940,23 @@ def _drain_remaining(self, master_fd: int) -> None: return with self._output_lock: self._raw += chunk + self._byte_sink(chunk) # still rendered; a query seen here is render-only drained += len(chunk) + # ------------------------------------------------------------- query replies + + def _admit_reply(self, payload: bytes, on_complete: Callable[[Optional[str]], None]) -> None: + """One non-blocking whole-item admission of a terminal reply, from the reader. + + Replies take the reserved partition because they are terminal protocol: a caller + saturating the queue with input must not be able to starve a required response. + They are never counted as caller input and never affect the input-driver + diagnostic. The queue's cursor preserves the reply across short writes. + """ + result, _ = self._input_queue.submit(payload, reserved=True, on_resolve=_reply_resolution(on_complete)) + if result.disposition is InputDisposition.ACCEPTED: + self._ring_doorbell() + # ----------------------------------------------------------- VEOF injection def _veof_prepare(self) -> None: @@ -969,6 +1023,20 @@ def _close_owned(self, name: str) -> None: _close_quietly(self._take_owned(name)) +def _reply_resolution(on_complete: Callable[[Optional[str]], None]) -> ResolveCallback: + """Maps one queue resolution onto the responder's delivered / not-delivered contract.""" + + def resolved(disposition: InputDisposition, error: Optional[BaseException]) -> None: + if error is not None: + on_complete(f"{REASON_WRITE_FAILED}: {error!r}") + elif disposition is InputDisposition.ACCEPTED: + on_complete(None) + else: + on_complete(f"{REASON_DISCARDED} ({disposition.value})") + + return resolved + + def _drain_doorbell(fd: int) -> None: while True: try: diff --git a/render_machine/output_normalizer.py b/render_machine/output_normalizer.py index 10118508..4b11cebb 100644 --- a/render_machine/output_normalizer.py +++ b/render_machine/output_normalizer.py @@ -183,6 +183,11 @@ def __init__( self.parse_failures = 0 self.fed_bytes = 0 + def resize(self, columns: int, lines: int) -> None: + """Matches the parser to the terminal the target was actually given.""" + with self._lock: + self._screen.resize(lines, columns) + def feed(self, data: bytes) -> None: if not data: return diff --git a/render_machine/terminal_process.py b/render_machine/terminal_process.py index 5059d631..7a5a6917 100644 --- a/render_machine/terminal_process.py +++ b/render_machine/terminal_process.py @@ -12,6 +12,8 @@ from enum import Enum from typing import List, Optional, Sequence, Tuple +from render_machine.terminal_queries import TerminalQueryResponder + # Launch, reader, and writer infrastructure failures surface on the renderer's existing # environment-error channel rather than being handed to the LLM patcher as a test failure. ENVIRONMENT_ERROR_EXIT_CODE = 69 @@ -90,6 +92,7 @@ class TerminalProcess: reader_failed: threading.Event reader_exc: Optional[BaseException] + query_responder: TerminalQueryResponder def spawn( self, @@ -114,6 +117,23 @@ def read_raw_output(self) -> bytes: """Raw output bytes accumulated since the previous call.""" raise NotImplementedError + def normalized_output(self) -> str: + """The rendered transcript so far. Cumulative, unlike `read_output()`.""" + raise NotImplementedError + + @property + def terminal_reply_failed(self) -> bool: + """True when a reply the target was waiting for could not be delivered. + + Independent of `reader_failed`: both pumps can be healthy while one required + protocol response was never accepted. + """ + raise NotImplementedError + + def terminal_reply_detail(self) -> str: + """Query kinds and pressure reasons behind `terminal_reply_failed`.""" + raise NotImplementedError + def write_input(self, data: bytes) -> InputWriteResult: raise NotImplementedError diff --git a/render_machine/terminal_queries.py b/render_machine/terminal_queries.py new file mode 100644 index 00000000..814614b7 --- /dev/null +++ b/render_machine/terminal_queries.py @@ -0,0 +1,136 @@ +"""Platform-neutral state for the terminal queries the reader answers live. + +A target under `TERM=xterm-256color` may emit a device-status, cursor-position or +device-attributes query and block until the terminal replies. A real terminal always +answers, so the parser has to run in the reader rather than after the fact — and the reply +has to be admitted without the reader ever waiting, since the reader is the only drainer of +the target's output. + +The responder owns the obligation that admission creates, for the item's whole lifecycle: + +* While `ACTIVE`, a query performs exactly one non-blocking whole-item admission and + registers an obligation. Immediate pressure, a native write failure and a teardown + discard all resolve it as not delivered and record `kind` plus `reason`. +* The foreground switches the responder to `QUIESCED` as soon as it observes an execution + outcome, before stopping either input pump. A query first seen after that renders but + records nothing: there is no client left whose query can be answered. +* Obligations registered while `ACTIVE` keep reporting, even when teardown is what + discovers the failure. + +One lock linearizes the query callback with the `ACTIVE -> QUIESCED` transition, so a +callback either admits while active or observes quiescence — never both, and never neither. +It is reentrant because an admission that is rejected outright resolves its obligation +inside the same call. Completion callbacks update the recorded failures through this same +state but never invoke backend code while holding the lock. + +This is separate from a reader or writer failure: the pumps can be healthy while one +required protocol response could not be accepted. +""" + +import functools +import threading +from dataclasses import dataclass +from enum import Enum +from typing import Callable, List, Optional, Set + +# Reasons a reply can fail to reach the target. +REASON_ADMISSION_RAISED = "admission raised" +REASON_DISCARDED = "discarded before delivery" +REASON_WRITE_FAILED = "write failed" + +# A backend admission: hands the reply over without blocking, then resolves the completion +# callback with None when the last native byte lands, or with a reason when it cannot. +CompletionCallback = Callable[[Optional[str]], None] +AdmitReply = Callable[[bytes, CompletionCallback], None] + + +class ResponderState(Enum): + ACTIVE = "active" + QUIESCED = "quiesced" + + +@dataclass(frozen=True) +class TerminalReplyFailure: + kind: str + reason: str + + def __str__(self) -> str: + return f"{self.kind} reply {self.reason}" + + +class _Obligation: + """One admitted reply, resolved exactly once by whoever retires it.""" + + __slots__ = ("kind", "resolved") + + def __init__(self, kind: str) -> None: + self.kind = kind + self.resolved = False + + +class TerminalQueryResponder: + """Tracks the delivery obligation of every terminal reply the parser produces. + + A responder built without an admission callable — the legacy backend, which has no + input channel — starts quiesced, so a printed escape query creates no obligation. + """ + + def __init__(self, admit: Optional[AdmitReply] = None, active: bool = True) -> None: + self._lock = threading.RLock() + self._admit = admit + self._state = ResponderState.ACTIVE if active and admit is not None else ResponderState.QUIESCED + self._outstanding: Set[_Obligation] = set() + self._failures: List[TerminalReplyFailure] = [] + self.admitted = 0 + self.render_only = 0 + + @property + def state(self) -> ResponderState: + with self._lock: + return self._state + + @property + def reply_failed(self) -> bool: + with self._lock: + return bool(self._failures) + + @property + def failures(self) -> List[TerminalReplyFailure]: + with self._lock: + return list(self._failures) + + @property + def outstanding(self) -> int: + with self._lock: + return len(self._outstanding) + + def failure_detail(self) -> str: + return "; ".join(str(failure) for failure in self.failures) + + def quiesce(self) -> None: + """Idempotent, foreground-triggered. Outstanding obligations keep reporting.""" + with self._lock: + self._state = ResponderState.QUIESCED + + def answer(self, kind: str, payload: bytes) -> None: + """The parser's reply hook, called on the reader thread. Never waits, never raises.""" + with self._lock: + if self._state is ResponderState.QUIESCED or self._admit is None: + self.render_only += 1 + return + obligation = _Obligation(kind) + self._outstanding.add(obligation) + self.admitted += 1 + try: + self._admit(payload, functools.partial(self._resolve, obligation)) + except BaseException as exc: + self._resolve(obligation, f"{REASON_ADMISSION_RAISED} {exc!r}") + + def _resolve(self, obligation: _Obligation, reason: Optional[str]) -> None: + with self._lock: + if obligation.resolved: + return + obligation.resolved = True + self._outstanding.discard(obligation) + if reason is not None: + self._failures.append(TerminalReplyFailure(obligation.kind, reason)) diff --git a/tests/test_terminal_queries.py b/tests/test_terminal_queries.py new file mode 100644 index 00000000..cc15aa99 --- /dev/null +++ b/tests/test_terminal_queries.py @@ -0,0 +1,364 @@ +"""Tests for the live terminal query responder. + +The responder cases are platform-neutral and run everywhere. The backend cases spawn a real +target on a real pseudoterminal, so they are POSIX-only; every boundary they assert is +driven through a hook, never through a sleep. +""" + +import sys +import time +from pathlib import Path + +import pytest + +from render_machine.output_normalizer import QUERY_CURSOR_POSITION, QUERY_DEVICE_ATTRIBUTES, QUERY_DEVICE_STATUS +from render_machine.terminal_process import InputDisposition, InputWriteResult +from render_machine.terminal_queries import ResponderState, TerminalQueryResponder + +posix_only = pytest.mark.skipif(sys.platform == "win32", reason="The POSIX PTY backend is not built on Windows.") + +if sys.platform != "win32": + from render_machine import _posix_pty + +SPAWN_TIMEOUT = 20.0 + + +class _Admissions: + """Records every admission and hands back the completion callback.""" + + def __init__(self, immediate_reason=None, raises=None): + self.payloads = [] + self.completions = [] + self._immediate_reason = immediate_reason + self._raises = raises + + def __call__(self, payload, on_complete): + self.payloads.append(payload) + if self._raises is not None: + raise self._raises + if self._immediate_reason is not None: + on_complete(self._immediate_reason) + return + self.completions.append(on_complete) + + +def test_a_responder_without_an_input_channel_starts_quiesced(): + """The legacy backend has nowhere to write a reply, so a query creates no obligation.""" + responder = TerminalQueryResponder() + + responder.answer(QUERY_CURSOR_POSITION, b"\x1b[1;1R") + + assert responder.state is ResponderState.QUIESCED + assert responder.reply_failed is False + assert responder.render_only == 1 + assert responder.admitted == 0 + + +def test_an_admitted_reply_that_completes_leaves_no_failure(): + admissions = _Admissions() + responder = TerminalQueryResponder(admissions) + + responder.answer(QUERY_CURSOR_POSITION, b"\x1b[1;1R") + assert responder.outstanding == 1 + admissions.completions[0](None) + + assert admissions.payloads == [b"\x1b[1;1R"] + assert responder.reply_failed is False + assert responder.outstanding == 0 + + +def test_immediate_admission_pressure_records_the_kind_and_the_reason(): + admissions = _Admissions(immediate_reason="discarded before delivery (backpressure)") + responder = TerminalQueryResponder(admissions) + + responder.answer(QUERY_DEVICE_STATUS, b"\x1b[0n") + + assert responder.reply_failed is True + assert [(failure.kind, failure.reason) for failure in responder.failures] == [ + (QUERY_DEVICE_STATUS, "discarded before delivery (backpressure)") + ] + assert responder.outstanding == 0 + + +def test_an_admission_that_raises_is_recorded_rather_than_propagated(): + """The reader feeds the parser; a reply must never be able to take it down.""" + responder = TerminalQueryResponder(_Admissions(raises=RuntimeError("no channel"))) + + responder.answer(QUERY_DEVICE_ATTRIBUTES, b"\x1b[?6c") + + assert responder.reply_failed is True + assert "admission raised" in responder.failures[0].reason + assert responder.outstanding == 0 + + +def test_a_reply_admitted_while_active_still_reports_after_quiescence(): + admissions = _Admissions() + responder = TerminalQueryResponder(admissions) + + responder.answer(QUERY_CURSOR_POSITION, b"\x1b[1;1R") + responder.quiesce() + admissions.completions[0]("discarded before delivery (closed)") + + assert responder.reply_failed is True + assert responder.failures[0].kind == QUERY_CURSOR_POSITION + + +def test_a_query_first_seen_after_quiescence_renders_and_records_nothing(): + admissions = _Admissions() + responder = TerminalQueryResponder(admissions) + + responder.quiesce() + responder.answer(QUERY_CURSOR_POSITION, b"\x1b[1;1R") + + assert admissions.payloads == [] + assert responder.render_only == 1 + assert responder.reply_failed is False + + +def test_quiescing_from_inside_an_admission_keeps_that_obligation(): + """The lock linearizes the two: a callback admits while active, or observes quiescence.""" + responder = TerminalQueryResponder() + completions = [] + + def admit(payload, on_complete): + responder.quiesce() # the transition cannot interleave with this callback + completions.append(on_complete) + + responder._admit = admit + responder._state = ResponderState.ACTIVE + + responder.answer(QUERY_CURSOR_POSITION, b"\x1b[2;3R") + responder.answer(QUERY_DEVICE_STATUS, b"\x1b[0n") # after the transition: render-only + completions[0]("write failed: OSError(5)") + + assert responder.render_only == 1 + assert [failure.kind for failure in responder.failures] == [QUERY_CURSOR_POSITION] + + +def test_quiesce_is_idempotent(): + responder = TerminalQueryResponder(_Admissions()) + + responder.quiesce() + responder.quiesce() + + assert responder.state is ResponderState.QUIESCED + + +def test_a_completion_resolves_its_obligation_exactly_once(): + admissions = _Admissions() + responder = TerminalQueryResponder(admissions) + + responder.answer(QUERY_CURSOR_POSITION, b"\x1b[1;1R") + admissions.completions[0]("write failed: OSError(5)") + admissions.completions[0]("discarded before delivery (closed)") + + assert len(responder.failures) == 1 + + +def test_failure_detail_names_every_query_kind_and_reason(): + responder = TerminalQueryResponder(_Admissions(immediate_reason="discarded before delivery (backpressure)")) + + responder.answer(QUERY_CURSOR_POSITION, b"\x1b[1;1R") + responder.answer(QUERY_DEVICE_STATUS, b"\x1b[0n") + + detail = responder.failure_detail() + assert QUERY_CURSOR_POSITION in detail and QUERY_DEVICE_STATUS in detail + assert detail.count("backpressure") == 2 + + +# --------------------------------------------------------------- backend integration + + +def write_target(tmp_path: Path, name: str, source: str) -> str: + path = tmp_path / name + path.write_text(source) + return str(path) + + +# Switches to noncanonical, no-echo mode first, exactly as a real query emitter does: in +# canonical mode the newline-less reply never satisfies read(), and with echo on the reply +# bytes would land in the raw transcript. +READS_THE_REPLY = """ +import os +import sys +import termios + +fd = sys.stdin.fileno() +saved = termios.tcgetattr(fd) +raw = termios.tcgetattr(fd) +raw[3] &= ~(termios.ICANON | termios.ECHO) +raw[6][termios.VMIN] = 1 +raw[6][termios.VTIME] = 0 +termios.tcsetattr(fd, termios.TCSANOW, raw) +try: + sys.stdout.write("\\x1b[6n") + sys.stdout.flush() + reply = b"" + while not reply.endswith(b"R"): + chunk = os.read(fd, 1) + if not chunk: + sys.stdout.write("no reply\\n") + sys.stdout.flush() + raise SystemExit(3) + reply += chunk +finally: + termios.tcsetattr(fd, termios.TCSANOW, saved) + +reply = reply[reply.index(b"\\x1b") :] # the spawn-time EOF byte is still queued ahead of it +row, column = reply[2:-1].split(b";") +sys.stdout.write("answered row %s column %s\\n" % (row.decode(), column.decode())) +sys.stdout.flush() +""" + +# Emits the query and carries on without waiting for it, which is what leaves the reply to +# fail on its own timeline. +ABANDONS_THE_REPLY = """ +import sys + +sys.stdout.write("\\x1b[6n") +sys.stdout.flush() +sys.stdout.write("carried on\\n") +sys.stdout.flush() +""" + + +def run_target(script, **spawn_kwargs): + """Spawns a target, drains it to exit, and always tears it down.""" + process = _posix_pty.PosixPtyProcess() + process.spawn([sys.executable, script], **spawn_kwargs) + return process + + +def drain_to_exit(process, timeout=SPAWN_TIMEOUT): + deadline = time.monotonic() + timeout + raw = bytearray() + while time.monotonic() < deadline: + raw += process.read_raw_output() + returncode = process.poll() + if returncode is not None: + raw += process.read_raw_output() + return returncode, bytes(raw) + time.sleep(0.01) + raise AssertionError(f"the target did not exit within {timeout}s; output so far {bytes(raw)!r}") + + +@posix_only +def test_a_live_cursor_position_query_is_answered_and_the_target_completes(tmp_path): + """The reply reaches a target that is blocked reading it, so it completes, not times out.""" + script = write_target(tmp_path, "reads_the_reply.py", READS_THE_REPLY) + process = run_target(script) + caller_writes = [] + original_write_input = process.write_input + process.write_input = lambda data: caller_writes.append(data) or original_write_input(data) + try: + returncode, raw = drain_to_exit(process) + normalized = process.normalized_output() + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert returncode == 0 + assert "answered row 1 column 1" in normalized + assert process.query_responder.admitted == 1 + assert process.terminal_reply_failed is False + # The reply is terminal protocol: it is not caller input and it is in neither transcript. + assert caller_writes == [] + assert b"\x1b[1;1R" not in raw + assert "\x1b" not in normalized + + +@posix_only +def test_immediate_reply_pressure_is_recorded_without_stalling_the_reader(tmp_path): + script = write_target(tmp_path, "abandons_the_reply.py", ABANDONS_THE_REPLY) + process = _posix_pty.PosixPtyProcess() + original_submit = process._input_queue.submit + + def rejecting_submit(data, reserved=False, prepare=None, finish=None, on_resolve=None): + if not data.startswith(b"\x1b"): # the spawn-time EOF still goes through + return original_submit(data, reserved=reserved, prepare=prepare, finish=finish, on_resolve=on_resolve) + receipt = _posix_pty._Receipt(on_resolve) + receipt.resolve(InputDisposition.BACKPRESSURE) + return InputWriteResult(InputDisposition.BACKPRESSURE, 0), receipt + + process._input_queue.submit = rejecting_submit + try: + process.spawn([sys.executable, script]) + returncode, _ = drain_to_exit(process) + normalized = process.normalized_output() + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert returncode == 0 + assert "carried on" in normalized, "the reader kept draining after the reply was refused" + assert process.terminal_reply_failed is True + assert process.query_responder.failures[0].kind == QUERY_CURSOR_POSITION + assert "backpressure" in process.terminal_reply_detail() + assert process.reader_failed.is_set() is False + + +@posix_only +def test_a_reply_discarded_at_teardown_still_records_a_failure(tmp_path): + """Admitted while ACTIVE, so the obligation survives the transition teardown makes.""" + script = write_target(tmp_path, "abandons_the_reply.py", ABANDONS_THE_REPLY) + process = run_target(script) + process._flush_input = lambda master_fd, budget: None # the reply never reaches the fd + try: + drain_to_exit(process) + assert process.query_responder.admitted == 1 + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert process.query_responder.state is ResponderState.QUIESCED + assert process.terminal_reply_failed is True + assert process.query_responder.failures[0].kind == QUERY_CURSOR_POSITION + assert "discarded before delivery" in process.terminal_reply_detail() + + +@posix_only +def test_a_reply_that_fails_its_native_write_is_recorded_separately_from_the_reader(tmp_path): + script = write_target(tmp_path, "abandons_the_reply.py", ABANDONS_THE_REPLY) + process = _posix_pty.PosixPtyProcess() + original_write = process._write_master + + def failing_write(fd, data): + if data.startswith(b"\x1b"): + raise OSError(5, "injected write failure") + return original_write(fd, data) + + process._write_master = failing_write + try: + process.spawn([sys.executable, script]) + deadline = time.monotonic() + SPAWN_TIMEOUT + while time.monotonic() < deadline and not process.terminal_reply_failed: + time.sleep(0.01) + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert process.terminal_reply_failed is True + failure = process.query_responder.failures[0] + assert failure.kind == QUERY_CURSOR_POSITION + assert "write failed" in failure.reason + assert process.reader_failed.is_set() is True # an independent signal, not the same one + + +@posix_only +def test_a_query_seen_only_after_quiescence_renders_and_records_nothing(tmp_path): + script = write_target(tmp_path, "abandons_the_reply.py", ABANDONS_THE_REPLY) + process = run_target(script) + try: + drain_to_exit(process) # poll() observed the outcome, so the responder is quiesced + assert process.query_responder.state is ResponderState.QUIESCED + admitted_before = process.query_responder.admitted + + process._byte_sink(b"\x1b[6ntrailing frame\r\n") # the reader's byte-feed hook + + assert process.query_responder.admitted == admitted_before + assert process.query_responder.render_only == 1 + assert process.terminal_reply_failed is False + assert "trailing frame" in process.normalized_output() + finally: + process.terminate_tree(grace=0.05) + process.close() From c5619f3666e3cfad2ecde0c785b6b7a8bfde4c93 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sat, 15 Aug 2026 23:05:51 +0200 Subject: [PATCH 11/83] Fix channel setup rollback and input-queue edge cases A failure part-way through _open_channels() leaked the descriptors already opened and escaped as a raw OSError instead of the environment-error channel. The final drain appended straight to the raw buffer, so output still in flight at close() never reached read_output(). close_and_fail_all() dropped the in-flight item without closing its transaction, leaving termios unrestored when an EAGAIN'd VEOF met a close. submit() copied before validating the size and admitted unlimited zero-length items; the queue is now bounded in items as well as bytes. Two tests were not testing what they claimed: the daemon-thread target outlived nothing, and the pre-ack cancellation raced a timer instead of the delayed-ack hook. --- render_machine/_posix_pty.py | 120 ++++++++++---- render_machine/terminal_process.py | 4 + tests/test_terminal_process.py | 249 ++++++++++++++++++++++++++++- 3 files changed, 334 insertions(+), 39 deletions(-) diff --git a/render_machine/_posix_pty.py b/render_machine/_posix_pty.py index 4458cbbc..2d44edbe 100644 --- a/render_machine/_posix_pty.py +++ b/render_machine/_posix_pty.py @@ -40,10 +40,12 @@ LAUNCHER_STDERR_CAP_BYTES, MAX_INPUT_ITEM_BYTES, MAX_PENDING_INPUT_BYTES, + MAX_PENDING_INPUT_ITEMS, POLL_INTERVAL_SECONDS, READ_CHUNK_BYTES, REAP_DEADLINE_SECONDS, RESERVED_INPUT_BYTES, + RESERVED_INPUT_ITEMS, SIGTERM_GRACE_PERIOD_SECONDS, TERMINAL_COLUMNS, TERMINAL_ROWS, @@ -170,6 +172,22 @@ def __init__( self.cursor = 0 self.prepared = False + def finish_once(self) -> Optional[BaseException]: + """Closes the transaction the item opened, at most once, and never raises. + + Whoever retires the item runs it — the pump on completion, teardown on a close + that takes the item mid-flight — so a prepared item can never be dropped with the + terminal left in the mode `prepare` put it in. + """ + if not self.prepared or self.finish is None: + return None + self.prepared = False + try: + self.finish() + except BaseException as exc: + return exc + return None + class _InputQueue: """Bounded, byte-accounted, ordered input queue. @@ -185,6 +203,8 @@ def __init__( max_item_bytes: int = MAX_INPUT_ITEM_BYTES, max_pending_bytes: int = MAX_PENDING_INPUT_BYTES, reserved_bytes: int = RESERVED_INPUT_BYTES, + max_pending_items: int = MAX_PENDING_INPUT_ITEMS, + reserved_items: int = RESERVED_INPUT_ITEMS, ) -> None: self._lock = threading.Lock() self._items: Deque[_InputItem] = collections.deque() @@ -195,6 +215,8 @@ def __init__( self._max_item_bytes = max_item_bytes self._max_pending_bytes = max_pending_bytes self._reserved_bytes = reserved_bytes + self._max_pending_items = max_pending_items + self._reserved_items = reserved_items def submit( self, @@ -205,25 +227,36 @@ def submit( on_resolve: Optional[ResolveCallback] = None, ) -> Tuple[InputWriteResult, _Receipt]: receipt = _Receipt(on_resolve) - payload = bytes(data) + size = len(data) # measured on the caller's view; nothing is copied until it is admitted + enqueued = False with self._lock: + byte_budget, item_budget = self._budget(reserved) + queued = len(self._items) + (0 if self._current is None else 1) if not self._accepting: result = InputWriteResult(InputDisposition.CLOSED, 0) - elif len(payload) > self._max_item_bytes: + elif size == 0: + # Nothing to deliver, so it never becomes an entry: an empty item would + # otherwise grow the queue without ever touching the byte budget. + result = InputWriteResult(InputDisposition.ACCEPTED, 0) + elif size > self._max_item_bytes: result = InputWriteResult(InputDisposition.BACKPRESSURE, 0) - elif self._pending_bytes + len(payload) > self._budget(reserved): + elif self._pending_bytes + size > byte_budget or queued >= item_budget: result = InputWriteResult(InputDisposition.BACKPRESSURE, 0) else: self._sequence += 1 - self._items.append(_InputItem(payload, receipt, reserved, prepare, finish, self._sequence)) - self._pending_bytes += len(payload) - result = InputWriteResult(InputDisposition.ACCEPTED, len(payload)) - if result.disposition is not InputDisposition.ACCEPTED: + self._items.append(_InputItem(bytes(data), receipt, reserved, prepare, finish, self._sequence)) + self._pending_bytes += size + result = InputWriteResult(InputDisposition.ACCEPTED, size) + enqueued = True + if not enqueued: # nothing will retire it later, so it resolves here receipt.resolve(result.disposition) return result, receipt - def _budget(self, reserved: bool) -> int: - return self._max_pending_bytes if reserved else self._max_pending_bytes - self._reserved_bytes + def _budget(self, reserved: bool) -> Tuple[int, int]: + """Remaining admission budget in both dimensions, bytes first.""" + if reserved: + return self._max_pending_bytes, self._max_pending_items + return self._max_pending_bytes - self._reserved_bytes, self._max_pending_items - self._reserved_items def has_pending(self) -> bool: with self._lock: @@ -233,6 +266,10 @@ def pending_bytes(self) -> int: with self._lock: return self._pending_bytes + def pending_items(self) -> int: + with self._lock: + return len(self._items) + (0 if self._current is None else 1) + def current(self) -> Optional[_InputItem]: """The item under the cursor, promoting the next waiting item when there is none.""" with self._lock: @@ -268,8 +305,11 @@ def close_and_fail_all(self, error: Optional[BaseException] = None) -> List[_Inp self._current = None self._pending_bytes = 0 for item in items: # callbacks run outside the lock and cannot re-enter the queue + # The in-flight item may hold an open transaction; closing it is teardown's + # job now, and it happens before the receipt reports the item retired. + finish_error = item.finish_once() try: - item.receipt.resolve(InputDisposition.CLOSED, error) + item.receipt.resolve(InputDisposition.CLOSED, error or finish_error) except BaseException as exc: # a receipt must never strand its siblings console.debug(f"input receipt callback raised: {exc!r}") return items @@ -600,23 +640,44 @@ def _configure_slave(self, slave_fd: int) -> None: self._veof_byte = bytes([attrs[6][termios.VEOF][0]]) def _open_channels(self) -> None: - """Pre-registers every parent-side owner before the API that fills it.""" - status_r, status_w = os.pipe() - ack_r, ack_w = os.pipe() - wakeup_r, wakeup_w = os.pipe() - err_r, err_w = os.pipe() - os.set_blocking(wakeup_r, False) - os.set_blocking(wakeup_w, False) + """Pre-registers every parent-side owner before the API that fills it. + + Nothing is published until every descriptor and both objects exist, so a failure + part-way through closes exactly what it opened and reports on the environment + channel rather than leaking ownerless descriptors behind a raw OSError. + """ + opened: List[int] = [] + + def pipe() -> Tuple[int, int]: + read_fd, write_fd = os.pipe() + opened.extend((read_fd, write_fd)) + return read_fd, write_fd + + try: + status_r, status_w = pipe() + ack_r, ack_w = pipe() + wakeup_r, wakeup_w = pipe() + err_r, err_w = pipe() + os.set_blocking(wakeup_r, False) + os.set_blocking(wakeup_w, False) + master_fd = self._pending_master_fd + assert master_fd is not None + bundle = _ReaderBundle(master_fd, wakeup_r, err_w) + reader = threading.Thread(target=self._reader_main, name="codeplain-pty-reader", daemon=True) + except BaseException as exc: + for fd in opened: + _close_quietly(fd) + if isinstance(exc, (OSError, RuntimeError)): # the terminal's own resources ran out + raise TerminalEnvironmentError(f"Could not open the terminal's control channels: {exc}") from exc + raise self._status_r = status_r self._ack_w = ack_w self._wakeup_w = wakeup_w self._err_r = err_r - master_fd = self._pending_master_fd - assert master_fd is not None self._pending_master_fd = None # the bundle owns it from here - self._bundle = _ReaderBundle(master_fd, wakeup_r, err_w) - self._reader = threading.Thread(target=self._reader_main, name="codeplain-pty-reader", daemon=True) + self._bundle = bundle + self._reader = reader self._child_fds = (status_w, ack_r) def _start_child(self, command: Sequence[str], cwd: Optional[str], env: Optional[dict]) -> None: @@ -840,7 +901,7 @@ def _reader_loop(self, decoder) -> None: _drain_doorbell(wakeup_r) # bytes coalesce; state carries the meaning if self._closing.is_set(): self._input_queue.close_and_fail_all() - self._drain_remaining(master_fd) + self._drain_remaining(master_fd, decoder) return if master_fd in readable and not self._read_once(master_fd, decoder): return # output always wins over queued input @@ -913,15 +974,12 @@ def _flush_input(self, master_fd: int, budget: int) -> None: raise error def _complete_item(self, item: _InputItem, error: Optional[BaseException]) -> Optional[BaseException]: - if item.prepared and item.finish is not None: - try: - item.finish() # the restore is part of the item's contract, so it runs from here too - except BaseException as exc: - error = error or exc + finish_error = item.finish_once() # the restore is part of the item's contract + error = error or finish_error self._input_queue.complete_current(error) return error - def _drain_remaining(self, master_fd: int) -> None: + def _drain_remaining(self, master_fd: int, decoder) -> None: """Catches output already in flight. Bounded by time, by bytes, and by a quiet period.""" deadline = self._drain_deadline or (time.monotonic() + DRAIN_DEADLINE_SECONDS) drained = 0 @@ -938,9 +996,9 @@ def _drain_remaining(self, master_fd: int) -> None: return if not chunk: return - with self._output_lock: - self._raw += chunk - self._byte_sink(chunk) # still rendered; a query seen here is render-only + # The same feed path as the loop: drained output belongs on the decoded + # channel too. A query seen here is render-only — replies are already quiesced. + self._feed_output(chunk, decoder) drained += len(chunk) # ------------------------------------------------------------- query replies diff --git a/render_machine/terminal_process.py b/render_machine/terminal_process.py index 7a5a6917..8d2c516a 100644 --- a/render_machine/terminal_process.py +++ b/render_machine/terminal_process.py @@ -44,6 +44,10 @@ MAX_INPUT_ITEM_BYTES = 64 * 1024 MAX_PENDING_INPUT_BYTES = 256 * 1024 RESERVED_INPUT_BYTES = 8 * 1024 +# The queue is bounded in items as well as in bytes: a queue entry costs far more than +# the bytes it carries, so the byte budget alone does not bound small items. +MAX_PENDING_INPUT_ITEMS = 1024 +RESERVED_INPUT_ITEMS = 64 INPUT_WRITE_BUDGET_BYTES = 64 * 1024 # Head and tail retained from the launcher's stderr, so a flooding launcher cannot hand diff --git a/tests/test_terminal_process.py b/tests/test_terminal_process.py index 6451e5f0..d4446cc6 100644 --- a/tests/test_terminal_process.py +++ b/tests/test_terminal_process.py @@ -550,6 +550,81 @@ def test_openpty_failure_is_an_environment_error(monkeypatch): assert "too many open files" in str(failure.value) +def fail_nth_call(monkeypatch, module, name, error, nth): + """Lets the first `nth - 1` calls through and fails the one after them.""" + real = getattr(module, name) + state = {"calls": 0} + + def failing(*args, **kwargs): + state["calls"] += 1 + if state["calls"] == nth: + raise error + return real(*args, **kwargs) + + monkeypatch.setattr(module, name, failing) + return state + + +@pytest.mark.parametrize("nth", [1, 2, 3, 4]) +def test_a_failing_channel_pipe_rolls_back_the_descriptors_already_opened(monkeypatch, nth): + """One case per os.pipe() in _open_channels: the earlier pairs must not survive it.""" + baseline = open_fd_count() + state = fail_nth_call(monkeypatch, _posix_pty.os, "pipe", OSError(errno.EMFILE, "too many open files"), nth) + + process = _posix_pty.PosixPtyProcess() + with pytest.raises(TerminalEnvironmentError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"]) + process.close() + monkeypatch.undo() + + assert state["calls"] == nth + assert failure.value.exit_code == 69 + assert "too many open files" in str(failure.value) + assert open_fd_count() == baseline + + +@pytest.mark.parametrize("nth", [2, 3]) +def test_a_failing_doorbell_mode_change_rolls_back_every_channel(monkeypatch, nth): + """The doorbell's os.set_blocking() calls run with all four pipe pairs already open.""" + baseline = open_fd_count() + error = OSError(errno.EBADF, "injected set_blocking failure") + state = fail_nth_call(monkeypatch, _posix_pty.os, "set_blocking", error, nth) + + process = _posix_pty.PosixPtyProcess() + with pytest.raises(TerminalEnvironmentError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"]) + process.close() + monkeypatch.undo() + + assert state["calls"] == nth # the first call belongs to the master, not to the doorbell + assert failure.value.exit_code == 69 + assert "injected set_blocking failure" in str(failure.value) + assert open_fd_count() == baseline + + +def test_a_failing_reader_thread_construction_rolls_back_every_channel(monkeypatch): + """The last construction step in _open_channels; nothing has an owner before it.""" + baseline = open_fd_count() + real_thread = _posix_pty.threading.Thread + + def failing_thread(*args, **kwargs): + if kwargs.get("name") == "codeplain-pty-reader": + raise RuntimeError("can't start new thread") + return real_thread(*args, **kwargs) + + monkeypatch.setattr(_posix_pty.threading, "Thread", failing_thread) + process = _posix_pty.PosixPtyProcess() + with pytest.raises(TerminalEnvironmentError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"]) + process.close() + monkeypatch.undo() + + assert failure.value.exit_code == 69 + assert "can't start new thread" in str(failure.value) + assert process._bundle is None and process._reader is None # nothing was published + assert open_fd_count() == baseline + + def test_write_input_reports_whole_item_admission(): with terminal(command=["/bin/sh", "-c", "read line; printf 'got:%s' \"$line\""], input_driver=object()) as process: result = process.write_input(b"payload\n") @@ -566,6 +641,56 @@ def test_write_input_reports_backpressure_for_an_oversized_item(): assert result.accepted_bytes == 0 +class _OversizedItem: + """Reports a size but refuses to be copied, so a copy-before-validate is visible.""" + + def __len__(self): + return _posix_pty.MAX_INPUT_ITEM_BYTES + 1 + + def __bytes__(self): + raise AssertionError("the oversized item was copied before it was rejected") + + +def test_an_oversized_item_is_rejected_before_it_is_copied(): + queue = _posix_pty._InputQueue() + result, receipt = queue.submit(_OversizedItem()) + + assert result.disposition is InputDisposition.BACKPRESSURE + assert result.accepted_bytes == 0 + assert receipt.resolutions == 1 + assert queue.pending_items() == 0 + + +def test_an_empty_item_never_becomes_a_queue_entry(): + """Zero-length items cost no bytes, so admitting them would grow the queue unbounded.""" + queue = _posix_pty._InputQueue() + for _ in range(10_000): + result, receipt = queue.submit(b"") + assert result.disposition is InputDisposition.ACCEPTED + assert result.accepted_bytes == 0 + assert receipt.resolutions == 1 + + assert queue.pending_items() == 0 + assert queue.pending_bytes() == 0 + assert not queue.has_pending() + + +def test_the_input_queue_bounds_the_item_count_as_well_as_the_bytes(): + """Single-byte items exhaust the item budget long before the byte budget.""" + queue = _posix_pty._InputQueue() + accepted = 0 + while queue.submit(b"x")[0].disposition is InputDisposition.ACCEPTED: + accepted += 1 + if accepted > _posix_pty.MAX_PENDING_INPUT_ITEMS: + raise AssertionError("the queue admitted more items than its item budget allows") + + assert accepted == _posix_pty.MAX_PENDING_INPUT_ITEMS - _posix_pty.RESERVED_INPUT_ITEMS + assert queue.pending_bytes() == accepted + assert queue.pending_bytes() < _posix_pty.MAX_PENDING_INPUT_BYTES, "the byte budget was not the binding limit" + # The reserved partition is an admission partition, so control items still fit. + assert queue.submit(b"x", reserved=True)[0].disposition is InputDisposition.ACCEPTED + + # ------------------------------------------------------------------- lifecycle @@ -646,16 +771,35 @@ def test_reader_exits_cleanly_when_the_leader_exits_with_a_descendant_on_the_sla def test_cancellation_inside_the_ack_window_leaves_nothing_behind(): - """Deterministic through the delayed-ack hook: the window is opened, not raced.""" + """Deterministic through the delayed-ack hook: the window is opened, not raced. + + The cancellation waits for the hook to be entered, so it can never land before + SESSION_READY however slowly the launcher gets there. + """ stop_event = threading.Event() process = _posix_pty.PosixPtyProcess() - threading.Timer(0.2, stop_event.set).start() + entered = threading.Event() + real_wait_pre_ack = process._wait_pre_ack + + def recording_wait_pre_ack(delay, deadline): + entered.set() + real_wait_pre_ack(delay, deadline) + + def cancel_inside_the_window(): + if entered.wait(SPAWN_TIMEOUT): + stop_event.set() + + process._wait_pre_ack = recording_wait_pre_ack + canceller = threading.Thread(target=cancel_inside_the_window, daemon=True) + canceller.start() try: with pytest.raises(RenderCancelledError): process.spawn(["/bin/sh", "-c", "sleep 30"], stop_event=stop_event, pre_ack_delay=5.0) finally: + canceller.join(timeout=SHORT_TIMEOUT) process.close() + assert entered.is_set() launcher_pid = process._proc.pid assert process._proc.returncode is not None assert wait_until_gone(launcher_pid) @@ -1230,6 +1374,50 @@ def stalling_write(fd, data): assert process._input_queue.pending_bytes() == 0 +def test_close_finishes_an_in_flight_compound_item_before_failing_its_receipt(tmp_path): + """An EAGAIN'd echo-suppressed item still has its transaction closed by teardown. + + Without that, a close during the spawn-time VEOF publishes CLOSED with the terminal + left in the mode `prepare` put it in. + """ + script = make_script(tmp_path, "compound_close", "sleep 10\n") + process = _posix_pty.PosixPtyProcess() + prepared = threading.Event() + finished = [] + + def prepare(): + process._veof_prepare() + prepared.set() + + def finish(): + process._veof_restore() + finished.append(termios.tcgetattr(process._bundle.master_fd)) # the reader still owns it + + try: + process.spawn([script], input_driver=object()) + + def held_write(fd, data): + raise BlockingIOError(errno.EAGAIN, "held mid-item") + + process._write_master = held_write + result, receipt = process._input_queue.submit(b"\x04", reserved=True, prepare=prepare, finish=finish) + assert result.disposition is InputDisposition.ACCEPTED + process._ring_doorbell() + + assert prepared.wait(SPAWN_TIMEOUT) + assert not termios.tcgetattr(process._bundle.master_fd)[3] & termios.ECHO + process.close() + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert len(finished) == 1, "the in-flight transaction was never closed" + assert finished[0][3] & termios.ECHO, "the terminal mode was not restored" + assert receipt.resolutions == 1 + assert receipt.disposition is InputDisposition.CLOSED + assert process._input_queue.pending_bytes() == 0 + + def test_a_saturated_doorbell_is_only_a_coalesced_notification(tmp_path): script = make_script(tmp_path, "doorbell", "sleep 10\n") process = _posix_pty.PosixPtyProcess() @@ -1319,12 +1507,45 @@ def test_the_final_drain_is_bounded_against_a_continuously_writing_escapee(tmp_p started = time.monotonic() process.close() elapsed = time.monotonic() - started + decoded = process.read_output() + raw = process.read_raw_output() finally: process.close() if escapee_pid is not None and not wait_until_gone(escapee_pid, timeout=0.5): os.kill(escapee_pid, signal.SIGKILL) assert elapsed < _posix_pty.DRAIN_DEADLINE_SECONDS + SHORT_TIMEOUT + assert "x" in decoded, "what the drain retained must reach the decoded channel too" + assert b"x" in raw + + +def test_output_in_flight_at_close_reaches_the_decoded_channel(tmp_path): + """The reader is parked, so the marker can only be picked up by the final drain.""" + script = make_script(tmp_path, "inflight", "printf 'inflight-marker\\n'\nsleep 10\n") + process = _posix_pty.PosixPtyProcess() + parked = {"calls": 0} + + def parked_read_once(master_fd, decoder): + parked["calls"] += 1 # the master is readable, but the bytes stay in the terminal + time.sleep(0.02) + return True + + process._read_once = parked_read_once + try: + process.spawn([script], input_driver=object()) + deadline = time.monotonic() + SPAWN_TIMEOUT + while parked["calls"] < 2 and time.monotonic() < deadline: + time.sleep(0.02) + assert parked["calls"] >= 2, "the target never wrote anything" + process.close() + decoded = process.read_output() + raw = process.read_raw_output() + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert "inflight-marker" in decoded + assert b"inflight-marker" in raw def test_write_input_after_the_reader_closed_the_master_touches_nothing(tmp_path): @@ -1383,7 +1604,12 @@ def jumping_time(): def test_the_interpreter_exits_while_the_reader_and_the_reaper_are_still_blocked(tmp_path): - """Every thread this design starts is a daemon; a non-daemon one hangs shutdown.""" + """Every thread this design starts is a daemon; a non-daemon one hangs shutdown. + + The target outlives the outer bound by a wide margin, so the reader is still blocked + on the master when the bound expires: only daemon threads let the interpreter exit + inside it. + """ driver = tmp_path / "daemon_threads.py" driver.write_text( "import subprocess, sys, threading\n" @@ -1397,19 +1623,26 @@ def test_the_interpreter_exits_while_the_reader_and_the_reaper_are_still_blocked " raise subprocess.TimeoutExpired('stuck', timeout)\n" " never_set.wait()\n" "process = _posix_pty.PosixPtyProcess()\n" - "process.spawn(['/bin/sh', '-c', 'sleep 5'])\n" + "process.spawn(['/bin/sh', '-c', 'sleep 300'])\n" "_posix_pty._reap(StuckProc(), 0.01)\n" "assert process._reader.is_alive()\n" - "sys.stdout.write('ready\\n')\n" + "sys.stdout.write('ready:%d\\n' % process._pgid)\n" "sys.stdout.flush()\n" ) started = time.monotonic() completed = subprocess.run([sys.executable, str(driver)], capture_output=True, text=True, timeout=SPAWN_TIMEOUT) elapsed = time.monotonic() - started - assert "ready" in completed.stdout, completed.stderr - assert completed.returncode == 0 - assert elapsed < SPAWN_TIMEOUT + assert "ready:" in completed.stdout, completed.stderr + target_pgid = int(completed.stdout.split("ready:", 1)[1].split()[0]) + try: + assert completed.returncode == 0 + assert elapsed < SPAWN_TIMEOUT + finally: # the driver exits without terminating its target + try: + os.killpg(target_pgid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass def framed(kind, payload=b""): From 5d9f98ecb1d4c0e4c93c5598d3e54f6bf04b0218 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sat, 15 Aug 2026 23:20:17 +0200 Subject: [PATCH 12/83] Bound normalizer parser state and harden query-failure tracking Escape sequences are framed and capped before pyte sees them, so an unterminated OSC string or CSI parameter no longer grows the reader's memory, an oversized sequence is dropped rather than parsed, and a parse failure costs one sequence instead of the rest of an OS-sized read. One cell can no longer accumulate combining marks without bound. The normalizer gained an idempotent finalize(), called at reader shutdown beside the decoded-stream flush, so a trailing partial UTF-8 sequence renders as U+FFFD instead of vanishing. Terminal-reply failures are counted in full but retained as a bounded, deduplicated sample, and failure_detail() names that sample plus how many failures it omits. Fixture tests assert a committed golden rendering per recording and compute ratios byte to byte; new tests cover the caps, boundary-invariant recovery, a query flood, and two-thread races at the callback and quiesce boundary. --- render_machine/_posix_pty.py | 1 + render_machine/output_normalizer.py | 184 +++++++++++++++++- render_machine/terminal_queries.py | 36 +++- .../terminal_output/fullscreen.normalized | 13 ++ .../terminal_output/nohup_build.normalized | 3 + .../terminal_output/npm_install.normalized | 1 + .../terminal_output/pytest_color.normalized | 20 ++ .../terminal_output/spinner.normalized | 2 + tests/test_output_normalizer.py | 142 ++++++++++++-- tests/test_terminal_queries.py | 112 ++++++++++- 10 files changed, 484 insertions(+), 30 deletions(-) create mode 100644 tests/fixtures/terminal_output/fullscreen.normalized create mode 100644 tests/fixtures/terminal_output/nohup_build.normalized create mode 100644 tests/fixtures/terminal_output/npm_install.normalized create mode 100644 tests/fixtures/terminal_output/pytest_color.normalized create mode 100644 tests/fixtures/terminal_output/spinner.normalized diff --git a/render_machine/_posix_pty.py b/render_machine/_posix_pty.py index 2d44edbe..dfe54fdf 100644 --- a/render_machine/_posix_pty.py +++ b/render_machine/_posix_pty.py @@ -880,6 +880,7 @@ def _reader_main(self) -> None: for fd in (self._bundle.take_master(), self._bundle.take_wakeup_r()): _close_quietly(fd) # independent: one failing close cannot skip the rest self._flush_decoder(decoder) + self.normalizer.finalize() # the same end-of-stream flush, on the rendered channel except BaseException as exc: # finalization can fail too reader_exc = reader_exc or exc finally: diff --git a/render_machine/output_normalizer.py b/render_machine/output_normalizer.py index 4b11cebb..2a4b3ece 100644 --- a/render_machine/output_normalizer.py +++ b/render_machine/output_normalizer.py @@ -18,6 +18,7 @@ import collections import threading +import unicodedata from typing import Callable, Deque, Dict, List, Optional import pyte @@ -30,6 +31,11 @@ SCROLLBACK_HEAD_LINES = 300 SCROLLBACK_TAIL_LINES = 1700 +# Caps on the parser state a target can grow. Both are far above anything a terminal +# renders and far below anything that costs the reader its memory. +MAX_SEQUENCE_BYTES = 4096 +MAX_COMBINING_MARKS = 8 + # Private DEC modes that swap in the alternate screen buffer. ALTERNATE_SCREEN_MODES = (47, 1047, 1049) @@ -77,6 +83,109 @@ def lines(self) -> List[str]: return self._head + [f"...[{omitted} lines omitted]..."] + list(self._tail) +# Framing states, and the bytes that move between them. +_GROUND, _ESCAPE, _INTERMEDIATE, _CSI, _STRING = range(5) +_ESC = 0x1B +_BEL = 0x07 +_STRING_INTRODUCERS = frozenset(b"]PX^_") # OSC, DCS, SOS, PM, APC +_ESCAPE_INTERMEDIATES = frozenset(b"#%()") # each takes exactly one more byte + + +class _SequenceGuard: + """Frames a byte stream into plain runs and whole escape sequences, with a size cap. + + pyte 0.8.2 accumulates an unterminated OSC string or CSI parameter inside its parser + coroutine without any bound, so a target that writes `ESC ] 0 ;` and then never + terminates it grows the reader's memory for as long as it runs. An in-progress sequence + is held here instead: the buffer is this class's own, it is capped, and the remainder of + an oversized sequence is dropped rather than parsed. + + Framing never changes what the parser sees — the same bytes arrive in the same order. + It only decides where one `feed()` call ends, which is what makes a parse failure cost + one sequence instead of the rest of an OS-sized read. + """ + + def __init__(self, max_sequence_bytes: int = MAX_SEQUENCE_BYTES) -> None: + self._max_sequence_bytes = max_sequence_bytes + self._state = _GROUND + self._pending = bytearray() + self._dropping = False + self._after_escape = False + self.dropped = 0 + + @property + def pending_bytes(self) -> int: + return len(self._pending) + + def frame(self, data: bytes) -> List[bytes]: + """The units to hand the parser: plain runs and complete escape sequences.""" + units: List[bytes] = [] + index = 0 + length = len(data) + while index < length: + if self._state == _GROUND: + start = data.find(_ESC, index) + if start < 0: + units.append(data[index:]) + break + if start > index: + units.append(data[index:start]) + self._state = _ESCAPE + self._pending += b"\x1b" + index = start + 1 + else: + index = self._consume(data, index, units) + return units + + def _consume(self, data: bytes, index: int, units: List[bytes]) -> int: + length = len(data) + while index < length and self._state != _GROUND: + byte = data[index] + index += 1 + if not self._dropping and len(self._pending) >= self._max_sequence_bytes: + # Nothing renders a sequence this long, so the rest of it is parsed by + # nobody and the buffer that held it is released here. + self._dropping = True + self.dropped += 1 + self._pending.clear() + if not self._dropping: + self._pending.append(byte) + if self._ends_sequence(byte): + if not self._dropping: + units.append(bytes(self._pending)) + self._reset() + return index + + def _ends_sequence(self, byte: int) -> bool: + if self._state == _ESCAPE: + if byte == 0x5B: # [ + self._state = _CSI + elif byte in _STRING_INTRODUCERS: + self._state = _STRING + elif byte in _ESCAPE_INTERMEDIATES: + self._state = _INTERMEDIATE + else: + return True + return False + if self._state == _INTERMEDIATE: + return True + if self._state == _CSI: + return 0x40 <= byte <= 0x7E # the final byte; parameters and controls are lower + if self._after_escape: # only ESC \ terminates a string; ESC anything else does not + self._after_escape = False + return byte == 0x5C + if byte == _ESC: + self._after_escape = True + return False + return byte == _BEL + + def _reset(self) -> None: + self._state = _GROUND + self._pending.clear() + self._dropping = False + self._after_escape = False + + class _RenderingScreen(pyte.Screen): """A pyte screen that retains what scrolls off and answers device queries. @@ -90,14 +199,46 @@ def __init__( lines: int, scrollback: _RetainedLines, reply_handler: Optional[Callable[[str, bytes], None]], + max_combining_marks: int = MAX_COMBINING_MARKS, ) -> None: # Set before super().__init__, which resets the screen and can reach these. self._scrollback = scrollback self._reply_handler = reply_handler self._alternate = False self._query_kind = QUERY_DEVICE_ATTRIBUTES + self._max_combining_marks = max_combining_marks + self._combining_run = 0 super().__init__(columns, lines) + # -------------------------------------------------------------------- drawing + + def draw(self, data: str) -> None: + """Caps how many combining marks one cell can accumulate. + + pyte appends every zero-width combining mark to the previous cell's string, so a + target emitting them in a loop grows one cell without bound. A run past the cap is + dropped: no terminal renders it, and nothing else bounds it. + """ + if data.isascii(): # the common case, and no combining mark is ASCII + self._combining_run = 0 + super().draw(data) + return + super().draw(self._cap_combining_marks(data)) + + def _cap_combining_marks(self, data: str) -> str: + kept: List[str] = [] + run = self._combining_run + for char in data: + if unicodedata.combining(char): + run += 1 + if run > self._max_combining_marks: + continue + else: + run = 0 # a character that advances the cursor starts the next cell's run + kept.append(char) + self._combining_run = run + return "".join(kept) + # ------------------------------------------------------------------ scrollback def index(self) -> None: @@ -165,7 +306,9 @@ class OutputNormalizer: """Renders a target's terminal output and answers the queries it emits. Fed by the reader thread and read by the foreground, so both entry points take one - lock. `feed()` never raises: a malformed sequence must not take the reader down. + lock. `feed()` never raises: a malformed sequence must not take the reader down. Every + piece of parser state a target can grow — an unterminated sequence, one cell's + combining marks — is capped, because the reader is the process's only drainer. """ def __init__( @@ -180,9 +323,16 @@ def __init__( self._scrollback = _RetainedLines(head_lines, tail_lines) self._screen = _RenderingScreen(columns, lines, self._scrollback, reply_handler) self._stream = pyte.ByteStream(self._screen) + self._guard = _SequenceGuard() + self._finalized = False self.parse_failures = 0 self.fed_bytes = 0 + @property + def bounded_sequences(self) -> int: + """Escape sequences dropped for exceeding the size cap.""" + return self._guard.dropped + def resize(self, columns: int, lines: int) -> None: """Matches the parser to the terminal the target was actually given.""" with self._lock: @@ -193,12 +343,36 @@ def feed(self, data: bytes) -> None: return with self._lock: self.fed_bytes += len(data) + for unit in self._guard.frame(data): + try: + self._stream.feed(unit) + except Exception: + # pyte reinitializes its parser before propagating, so the next unit is + # parsed from a clean state. The guard hands over one sequence at a + # time, so a malformed one costs itself rather than the rest of the + # read — and never the reader that feeds it. + self.parse_failures += 1 + + def finalize(self) -> None: + """Ends the stream: flushes the parser's decoder. Idempotent. + + A trailing incomplete UTF-8 sequence sits in pyte's incremental decoder until it is + finalized, so without this it never reaches the screen and vanishes from the + transcript instead of rendering as U+FFFD. `utf8_decoder` is pyte 0.8.2's decoder + attribute and is reached defensively. + """ + with self._lock: + if self._finalized: + return + self._finalized = True + decoder = getattr(self._stream, "utf8_decoder", None) + if decoder is None: + return try: - self._stream.feed(data) + tail = decoder.decode(b"", final=True) + if tail: + pyte.Stream.feed(self._stream, tail) # already text, so not ByteStream.feed except Exception: - # pyte reinitializes its parser before propagating, so the next chunk is - # parsed from a clean state. Rendering continues with what was already - # drawn rather than costing the reader its life. self.parse_failures += 1 def text(self) -> str: diff --git a/render_machine/terminal_queries.py b/render_machine/terminal_queries.py index 814614b7..b2d735a1 100644 --- a/render_machine/terminal_queries.py +++ b/render_machine/terminal_queries.py @@ -16,6 +16,9 @@ records nothing: there is no client left whose query can be answered. * Obligations registered while `ACTIVE` keep reporting, even when teardown is what discovers the failure. +* Every failure is counted, but only a bounded sample is retained — one record per distinct + kind and reason. A target that queries in a loop against a closed channel fails a reply + per query, and a diagnostic must not grow with it. One lock linearizes the query callback with the `ACTIVE -> QUIESCED` transition, so a callback either admits while active or observes quiescence — never both, and never neither. @@ -31,13 +34,17 @@ import threading from dataclasses import dataclass from enum import Enum -from typing import Callable, List, Optional, Set +from typing import Callable, List, Optional, Set, Tuple # Reasons a reply can fail to reach the target. REASON_ADMISSION_RAISED = "admission raised" REASON_DISCARDED = "discarded before delivery" REASON_WRITE_FAILED = "write failed" +# How many distinct failures are kept. A target that queries in a loop against a closed +# channel fails one reply per query, so the history is a sample plus a count, never a log. +MAX_TRACKED_FAILURES = 16 + # A backend admission: hands the reply over without blocking, then resolves the completion # callback with None when the last native byte lands, or with a reason when it cannot. CompletionCallback = Callable[[Optional[str]], None] @@ -81,8 +88,10 @@ def __init__(self, admit: Optional[AdmitReply] = None, active: bool = True) -> N self._state = ResponderState.ACTIVE if active and admit is not None else ResponderState.QUIESCED self._outstanding: Set[_Obligation] = set() self._failures: List[TerminalReplyFailure] = [] + self._recorded_kinds: Set[Tuple[str, str]] = set() self.admitted = 0 self.render_only = 0 + self.failures_recorded = 0 @property def state(self) -> ResponderState: @@ -92,10 +101,11 @@ def state(self) -> ResponderState: @property def reply_failed(self) -> bool: with self._lock: - return bool(self._failures) + return self.failures_recorded > 0 @property def failures(self) -> List[TerminalReplyFailure]: + """The retained sample: distinct kind and reason pairs, capped.""" with self._lock: return list(self._failures) @@ -105,7 +115,14 @@ def outstanding(self) -> int: return len(self._outstanding) def failure_detail(self) -> str: - return "; ".join(str(failure) for failure in self.failures) + """The sample, then how many failures it does not name. Bounded by construction.""" + with self._lock: + detail = "; ".join(str(failure) for failure in self._failures) + omitted = self.failures_recorded - len(self._failures) + if omitted <= 0: + return detail + summary = f"...[{omitted} further reply failures]..." + return f"{detail}; {summary}" if detail else summary def quiesce(self) -> None: """Idempotent, foreground-triggered. Outstanding obligations keep reporting.""" @@ -133,4 +150,15 @@ def _resolve(self, obligation: _Obligation, reason: Optional[str]) -> None: obligation.resolved = True self._outstanding.discard(obligation) if reason is not None: - self._failures.append(TerminalReplyFailure(obligation.kind, reason)) + self._record_failure(obligation.kind, reason) + + def _record_failure(self, kind: str, reason: str) -> None: + """Counts every failure; retains one record per distinct kind and reason, capped.""" + self.failures_recorded += 1 + if len(self._failures) >= MAX_TRACKED_FAILURES: + return # the counter carries the rest, so neither list nor index can grow + key = (kind, reason) + if key in self._recorded_kinds: + return + self._recorded_kinds.add(key) + self._failures.append(TerminalReplyFailure(kind, reason)) diff --git a/tests/fixtures/terminal_output/fullscreen.normalized b/tests/fixtures/terminal_output/fullscreen.normalized new file mode 100644 index 00000000..880ff8d6 --- /dev/null +++ b/tests/fixtures/terminal_output/fullscreen.normalized @@ -0,0 +1,13 @@ + BUILD DASHBOARD frame 11 + ---------------------------------------------- + suite-01 running 1/10 cases + suite-02 running 2/10 cases + suite-03 running 3/10 cases + suite-04 running 4/10 cases + suite-05 running 5/10 cases + suite-06 running 6/10 cases + suite-07 passed 7/10 cases + suite-08 passed 8/10 cases + ---------------------------------------------- + elapsed 11s +BUILD FAILED: suite-03 case 7 timed out diff --git a/tests/fixtures/terminal_output/nohup_build.normalized b/tests/fixtures/terminal_output/nohup_build.normalized new file mode 100644 index 00000000..d98d558c --- /dev/null +++ b/tests/fixtures/terminal_output/nohup_build.normalized @@ -0,0 +1,3 @@ +stdout is a terminal, colour enabled +compiled 12 modules +warning: 1 deprecated call in module 07 diff --git a/tests/fixtures/terminal_output/npm_install.normalized b/tests/fixtures/terminal_output/npm_install.normalized new file mode 100644 index 00000000..7c6b6073 --- /dev/null +++ b/tests/fixtures/terminal_output/npm_install.normalized @@ -0,0 +1 @@ +added 69 packages in 3s diff --git a/tests/fixtures/terminal_output/pytest_color.normalized b/tests/fixtures/terminal_output/pytest_color.normalized new file mode 100644 index 00000000..9bacef05 --- /dev/null +++ b/tests/fixtures/terminal_output/pytest_color.normalized @@ -0,0 +1,20 @@ +....F [100%] +======================================================= FAILURES ======================================================= +________________________________________________ test_reports_a_failure ________________________________________________ + + def test_reports_a_failure(): + expected = {"name": "widget", "count": 3} + actual = {"name": "widget", "count": 4} +> assert actual == expected +E AssertionError: assert {'name': 'widget', 'count': 4} == {'name': 'widget', 'count': 3} +E +E Omitting 1 identical items, use -vv to show +E Differing items: +E {'count': 4} != {'count': 3} +E Use -v to get more diff + +test_sample.py:16: AssertionError +=============================================== short test summary info ================================================ +FAILED test_sample.py::test_reports_a_failure - AssertionError: assert {'name': 'widget', 'count': 4} == {'name': 'widge +t', 'count': 3} +1 failed, 4 passed in 0.02s diff --git a/tests/fixtures/terminal_output/spinner.normalized b/tests/fixtures/terminal_output/spinner.normalized new file mode 100644 index 00000000..b04a8d1e --- /dev/null +++ b/tests/fixtures/terminal_output/spinner.normalized @@ -0,0 +1,2 @@ +[####################] 100% done +installed 60 packages diff --git a/tests/test_output_normalizer.py b/tests/test_output_normalizer.py index 7b5f71bb..fe023ce6 100644 --- a/tests/test_output_normalizer.py +++ b/tests/test_output_normalizer.py @@ -5,9 +5,10 @@ a pseudoterminal allocated by this project's own PTY backend, at 120x40 under `TERM=xterm-256color`. -Every fixture case asserts the rendered result *and* the compression ratio, so a -regression that reintroduces noise shows up as a number rather than as a diff nobody -reads. +Every fixture case asserts the rendered result against a committed golden file *and* the +compression ratio, so a regression that reintroduces noise shows up as a number, and one +that deletes output shows up as a diff — needles and an upper ratio bound alone would pass +for a normalizer that dropped nearly everything. """ import re @@ -16,6 +17,8 @@ import pytest from render_machine.output_normalizer import ( + MAX_COMBINING_MARKS, + MAX_SEQUENCE_BYTES, QUERY_CURSOR_POSITION, QUERY_DEVICE_ATTRIBUTES, QUERY_DEVICE_STATUS, @@ -44,6 +47,11 @@ def read_fixture(name: str) -> bytes: return (FIXTURES / name).read_bytes() +def read_golden(name: str) -> str: + """The rendering committed alongside the recording, byte for byte.""" + return (FIXTURES / name).with_suffix(".normalized").read_text(encoding="utf-8") + + # name, max compression ratio, expected present, expected absent FIXTURE_CASES = [ pytest.param( @@ -99,10 +107,17 @@ def test_recorded_output_renders_to_plain_text(name, max_ratio, present, absent) assert needle not in text, f"{needle!r} should have been overwritten:\n{text}" +@pytest.mark.parametrize("name, max_ratio, present, absent", FIXTURE_CASES) +def test_recorded_output_matches_the_committed_rendering(name, max_ratio, present, absent): + """Equality, because an upper ratio bound alone rewards deleting output.""" + assert normalize(read_fixture(name)).text() == read_golden(name) + + @pytest.mark.parametrize("name, max_ratio, present, absent", FIXTURE_CASES) def test_recorded_output_is_compressed_to_what_a_terminal_would_show(name, max_ratio, present, absent): raw = read_fixture(name) - ratio = len(normalize(raw).text()) / len(raw) + rendered = normalize(raw).text().encode("utf-8") # bytes to bytes, so the ratio is one unit + ratio = len(rendered) / len(raw) assert ratio <= max_ratio, f"{name} normalized to {ratio:.3f} of its raw size, above {max_ratio}" @@ -260,26 +275,117 @@ def test_a_private_device_status_request_is_not_answered(): assert normalizer.text() == "x\n" -def test_a_parser_failure_is_counted_and_rendering_continues(monkeypatch): - """A malformed stream must cost a chunk, never the reader that feeds it.""" - normalizer = OutputNormalizer(columns=20, lines=5) - normalizer.feed(b"before\r\n") +POISON_SEQUENCE = b"\x1b[1;2;3z" # the one sequence the parser is made to reject below + +def render_with_a_rejected_sequence(monkeypatch, raw: bytes, chunk_size: int) -> OutputNormalizer: + normalizer = OutputNormalizer(columns=40, lines=5) original = normalizer._stream.feed - calls = [] - def failing_feed(data): - calls.append(data) - raise ValueError("malformed") + def feed(data): + if data == POISON_SEQUENCE: + raise ValueError("malformed") + original(data) + + monkeypatch.setattr(normalizer._stream, "feed", feed) + for offset in range(0, len(raw), chunk_size): + normalizer.feed(raw[offset : offset + chunk_size]) + return normalizer + - monkeypatch.setattr(normalizer._stream, "feed", failing_feed) - normalizer.feed(b"poison") - monkeypatch.setattr(normalizer._stream, "feed", original) - normalizer.feed(b"after\r\n") +def test_a_parser_failure_costs_one_sequence_and_rendering_continues(monkeypatch): + """A malformed sequence must cost itself, never the reader that feeds it.""" + raw = b"before " + POISON_SEQUENCE + b"after\r\n" + normalizer = render_with_a_rejected_sequence(monkeypatch, raw, chunk_size=len(raw)) - assert calls == [b"poison"] assert normalizer.parse_failures == 1 - assert normalizer.text() == "before\nafter\n" + assert normalizer.text() == "before after\n" + + +def test_parser_failure_recovery_does_not_depend_on_the_read_boundaries(monkeypatch): + """The reader feeds whatever `read()` returns, so recovery cannot cost the rest of it.""" + raw = b"before " + POISON_SEQUENCE + b"after\r\n" + whole = render_with_a_rejected_sequence(monkeypatch, raw, chunk_size=len(raw)) + split = render_with_a_rejected_sequence(monkeypatch, raw, chunk_size=1) + mid = render_with_a_rejected_sequence(monkeypatch, raw, chunk_size=9) + + assert whole.text() == split.text() == mid.text() + assert whole.parse_failures == split.parse_failures == mid.parse_failures == 1 + + +def test_an_unterminated_osc_string_is_bounded_and_draining_continues(): + """pyte would hold every byte of it; the guard holds a capped buffer instead.""" + normalizer = OutputNormalizer(columns=40, lines=5) + normalizer.feed(b"\x1b]0;") + for _ in range(200): + normalizer.feed(b"A" * 4096) # a title that never terminates + normalizer.feed(b"\x07after\r\n") + + assert normalizer._guard.pending_bytes <= MAX_SEQUENCE_BYTES + assert normalizer.bounded_sequences == 1 + assert normalizer.text() == "after\n" + assert len(normalizer._screen.title) <= MAX_SEQUENCE_BYTES + + +def test_an_unterminated_csi_parameter_is_bounded_and_draining_continues(): + normalizer = OutputNormalizer(columns=40, lines=5) + normalizer.feed(b"\x1b[") + for _ in range(200): + normalizer.feed(b"9" * 4096) # a parameter no terminal would ever finish reading + normalizer.feed(b"m") + normalizer.feed(b"still here\r\n") + + assert normalizer._guard.pending_bytes <= MAX_SEQUENCE_BYTES + assert normalizer.bounded_sequences == 1 + assert normalizer.parse_failures == 0 + assert normalizer.text() == "still here\n" + + +def test_a_completed_oversized_osc_string_is_dropped_rather_than_kept_as_metadata(): + normalizer = OutputNormalizer(columns=40, lines=5) + normalizer.feed(b"\x1b]0;short title\x07") + normalizer.feed(b"\x1b]0;" + b"B" * (MAX_SEQUENCE_BYTES * 4) + b"\x07") + normalizer.feed(b"work goes on\r\n") + + assert normalizer._screen.title == "short title" + assert normalizer.bounded_sequences == 1 + assert normalizer.text() == "work goes on\n" + + +def test_repeated_combining_marks_do_not_grow_one_cell_without_bound(): + normalizer = OutputNormalizer(columns=40, lines=5) + marks = ("́" * 200).encode("utf-8") # combining acute accents, one cell's worth of state + normalizer.feed(b"a") + for _ in range(500): + normalizer.feed(marks) + normalizer.feed(b"\r\nsecond line\r\n") + + first_line = normalizer.text().splitlines()[0] + assert len(first_line) <= MAX_COMBINING_MARKS + 1 + assert first_line.startswith("á") # the first mark still composes with the letter + assert normalizer.text().splitlines()[1] == "second line" + + +def test_a_trailing_partial_utf8_sequence_is_finalized_as_a_replacement_character(): + normalizer = OutputNormalizer(columns=20, lines=3) + normalizer.feed("hé".encode("utf-8")[:-1]) # the stream ends mid-character + + assert normalizer.text() == "h\n" + + normalizer.finalize() + normalizer.finalize() # idempotent: shutdown paths can overlap + + assert normalizer.text() == "h�\n" + + +def test_finalizing_a_complete_stream_changes_nothing(): + normalizer = OutputNormalizer(columns=20, lines=3) + normalizer.feed("héllo\r\n".encode("utf-8")) + before = normalizer.text() + + normalizer.finalize() + + assert normalizer.text() == before == "héllo\n" def test_fed_bytes_counts_every_byte_handed_to_the_parser(): diff --git a/tests/test_terminal_queries.py b/tests/test_terminal_queries.py index cc15aa99..4f54cd36 100644 --- a/tests/test_terminal_queries.py +++ b/tests/test_terminal_queries.py @@ -6,6 +6,7 @@ """ import sys +import threading import time from pathlib import Path @@ -13,7 +14,7 @@ from render_machine.output_normalizer import QUERY_CURSOR_POSITION, QUERY_DEVICE_ATTRIBUTES, QUERY_DEVICE_STATUS from render_machine.terminal_process import InputDisposition, InputWriteResult -from render_machine.terminal_queries import ResponderState, TerminalQueryResponder +from render_machine.terminal_queries import MAX_TRACKED_FAILURES, ResponderState, TerminalQueryResponder posix_only = pytest.mark.skipif(sys.platform == "win32", reason="The POSIX PTY backend is not built on Windows.") @@ -166,6 +167,101 @@ def test_failure_detail_names_every_query_kind_and_reason(): assert detail.count("backpressure") == 2 +def test_a_repeated_failure_is_counted_once_and_summarized(): + """A target that queries in a loop against a closed channel must not grow the history.""" + admissions = _Admissions(immediate_reason="discarded before delivery (closed)") + responder = TerminalQueryResponder(admissions) + + for _ in range(5000): + responder.answer(QUERY_CURSOR_POSITION, b"\x1b[1;1R") + + assert responder.failures_recorded == 5000 + assert len(responder.failures) == 1 # one kind, one reason + detail = responder.failure_detail() + assert "4999 further reply failures" in detail + assert len(detail) < 200 + + +def test_distinct_failure_reasons_are_sampled_rather_than_accumulated(): + """Reasons carry exception text, so distinctness cannot be an excuse to keep them all.""" + admissions = _Admissions() + responder = TerminalQueryResponder(admissions) + + for _ in range(2000): + responder.answer(QUERY_DEVICE_STATUS, b"\x1b[0n") + for index, complete in enumerate(admissions.completions): + complete(f"write failed: OSError({index})") + + assert responder.failures_recorded == 2000 + assert len(responder.failures) == MAX_TRACKED_FAILURES + assert responder.outstanding == 0 + assert len(responder.failure_detail()) < 2000 + + +def run_racing(first, second, reversed_order: bool) -> None: + """Releases both threads together, from a third one, so neither is ahead by construction.""" + go = threading.Event() + threads = [threading.Thread(target=lambda call=call: (go.wait(), call())) for call in (first, second)] + if reversed_order: # started in both orders, since the starter is itself a head start + threads.reverse() + for thread in threads: + thread.start() + go.set() + for thread in threads: + thread.join(SPAWN_TIMEOUT) + assert not thread.is_alive() + + +def test_a_query_racing_quiescence_is_admitted_or_render_only_but_never_both(): + """Two threads, one lock: the callback either admits while active or observes the switch.""" + outcomes = {"admitted": 0, "render_only": 0} + for attempt in range(200): + admissions = _Admissions() + responder = TerminalQueryResponder(admissions) + + run_racing( + lambda: responder.answer(QUERY_CURSOR_POSITION, b"\x1b[1;1R"), + responder.quiesce, + reversed_order=bool(attempt % 2), + ) + + assert responder.state is ResponderState.QUIESCED + assert responder.admitted + responder.render_only == 1 + assert responder.outstanding == responder.admitted + for complete in admissions.completions: + complete("discarded before delivery (closed)") + assert responder.outstanding == 0 + assert responder.reply_failed is bool(responder.admitted) + outcomes["admitted"] += responder.admitted + outcomes["render_only"] += responder.render_only + + assert min(outcomes.values()) > 0, f"the race never went both ways: {outcomes}" + + +def test_a_completion_racing_teardown_resolves_its_obligation_exactly_once(): + """Teardown discards while the backend reports the write: one obligation, one record.""" + for attempt in range(200): + admissions = _Admissions() + responder = TerminalQueryResponder(admissions) + responder.answer(QUERY_CURSOR_POSITION, b"\x1b[1;1R") + complete = admissions.completions[0] + + def teardown(): + responder.quiesce() + complete("discarded before delivery (closed)") + + run_racing( + lambda: complete("write failed: OSError(5)"), + teardown, + reversed_order=bool(attempt % 2), + ) + + assert responder.failures_recorded == 1 + assert len(responder.failures) == 1 + assert responder.failures[0].kind == QUERY_CURSOR_POSITION + assert responder.outstanding == 0 + + # --------------------------------------------------------------- backend integration @@ -301,9 +397,19 @@ def rejecting_submit(data, reserved=False, prepare=None, finish=None, on_resolve def test_a_reply_discarded_at_teardown_still_records_a_failure(tmp_path): """Admitted while ACTIVE, so the obligation survives the transition teardown makes.""" script = write_target(tmp_path, "abandons_the_reply.py", ABANDONS_THE_REPLY) - process = run_target(script) - process._flush_input = lambda master_fd, budget: None # the reply never reaches the fd + process = _posix_pty.PosixPtyProcess() + original_flush = process._flush_input + + def stall_replies(master_fd, budget): + item = process._input_queue.current() + if item is not None and item.data.startswith(b"\x1b"): + return # a reply never reaches the fd; the spawn-time EOF still does + original_flush(master_fd, budget) + + # Installed before the spawn, so no reply can complete before the stall is in place. + process._flush_input = stall_replies try: + process.spawn([sys.executable, script]) drain_to_exit(process) assert process.query_responder.admitted == 1 finally: From 283a836ec5c1a18bb8dc6e7ce9d439f5e915589e Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sat, 15 Aug 2026 23:28:28 +0200 Subject: [PATCH 13/83] Add legacy pipe backend behind TerminalProcess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps today's Popen path — merged streams, the drain thread, the Linux pipe widening — behind the TerminalProcess interface, keeping stdin at DEVNULL so neither the escape hatch nor the Windows interim can steal keystrokes again. The backend has no input channel, so its query responder starts quiesced and output is fed through the normalizer. --- render_machine/_legacy_pipe.py | 249 ++++++++++++++++++++++++++ tests/test_legacy_pipe.py | 312 +++++++++++++++++++++++++++++++++ 2 files changed, 561 insertions(+) create mode 100644 render_machine/_legacy_pipe.py create mode 100644 tests/test_legacy_pipe.py diff --git a/render_machine/_legacy_pipe.py b/render_machine/_legacy_pipe.py new file mode 100644 index 00000000..41ae2ced --- /dev/null +++ b/render_machine/_legacy_pipe.py @@ -0,0 +1,249 @@ +"""Legacy pipe backend for `TerminalProcess`. + +Wraps the `Popen(stdout=PIPE, stderr=STDOUT, start_new_session=True)` path Codeplain +shipped before the PTY, behind the same interface. It survives for two reasons: it is the +`CODEPLAIN_NO_PTY` escape hatch, and it is the Windows interim until the ConPTY backend +lands. + +The child's stdin is `DEVNULL`, permanently and on every platform. A child without a +terminal of its own would otherwise inherit Codeplain's fd 0, and `start_new_session=True` +removes the controlling terminal whose absence makes the kernel permit the read instead of +stopping it — so the child would consume the user's keystrokes. Closing that hole is the +one thing this backend may never give back. + +There is no input channel, so `write_input()` accepts nothing and a query the target +prints is rendered without a reply: a backend that cannot answer must not register an +obligation it can only fail. +""" + +import codecs +import os +import signal +import subprocess +import sys +import threading +from typing import List, Optional, Sequence, Tuple + +from plain2code_console import console +from render_machine.output_normalizer import OutputNormalizer +from render_machine.terminal_process import ( + DRAIN_DEADLINE_SECONDS, + READ_CHUNK_BYTES, + REAP_DEADLINE_SECONDS, + SIGTERM_GRACE_PERIOD_SECONDS, + TERMINAL_COLUMNS, + TERMINAL_ROWS, + InputDisposition, + InputWriteResult, + TerminalLaunchError, + TerminalProcess, +) +from render_machine.terminal_queries import TerminalQueryResponder + +if sys.platform == "linux": + import fcntl + +F_SETPIPE_SIZE = 1031 # Linux-only constant +PIPE_SIZE_KB = 1024 # 1MB + +# How long close() waits for the reader before it closes the pipe under it. A descendant +# that inherited the write end keeps the pipe open past the leader's exit. +CLOSE_JOIN_SECONDS = 1.0 + + +class LegacyPipeProcess(TerminalProcess): + """One command, one pipe carrying its merged stdout and stderr, one reader thread.""" + + def __init__(self) -> None: + self.reader_failed = threading.Event() + self.reader_exc: Optional[BaseException] = None + # No admission callable: the responder starts QUIESCED, so an escape sequence the + # target prints is rendered and nothing is ever owed to it. + self.query_responder = TerminalQueryResponder() + # The parser still reports the queries it sees, so they are accounted for on the + # render-only side rather than silently dropped. + self.normalizer = OutputNormalizer(reply_handler=self.query_responder.answer) + + self._proc: Optional[subprocess.Popen] = None + self._reader: Optional[threading.Thread] = None + self._spawned = False + self._closed = False + self._reaped = False + + self._output_lock = threading.Lock() + self._decoded: List[str] = [] + self._raw = bytearray() + + # ---------------------------------------------------------------- public API + + def spawn( + self, + command: Sequence[str], + cwd: Optional[str] = None, + env: Optional[dict] = None, + terminal_size: Tuple[int, int] = (TERMINAL_COLUMNS, TERMINAL_ROWS), + stop_event: Optional[threading.Event] = None, + input_driver: Optional[object] = None, + ) -> None: + if self._spawned: + raise RuntimeError("LegacyPipeProcess instances are single-use") + self._spawned = True + columns, rows = terminal_size + self.normalizer.resize(columns, rows) + try: + self._proc = subprocess.Popen( + list(command), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=cwd, + env=self._child_env(env), + start_new_session=(sys.platform != "win32"), + ) + except OSError as exc: + raise TerminalLaunchError(f"Could not start the script: {exc}") from exc + self._widen_pipe() + # Drain in a background thread: without continuous draining a script that + # outproduces the pipe buffer blocks on write and never exits. + self._reader = threading.Thread(target=self._reader_main, name="codeplain-pipe-reader", daemon=True) + self._reader.start() + + def poll(self) -> Optional[int]: + if self._proc is None: + return None + returncode = self._proc.poll() + if returncode is not None: + self._reaped = True + return returncode + + def read_output(self) -> str: + with self._output_lock: + text = "".join(self._decoded) + self._decoded.clear() + return text + + def read_raw_output(self) -> bytes: + with self._output_lock: + data = bytes(self._raw) + self._raw.clear() + return data + + def normalized_output(self) -> str: + return self.normalizer.text() + + @property + def terminal_reply_failed(self) -> bool: + return self.query_responder.reply_failed + + def terminal_reply_detail(self) -> str: + return self.query_responder.failure_detail() + + def write_input(self, data: bytes) -> InputWriteResult: + """Always closed: this backend hands the child `DEVNULL`, by design.""" + return InputWriteResult(InputDisposition.CLOSED, 0) + + def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: + proc = self._proc + if proc is None or self._reaped: + return + self._signal(proc, terminal=False) + try: + proc.wait(timeout=grace) + except subprocess.TimeoutExpired: + self._signal(proc, terminal=True) + try: + proc.wait(timeout=REAP_DEADLINE_SECONDS) + except subprocess.TimeoutExpired: + console.debug(f"process {proc.pid} outlived the reap deadline") + return + self._reaped = True + + def close(self) -> None: + if self._closed: + return + self._closed = True + if self._reader is not None and self._reader.ident is not None: + self._reader.join(timeout=DRAIN_DEADLINE_SECONDS) + if self._reader.is_alive(): + # A descendant is holding the write end open; the parked read has to be + # broken rather than waited out. + self._close_stdout() + self._reader.join(timeout=CLOSE_JOIN_SECONDS) + self._close_stdout() + self.normalizer.finalize() + + # -------------------------------------------------------------------- internals + + def _child_env(self, env: Optional[dict]) -> dict: + return dict(os.environ if env is None else env) + + def _widen_pipe(self) -> None: + """Best-effort 1MB pipe buffer, so bursts of output need fewer reader wakeups.""" + if sys.platform == "linux": + assert self._proc is not None and self._proc.stdout is not None + try: + fcntl.fcntl(self._proc.stdout.fileno(), F_SETPIPE_SIZE, PIPE_SIZE_KB * 1024) + except OSError as exc: # a lowered fs.pipe-max-size is not a launch failure + console.debug(f"could not widen the output pipe: {exc}") + + def _signal(self, proc: subprocess.Popen, terminal: bool) -> None: + """Signals the child's whole group, falling back to the child alone.""" + if sys.platform == "win32": + self._signal_process(proc, terminal) + return + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL if terminal else signal.SIGTERM) + except OSError: + self._signal_process(proc, terminal) + + def _signal_process(self, proc: subprocess.Popen, terminal: bool) -> None: + if terminal: + proc.kill() + else: + proc.terminate() + + def _close_stdout(self) -> None: + if self._proc is not None and self._proc.stdout is not None: + try: + self._proc.stdout.close() + except OSError: + pass + + def _reader_main(self) -> None: + assert self._proc is not None and self._proc.stdout is not None + stream = self._proc.stdout + reader_exc: Optional[BaseException] = None + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + try: + while True: + chunk = stream.read1(READ_CHUNK_BYTES) + if not chunk: + break + self._feed_output(chunk, decoder) + except (OSError, ValueError): + pass # expected: close() breaks a parked read by closing the pipe underneath it + except BaseException as exc: # nothing here reaches threading.excepthook + reader_exc = exc + finally: + try: + self._flush_decoder(decoder) + self.normalizer.finalize() + except BaseException as exc: + reader_exc = reader_exc or exc + self.reader_exc = reader_exc # stored while still unobservable + if reader_exc is not None: + self.reader_failed.set() + + def _feed_output(self, chunk: bytes, decoder) -> None: + text = decoder.decode(chunk) + with self._output_lock: + self._raw += chunk + if text: + self._decoded.append(text) + self.normalizer.feed(chunk) # outside the lock: parsing must not block read_output() + + def _flush_decoder(self, decoder) -> None: + tail = decoder.decode(b"", final=True) # a trailing partial sequence becomes U+FFFD + if tail: + with self._output_lock: + self._decoded.append(tail) diff --git a/tests/test_legacy_pipe.py b/tests/test_legacy_pipe.py new file mode 100644 index 00000000..bf1a8201 --- /dev/null +++ b/tests/test_legacy_pipe.py @@ -0,0 +1,312 @@ +"""Tests for the legacy pipe backend behind `TerminalProcess`. + +The backend wraps the pipe path Codeplain shipped before the PTY, so what is asserted here +is that path's behaviour — exit codes, merged streams, a drain that survives more output +than the pipe buffer holds — plus the properties the interface adds: no input channel, a +responder that owes nothing, and normalized output alongside the raw bytes. + +Scripts are executed for real, so every case that runs one is POSIX-only. +""" + +import contextlib +import json +import os +import stat +import subprocess +import sys +import textwrap +import time +from pathlib import Path + +import pytest + +from render_machine.terminal_process import InputDisposition +from render_machine.terminal_queries import ResponderState + +posix_only = pytest.mark.skipif( + sys.platform == "win32", + reason="These cases run POSIX shell and Python scripts directly.", +) + +pytestmark = posix_only + +if sys.platform != "win32": + from render_machine._legacy_pipe import LegacyPipeProcess + +SPAWN_TIMEOUT = 20.0 + +# Larger than the 64KB macOS pipe buffer, so the child blocks on write unless drained. +LARGE_OUTPUT_BYTES = 512 * 1024 + + +def make_script(directory: Path, name: str, program: str) -> str: + """Writes an executable Python script and returns its absolute path.""" + script_path = directory / f"{name}.py" + script_path.write_text(f"#!{sys.executable}\n" + textwrap.dedent(program)) + script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR) + return str(script_path) + + +def make_shell_script(directory: Path, name: str, body: str) -> str: + script_path = directory / f"{name}.sh" + script_path.write_text("#!/bin/sh\n" + textwrap.dedent(body)) + script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR) + return str(script_path) + + +@pytest.fixture +def backend(): + process = LegacyPipeProcess() + try: + yield process + finally: + process.terminate_tree(grace=0.1) + process.close() + + +def wait_for_exit(process, timeout=SPAWN_TIMEOUT): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + returncode = process.poll() + if returncode is not None: + return returncode + time.sleep(0.02) + raise AssertionError(f"the target did not exit within {timeout}s") + + +def run(process, command, timeout=SPAWN_TIMEOUT): + """Spawns, waits for the exit, and closes so every byte has been drained.""" + process.spawn(command) + returncode = wait_for_exit(process, timeout) + process.close() + return returncode + + +def test_exit_code_and_merged_streams_reach_the_caller(tmp_path, backend): + script = make_shell_script(tmp_path, "both_streams", 'echo "on stdout"\necho "on stderr" >&2\nexit 3\n') + + returncode = run(backend, [script]) + + output = backend.read_output() + assert returncode == 3 + assert "on stdout" in output + assert "on stderr" in output + + +def test_output_larger_than_the_pipe_buffer_is_captured_without_deadlock(tmp_path, backend): + script = make_script( + tmp_path, + "large_output", + f""" + import sys + + sys.stdout.write("x" * {LARGE_OUTPUT_BYTES}) + sys.stdout.write("\\nEND-OF-OUTPUT\\n") + """, + ) + + returncode = run(backend, [script], timeout=60) + + output = backend.read_output() + assert returncode == 0 + assert output.count("x") == LARGE_OUTPUT_BYTES + assert output.rstrip().endswith("END-OF-OUTPUT") + + +def test_raw_bytes_are_kept_verbatim_and_the_transcript_is_normalized(tmp_path, backend): + script = make_script( + tmp_path, + "coloured", + """ + import sys + + sys.stdout.write("\\033[31mred\\033[0m\\n") + """, + ) + + assert run(backend, [script]) == 0 + + assert b"\033[31m" in backend.read_raw_output() + assert backend.normalized_output() == "red\n" + + +def test_a_printed_query_is_rendered_without_creating_an_obligation(tmp_path, backend): + script = make_script( + tmp_path, + "querying", + """ + import sys + + sys.stdout.write("\\033[6nbefore\\033[5nafter\\n") + """, + ) + + assert run(backend, [script]) == 0 + + assert backend.query_responder.state is ResponderState.QUIESCED + assert backend.query_responder.render_only >= 2 + assert backend.query_responder.admitted == 0 + assert backend.terminal_reply_failed is False + assert backend.terminal_reply_detail() == "" + + +def test_write_input_accepts_nothing(tmp_path, backend): + script = make_shell_script(tmp_path, "quiet", "sleep 30\n") + backend.spawn([script]) + + result = backend.write_input(b"anything\n") + + assert result.disposition is InputDisposition.CLOSED + assert result.accepted_bytes == 0 + + +def test_terminate_tree_reaches_a_descendant(tmp_path, backend): + script = make_script( + tmp_path, + "with_descendant", + """ + import os + import sys + import time + + pid = os.fork() + if pid == 0: + time.sleep(300) + os._exit(0) + sys.stdout.write("child %d\\n" % pid) + sys.stdout.flush() + time.sleep(300) + """, + ) + backend.spawn([script]) + + deadline = time.monotonic() + SPAWN_TIMEOUT + reported = "" + while time.monotonic() < deadline and "\n" not in reported: + reported += backend.read_output() + time.sleep(0.02) + assert "\n" in reported + descendant_pid = int(reported.split()[1]) + + backend.terminate_tree(grace=0.5) + + gone_by = time.monotonic() + SPAWN_TIMEOUT + while time.monotonic() < gone_by: + try: + os.kill(descendant_pid, 0) + except OSError: + return + time.sleep(0.02) + raise AssertionError("the descendant outlived terminate_tree()") + + +def test_close_is_idempotent_and_survives_a_process_that_never_spawned(backend): + backend.close() + backend.close() + + assert backend.poll() is None + assert backend.read_output() == "" + + +def test_instances_are_single_use(tmp_path, backend): + script = make_shell_script(tmp_path, "trivial", "true\n") + backend.spawn([script]) + + with pytest.raises(RuntimeError): + backend.spawn([script]) + + +def test_a_command_that_cannot_be_started_is_an_environment_error(tmp_path, backend): + from render_machine.terminal_process import ENVIRONMENT_ERROR_EXIT_CODE, TerminalLaunchError + + missing = str(tmp_path / "not-a-real-script") + + with pytest.raises(TerminalLaunchError) as failure: + backend.spawn([missing]) + + assert failure.value.exit_code == ENVIRONMENT_ERROR_EXIT_CODE + + +# --- The terminal-isolation guard ------------------------------------------------ +# +# The escape hatch and the Windows interim both run on this backend, so it has to keep +# the child away from Codeplain's own terminal exactly as the PTY path does. + +KEYSTROKES = "secret-keystrokes\n" +STDIN_READ_LIMIT = 1024 +IMMEDIATE_EOF_SECONDS = 5 + +STDIN_PROBE_PROGRAM = f""" +import json +import os +import sys +import time + +started = time.monotonic() +data = os.read(0, {STDIN_READ_LIMIT}) +report = {{ + "isatty": os.isatty(0), + "data": data.decode(errors="replace"), + "read_seconds": time.monotonic() - started, +}} +sys.stdout.write(json.dumps(report)) +sys.stdout.flush() +""" + + +@pytest.fixture +def terminal_on_stdin(): + """Puts a PTY slave on the test process's fd 0 and yields the master fd.""" + try: + saved_stdin_fd = os.dup(0) + except OSError as exc: + pytest.skip(f"fd 0 cannot be duplicated in this environment: {exc}") + + master_fd, slave_fd = os.openpty() + os.dup2(slave_fd, 0) + try: + yield master_fd + finally: + os.dup2(saved_stdin_fd, 0) + for fd in (saved_stdin_fd, slave_fd, master_fd): + with contextlib.suppress(OSError): + os.close(fd) + + +def test_the_child_never_reads_the_renderers_terminal(tmp_path, backend, terminal_on_stdin): + script = make_script(tmp_path, "stdin_probe", STDIN_PROBE_PROGRAM) + os.write(terminal_on_stdin, KEYSTROKES.encode()) + + started = time.monotonic() + assert run(backend, [script]) == 0 + elapsed = time.monotonic() - started + + report = json.loads(backend.read_output().strip()) + assert report["isatty"] is False + assert report["data"] == "" + assert report["read_seconds"] < IMMEDIATE_EOF_SECONDS + assert elapsed < SPAWN_TIMEOUT + + +def test_the_control_case_proves_the_harness_terminal_delivers_keystrokes(tmp_path, terminal_on_stdin): + """Without this the isolation assertion above could hold for the wrong reason.""" + script = make_script(tmp_path, "inheriting_probe", STDIN_PROBE_PROGRAM) + os.write(terminal_on_stdin, KEYSTROKES.encode()) + + process = subprocess.Popen( + [script], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + start_new_session=True, + ) + try: + output, _ = process.communicate(timeout=SPAWN_TIMEOUT) + except subprocess.TimeoutExpired: + process.kill() + process.communicate(timeout=SPAWN_TIMEOUT) + pytest.fail("the control probe never returned from its read of fd 0") + + report = json.loads(output.strip()) + assert report["isatty"] is True + assert report["data"] == KEYSTROKES From 15ba58e144109eef5947e14c99e906e46382de10 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sat, 15 Aug 2026 23:35:21 +0200 Subject: [PATCH 14/83] Route execute_script through TerminalProcess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scripts now run on the PTY backend on POSIX and on the legacy pipe backend on Windows, an interim until the ConPTY backend lands. One arbiter ranks the conditions that can race — infrastructure failure, cancellation, the deadline, an undelivered terminal reply, the target's exit — so launch and reader failures reach the environment-error channel instead of the patcher, and the timeout message names the absent input driver. The temp file now holds the rendered transcript, with the raw bytes kept beside it. --- render_machine/render_utils.py | 424 ++++++++++++++++++----------- render_machine/terminal_process.py | 10 +- tests/test_render_utils.py | 266 ++++++++++++++++-- 3 files changed, 514 insertions(+), 186 deletions(-) diff --git a/render_machine/render_utils.py b/render_machine/render_utils.py index b6b83b02..20765df6 100644 --- a/render_machine/render_utils.py +++ b/render_machine/render_utils.py @@ -1,28 +1,47 @@ -import os -import re -import signal -import subprocess import sys import tempfile import threading import time from typing import Optional -if sys.platform == "linux": - import fcntl - import file_utils import plain_spec from plain2code_console import MUTED_COLOR, RETRY_COLOR, SUCCESS_COLOR, console from plain2code_exceptions import RenderCancelledError +from render_machine.terminal_process import ( + ENVIRONMENT_ERROR_EXIT_CODE, + TerminalProcess, + TerminalProcessError, + TerminalReaderError, + create_terminal_process, +) SCRIPT_EXECUTION_TIMEOUT = 120 TIMEOUT_ERROR_EXIT_CODE = 124 POLL_INTERVAL_SECONDS = 0.2 -SIGTERM_GRACE_PERIOD_SECONDS = 0.2 -STDOUT_READ_TIMEOUT_SECONDS = 2 -F_SETPIPE_SIZE = 1031 # Linux-only constant -PIPE_SIZE_KB = 1024 # 1MB + +# The `codeplain-tty` broker that would drive a script's terminal input is deferred, so no +# input driver is ever attached. The timeout diagnostic is keyed on this declaration rather +# than on bytes written: a script that blocks on input has written nothing either way. +INPUT_DRIVER: Optional[object] = None + +NO_INPUT_DIAGNOSTIC = ( + " No input driver was attached to the script's terminal, so a script that waits for input " + "never receives any and runs to the timeout." +) + +# Conditions the arbiter chooses between, highest precedence last. +CONDITION_EXIT = "exit" +CONDITION_TIMEOUT = "timeout" +CONDITION_CANCELLED = "cancelled" +CONDITION_INFRASTRUCTURE = "infrastructure" + +_CONDITION_RANK = { + CONDITION_EXIT: 0, + CONDITION_TIMEOUT: 1, + CONDITION_CANCELLED: 2, + CONDITION_INFRASTRUCTURE: 3, +} def revert_changes_for_frid(render_context): @@ -54,39 +73,224 @@ def print_inputs(render_context, existing_files_content, message): ) -def _kill_process(proc: subprocess.Popen) -> None: - """Kill a process and its entire process group.""" - if sys.platform != "win32": +class _ScriptOutcome: + """The single place a script execution's primary condition is decided. + + Teardown always runs before publication and may still add evidence, so conditions are + ranked rather than assigned in whatever order they happen to be discovered: an + independent infrastructure failure outranks an observed cancellation, which outranks + the expired deadline, which outranks the target's own exit status. Workers publish + facts; only the foreground records a condition here. + """ + + def __init__(self) -> None: + self.condition = CONDITION_EXIT + self.exit_code: Optional[int] = None + self.detail = "" + + def target_exited(self, exit_code: int) -> None: + self.exit_code = exit_code + + def timed_out(self) -> None: + self._record(CONDITION_TIMEOUT) + + def cancelled(self) -> None: + self._record(CONDITION_CANCELLED) + + def infrastructure_failed(self, detail: str) -> None: + self._record(CONDITION_INFRASTRUCTURE, detail) + + def _record(self, condition: str, detail: str = "") -> None: + if _CONDITION_RANK[condition] < _CONDITION_RANK[self.condition]: + return + if condition == self.condition and self.detail: + return # the first evidence of a condition is the one that explains it + self.condition = condition + self.detail = detail + + +class _ScriptExecution: + """Everything publication needs, gathered once the backend has been torn down.""" + + def __init__(self) -> None: + self.outcome = _ScriptOutcome() + self.output = "" + self.raw_output = b"" + self.reply_failed = False + self.reply_detail = "" + + +def _await_target( + process: TerminalProcess, + script_timeout: float, + stop_event: Optional[threading.Event], + outcome: _ScriptOutcome, +) -> None: + """Waits for the target, recording whichever condition ends the wait.""" + deadline = time.monotonic() + script_timeout + while True: + returncode = process.poll() + if returncode is not None: + outcome.target_exited(returncode) + return + if process.reader_failed.is_set(): + raise TerminalReaderError(f"the terminal output reader failed: {process.reader_exc!r}") + if time.monotonic() >= deadline: + outcome.timed_out() + return + if stop_event is not None: + stop_event.wait(timeout=POLL_INTERVAL_SECONDS) + if stop_event.is_set(): + raise RenderCancelledError() + else: + time.sleep(POLL_INTERVAL_SECONDS) + + +def _teardown(process: TerminalProcess, outcome: _ScriptOutcome) -> None: + """Releases every handle the backend owns, then classifies what teardown revealed.""" + try: try: - os.killpg(os.getpgid(proc.pid), signal.SIGTERM) - except OSError: - proc.terminate() - else: - proc.terminate() + process.terminate_tree() + finally: + process.close() + except TerminalProcessError as exc: + outcome.infrastructure_failed(str(exc)) + except Exception as exc: + outcome.infrastructure_failed(f"the terminal backend failed while shutting down: {exc!r}") + # Deliberately checked after teardown and at the highest precedence: a reader that + # died independently is an environment failure even when it surfaces while a timeout + # or a cancellation is being cleaned up. + if process.reader_failed.is_set(): + outcome.infrastructure_failed(f"the terminal output reader failed: {process.reader_exc!r}") + + +def _run_script(cmd: list[str], script_timeout: float, stop_event: Optional[threading.Event]) -> _ScriptExecution: + execution = _ScriptExecution() + process: Optional[TerminalProcess] = None try: - proc.wait(timeout=SIGTERM_GRACE_PERIOD_SECONDS) - except subprocess.TimeoutExpired: - if sys.platform != "win32": - try: - os.killpg(os.getpgid(proc.pid), signal.SIGKILL) - except OSError: - proc.kill() + process = create_terminal_process() + try: + process.spawn(cmd, stop_event=stop_event, input_driver=INPUT_DRIVER) + _await_target(process, script_timeout, stop_event, execution.outcome) + finally: + _teardown(process, execution.outcome) + except RenderCancelledError: + execution.outcome.cancelled() + except TerminalProcessError as exc: + execution.outcome.infrastructure_failed(str(exc)) + if process is not None: + execution.output = process.normalized_output() + execution.raw_output = process.read_raw_output() + execution.reply_failed = process.terminal_reply_failed + execution.reply_detail = process.terminal_reply_detail() + return execution + + +def _store_raw_output(script_type: str, raw_output: bytes) -> None: + """Keeps the unrendered bytes next to the transcript, for diagnosing the renderer.""" + with tempfile.NamedTemporaryFile(mode="wb", delete=False, suffix=".script_output.raw") as raw_file: + raw_file.write(raw_output) + console.debug(f"{script_type} script raw output stored in: {raw_file.name}", color=MUTED_COLOR) + + +def _publish_exit( + script: str, + script_type: str, + exit_code: int, + output: str, + elapsed_time: float, + frid: Optional[str], + module: Optional[str], +) -> tuple[int, str, Optional[str]]: + with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", delete=False, suffix=".script_output") as temp_file: + temp_file.write(f"\n═════════════════════════ {script_type} Script Output ═════════════════════════\n") + temp_file.write(output) + temp_file.write("\n══════════════════════════════════════════════════════════════════════\n") + temp_file_path = temp_file.name + if exit_code != 0: + temp_file.write(f"{script_type} script {script} failed with exit code {exit_code}.\n") + else: + temp_file.write(f"{script_type} script {script} successfully passed.\n") + temp_file.write(f"{script_type} script execution time: {elapsed_time:.2f} seconds.\n") + + console.debug(f"{script_type} script output stored in: {temp_file_path.strip()}", color=MUTED_COLOR) + + if exit_code != 0: + if frid is not None: + console.info( + f"↻ The {script_type} script for functionality ID {frid} of module {module} has failed. " + f"Initiating the patching mode to automatically correct the discrepancies.", + color=RETRY_COLOR, + ) + else: + console.info( + f"↻ The {script_type} script has failed. " + f"Initiating the patching mode to automatically correct the discrepancies.", + color=RETRY_COLOR, + ) + else: + if frid is not None: + console.info( + f"✓ The {script_type} script for functionality ID {frid} of module {module} " + f"has passed successfully.", + color=SUCCESS_COLOR, + ) else: - proc.kill() + console.info(f"✓ All {script_type} scripts have passed successfully.", color=SUCCESS_COLOR) + return exit_code, output, temp_file_path -def _sanitize_script_output(script_output: str) -> str: - # this function removes the escape codes that clear the console - clear_console_escape_codes_pattern = r"(?:\033\[[^a-zA-Z]*[a-zA-Z])*\033\[2J(?:\033\[[^a-zA-Z]*[a-zA-Z])*" - pattern = re.compile(clear_console_escape_codes_pattern) - parts = pattern.split(script_output) +def _publish_environment_error( + script: str, script_type: str, detail: str, output: str +) -> tuple[int, str, Optional[str]]: + """The 69 channel: an infrastructure failure is never handed to the patcher.""" + issue = f"{script_type} script {script} could not be executed: {detail}" + with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", delete=False, suffix=".script_output") as temp_file: + temp_file.write(f"{issue}\n") + if output: + temp_file.write(f"{script_type} script output before the failure:\n{output}") + temp_file_path = temp_file.name + console.warning(f"{issue} {script_type} script output stored in: {temp_file_path}") + if output: + issue = f"{issue}\nPartial {script_type} script output:\n{output}" + return ENVIRONMENT_ERROR_EXIT_CODE, issue, temp_file_path + + +def _publish_timeout( + script: str, + script_type: str, + script_timeout: float, + output: str, + reply_failed: bool, + reply_detail: str, +) -> tuple[int, str, Optional[str]]: + diagnostics = NO_INPUT_DIAGNOSTIC if INPUT_DRIVER is None else "" + if reply_failed: + diagnostics += f" Terminal replies the script asked for could not be delivered: {reply_detail}." + + with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", delete=False, suffix=".script_timeout") as temp_file: + temp_file.write(f"{script_type} script {script} timed out after {script_timeout} seconds.") + temp_file.write(diagnostics) + if output: + temp_file.write(f"{script_type} script partial output before the timeout:\n{output}") + else: + temp_file.write(f"{script_type} script did not produce any output before the timeout.") + temp_file_path = temp_file.name + console.warning( + f"The {script_type} script timed out after {script_timeout} seconds.{diagnostics} " + f"{script_type} script output stored in: {temp_file_path}" + ) - # take only the part after the last clear console escape code - return parts[-1] if len(parts) > 1 else script_output + partial_output = f"\nPartial test script output:\n{output}" if output else "" + return ( + TIMEOUT_ERROR_EXIT_CODE, + f"{script_type} script did not finish in {script_timeout} seconds.{diagnostics}{partial_output}", + temp_file_path, + ) -def execute_script( # noqa: C901 +def execute_script( script: str, scripts_args: list[str], script_type: str, @@ -95,7 +299,6 @@ def execute_script( # noqa: C901 timeout: Optional[int] = None, stop_event: Optional[threading.Event] = None, ) -> tuple[int, str, Optional[str]]: - temp_file_path = None script_timeout = timeout if timeout is not None else SCRIPT_EXECUTION_TIMEOUT script_path = file_utils.add_current_path_if_no_path(script) @@ -107,129 +310,32 @@ def execute_script( # noqa: C901 cmd = [script_path] + scripts_args start_time = time.time() - proc = subprocess.Popen( - cmd, - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - encoding="utf-8", - errors="replace", - start_new_session=(sys.platform != "win32"), - ) - - if sys.platform == "linux": - # Set the pipe size to 1MB to avoid buffer overflows - fcntl.fcntl(proc.stdout.fileno(), F_SETPIPE_SIZE, PIPE_SIZE_KB * 1024) # 1MB - - # Drain stdout in a background thread to prevent pipe buffer deadlock. - # macOS has a 64KB pipe buffer; without continuous draining, scripts that produce - # more output than that block on write and never exit, causing spurious timeouts. - output_chunks: list[str] = [] - - def _drain_stdout() -> None: - try: - for chunk in iter(lambda: proc.stdout.read(8192), ""): - output_chunks.append(chunk) - except (OSError, ValueError): - pass - - reader = threading.Thread(target=_drain_stdout, daemon=True) - reader.start() + execution = _run_script(cmd, script_timeout, stop_event) + elapsed_time = time.time() - start_time + outcome = execution.outcome + _store_raw_output(script_type, execution.raw_output) - try: - while proc.poll() is None: - if time.time() - start_time >= script_timeout: - _kill_process(proc) - reader.join(timeout=2) - partial_stdout = "".join(output_chunks) - exc = subprocess.TimeoutExpired(cmd, script_timeout) - exc.stdout = partial_stdout - raise exc - if stop_event is not None: - stop_event.wait(timeout=POLL_INTERVAL_SECONDS) - if stop_event.is_set(): - _kill_process(proc) - raise RenderCancelledError() - else: - time.sleep(POLL_INTERVAL_SECONDS) - - # Wait for the reader to finish draining remaining output. - # Close stdout if child processes keep the pipe open beyond the grace period. - reader.join(timeout=STDOUT_READ_TIMEOUT_SECONDS) - if reader.is_alive(): - proc.stdout.close() - reader.join(timeout=1) - stdout = "".join(output_chunks) - elapsed_time = time.time() - start_time - - sanitized_script_output = _sanitize_script_output(stdout) - - with tempfile.NamedTemporaryFile( - mode="w+", encoding="utf-8", delete=False, suffix=".script_output" - ) as temp_file: - temp_file.write(f"\n═════════════════════════ {script_type} Script Output ═════════════════════════\n") - temp_file.write(sanitized_script_output) - temp_file.write("\n══════════════════════════════════════════════════════════════════════\n") - temp_file_path = temp_file.name - if proc.returncode != 0: - temp_file.write(f"{script_type} script {script} failed with exit code {proc.returncode}.\n") - else: - temp_file.write(f"{script_type} script {script} successfully passed.\n") - temp_file.write(f"{script_type} script execution time: {elapsed_time:.2f} seconds.\n") - - console.debug(f"{script_type} script output stored in: {temp_file_path.strip()}", color=MUTED_COLOR) - - if proc.returncode != 0: - if frid is not None: - console.info( - f"↻ The {script_type} script for functionality ID {frid} of module {module} has failed. " - f"Initiating the patching mode to automatically correct the discrepancies.", - color=RETRY_COLOR, - ) - else: - console.info( - f"↻ The {script_type} script has failed. " - f"Initiating the patching mode to automatically correct the discrepancies.", - color=RETRY_COLOR, - ) - else: - if frid is not None: - console.info( - f"✓ The {script_type} script for functionality ID {frid} of module {module} " - f"has passed successfully.", - color=SUCCESS_COLOR, - ) - else: - console.info(f"✓ All {script_type} scripts have passed successfully.", color=SUCCESS_COLOR) - - return proc.returncode, sanitized_script_output, temp_file_path - - except RenderCancelledError: - raise - except subprocess.TimeoutExpired as e: - with tempfile.NamedTemporaryFile( - mode="w+", encoding="utf-8", delete=False, suffix=".script_timeout" - ) as temp_file: - temp_file.write(f"{script_type} script {script} timed out after {script_timeout} seconds.") - if e.stdout: - decoded_output = e.stdout.decode("utf-8") if isinstance(e.stdout, bytes) else e.stdout - temp_file.write(f"{script_type} script partial output before the timeout:\n{decoded_output}") - else: - temp_file.write(f"{script_type} script did not produce any output before the timeout.") - temp_file_path = temp_file.name - console.warning( - f"The {script_type} script timed out after {script_timeout} seconds. {script_type} script output stored in: {temp_file_path}" + # The outcome arbiter, in precedence order. + if outcome.condition == CONDITION_INFRASTRUCTURE: + return _publish_environment_error(script, script_type, outcome.detail, execution.output) + if outcome.condition == CONDITION_CANCELLED: + raise RenderCancelledError() + if outcome.condition == CONDITION_TIMEOUT: + return _publish_timeout( + script, script_type, script_timeout, execution.output, execution.reply_failed, execution.reply_detail ) - - partial_output = "" - if e.stdout: - decoded = e.stdout.decode("utf-8") if isinstance(e.stdout, bytes) else e.stdout - sanitized = _sanitize_script_output(decoded) - if sanitized: - partial_output = f"\nPartial test script output:\n{sanitized}" - return ( - TIMEOUT_ERROR_EXIT_CODE, - f"{script_type} script did not finish in {script_timeout} seconds.{partial_output}", - temp_file_path, + if outcome.exit_code is None: + return _publish_environment_error( + script, script_type, "the script's exit status was never observed", execution.output + ) + if execution.reply_failed: + # The pumps were healthy and the script exited normally, but a reply it was + # waiting for never reached it — so its exit status describes a run that did not + # get the terminal it asked for. + return _publish_environment_error( + script, + script_type, + f"terminal replies the script asked for could not be delivered: {execution.reply_detail}", + execution.output, ) + return _publish_exit(script, script_type, outcome.exit_code, execution.output, elapsed_time, frid, module) diff --git a/render_machine/terminal_process.py b/render_machine/terminal_process.py index 8d2c516a..e8286539 100644 --- a/render_machine/terminal_process.py +++ b/render_machine/terminal_process.py @@ -155,9 +155,13 @@ def __exit__(self, exc_type, exc, tb) -> None: def create_terminal_process() -> TerminalProcess: - """Returns the backend for the running platform.""" + """The one construction site: returns the backend this execution runs on.""" if sys.platform == "win32": - raise TerminalEnvironmentError("The ConPTY backend is not implemented yet.") + # Interim: Windows has no PTY backend yet, so it stays on the documented legacy + # pipe path until the ConPTY backend lands (ENG-34, Phase 6). + from render_machine._legacy_pipe import LegacyPipeProcess + + return LegacyPipeProcess() from render_machine._posix_pty import PosixPtyProcess @@ -166,4 +170,4 @@ def create_terminal_process() -> TerminalProcess: def available_backends() -> List[str]: """Names the backends this build can construct. Used by diagnostics and tests.""" - return [] if sys.platform == "win32" else ["posix-pty"] + return ["legacy-pipe"] if sys.platform == "win32" else ["posix-pty", "legacy-pipe"] diff --git a/tests/test_render_utils.py b/tests/test_render_utils.py index f227ea02..959e7df2 100644 --- a/tests/test_render_utils.py +++ b/tests/test_render_utils.py @@ -1,13 +1,19 @@ """Characterization of `render_machine.render_utils.execute_script()`. -The behaviour asserted here is the behaviour of the pipe-based implementation as it -stands today: exit-code passthrough, stderr merged into stdout, the timeout result, -cancellation, and the pipe-buffer case the drain thread exists for. The PTY backend -that replaces the pipe path has to reproduce all of it. +The behaviour asserted here is the contract the callers depend on: exit-code passthrough, +stderr merged into stdout, the timeout result with its partial output, cancellation, and +output that outruns the buffer without deadlocking. It was written against the pipe +implementation and now runs against the terminal backend, which has to reproduce all of +it. Two things legitimately changed with the backend: the transcript is rendered rather +than concatenated, so it is bounded by the retained scrollback, and the script's +descriptors are a terminal, so `isatty()` is true — while the terminal it gets is still +never Codeplain's own. + +The outcome arbiter is exercised separately, against an injected backend, because the +conditions it ranks race with each other and cannot be provoked reliably from a script. Scripts are executed for real, so every case that runs one is POSIX-only; the Windows -branch of `execute_script()` accepts `.ps1` files only. `_sanitize_script_output()` is -platform-neutral and is exercised everywhere. +branch of `execute_script()` accepts `.ps1` files only. """ import contextlib @@ -25,6 +31,11 @@ from plain2code_exceptions import RenderCancelledError from render_machine import render_utils +from render_machine.terminal_process import ( + ENVIRONMENT_ERROR_EXIT_CODE, + TerminalLaunchError, + TerminalProcess, +) posix_only = pytest.mark.skipif( sys.platform == "win32", @@ -136,7 +147,10 @@ def test_output_larger_than_the_pipe_buffer_is_captured_without_deadlock(tmp_pat exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=60) assert exit_code == 0 - assert output.count("x") == LARGE_OUTPUT_BYTES + # The transcript is rendered from the screen, so it keeps the retained scrollback + # rather than every byte — but the run completes and its last line survives, which is + # what the drain exists to guarantee. + assert output.count("x") > 100_000 assert output.rstrip().endswith("END-OF-OUTPUT") @@ -178,20 +192,27 @@ def test_script_without_a_path_is_resolved_against_the_working_directory(tmp_pat assert "resolved from the working directory" in output -@pytest.mark.parametrize( - "script_output, expected", - [ - ("plain output", "plain output"), - ("", ""), - (f"before{CLEAR_SCREEN}after", "after"), - (f"first{CLEAR_SCREEN}second{CLEAR_SCREEN}third", "third"), - (f"before\033[H{CLEAR_SCREEN}\033[3Jafter", "after"), - (f"trailing{CLEAR_SCREEN}", ""), - ("\033[31mred\033[0m", "\033[31mred\033[0m"), - ], -) -def test_sanitize_script_output_keeps_only_what_follows_the_last_screen_clear(script_output, expected): - assert render_utils._sanitize_script_output(script_output) == expected +@posix_only +def test_a_repainted_screen_yields_one_frame_and_no_escape_sequences(tmp_path, run_script): + """What the screen-clear sanitizer used to approximate, now done by rendering it.""" + script = _make_python_script( + tmp_path, + "repainting", + f""" + import sys + + for frame in range(3): + sys.stdout.write("{CLEAR_SCREEN}\\033[H") + sys.stdout.write("\\033[32mframe %d\\033[0m\\n" % frame) + sys.stdout.flush() + """, + ) + + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == 0 + assert output == "frame 2\n" + assert "\033[" not in output # --- The terminal-isolation guard ------------------------------------------------ @@ -276,7 +297,8 @@ def test_terminal_bytes_reach_a_child_that_inherits_stdin(tmp_path, terminal_on_ @posix_only -def test_script_stdin_is_at_eof_and_never_reads_the_terminal(tmp_path, run_script, terminal_on_stdin): +def test_script_stdin_is_a_terminal_of_its_own_and_never_the_renderers(tmp_path, run_script, terminal_on_stdin): + """The script gets a terminal — just not this one, and with nothing queued on it.""" script = _make_python_script(tmp_path, "stdin_probe", STDIN_PROBE_PROGRAM) os.write(terminal_on_stdin, KEYSTROKES.encode()) @@ -286,7 +308,203 @@ def test_script_stdin_is_at_eof_and_never_reads_the_terminal(tmp_path, run_scrip assert exit_code == 0 report = _probe_report(output) - assert report["isatty"] is False - assert report["data"] == "" + assert report["isatty"] is True + assert report["data"] == "" # the spawn-time VEOF, never the keystrokes above + assert KEYSTROKES.strip() not in output assert report["read_seconds"] < IMMEDIATE_EOF_SECONDS assert elapsed < CONTROL_PROBE_TIMEOUT_SECONDS + + +# --- The outcome arbiter --------------------------------------------------------- +# +# Every condition below can be observed while another is already being cleaned up, so +# the cases are driven through an injected backend rather than through a real script: +# the point is which condition wins, not how it arose. + +FAKE_SCRIPT = "arbiter.sh" +FAKE_OUTPUT = "fake transcript\n" +READER_FAILURE = RuntimeError("the master descriptor went away") +REPLY_DETAIL = "cursor-position reply discarded before delivery" + + +class _FakeTerminalProcess(TerminalProcess): + """A backend whose outcome is scripted, including failures discovered during teardown.""" + + def __init__(self, exit_code=None, spawn_error=None, reader_fails_on_close=False, reply_failed=False): + self.reader_failed = threading.Event() + self.reader_exc = None + self.exit_code = exit_code + self.spawn_error = spawn_error + self.reader_fails_on_close = reader_fails_on_close + self._reply_failed = reply_failed + self.terminated = False + self.closed = False + + def spawn(self, command, cwd=None, env=None, terminal_size=(80, 24), stop_event=None, input_driver=None): + if self.spawn_error is not None: + raise self.spawn_error + + def poll(self): + return self.exit_code + + def read_output(self): + return FAKE_OUTPUT + + def read_raw_output(self): + return FAKE_OUTPUT.encode() + + def normalized_output(self): + return FAKE_OUTPUT + + @property + def terminal_reply_failed(self): + return self._reply_failed + + def terminal_reply_detail(self): + return REPLY_DETAIL if self._reply_failed else "" + + def write_input(self, data): + raise AssertionError("the arbiter cases never write input") + + def terminate_tree(self, grace=0.0): + self.terminated = True + if self.reader_fails_on_close: # discovered while the grace period runs + self.reader_exc = READER_FAILURE + self.reader_failed.set() + + def close(self): + self.closed = True + + +@pytest.fixture +def injected_backend(monkeypatch): + """Installs a scripted backend at the single construction site.""" + installed = {} + + def _install(**kwargs): + process = _FakeTerminalProcess(**kwargs) + installed["process"] = process + monkeypatch.setattr(render_utils, "create_terminal_process", lambda: process) + return process + + yield _install + + +RAISES_CANCELLED = "raises RenderCancelledError" + +ARBITER_CASES = [ + # name, backend kwargs, stop_event set, timeout, expected exit code + ("the deadline alone", {}, False, 0, render_utils.TIMEOUT_ERROR_EXIT_CODE), + ("the deadline with a reader failure", {"reader_fails_on_close": True}, False, 0, ENVIRONMENT_ERROR_EXIT_CODE), + ("a cancellation alone", {}, True, 30, RAISES_CANCELLED), + ("a cancellation with a query failure", {"reply_failed": True}, True, 30, RAISES_CANCELLED), + ("a cancellation with a reader failure", {"reader_fails_on_close": True}, True, 30, ENVIRONMENT_ERROR_EXIT_CODE), + ("a nonzero exit alone", {"exit_code": 3}, False, 30, 3), + ( + "a nonzero exit with a query failure", + {"exit_code": 3, "reply_failed": True}, + False, + 30, + ENVIRONMENT_ERROR_EXIT_CODE, + ), + ( + "a zero exit with a query failure", + {"exit_code": 0, "reply_failed": True}, + False, + 30, + ENVIRONMENT_ERROR_EXIT_CODE, + ), + ( + "a launch failure", + {"spawn_error": TerminalLaunchError("openpty failed")}, + False, + 30, + ENVIRONMENT_ERROR_EXIT_CODE, + ), +] + + +@pytest.mark.parametrize( + "case_name, backend_kwargs, cancelled, timeout, expected", + ARBITER_CASES, + ids=[case[0] for case in ARBITER_CASES], +) +def test_the_arbiter_ranks_every_condition_that_can_race( + case_name, backend_kwargs, cancelled, timeout, expected, injected_backend, run_script +): + process = injected_backend(**backend_kwargs) + stop_event = threading.Event() + if cancelled: + stop_event.set() + + if expected is RAISES_CANCELLED: + with pytest.raises(RenderCancelledError): + render_utils.execute_script(FAKE_SCRIPT, [], SCRIPT_TYPE, timeout=timeout, stop_event=stop_event) + else: + exit_code, _, _ = run_script(FAKE_SCRIPT, [], SCRIPT_TYPE, timeout=timeout, stop_event=stop_event) + assert exit_code == expected + + assert process.closed # teardown runs before publication on every path + + +def test_a_reader_failure_during_teardown_names_the_reader(injected_backend, run_script): + injected_backend(exit_code=0, reader_fails_on_close=True) + + exit_code, issue, _ = run_script(FAKE_SCRIPT, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == ENVIRONMENT_ERROR_EXIT_CODE + assert "reader" in issue + + +def test_an_undeliverable_reply_names_the_query_that_went_unanswered(injected_backend, run_script): + injected_backend(exit_code=0, reply_failed=True) + + exit_code, issue, _ = run_script(FAKE_SCRIPT, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == ENVIRONMENT_ERROR_EXIT_CODE + assert REPLY_DETAIL in issue + + +def test_a_launch_failure_is_reported_on_the_environment_channel_and_never_as_127(injected_backend, run_script): + injected_backend(spawn_error=TerminalLaunchError("the launcher hung before exec")) + + exit_code, issue, output_file = run_script(FAKE_SCRIPT, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == ENVIRONMENT_ERROR_EXIT_CODE + assert exit_code != 127 + assert "the launcher hung before exec" in issue + assert os.path.isfile(output_file) + + +@posix_only +def test_a_script_that_cannot_be_executed_is_an_environment_error(tmp_path, run_script): + """The real path: the launcher cannot exec the target, so nothing reaches the patcher.""" + missing = str(tmp_path / "not-a-real-script.sh") + + exit_code, issue, _ = run_script(missing, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == ENVIRONMENT_ERROR_EXIT_CODE + assert missing in issue + + +@posix_only +def test_the_timeout_message_names_the_absent_input_driver(tmp_path, run_script): + script = _make_python_script( + tmp_path, + "reads_forever", + """ + import os + import sys + + while True: + if not os.read(0, 1): + sys.stdout.write("stdin closed\\n") + sys.stdout.flush() + """, + ) + + exit_code, output, output_file = run_script(script, [], SCRIPT_TYPE, timeout=2) + + assert exit_code == render_utils.TIMEOUT_ERROR_EXIT_CODE + assert "no input driver was attached" in output.lower() + assert "no input driver was attached" in Path(output_file).read_text().lower() From c01c9808fb84deb0e16c356f738800a597bfbd06 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sat, 15 Aug 2026 23:38:59 +0200 Subject: [PATCH 15/83] Add CODEPLAIN_NO_PTY escape hatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting CODEPLAIN_NO_PTY=1 in Codeplain's own environment runs scripts on the legacy pipe backend, read once per spawn at the single construction site and warned about on every use. It is never selected automatically — a failed openpty() stays an environment error — and the variable is stripped from the child's environment so a rendered script cannot branch on it. --- render_machine/_legacy_pipe.py | 3 +- render_machine/_posix_pty.py | 3 +- render_machine/terminal_process.py | 41 +++++++ tests/test_no_pty_escape_hatch.py | 189 +++++++++++++++++++++++++++++ 4 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 tests/test_no_pty_escape_hatch.py diff --git a/render_machine/_legacy_pipe.py b/render_machine/_legacy_pipe.py index 41ae2ced..abdbcb8a 100644 --- a/render_machine/_legacy_pipe.py +++ b/render_machine/_legacy_pipe.py @@ -37,6 +37,7 @@ InputWriteResult, TerminalLaunchError, TerminalProcess, + child_environment, ) from render_machine.terminal_queries import TerminalQueryResponder @@ -175,7 +176,7 @@ def close(self) -> None: # -------------------------------------------------------------------- internals def _child_env(self, env: Optional[dict]) -> dict: - return dict(os.environ if env is None else env) + return child_environment(env) def _widen_pipe(self) -> None: """Best-effort 1MB pipe buffer, so bursts of output need fewer reader wakeups.""" diff --git a/render_machine/_posix_pty.py b/render_machine/_posix_pty.py index dfe54fdf..80289ee8 100644 --- a/render_machine/_posix_pty.py +++ b/render_machine/_posix_pty.py @@ -55,6 +55,7 @@ TerminalLaunchError, TerminalProcess, TerminalReaderError, + child_environment, ) from render_machine.terminal_queries import REASON_DISCARDED, REASON_WRITE_FAILED, TerminalQueryResponder @@ -717,7 +718,7 @@ def _start_child(self, command: Sequence[str], cwd: Optional[str], env: Optional self._pending_slave_fd = None def _child_env(self, env: Optional[dict]) -> dict: - child_env = dict(os.environ if env is None else env) + child_env = child_environment(env) term = child_env.get("TERM") child_env["TERM"] = term if term else DEFAULT_TERM # git reads /dev/tty directly, so neither the VEOF nor a redirected stdin can diff --git a/render_machine/terminal_process.py b/render_machine/terminal_process.py index e8286539..c7ceb6bc 100644 --- a/render_machine/terminal_process.py +++ b/render_machine/terminal_process.py @@ -6,14 +6,24 @@ `render_machine._conpty`. Only this module is imported by callers. """ +import os import sys import threading from dataclasses import dataclass from enum import Enum from typing import List, Optional, Sequence, Tuple +from plain2code_console import console from render_machine.terminal_queries import TerminalQueryResponder +# Break-glass override, not a tuning knob: set CODEPLAIN_NO_PTY=1 to run scripts on the +# legacy pipe backend when PTY allocation fails in an environment. It is never selected +# automatically — a failed openpty() is an environment error, because a silent downgrade +# would make execution behaviour machine-dependent again. Every use is a bug report worth +# filing; the variable goes away with the legacy path (ENG-34). +NO_PTY_ENV_VAR = "CODEPLAIN_NO_PTY" +NO_PTY_ENABLED_VALUE = "1" + # Launch, reader, and writer infrastructure failures surface on the renderer's existing # environment-error channel rather than being handed to the LLM patcher as a test failure. ENVIRONMENT_ERROR_EXIT_CODE = 69 @@ -154,8 +164,39 @@ def __exit__(self, exc_type, exc, tb) -> None: self.close() +def pty_disabled_by_environment() -> bool: + """Reads the override from Codeplain's own environment, once per spawn. + + Only the exact value "1" selects the pipe backend; unset, empty, or anything else + leaves the PTY in place. Env-only, so it stays a break-glass control rather than a + configuration axis a workflow can be built on. + """ + return os.environ.get(NO_PTY_ENV_VAR) == NO_PTY_ENABLED_VALUE + + +def child_environment(env: Optional[dict]) -> dict: + """The environment a target runs in, minus the controls it must not observe. + + A rendered script that could see the override could branch on it, which would turn a + support control into part of the contract. + """ + child_env = dict(os.environ if env is None else env) + child_env.pop(NO_PTY_ENV_VAR, None) + return child_env + + def create_terminal_process() -> TerminalProcess: """The one construction site: returns the backend this execution runs on.""" + if pty_disabled_by_environment(): + console.warning( + f"{NO_PTY_ENV_VAR}={NO_PTY_ENABLED_VALUE} is set, so this script runs on the legacy pipe " + "backend: terminal semantics are disabled and isatty() will be false in the script. " + "Unset it once the environment problem that needed it is resolved, and please report that problem." + ) + from render_machine._legacy_pipe import LegacyPipeProcess + + return LegacyPipeProcess() + if sys.platform == "win32": # Interim: Windows has no PTY backend yet, so it stays on the documented legacy # pipe path until the ConPTY backend lands (ENG-34, Phase 6). diff --git a/tests/test_no_pty_escape_hatch.py b/tests/test_no_pty_escape_hatch.py new file mode 100644 index 00000000..9544ee9f --- /dev/null +++ b/tests/test_no_pty_escape_hatch.py @@ -0,0 +1,189 @@ +"""The `CODEPLAIN_NO_PTY` escape hatch. + +An explicit user override, never an automatic fallback: a failed `openpty()` stays an +environment error, because a silent downgrade would make execution behaviour +machine-dependent again. What is asserted here is the contract that keeps it an override — +the exact value that selects it, the warning on every use, the variable's absence from the +child's environment — plus the characterization cases, re-run unchanged against the pipe +backend the hatch selects. +""" + +import json +import sys + +import pytest + +from render_machine.terminal_process import ( + ENVIRONMENT_ERROR_EXIT_CODE, + NO_PTY_ENV_VAR, + create_terminal_process, + pty_disabled_by_environment, +) +from tests import test_render_utils as characterization + +posix_only = pytest.mark.skipif( + sys.platform == "win32", + reason="These cases run POSIX shell and Python scripts directly.", +) + +if sys.platform != "win32": + from render_machine._legacy_pipe import LegacyPipeProcess + from render_machine._posix_pty import PosixPtyProcess + +SCRIPT_TYPE = characterization.SCRIPT_TYPE + + +@pytest.fixture(autouse=True) +def hatch(monkeypatch): + """Every case in this module runs with the hatch open.""" + monkeypatch.setenv(NO_PTY_ENV_VAR, "1") + + +@pytest.fixture +def hatch_warnings(monkeypatch): + """Records the warnings that name the hatch, without printing any of them. + + `console` is one shared object, so the recorder sees every warning the execution + emits; only the ones naming the variable belong to the hatch. + """ + recorded = [] + monkeypatch.setattr( + "render_machine.terminal_process.console.warning", + lambda message: recorded.append(message) if NO_PTY_ENV_VAR in message else None, + ) + return recorded + + +# The characterization cases, re-run unchanged. The terminal-isolation case is not among +# them: the pipe backend gives the script DEVNULL rather than a terminal of its own, and +# its own module asserts that the script still never reaches Codeplain's terminal. +run_script = characterization.run_script + +test_successful_script_returns_zero_with_its_output = ( + characterization.test_successful_script_returns_zero_with_its_output +) +test_failing_script_exit_code_is_returned_verbatim = characterization.test_failing_script_exit_code_is_returned_verbatim +test_stderr_is_merged_into_the_captured_output = characterization.test_stderr_is_merged_into_the_captured_output +test_output_larger_than_the_pipe_buffer_is_captured_without_deadlock = ( + characterization.test_output_larger_than_the_pipe_buffer_is_captured_without_deadlock +) +test_script_exceeding_the_timeout_returns_124_and_keeps_partial_output = ( + characterization.test_script_exceeding_the_timeout_returns_124_and_keeps_partial_output +) +test_set_stop_event_cancels_the_script = characterization.test_set_stop_event_cancels_the_script +test_script_without_a_path_is_resolved_against_the_working_directory = ( + characterization.test_script_without_a_path_is_resolved_against_the_working_directory +) +test_a_repainted_screen_yields_one_frame_and_no_escape_sequences = ( + characterization.test_a_repainted_screen_yields_one_frame_and_no_escape_sequences +) + + +@posix_only +def test_the_hatch_selects_the_pipe_backend(hatch_warnings): + process = create_terminal_process() + try: + assert isinstance(process, LegacyPipeProcess) + finally: + process.close() + + +@posix_only +@pytest.mark.parametrize("value", ["", "0", "true", "yes", "11", " 1"]) +def test_only_the_value_one_selects_the_pipe_backend(monkeypatch, value): + monkeypatch.setenv(NO_PTY_ENV_VAR, value) + + assert pty_disabled_by_environment() is False + process = create_terminal_process() + try: + assert isinstance(process, PosixPtyProcess) + finally: + process.close() + + +@posix_only +def test_the_warning_names_the_variable_on_every_use(hatch_warnings): + for _ in range(2): + create_terminal_process().close() + + assert len(hatch_warnings) == 2 + for message in hatch_warnings: + assert NO_PTY_ENV_VAR in message + assert "isatty" in message + + +@posix_only +def test_no_warning_is_emitted_when_the_hatch_is_closed(monkeypatch, hatch_warnings): + monkeypatch.delenv(NO_PTY_ENV_VAR) + + create_terminal_process().close() + + assert hatch_warnings == [] + + +ENVIRONMENT_PROBE_PROGRAM = f""" +import json +import os +import sys + +sys.stdout.write(json.dumps({{"present": "{NO_PTY_ENV_VAR}" in os.environ}})) +sys.stdout.flush() +""" + + +@posix_only +@pytest.mark.parametrize("value", ["1", "0"]) +def test_the_variable_never_reaches_the_child(tmp_path, run_script, monkeypatch, value): + """Whichever backend it selects, a rendered script must not be able to branch on it.""" + monkeypatch.setenv(NO_PTY_ENV_VAR, value) + script = characterization._make_python_script(tmp_path, "env_probe", ENVIRONMENT_PROBE_PROGRAM) + + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == 0 + assert json.loads(output.strip()) == {"present": False} + + +@posix_only +def test_a_failed_openpty_is_an_environment_error_rather_than_a_downgrade( + tmp_path, run_script, monkeypatch, hatch_warnings +): + """The hatch is the only way to the pipe backend; PTY exhaustion is never a fallback.""" + monkeypatch.delenv(NO_PTY_ENV_VAR) + script = characterization._make_shell_script(tmp_path, "never_runs", 'echo "unreachable"\n') + + def refuse_to_allocate(): + raise OSError(23, "too many open files in system") + + monkeypatch.setattr("render_machine._posix_pty.os.openpty", refuse_to_allocate) + + exit_code, issue, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == ENVIRONMENT_ERROR_EXIT_CODE + assert "pseudoterminal" in issue + assert "unreachable" not in issue + assert hatch_warnings == [] + + +@posix_only +def test_the_hatch_is_read_at_every_spawn(tmp_path, run_script, monkeypatch): + """Read at spawn, not cached at import, so opening it takes effect immediately.""" + monkeypatch.delenv(NO_PTY_ENV_VAR) + script = characterization._make_python_script(tmp_path, "isatty_probe", ISATTY_PROBE_PROGRAM) + + _, with_pty, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + monkeypatch.setenv(NO_PTY_ENV_VAR, "1") + _, without_pty, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + + assert json.loads(with_pty.strip()) == {"isatty": True} + assert json.loads(without_pty.strip()) == {"isatty": False} + + +ISATTY_PROBE_PROGRAM = """ +import json +import os +import sys + +sys.stdout.write(json.dumps({"isatty": os.isatty(0) and os.isatty(1)})) +sys.stdout.flush() +""" From f354470576dc816a603ca6a0beca327faa72c43f Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sat, 15 Aug 2026 23:50:58 +0200 Subject: [PATCH 16/83] Validate terminal semantics through execute_script Add a validation suite that drives the terminal contract through the real path: session and foreground-group invariants, Node and shell compatibility, lifecycle bounds, the documented process-tree limits, PTY exhaustion, terminal isolation on both backends, the child environment, and a detached run. --- tests/test_terminal_validation.py | 713 ++++++++++++++++++++++++++++++ 1 file changed, 713 insertions(+) create mode 100644 tests/test_terminal_validation.py diff --git a/tests/test_terminal_validation.py b/tests/test_terminal_validation.py new file mode 100644 index 00000000..c499d185 --- /dev/null +++ b/tests/test_terminal_validation.py @@ -0,0 +1,713 @@ +"""Validation of the terminal contract, driven through `execute_script()`. + +Everything here goes through the real path a render takes — `execute_script()` with a real +script on disk — rather than through a backend directly. The backend suites assert how the +pieces behave; this one asserts that what a rendered script actually observes matches the +contract: a terminal of its own on all three descriptors, its own session and foreground +process group, a bounded lifecycle, the documented process-tree limits, and an environment +with exactly the hints the renderer promises and no others. + +Cases already covered verbatim elsewhere are not repeated here: + +- output larger than the terminal buffer, exit-code passthrough, merged stderr, the + timeout result with its partial output, and the timeout message naming the absent input + driver live in `tests/test_render_utils.py` +- the escape hatch's selection rule, its warning, and the variable's absence from the + child environment live in `tests/test_no_pty_escape_hatch.py`; what is added here is the + terminal-isolation guard run through `execute_script()` against *both* backends + +Scripts are executed for real, so the whole module is POSIX-only. +""" + +import errno +import json +import os +import re +import shutil +import signal +import subprocess +import sys +import textwrap +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest + +from plain2code_exceptions import RenderCancelledError +from render_machine import render_utils +from render_machine.terminal_process import ( + DEFAULT_TERM, + ENVIRONMENT_ERROR_EXIT_CODE, + NO_PTY_ENV_VAR, +) +from tests import test_render_utils as characterization + +pytestmark = pytest.mark.skipif( + sys.platform == "win32", + reason="execute_script() runs .ps1 scripts on Windows; these cases use POSIX scripts.", +) + +REPO_ROOT = str(Path(__file__).resolve().parent.parent) +SCRIPT_TYPE = characterization.SCRIPT_TYPE + +NODE = shutil.which("node") +GIT = shutil.which("git") +needs_node = pytest.mark.skipif(NODE is None, reason="node is not installed on this machine.") +needs_git = pytest.mark.skipif(GIT is None, reason="git is not installed on this machine.") + +# Every wait below is bounded. The budgets are generous relative to the work they cover, +# so a failure means something hung rather than that the machine was busy. +SETTLE_SECONDS = 5.0 +LIVENESS_WINDOW_SECONDS = 1.0 +DETACHED_TIMEOUT_SECONDS = 60.0 + +_make_shell_script = characterization._make_shell_script +_make_python_script = characterization._make_python_script + +# Fixtures reused from the characterization module: the same output-file bookkeeping and +# the same real PTY on the harness's own fd 0. +run_script = characterization.run_script +terminal_on_stdin = characterization.terminal_on_stdin + + +def _report(output): + """Parses a JSON report out of a rendered transcript. + + The transcript is rendered from a 120-column screen, so a long report is wrapped + across rows. Joining the rows restores it: the probes below emit JSON without + insignificant whitespace, and rendering only ever drops trailing blanks. + """ + return json.loads("".join(output.split("\n"))) + + +def _wait_until(predicate, seconds): + """Polls a predicate to a deadline and returns whether it ever held.""" + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.05) + return predicate() + + +def _open_descriptor_count(): + try: + return len(os.listdir("/dev/fd")) + except OSError as exc: # pragma: no cover - only on a host without /dev/fd + pytest.skip(f"open descriptors cannot be counted in this environment: {exc}") + + +# --- Terminal invariants --------------------------------------------------------- + +INVARIANT_PROBE_PROGRAM = """ +import json +import os +import sys + +report = { + "isatty_stdin": os.isatty(0), + "isatty_stdout": os.isatty(1), + "isatty_stderr": os.isatty(2), + "session_leader": os.getsid(0) == os.getpid(), + "group_leader": os.getpgrp() == os.getpid(), + "in_the_foreground": os.tcgetpgrp(0) == os.getpgrp(), + "dev_tty": False, + "term": os.environ.get("TERM"), +} +try: + tty_fd = os.open("/dev/tty", os.O_RDWR) +except OSError: + pass +else: + report["dev_tty"] = True + os.close(tty_fd) +sys.stdout.write(json.dumps(report, separators=(",", ":"))) +sys.stdout.flush() +""" + + +def test_a_script_leads_its_own_session_with_its_terminal_in_the_foreground(tmp_path, run_script): + """The whole topology asserted from inside the rendered command, in one run.""" + script = _make_python_script(tmp_path, "invariants", INVARIANT_PROBE_PROGRAM) + + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == 0 + report = _report(output) + assert report.pop("term") # asserted in full by the child-environment cases below + assert report == { + "isatty_stdin": True, + "isatty_stdout": True, + "isatty_stderr": True, + "session_leader": True, + "group_leader": True, + "in_the_foreground": True, + "dev_tty": True, + } + + +# --- Compatibility --------------------------------------------------------------- + + +@needs_node +def test_node_reports_its_version_through_the_real_path(run_script): + """Node is resolved to an absolute path: a bare name would be rewritten to `./node`.""" + exit_code, output, _ = run_script(NODE, ["--version"], SCRIPT_TYPE, timeout=60) + + assert exit_code == 0 + assert re.match(r"^v\d+\.\d+\.\d+", output.strip()), output + + +def test_a_shell_sees_a_terminal_on_all_three_descriptors(tmp_path, run_script): + script = _make_shell_script( + tmp_path, + "shell_tty", + 'test -t 0 && test -t 1 && test -t 2 && echo "all three are terminals"\n', + ) + + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == 0 + assert "all three are terminals" in output + + +NODE_RAW_MODE_PROGRAM = """ +const report = {isTTY: process.stdin.isTTY === true, raw: false, restored: false}; +try { + process.stdin.setRawMode(true); + report.raw = process.stdin.isRaw === true; + process.stdin.setRawMode(false); + report.restored = process.stdin.isRaw === false; +} catch (error) { + report.error = String(error.message).replace(/\\s+/g, "-"); +} +process.stdout.write(JSON.stringify(report)); +process.exit(0); +""" + + +@needs_node +def test_node_sees_a_tty_on_stdin_and_can_toggle_raw_mode(tmp_path, run_script): + script_path = tmp_path / "raw_mode.js" + script_path.write_text(f"#!{NODE}\n" + textwrap.dedent(NODE_RAW_MODE_PROGRAM)) + script_path.chmod(0o755) + + exit_code, output, _ = run_script(str(script_path), [], SCRIPT_TYPE, timeout=60) + + assert exit_code == 0 + assert _report(output) == {"isTTY": True, "raw": True, "restored": True} + + +TERMIOS_MODE_PROBE_PROGRAM = """ +import json +import os +import sys +import termios +import tty + +saved = termios.tcgetattr(0) +report = {"canonical": bool(termios.tcgetattr(0)[3] & termios.ICANON)} +try: + tty.setcbreak(0) + report["cbreak"] = not termios.tcgetattr(0)[3] & termios.ICANON + tty.setraw(0) + local = termios.tcgetattr(0)[3] + report["raw"] = not local & (termios.ICANON | termios.ECHO | termios.ISIG) +finally: + termios.tcsetattr(0, termios.TCSANOW, saved) +report["restored"] = bool(termios.tcgetattr(0)[3] & termios.ICANON) +sys.stdout.write(json.dumps(report, separators=(",", ":"))) +sys.stdout.flush() +""" + + +def test_canonical_cbreak_and_raw_modes_are_all_reachable(tmp_path, run_script): + script = _make_python_script(tmp_path, "termios_modes", TERMIOS_MODE_PROBE_PROGRAM) + + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == 0 + assert _report(output) == {"canonical": True, "cbreak": True, "raw": True, "restored": True} + + +FRAGMENTED_OUTPUT_PROGRAM = """ +import sys +import time + +# One byte per write, so every multi-byte character crosses a read boundary. +for byte in "hello wörld ✓ café".encode(): + sys.stdout.buffer.write(bytes([byte])) + sys.stdout.buffer.flush() + time.sleep(0.001) +sys.stdout.buffer.write(b"\\n") +sys.stdout.buffer.flush() +# The same for an SGR sequence, which the renderer must consume rather than print. +for chunk in (b"\\x1b", b"[3", b"1m", b"red text", b"\\x1b", b"[0", b"m\\n"): + sys.stdout.buffer.write(chunk) + sys.stdout.buffer.flush() + time.sleep(0.001) +""" + + +def test_partial_utf8_and_split_escape_sequences_survive_the_stream(tmp_path, run_script): + script = _make_python_script(tmp_path, "fragmented", FRAGMENTED_OUTPUT_PROGRAM) + + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == 0 + assert "hello wörld ✓ café" in output + assert "red text" in output + assert "\033[" not in output + assert "�" not in output # no replacement character from a split code point + + +# --- Lifecycle ------------------------------------------------------------------- + + +def test_a_stop_event_set_mid_run_cancels_the_script(tmp_path): + """Cancellation while the target is running, rather than before it starts.""" + script = _make_shell_script(tmp_path, "long_run", 'echo "started"\nsleep 30\n') + stop_event = threading.Event() + canceller = threading.Timer(1.0, stop_event.set) + canceller.start() + + started = time.monotonic() + try: + with pytest.raises(RenderCancelledError): + render_utils.execute_script(script, [], SCRIPT_TYPE, timeout=30, stop_event=stop_event) + finally: + canceller.cancel() + + assert time.monotonic() - started < 20 + + +def test_repeated_executions_leak_no_descriptors_and_no_threads(tmp_path, run_script): + script = _make_shell_script(tmp_path, "quick", 'echo "done"\n') + run_script(script, [], SCRIPT_TYPE, timeout=30) # first run pays the import costs + + descriptors_before = _open_descriptor_count() + threads_before = threading.active_count() + for _ in range(4): + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + assert exit_code == 0 + assert "done" in output + + # The reaper is a background thread on the teardown path, so both counts are given a + # bounded moment to return to where they started. + assert _wait_until(lambda: _open_descriptor_count() <= descriptors_before, SETTLE_SECONDS) + assert _wait_until(lambda: threading.active_count() <= threads_before, SETTLE_SECONDS) + + +# --- Process-tree boundaries ----------------------------------------------------- +# +# These assert the *documented* contract, not full containment: a descendant that leaves +# the process group, and one whose leader was reaped before teardown, are outside what +# `terminate_tree()` claims to reach. Both are cases Phase 6's Job Object does contain, +# which is the platform asymmetry these cases exist to keep visible. + +DESCENDANT_PROGRAM = """ +import os +import signal +import sys +import time + +beats_path, mode = sys.argv[1], sys.argv[2] +pid = os.fork() +if pid == 0: + if "own-group" in mode: + os.setpgid(0, 0) + if "ignore-hup" in mode: + signal.signal(signal.SIGHUP, signal.SIG_IGN) + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + with open(beats_path, "a") as beats: + beats.write("tick\\n") + time.sleep(0.05) + os._exit(0) +sys.stdout.write("descendant %d\\n" % pid) +sys.stdout.flush() +if "leader-exits" in mode: + sys.exit(0) +time.sleep(60) +""" + + +@pytest.fixture +def descendants(): + """Kills whatever a case deliberately left running outside the process tree.""" + survivors = [] + yield survivors + for pid in survivors: + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + + +def _descendant_pid(output): + match = re.search(r"descendant (\d+)", output) + assert match is not None, f"the script never reported its descendant: {output!r}" + return int(match.group(1)) + + +def _beats(path): + try: + return path.read_text().count("tick") + except OSError: + return 0 + + +def _still_beating(path): + """True when the heartbeat file grows over a bounded window.""" + before = _beats(path) + return _wait_until(lambda: _beats(path) > before, LIVENESS_WINDOW_SECONDS) + + +def test_a_descendant_that_leaves_the_process_group_survives_termination(tmp_path, run_script, descendants): + beats = tmp_path / "own_group.beats" + script = _make_python_script(tmp_path, "escapes_group", DESCENDANT_PROGRAM) + + exit_code, output, _ = run_script(script, [str(beats), "own-group"], SCRIPT_TYPE, timeout=2) + + assert exit_code == render_utils.TIMEOUT_ERROR_EXIT_CODE + descendants.append(_descendant_pid(output)) + assert _still_beating(beats), "the documented escape stopped working: the descendant was reached after all" + + +def test_a_sighup_ignoring_descendant_survives_a_leader_reaped_before_teardown(tmp_path, run_script, descendants): + """Once `poll()` has reaped the leader the pgid may be recycled, so nothing is signalled.""" + beats = tmp_path / "same_group.beats" + script = _make_python_script(tmp_path, "leader_exits", DESCENDANT_PROGRAM) + + exit_code, output, _ = run_script(script, [str(beats), "ignore-hup,leader-exits"], SCRIPT_TYPE, timeout=30) + + assert exit_code == 0 + descendants.append(_descendant_pid(output)) + assert _still_beating(beats) + + +HANGUP_SCRIPT_PROGRAM = """ +import os +import signal +import sys +import time + +directory = sys.argv[1] +for name, ignores_hup in (("default", False), ("ignoring", True)): + if os.fork() == 0: + if ignores_hup: + signal.signal(signal.SIGHUP, signal.SIG_IGN) + with open(os.path.join(directory, name + ".pid"), "w") as pid_file: + pid_file.write(str(os.getpid())) + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + with open(os.path.join(directory, name + ".beats"), "a") as beats: + beats.write("tick\\n") + time.sleep(0.05) + os._exit(0) +time.sleep(60) +""" + + +def test_a_dead_renderer_hangs_up_the_terminal_but_cannot_contain_the_tree(tmp_path, descendants): + """Best-effort, and deliberately asserted as such. + + When Codeplain itself dies the master closes, the slave hangs up, and the foreground + group receives `SIGHUP` — which terminates a default-disposition descendant and does + nothing at all to one that ignores it. Genuine crash containment needs an OS mechanism + that outlives the renderer, which is not what this path provides. + """ + script = _make_python_script(tmp_path, "hangup_targets", HANGUP_SCRIPT_PROGRAM) + runner = subprocess.Popen( + [sys.executable, "-c", _renderer_program(script, [str(tmp_path)])], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + cwd=str(tmp_path), + start_new_session=True, + ) + default_beats, ignoring_beats = tmp_path / "default.beats", tmp_path / "ignoring.beats" + try: + assert _wait_until(lambda: _beats(default_beats) and _beats(ignoring_beats), 30.0), "descendants never started" + for name in ("default", "ignoring"): + descendants.append(int((tmp_path / f"{name}.pid").read_text())) + runner.kill() # the renderer dies without ever running its teardown + runner.wait(timeout=SETTLE_SECONDS) + finally: + if runner.poll() is None: # pragma: no cover - only if the kill above never landed + runner.kill() + + assert _wait_until(lambda: not _still_beating(default_beats), SETTLE_SECONDS) + assert _still_beating(ignoring_beats), "a SIGHUP-ignoring descendant is expected to survive the hangup" + + +def _renderer_program(script, args, result_path=None): + """A one-liner renderer: imports the real path and runs one script through it.""" + return textwrap.dedent(f""" + import json + import os + import sys + + sys.path.insert(0, {REPO_ROOT!r}) + from render_machine import render_utils + + renderer_stdin_is_a_terminal = os.isatty(0) + exit_code, output, _ = render_utils.execute_script( + {script!r}, {args!r}, "Detached", timeout={int(DETACHED_TIMEOUT_SECONDS)} + ) + result_path = {result_path!r} + if result_path is not None: + with open(result_path, "w") as result_file: + json.dump( + {{ + "exit_code": exit_code, + "output": output, + "renderer_stdin_is_a_terminal": renderer_stdin_is_a_terminal, + }}, + result_file, + ) + """) + + +# --- PTY exhaustion -------------------------------------------------------------- + + +def _never_constructed(*_args, **_kwargs): + raise AssertionError("the pipe backend was constructed as a fallback") + + +def test_a_failed_openpty_reports_the_errno_and_never_falls_back_to_pipes(tmp_path, run_script, monkeypatch): + """PTYs are a finite system resource; running out of them is an environment failure.""" + script = _make_shell_script(tmp_path, "never_runs", 'echo "unreachable"\n') + monkeypatch.delenv(NO_PTY_ENV_VAR, raising=False) + + def exhausted(*_args, **_kwargs): + raise OSError(errno.ENOSPC, os.strerror(errno.ENOSPC)) + + monkeypatch.setattr("render_machine._posix_pty.os.openpty", exhausted) + monkeypatch.setattr("render_machine._legacy_pipe.LegacyPipeProcess", _never_constructed) + + exit_code, issue, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == ENVIRONMENT_ERROR_EXIT_CODE + assert "pseudoterminal" in issue + assert f"Errno {errno.ENOSPC}" in issue + assert "unreachable" not in issue + + +# --- Terminal isolation (F7) ----------------------------------------------------- + + +@pytest.mark.parametrize("pty_disabled", [False, True], ids=["pty backend", "pipe backend"]) +def test_a_script_never_reads_the_renderers_terminal_on_either_backend( + tmp_path, run_script, terminal_on_stdin, monkeypatch, pty_disabled +): + """The decisive case: the harness holds a real terminal and types into it. + + Both backends have to keep the script away from it — the escape hatch and the Windows + interim must not reopen the hole the PTY closes. + """ + if pty_disabled: + monkeypatch.setenv(NO_PTY_ENV_VAR, "1") + else: + monkeypatch.delenv(NO_PTY_ENV_VAR, raising=False) + script = _make_python_script(tmp_path, "stdin_probe", characterization.STDIN_PROBE_PROGRAM) + os.write(terminal_on_stdin, characterization.KEYSTROKES.encode()) + + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == 0 + report = _report(output) + assert report["data"] == "" + assert report["isatty"] is not pty_disabled # a terminal of its own, or none at all + assert characterization.KEYSTROKES.strip() not in output + + +# --- The no-input contract ------------------------------------------------------- +# +# The repeatedly-reading case — the timeout message naming the absent input driver — is +# asserted in `tests/test_render_utils.py` and is not repeated here. + +SINGLE_READ_PROGRAM = """ +import os +import sys +import time + +started = time.monotonic() +data = os.read(0, 1024) +sys.stdout.write("read %d bytes in %.2fs\\n" % (len(data), time.monotonic() - started)) +sys.stdout.flush() +""" + + +def test_a_single_read_script_exits_on_the_spawn_time_veof(tmp_path, run_script): + script = _make_python_script(tmp_path, "single_read", SINGLE_READ_PROGRAM) + + started = time.monotonic() + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + elapsed = time.monotonic() - started + + assert exit_code == 0 + assert "read 0 bytes" in output + assert elapsed < 20 # nowhere near the configured timeout + + +def test_a_slow_silent_script_that_never_reads_completes_untouched(tmp_path, run_script): + script = _make_shell_script(tmp_path, "slow_and_silent", 'sleep 3\necho "finished on its own"\n') + + started = time.monotonic() + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + elapsed = time.monotonic() - started + + assert exit_code == 0 + assert "finished on its own" in output + assert elapsed >= 3 + + +# --- The child environment ------------------------------------------------------- + +# Hints a non-interactive runner might be tempted to set. The child gets a terminal, so +# none of them belongs in its environment. +NON_INTERACTIVE_HINTS = ("CI", "PIP_NO_INPUT", "NPM_CONFIG_YES", "DEBIAN_FRONTEND") + +ENVIRONMENT_PROBE_PROGRAM = """ +import json +import os +import sys + +names = ("TERM", "GIT_TERMINAL_PROMPT", "CI", "PIP_NO_INPUT", "NPM_CONFIG_YES", "DEBIAN_FRONTEND") +sys.stdout.write(json.dumps({name: os.environ.get(name) for name in names}, separators=(",", ":"))) +sys.stdout.flush() +""" + + +@pytest.fixture +def child_environment(tmp_path, run_script): + """Runs the environment probe and returns what the child saw.""" + script = _make_python_script(tmp_path, "env_probe", ENVIRONMENT_PROBE_PROGRAM) + + def _run(): + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + assert exit_code == 0 + return _report(output) + + return _run + + +@pytest.mark.parametrize( + "parent_term, expected", + [("vt100-under-test", "vt100-under-test"), (None, DEFAULT_TERM), ("", DEFAULT_TERM)], + ids=["inherited when set", "defaulted when unset", "defaulted when detached-empty"], +) +def test_term_is_inherited_when_set_and_defaulted_otherwise(monkeypatch, child_environment, parent_term, expected): + if parent_term is None: + monkeypatch.delenv("TERM", raising=False) + else: + monkeypatch.setenv("TERM", parent_term) + + assert child_environment()["TERM"] == expected + + +def test_git_terminal_prompt_reaches_the_child_even_when_the_parent_disagrees(monkeypatch, child_environment): + monkeypatch.setenv("GIT_TERMINAL_PROMPT", "1") + + assert child_environment()["GIT_TERMINAL_PROMPT"] == "0" + + +def test_no_other_non_interactive_hint_reaches_the_child(monkeypatch, child_environment): + for name in NON_INTERACTIVE_HINTS: + monkeypatch.delenv(name, raising=False) + + seen = child_environment() + + assert {name: seen[name] for name in NON_INTERACTIVE_HINTS} == dict.fromkeys(NON_INTERACTIVE_HINTS, None) + + +class _UnauthorizedHandler(BaseHTTPRequestHandler): + """Answers every request with a basic-auth challenge and nothing else.""" + + def do_GET(self): + self.send_response(401) + self.send_header("WWW-Authenticate", 'Basic realm="git"') + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, *args): + pass + + +@pytest.fixture +def credential_demanding_remote(): + """A local HTTP remote that demands credentials, so no network is involved.""" + server = ThreadingHTTPServer(("127.0.0.1", 0), _UnauthorizedHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_address[1]}/repository.git" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=SETTLE_SECONDS) + + +@needs_git +def test_a_git_operation_needing_credentials_fails_instead_of_blocking_on_dev_tty( + tmp_path, run_script, monkeypatch, credential_demanding_remote +): + """git reads `/dev/tty` directly, so failing fast is the only bounded outcome.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(tmp_path / "absent.gitconfig")) + for name in ("GIT_ASKPASS", "SSH_ASKPASS", "GIT_CREDENTIAL_HELPER"): + monkeypatch.delenv(name, raising=False) + script = _make_shell_script(tmp_path, "needs_credentials", f'exec git ls-remote "{credential_demanding_remote}"\n') + + started = time.monotonic() + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + elapsed = time.monotonic() - started + + assert exit_code not in (0, render_utils.TIMEOUT_ERROR_EXIT_CODE) + assert "terminal prompts disabled" in output + assert elapsed < 20 + + +# --- Detached --------------------------------------------------------------------- + + +def test_a_detached_renderer_still_gives_the_script_a_terminal(tmp_path): + """A `nohup`-style parent: its own session, no terminal anywhere, stdio redirected. + + This is the `execute_script()` half of the detached case. The full `--headless` render + under `nohup` needs the live API, so it belongs to the e2e job rather than here. + """ + script = _make_python_script(tmp_path, "detached_invariants", INVARIANT_PROBE_PROGRAM) + result_path = tmp_path / "detached.json" + log_path = tmp_path / "detached.log" + environment = dict(os.environ) + environment.pop("TERM", None) # a detached parent commonly has none + + with open(log_path, "w") as log_file: + runner = subprocess.Popen( + [sys.executable, "-c", _renderer_program(script, [], str(result_path))], + stdin=subprocess.DEVNULL, + stdout=log_file, + stderr=subprocess.STDOUT, + cwd=str(tmp_path), + env=environment, + start_new_session=True, + ) + try: + assert runner.wait(timeout=DETACHED_TIMEOUT_SECONDS) == 0, log_path.read_text() + finally: + if runner.poll() is None: # pragma: no cover - only if the detached run hung + runner.kill() + + result = json.loads(result_path.read_text()) + assert result["exit_code"] == 0 + assert result["renderer_stdin_is_a_terminal"] is False + report = _report(result["output"]) + assert report["isatty_stdin"] and report["isatty_stdout"] and report["isatty_stderr"] + assert report["session_leader"] and report["in_the_foreground"] + assert report["term"] == DEFAULT_TERM # the detached parent had none to inherit From 36fedb8fde173c055ad9f6986077807decca50ba Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sat, 15 Aug 2026 23:54:00 +0200 Subject: [PATCH 17/83] Add macOS e2e job running Node through the installed wheel The new job builds and installs this checkout's wheel, then runs Node through the installed execute_script(), so it needs neither Docker nor the API key. Both existing jobs now set up Python 3.11, matching the project's pin. --- .github/workflows/e2e.yml | 34 +++++++++++++++++++++-- tests/e2e/macos_node_smoke.py | 52 +++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/macos_node_smoke.py diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0ab6fcfd..76400ba1 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: '3.11' - run: pip install pytest @@ -52,11 +52,39 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: '3.11' - run: pip install pytest - name: Run E2E tests env: CODEPLAIN_API_KEY: ${{ secrets.CODEPLAIN_API_KEY }} - run: pytest tests/e2e/ -v --tb=short \ No newline at end of file + run: pytest tests/e2e/ -v --tb=short + + # The installer jobs above test the published package; this one tests the wheel built + # from the checkout. It runs neither the pytest e2e collection (its POSIX fixture needs + # a Docker daemon and its other case is Windows-only) nor anything that needs the API + # key: the point is that a real toolchain runs through the terminal backend on macOS. + e2e-macos: + runs-on: macos-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # hatch-vcs derives the version from the git tags + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Build and install this checkout's wheel + run: | + python -m pip install --upgrade pip build + python -m build --wheel + pip install dist/*.whl + + - name: Report the Node under test + run: node --version # macos-latest ships Node preinstalled + + - name: Run Node through the installed execute_script() + run: python tests/e2e/macos_node_smoke.py diff --git a/tests/e2e/macos_node_smoke.py b/tests/e2e/macos_node_smoke.py new file mode 100644 index 00000000..de372bbb --- /dev/null +++ b/tests/e2e/macos_node_smoke.py @@ -0,0 +1,52 @@ +"""macOS smoke test: run Node through the installed package's `execute_script()`. + +Invoked directly by the `e2e-macos` job, not by pytest — the rest of `tests/e2e/` +needs a Docker daemon or a Windows runner, and this case needs neither that nor the +live API. It exercises the one thing a macOS runner can prove cheaply: a real toolchain +launched through the terminal backend of the wheel built from this checkout. + +Exit codes: 0 on success, 1 on a failed assertion, 69 when the environment cannot run +the case at all. +""" + +import re +import shutil +import sys +from pathlib import Path + +CHECKOUT_ROOT = Path(__file__).resolve().parent.parent.parent +ENVIRONMENT_FAILURE = 69 +VERSION_PATTERN = re.compile(r"^v\d+\.\d+\.\d+") + + +def fail(message): + print(f"FAIL: {message}") + return 1 + + +def main(): + node = shutil.which("node") + if node is None: + print("Error: node is required for the macOS smoke test") + return ENVIRONMENT_FAILURE + + from render_machine import render_utils + + installed_from = Path(render_utils.__file__).resolve() + if installed_from.is_relative_to(CHECKOUT_ROOT): + return fail(f"execute_script() was imported from the checkout at {installed_from}, not from the wheel") + + print(f"Running {node} --version through {installed_from}") + exit_code, output, _ = render_utils.execute_script(node, ["--version"], "Smoke", timeout=60) + + if exit_code != 0: + return fail(f"node exited with {exit_code}; output: {output!r}") + if not VERSION_PATTERN.match(output.strip()): + return fail(f"output is not a version: {output!r}") + + print(f"OK: node reported {output.strip()}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From fe89b0db694f2c8f1558d8ccfcf9a61b6f704a0a Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 00:10:22 +0200 Subject: [PATCH 18/83] Fix outcome arbitration and artifact lifecycle in execute_script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each poll now records every observable fact — target exit, expired deadline, set cancellation, reader failure — before the rank table picks one, so racing conditions are arbitrated instead of published in discovery order. Any backend exception, not only TerminalProcessError, is classified as an environment failure with its detail, and the launch failure is recorded before teardown so a cleanup diagnostic follows it rather than replacing it. Both backends publish a reader that outlives its join bound, the pipe reader publishes read failures that happen while it is active, and the PTY drain suppresses only PTY EOF and a closing backend's EBADF. The raw transcript is written as a sibling of the published output file instead of an orphaned temp file, and a cancelled run leaves no artifact at all. --- render_machine/_legacy_pipe.py | 14 ++- render_machine/_posix_pty.py | 13 ++- render_machine/render_utils.py | 137 ++++++++++++++++++++-------- render_machine/terminal_process.py | 18 ++++ tests/test_legacy_pipe.py | 50 ++++++++++- tests/test_render_utils.py | 139 +++++++++++++++++++++++++++-- 6 files changed, 325 insertions(+), 46 deletions(-) diff --git a/render_machine/_legacy_pipe.py b/render_machine/_legacy_pipe.py index abdbcb8a..1c209bfb 100644 --- a/render_machine/_legacy_pipe.py +++ b/render_machine/_legacy_pipe.py @@ -69,6 +69,7 @@ def __init__(self) -> None: self._reader: Optional[threading.Thread] = None self._spawned = False self._closed = False + self._closing = threading.Event() self._reaped = False self._output_lock = threading.Lock() @@ -163,6 +164,8 @@ def close(self) -> None: if self._closed: return self._closed = True + self._closing.set() # from here a failing read is expected closure, not a fault + stalled = False if self._reader is not None and self._reader.ident is not None: self._reader.join(timeout=DRAIN_DEADLINE_SECONDS) if self._reader.is_alive(): @@ -170,8 +173,11 @@ def close(self) -> None: # broken rather than waited out. self._close_stdout() self._reader.join(timeout=CLOSE_JOIN_SECONDS) + stalled = self._reader.is_alive() self._close_stdout() self.normalizer.finalize() + if stalled: + self._publish_reader_stall() # -------------------------------------------------------------------- internals @@ -221,8 +227,12 @@ def _reader_main(self) -> None: if not chunk: break self._feed_output(chunk, decoder) - except (OSError, ValueError): - pass # expected: close() breaks a parked read by closing the pipe underneath it + except (OSError, ValueError) as exc: + # Expected only once the pipe is gone: close() breaks a parked read by closing + # it underneath the reader. The same error while the backend is still active + # is an independent reader failure and has to be published like any other. + if not (self._closing.is_set() or stream.closed): + reader_exc = exc except BaseException as exc: # nothing here reaches threading.excepthook reader_exc = exc finally: diff --git a/render_machine/_posix_pty.py b/render_machine/_posix_pty.py index 80289ee8..7ddd24a5 100644 --- a/render_machine/_posix_pty.py +++ b/render_machine/_posix_pty.py @@ -599,8 +599,10 @@ def close(self) -> None: self._drain_deadline = time.monotonic() + DRAIN_DEADLINE_SECONDS self._input_queue.stop_accepting(self._closing) self._ring_doorbell() + stalled = False if self._reader is not None and self._reader.ident is not None: # None when it never started self._reader.join(timeout=DRAIN_DEADLINE_SECONDS + REAP_DEADLINE_SECONDS) + stalled = self._reader.is_alive() self._close_owned("_wakeup_w") self._close_owned("_err_r") self._close_owned("_status_r") @@ -609,6 +611,8 @@ def close(self) -> None: self._proc.stderr.close() if self._bundle is not None and self._bundle.owner == _OWNER_PARENT: self._bundle.close_all() # no reader ever took them + if stalled: # every handle this side owns is released first + self._publish_reader_stall() # ------------------------------------------------------------- spawn helpers @@ -994,8 +998,15 @@ def _drain_remaining(self, master_fd: int, decoder) -> None: return # nothing more is in flight try: chunk = self._read_master(master_fd, READ_CHUNK_BYTES) - except (BlockingIOError, OSError): + except BlockingIOError: return + except OSError as exc: + # Only the two ways a drain legitimately ends: the PTY reached EOF, or the + # master was released under a backend that is already closing. Anything + # else is an independent read failure and is published like one. + if exc.errno == errno.EIO or (exc.errno == errno.EBADF and self._closing.is_set()): + return + raise if not chunk: return # The same feed path as the loop: drained output belongs on the decoded diff --git a/render_machine/render_utils.py b/render_machine/render_utils.py index 20765df6..d8418d96 100644 --- a/render_machine/render_utils.py +++ b/render_machine/render_utils.py @@ -12,7 +12,6 @@ ENVIRONMENT_ERROR_EXIT_CODE, TerminalProcess, TerminalProcessError, - TerminalReaderError, create_terminal_process, ) @@ -20,6 +19,10 @@ TIMEOUT_ERROR_EXIT_CODE = 124 POLL_INTERVAL_SECONDS = 0.2 +# The raw transcript is written beside the published one under this suffix, so it is +# discoverable from the returned path and cleanable by the same convention. +RAW_OUTPUT_SUFFIX = ".raw" + # The `codeplain-tty` broker that would drive a script's terminal input is deferred, so no # input driver is ever attached. The timeout diagnostic is keyed on this declaration rather # than on bytes written: a script that blocks on input has written nothing either way. @@ -88,6 +91,10 @@ def __init__(self) -> None: self.exit_code: Optional[int] = None self.detail = "" + def decided(self) -> bool: + """True once any condition has been observed, whatever its rank.""" + return self.exit_code is not None or self.condition != CONDITION_EXIT + def target_exited(self, exit_code: int) -> None: self.exit_code = exit_code @@ -104,7 +111,11 @@ def _record(self, condition: str, detail: str = "") -> None: if _CONDITION_RANK[condition] < _CONDITION_RANK[self.condition]: return if condition == self.condition and self.detail: - return # the first evidence of a condition is the one that explains it + # The first evidence of a condition is the one that explains it; later + # evidence — a teardown diagnostic, say — is kept after it, never instead. + if detail and detail not in self.detail: + self.detail = f"{self.detail} (also: {detail})" + return self.condition = condition self.detail = detail @@ -120,28 +131,37 @@ def __init__(self) -> None: self.reply_detail = "" +def _reader_failure_detail(process: TerminalProcess) -> str: + return f"the terminal output reader failed: {process.reader_exc!r}" + + def _await_target( process: TerminalProcess, script_timeout: float, stop_event: Optional[threading.Event], outcome: _ScriptOutcome, ) -> None: - """Waits for the target, recording whichever condition ends the wait.""" + """Waits for the target, recording every condition each poll can observe. + + No fact ends the wait before the others have been recorded: a target that exits after + its deadline, or while a cancellation is already set, races with the condition it + coincides with, and only the rank table decides which of them is published. + """ deadline = time.monotonic() + script_timeout while True: returncode = process.poll() if returncode is not None: outcome.target_exited(returncode) - return - if process.reader_failed.is_set(): - raise TerminalReaderError(f"the terminal output reader failed: {process.reader_exc!r}") + if stop_event is not None and stop_event.is_set(): + outcome.cancelled() if time.monotonic() >= deadline: outcome.timed_out() + if process.reader_failed.is_set(): + outcome.infrastructure_failed(_reader_failure_detail(process)) + if outcome.decided(): return if stop_event is not None: stop_event.wait(timeout=POLL_INTERVAL_SECONDS) - if stop_event.is_set(): - raise RenderCancelledError() else: time.sleep(POLL_INTERVAL_SECONDS) @@ -161,36 +181,75 @@ def _teardown(process: TerminalProcess, outcome: _ScriptOutcome) -> None: # died independently is an environment failure even when it surfaces while a timeout # or a cancellation is being cleaned up. if process.reader_failed.is_set(): - outcome.infrastructure_failed(f"the terminal output reader failed: {process.reader_exc!r}") + outcome.infrastructure_failed(_reader_failure_detail(process)) + + +def _record_backend_failure(outcome: _ScriptOutcome, exc: Exception, phase: str) -> None: + """Classifies anything the backend raises, not only what it declares. + + The tuple contract holds for every failure of the machinery around the script: a + backend that raises something unforeseen is still an environment failure, never an + exception the callers have to unwind. + """ + if isinstance(exc, TerminalProcessError): + outcome.infrastructure_failed(str(exc)) + else: + outcome.infrastructure_failed(f"the terminal backend failed {phase}: {exc!r}") + + +def _collect_backend_state(process: TerminalProcess, execution: _ScriptExecution) -> None: + """Reads everything publication needs off the torn-down backend.""" + try: + execution.output = process.normalized_output() + execution.raw_output = process.read_raw_output() + execution.reply_failed = process.terminal_reply_failed + execution.reply_detail = process.terminal_reply_detail() + except Exception as exc: + _record_backend_failure(execution.outcome, exc, "while reporting its result") def _run_script(cmd: list[str], script_timeout: float, stop_event: Optional[threading.Event]) -> _ScriptExecution: execution = _ScriptExecution() + outcome = execution.outcome process: Optional[TerminalProcess] = None try: process = create_terminal_process() - try: - process.spawn(cmd, stop_event=stop_event, input_driver=INPUT_DRIVER) - _await_target(process, script_timeout, stop_event, execution.outcome) - finally: - _teardown(process, execution.outcome) + except Exception as exc: + _record_backend_failure(outcome, exc, "while being created") + if process is None: + return execution + try: + process.spawn(cmd, stop_event=stop_event, input_driver=INPUT_DRIVER) + _await_target(process, script_timeout, stop_event, outcome) except RenderCancelledError: - execution.outcome.cancelled() - except TerminalProcessError as exc: - execution.outcome.infrastructure_failed(str(exc)) - if process is not None: - execution.output = process.normalized_output() - execution.raw_output = process.read_raw_output() - execution.reply_failed = process.terminal_reply_failed - execution.reply_detail = process.terminal_reply_detail() + outcome.cancelled() + except Exception as exc: + # Recorded here rather than around the teardown, so the failure that ended the run + # is the one that explains the outcome and a teardown diagnostic can only follow it. + _record_backend_failure(outcome, exc, "while running the script") + finally: + _teardown(process, outcome) + _collect_backend_state(process, execution) return execution -def _store_raw_output(script_type: str, raw_output: bytes) -> None: - """Keeps the unrendered bytes next to the transcript, for diagnosing the renderer.""" - with tempfile.NamedTemporaryFile(mode="wb", delete=False, suffix=".script_output.raw") as raw_file: - raw_file.write(raw_output) - console.debug(f"{script_type} script raw output stored in: {raw_file.name}", color=MUTED_COLOR) +def _store_raw_output(script_type: str, raw_output: bytes, output_file_path: Optional[str]) -> None: + """Keeps the unrendered bytes next to the transcript, for diagnosing the renderer. + + A derived sibling of the published artifact rather than a temp file of its own: the + raw bytes are only useful beside the transcript they explain, and a caller holding the + path it was handed can find and remove this one by convention. + """ + if output_file_path is None: + return + raw_file_path = output_file_path + RAW_OUTPUT_SUFFIX + try: + with open(raw_file_path, "wb") as raw_file: + raw_file.write(raw_output) + except OSError as exc: # a diagnostic artifact never changes the published outcome + console.debug(f"could not store the {script_type} script raw output: {exc}", color=MUTED_COLOR) + return + console.debug(f"{script_type} script raw output stored in: {raw_file_path}", color=MUTED_COLOR) def _publish_exit( @@ -313,29 +372,33 @@ def execute_script( execution = _run_script(cmd, script_timeout, stop_event) elapsed_time = time.time() - start_time outcome = execution.outcome - _store_raw_output(script_type, execution.raw_output) # The outcome arbiter, in precedence order. if outcome.condition == CONDITION_INFRASTRUCTURE: - return _publish_environment_error(script, script_type, outcome.detail, execution.output) - if outcome.condition == CONDITION_CANCELLED: + result = _publish_environment_error(script, script_type, outcome.detail, execution.output) + elif outcome.condition == CONDITION_CANCELLED: + # A cancelled run publishes nothing, so it leaves no artifact behind either. raise RenderCancelledError() - if outcome.condition == CONDITION_TIMEOUT: - return _publish_timeout( + elif outcome.condition == CONDITION_TIMEOUT: + result = _publish_timeout( script, script_type, script_timeout, execution.output, execution.reply_failed, execution.reply_detail ) - if outcome.exit_code is None: - return _publish_environment_error( + elif outcome.exit_code is None: + result = _publish_environment_error( script, script_type, "the script's exit status was never observed", execution.output ) - if execution.reply_failed: + elif execution.reply_failed: # The pumps were healthy and the script exited normally, but a reply it was # waiting for never reached it — so its exit status describes a run that did not # get the terminal it asked for. - return _publish_environment_error( + result = _publish_environment_error( script, script_type, f"terminal replies the script asked for could not be delivered: {execution.reply_detail}", execution.output, ) - return _publish_exit(script, script_type, outcome.exit_code, execution.output, elapsed_time, frid, module) + else: + result = _publish_exit(script, script_type, outcome.exit_code, execution.output, elapsed_time, frid, module) + + _store_raw_output(script_type, execution.raw_output, result[2]) + return result diff --git a/render_machine/terminal_process.py b/render_machine/terminal_process.py index c7ceb6bc..ede0cfc6 100644 --- a/render_machine/terminal_process.py +++ b/render_machine/terminal_process.py @@ -64,6 +64,11 @@ # the parent an unbounded buffer while the reads continue. LAUNCHER_STDERR_CAP_BYTES = 16 * 1024 +# Published when close() has waited out its bound and the reader is still running: such a +# reader can still append output or fail afterwards, so the transcript it produced cannot +# be trusted and the execution is an environment failure. +READER_STALL_DETAIL = "the terminal output reader did not terminate within its shutdown bound" + class InputDisposition(Enum): """Immediate whole-item backend admission — never a delivery receipt.""" @@ -157,6 +162,19 @@ def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: def close(self) -> None: raise NotImplementedError + def _publish_reader_stall(self) -> None: + """Publishes a reader that close() could not join, and refuses to return quietly. + + A backend whose reader is still running owns handles it has not released and can + still append output, so close() must not report a released backend: the stall is + published on the reader's own channel and raised. + """ + error = TerminalReaderError(READER_STALL_DETAIL) + if self.reader_exc is None: + self.reader_exc = error + self.reader_failed.set() + raise error + def __enter__(self) -> "TerminalProcess": return self diff --git a/tests/test_legacy_pipe.py b/tests/test_legacy_pipe.py index bf1a8201..646c83fe 100644 --- a/tests/test_legacy_pipe.py +++ b/tests/test_legacy_pipe.py @@ -9,18 +9,20 @@ """ import contextlib +import errno import json import os import stat import subprocess import sys import textwrap +import threading import time from pathlib import Path import pytest -from render_machine.terminal_process import InputDisposition +from render_machine.terminal_process import READER_STALL_DETAIL, InputDisposition, TerminalReaderError from render_machine.terminal_queries import ResponderState posix_only = pytest.mark.skipif( @@ -31,6 +33,7 @@ pytestmark = posix_only if sys.platform != "win32": + from render_machine import _legacy_pipe from render_machine._legacy_pipe import LegacyPipeProcess SPAWN_TIMEOUT = 20.0 @@ -227,6 +230,51 @@ def test_a_command_that_cannot_be_started_is_an_environment_error(tmp_path, back assert failure.value.exit_code == ENVIRONMENT_ERROR_EXIT_CODE +def test_a_read_failure_while_the_backend_is_active_is_published(tmp_path, backend): + """An OSError from the read path is expected closure only once the pipe is gone.""" + script = make_shell_script(tmp_path, "chatty", "while true; do printf tick; sleep 0.05; done\n") + + def failing_feed(chunk, decoder): + raise OSError(errno.EIO, "injected reader failure") + + backend._feed_output = failing_feed + backend.spawn([script]) + + deadline = time.monotonic() + SPAWN_TIMEOUT + while not backend.reader_failed.is_set() and time.monotonic() < deadline: + time.sleep(0.02) + + assert backend.reader_failed.is_set() + assert isinstance(backend.reader_exc, OSError) + + +def test_a_reader_that_outlives_its_join_bound_is_published_as_a_reader_failure(tmp_path, backend, monkeypatch): + """close() must not report a released backend while the reader still holds the pipe.""" + monkeypatch.setattr(_legacy_pipe, "DRAIN_DEADLINE_SECONDS", 0.05) + monkeypatch.setattr(_legacy_pipe, "CLOSE_JOIN_SECONDS", 0.05) + script = make_shell_script(tmp_path, "prints_then_waits", 'echo "hello"\nsleep 30\n') + reading = threading.Event() + release = threading.Event() + real_feed = backend._feed_output + + def stalling_feed(chunk, decoder): + reading.set() + release.wait(SPAWN_TIMEOUT) # holds the reader past both joins in close() + real_feed(chunk, decoder) + + backend._feed_output = stalling_feed + backend.spawn([script]) + assert reading.wait(SPAWN_TIMEOUT) + + try: + with pytest.raises(TerminalReaderError) as failure: + backend.close() + assert READER_STALL_DETAIL in str(failure.value) + assert backend.reader_failed.is_set() + finally: + release.set() + + # --- The terminal-isolation guard ------------------------------------------------ # # The escape hatch and the Windows interim both run on this backend, so it has to keep diff --git a/tests/test_render_utils.py b/tests/test_render_utils.py index 959e7df2..10665136 100644 --- a/tests/test_render_utils.py +++ b/tests/test_render_utils.py @@ -22,6 +22,7 @@ import stat import subprocess import sys +import tempfile import textwrap import threading import time @@ -33,8 +34,10 @@ from render_machine import render_utils from render_machine.terminal_process import ( ENVIRONMENT_ERROR_EXIT_CODE, + READER_STALL_DETAIL, TerminalLaunchError, TerminalProcess, + TerminalProcessError, ) posix_only = pytest.mark.skipif( @@ -72,6 +75,25 @@ def _make_python_script(directory: Path, name: str, program: str) -> str: return str(script_path) +RAW_ARTIFACT_GLOB = f"*.script_*{render_utils.RAW_OUTPUT_SUFFIX}" + + +@pytest.fixture(autouse=True) +def no_orphaned_raw_transcripts(): + """Every raw transcript must be reachable from the path execute_script() returned. + + Runs around every case in this module, including the ones that never call the + `run_script` fixture, so an artifact nobody can name shows up as a failure here. + """ + temp_dir = Path(tempfile.gettempdir()) + before = set(temp_dir.glob(RAW_ARTIFACT_GLOB)) + + yield + + orphaned = set(temp_dir.glob(RAW_ARTIFACT_GLOB)) - before + assert not orphaned, f"raw transcripts left behind: {sorted(str(path) for path in orphaned)}" + + @pytest.fixture def run_script(): """Calls execute_script() and removes the output files it leaves behind.""" @@ -86,8 +108,10 @@ def _run(*args, **kwargs): yield _run for output_file in output_files: - with contextlib.suppress(OSError): - os.remove(output_file) + # The raw transcript is a derived sibling, so the returned path names both. + for path in (output_file, output_file + render_utils.RAW_OUTPUT_SUFFIX): + with contextlib.suppress(OSError): + os.remove(path) @posix_only @@ -101,6 +125,18 @@ def test_successful_script_returns_zero_with_its_output(tmp_path, run_script): assert os.path.isfile(output_file) +@posix_only +def test_the_raw_transcript_is_a_named_sibling_of_the_published_output(tmp_path, run_script): + """The unrendered bytes are findable from the returned path, so they can be cleaned up.""" + script = _make_shell_script(tmp_path, "coloured", 'printf "\\033[31mred\\033[0m\\n"\n') + + exit_code, _, output_file = run_script(script, [], SCRIPT_TYPE, timeout=30) + + raw_file = Path(output_file + render_utils.RAW_OUTPUT_SUFFIX) + assert exit_code == 0 + assert b"\033[31m" in raw_file.read_bytes() + + @posix_only @pytest.mark.parametrize("expected_exit_code", [1, 3, 69]) def test_failing_script_exit_code_is_returned_verbatim(tmp_path, run_script, expected_exit_code): @@ -328,23 +364,48 @@ def test_script_stdin_is_a_terminal_of_its_own_and_never_the_renderers(tmp_path, class _FakeTerminalProcess(TerminalProcess): - """A backend whose outcome is scripted, including failures discovered during teardown.""" + """A backend whose outcome is scripted, including failures discovered during teardown. + + Its reader is modelled rather than assumed: `reader_running` stays true until a + `close()` that actually joined it, so a case can leave a reader alive past the join + bound and the barrier has something real to hold. + """ - def __init__(self, exit_code=None, spawn_error=None, reader_fails_on_close=False, reply_failed=False): + def __init__( + self, + exit_code=None, + spawn_error=None, + poll_error=None, + reader_fails_while_running=False, + reader_fails_on_close=False, + reader_outlives_close=False, + teardown_error=None, + reply_failed=False, + ): self.reader_failed = threading.Event() self.reader_exc = None self.exit_code = exit_code self.spawn_error = spawn_error + self.poll_error = poll_error + self.reader_fails_while_running = reader_fails_while_running self.reader_fails_on_close = reader_fails_on_close + self.reader_outlives_close = reader_outlives_close + self.teardown_error = teardown_error self._reply_failed = reply_failed self.terminated = False self.closed = False + self.reader_running = True def spawn(self, command, cwd=None, env=None, terminal_size=(80, 24), stop_event=None, input_driver=None): if self.spawn_error is not None: raise self.spawn_error def poll(self): + if self.poll_error is not None: + raise self.poll_error + if self.reader_fails_while_running: # an independent failure, while the target runs + self.reader_exc = READER_FAILURE + self.reader_failed.set() return self.exit_code def read_output(self): @@ -371,9 +432,14 @@ def terminate_tree(self, grace=0.0): if self.reader_fails_on_close: # discovered while the grace period runs self.reader_exc = READER_FAILURE self.reader_failed.set() + if self.teardown_error is not None: + raise self.teardown_error def close(self): self.closed = True + if self.reader_outlives_close: + self._publish_reader_stall() # the join bound expired with the reader alive + self.reader_running = False @pytest.fixture @@ -392,13 +458,19 @@ def _install(**kwargs): RAISES_CANCELLED = "raises RenderCancelledError" +# Every case is decided within a single poll: a zero timeout makes the deadline already +# expired when it is first read, and the stop event is set before the call. Nothing waits +# on the clock, so the racing pairs below are as deterministic as the single conditions. ARBITER_CASES = [ # name, backend kwargs, stop_event set, timeout, expected exit code ("the deadline alone", {}, False, 0, render_utils.TIMEOUT_ERROR_EXIT_CODE), ("the deadline with a reader failure", {"reader_fails_on_close": True}, False, 0, ENVIRONMENT_ERROR_EXIT_CODE), + ("the deadline with an exit observed in the same poll", {"exit_code": 3}, False, 0, 124), ("a cancellation alone", {}, True, 30, RAISES_CANCELLED), ("a cancellation with a query failure", {"reply_failed": True}, True, 30, RAISES_CANCELLED), ("a cancellation with a reader failure", {"reader_fails_on_close": True}, True, 30, ENVIRONMENT_ERROR_EXIT_CODE), + ("a cancellation with the deadline expired", {}, True, 0, RAISES_CANCELLED), + ("a cancellation with an exit observed in the same poll", {"exit_code": 3}, True, 30, RAISES_CANCELLED), ("a nonzero exit alone", {"exit_code": 3}, False, 30, 3), ( "a nonzero exit with a query failure", @@ -444,7 +516,10 @@ def test_the_arbiter_ranks_every_condition_that_can_race( exit_code, _, _ = run_script(FAKE_SCRIPT, [], SCRIPT_TYPE, timeout=timeout, stop_event=stop_event) assert exit_code == expected - assert process.closed # teardown runs before publication on every path + # Teardown runs before publication on every path, and it joined the reader: nothing + # can append to the transcript or publish a failure after the outcome was decided. + assert process.closed + assert process.reader_running is False def test_a_reader_failure_during_teardown_names_the_reader(injected_backend, run_script): @@ -465,6 +540,60 @@ def test_an_undeliverable_reply_names_the_query_that_went_unanswered(injected_ba assert REPLY_DETAIL in issue +def test_a_reader_failure_while_the_target_runs_is_an_environment_error(injected_backend, run_script): + """An active reader that dies is infrastructure, not the exit status it coincides with.""" + injected_backend(exit_code=0, reader_fails_while_running=True) + + exit_code, issue, _ = run_script(FAKE_SCRIPT, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == ENVIRONMENT_ERROR_EXIT_CODE + assert "reader" in issue + + +def test_a_reader_that_outlives_the_join_bound_is_an_environment_error(injected_backend, run_script): + """close() cannot report a released backend while its reader is still running.""" + process = injected_backend(exit_code=0, reader_outlives_close=True) + + exit_code, issue, _ = run_script(FAKE_SCRIPT, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == ENVIRONMENT_ERROR_EXIT_CODE + assert READER_STALL_DETAIL in issue + assert process.reader_running is True # exactly the state the barrier has to catch + + +def test_a_backend_that_raises_something_unforeseen_on_spawn_still_returns_69(injected_backend, run_script): + """Thread.start() failing is a RuntimeError, and must not escape the tuple contract.""" + injected_backend(spawn_error=RuntimeError("can't start new thread")) + + exit_code, issue, output_file = run_script(FAKE_SCRIPT, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == ENVIRONMENT_ERROR_EXIT_CODE + assert "can't start new thread" in issue + assert os.path.isfile(output_file) + + +def test_a_backend_that_raises_while_polling_still_returns_69(injected_backend, run_script): + injected_backend(poll_error=OSError("the child could not be waited on")) + + exit_code, issue, _ = run_script(FAKE_SCRIPT, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == ENVIRONMENT_ERROR_EXIT_CODE + assert "the child could not be waited on" in issue + + +def test_a_teardown_failure_does_not_displace_the_launch_failure_it_followed(injected_backend, run_script): + injected_backend( + spawn_error=TerminalLaunchError("openpty failed"), + teardown_error=TerminalProcessError("the process group could not be signalled"), + ) + + exit_code, issue, _ = run_script(FAKE_SCRIPT, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == ENVIRONMENT_ERROR_EXIT_CODE + assert "could not be executed: openpty failed" in issue # the launch failure leads + assert issue.index("openpty failed") < issue.index("could not be signalled") + + def test_a_launch_failure_is_reported_on_the_environment_channel_and_never_as_127(injected_backend, run_script): injected_backend(spawn_error=TerminalLaunchError("the launcher hung before exec")) From 6b9120c4b9cae0d8dd2e47dc2f7bb48a292bcb95 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 00:20:30 +0200 Subject: [PATCH 19/83] Harden validation-suite process hygiene and coverage Add the missing case for terminating a process group that was stopped mid-run, and make the detached case launch its parent through the real nohup binary. Survivors are now registered by pidfile the moment a process starts and swept with bounded waits and escalation, so cleanup no longer depends on where a case failed. Pin the backend read size to one byte in the fragmented-stream case, and judge the renderer-death case on the signal that killed it and on a stable-quiet heartbeat window. --- .github/workflows/e2e.yml | 4 +- tests/test_terminal_validation.py | 216 +++++++++++++++++++++++++----- 2 files changed, 189 insertions(+), 31 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 76400ba1..bd02184c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -86,5 +86,7 @@ jobs: - name: Report the Node under test run: node --version # macos-latest ships Node preinstalled + # Under `nohup` with stdin from /dev/null: the detached shape a renderer is started + # in, where nothing upstream of the script has a terminal to lend it. - name: Run Node through the installed execute_script() - run: python tests/e2e/macos_node_smoke.py + run: nohup python tests/e2e/macos_node_smoke.py < /dev/null diff --git a/tests/test_terminal_validation.py b/tests/test_terminal_validation.py index c499d185..38c6f291 100644 --- a/tests/test_terminal_validation.py +++ b/tests/test_terminal_validation.py @@ -54,13 +54,18 @@ NODE = shutil.which("node") GIT = shutil.which("git") +NOHUP = shutil.which("nohup") needs_node = pytest.mark.skipif(NODE is None, reason="node is not installed on this machine.") needs_git = pytest.mark.skipif(GIT is None, reason="git is not installed on this machine.") +needs_nohup = pytest.mark.skipif(NOHUP is None, reason="nohup is not installed on this machine.") # Every wait below is bounded. The budgets are generous relative to the work they cover, # so a failure means something hung rather than that the machine was busy. SETTLE_SECONDS = 5.0 -LIVENESS_WINDOW_SECONDS = 1.0 +# A heartbeat lands every 50ms, so both windows are orders of magnitude above the beat +# interval: a process starved by a busy runner must not read as a dead one. +LIVENESS_WINDOW_SECONDS = 3.0 +QUIET_WINDOW_SECONDS = 3.0 DETACHED_TIMEOUT_SECONDS = 60.0 _make_shell_script = characterization._make_shell_script @@ -251,7 +256,18 @@ def test_canonical_cbreak_and_raw_modes_are_all_reachable(tmp_path, run_script): """ -def test_partial_utf8_and_split_escape_sequences_survive_the_stream(tmp_path, run_script): +def test_partial_utf8_and_split_escape_sequences_survive_the_stream(tmp_path, run_script, monkeypatch): + """The child writes one byte at a time and the backend reads one byte at a time. + + The child's writes alone do not guarantee fragmentation — the terminal is free to + hand a whole line to a single read — so the read size is pinned to a byte through the + backend's own read seam. Everything else is the real `execute_script()` path. + """ + monkeypatch.delenv(NO_PTY_ENV_VAR, raising=False) # the seam below belongs to the PTY backend + monkeypatch.setattr( + "render_machine._posix_pty.PosixPtyProcess._read_master", + lambda self, fd, size: os.read(fd, 1), + ) script = _make_python_script(tmp_path, "fragmented", FRAGMENTED_OUTPUT_PROGRAM) exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) @@ -283,6 +299,63 @@ def test_a_stop_event_set_mid_run_cancels_the_script(tmp_path): assert time.monotonic() - started < 20 +STOPPED_TARGET_PROGRAM = """ +import os +import signal +import sys +import time + +pgid_path, marker_path = sys.argv[1], sys.argv[2] + +def on_term(signum, frame): + with open(marker_path, "w") as marker: + marker.write("caught SIGTERM") + os._exit(0) + +signal.signal(signal.SIGTERM, on_term) +# Renamed into place, so a reader never sees a half-written group id. +with open(pgid_path + ".partial", "w") as pgid_file: + pgid_file.write(str(os.getpgrp())) +os.rename(pgid_path + ".partial", pgid_path) +time.sleep(60) +""" + + +def _stop_group_when_reported(pgid_path, stopped): + """SIGSTOPs the target's group as soon as the target has reported it.""" + if not _wait_until(pgid_path.exists, SETTLE_SECONDS): + return + pgid = int(pgid_path.read_text()) + os.killpg(pgid, signal.SIGSTOP) + stopped.append(pgid) + + +def test_a_stopped_process_group_is_continued_before_it_is_terminated(tmp_path, run_script): + """Termination has to reach a group that was stopped while it ran. + + A stopped process cannot run a handler, so the `SIGCONT` that accompanies `SIGTERM` + is what lets the target act on it at all. The marker the handler writes tells that + apart from the target merely being SIGKILLed at the end of the grace period: drop the + `SIGCONT` and the marker never appears. + """ + pgid_path = tmp_path / "target.pgid" + marker = tmp_path / "caught.term" + script = _make_python_script(tmp_path, "stopped_target", STOPPED_TARGET_PROGRAM) + stopped = [] + stopper = threading.Thread(target=_stop_group_when_reported, args=(pgid_path, stopped), daemon=True) + stopper.start() + + started = time.monotonic() + exit_code, _, _ = run_script(script, [str(pgid_path), str(marker)], SCRIPT_TYPE, timeout=3) + elapsed = time.monotonic() - started + stopper.join(timeout=SETTLE_SECONDS) + + assert stopped, "the target never reported the process group to stop" + assert exit_code == render_utils.TIMEOUT_ERROR_EXIT_CODE + assert elapsed < 30 # termination completed rather than hanging on a stopped target + assert marker.exists(), "the stopped target was never continued, so its SIGTERM handler never ran" + + def test_repeated_executions_leak_no_descriptors_and_no_threads(tmp_path, run_script): script = _make_shell_script(tmp_path, "quick", 'echo "done"\n') run_script(script, [], SCRIPT_TYPE, timeout=30) # first run pays the import costs @@ -313,13 +386,17 @@ def test_repeated_executions_leak_no_descriptors_and_no_threads(tmp_path, run_sc import sys import time -beats_path, mode = sys.argv[1], sys.argv[2] +directory, mode = sys.argv[1], sys.argv[2] pid = os.fork() if pid == 0: if "own-group" in mode: os.setpgid(0, 0) if "ignore-hup" in mode: signal.signal(signal.SIGHUP, signal.SIG_IGN) + # Written before the first beat, so the sweep reaches this process however the case ends. + with open(os.path.join(directory, "descendant.pid"), "w") as pid_file: + pid_file.write(str(os.getpid())) + beats_path = os.path.join(directory, "descendant.beats") deadline = time.monotonic() + 60 while time.monotonic() < deadline: with open(beats_path, "a") as beats: @@ -334,16 +411,72 @@ def test_repeated_executions_leak_no_descriptors_and_no_threads(tmp_path, run_sc """ +def _alive(pid): + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +def _signal_quietly(pid, sig): + try: + os.kill(pid, sig) + except OSError: # already gone + pass + + +class _Survivors: + """Cleanup for the processes these cases deliberately leave running. + + Registration is by pidfile: a process records itself the moment it starts, before it + does any work, so the sweep reaches it no matter where the case failed. The 60-second + self-expiry every one of them carries is a backstop, not the mechanism. + """ + + def __init__(self, directory): + self.directory = directory + self.directory.mkdir() + self._registered = [] + + def register(self, pid): + """Records a process the harness started itself, which writes no pidfile.""" + self._registered.append(pid) + + def pid(self, name): + return int((self.directory / f"{name}.pid").read_text()) + + def pids(self): + found = list(self._registered) + for pidfile in self.directory.glob("*.pid"): + try: + found.append(int(pidfile.read_text())) + except (OSError, ValueError): # read while it was being written + pass + return list(dict.fromkeys(found)) + + def sweep(self): + """Signals every recorded process and waits, bounded, until none is left. + + Parents are swept alongside their children, so a killed child that is briefly a + zombie is reaped once its parent goes too. + """ + pids = self.pids() + for sig in (signal.SIGTERM, signal.SIGKILL): + for pid in pids: + _signal_quietly(pid, sig) + if _wait_until(lambda: not any(_alive(pid) for pid in pids), SETTLE_SECONDS): + return + lingering = [pid for pid in pids if _alive(pid)] + assert not lingering, f"processes outlived the sweep: {lingering}" + + @pytest.fixture -def descendants(): - """Kills whatever a case deliberately left running outside the process tree.""" - survivors = [] - yield survivors - for pid in survivors: - try: - os.kill(pid, signal.SIGKILL) - except OSError: - pass +def survivors(tmp_path): + """Sweeps whatever a case deliberately left running outside the process tree.""" + sweeper = _Survivors(tmp_path / "survivors") + yield sweeper + sweeper.sweep() def _descendant_pid(output): @@ -365,27 +498,43 @@ def _still_beating(path): return _wait_until(lambda: _beats(path) > before, LIVENESS_WINDOW_SECONDS) -def test_a_descendant_that_leaves_the_process_group_survives_termination(tmp_path, run_script, descendants): - beats = tmp_path / "own_group.beats" +def _went_quiet(path): + """True once the heartbeat stops growing and stays unchanged for a whole window. + + A single silent second is not enough: a process the runner has starved of CPU would + look dead. Only a full window without a beat counts, and the wait for one is bounded. + """ + deadline = time.monotonic() + SETTLE_SECONDS + QUIET_WINDOW_SECONDS + while time.monotonic() < deadline: + before = _beats(path) + if not _wait_until(lambda: _beats(path) > before, QUIET_WINDOW_SECONDS): + return True + return False + + +def test_a_descendant_that_leaves_the_process_group_survives_termination(tmp_path, run_script, survivors): + beats = survivors.directory / "descendant.beats" script = _make_python_script(tmp_path, "escapes_group", DESCENDANT_PROGRAM) - exit_code, output, _ = run_script(script, [str(beats), "own-group"], SCRIPT_TYPE, timeout=2) + exit_code, output, _ = run_script(script, [str(survivors.directory), "own-group"], SCRIPT_TYPE, timeout=2) assert exit_code == render_utils.TIMEOUT_ERROR_EXIT_CODE - descendants.append(_descendant_pid(output)) assert _still_beating(beats), "the documented escape stopped working: the descendant was reached after all" + assert _descendant_pid(output) == survivors.pid("descendant") -def test_a_sighup_ignoring_descendant_survives_a_leader_reaped_before_teardown(tmp_path, run_script, descendants): +def test_a_sighup_ignoring_descendant_survives_a_leader_reaped_before_teardown(tmp_path, run_script, survivors): """Once `poll()` has reaped the leader the pgid may be recycled, so nothing is signalled.""" - beats = tmp_path / "same_group.beats" + beats = survivors.directory / "descendant.beats" script = _make_python_script(tmp_path, "leader_exits", DESCENDANT_PROGRAM) - exit_code, output, _ = run_script(script, [str(beats), "ignore-hup,leader-exits"], SCRIPT_TYPE, timeout=30) + exit_code, output, _ = run_script( + script, [str(survivors.directory), "ignore-hup,leader-exits"], SCRIPT_TYPE, timeout=30 + ) assert exit_code == 0 - descendants.append(_descendant_pid(output)) assert _still_beating(beats) + assert _descendant_pid(output) == survivors.pid("descendant") HANGUP_SCRIPT_PROGRAM = """ @@ -395,6 +544,10 @@ def test_a_sighup_ignoring_descendant_survives_a_leader_reaped_before_teardown(t import time directory = sys.argv[1] +# The parent is recorded too: sweeping it alongside its children is what lets a killed +# child be reaped instead of lingering as a zombie. +with open(os.path.join(directory, "parent.pid"), "w") as pid_file: + pid_file.write(str(os.getpid())) for name, ignores_hup in (("default", False), ("ignoring", True)): if os.fork() == 0: if ignores_hup: @@ -411,7 +564,7 @@ def test_a_sighup_ignoring_descendant_survives_a_leader_reaped_before_teardown(t """ -def test_a_dead_renderer_hangs_up_the_terminal_but_cannot_contain_the_tree(tmp_path, descendants): +def test_a_dead_renderer_hangs_up_the_terminal_but_cannot_contain_the_tree(tmp_path, survivors): """Best-effort, and deliberately asserted as such. When Codeplain itself dies the master closes, the slave hangs up, and the foreground @@ -421,25 +574,26 @@ def test_a_dead_renderer_hangs_up_the_terminal_but_cannot_contain_the_tree(tmp_p """ script = _make_python_script(tmp_path, "hangup_targets", HANGUP_SCRIPT_PROGRAM) runner = subprocess.Popen( - [sys.executable, "-c", _renderer_program(script, [str(tmp_path)])], + [sys.executable, "-c", _renderer_program(script, [str(survivors.directory)])], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=str(tmp_path), start_new_session=True, ) - default_beats, ignoring_beats = tmp_path / "default.beats", tmp_path / "ignoring.beats" + survivors.register(runner.pid) + default_beats = survivors.directory / "default.beats" + ignoring_beats = survivors.directory / "ignoring.beats" try: assert _wait_until(lambda: _beats(default_beats) and _beats(ignoring_beats), 30.0), "descendants never started" - for name in ("default", "ignoring"): - descendants.append(int((tmp_path / f"{name}.pid").read_text())) runner.kill() # the renderer dies without ever running its teardown - runner.wait(timeout=SETTLE_SECONDS) + assert runner.wait(timeout=SETTLE_SECONDS) == -signal.SIGKILL finally: if runner.poll() is None: # pragma: no cover - only if the kill above never landed runner.kill() + runner.wait(timeout=SETTLE_SECONDS) - assert _wait_until(lambda: not _still_beating(default_beats), SETTLE_SECONDS) + assert _went_quiet(default_beats), "a default-disposition descendant is expected to die of the hangup" assert _still_beating(ignoring_beats), "a SIGHUP-ignoring descendant is expected to survive the hangup" @@ -676,8 +830,9 @@ def test_a_git_operation_needing_credentials_fails_instead_of_blocking_on_dev_tt # --- Detached --------------------------------------------------------------------- +@needs_nohup def test_a_detached_renderer_still_gives_the_script_a_terminal(tmp_path): - """A `nohup`-style parent: its own session, no terminal anywhere, stdio redirected. + """The real `nohup` binary: its own session, no terminal anywhere, stdio redirected. This is the `execute_script()` half of the detached case. The full `--headless` render under `nohup` needs the live API, so it belongs to the e2e job rather than here. @@ -690,8 +845,8 @@ def test_a_detached_renderer_still_gives_the_script_a_terminal(tmp_path): with open(log_path, "w") as log_file: runner = subprocess.Popen( - [sys.executable, "-c", _renderer_program(script, [], str(result_path))], - stdin=subprocess.DEVNULL, + [str(NOHUP), sys.executable, "-c", _renderer_program(script, [], str(result_path))], + stdin=subprocess.DEVNULL, # `< /dev/null`, as a detached invocation is written stdout=log_file, stderr=subprocess.STDOUT, cwd=str(tmp_path), @@ -703,6 +858,7 @@ def test_a_detached_renderer_still_gives_the_script_a_terminal(tmp_path): finally: if runner.poll() is None: # pragma: no cover - only if the detached run hung runner.kill() + runner.wait(timeout=SETTLE_SECONDS) result = json.loads(result_path.read_text()) assert result["exit_code"] == 0 From 9d4545a83aeb12ee681fa5d7179643eca0e83c70 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 00:30:27 +0200 Subject: [PATCH 20/83] Keep console log output verbatim under color terminals The repr highlighter interleaved ANSI codes inside logged text on color-capable terminals, breaking the exactly-as-logged contract that markup=False already promised. Disable highlighting on the same path. --- plain2code_console.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plain2code_console.py b/plain2code_console.py index e5047ac7..a2c40a91 100644 --- a/plain2code_console.py +++ b/plain2code_console.py @@ -67,8 +67,10 @@ def _log_and_print(self, level, base_style, args, color, kwargs): logger.log(level, " ".join(map(str, args)), extra={"log_color": color}) style = base_style + Style(color=color) if color else base_style # Log messages must render exactly as logged: don't interpret square brackets - # in interpolated content (error texts, file names) as Rich markup. + # in interpolated content (error texts, file names) as Rich markup, and don't + # let the repr highlighter restyle brackets and numbers inside them. kwargs.setdefault("markup", False) + kwargs.setdefault("highlight", False) super().print(*args, **kwargs, style=style) def print_list(self, items, style=None): From f6c9571b2bc1466778ef5fcbab663d09b9c967a9 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 00:43:26 +0200 Subject: [PATCH 21/83] Detach Windows children from the renderer console Redirecting stdin to DEVNULL does not stop a Windows child from opening CONIN$ on the console it inherited, so children are now created with CREATE_NO_WINDOW and get a console of their own. The legacy backend also honours a stop event that is already set before it launches anything. --- render_machine/_legacy_pipe.py | 20 +++++ tests/test_legacy_pipe_windows.py | 127 ++++++++++++++++++++++++++++++ tests/test_no_pty_escape_hatch.py | 4 +- tests/test_render_utils.py | 29 ++++++- 4 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 tests/test_legacy_pipe_windows.py diff --git a/render_machine/_legacy_pipe.py b/render_machine/_legacy_pipe.py index 1c209bfb..aeedcaaa 100644 --- a/render_machine/_legacy_pipe.py +++ b/render_machine/_legacy_pipe.py @@ -11,6 +11,12 @@ stopping it — so the child would consume the user's keystrokes. Closing that hole is the one thing this backend may never give back. +On Windows the same hole needs a second lock, because redirecting the standard input +handle does not stop a child from opening `CONIN$`: that opens the console input buffer of +whatever console the child is attached to, which by default is the renderer's own. Only +detaching the child from that console closes it, so every Windows child is created with +`CREATE_NO_WINDOW`. + There is no input channel, so `write_input()` accepts nothing and a query the target prints is rendered without a reply: a backend that cannot answer must not register an obligation it can only fail. @@ -25,6 +31,7 @@ from typing import List, Optional, Sequence, Tuple from plain2code_console import console +from plain2code_exceptions import RenderCancelledError from render_machine.output_normalizer import OutputNormalizer from render_machine.terminal_process import ( DRAIN_DEADLINE_SECONDS, @@ -51,6 +58,14 @@ # that inherited the write end keeps the pipe open past the leader's exit. CLOSE_JOIN_SECONDS = 1.0 +# Windows gives a child the parent's console unless told otherwise, and a child on that +# console can read the renderer's keystrokes through CONIN$ regardless of where its +# standard input handle points. CREATE_NO_WINDOW gives it a console of its own instead. +if sys.platform == "win32": + CREATION_FLAGS = subprocess.CREATE_NO_WINDOW +else: + CREATION_FLAGS = 0 + class LegacyPipeProcess(TerminalProcess): """One command, one pipe carrying its merged stdout and stderr, one reader thread.""" @@ -90,6 +105,10 @@ def spawn( if self._spawned: raise RuntimeError("LegacyPipeProcess instances are single-use") self._spawned = True + if stop_event is not None and stop_event.is_set(): + # A cancellation already observed must not start the target: the script would + # run its side effects before the wait loop could notice. + raise RenderCancelledError() columns, rows = terminal_size self.normalizer.resize(columns, rows) try: @@ -101,6 +120,7 @@ def spawn( cwd=cwd, env=self._child_env(env), start_new_session=(sys.platform != "win32"), + creationflags=CREATION_FLAGS, ) except OSError as exc: raise TerminalLaunchError(f"Could not start the script: {exc}") from exc diff --git a/tests/test_legacy_pipe_windows.py b/tests/test_legacy_pipe_windows.py new file mode 100644 index 00000000..b51fe408 --- /dev/null +++ b/tests/test_legacy_pipe_windows.py @@ -0,0 +1,127 @@ +"""Console detachment on native Windows, for the legacy pipe backend. + +Windows is the one platform where `stdin=DEVNULL` is not enough. A child attached to the +renderer's console can open `CONIN$` and read the console input buffer directly, whatever +its standard input handle points at, so the backend detaches every Windows child from that +console instead. What is asserted here is the detachment itself: the renderer's pid must +not appear in the child's console process list. + +The control case runs the same probe with the same redirections and no creation flags, so +a green assertion above cannot be explained by the redirection alone. It is skipped when +the test process has no console of its own — a CI runner without one has nothing for a +child to inherit, which leaves the guard true for a reason this module cannot claim credit +for. +""" + +import ctypes +import json +import os +import subprocess +import sys +import textwrap +import time +from pathlib import Path +from typing import List + +import pytest + +from render_machine._legacy_pipe import LegacyPipeProcess + +pytestmark = pytest.mark.skipif( + sys.platform != "win32", + reason="Console attachment is a Windows notion, and so is the creation flag under test.", +) + +PROBE_TIMEOUT_SECONDS = 30.0 +POLL_INTERVAL_SECONDS = 0.02 + +# Enough for any console a test child can find itself on; a longer list is truncated +# rather than trusted, because the API reports the required length instead of filling. +MAX_CONSOLE_PIDS = 64 + +# Reports which processes share the console this program is attached to. +CONSOLE_PROBE_PROGRAM = f""" +import ctypes +import json +import os +import sys + +kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) +buffer = (ctypes.c_uint * {MAX_CONSOLE_PIDS})() +count = min(kernel32.GetConsoleProcessList(buffer, {MAX_CONSOLE_PIDS}), {MAX_CONSOLE_PIDS}) +report = {{"pid": os.getpid(), "console_pids": list(buffer[:count])}} +sys.stdout.write(json.dumps(report)) +sys.stdout.flush() +""" + + +def console_pids() -> List[int]: + """The pids attached to this process's console, or an empty list when it has none.""" + if sys.platform != "win32": # unreachable: the module is skipped everywhere else + return [] + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + buffer = (ctypes.c_uint * MAX_CONSOLE_PIDS)() + count = min(kernel32.GetConsoleProcessList(buffer, MAX_CONSOLE_PIDS), MAX_CONSOLE_PIDS) + return list(buffer[:count]) + + +@pytest.fixture +def probe_script(tmp_path: Path) -> str: + script_path = tmp_path / "console_probe.py" + script_path.write_text(textwrap.dedent(CONSOLE_PROBE_PROGRAM)) + return str(script_path) + + +@pytest.fixture +def backend(): + process = LegacyPipeProcess() + try: + yield process + finally: + process.terminate_tree(grace=0.1) + process.close() + + +def run_probe(process: LegacyPipeProcess, script_path: str) -> dict: + """Runs the probe on the backend and returns the report it printed.""" + process.spawn([sys.executable, script_path]) + deadline = time.monotonic() + PROBE_TIMEOUT_SECONDS + while time.monotonic() < deadline: + if process.poll() is not None: + break + time.sleep(POLL_INTERVAL_SECONDS) + else: + raise AssertionError(f"the console probe did not exit within {PROBE_TIMEOUT_SECONDS}s") + process.close() + return json.loads(process.read_output().strip()) + + +def test_the_child_is_not_attached_to_the_renderers_console(backend, probe_script): + report = run_probe(backend, probe_script) + + assert os.getpid() not in report["console_pids"] + # Whatever console the child ended up with is its own, so the probe read a real list + # rather than reporting an empty one because the call failed. + assert report["console_pids"] in ([], [report["pid"]]) + + +def test_the_control_case_proves_the_redirection_alone_does_not_detach(probe_script): + """The same spawn shape minus the creation flags: this child does share the console.""" + if not console_pids(): + pytest.skip("this process has no console, so there is none for a child to inherit") + + process = subprocess.Popen( + [sys.executable, probe_script], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + output, _ = process.communicate(timeout=PROBE_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + process.communicate(timeout=PROBE_TIMEOUT_SECONDS) + pytest.fail("the control probe never reported its console") + + assert os.getpid() in json.loads(output.strip())["console_pids"] diff --git a/tests/test_no_pty_escape_hatch.py b/tests/test_no_pty_escape_hatch.py index 9544ee9f..0e263a04 100644 --- a/tests/test_no_pty_escape_hatch.py +++ b/tests/test_no_pty_escape_hatch.py @@ -70,7 +70,9 @@ def hatch_warnings(monkeypatch): test_script_exceeding_the_timeout_returns_124_and_keeps_partial_output = ( characterization.test_script_exceeding_the_timeout_returns_124_and_keeps_partial_output ) -test_set_stop_event_cancels_the_script = characterization.test_set_stop_event_cancels_the_script +test_a_set_stop_event_cancels_the_script_without_ever_launching_it = ( + characterization.test_a_set_stop_event_cancels_the_script_without_ever_launching_it +) test_script_without_a_path_is_resolved_against_the_working_directory = ( characterization.test_script_without_a_path_is_resolved_against_the_working_directory ) diff --git a/tests/test_render_utils.py b/tests/test_render_utils.py index 10665136..df3f6965 100644 --- a/tests/test_render_utils.py +++ b/tests/test_render_utils.py @@ -208,13 +208,36 @@ def test_script_exceeding_the_timeout_returns_124_and_keeps_partial_output(tmp_p @posix_only -def test_set_stop_event_cancels_the_script(tmp_path): - script = _make_shell_script(tmp_path, "cancellable", "sleep 30\n") +def test_a_set_stop_event_cancels_the_script_without_ever_launching_it(tmp_path, monkeypatch): + """Cancellation is not a race the target gets to win: it never starts. + + A backend that launches first and notices the event afterwards has already let the + script run whatever side effects it opens with. Whether the sentinel survives that + depends on which of the two wins the microseconds, so the launch itself is what is + asserted: both backends reach the target through `subprocess.Popen`, so a call that + never happens is the proof, and the sentinel is the visible consequence. + """ + sentinel = tmp_path / "the-target-ran" + script = _make_shell_script(tmp_path, "cancellable", f'touch "{sentinel}"\nsleep 30\n') + launched = [] + + def refuse_to_launch(*args, **kwargs): + launched.append(args[0] if args else kwargs.get("args")) + raise AssertionError("the target was launched after cancellation had already been observed") + + monkeypatch.setattr(subprocess, "Popen", refuse_to_launch) stop_event = threading.Event() stop_event.set() + cancelled = False - with pytest.raises(RenderCancelledError): + try: render_utils.execute_script(script, [], SCRIPT_TYPE, timeout=30, stop_event=stop_event) + except RenderCancelledError: + cancelled = True + + assert not launched + assert not sentinel.exists() + assert cancelled @posix_only From 681cc9ab74456df8f170bf6ef371e657ab94b688 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 00:47:05 +0200 Subject: [PATCH 22/83] Wait for script teardown before TUI shutdown completes The wrapper joined the render thread for 0.7 seconds, less than the SIGTERM grace a script teardown runs to its end, so the CLI could exit mid-escalation and leave a descendant alive. The wait is now the sum of the teardown budgets the backend can spend, and a thread still running past it is reported. --- plain2code.py | 43 +++++++++++++++++++++-- tests/test_plain2code.py | 75 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/plain2code.py b/plain2code.py index ef60ddf4..509a0972 100644 --- a/plain2code.py +++ b/plain2code.py @@ -51,12 +51,30 @@ ) from plain2code_state import RunState from plain2code_telemetry import capture_crash, initialize_telemetry +from render_machine.terminal_process import ( + DRAIN_DEADLINE_SECONDS, + REAP_DEADLINE_SECONDS, + SIGTERM_GRACE_PERIOD_SECONDS, +) from system_config import system_config from tui.plain2code_tui import Plain2CodeTUI from tui.plain_module_render_choice_tui import PlainModuleRenderChoiceTUI DEFAULT_TEMPLATE_DIRS = "standard_template_library" -RENDER_THREAD_SHUTDOWN_TIMEOUT = 0.7 + +# The render thread is cancelled, never killed, so the wait after cancellation has to +# outlast the teardown a script execution is entitled to: the SIGTERM grace runs to its +# end before the SIGKILL, the killed group is then reaped, and the output reader is joined +# on its own drain and reap budgets. A shorter wait lets the CLI exit mid-escalation and +# leave a descendant that ignores TERM alive, so the bound is those budgets in sequence. +RENDER_THREAD_UNWIND_MARGIN_SECONDS = 1.0 +RENDER_THREAD_SHUTDOWN_TIMEOUT = ( + SIGTERM_GRACE_PERIOD_SECONDS + + REAP_DEADLINE_SECONDS + + DRAIN_DEADLINE_SECONDS + + REAP_DEADLINE_SECONDS + + RENDER_THREAD_UNWIND_MARGIN_SECONDS +) # Exceptions that represent expected, user-facing error conditions. They are # reported to the user directly and must never be sent to Sentry as crashes. @@ -188,6 +206,26 @@ def warn_if_acceptance_tests_without_conformance_script(plain_module, args) -> N ) +def shutdown_render_thread(render_thread: threading.Thread, stop_event: threading.Event) -> bool: + """Cancels the render and waits for the thread to finish tearing its script down. + + Returns True when the thread completed within the bound. A thread still running past + it is reported rather than waited on further: the wait covers every budget the backend + can spend, so anything beyond it is unbounded and the process must not hang on it. + """ + stop_event.set() + if render_thread.is_alive(): + console.info("Stopping the render. Waiting for the running script to shut down...") + render_thread.join(timeout=RENDER_THREAD_SHUTDOWN_TIMEOUT) + if render_thread.is_alive(): + console.warning( + f"The render did not stop within {RENDER_THREAD_SHUTDOWN_TIMEOUT:.0f} seconds. " + "A script it started may still be running." + ) + return False + return True + + def render( # noqa: C901 plain_module: plain_modules.PlainModule, args, @@ -306,8 +344,7 @@ def run_render(): ) app.run() - stop_event.set() - render_thread.join(timeout=RENDER_THREAD_SHUTDOWN_TIMEOUT) + shutdown_render_thread(render_thread, stop_event) if render_error: raise render_error[0] diff --git a/tests/test_plain2code.py b/tests/test_plain2code.py index 84c93975..9063254a 100644 --- a/tests/test_plain2code.py +++ b/tests/test_plain2code.py @@ -1,4 +1,6 @@ import tempfile +import threading +import time from argparse import Namespace from types import SimpleNamespace from unittest.mock import patch @@ -6,6 +8,7 @@ import plain2code import plain_spec from plain_modules import PlainModule +from render_machine import terminal_process def _make_module(module_name, has_acceptance_tests, required_modules=None): @@ -91,3 +94,75 @@ def test_warning_covers_required_modules_for_real_plain_module(get_test_data_pat mock_console.warning.assert_called_once() warning_message = mock_console.warning.call_args.args[0] assert "required_with_acceptance_tests" in warning_message + + +# --- The shutdown wait after the TUI closes -------------------------------------- +# +# The render runs on a daemon thread, so whatever it is still doing when the wrapper +# returns is abandoned. What it is usually still doing is tearing a script down on the +# backend's clock, which is why the wait has to outlast that clock rather than a fixed +# fraction of it. + +# What the wrapper waited before this was fixed — shorter than the SIGTERM grace a script +# teardown runs to its end, so the CLI could exit mid-escalation. +SUPERSEDED_SHUTDOWN_TIMEOUT = 0.7 +SLOW_TEARDOWN_SECONDS = SUPERSEDED_SHUTDOWN_TIMEOUT + 0.3 + +# The wedged thread is released by the test, not by the timeout it would otherwise sit on. +WEDGED_THREAD_TIMEOUT = 30.0 +WEDGE_SHUTDOWN_TIMEOUT = 0.3 +WEDGE_WAIT_CEILING = 5.0 + + +def test_the_shutdown_bound_covers_the_teardown_budgets_of_a_script(): + """The wait is derived from what a backend teardown may spend, not picked.""" + worst_case_teardown = ( + terminal_process.SIGTERM_GRACE_PERIOD_SECONDS + + terminal_process.REAP_DEADLINE_SECONDS + + terminal_process.DRAIN_DEADLINE_SECONDS + + terminal_process.REAP_DEADLINE_SECONDS + ) + + assert plain2code.RENDER_THREAD_SHUTDOWN_TIMEOUT > worst_case_teardown + assert plain2code.RENDER_THREAD_SHUTDOWN_TIMEOUT > SUPERSEDED_SHUTDOWN_TIMEOUT + + +def test_shutdown_waits_for_a_teardown_that_outlasts_the_superseded_bound(): + stop_event = threading.Event() + torn_down = threading.Event() + + def render_then_tear_down(): + stop_event.wait(timeout=WEDGED_THREAD_TIMEOUT) + time.sleep(SLOW_TEARDOWN_SECONDS) # the grace the backend runs to its end + torn_down.set() + + render_thread = threading.Thread(target=render_then_tear_down, daemon=True) + render_thread.start() + + with patch("plain2code.console"): + completed = plain2code.shutdown_render_thread(render_thread, stop_event) + + assert completed is True + assert torn_down.is_set() + assert not render_thread.is_alive() + + +def test_shutdown_stays_bounded_when_the_teardown_never_completes(monkeypatch): + """A teardown past the derived ceiling is reported, never waited on indefinitely.""" + monkeypatch.setattr(plain2code, "RENDER_THREAD_SHUTDOWN_TIMEOUT", WEDGE_SHUTDOWN_TIMEOUT) + release = threading.Event() + render_thread = threading.Thread(target=lambda: release.wait(WEDGED_THREAD_TIMEOUT), daemon=True) + render_thread.start() + + try: + started = time.monotonic() + with patch("plain2code.console") as mock_console: + completed = plain2code.shutdown_render_thread(render_thread, threading.Event()) + elapsed = time.monotonic() - started + + assert completed is False + assert elapsed < WEDGE_WAIT_CEILING + mock_console.warning.assert_called_once() + finally: + release.set() + render_thread.join(timeout=WEDGED_THREAD_TIMEOUT) From 02926c574c0c25586ab5a52447cce3bcf00c253c Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 00:49:09 +0200 Subject: [PATCH 23/83] Remove migration leftovers The characterization harness documents the topology execute_script() produced before this branch, not the one it produces now. available_backends() has no callers and only leaked internal backend names into the public surface. --- render_machine/terminal_process.py | 7 +------ tests/test_pty_characterization.py | 9 +++++---- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/render_machine/terminal_process.py b/render_machine/terminal_process.py index ede0cfc6..070c7e7d 100644 --- a/render_machine/terminal_process.py +++ b/render_machine/terminal_process.py @@ -11,7 +11,7 @@ import threading from dataclasses import dataclass from enum import Enum -from typing import List, Optional, Sequence, Tuple +from typing import Optional, Sequence, Tuple from plain2code_console import console from render_machine.terminal_queries import TerminalQueryResponder @@ -225,8 +225,3 @@ def create_terminal_process() -> TerminalProcess: from render_machine._posix_pty import PosixPtyProcess return PosixPtyProcess() - - -def available_backends() -> List[str]: - """Names the backends this build can construct. Used by diagnostics and tests.""" - return ["legacy-pipe"] if sys.platform == "win32" else ["posix-pty", "legacy-pipe"] diff --git a/tests/test_pty_characterization.py b/tests/test_pty_characterization.py index 14d3764d..480d2c2a 100644 --- a/tests/test_pty_characterization.py +++ b/tests/test_pty_characterization.py @@ -1,9 +1,10 @@ """Characterization of the stale controlling-TTY topology described in ADR-001. -The harness rebuilds the process topology that `execute_script()` produces today — -a child spawned with `start_new_session=True` whose fd 0 still points at a terminal -owned by the session the child has just left — without touching any production code. -It documents the defect; it never validates a fix. +The harness rebuilds the process topology `execute_script()` produced before this +branch — a child spawned with `start_new_session=True` whose fd 0 still points at a +terminal owned by the session the child has just left — without touching any +production code. It documents the defect the terminal backend was built to remove; it +never validates the fix. Three levels are needed. `os.openpty()` alone yields a terminal owned by no session, which is a weaker state than the one under study, so a middle process takes the slave From d4f802041de2ac1e85e3edbc5c68018f469985ec Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 01:10:57 +0200 Subject: [PATCH 24/83] Ignore .idea directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7893c1e3..feb8d066 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ dist .coverage logging_config.yaml +/.idea/ From 2ba7e881a28d188ae1c3efbb4ad7ed2a555cf73e Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 01:16:41 +0200 Subject: [PATCH 25/83] Fix test collection and harness teardown on CI runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The characterization harness is a session leader whose controlling terminal is the PTY it opens; Linux delivers SIGHUP when the master closes, killing it before the report is written. Ignore the hangup — it is teardown noise. test_terminal_process imported termios at module level, breaking collection on Windows; move it behind the existing platform guard. --- tests/test_pty_characterization.py | 6 ++++++ tests/test_terminal_process.py | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_pty_characterization.py b/tests/test_pty_characterization.py index 480d2c2a..fc4259b8 100644 --- a/tests/test_pty_characterization.py +++ b/tests/test_pty_characterization.py @@ -78,6 +78,7 @@ import fcntl import json import os +import signal import subprocess import sys import termios @@ -85,6 +86,11 @@ PROBE_TIMEOUT_SECONDS = 20 CLEANUP_TIMEOUT_SECONDS = 10 +# Closing the PTY master hangs up the terminal; Linux delivers SIGHUP to this +# process (session leader with the slave as controlling terminal) before the +# report is written. The hangup is teardown noise, not part of the topology. +signal.signal(signal.SIGHUP, signal.SIG_IGN) + def reap(process): process.kill() diff --git a/tests/test_terminal_process.py b/tests/test_terminal_process.py index d4446cc6..91817043 100644 --- a/tests/test_terminal_process.py +++ b/tests/test_terminal_process.py @@ -11,7 +11,6 @@ import signal import subprocess import sys -import termios import threading import time from contextlib import contextmanager @@ -24,6 +23,8 @@ pytestmark = posix_only if sys.platform != "win32": + import termios + from render_machine import pty_exec REPO_ROOT = Path(__file__).resolve().parent.parent From 1ba1196783fcaa98e418dd65d184ddd2674421f8 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 01:22:07 +0200 Subject: [PATCH 26/83] Stop collecting the POSIX backend suite on Windows Parametrize lists reference pty_exec at import time, so the skipif mark cannot save collection; skip the module before anything evaluates. --- tests/test_terminal_process.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_terminal_process.py b/tests/test_terminal_process.py index 91817043..d67d8ad9 100644 --- a/tests/test_terminal_process.py +++ b/tests/test_terminal_process.py @@ -18,9 +18,10 @@ import pytest -posix_only = pytest.mark.skipif(sys.platform == "win32", reason="The POSIX PTY backend is not built on Windows.") - -pytestmark = posix_only +if sys.platform == "win32": + # Parametrize lists below reference the POSIX-only modules at import time, so a + # skipif mark is not enough — collection itself must stop here. + pytest.skip("The POSIX PTY backend is not built on Windows.", allow_module_level=True) if sys.platform != "win32": import termios From 80dbe402aaa709d21a6c37192d898edf7103edef Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 01:32:13 +0200 Subject: [PATCH 27/83] Make the test suite green on Windows and deflake the drain bound The arbiter fixture name must end in .ps1 on Windows or execute_script rejects it before the injected backend is reached. The git-plumbing suites skip on native Windows: files land on disk with CRLF and GitPython keeps repository handles open, breaking diff assertions and temp-dir teardown. The drain-bound test now waits for the escapee's flood before closing, so the escapee is provably still writing while close() drains. --- tests/test_git_utils.py | 9 +++++++++ tests/test_plain_modules.py | 19 +++++++++++++++++++ tests/test_render_utils.py | 4 +++- tests/test_terminal_process.py | 9 +++++++-- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py index dac3675d..df4c4d33 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -1,4 +1,5 @@ import os +import sys import tempfile from pathlib import Path from textwrap import dedent @@ -19,6 +20,14 @@ ) from plain2code_exceptions import InvalidGitRepositoryError +pytestmark = pytest.mark.skipif( + sys.platform == "win32", + reason=( + "Text files land on disk with CRLF and GitPython keeps repository handles open, so diff " + "assertions and temp-dir teardown both break; native Windows runs via WSL." + ), +) + @pytest.fixture def temp_repo(): diff --git a/tests/test_plain_modules.py b/tests/test_plain_modules.py index 094a857e..81f5e433 100644 --- a/tests/test_plain_modules.py +++ b/tests/test_plain_modules.py @@ -7,6 +7,7 @@ import json import os +import sys import tempfile from pathlib import Path @@ -22,6 +23,12 @@ # -------------------------------------------------------------------------- +uses_git_repo = pytest.mark.skipif( + sys.platform == "win32", + reason="GitPython keeps repository handles open on Windows, so the temporary build folder cannot be removed; native Windows runs via WSL.", +) + + @pytest.fixture def fixtures_dir(get_test_data_path): return get_test_data_path("data/partial_rendering") @@ -247,6 +254,7 @@ def test_get_module_render_status_no_rendering(root_module): assert root_module.get_module_render_status() == (None, None) +@uses_git_repo def test_get_module_render_status_returns_from_leaf_when_only_leaf_rendered(root_module): leaf = root_module.get_required_module_by_name("pr_leaf") _init_build_repo_with_finished_frid(leaf, "1") @@ -256,6 +264,7 @@ def test_get_module_render_status_returns_from_leaf_when_only_leaf_rendered(root assert frid == "1" +@uses_git_repo def test_get_module_render_status_prefers_most_progressed_module(root_module): """The scan walks required_modules in reverse order — the right-most rendered module wins.""" @@ -269,6 +278,7 @@ def test_get_module_render_status_prefers_most_progressed_module(root_module): assert frid == "1" +@uses_git_repo def test_get_module_render_status_returns_root_when_root_has_checkpoint(root_module): """A checkpoint in the root's own build folder takes precedence over required-module checkpoints.""" @@ -290,11 +300,13 @@ def test_is_module_fully_rendered_false_when_nothing_rendered(solo_module): assert solo_module.is_module_fully_rendered() is False +@uses_git_repo def test_is_module_fully_rendered_false_when_only_first_frid_rendered(solo_module): _init_build_repo_with_finished_frid(solo_module, "1") assert solo_module.is_module_fully_rendered() is False +@uses_git_repo def test_is_module_fully_rendered_true_when_last_frid_rendered(solo_module): # solo module has FRIDs ["1", "2", "3"]; "3" is the last. _init_build_repo_with_finished_frid(solo_module, "3") @@ -425,6 +437,7 @@ def _commit_finished_frid(module: PlainModule, frid: str) -> None: ) +@uses_git_repo def test_revert_code_to_frid_reverts_repo_and_trims_metadata(solo_module): os.makedirs(solo_module.module_build_folder) init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) @@ -443,6 +456,7 @@ def test_revert_code_to_frid_reverts_repo_and_trims_metadata(solo_module): assert os.path.exists(solo_module.get_codeplain_folder()) +@uses_git_repo def test_revert_code_to_frid_none_reverts_to_initial_state(solo_module): os.makedirs(solo_module.module_build_folder) init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) @@ -460,6 +474,7 @@ def test_revert_code_to_frid_none_reverts_to_initial_state(solo_module): # -------------------------------------------------------------------------- +@uses_git_repo def test_reconcile_metadata_with_git_trims_metadata_ahead_of_git(solo_module): # Simulates the crash window: metadata records FR 2 but git only committed FR 1. os.makedirs(solo_module.module_build_folder) @@ -475,6 +490,7 @@ def test_reconcile_metadata_with_git_trims_metadata_ahead_of_git(solo_module): assert metadata["source_hash"] == "abc" +@uses_git_repo def test_reconcile_metadata_with_git_in_sync_is_noop(solo_module): os.makedirs(solo_module.module_build_folder) init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) @@ -487,6 +503,7 @@ def test_reconcile_metadata_with_git_in_sync_is_noop(solo_module): assert solo_module.load_module_metadata()["functionalities"] == ["fr1", "fr2"] +@uses_git_repo def test_reconcile_metadata_with_git_no_finished_frid_empties_list(solo_module): # Only the initial commit exists (no finished FRID), so the baseline must be emptied. os.makedirs(solo_module.module_build_folder) @@ -498,6 +515,7 @@ def test_reconcile_metadata_with_git_no_finished_frid_empties_list(solo_module): assert solo_module.load_module_metadata()["functionalities"] == [] +@uses_git_repo def test_reconcile_metadata_with_git_ignores_foreign_module_frid(solo_module): # A repo cloned from a required module carries that module's finished FRID; this # module has rendered none of its own, so its baseline must be emptied. @@ -518,6 +536,7 @@ def test_reconcile_metadata_with_git_ignores_foreign_module_frid(solo_module): assert solo_module.load_module_metadata()["functionalities"] == [] +@uses_git_repo def test_reconcile_metadata_with_git_no_metadata_is_noop(solo_module): os.makedirs(solo_module.module_build_folder) init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) diff --git a/tests/test_render_utils.py b/tests/test_render_utils.py index df3f6965..ec35a106 100644 --- a/tests/test_render_utils.py +++ b/tests/test_render_utils.py @@ -380,7 +380,9 @@ def test_script_stdin_is_a_terminal_of_its_own_and_never_the_renderers(tmp_path, # the cases are driven through an injected backend rather than through a real script: # the point is which condition wins, not how it arose. -FAKE_SCRIPT = "arbiter.sh" +# execute_script() accepts only .ps1 on Windows, and that check runs before the +# injected backend is reached, so the fake name has to match the platform. +FAKE_SCRIPT = "arbiter.ps1" if sys.platform == "win32" else "arbiter.sh" FAKE_OUTPUT = "fake transcript\n" READER_FAILURE = RuntimeError("the master descriptor went away") REPLY_DETAIL = "cursor-position reply discarded before delivery" diff --git a/tests/test_terminal_process.py b/tests/test_terminal_process.py index d67d8ad9..b17c61fe 100644 --- a/tests/test_terminal_process.py +++ b/tests/test_terminal_process.py @@ -1505,12 +1505,17 @@ def test_the_final_drain_is_bounded_against_a_continuously_writing_escapee(tmp_p try: process.spawn([script]) escapee_pid = reported_pid(process, "escapee") + # Both channels clear on read; accumulate so the assertions see the whole + # transcript. Waiting for the flood also guarantees the escapee is still + # writing while close() drains, which is the bound under test. + decoded = wait_for_output(process, "x") + raw = process.read_raw_output() process.terminate_tree(grace=0.1) # the escapee left the group and survives started = time.monotonic() process.close() elapsed = time.monotonic() - started - decoded = process.read_output() - raw = process.read_raw_output() + decoded += process.read_output() + raw += process.read_raw_output() finally: process.close() if escapee_pid is not None and not wait_until_gone(escapee_pid, timeout=0.5): From ee04eae2ee702f19e8973444a6579d4df9833e8a Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 01:41:45 +0200 Subject: [PATCH 28/83] Close GitPython repos instead of skipping tests on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Repo in git_utils leaked its persistent git cat-file children; they are only reaped by close(). On Windows a live child keeps the repository directory undeletable, which is what broke temp-dir teardown — and on every platform the children accumulate for the lifetime of a render. All repos now close before returning; a closed Repo re-acquires resources lazily, so the returned handles stay usable. Tests write files with explicit LF (text mode writes CRLF on Windows, which the diff assertions would see) and the fixtures collect test-held repos before the temp directory is removed. The win32 skips are gone. --- git_utils.py | 266 ++++++++++++++++++------------------ system_config.py | 9 +- tests/test_git_utils.py | 82 ++++++----- tests/test_plain_modules.py | 18 --- 4 files changed, 180 insertions(+), 195 deletions(-) diff --git a/git_utils.py b/git_utils.py index 53b5b331..ae447a89 100644 --- a/git_utils.py +++ b/git_utils.py @@ -43,21 +43,20 @@ def _get_full_commit_message(message, module_name, frid, render_id) -> str: def _ensure_git_config(repo: Repo) -> None: - config = repo.config_reader() - - try: - config.get_value("user", "name") - except (NoSectionError, NoOptionError): - # user.name not configured, set a default at repo level - with repo.config_writer(config_level="repository") as writer: - writer.set_value("user", "name", "Codeplain") - - try: - config.get_value("user", "email") - except (NoSectionError, NoOptionError): - # user.email not configured, set a default at repo level - with repo.config_writer(config_level="repository") as writer: - writer.set_value("user", "email", "codeplain@localhost") + with repo.config_reader() as config: + try: + config.get_value("user", "name") + except (NoSectionError, NoOptionError): + # user.name not configured, set a default at repo level + with repo.config_writer(config_level="repository") as writer: + writer.set_value("user", "name", "Codeplain") + + try: + config.get_value("user", "email") + except (NoSectionError, NoOptionError): + # user.email not configured, set a default at repo level + with repo.config_writer(config_level="repository") as writer: + writer.set_value("user", "email", "codeplain@localhost") def init_git_repo( @@ -75,12 +74,16 @@ def init_git_repo( else: os.makedirs(path_to_repo) - repo = Repo.init(path_to_repo) - _ensure_git_config(repo) + # Every function here closes its Repo before returning: GitPython's persistent + # `git cat-file` children are only reaped by close(), and on Windows a live child + # keeps the repository directory undeletable. A closed Repo stays usable — it + # re-acquires its resources lazily — so returning it is safe. + with Repo.init(path_to_repo) as repo: + _ensure_git_config(repo) - repo.git.commit( - "--allow-empty", "-m", _get_full_commit_message(INITIAL_COMMIT_MESSAGE, module_name, None, render_id) - ) + repo.git.commit( + "--allow-empty", "-m", _get_full_commit_message(INITIAL_COMMIT_MESSAGE, module_name, None, render_id) + ) return repo @@ -91,17 +94,18 @@ def clone_repo( module_name: Optional[str] = None, render_id: Optional[str] = None, ) -> Repo: - repo = Repo.clone_from(source_repo_path, new_repo_path) + with Repo.clone_from(source_repo_path, new_repo_path) as repo: + repo.git.commit( + "--allow-empty", "-m", _get_full_commit_message(INITIAL_COMMIT_MESSAGE, module_name, None, render_id) + ) - repo.git.commit( - "--allow-empty", "-m", _get_full_commit_message(INITIAL_COMMIT_MESSAGE, module_name, None, render_id) - ) + return repo def is_dirty(repo_path: Union[str, os.PathLike]) -> bool: """Checks if the repository is dirty.""" - repo = Repo(repo_path) - return repo.is_dirty(untracked_files=True) + with Repo(repo_path) as repo: + return repo.is_dirty(untracked_files=True) def add_all_files_and_commit( @@ -112,25 +116,25 @@ def add_all_files_and_commit( render_id: Optional[str] = None, ) -> Repo: """Adds all files to the git repository and commits them.""" - repo = Repo(repo_path) - repo.git.add(".") + with Repo(repo_path) as repo: + repo.git.add(".") - message = _get_full_commit_message(commit_message, module_name, frid, render_id) + message = _get_full_commit_message(commit_message, module_name, frid, render_id) - # Check if there are any changes to commit - if not repo.is_dirty(untracked_files=True): - repo.git.commit("--allow-empty", "-m", message) - else: - repo.git.commit("-m", message) + # Check if there are any changes to commit + if not repo.is_dirty(untracked_files=True): + repo.git.commit("--allow-empty", "-m", message) + else: + repo.git.commit("-m", message) return repo def revert_changes(repo_path: Union[str, os.PathLike]) -> Repo: """Reverts all changes made since the last commit.""" - repo = Repo(repo_path) - repo.git.reset("--hard") - repo.git.clean("-xdf") + with Repo(repo_path) as repo: + repo.git.reset("--hard") + repo.git.clean("-xdf") return repo @@ -144,15 +148,16 @@ def revert_to_commit_with_frid(repo_path: Union[str, os.PathLike], frid: Optiona It is expected that the repo has at least one commit related to provided frid if frid is not None. In case the frid related commit is not found, an exception is raised. """ - repo = Repo(repo_path) - - commit = _get_commit(repo, frid) + with Repo(repo_path) as repo: + commit = _get_commit(repo, frid) - if not commit: - raise InvalidGitRepositoryError("Git repository is in an invalid state. Relevant commit could not be found.") + if not commit: + raise InvalidGitRepositoryError( + "Git repository is in an invalid state. Relevant commit could not be found." + ) - repo.git.reset("--hard", commit) - repo.git.clean("-xdf") + repo.git.reset("--hard", commit) + repo.git.clean("-xdf") return repo @@ -166,14 +171,15 @@ def checkout_commit_with_frid(repo_path: Union[str, os.PathLike], frid: Optional It is expected that the repo has at least one commit related to provided frid if frid is not None. In case the frid related commit is not found, an exception is raised. """ - repo = Repo(repo_path) + with Repo(repo_path) as repo: + commit = _get_commit(repo, frid) - commit = _get_commit(repo, frid) - - if not commit: - raise InvalidGitRepositoryError("Git repository is in an invalid state. Relevant commit could not be found.") + if not commit: + raise InvalidGitRepositoryError( + "Git repository is in an invalid state. Relevant commit could not be found." + ) - repo.git.checkout(commit) + repo.git.checkout(commit) return repo @@ -187,8 +193,8 @@ def checkout_previous_branch(repo_path: Union[str, os.PathLike]) -> Repo: Returns: Repo: The git repository object """ - repo = Repo(repo_path) - repo.git.checkout("-") + with Repo(repo_path) as repo: + repo.git.checkout("-") return repo @@ -255,15 +261,14 @@ def diff(repo_path: Union[str, os.PathLike], previous_frid: str = None) -> dict: Returns: dict: Dictionary with file names as keys and their clean diff strings as values """ - repo = Repo(repo_path) - - commit = _get_commit(repo, previous_frid) + with Repo(repo_path) as repo: + commit = _get_commit(repo, previous_frid) - # Add all files to the index to get a clean diff - repo.git.add("-N", ".") + # Add all files to the index to get a clean diff + repo.git.add("-N", ".") - # Get the raw git diff output, excluding .pyc files - diff_output = repo.git.diff(commit, "--text", ":!*.pyc") + # Get the raw git diff output, excluding .pyc files + diff_output = repo.git.diff(commit, "--text", ":!*.pyc") if not diff_output: return {} @@ -322,7 +327,8 @@ def _get_commit_with_frid(repo: Repo, frid: str, module_name: Optional[str] = No def has_commit_for_frid(repo_path: Union[str, os.PathLike], frid: str, module_name: Optional[str] = None) -> bool: - return bool(_get_commit_with_frid(Repo(repo_path), frid, module_name)) + with Repo(repo_path) as repo: + return bool(_get_commit_with_frid(repo, frid, module_name)) def _get_base_folder_commit(repo: Repo) -> str: @@ -343,18 +349,17 @@ def _get_commit_with_message(repo: Repo, message: str) -> str: def get_implementation_code_diff(repo_path: Union[str, os.PathLike], frid: str, previous_frid: str) -> dict: - repo = Repo(repo_path) - - implementation_commit = _get_commit_with_message(repo, REFACTORED_CODE_COMMIT_MESSAGE.format(frid)) - if not implementation_commit: - implementation_commit = _get_commit_with_message( - repo, FUNCTIONAL_REQUIREMENT_IMPLEMENTED_COMMIT_MESSAGE.format(frid) - ) + with Repo(repo_path) as repo: + implementation_commit = _get_commit_with_message(repo, REFACTORED_CODE_COMMIT_MESSAGE.format(frid)) + if not implementation_commit: + implementation_commit = _get_commit_with_message( + repo, FUNCTIONAL_REQUIREMENT_IMPLEMENTED_COMMIT_MESSAGE.format(frid) + ) - previous_frid_commit = _get_commit(repo, previous_frid) + previous_frid_commit = _get_commit(repo, previous_frid) - # Get the raw git diff output, excluding .pyc files - diff_output = repo.git.diff(previous_frid_commit, implementation_commit, "--text", ":!*.pyc") + # Get the raw git diff output, excluding .pyc files + diff_output = repo.git.diff(previous_frid_commit, implementation_commit, "--text", ":!*.pyc") if not diff_output: return {} @@ -363,22 +368,21 @@ def get_implementation_code_diff(repo_path: Union[str, os.PathLike], frid: str, def get_fixed_implementation_code_diff(repo_path: Union[str, os.PathLike], frid: str) -> dict: - repo = Repo(repo_path) + with Repo(repo_path) as repo: + implementation_commit = _get_commit_with_message(repo, REFACTORED_CODE_COMMIT_MESSAGE.format(frid)) + if not implementation_commit: + implementation_commit = _get_commit_with_message( + repo, FUNCTIONAL_REQUIREMENT_IMPLEMENTED_COMMIT_MESSAGE.format(frid) + ) - implementation_commit = _get_commit_with_message(repo, REFACTORED_CODE_COMMIT_MESSAGE.format(frid)) - if not implementation_commit: - implementation_commit = _get_commit_with_message( - repo, FUNCTIONAL_REQUIREMENT_IMPLEMENTED_COMMIT_MESSAGE.format(frid) + conformance_tests_passed_commit = _get_commit_with_message( + repo, CONFORMANCE_TESTS_PASSED_COMMIT_MESSAGE.format(frid) ) + if not conformance_tests_passed_commit: + return None - conformance_tests_passed_commit = _get_commit_with_message( - repo, CONFORMANCE_TESTS_PASSED_COMMIT_MESSAGE.format(frid) - ) - if not conformance_tests_passed_commit: - return None - - # Get the raw git diff output, excluding .pyc files - diff_output = repo.git.diff(implementation_commit, conformance_tests_passed_commit, "--text", ":!*.pyc") + # Get the raw git diff output, excluding .pyc files + diff_output = repo.git.diff(implementation_commit, conformance_tests_passed_commit, "--text", ":!*.pyc") if not diff_output: return {} @@ -396,31 +400,30 @@ def get_repo_info(repo_path: Union[str, os.PathLike]) -> dict: - is_dirty: boolean (includes untracked files) - remotes: dict mapping remote name to list of URLs """ - repo = Repo(repo_path) - - info = {"path": os.path.abspath(repo_path)} - - # Active branch (handle detached HEAD safely) - try: - if getattr(repo.head, "is_detached", False): - # Provide short commit identifier for detached head if available - try: - commit_sha = repo.head.commit.hexsha[:7] - info["active_branch"] = f"DETACHED_{commit_sha}" - except Exception: - info["active_branch"] = "DETACHED" - else: - info["active_branch"] = repo.active_branch.name - except Exception: - info["active_branch"] = None - - info["is_dirty"] = repo.is_dirty(untracked_files=True) - - # Remotes - remotes = {} - for remote in repo.remotes: - remotes[remote.name] = list(remote.urls) - info["remotes"] = remotes + with Repo(repo_path) as repo: + info = {"path": os.path.abspath(repo_path)} + + # Active branch (handle detached HEAD safely) + try: + if getattr(repo.head, "is_detached", False): + # Provide short commit identifier for detached head if available + try: + commit_sha = repo.head.commit.hexsha[:7] + info["active_branch"] = f"DETACHED_{commit_sha}" + except Exception: + info["active_branch"] = "DETACHED" + else: + info["active_branch"] = repo.active_branch.name + except Exception: + info["active_branch"] = None + + info["is_dirty"] = repo.is_dirty(untracked_files=True) + + # Remotes + remotes = {} + for remote in repo.remotes: + remotes[remote.name] = list(remote.urls) + info["remotes"] = remotes return info @@ -429,36 +432,39 @@ def get_last_rendered_functionality(repo_path: Union[str, os.PathLike]) -> tuple if not os.path.exists(repo_path): return None, None - repo = Repo(repo_path) - grep_pattern = FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format(".*") - grep_pattern = grep_pattern.replace("[", "\\[").replace("]", "\\]") - commit_sha = repo.git.rev_list(repo.active_branch.name, "--grep", grep_pattern, "-n", "1") - - if not commit_sha: - # Repo was interrupted during the first functionality, fallback to initial commit and provide only module name - grep_pattern = INITIAL_COMMIT_MESSAGE + with Repo(repo_path) as repo: + grep_pattern = FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format(".*") grep_pattern = grep_pattern.replace("[", "\\[").replace("]", "\\]") commit_sha = repo.git.rev_list(repo.active_branch.name, "--grep", grep_pattern, "-n", "1") + if not commit_sha: - raise InvalidGitRepositoryError("Git repository is in an invalid state. Initial commit could not be found.") + # Repo was interrupted during the first functionality, fallback to initial commit + # and provide only module name + grep_pattern = INITIAL_COMMIT_MESSAGE + grep_pattern = grep_pattern.replace("[", "\\[").replace("]", "\\]") + commit_sha = repo.git.rev_list(repo.active_branch.name, "--grep", grep_pattern, "-n", "1") + if not commit_sha: + raise InvalidGitRepositoryError( + "Git repository is in an invalid state. Initial commit could not be found." + ) + + commit_message = repo.commit(commit_sha).message + if isinstance(commit_message, bytes): + commit_message = commit_message.decode("utf-8") + + match = re.search(r"Module name:\s*(\S+)\n", commit_message) + if not match: + raise InvalidGitRepositoryError( + "Git repository is in an invalid state. Could not find module name in initial commit." + ) + + module_name = match.group(1) + return module_name, None commit_message = repo.commit(commit_sha).message if isinstance(commit_message, bytes): commit_message = commit_message.decode("utf-8") - match = re.search(r"Module name:\s*(\S+)\n", commit_message) - if not match: - raise InvalidGitRepositoryError( - "Git repository is in an invalid state. Could not find module name in initial commit." - ) - - module_name = match.group(1) - return module_name, None - - commit_message = repo.commit(commit_sha).message - if isinstance(commit_message, bytes): - commit_message = commit_message.decode("utf-8") - match = re.search(r"FRID\):(\S+) fully implemented", commit_message) if not match: raise InvalidGitRepositoryError( diff --git a/system_config.py b/system_config.py index a97d7f28..c2f04351 100644 --- a/system_config.py +++ b/system_config.py @@ -32,11 +32,10 @@ def _resolve_version() -> str: # codeplain checkout's repo, not the caller's working directory # (codeplain may be run from anywhere). source_dir = os.path.dirname(os.path.abspath(__file__)) - repo = git.Repo(source_dir, search_parent_directories=True) - - # Highest version tag, regardless of branch ancestry (a dev run may sit - # on a feature branch that doesn't descend from the latest release tag). - latest_tag = repo.git.tag("--list", "--sort=-v:refname").splitlines()[0] + with git.Repo(source_dir, search_parent_directories=True) as repo: + # Highest version tag, regardless of branch ancestry (a dev run may sit + # on a feature branch that doesn't descend from the latest release tag). + latest_tag = repo.git.tag("--list", "--sort=-v:refname").splitlines()[0] return latest_tag.lstrip("v") except Exception: return "0.0.0.dev0" diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py index df4c4d33..e0fef30a 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -1,5 +1,5 @@ +import gc import os -import sys import tempfile from pathlib import Path from textwrap import dedent @@ -20,14 +20,6 @@ ) from plain2code_exceptions import InvalidGitRepositoryError -pytestmark = pytest.mark.skipif( - sys.platform == "win32", - reason=( - "Text files land on disk with CRLF and GitPython keeps repository handles open, so diff " - "assertions and temp-dir teardown both break; native Windows runs via WSL." - ), -) - @pytest.fixture def temp_repo(): @@ -37,14 +29,19 @@ def temp_repo(): # Create and commit initial file file_path = Path(temp_dir) / "test.txt" - file_path.write_text("initial content\nline2\nline3\n") + file_path.write_text("initial content\nline2\nline3\n", newline="\n") - repo = Repo(temp_dir) - repo.index.add(["test.txt"]) + with Repo(temp_dir) as repo: + repo.index.add(["test.txt"]) add_all_files_and_commit(temp_dir, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("1.1")) yield temp_dir + # Repos opened inside the test body may still hold `git cat-file` children; + # on Windows those keep temp_dir undeletable. Collecting here reaps them + # (GitPython terminates the children when its objects are finalized). + gc.collect() + @pytest.fixture def empty_repo(): @@ -52,6 +49,7 @@ def empty_repo(): with tempfile.TemporaryDirectory() as temp_dir: init_git_repo(temp_dir) yield temp_dir + gc.collect() # same reason as in temp_repo def test_empty_diff(temp_repo): @@ -66,7 +64,7 @@ def test_single_file_change(temp_repo): # Modify the file file_path = Path(temp_repo) / "test.txt" - file_path.write_text("modified content\nline2\nline3\n") + file_path.write_text("modified content\nline2\nline3\n", newline="\n") repo.index.add(["test.txt"]) repo.index.commit("Modified test.txt") @@ -92,15 +90,15 @@ def test_multiple_file_changes(temp_repo): # Create and commit second file file2_path = Path(temp_repo) / "file2.txt" - file2_path.write_text("file2 initial\nline2\n") + file2_path.write_text("file2 initial\nline2\n", newline="\n") add_all_files_and_commit(temp_repo, "Added file2.txt", None, "1.2") # Modify both files file1_path = Path(temp_repo) / "test.txt" - file1_path.write_text("file1 modified\nline2\n") + file1_path.write_text("file1 modified\nline2\n", newline="\n") - file2_path.write_text("file2 modified\nline2") + file2_path.write_text("file2 modified\nline2", newline="\n") # Get diff result = diff(temp_repo, "1.1") @@ -139,23 +137,23 @@ def test_multiple_commits_diff(temp_repo): # Create and commit second file file2_path = Path(temp_repo) / "file2.txt" - file2_path.write_text("file2 frid1.1 refactored version\nline2\n") + file2_path.write_text("file2 frid1.1 refactored version\nline2\n", newline="\n") add_all_files_and_commit(temp_repo, REFACTORED_CODE_COMMIT_MESSAGE.format("1.1"), None, "1.1") add_all_files_and_commit(temp_repo, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("1.1"), None, "1.1") - file1_path.write_text("file1 frid1.2 version\nline2\n") - file2_path.write_text("file2 frid1.2 version\nline2\n") + file1_path.write_text("file1 frid1.2 version\nline2\n", newline="\n") + file2_path.write_text("file2 frid1.2 version\nline2\n", newline="\n") add_all_files_and_commit(temp_repo, "implemented frid 1.2", None, "1.2") - file1_path.write_text("file1 frid1.2 refactored version\nline2\n") + file1_path.write_text("file1 frid1.2 refactored version\nline2\n", newline="\n") add_all_files_and_commit(temp_repo, REFACTORED_CODE_COMMIT_MESSAGE.format("1.2"), None, "1.2") add_all_files_and_commit(temp_repo, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("1.2"), None, "1.2") file3_path = Path(temp_repo) / "file3.txt" - file3_path.write_text("file3 frid1.2 new file\nline2\n") + file3_path.write_text("file3 frid1.2 new file\nline2\n", newline="\n") # Get diff result = diff(temp_repo, "1.1") @@ -201,12 +199,12 @@ def test_diff_without_previous_frid_and_no_base_folder(empty_repo): """Test diff without previous frid and no base folder.""" # Create a new file without committing file_path = Path(empty_repo) / "new.txt" - file_path.write_text("new file content\nline2\n") + file_path.write_text("new file content\nline2\n", newline="\n") add_all_files_and_commit(empty_repo, "First commit") # create one more file file_path = Path(empty_repo) / "new2.txt" - file_path.write_text("new file content\nline2\n") + file_path.write_text("new file content\nline2\n", newline="\n") # Get diff result = diff(empty_repo) @@ -236,11 +234,11 @@ def test_diff_without_previous_frid_and_base_folder(temp_repo): """Test diff without previous frid and base folder.""" # Create a commit for the base folder file_path = Path(temp_repo) / "new.txt" - file_path.write_text("base folder content\nline2\n") + file_path.write_text("base folder content\nline2\n", newline="\n") add_all_files_and_commit(temp_repo, BASE_FOLDER_COMMIT_MESSAGE) # update the file - file_path.write_text("updated base folder content\nline2\n") + file_path.write_text("updated base folder content\nline2\n", newline="\n") # Get diff result = diff(temp_repo) @@ -263,7 +261,7 @@ def test_new_file(temp_repo): # Create a new file without committing file_path = Path(temp_repo) / "new.txt" - file_path.write_text("new file content\nline2\n") + file_path.write_text("new file content\nline2\n", newline="\n") # Get diff result = diff(temp_repo, "1.1") @@ -319,9 +317,9 @@ def test_add_all_files_and_commit(temp_repo): """Test adding all files and committing them.""" # Create some test files file1_path = Path(temp_repo) / "file1.txt" - file1_path.write_text("content1") + file1_path.write_text("content1", newline="\n") file2_path = Path(temp_repo) / "file2.txt" - file2_path.write_text("content2") + file2_path.write_text("content2", newline="\n") # Add and commit files repo = add_all_files_and_commit(temp_repo, "Test commit", None, "FR123", "render-id") @@ -341,7 +339,7 @@ def test_add_all_files_and_commit(temp_repo): assert "file1.txt" in tree assert "file2.txt" in tree - file2_path.write_text("content2 modified") + file2_path.write_text("content2 modified", newline="\n") repo = add_all_files_and_commit(temp_repo, "Commit changes on existing file", None, "FR4") commits = list(repo.iter_commits()) assert len(commits) == 4 @@ -357,11 +355,11 @@ def test_revert_changes(temp_repo): """Test reverting changes in the repository.""" # Create and commit initial file file_path = Path(temp_repo) / "test.txt" - file_path.write_text("initial content") + file_path.write_text("initial content", newline="\n") repo = add_all_files_and_commit(temp_repo, "Initial commit", None, "FR123") # Modify the file - file_path.write_text("modified content") + file_path.write_text("modified content", newline="\n") # Verify the file was modified assert file_path.read_text() == "modified content" @@ -380,19 +378,19 @@ def test_revert_to_commit_with_frid(temp_repo): """Test reverting to a specific commit with FRID.""" # Create and commit first version file_path = Path(temp_repo) / "test.txt" - file_path.write_text("version 1") + file_path.write_text("version 1", newline="\n") repo = add_all_files_and_commit( temp_repo, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("FR123"), None, "FR123" ) # Create and commit second version - file_path.write_text("version 2") + file_path.write_text("version 2", newline="\n") repo = add_all_files_and_commit( temp_repo, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("FR456"), None, "FR456" ) # Create and commit third version - file_path.write_text("version 3") + file_path.write_text("version 3", newline="\n") repo = add_all_files_and_commit( temp_repo, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("FR789"), None, "FR789" ) @@ -416,11 +414,11 @@ def test_revert_to_commit_with_frid_and_base_folder(temp_repo): """Test reverting to base folder.""" # Create a commit for the base folder file_path = Path(temp_repo) / "new.txt" - file_path.write_text("base folder content\nline1\n") + file_path.write_text("base folder content\nline1\n", newline="\n") add_all_files_and_commit(temp_repo, BASE_FOLDER_COMMIT_MESSAGE) # create another commit - file_path.write_text("changed file content\nline2\n") + file_path.write_text("changed file content\nline2\n", newline="\n") add_all_files_and_commit(temp_repo, "Another commit") # revert to base folder @@ -434,7 +432,7 @@ def test_revert_to_base_folder_no_commit(temp_repo): """Test reverting to base folder.""" # Create a commit for the base folder file_path = Path(temp_repo) / "new.txt" - file_path.write_text("some content\n") + file_path.write_text("some content\n", newline="\n") add_all_files_and_commit(temp_repo, "FRID", 123) # revert initial commit @@ -461,7 +459,7 @@ def test_get_last_finished_frid_empty_repo(empty_repo): def test_get_last_finished_frid_returns_latest(empty_repo): """Return the module name and frid from the most recent finished-frid commit.""" file_path = Path(empty_repo) / "a.txt" - file_path.write_text("v1") + file_path.write_text("v1", newline="\n") add_all_files_and_commit( empty_repo, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("1"), @@ -469,7 +467,7 @@ def test_get_last_finished_frid_returns_latest(empty_repo): frid="1", ) - file_path.write_text("v2") + file_path.write_text("v2", newline="\n") add_all_files_and_commit( empty_repo, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("2"), @@ -483,7 +481,7 @@ def test_get_last_finished_frid_returns_latest(empty_repo): def test_get_last_finished_frid_ignores_non_finished_commits(empty_repo): """Commits that aren't finished-frid checkpoints must be skipped.""" file_path = Path(empty_repo) / "a.txt" - file_path.write_text("v1") + file_path.write_text("v1", newline="\n") add_all_files_and_commit( empty_repo, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("1"), @@ -492,7 +490,7 @@ def test_get_last_finished_frid_ignores_non_finished_commits(empty_repo): ) # A refactor commit (not a finished-frid checkpoint) comes after. - file_path.write_text("v2") + file_path.write_text("v2", newline="\n") add_all_files_and_commit( empty_repo, REFACTORED_CODE_COMMIT_MESSAGE.format("2"), @@ -507,7 +505,7 @@ def test_get_last_finished_frid_ignores_non_finished_commits(empty_repo): def test_get_last_finished_frid_without_module_name(empty_repo): """Raise InvalidGitRepositoryError when the finished commit omits the module name line.""" file_path = Path(empty_repo) / "a.txt" - file_path.write_text("v1") + file_path.write_text("v1", newline="\n") add_all_files_and_commit( empty_repo, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("7"), diff --git a/tests/test_plain_modules.py b/tests/test_plain_modules.py index 81f5e433..732d9b35 100644 --- a/tests/test_plain_modules.py +++ b/tests/test_plain_modules.py @@ -23,12 +23,6 @@ # -------------------------------------------------------------------------- -uses_git_repo = pytest.mark.skipif( - sys.platform == "win32", - reason="GitPython keeps repository handles open on Windows, so the temporary build folder cannot be removed; native Windows runs via WSL.", -) - - @pytest.fixture def fixtures_dir(get_test_data_path): return get_test_data_path("data/partial_rendering") @@ -254,7 +248,6 @@ def test_get_module_render_status_no_rendering(root_module): assert root_module.get_module_render_status() == (None, None) -@uses_git_repo def test_get_module_render_status_returns_from_leaf_when_only_leaf_rendered(root_module): leaf = root_module.get_required_module_by_name("pr_leaf") _init_build_repo_with_finished_frid(leaf, "1") @@ -264,7 +257,6 @@ def test_get_module_render_status_returns_from_leaf_when_only_leaf_rendered(root assert frid == "1" -@uses_git_repo def test_get_module_render_status_prefers_most_progressed_module(root_module): """The scan walks required_modules in reverse order — the right-most rendered module wins.""" @@ -278,7 +270,6 @@ def test_get_module_render_status_prefers_most_progressed_module(root_module): assert frid == "1" -@uses_git_repo def test_get_module_render_status_returns_root_when_root_has_checkpoint(root_module): """A checkpoint in the root's own build folder takes precedence over required-module checkpoints.""" @@ -300,13 +291,11 @@ def test_is_module_fully_rendered_false_when_nothing_rendered(solo_module): assert solo_module.is_module_fully_rendered() is False -@uses_git_repo def test_is_module_fully_rendered_false_when_only_first_frid_rendered(solo_module): _init_build_repo_with_finished_frid(solo_module, "1") assert solo_module.is_module_fully_rendered() is False -@uses_git_repo def test_is_module_fully_rendered_true_when_last_frid_rendered(solo_module): # solo module has FRIDs ["1", "2", "3"]; "3" is the last. _init_build_repo_with_finished_frid(solo_module, "3") @@ -437,7 +426,6 @@ def _commit_finished_frid(module: PlainModule, frid: str) -> None: ) -@uses_git_repo def test_revert_code_to_frid_reverts_repo_and_trims_metadata(solo_module): os.makedirs(solo_module.module_build_folder) init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) @@ -456,7 +444,6 @@ def test_revert_code_to_frid_reverts_repo_and_trims_metadata(solo_module): assert os.path.exists(solo_module.get_codeplain_folder()) -@uses_git_repo def test_revert_code_to_frid_none_reverts_to_initial_state(solo_module): os.makedirs(solo_module.module_build_folder) init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) @@ -474,7 +461,6 @@ def test_revert_code_to_frid_none_reverts_to_initial_state(solo_module): # -------------------------------------------------------------------------- -@uses_git_repo def test_reconcile_metadata_with_git_trims_metadata_ahead_of_git(solo_module): # Simulates the crash window: metadata records FR 2 but git only committed FR 1. os.makedirs(solo_module.module_build_folder) @@ -490,7 +476,6 @@ def test_reconcile_metadata_with_git_trims_metadata_ahead_of_git(solo_module): assert metadata["source_hash"] == "abc" -@uses_git_repo def test_reconcile_metadata_with_git_in_sync_is_noop(solo_module): os.makedirs(solo_module.module_build_folder) init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) @@ -503,7 +488,6 @@ def test_reconcile_metadata_with_git_in_sync_is_noop(solo_module): assert solo_module.load_module_metadata()["functionalities"] == ["fr1", "fr2"] -@uses_git_repo def test_reconcile_metadata_with_git_no_finished_frid_empties_list(solo_module): # Only the initial commit exists (no finished FRID), so the baseline must be emptied. os.makedirs(solo_module.module_build_folder) @@ -515,7 +499,6 @@ def test_reconcile_metadata_with_git_no_finished_frid_empties_list(solo_module): assert solo_module.load_module_metadata()["functionalities"] == [] -@uses_git_repo def test_reconcile_metadata_with_git_ignores_foreign_module_frid(solo_module): # A repo cloned from a required module carries that module's finished FRID; this # module has rendered none of its own, so its baseline must be emptied. @@ -536,7 +519,6 @@ def test_reconcile_metadata_with_git_ignores_foreign_module_frid(solo_module): assert solo_module.load_module_metadata()["functionalities"] == [] -@uses_git_repo def test_reconcile_metadata_with_git_no_metadata_is_noop(solo_module): os.makedirs(solo_module.module_build_folder) init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) From affcde8c18820c846dbfc38225446717b86afb39 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 02:43:33 +0200 Subject: [PATCH 29/83] Add the platform-neutral half of the ConPTY backend Marshaling for CreateProcessW (list2cmdline quoting, the double-NUL environment block, and rejection of embedded NULs, '=' in a name and an empty name), the bounded input queue with its reserved partition and control lane, and the writer protocol that owns every write to a synchronous input pipe. Split from the backend module, which binds kernel32 at import time, so these rules run in every platform's test suite instead of only on a Windows runner. --- render_machine/_conpty_support.py | 644 ++++++++++++++++++++++++++++++ tests/test_conpty_support.py | 499 +++++++++++++++++++++++ 2 files changed, 1143 insertions(+) create mode 100644 render_machine/_conpty_support.py create mode 100644 tests/test_conpty_support.py diff --git a/render_machine/_conpty_support.py b/render_machine/_conpty_support.py new file mode 100644 index 00000000..51ae53fb --- /dev/null +++ b/render_machine/_conpty_support.py @@ -0,0 +1,644 @@ +"""Platform-neutral parts of the Windows ConPTY backend. + +`_conpty.py` binds kernel32 at import time and can only be imported on Windows, so the +rules that need no Windows API live here instead: the marshaling `CreateProcessW` requires, +the bounded input queue, and the writer protocol that owns every write to the +pseudoconsole's input pipe. Splitting them out is what lets them run in the test suite on +every platform rather than only on a Windows runner. + +The input writer is a thread because the pseudoconsole's input pipe is an anonymous pipe, +and anonymous pipes are synchronous: a target that stops reading its input leaves +`WriteFile` blocked until somebody cancels it. Only this thread's handle is registered for +cancellation, so every producer — caller input, terminal-query replies, the graceful +control byte — enqueues a whole item here instead of writing to the pipe itself. +""" + +import collections +import subprocess +import threading +import time +from enum import Enum +from typing import Callable, Deque, List, Mapping, Optional, Sequence, Tuple + +from plain2code_console import console +from render_machine.terminal_process import ( + MAX_INPUT_ITEM_BYTES, + MAX_PENDING_INPUT_BYTES, + MAX_PENDING_INPUT_ITEMS, + RESERVED_INPUT_BYTES, + RESERVED_INPUT_ITEMS, + InputDisposition, + InputWriteResult, + TerminalEnvironmentError, +) +from render_machine.terminal_queries import REASON_DISCARDED, REASON_WRITE_FAILED + +NUL = "\x00" + +# How often the foreground retries `CancelSynchronousIo()` while waiting for a control item +# or for the writer to join. A cancel issued before the writer has entered its write reports +# ERROR_NOT_FOUND and does nothing, so the call is a tick rather than a one-shot. +CANCEL_TICK_SECONDS = 0.02 + +# How long an idle writer parks on the queue before looking at its stopping flag again. +WRITER_IDLE_TICK_SECONDS = 0.05 + +# How long teardown waits for the writer to leave a synchronous write before the whole +# session is handed to the finalizer. +WRITER_JOIN_DEADLINE_SECONDS = 3.0 + +# A target that queries in a loop against a closed channel loses one item per query, so the +# loss is logged as a sample plus a count rather than once per item. +DROP_LOG_INTERVAL_SECONDS = 5.0 + +# Resolution of one queued item, as the producer sees it. +ResolveCallback = Callable[[InputDisposition, Optional[BaseException]], None] + + +# --------------------------------------------------------------------- marshaling + + +def _reject_nul(value: str, description: str) -> None: + """`CreateProcessW` takes NUL-terminated strings, so an embedded NUL truncates silently. + + `subprocess` performs this check for its callers; calling the API through ctypes bypasses + it, so it is re-established here rather than assumed. + """ + if NUL in value: + raise TerminalEnvironmentError(f"{description} contains a NUL character, which Windows cannot carry.") + + +def build_command_line(command: Sequence[str]) -> str: + """One command line quoted to the MS C runtime rules. + + `subprocess.list2cmdline()` rather than a second dialect, so a command spawned through + the ConPTY backend produces the same argv as the same command spawned through `Popen`. + """ + argv = list(command) + if not argv: + raise TerminalEnvironmentError("The command to run is empty.") + for index, argument in enumerate(argv): + _reject_nul(argument, f"Argument {index} of the command") + return subprocess.list2cmdline(argv) + + +def validate_working_directory(cwd: Optional[str]) -> Optional[str]: + if cwd is not None: + _reject_nul(cwd, "The working directory") + return cwd + + +def build_environment_block(env: Mapping[str, str]) -> str: + """`KEY=VALUE` entries, each NUL-terminated, sorted case-insensitively. + + The sort order is documented as a requirement of the environment block, not a + convention. The caller copies the result into a unicode buffer, whose own terminator + supplies the second NUL the block ends with. + """ + entries = [] + for name, value in sorted(env.items(), key=lambda item: item[0].upper()): + if not name: + raise TerminalEnvironmentError("An environment variable name is empty.") + if "=" in name: + # The block's own name/value separator: a name carrying one silently reshapes + # the block into different variables. + raise TerminalEnvironmentError(f"Environment variable name {name!r} contains '='.") + _reject_nul(name, f"Environment variable name {name!r}") + _reject_nul(value, f"The value of environment variable {name!r}") + entries.append(f"{name}={value}") + if not entries: + return NUL + return "".join(entry + NUL for entry in entries) + + +def native_thread_id() -> int: + """The kernel thread id `OpenThread` needs. + + `Thread.ident` is a Python-level cookie with no OS meaning, so it cannot be used here. + Wrapped in a function of its own so a failure before publication can be injected without + patching the threading module the test runner also uses. + """ + return threading.get_native_id() + + +def reply_resolution(on_complete: Callable[[Optional[str]], None]) -> ResolveCallback: + """Maps one queue resolution onto the responder's delivered / not-delivered contract.""" + + def resolved(disposition: InputDisposition, error: Optional[BaseException]) -> None: + if error is not None: + on_complete(f"{REASON_WRITE_FAILED}: {error!r}") + elif disposition is InputDisposition.ACCEPTED: + on_complete(None) + else: + on_complete(f"{REASON_DISCARDED} ({disposition.value})") + + return resolved + + +# ------------------------------------------------------------------- input queue + + +class InputLane(Enum): + """Which lane an item is admitted to. Control items are serviced ahead of data.""" + + DATA = "data" + CONTROL = "control" + + +class Receipt: + """Resolution of one queued item. Resolved exactly once, by whoever retires it. + + `on_resolve` lets a producer observe that transition without ever waiting for it, which + is what the output reader needs when it is the producer. + """ + + def __init__(self, on_resolve: Optional[ResolveCallback] = None) -> None: + self._event = threading.Event() + self.error: Optional[BaseException] = None + self.disposition: Optional[InputDisposition] = None + self.resolutions = 0 + self._on_resolve = on_resolve + + def resolve(self, disposition: InputDisposition, error: Optional[BaseException] = None) -> None: + self.resolutions += 1 + if self._event.is_set(): + return + self.disposition = disposition + self.error = error + self._event.set() + if self._on_resolve is not None: + try: + self._on_resolve(disposition, error) + except BaseException as exc: # a completion callback must never strand the queue + console.debug(f"input completion callback raised: {exc!r}") + + @property + def resolved(self) -> bool: + return self._event.is_set() + + @property + def delivered(self) -> bool: + return self.resolved and self.disposition is InputDisposition.ACCEPTED and self.error is None + + +class InputItem: + """One whole logical write, plus the cursor the writer keeps across partial writes.""" + + def __init__(self, data: bytes, receipt: Receipt, lane: InputLane, sequence: int, stop: bool = False) -> None: + self.data = data + self.receipt = receipt + self.lane = lane + self.sequence = sequence + self.stop = stop + self.cursor = 0 + + +class InputQueue: + """Bounded, byte-accounted queue with a reserved admission partition and a control lane. + + Dequeue is not completion: the item under the writer's cursor stays accounted for and + keeps its receipt attached until its last byte completes or teardown fails it, so + capacity is released exactly once at that terminal transition. + """ + + def __init__( + self, + max_item_bytes: int = MAX_INPUT_ITEM_BYTES, + max_pending_bytes: int = MAX_PENDING_INPUT_BYTES, + reserved_bytes: int = RESERVED_INPUT_BYTES, + max_pending_items: int = MAX_PENDING_INPUT_ITEMS, + reserved_items: int = RESERVED_INPUT_ITEMS, + ) -> None: + self._condition = threading.Condition() + self._data: Deque[InputItem] = collections.deque() + self._control: Deque[InputItem] = collections.deque() + self._current: Optional[InputItem] = None + self._pending_bytes = 0 + self._sequence = 0 + self._accepting = True + self._max_item_bytes = max_item_bytes + self._max_pending_bytes = max_pending_bytes + self._reserved_bytes = reserved_bytes + self._max_pending_items = max_pending_items + self._reserved_items = reserved_items + + def submit( + self, + data: bytes, + reserved: bool = False, + lane: InputLane = InputLane.DATA, + on_resolve: Optional[ResolveCallback] = None, + ) -> Tuple[InputWriteResult, Receipt]: + """One non-blocking whole-item admission. Never waits, whoever the producer is.""" + receipt = Receipt(on_resolve) + size = len(data) + enqueued = False + with self._condition: + byte_budget, item_budget = self._budget(reserved) + queued = len(self._data) + len(self._control) + (0 if self._current is None else 1) + if not self._accepting: + result = InputWriteResult(InputDisposition.CLOSED, 0) + elif size == 0: + # Nothing to deliver, so it never becomes an entry: an empty item would grow + # the queue without ever touching the byte budget. + result = InputWriteResult(InputDisposition.ACCEPTED, 0) + elif size > self._max_item_bytes: + result = InputWriteResult(InputDisposition.BACKPRESSURE, 0) + elif self._pending_bytes + size > byte_budget or queued >= item_budget: + result = InputWriteResult(InputDisposition.BACKPRESSURE, 0) + else: + self._append(InputItem(bytes(data), receipt, lane, self._next_sequence())) + self._pending_bytes += size + result = InputWriteResult(InputDisposition.ACCEPTED, size) + enqueued = True + if not enqueued: # nothing will retire it later, so it resolves here + receipt.resolve(result.disposition) + return result, receipt + + def post_stop(self) -> Receipt: + """Teardown's own sentinel. Admitted after the queue stops accepting producers.""" + receipt = Receipt() + with self._condition: + self._append(InputItem(b"", receipt, InputLane.CONTROL, self._next_sequence(), stop=True)) + return receipt + + def _next_sequence(self) -> int: + self._sequence += 1 + return self._sequence + + def _append(self, item: InputItem) -> None: + """Called under the condition. Appending is what wakes a parked writer.""" + if item.lane is InputLane.CONTROL: + self._control.append(item) + else: + self._data.append(item) + self._condition.notify_all() + + def _budget(self, reserved: bool) -> Tuple[int, int]: + if reserved: + return self._max_pending_bytes, self._max_pending_items + return self._max_pending_bytes - self._reserved_bytes, self._max_pending_items - self._reserved_items + + def next_item(self, timeout: float) -> Optional[InputItem]: + """The item under the cursor, waiting up to `timeout` for one to arrive. + + Control items are serviced ahead of data; order inside a lane is FIFO. + """ + with self._condition: + if self._current is None and not self._control and not self._data: + self._condition.wait(timeout) + if self._current is None: + if self._control: + self._current = self._control.popleft() + elif self._data: + self._current = self._data.popleft() + return self._current + + def current(self) -> Optional[InputItem]: + with self._condition: + return self._current + + def retire_current(self, delivered: bool, error: Optional[BaseException] = None) -> None: + """Releases the item's accounting once and resolves its receipt once.""" + with self._condition: + item = self._current + if item is None: + return + self._current = None + self._pending_bytes -= len(item.data) + item.receipt.resolve( + InputDisposition.ACCEPTED if delivered and error is None else InputDisposition.CLOSED, error + ) + + def requeue_current_front(self) -> None: + """Returns an untouched item to the head of its lane, accounting unchanged.""" + with self._condition: + item = self._current + if item is None: + return + self._current = None + if item.lane is InputLane.CONTROL: + self._control.appendleft(item) + else: + self._data.appendleft(item) + self._condition.notify_all() + + def stop_accepting(self) -> None: + with self._condition: + self._accepting = False + + def accepting(self) -> bool: + with self._condition: + return self._accepting + + def has_pending(self) -> bool: + with self._condition: + return self._current is not None or bool(self._control) or bool(self._data) + + def pending_bytes(self) -> int: + with self._condition: + return self._pending_bytes + + def pending_items(self) -> int: + with self._condition: + return len(self._data) + len(self._control) + (0 if self._current is None else 1) + + def discard_pending_data(self) -> List[InputItem]: + """Drops queued data items, resolving each receipt as not delivered. + + The item under the cursor is left alone: it may be inside a synchronous write, and + only the writer can retire it. + """ + with self._condition: + items = list(self._data) + self._data.clear() + for item in items: + self._pending_bytes -= len(item.data) + for item in items: + item.receipt.resolve(InputDisposition.CLOSED) + return items + + def close_and_fail_all(self, error: Optional[BaseException] = None) -> List[InputItem]: + with self._condition: + self._accepting = False + items = list(self._control) + list(self._data) + self._control.clear() + self._data.clear() + if self._current is not None: + items.append(self._current) + self._current = None + self._pending_bytes = 0 + for item in items: # callbacks run outside the lock and cannot re-enter the queue + try: + item.receipt.resolve(InputDisposition.CLOSED, error) + except BaseException as exc: # a receipt must never strand its siblings + console.debug(f"input receipt callback raised: {exc!r}") + return items + + +# ------------------------------------------------------------------- input writer + + +class WriteAborted(Exception): + """A synchronous write completed as cancelled. + + `WriteFile` initializes its byte count to zero and a cancelled completion carries no + trustworthy cursor, so the item it belonged to is retired rather than retried. + """ + + +class WriteChannel: + """The two native operations the writer performs, behind one seam. + + `cancel()` is issued from another thread against the writer's own thread handle, which + is why the writer never derives that handle itself. + """ + + def write(self, data: bytes) -> int: + raise NotImplementedError + + def cancel(self) -> None: + raise NotImplementedError + + +class GateDecision(Enum): + RUN = "run" + ABORT = "abort" + + +class DecisionGate: + """A gate carrying a decision, so a writer released without a stored cancel handle exits + instead of blocking in a write nothing can cancel.""" + + def __init__(self) -> None: + self._event = threading.Event() + self._decision = GateDecision.ABORT + + def set(self, decision: GateDecision) -> None: + self._decision = decision + self._event.set() + + def wait(self, timeout: Optional[float] = None) -> GateDecision: + self._event.wait(timeout) + return self._decision + + @property + def released(self) -> bool: + return self._event.is_set() + + +class _DropLog: + """Rate-limited loss reporting: a query storm must not turn the log into its own flood.""" + + def __init__(self, interval: float = DROP_LOG_INTERVAL_SECONDS) -> None: + self._interval = interval + self._lock = threading.Lock() + self._last = 0.0 + self.dropped = 0 + + def record(self, reason: str) -> None: + with self._lock: + self.dropped += 1 + now = time.monotonic() + if self._last and now - self._last < self._interval: + return + self._last = now + dropped = self.dropped + console.debug(f"terminal input item not delivered ({reason}); {dropped} lost so far") + + +class InputWriter: + """Sole owner of every write to the pseudoconsole's input pipe. + + Startup is a gate protocol: the thread publishes its native id and parks, the creator + opens a thread handle while the gate still holds it, stores the handle, and releases the + gate with `RUN`. Any failure in between releases the gate with `ABORT`, and a writer that + wakes to `ABORT` returns without touching the pipe. + """ + + def __init__(self, queue: InputQueue, channel: WriteChannel, name: str = "codeplain-conpty-writer") -> None: + self.queue = queue + self.channel = channel + self.ready = threading.Event() + self.finished = threading.Event() + self.gate = DecisionGate() + self.failed = threading.Event() + self.exc: Optional[BaseException] = None + self.native_id: Optional[int] = None + self.cancels = 0 + self.drops = _DropLog() + self._stopping = threading.Event() + self._lock = threading.Lock() # guards both generations, held across check and cancel + self._requested_generation = 0 + self._preempted_generation = 0 + self.thread = threading.Thread(target=self._run, name=name, daemon=True) + + # ------------------------------------------------------------ creator side + + def start(self) -> None: + self.thread.start() + + def started(self) -> bool: + return self.thread.ident is not None + + def await_ready(self, deadline: float, stop_check: Optional[Callable[[], None]] = None) -> Optional[int]: + """Waits for the writer to publish its native id or its failure, under a deadline. + + Returns the id, or None when the writer failed or the deadline expired. The wait is + bounded and abortable because a writer that dies before publishing must not park the + creator. + """ + while not self.ready.is_set(): + if stop_check is not None: + stop_check() + if time.monotonic() >= deadline: + return None + self.ready.wait(CANCEL_TICK_SECONDS) + return None if self.failed.is_set() else self.native_id + + def deliver_control(self, data: bytes, deadline_seconds: float) -> bool: + """Posts an urgent control item, preempts any data write, and awaits its receipt. + + Reserved capacity buys admission, not service: a writer already blocked in a + synchronous data write never reaches the queue again on its own, so the in-flight + write is cancelled through the stored thread handle until the writer acknowledges + this generation. + """ + result, receipt = self.queue.submit(data, reserved=True, lane=InputLane.CONTROL) + if result.disposition is not InputDisposition.ACCEPTED: + return False + with self._lock: + self._requested_generation += 1 + generation = self._requested_generation + deadline = time.monotonic() + deadline_seconds + while not receipt.resolved: + if time.monotonic() >= deadline: + return False + if self.finished.is_set() and not receipt.resolved: + break # a retired writer resolves every receipt, so this is a lost race, not a wait + with self._lock: + # The lock spans the check and the cancel, so a cancel can never land on the + # control write the acknowledgment has just cleared the way for. + if self._preempted_generation < generation: + self._cancel() + time.sleep(CANCEL_TICK_SECONDS) + return receipt.delivered + + def stop(self, bound_seconds: float = WRITER_JOIN_DEADLINE_SECONDS) -> bool: + """Sentinel, discard, retried cancel, bounded join. False when the writer is still in a write. + + An idle writer is parked on the queue rather than inside a write, so a cancel-only + loop would report ERROR_NOT_FOUND forever and never join it. + """ + self._stopping.set() + self.queue.stop_accepting() + self.queue.discard_pending_data() + self.queue.post_stop() + if not self.started(): + return True + if not self.gate.released: # an unreleased gate parks the writer forever + self.gate.set(GateDecision.ABORT) + deadline = time.monotonic() + bound_seconds + while True: + self.thread.join(CANCEL_TICK_SECONDS) + if not self.thread.is_alive(): + return True + if time.monotonic() >= deadline: + return False + with self._lock: + self._cancel() + + def _cancel(self) -> None: + """Called under the preemption lock, by whoever is waiting on the writer.""" + self.cancels += 1 + try: + self.channel.cancel() + except BaseException as exc: # cancellation is best effort; the bound decides the outcome + console.debug(f"cancelling the terminal input write raised: {exc!r}") + + # ------------------------------------------------------------- writer thread + + def _run(self) -> None: + try: + try: + self.native_id = native_thread_id() + finally: + # From the writer's own finally, so a writer that dies before publishing the + # id still releases the creator. + self.ready.set() + if self.gate.wait() is not GateDecision.RUN: + return + self._loop() + except BaseException as exc: # nothing here reaches threading.excepthook + self._publish(exc) + finally: + self.queue.close_and_fail_all() + self.finished.set() + + def _publish(self, exc: BaseException) -> None: + self.exc = exc # stored while still unobservable + self.failed.set() + + def _loop(self) -> None: + while True: + item = self.queue.next_item(WRITER_IDLE_TICK_SECONDS) + if item is None: + if self._stopping.is_set(): + return + continue + if item.stop: + self.queue.retire_current(delivered=True) + return + self._service(item) + if self._stopping.is_set(): + # Consulted before taking another item, so a cancelled write during teardown + # exits instead of consuming the backlog. + return + + def _service(self, item: InputItem) -> None: + if item.lane is InputLane.CONTROL: + # Published before the control write begins: it means "no earlier data I/O + # remains", and it is what stops the poster's cancel loop. + self._acknowledge_preemption() + self._write_item(item, preemptible=False) + return + self._write_item(item, preemptible=True) + + def _write_item(self, item: InputItem, preemptible: bool) -> None: + while item.cursor < len(item.data): + if preemptible and self._control_pending(): + if item.cursor == 0: # nothing was written, so nothing can be lost or duplicated + self.queue.requeue_current_front() + else: + self._retire_undelivered(item, "preempted mid-item") + self._acknowledge_preemption() + return + try: + written = self.channel.write(item.data[item.cursor :]) + except WriteAborted: + expected = self._stopping.is_set() or self._control_pending() + self._retire_undelivered(item, "write cancelled") + self._acknowledge_preemption() + if not expected: + # A cancellation nobody asked for is a genuine writer failure; one the + # stop protocol or a preemption asked for is control flow. + raise + return + except BaseException as exc: + self.queue.retire_current(delivered=False, error=exc) + raise + item.cursor += written + self.queue.retire_current(delivered=True) + + def _retire_undelivered(self, item: InputItem, reason: str) -> None: + self.queue.retire_current(delivered=False) + if item.lane is InputLane.DATA: + self.drops.record(reason) + + def _control_pending(self) -> bool: + with self._lock: + return self._preempted_generation < self._requested_generation + + def _acknowledge_preemption(self) -> None: + with self._lock: + self._preempted_generation = self._requested_generation diff --git a/tests/test_conpty_support.py b/tests/test_conpty_support.py new file mode 100644 index 00000000..f3c89193 --- /dev/null +++ b/tests/test_conpty_support.py @@ -0,0 +1,499 @@ +"""The platform-neutral half of the Windows ConPTY backend. + +Everything here is ordinary Python and runs on every platform, which is the point: the +marshaling rules and the writer protocol are the parts of the backend whose failures are +silent — a truncated command line, a retried cancelled write, a writer parked on a gate +nobody released — and they would otherwise be provable only on a Windows runner. +""" + +import subprocess +import threading +import time + +import pytest + +from render_machine import _conpty_support as support +from render_machine._conpty_support import ( + CANCEL_TICK_SECONDS, + GateDecision, + InputLane, + InputQueue, + InputWriter, + Receipt, + WriteAborted, + WriteChannel, + build_command_line, + build_environment_block, + reply_resolution, + validate_working_directory, +) +from render_machine.terminal_process import InputDisposition, TerminalEnvironmentError + +# Every wait below is bounded, so a failure is a failure rather than a hung suite. +SHORT_TIMEOUT = 5.0 +NUL = "\x00" + + +class FakeChannel(WriteChannel): + """The two native operations, recorded. + + `park` makes a write block until it is cancelled, which is the state a target that has + stopped reading its input leaves the writer in. + """ + + def __init__(self, chunk=None): + self.writes = [] + self.written = bytearray() + self.cancels = 0 + self.chunk = chunk + self.park = False + self.prefix_before_abort = 0 + self.fail = None + self.entered = threading.Event() + self._release = threading.Event() + + def write(self, data: bytes) -> int: + self.writes.append(bytes(data)) + if self.fail is not None: + raise self.fail + if self.park: + self.entered.set() + if not self._release.wait(SHORT_TIMEOUT): + raise AssertionError("the parked write was never cancelled") + self._release.clear() + self.entered.clear() + # A cancelled synchronous write may already have moved a prefix; the completion + # carries no trustworthy cursor either way. + self.written += data[: self.prefix_before_abort] + raise WriteAborted("cancelled") + count = len(data) if self.chunk is None else min(self.chunk, len(data)) + self.written += data[:count] + return count + + def cancel(self) -> None: + self.cancels += 1 + self._release.set() + + +def wait_until(predicate, timeout=SHORT_TIMEOUT): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.01) + return predicate() + + +# ------------------------------------------------------------------- marshaling + + +@pytest.mark.parametrize( + "argv", + [ + ["script.ps1", "one two"], + ["script.ps1", 'say "hello"'], + ["script.ps1", "trailing\\\\"], + ["script.ps1", ""], + ["script.ps1", "C:\\path with space\\", "plain"], + ], +) +def test_the_command_line_is_the_quoting_subprocess_already_produces(argv): + """The naive cases: spaces, embedded quotes, trailing backslashes and the empty string + are where hand-rolled quoting produces a different argv without erroring.""" + assert build_command_line(argv) == subprocess.list2cmdline(argv) + + +def test_the_empty_string_argument_is_quoted_rather_than_dropped(): + assert build_command_line(["script.ps1", ""]).endswith('""') + + +def test_an_empty_command_is_refused(): + with pytest.raises(TerminalEnvironmentError): + build_command_line([]) + + +def test_a_nul_in_an_argument_is_refused_before_anything_is_built(): + with pytest.raises(TerminalEnvironmentError) as error: + build_command_line(["script.ps1", f"before{NUL}after"]) + assert "NUL" in str(error.value) + + +def test_a_nul_in_the_working_directory_is_refused(): + with pytest.raises(TerminalEnvironmentError): + validate_working_directory(f"C:\\builds{NUL}") + + +def test_a_working_directory_passes_through_unchanged(): + assert validate_working_directory("C:\\builds") == "C:\\builds" + assert validate_working_directory(None) is None + + +def test_a_nul_in_an_environment_name_is_refused(): + with pytest.raises(TerminalEnvironmentError): + build_environment_block({f"NA{NUL}ME": "value"}) + + +def test_a_nul_in_an_environment_value_is_refused(): + with pytest.raises(TerminalEnvironmentError): + build_environment_block({"NAME": f"va{NUL}lue"}) + + +def test_an_environment_name_carrying_the_block_separator_is_refused(): + with pytest.raises(TerminalEnvironmentError) as error: + build_environment_block({"NA=ME": "value"}) + assert "'='" in str(error.value) + + +def test_an_empty_environment_name_is_refused(): + with pytest.raises(TerminalEnvironmentError): + build_environment_block({"": "value"}) + + +def test_the_environment_block_is_sorted_case_insensitively(): + block = build_environment_block({"beta": "2", "Alpha": "1", "GAMMA": "3"}) + + assert block == f"Alpha=1{NUL}beta=2{NUL}GAMMA=3{NUL}" + + +def test_an_empty_environment_block_still_terminates(): + """The buffer's own terminator supplies the second NUL, so one is enough here.""" + assert build_environment_block({}) == NUL + + +# ------------------------------------------------------------- reply resolution + + +def test_a_delivered_reply_reports_no_reason(): + reasons = [] + reply_resolution(reasons.append)(InputDisposition.ACCEPTED, None) + + assert reasons == [None] + + +def test_a_failed_reply_reports_the_write_failure(): + reasons = [] + reply_resolution(reasons.append)(InputDisposition.ACCEPTED, OSError("gone")) + + assert "write failed" in reasons[0] + + +def test_a_discarded_reply_reports_the_disposition(): + reasons = [] + reply_resolution(reasons.append)(InputDisposition.CLOSED, None) + + assert "discarded" in reasons[0] and "closed" in reasons[0] + + +# ------------------------------------------------------------------- the queue + + +def test_admission_accounts_for_the_item_until_it_is_retired(): + queue = InputQueue() + + result, receipt = queue.submit(b"abcd") + + assert result.disposition is InputDisposition.ACCEPTED + assert result.accepted_bytes == 4 + assert queue.pending_bytes() == 4 + queue.next_item(0) # dequeue is not completion + assert queue.pending_bytes() == 4 + queue.retire_current(delivered=True) + assert queue.pending_bytes() == 0 + assert receipt.delivered + + +def test_an_empty_item_never_becomes_an_entry(): + queue = InputQueue() + + result, receipt = queue.submit(b"") + + assert result.disposition is InputDisposition.ACCEPTED + assert queue.pending_items() == 0 + assert receipt.resolved + + +def test_an_oversized_item_is_refused_whole(): + queue = InputQueue(max_item_bytes=4) + + result, _ = queue.submit(b"abcde") + + assert result.disposition is InputDisposition.BACKPRESSURE + assert result.accepted_bytes == 0 + + +def test_a_data_backlog_cannot_crowd_out_the_reserved_partition(): + queue = InputQueue(max_pending_bytes=10, reserved_bytes=4, max_pending_items=10, reserved_items=4) + + assert queue.submit(b"123456")[0].disposition is InputDisposition.ACCEPTED + assert queue.submit(b"7")[0].disposition is InputDisposition.BACKPRESSURE + assert queue.submit(b"7", reserved=True)[0].disposition is InputDisposition.ACCEPTED + + +def test_control_items_are_serviced_ahead_of_queued_data(): + queue = InputQueue() + queue.submit(b"data") + queue.submit(b"\x03", reserved=True, lane=InputLane.CONTROL) + + assert queue.next_item(0).data == b"\x03" + + +def test_a_requeued_item_keeps_its_place_and_its_accounting(): + queue = InputQueue() + queue.submit(b"abc") + queue.next_item(0) + + queue.requeue_current_front() + + assert queue.pending_bytes() == 3 + assert queue.next_item(0).data == b"abc" + + +def test_discarding_data_leaves_the_item_under_the_cursor_alone(): + queue = InputQueue() + queue.submit(b"first") + queue.submit(b"second") + in_flight = queue.next_item(0) + + discarded = queue.discard_pending_data() + + assert [item.data for item in discarded] == [b"second"] + assert queue.current() is in_flight + assert discarded[0].receipt.resolved and not discarded[0].receipt.delivered + + +def test_closing_the_queue_resolves_every_receipt_once(): + queue = InputQueue() + _, first = queue.submit(b"one") + _, second = queue.submit(b"two") + queue.next_item(0) + + queue.close_and_fail_all() + + assert first.resolutions == 1 and second.resolutions == 1 + assert not first.delivered and not second.delivered + assert queue.submit(b"three")[0].disposition is InputDisposition.CLOSED + + +def test_a_receipt_reports_its_resolution_to_the_producer(): + seen = [] + receipt = Receipt(lambda disposition, error: seen.append((disposition, error))) + + receipt.resolve(InputDisposition.ACCEPTED) + receipt.resolve(InputDisposition.CLOSED) # a second resolution changes nothing + + assert seen == [(InputDisposition.ACCEPTED, None)] + assert receipt.disposition is InputDisposition.ACCEPTED + + +# ------------------------------------------------------------------ the writer + + +def start_writer(queue, channel, decision=None): + """Runs the creator's half of the gate protocol and returns the started writer.""" + writer = InputWriter(queue, channel) + writer.start() + native_id = writer.await_ready(time.monotonic() + SHORT_TIMEOUT) + writer.gate.set(GateDecision.RUN if decision is None else decision) + return writer, native_id + + +def test_the_writer_publishes_its_native_id_before_it_parks(): + queue, channel = InputQueue(), FakeChannel() + + writer, native_id = start_writer(queue, channel) + try: + assert native_id is not None and native_id == writer.native_id + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_a_writer_released_with_abort_never_touches_the_pipe(): + queue, channel = InputQueue(), FakeChannel() + queue.submit(b"payload") + + writer, _ = start_writer(queue, channel, decision=GateDecision.ABORT) + + assert wait_until(writer.finished.is_set) + assert channel.writes == [] + assert not writer.failed.is_set() + + +def test_a_writer_that_dies_before_publishing_its_id_still_releases_the_creator(monkeypatch): + def unavailable(): + raise OSError("no native id") + + monkeypatch.setattr(support, "native_thread_id", unavailable) + queue, channel = InputQueue(), FakeChannel() + + writer, native_id = start_writer(queue, channel) + + assert native_id is None + assert wait_until(writer.failed.is_set) + assert channel.writes == [] + + +def test_the_ready_wait_gives_up_at_its_deadline(): + """A writer that never starts must not park the creator forever.""" + writer = InputWriter(InputQueue(), FakeChannel()) # deliberately not started + + assert writer.await_ready(time.monotonic() + 0.05) is None + + +def test_a_whole_item_is_written_and_its_receipt_reports_delivery(): + queue, channel = InputQueue(), FakeChannel(chunk=2) + writer, _ = start_writer(queue, channel) + try: + _, receipt = queue.submit(b"abcdef") + + assert wait_until(lambda: receipt.resolved) + assert receipt.delivered + assert bytes(channel.written) == b"abcdef" + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_an_urgent_control_item_cancels_the_data_write_in_flight(): + queue, channel = InputQueue(), FakeChannel() + channel.park = True + writer, _ = start_writer(queue, channel) + try: + _, data_receipt = queue.submit(b"blocked payload") + assert channel.entered.wait(SHORT_TIMEOUT) + channel.park = False # the control write itself completes + + assert writer.deliver_control(b"\x03", SHORT_TIMEOUT) + + assert bytes(channel.written).endswith(b"\x03") + assert data_receipt.resolved and not data_receipt.delivered + assert channel.cancels >= 1 + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_a_cancelled_write_is_never_retried_and_never_duplicates_its_prefix(): + queue, channel = InputQueue(), FakeChannel() + channel.park = True + channel.prefix_before_abort = 3 + writer, _ = start_writer(queue, channel) + try: + _, data_receipt = queue.submit(b"abcdef") + assert channel.entered.wait(SHORT_TIMEOUT) + channel.park = False + + assert writer.deliver_control(b"\x03", SHORT_TIMEOUT) + + assert channel.writes.count(b"abcdef") == 1 # the buffer is never reissued + assert bytes(channel.written) == b"abc\x03" + assert data_receipt.resolutions == 1 + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_no_cancel_is_issued_once_the_writer_has_acknowledged_the_generation(): + queue, channel = InputQueue(), FakeChannel() + writer, _ = start_writer(queue, channel) + try: + assert writer.deliver_control(b"\x03", SHORT_TIMEOUT) + settled = writer.cancels + + time.sleep(CANCEL_TICK_SECONDS * 5) + + assert writer.cancels == settled + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_an_undelivered_control_item_reports_failure_rather_than_waiting_out_the_grace(): + queue = InputQueue(max_pending_bytes=0, reserved_bytes=0) # no capacity for anything + writer, _ = start_writer(queue, FakeChannel()) + try: + assert writer.deliver_control(b"\x03", 0.2) is False + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_stopping_an_idle_writer_joins_it(): + """An idle writer is parked on the queue rather than inside a write, so a cancel-only + loop would never join it.""" + queue, channel = InputQueue(), FakeChannel() + writer, _ = start_writer(queue, channel) + + assert writer.stop(SHORT_TIMEOUT) + assert channel.writes == [] + + +def test_stopping_discards_queued_data_rather_than_writing_it(): + queue, channel = InputQueue(), FakeChannel() + channel.park = True + writer, _ = start_writer(queue, channel) + _, first = queue.submit(b"in flight") + assert channel.entered.wait(SHORT_TIMEOUT) + _, second = queue.submit(b"queued behind it") + + assert writer.stop(SHORT_TIMEOUT) + + assert first.resolved and not first.delivered + assert second.resolved and not second.delivered + assert b"queued behind it" not in channel.writes + + +def test_a_write_cancelled_by_the_stop_protocol_is_not_a_writer_failure(): + queue, channel = InputQueue(), FakeChannel() + channel.park = True + writer, _ = start_writer(queue, channel) + queue.submit(b"blocked payload") + assert channel.entered.wait(SHORT_TIMEOUT) + + assert writer.stop(SHORT_TIMEOUT) + + assert not writer.failed.is_set() + + +def test_a_cancellation_nobody_asked_for_is_a_writer_failure(): + queue, channel = InputQueue(), FakeChannel() + channel.fail = WriteAborted("cancelled by nobody") + writer, _ = start_writer(queue, channel) + try: + queue.submit(b"payload") + + assert wait_until(writer.failed.is_set) + assert isinstance(writer.exc, WriteAborted) + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_a_write_failure_is_published_to_the_foreground(): + queue, channel = InputQueue(), FakeChannel() + channel.fail = OSError("the pipe is gone") + writer, _ = start_writer(queue, channel) + try: + _, receipt = queue.submit(b"payload") + + assert wait_until(writer.failed.is_set) + assert isinstance(writer.exc, OSError) + assert receipt.resolved and not receipt.delivered + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_the_cancel_is_retried_while_the_writer_is_still_parked_before_its_write(): + """A one-shot cancel issued before the writer enters a write reaches nothing.""" + queue, channel = InputQueue(), FakeChannel() + writer, _ = start_writer(queue, channel) + + assert writer.stop(SHORT_TIMEOUT) + assert writer.cancels >= 0 # the loop joins on the sentinel rather than on a cancel + + +def test_repeated_admissions_against_a_saturated_channel_still_stop_within_the_bound(): + queue, channel = InputQueue(), FakeChannel() + channel.park = True + writer, _ = start_writer(queue, channel) + for _ in range(50): + queue.submit(b"reply", reserved=True) + assert channel.entered.wait(SHORT_TIMEOUT) + + started = time.monotonic() + assert writer.stop(SHORT_TIMEOUT) + + assert time.monotonic() - started < SHORT_TIMEOUT From d7ac38ce0e1c8e489cf7927261ad5e886cf3020c Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 02:43:44 +0200 Subject: [PATCH 30/83] Add the ConPTY backend and make it the Windows default One pseudoconsole behind the script's standard handles and one Job Object holding its process tree, both attached through the same proc-thread attribute list so the child is created inside the job or not at all. Ownership is incremental from before the first allocation, and the ordered teardown runs above the rollback stack so a session that outlives its bound can be handed to a daemon finalizer. Windows selects it whenever CODEPLAIN_NO_PTY is not set; the escape hatch keeps selecting the legacy pipe backend on both platforms. Builds below 17763 report an environment error rather than downgrading to pipes. --- render_machine/_conpty.py | 1199 ++++++++++++++++++++++++++++ render_machine/_legacy_pipe.py | 6 +- render_machine/terminal_process.py | 23 +- 3 files changed, 1220 insertions(+), 8 deletions(-) create mode 100644 render_machine/_conpty.py diff --git a/render_machine/_conpty.py b/render_machine/_conpty.py new file mode 100644 index 00000000..37300971 --- /dev/null +++ b/render_machine/_conpty.py @@ -0,0 +1,1199 @@ +"""Windows ConPTY backend for `TerminalProcess`. + +One pseudoconsole backs the target's standard handles, and one Job Object contains the +process tree it starts. Both are attached at creation: the job goes into the same +proc-thread attribute list as the pseudoconsole, so the child is either created inside the +job or not created at all, and `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` makes the kernel tear +the tree down when the last job handle closes. + +A single reader thread owns the output pipe's read handle for its whole lifetime, and a +single writer thread owns every write to the input pipe. The writer exists because the +input pipe is anonymous and therefore synchronous: a target that stops reading leaves +`WriteFile` blocked until it is cancelled, and only the writer's own thread handle is +registered for that cancellation. + +Ownership is incremental from before the first allocation. Every handle is wrapped in a +holder carrying an owned flag, the rollback stack always registers `close_if_owned`, and +handing a resource on is `take()` — flag first, value second, never the reverse. + +Two platform asymmetries are deliberate and documented here rather than hidden: + +* The job is a stronger containment than a POSIX process group. It survives `setpgid`-style + escapes and the kernel enforces it, whereas PTY hangup delivers a signal a target may + ignore. +* There is no synthetic end-of-file. POSIX injects `VEOF` when no input driver is attached; + ConPTY has no parent-side equivalent that keeps the input channel open, and the channel + has to stay open for the graceful control byte and for terminal-query replies. A script + that reads input therefore blocks until the execution timeout rather than seeing EOF. +""" + +import codecs +import ctypes +import sys +import threading +import time +from contextlib import ExitStack +from ctypes import wintypes +from typing import Callable, List, Optional, Sequence, Tuple + +from plain2code_console import console +from plain2code_exceptions import RenderCancelledError +from render_machine._conpty_support import ( + WRITER_JOIN_DEADLINE_SECONDS, + GateDecision, + InputQueue, + InputWriter, + WriteAborted, + WriteChannel, + build_command_line, + build_environment_block, + reply_resolution, + validate_working_directory, +) +from render_machine.output_normalizer import OutputNormalizer +from render_machine.terminal_process import ( + CONTROL_DELIVERY_DEADLINE_SECONDS, + DEFAULT_TERM, + DRAIN_DEADLINE_SECONDS, + GRACE_TICK_SECONDS, + HANDSHAKE_TIMEOUT_SECONDS, + POLL_INTERVAL_SECONDS, + READ_CHUNK_BYTES, + REAP_DEADLINE_SECONDS, + SIGTERM_GRACE_PERIOD_SECONDS, + TERMINAL_COLUMNS, + TERMINAL_ROWS, + InputWriteResult, + TerminalEnvironmentError, + TerminalProcess, + child_environment, +) +from render_machine.terminal_queries import TerminalQueryResponder + +if sys.platform != "win32": # pragma: no cover - the ConPTY backend is Windows-only + raise ImportError("render_machine._conpty is Windows-only") + +# ------------------------------------------------------------------ FFI: types +# +# Declared before any lifecycle code. ctypes converts return values as c_int by default, +# which truncates 64-bit handles, heap pointers and attribute-list addresses before any +# ownership rule can help, so every imported function below carries explicit argtypes and +# restype: pointer-width types for HANDLE / HPCON / PVOID / SIZE_T, BOOL for the Win32 BOOL +# APIs, signed 32-bit for the HRESULT-returning pseudoconsole calls, and None for the void +# ones. + +kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + +HANDLE = wintypes.HANDLE +PHANDLE = ctypes.POINTER(HANDLE) +HPCON = wintypes.HANDLE +PHPCON = ctypes.POINTER(HPCON) +DWORD = wintypes.DWORD +LPDWORD = ctypes.POINTER(DWORD) +BOOL = wintypes.BOOL +PBOOL = ctypes.POINTER(BOOL) +LPVOID = ctypes.c_void_p +SIZE_T = ctypes.c_size_t +PSIZE_T = ctypes.POINTER(SIZE_T) +ULONG_PTR = ctypes.c_size_t +LARGE_INTEGER = wintypes.LARGE_INTEGER + +S_OK = 0 + +EXTENDED_STARTUPINFO_PRESENT = 0x00080000 +CREATE_UNICODE_ENVIRONMENT = 0x00000400 +PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x00020016 +PROC_THREAD_ATTRIBUTE_JOB_LIST = 0x0002000D +PROC_THREAD_ATTRIBUTE_COUNT = 2 + +JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 +JOBOBJECT_BASIC_ACCOUNTING_INFORMATION_CLASS = 1 +JOBOBJECT_EXTENDED_LIMIT_INFORMATION_CLASS = 9 + +THREAD_TERMINATE = 0x0001 + +ERROR_HANDLE_EOF = 38 +ERROR_BROKEN_PIPE = 109 +ERROR_INSUFFICIENT_BUFFER = 122 +ERROR_OPERATION_ABORTED = 995 +ERROR_NOT_FOUND = 1168 + +WAIT_OBJECT_0 = 0 + +# ConPTY ships from Windows 10 1809. Below it there is no fallback: a silent downgrade to +# pipes would make execution behaviour depend on the machine again. +MIN_CONPTY_BUILD = 17763 + +# The forced-termination exit code the job reports for its members. +JOB_TERMINATION_EXIT_CODE = 1 + +# How long the finalizer keeps retrying a teardown the foreground had to abandon. +FINALIZER_DEADLINE_SECONDS = 60.0 +FINALIZER_TICK_SECONDS = 0.5 + +_OWNER_PARENT = "parent" +_OWNER_READER = "reader" + +# The graceful signal: writing 0x03 into the pseudoconsole input is how terminal emulators +# deliver Ctrl-C to a ConPTY client. `GenerateConsoleCtrlEvent` cannot be used, because it +# reaches only processes sharing the caller's console and the target is on the pseudoconsole. +CONTROL_C_BYTE = b"\x03" + + +class COORD(ctypes.Structure): + _fields_ = [("X", ctypes.c_short), ("Y", ctypes.c_short)] + + +class STARTUPINFOW(ctypes.Structure): + _fields_ = [ + ("cb", DWORD), + ("lpReserved", wintypes.LPWSTR), + ("lpDesktop", wintypes.LPWSTR), + ("lpTitle", wintypes.LPWSTR), + ("dwX", DWORD), + ("dwY", DWORD), + ("dwXSize", DWORD), + ("dwYSize", DWORD), + ("dwXCountChars", DWORD), + ("dwYCountChars", DWORD), + ("dwFillAttribute", DWORD), + ("dwFlags", DWORD), + ("wShowWindow", wintypes.WORD), + ("cbReserved2", wintypes.WORD), + ("lpReserved2", ctypes.POINTER(ctypes.c_byte)), + ("hStdInput", HANDLE), + ("hStdOutput", HANDLE), + ("hStdError", HANDLE), + ] + + +class STARTUPINFOEXW(ctypes.Structure): + _fields_ = [("StartupInfo", STARTUPINFOW), ("lpAttributeList", LPVOID)] + + +class PROCESS_INFORMATION(ctypes.Structure): + _fields_ = [("hProcess", HANDLE), ("hThread", HANDLE), ("dwProcessId", DWORD), ("dwThreadId", DWORD)] + + +class IO_COUNTERS(ctypes.Structure): + _fields_ = [ + ("ReadOperationCount", ctypes.c_ulonglong), + ("WriteOperationCount", ctypes.c_ulonglong), + ("OtherOperationCount", ctypes.c_ulonglong), + ("ReadTransferCount", ctypes.c_ulonglong), + ("WriteTransferCount", ctypes.c_ulonglong), + ("OtherTransferCount", ctypes.c_ulonglong), + ] + + +class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("PerProcessUserTimeLimit", LARGE_INTEGER), + ("PerJobUserTimeLimit", LARGE_INTEGER), + ("LimitFlags", DWORD), + ("MinimumWorkingSetSize", SIZE_T), + ("MaximumWorkingSetSize", SIZE_T), + ("ActiveProcessLimit", DWORD), + ("Affinity", ULONG_PTR), + ("PriorityClass", DWORD), + ("SchedulingClass", DWORD), + ] + + +class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION), + ("IoInfo", IO_COUNTERS), + ("ProcessMemoryLimit", SIZE_T), + ("JobMemoryLimit", SIZE_T), + ("PeakProcessMemoryUsed", SIZE_T), + ("PeakJobMemoryUsed", SIZE_T), + ] + + +class JOBOBJECT_BASIC_ACCOUNTING_INFORMATION(ctypes.Structure): + _fields_ = [ + ("TotalUserTime", LARGE_INTEGER), + ("TotalKernelTime", LARGE_INTEGER), + ("ThisPeriodTotalUserTime", LARGE_INTEGER), + ("ThisPeriodTotalKernelTime", LARGE_INTEGER), + ("TotalPageFaultCount", DWORD), + ("TotalProcesses", DWORD), + ("ActiveProcesses", DWORD), + ("TotalTerminatedProcesses", DWORD), + ] + + +# -------------------------------------------------------------- FFI: functions + + +def _declare(name: str, argtypes: Sequence[object], restype: Optional[object]): + function = getattr(kernel32, name) + function.argtypes = list(argtypes) + function.restype = restype + return function + + +_declare("CloseHandle", [HANDLE], BOOL) +_declare("CreatePipe", [PHANDLE, PHANDLE, LPVOID, DWORD], BOOL) +_declare("ReadFile", [HANDLE, LPVOID, DWORD, LPDWORD, LPVOID], BOOL) +_declare("WriteFile", [HANDLE, LPVOID, DWORD, LPDWORD, LPVOID], BOOL) +_declare("GetProcessHeap", [], HANDLE) +_declare("HeapAlloc", [HANDLE, DWORD, SIZE_T], LPVOID) +_declare("HeapFree", [HANDLE, DWORD, LPVOID], BOOL) +_declare("InitializeProcThreadAttributeList", [LPVOID, DWORD, DWORD, PSIZE_T], BOOL) +_declare("UpdateProcThreadAttribute", [LPVOID, DWORD, ULONG_PTR, LPVOID, SIZE_T, LPVOID, PSIZE_T], BOOL) +_declare("DeleteProcThreadAttributeList", [LPVOID], None) +_declare( + "CreateProcessW", + [ + wintypes.LPCWSTR, + wintypes.LPWSTR, + LPVOID, + LPVOID, + BOOL, + DWORD, + LPVOID, + wintypes.LPCWSTR, + ctypes.POINTER(STARTUPINFOEXW), + ctypes.POINTER(PROCESS_INFORMATION), + ], + BOOL, +) +_declare("CreateJobObjectW", [LPVOID, wintypes.LPCWSTR], HANDLE) +_declare("SetInformationJobObject", [HANDLE, ctypes.c_int, LPVOID, DWORD], BOOL) +_declare("QueryInformationJobObject", [HANDLE, ctypes.c_int, LPVOID, DWORD, LPDWORD], BOOL) +_declare("TerminateJobObject", [HANDLE, wintypes.UINT], BOOL) +_declare("IsProcessInJob", [HANDLE, HANDLE, PBOOL], BOOL) +_declare("GetExitCodeProcess", [HANDLE, LPDWORD], BOOL) +_declare("WaitForSingleObject", [HANDLE, DWORD], DWORD) +_declare("OpenThread", [DWORD, BOOL, DWORD], HANDLE) +_declare("CancelSynchronousIo", [HANDLE], BOOL) + + +def _declare_pseudoconsole_api() -> bool: + """Binds the three ConPTY entry points, or reports that this build has none. + + Their restype is signed 32-bit rather than `ctypes.HRESULT`, which would raise an + `OSError` of its own: the failure has to be reported as the HRESULT itself, because + these calls do not promise to set the last error. + """ + if not hasattr(kernel32, "CreatePseudoConsole"): + return False + _declare("CreatePseudoConsole", [COORD, HANDLE, HANDLE, DWORD, PHPCON], ctypes.c_long) + _declare("ResizePseudoConsole", [HPCON, COORD], ctypes.c_long) + _declare("ClosePseudoConsole", [HPCON], None) + return True + + +PSEUDOCONSOLE_AVAILABLE = _declare_pseudoconsole_api() + + +# ------------------------------------------------------------------- FFI: errors + + +def _win_error(action: str, error: int) -> TerminalEnvironmentError: + return TerminalEnvironmentError(f"{action} failed: Windows error {error} ({ctypes.FormatError(error)}).") + + +def _hresult_error(action: str, hresult: int) -> TerminalEnvironmentError: + return TerminalEnvironmentError(f"{action} failed: HRESULT 0x{hresult & 0xFFFFFFFF:08X}.") + + +def _windows_build() -> int: + return int(sys.getwindowsversion().build) + + +def _require_pseudoconsole_support() -> None: + build = _windows_build() + if PSEUDOCONSOLE_AVAILABLE and build >= MIN_CONPTY_BUILD: + return + raise TerminalEnvironmentError( + f"This Windows build ({build}) has no pseudoconsole support, so a script cannot be given a " + f"terminal. Codeplain needs Windows 10 build {MIN_CONPTY_BUILD} (1809) or newer. There is no " + "pipe fallback, because execution behaviour must not depend on the machine." + ) + + +def _close_handle(handle: Optional[int]) -> None: + if not handle: + return + kernel32.CloseHandle(handle) + + +# ------------------------------------------------------------------- ownership + + +class _PipePair: + """Validity shared by both endpoints of one pipe. + + A failed `CreatePipe` leaves whatever was in the two slots behind, so neither endpoint + may be closed on that path. One flag, consulted by both closers, is what keeps them from + disagreeing. + """ + + def __init__(self) -> None: + self.valid = False + + +class _Holder: + """One handle plus the flag that says whether this side still owns it. + + `take()` flips the flag and then returns the value. Closing first and disarming + afterwards leaves a window in which rollback holds a handle Windows may already have + recycled — the corruption this helper exists to prevent. + """ + + def __init__(self, pair: Optional[_PipePair] = None) -> None: + self.value = HANDLE() + self.owned = True + self._pair = pair + self._lock = threading.Lock() + + @property + def slot(self): + """The address every API writes straight into, so there is no copy-out step.""" + return ctypes.byref(self.value) + + def handle(self) -> Optional[int]: + if not self.owned or (self._pair is not None and not self._pair.valid): + return None + return self.value.value + + def take(self) -> Optional[int]: + with self._lock: + if not self.owned or (self._pair is not None and not self._pair.valid): + return None + self.owned = False + return self.value.value + + def close_if_owned(self) -> None: + _close_handle(self.take()) + + +class _AttrList: + """The attribute list's two ownership states. + + `InitializeProcThreadAttributeList()` does not allocate, so the buffer and the + initialized list are separate states with separate cleanups: a buffer alone is freed, + while an initialized list is deleted first and only then freed. + """ + + def __init__(self) -> None: + self.buffer: Optional[int] = None + self.initialized = False + self.owned = True + + def dispose(self) -> None: + buffer, self.buffer = self.buffer, None + initialized, self.initialized = self.initialized, False + self.owned = False + if buffer is None: + return + if initialized: + kernel32.DeleteProcThreadAttributeList(buffer) + kernel32.HeapFree(kernel32.GetProcessHeap(), 0, buffer) + + def dispose_if_owned(self) -> None: + if self.owned: + self.dispose() + + +class _ProcInfo: + """`PROCESS_INFORMATION`: two handles the kernel fills into one pre-owned struct. + + Both are owned from the moment the call returns. Recording only the process handle and + leaving the thread handle for later means an unwind at the next check leaks it. + """ + + def __init__(self) -> None: + self.pi = PROCESS_INFORMATION() + self.valid = False + self._lock = threading.Lock() + + def _take(self, name: str) -> Optional[int]: + with self._lock: + if not self.valid: # a failed call leaves garbage in both fields + return None + handle = getattr(self.pi, name) + setattr(self.pi, name, None) + return handle + + def take_process(self) -> Optional[int]: + return self._take("hProcess") + + def take_thread(self) -> Optional[int]: + return self._take("hThread") + + def process_handle(self) -> Optional[int]: + return self.pi.hProcess if self.valid else None + + def close_all(self) -> None: + _close_handle(self.take_process()) + _close_handle(self.take_thread()) + + +class _ReaderHandles: + """The output read handle, whose ownership moves to the reader in one assignment. + + `owner` is the single field that decides, so rollback and reader can never disagree and + there is no state in which the handle has left one owner without reaching the other. + """ + + def __init__(self, pair: _PipePair) -> None: + self.owner = _OWNER_PARENT + self.out_r = HANDLE() + self._pair = pair + self._lock = threading.Lock() + + @property + def slot(self): + return ctypes.byref(self.out_r) + + def take(self) -> Optional[int]: + with self._lock: + if not self._pair.valid: + return None + handle = self.out_r.value + self.out_r = HANDLE() + return handle + + def close_if_owner_is_parent(self) -> None: + if self.owner == _OWNER_PARENT: + _close_handle(self.take()) + + +# -------------------------------------------------------------- native helpers +# +# Every native step of the spawn sequence goes through one of these, so a test can fail a +# single step and assert what the rollback releases. + + +def _create_pipe(read_holder, write_holder, pair: _PipePair) -> None: + ok = kernel32.CreatePipe(read_holder.slot, write_holder.slot, None, 0) + if not ok: + error = ctypes.get_last_error() # captured before formatting or any other call + raise _win_error("Creating a terminal pipe", error) + pair.valid = True + + +def _create_job() -> int: + handle = kernel32.CreateJobObjectW(None, None) + if not handle: + error = ctypes.get_last_error() + raise _win_error("Creating the job object for the script's process tree", error) + return handle + + +def _set_kill_on_job_close(job: int) -> None: + """The crash-safety backstop: the kernel tears the tree down when the last handle closes.""" + limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION() + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + ok = kernel32.SetInformationJobObject( + job, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION_CLASS, + ctypes.byref(limits), + ctypes.sizeof(limits), + ) + if not ok: + error = ctypes.get_last_error() + raise _win_error("Configuring the job object", error) + + +def _create_pseudoconsole(columns: int, rows: int, in_r: int, out_w: int, slot) -> None: + size = COORD(ctypes.c_short(columns), ctypes.c_short(rows)) + hresult = kernel32.CreatePseudoConsole(size, in_r, out_w, 0, slot) + if hresult != S_OK: # HRESULT, not BOOL: success is zero and failure is everything else + raise _hresult_error("Creating the pseudoconsole", hresult) + + +def _initialize_attribute_list(attrs: _AttrList, count: int) -> None: + """The three-call protocol: size, allocate, initialize. + + The sizing call fails by design, but only one failure is the expected one — anything + else is a real error that must abort rather than flow into a zero-byte allocation. + """ + size = SIZE_T(0) + ok = kernel32.InitializeProcThreadAttributeList(None, count, 0, ctypes.byref(size)) + error = ctypes.get_last_error() + if ok: + raise TerminalEnvironmentError("Sizing the process attribute list unexpectedly succeeded.") + if error != ERROR_INSUFFICIENT_BUFFER: + raise _win_error("Sizing the process attribute list", error) + if size.value == 0: + raise TerminalEnvironmentError("Sizing the process attribute list reported a zero-byte list.") + buffer = kernel32.HeapAlloc(kernel32.GetProcessHeap(), 0, size.value) + if not buffer: # HeapAlloc reports failure by returning NULL rather than raising + raise TerminalEnvironmentError(f"Allocating {size.value} bytes for the process attribute list failed.") + attrs.buffer = buffer # state one: free only + ok = kernel32.InitializeProcThreadAttributeList(buffer, count, 0, ctypes.byref(size)) + if not ok: + error = ctypes.get_last_error() + raise _win_error("Initializing the process attribute list", error) + attrs.initialized = True # state two: delete, then free + + +def _update_attribute(attrs: _AttrList, attribute: int, value, size: int, description: str) -> None: + ok = kernel32.UpdateProcThreadAttribute(attrs.buffer, 0, attribute, value, size, None, None) + if not ok: + error = ctypes.get_last_error() + raise _win_error(f"Adding the {description} to the process attribute list", error) + + +def _create_process( + command_line: str, + directory: Optional[str], + environment: str, + attrs: _AttrList, + proc: _ProcInfo, +) -> None: + startup = STARTUPINFOEXW() + startup.StartupInfo.cb = ctypes.sizeof(STARTUPINFOEXW) + startup.lpAttributeList = attrs.buffer + # CreateProcessW may modify lpCommandLine in place, so it is handed a writable buffer. + command_buffer = ctypes.create_unicode_buffer(command_line) + # The buffer's own terminator supplies the block's second NUL. + environment_buffer = ctypes.create_unicode_buffer(environment) + ok = kernel32.CreateProcessW( + None, + ctypes.cast(command_buffer, wintypes.LPWSTR), + None, + None, + False, + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + ctypes.cast(environment_buffer, LPVOID), + directory, + ctypes.byref(startup), + ctypes.byref(proc.pi), + ) + if not ok: # zero is failure, and ctypes does not raise on it + error = ctypes.get_last_error() + raise _win_error("Starting the script", error) + proc.valid = True # closers ignore the garbage a failed call leaves behind + + +def _open_thread_handle(native_id: int) -> int: + """THREAD_TERMINATE is what `CancelSynchronousIo()` requires. + + The handle is opened while the writer is still parked on its gate: an open handle cannot + be recycled, so every later cancel lands on the writer rather than on whichever thread + inherited its id. + """ + handle = kernel32.OpenThread(THREAD_TERMINATE, False, native_id) + if not handle: + error = ctypes.get_last_error() + raise _win_error("Opening a handle to the terminal input writer", error) + return handle + + +def _job_active_processes(job: int) -> Optional[int]: + info = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION() + returned = DWORD(0) + ok = kernel32.QueryInformationJobObject( + job, + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION_CLASS, + ctypes.byref(info), + ctypes.sizeof(info), + ctypes.byref(returned), + ) + if not ok: + error = ctypes.get_last_error() + console.debug(f"querying the job object reported Windows error {error}") + return None + return int(info.ActiveProcesses) + + +# ------------------------------------------------------------------ the session + + +class _PseudoconsoleInput(WriteChannel): + """`WriteFile` on the input pipe, cancelled through the writer's own thread handle.""" + + def __init__(self, session: "_SessionBundle") -> None: + self._session = session + + def write(self, data: bytes) -> int: + handle = self._session.in_w.handle() + if not handle: + raise BrokenPipeError("the terminal input channel is closed") + written = DWORD(0) + ok = kernel32.WriteFile(handle, data, len(data), ctypes.byref(written), None) + if not ok: + error = ctypes.get_last_error() + if error == ERROR_OPERATION_ABORTED: + raise WriteAborted("the terminal input write was cancelled") + raise _win_error("Writing to the script's terminal input", error) + return int(written.value) + + def cancel(self) -> None: + handle = self._session.writer_handle + if not handle: + return + ok = kernel32.CancelSynchronousIo(handle) + if not ok: + error = ctypes.get_last_error() + if error != ERROR_NOT_FOUND: # nothing was in flight; the next tick tries again + console.debug(f"cancelling the terminal input write reported Windows error {error}") + + +class _SessionBundle: + """Everything that lives as long as the session, plus the one ordered teardown. + + The teardown is invoked explicitly rather than registered as a stack callback: it is the + single step that can time out and hand its resources away, and a callback cannot do that + safely while `ExitStack.close()` is mid-unwind on the same stack. + """ + + def __init__(self, out_w: _Holder, in_pair: _PipePair, in_queue: InputQueue) -> None: + self.out_w = out_w + self.in_w = _Holder(pair=in_pair) + self.in_queue = in_queue + self.writer: Optional[InputWriter] = None + self.writer_handle: Optional[int] = None + self.reader: Optional[threading.Thread] = None + self.hPC = HPCON() + self.hPC_valid = False # a failed HRESULT output is never closable + self.hJob: Optional[int] = None + self.job_array = (HANDLE * 1)() # must outlive the attribute list that points at it + self.proc = _ProcInfo() + self.exit_code: Optional[int] = None + self._lock = threading.Lock() + + # ------------------------------------------------------------- observation + + def poll_exit_code(self) -> Optional[int]: + """Non-blocking exit status. `WaitForSingleObject` decides, so a target that exits + with 259 is not mistaken for one that is still running.""" + with self._lock: + if self.exit_code is not None: + return self.exit_code + handle = self.proc.process_handle() + if not handle: + return None + if kernel32.WaitForSingleObject(handle, 0) != WAIT_OBJECT_0: + return None + code = DWORD(0) + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): + error = ctypes.get_last_error() + console.debug(f"reading the script's exit code reported Windows error {error}") + return None + self.exit_code = int(code.value) + return self.exit_code + + def running(self) -> bool: + """False once the process handle has been released, whatever the target is doing: + nothing after that point may wait on it.""" + return self.proc.process_handle() is not None and self.poll_exit_code() is None + + # ---------------------------------------------------------------- teardown + + def teardown(self, grace: Optional[float]) -> bool: + """The ordered shutdown. True when it ran out of bound and must be handed off. + + Idempotent: every step takes what it releases, so a repeated call finds nothing left + to do. `grace` of None skips the graceful phase, which is what every forced path and + every rollback wants. + """ + self.poll_exit_code() # the only place a status is read; never after a forced kill + if grace is not None and self.running(): + self._graceful_phase(grace) + self._terminate_job() + if not self._await_job_empty(REAP_DEADLINE_SECONDS): + return True + if not self._stop_writer(): + # The writer is still inside a write on `in_w`, which teardown is about to + # close. Closing a handle underneath a blocked write is what the hand-off exists + # to avoid. + return True + self._close_pseudoconsole() + self._release_handles() + return False + + def _graceful_phase(self, grace: float) -> None: + """Delivery and grace are two different bounds: queue delay must not consume the + target's cleanup time.""" + writer = self.writer + if writer is None: + return + if not writer.deliver_control(CONTROL_C_BYTE, CONTROL_DELIVERY_DEADLINE_SECONDS): + return # undelivered: escalate now rather than waiting out a grace nobody received + deadline = time.monotonic() + grace # a fresh monotonic interval, started on delivery + while time.monotonic() < deadline: + if not self.running(): + return + time.sleep(GRACE_TICK_SECONDS) + + def _terminate_job(self) -> None: + job = self.hJob + if job is None: + return + if not kernel32.TerminateJobObject(job, JOB_TERMINATION_EXIT_CODE): + error = ctypes.get_last_error() + console.debug(f"terminating the job object reported Windows error {error}") + + def _await_job_empty(self, bound: float) -> bool: + """Closes the process handles, then waits for the job's membership to reach zero.""" + self.proc.close_all() + job = self.hJob + if job is None: + return True + deadline = time.monotonic() + bound + while True: + active = _job_active_processes(job) + if active is None or active == 0: + return True + if time.monotonic() >= deadline: + console.debug(f"the job object still held {active} processes after {bound}s") + return False + time.sleep(POLL_INTERVAL_SECONDS) + + def _stop_writer(self) -> bool: + writer = self.writer + if writer is None: + return True + return writer.stop(WRITER_JOIN_DEADLINE_SECONDS) + + def _close_pseudoconsole(self) -> None: + """The precondition is the output pipe: drained *or* closed, never neither. + + One sequence serves both branches, which is why there is no test on the reader here: + the close happens while a live reader is still draining, and a reader that has + already failed closed the read handle before it published anything, so the same call + finds the pipe closed. The join only follows the close, never precedes it, and this + never runs on the reader thread. + """ + if not self.hPC_valid: + return + self.hPC_valid = False + _close_handle(self.out_w.take()) # the write side must go, or the reader never sees EOF + kernel32.ClosePseudoConsole(self.hPC) + self.hPC = HPCON() + reader = self.reader + if reader is not None and reader.ident is not None: + reader.join(timeout=DRAIN_DEADLINE_SECONDS) + + def _release_handles(self) -> None: + self.in_w.close_if_owned() # after the writer has stopped, never before + _close_handle(self.writer_handle) + self.writer_handle = None + job, self.hJob = self.hJob, None + _close_handle(job) # last: closing it is also the kill-on-close backstop + self.proc.close_all() + + def join_reader(self, bound: float) -> bool: + """Waits for the reader once every handle it could be blocked on is released. + + True when it is still running, which means it still owns state and can still append + to the transcript. + """ + reader = self.reader + if reader is None or reader.ident is None: # None when it never started + return False + reader.join(timeout=bound) + return reader.is_alive() + + +class _SessionOwner: + """The session and its rollback stack, as one reference. + + `armed` is the commit flag: while it is set the stack alone owns everything and the + session is not yet a session. Storing the owner early is harmless for exactly that + reason, and the commit is the single flip. + """ + + def __init__(self, session: _SessionBundle, stack: ExitStack) -> None: + self.session = session + self.stack = stack + self.armed = True + + +def _hand_off_to_finalizer(owner: _SessionOwner) -> None: + threading.Thread(target=_finalize_session, args=(owner,), name="codeplain-conpty-finalizer", daemon=True).start() + + +def _finalize_session(owner: _SessionOwner) -> None: + """Finishes a teardown that outlived the foreground's bound, then closes the stack. + + The stack is closed only once the teardown has completed, so there is exactly one owner + at every instant and the transfer never races an unwind in progress. + """ + deadline = time.monotonic() + FINALIZER_DEADLINE_SECONDS + try: + while True: + if not owner.session.teardown(None): + break + if time.monotonic() >= deadline: + console.debug("the terminal session finalizer gave up on an unfinished teardown") + break + time.sleep(FINALIZER_TICK_SECONDS) + except BaseException as exc: # nothing here can be reported anywhere useful + console.debug(f"the terminal session finalizer failed: {exc!r}") + finally: + try: + owner.stack.close() + except BaseException as exc: + console.debug(f"the terminal session finalizer could not release its handles: {exc!r}") + + +# ------------------------------------------------------------------- the backend + + +class ConPtyProcess(TerminalProcess): + """One command, one pseudoconsole, one job, one reader thread, one writer thread.""" + + def __init__(self) -> None: + self.reader_failed = threading.Event() + self.reader_exc: Optional[BaseException] = None + + self._spawned = False + self._closed = False + self._stop_event = threading.Event() + self._input_driver: Optional[object] = None + self._owner: Optional[_SessionOwner] = None + self._writer: Optional[InputWriter] = None + self._input_queue = InputQueue() + + self._output_lock = threading.Lock() + self._decoded: List[str] = [] + self._raw = bytearray() + + # The parser runs live in the reader, because terminals answer queries: a + # render-afterwards parser would leave a querying target hanging. + self.query_responder = TerminalQueryResponder(self._admit_reply) + self.normalizer = OutputNormalizer(reply_handler=self.query_responder.answer) + self._byte_sink: Callable[[bytes], None] = self.normalizer.feed + + # ---------------------------------------------------------------- public API + + def spawn( + self, + command: Sequence[str], + cwd: Optional[str] = None, + env: Optional[dict] = None, + terminal_size: Tuple[int, int] = (TERMINAL_COLUMNS, TERMINAL_ROWS), + stop_event: Optional[threading.Event] = None, + input_driver: Optional[object] = None, + spawn_timeout: float = HANDSHAKE_TIMEOUT_SECONDS, + ) -> None: + if self._spawned: + raise RuntimeError("ConPtyProcess instances are single-use") + self._spawned = True + self._stop_event = stop_event if stop_event is not None else threading.Event() + self._input_driver = input_driver + self._check_cancelled() + _require_pseudoconsole_support() + columns, rows = terminal_size + self.normalizer.resize(columns, rows) + # Marshaling first: an input Windows cannot carry is rejected before anything is + # allocated, and long before there is a process to truncate a command line for. + command_line = build_command_line(command) + directory = validate_working_directory(cwd) + environment = build_environment_block(self._child_env(env)) + self._start_session(command_line, directory, environment, columns, rows, time.monotonic() + spawn_timeout) + + def poll(self) -> Optional[int]: + owner = self._owner + if owner is None: + return None + code = owner.session.poll_exit_code() + if code is not None: + # The execution outcome is observed, so no client is left to answer. + self.query_responder.quiesce() + return code + + def read_output(self) -> str: + with self._output_lock: + text = "".join(self._decoded) + self._decoded.clear() + return text + + def read_raw_output(self) -> bytes: + with self._output_lock: + data = bytes(self._raw) + self._raw.clear() + return data + + def normalized_output(self) -> str: + return self.normalizer.text() + + @property + def terminal_reply_failed(self) -> bool: + return self.query_responder.reply_failed + + def terminal_reply_detail(self) -> str: + return self.query_responder.failure_detail() + + def write_input(self, data: bytes) -> InputWriteResult: + result, _ = self._input_queue.submit(data) + return result + + def infrastructure_failure(self) -> Optional[str]: + detail = super().infrastructure_failure() + if detail is not None: + return detail + writer = self._writer + if writer is not None and writer.failed.is_set(): + return f"the terminal input writer failed: {writer.exc!r}" + return None + + def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: + """Graceful control byte, then the job. The grace period is never skipped silently: + an undelivered control byte escalates immediately, a delivered one is given its own + fresh interval.""" + self.query_responder.quiesce() + owner = self._owner + if owner is None: + return + self._shutdown(None if owner.session.poll_exit_code() is not None else grace) + + def close(self) -> None: + if self._closed: + return + self._closed = True + self.query_responder.quiesce() # before either input pump stops + try: + self._shutdown(None) + finally: + self.normalizer.finalize() + owner = self._owner + if owner is not None: + owner.stack.close() # only ever after the teardown has completed + owner.armed = False + # Joined after the stack close, because that is what releases the last write + # handle a reader parked on an early failure path is still waiting for. + if owner.session.join_reader(DRAIN_DEADLINE_SECONDS): + self._publish_reader_stall() + + # ------------------------------------------------------------ spawn sequence + + def _start_session( + self, + command_line: str, + directory: Optional[str], + environment: str, + columns: int, + rows: int, + deadline: float, + ) -> None: + stack = ExitStack() # opens before the first allocation + in_pair, out_pair = _PipePair(), _PipePair() + in_r = _Holder(pair=in_pair) + out_w = _Holder(pair=out_pair) + attrs = _AttrList() + proc = _ProcInfo() + bundle = _ReaderHandles(out_pair) + session = _SessionBundle(out_w, in_pair, self._input_queue) + gate = threading.Event() + # Every owner is registered before the API that fills it, in reverse of unwind order. + for holder in (in_r, out_w): + stack.callback(holder.close_if_owned) + stack.callback(bundle.close_if_owner_is_parent) + stack.callback(attrs.dispose_if_owned) + # The session teardown is deliberately not a stack callback: it is the one step that + # can time out and transfer ownership, which a callback cannot do mid-unwind. + owner = _SessionOwner(session, stack) + self._owner = owner # stored early; harmless while armed + + try: + _create_pipe(in_r, session.in_w, in_pair) + _create_pipe(bundle, out_w, out_pair) + + self._start_reader(session, bundle, gate) + self._start_writer(session, deadline) + self._check_pumps() + + session.hJob = _create_job() + _set_kill_on_job_close(session.hJob) + + in_read = in_r.handle() + out_write = out_w.handle() + assert in_read is not None and out_write is not None + _create_pseudoconsole(columns, rows, in_read, out_write, ctypes.byref(session.hPC)) + session.hPC_valid = True # armed only after S_OK + + _initialize_attribute_list(attrs, PROC_THREAD_ATTRIBUTE_COUNT) + session.job_array[0] = session.hJob + _update_attribute( + attrs, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + session.hPC.value, + ctypes.sizeof(HPCON), + "pseudoconsole", + ) + _update_attribute( + attrs, + PROC_THREAD_ATTRIBUTE_JOB_LIST, + ctypes.addressof(session.job_array), + ctypes.sizeof(session.job_array), + "job list", + ) + + self._check_pumps() + session.proc = proc # attached before the call, as the reader is + _create_process(command_line, directory, environment, attrs, proc) + # The reader can die during process creation, which is the slowest step here, so + # the check runs again on the other side of it. The job already holds the child, + # so this unwind needs no special case. + self._check_pumps() + + # Documented timing: the pseudoconsole owns these two now, and holding + # outputWriteSide open means the reader never observes EOF. + _close_handle(in_r.take()) + _close_handle(out_w.take()) + attrs.dispose() # startup-only, retired after every check has passed + _close_handle(proc.take_thread()) + + owner.armed = False # the commit + except BaseException: + if session.teardown(None): # before any stack unwinding starts + owner.armed = False # disarm first: `finally` runs on this path too + self._owner = None + _hand_off_to_finalizer(owner) + raise TerminalEnvironmentError( + "The script's terminal session could not be released within its bound and was " + "handed to the background finalizer." + ) + raise + finally: + if owner.armed: + stack.close() # releases exactly what never transferred + + def _start_reader(self, session: _SessionBundle, bundle: _ReaderHandles, gate: threading.Event) -> None: + """Starts before the pseudoconsole exists, because its teardown depends on a drainer. + + The thread is attached to the session before the commit, so every failure between + here and `CreateProcessW` unwinds with a reader the teardown can still join. + """ + reader = threading.Thread( + target=self._reader_main, args=(bundle, gate), name="codeplain-conpty-reader", daemon=True + ) + try: + session.reader = reader + reader.start() + bundle.owner = _OWNER_READER # the commit: one assignment, nothing after it + finally: + gate.set() # always: an unopened gate parks the thread forever + + def _start_writer(self, session: _SessionBundle, deadline: float) -> None: + """Gate protocol: the writer publishes its native id and parks, the creator opens a + thread handle while the gate still holds it, and the gate is released with a decision + on every path.""" + writer = InputWriter(session.in_queue, _PseudoconsoleInput(session)) + session.writer = writer + self._writer = writer + decision = GateDecision.ABORT # initialized before any fallible step + try: + writer.start() + native_id = writer.await_ready(deadline, self._check_spawn_interrupted) + if native_id is None: + raise TerminalEnvironmentError( + f"The terminal input writer did not start: {writer.exc!r}" + if writer.failed.is_set() + else "The terminal input writer did not report itself before the spawn deadline." + ) + session.writer_handle = _open_thread_handle(native_id) + decision = GateDecision.RUN # only after the handle is stored + finally: + writer.gate.set(decision) # always: ABORT wakes the writer to exit untouched + + def _reader_main(self, bundle: _ReaderHandles, gate: threading.Event) -> None: + gate.wait() + if bundle.owner != _OWNER_READER: + return # the parent still owns everything; touch nothing, publish nothing + reader_exc: Optional[BaseException] = None + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + handle = bundle.out_r.value + try: + self._reader_loop(handle, decoder) + except BaseException as exc: # nothing here reaches threading.excepthook + reader_exc = exc + finally: + # Cleanup before publication: a rollback that sees the failure flag can rely on + # the read handle already being closed, which is the branch that makes + # ClosePseudoConsole() safe without a drainer. + _close_handle(bundle.take()) + try: + self._flush_decoder(decoder) + self.normalizer.finalize() + except BaseException as exc: # finalization can fail too + reader_exc = reader_exc or exc + finally: + self.reader_exc = reader_exc # stored while still unobservable + if reader_exc is not None: + self.reader_failed.set() + + def _reader_loop(self, handle: Optional[int], decoder) -> None: + if not handle: + return + buffer = ctypes.create_string_buffer(READ_CHUNK_BYTES) + read = DWORD(0) + while True: + ok = kernel32.ReadFile(handle, buffer, READ_CHUNK_BYTES, ctypes.byref(read), None) + if not ok: + error = ctypes.get_last_error() + if error in (ERROR_BROKEN_PIPE, ERROR_HANDLE_EOF, ERROR_OPERATION_ABORTED): + return # the pseudoconsole released its end + raise _win_error("Reading the script's terminal output", error) + if read.value == 0: + return + self._feed_output(buffer.raw[: read.value], decoder) + + def _feed_output(self, chunk: bytes, decoder) -> None: + text = decoder.decode(chunk) + with self._output_lock: + self._raw += chunk + if text: + self._decoded.append(text) + self._byte_sink(chunk) # outside the output lock: parsing must not block read_output() + + def _flush_decoder(self, decoder) -> None: + tail = decoder.decode(b"", final=True) # a trailing partial sequence becomes U+FFFD + if tail: + with self._output_lock: + self._decoded.append(tail) + + # ------------------------------------------------------------------ internals + + def _admit_reply(self, payload: bytes, on_complete: Callable[[Optional[str]], None]) -> None: + """One non-blocking whole-item admission of a terminal reply, from the reader. + + Replies take the reserved partition because they are terminal protocol: a caller + saturating the queue with input must not starve a required response. They keep their + place in the data lane, so a reply never overtakes input the caller sent first. + """ + self._input_queue.submit(payload, reserved=True, on_resolve=reply_resolution(on_complete)) + + def _child_env(self, env: Optional[dict]) -> dict: + child_env = child_environment(env) + term = child_env.get("TERM") + child_env["TERM"] = term if term else DEFAULT_TERM + # git reads the console directly, so no redirection can reach a credential prompt; + # failing is the only bounded outcome. + child_env["GIT_TERMINAL_PROMPT"] = "0" + return child_env + + def _shutdown(self, grace: Optional[float]) -> None: + owner = self._owner + if owner is None: + return + if owner.session.teardown(grace): + owner.armed = False # disarm before publishing: the finalizer owns the stack now + self._owner = None + _hand_off_to_finalizer(owner) + raise TerminalEnvironmentError( + "The script's terminal session did not shut down within its bound and was handed to " + "the background finalizer." + ) + + def _check_cancelled(self) -> None: + if self._stop_event.is_set(): + raise RenderCancelledError() + + def _check_pumps(self) -> None: + detail = self.infrastructure_failure() + if detail is not None: + raise TerminalEnvironmentError(f"The terminal backend failed while starting the script: {detail}") + + def _check_spawn_interrupted(self) -> None: + self._check_cancelled() + self._check_pumps() diff --git a/render_machine/_legacy_pipe.py b/render_machine/_legacy_pipe.py index aeedcaaa..d3b9c913 100644 --- a/render_machine/_legacy_pipe.py +++ b/render_machine/_legacy_pipe.py @@ -1,9 +1,9 @@ """Legacy pipe backend for `TerminalProcess`. Wraps the `Popen(stdout=PIPE, stderr=STDOUT, start_new_session=True)` path Codeplain -shipped before the PTY, behind the same interface. It survives for two reasons: it is the -`CODEPLAIN_NO_PTY` escape hatch, and it is the Windows interim until the ConPTY backend -lands. +shipped before the PTY, behind the same interface. It survives for one reason: it is the +`CODEPLAIN_NO_PTY` escape hatch, on POSIX and on Windows alike. Neither platform selects +it automatically. The child's stdin is `DEVNULL`, permanently and on every platform. A child without a terminal of its own would otherwise inherit Codeplain's fd 0, and `start_new_session=True` diff --git a/render_machine/terminal_process.py b/render_machine/terminal_process.py index 070c7e7d..737d7a1e 100644 --- a/render_machine/terminal_process.py +++ b/render_machine/terminal_process.py @@ -2,7 +2,7 @@ A `TerminalProcess` runs one command with a terminal behind all three of its standard descriptors and owns every handle that arrangement needs. The POSIX implementation lives -in `render_machine._posix_pty`; the Windows ConPTY implementation will live in +in `render_machine._posix_pty` and the Windows ConPTY implementation in `render_machine._conpty`. Only this module is imported by callers. """ @@ -38,6 +38,10 @@ # Every duration below is a monotonic budget, never wall time. HANDSHAKE_TIMEOUT_SECONDS = 20.0 SIGTERM_GRACE_PERIOD_SECONDS = 3.0 +# Bounds the delivery of a graceful control byte, which on Windows travels through a +# synchronous pipe a wedged target may never read. It is never the grace period itself: +# queue delay must not silently consume the handler's time. +CONTROL_DELIVERY_DEADLINE_SECONDS = 2.0 GRACE_TICK_SECONDS = 0.05 REAP_DEADLINE_SECONDS = 5.0 DRAIN_DEADLINE_SECONDS = 2.0 @@ -162,6 +166,17 @@ def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: def close(self) -> None: raise NotImplementedError + def infrastructure_failure(self) -> Optional[str]: + """Detail of a failed backend pump, or None while they are all healthy. + + The output reader is the one pump every backend has. A backend that runs more of + them — the Windows input writer — reports them here too, so the execution loop has + one question to ask rather than one per platform. + """ + if self.reader_failed.is_set(): + return f"the terminal output reader failed: {self.reader_exc!r}" + return None + def _publish_reader_stall(self) -> None: """Publishes a reader that close() could not join, and refuses to return quietly. @@ -216,11 +231,9 @@ def create_terminal_process() -> TerminalProcess: return LegacyPipeProcess() if sys.platform == "win32": - # Interim: Windows has no PTY backend yet, so it stays on the documented legacy - # pipe path until the ConPTY backend lands (ENG-34, Phase 6). - from render_machine._legacy_pipe import LegacyPipeProcess + from render_machine._conpty import ConPtyProcess - return LegacyPipeProcess() + return ConPtyProcess() from render_machine._posix_pty import PosixPtyProcess From eda6023bc2c4e8ddfa76f107905d0c86f982dad5 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 02:43:44 +0200 Subject: [PATCH 31/83] Report every backend pump failure through one execution-loop check The execution loop asked reader_failed directly, which leaves the Windows input writer's failures invisible: a dead consumer would keep accepting enqueues and the graceful control byte would reach nobody. Backends now answer one question. The no-input timeout diagnostic gains the Windows clause: ConPTY carries no synthetic end-of-file, so a script that reads input blocks to the timeout. --- render_machine/render_utils.py | 41 ++++++++++++++++++++++++---------- tests/test_render_utils.py | 12 ++++++++++ 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/render_machine/render_utils.py b/render_machine/render_utils.py index d8418d96..cf330353 100644 --- a/render_machine/render_utils.py +++ b/render_machine/render_utils.py @@ -28,11 +28,30 @@ # than on bytes written: a script that blocks on input has written nothing either way. INPUT_DRIVER: Optional[object] = None -NO_INPUT_DIAGNOSTIC = ( +NO_INPUT_DIAGNOSTIC_BASE = ( " No input driver was attached to the script's terminal, so a script that waits for input " "never receives any and runs to the timeout." ) +# A documented platform asymmetry, not an implementation detail. POSIX injects the +# terminal's EOF byte at spawn when no input driver is attached, so a script that reads +# input sees end-of-file at once. ConPTY has no parent-side equivalent that leaves the input +# channel open, and the channel has to stay open for the graceful control byte and for +# terminal-query replies, so the same script blocks until the timeout. +WINDOWS_NO_EOF_DIAGNOSTIC = ( + " On Windows the terminal carries no synthetic end-of-file, so such a script blocks until the " + "timeout instead of reading end-of-file." +) + + +def no_input_diagnostic(platform: str) -> str: + if platform == "win32": + return NO_INPUT_DIAGNOSTIC_BASE + WINDOWS_NO_EOF_DIAGNOSTIC + return NO_INPUT_DIAGNOSTIC_BASE + + +NO_INPUT_DIAGNOSTIC = no_input_diagnostic(sys.platform) + # Conditions the arbiter chooses between, highest precedence last. CONDITION_EXIT = "exit" CONDITION_TIMEOUT = "timeout" @@ -131,10 +150,6 @@ def __init__(self) -> None: self.reply_detail = "" -def _reader_failure_detail(process: TerminalProcess) -> str: - return f"the terminal output reader failed: {process.reader_exc!r}" - - def _await_target( process: TerminalProcess, script_timeout: float, @@ -156,8 +171,9 @@ def _await_target( outcome.cancelled() if time.monotonic() >= deadline: outcome.timed_out() - if process.reader_failed.is_set(): - outcome.infrastructure_failed(_reader_failure_detail(process)) + pump_failure = process.infrastructure_failure() + if pump_failure is not None: + outcome.infrastructure_failed(pump_failure) if outcome.decided(): return if stop_event is not None: @@ -177,11 +193,12 @@ def _teardown(process: TerminalProcess, outcome: _ScriptOutcome) -> None: outcome.infrastructure_failed(str(exc)) except Exception as exc: outcome.infrastructure_failed(f"the terminal backend failed while shutting down: {exc!r}") - # Deliberately checked after teardown and at the highest precedence: a reader that - # died independently is an environment failure even when it surfaces while a timeout - # or a cancellation is being cleaned up. - if process.reader_failed.is_set(): - outcome.infrastructure_failed(_reader_failure_detail(process)) + # Deliberately checked after teardown and at the highest precedence: a pump that died + # independently is an environment failure even when it surfaces while a timeout or a + # cancellation is being cleaned up. + pump_failure = process.infrastructure_failure() + if pump_failure is not None: + outcome.infrastructure_failed(pump_failure) def _record_backend_failure(outcome: _ScriptOutcome, exc: Exception, phase: str) -> None: diff --git a/tests/test_render_utils.py b/tests/test_render_utils.py index ec35a106..97a8e448 100644 --- a/tests/test_render_utils.py +++ b/tests/test_render_utils.py @@ -662,3 +662,15 @@ def test_the_timeout_message_names_the_absent_input_driver(tmp_path, run_script) assert exit_code == render_utils.TIMEOUT_ERROR_EXIT_CODE assert "no input driver was attached" in output.lower() assert "no input driver was attached" in Path(output_file).read_text().lower() + + +def test_the_no_input_diagnostic_names_the_platform_asymmetry_on_windows(): + """POSIX injects the terminal's EOF byte at spawn and ConPTY has no equivalent, so the + same script behaves differently and the message has to say so.""" + posix = render_utils.no_input_diagnostic("darwin") + windows = render_utils.no_input_diagnostic("win32") + + assert posix == render_utils.NO_INPUT_DIAGNOSTIC_BASE + assert windows.startswith(posix) + assert "end-of-file" in windows + assert render_utils.NO_INPUT_DIAGNOSTIC == render_utils.no_input_diagnostic(sys.platform) From fa72d18c88fb80a8ff4ea2db7be5444b713e3f23 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 02:45:50 +0200 Subject: [PATCH 32/83] Test the ConPTY lifecycle and backend selection on Windows Declaration checks per return convention, the live lifecycle (console, job membership, descendant termination, graceful signal, stored-session input), one fault injection per native step asserting no process, thread or handle survives, and the finalizer hand-off. The escape-hatch selection cases lose their POSIX-only mark: the hatch is read before the platform branch, so win32 with CODEPLAIN_NO_PTY=1 is covered too. --- tests/test_conpty.py | 607 ++++++++++++++++++++++++++++++ tests/test_no_pty_escape_hatch.py | 17 +- 2 files changed, 616 insertions(+), 8 deletions(-) create mode 100644 tests/test_conpty.py diff --git a/tests/test_conpty.py b/tests/test_conpty.py new file mode 100644 index 00000000..9e5666ad --- /dev/null +++ b/tests/test_conpty.py @@ -0,0 +1,607 @@ +"""The ConPTY backend on native Windows. + +Every case here allocates real pseudoconsoles, jobs, pipes and processes, so the whole +module is Windows-only and each helper is responsible for leaving nothing behind. The +fault-injection cases fail one native step at a time and assert what the rollback releases: +no process, no thread, no handle, and — on the paths that reach it — no pseudoconsole +closed on the foreground thread. + +The teardown-completes-within-a-bound assertions are only meaningful on a build in the +range where `ClosePseudoConsole()` can block, which is why CI runs this module on a +`windows-2022` image as well as on `windows-latest`. +""" + +import ctypes +import os +import signal +import sys +import textwrap +import threading +import time +from pathlib import Path + +import pytest + +if sys.platform != "win32": + # The module binds kernel32 at import time, so collection has to stop here rather than + # leaving the cases to a skip mark. + pytest.skip("The ConPTY backend is not built off Windows.", allow_module_level=True) + +from ctypes import wintypes # noqa: E402 + +from render_machine import _conpty # noqa: E402 +from render_machine._conpty import ConPtyProcess # noqa: E402 +from render_machine._legacy_pipe import LegacyPipeProcess # noqa: E402 +from render_machine.terminal_process import ( # noqa: E402 + NO_PTY_ENV_VAR, + InputDisposition, + TerminalEnvironmentError, + create_terminal_process, +) + +# Generous relative to the operations they cover, so a failure means a hang rather than a +# slow machine. +SPAWN_TIMEOUT = 30.0 +WAIT_TIMEOUT = 30.0 +TEARDOWN_BOUND = 25.0 +POLL = 0.05 + +SYNCHRONIZE = 0x00100000 +PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +WAIT_OBJECT_0 = 0 + +# The backend's own binding, used only to assert its declarations and to observe the calls +# it makes. Everything this module calls for its own purposes goes through a second binding, +# so a test never adds a declaration production code then depends on. +kernel32 = _conpty.kernel32 + +probe = ctypes.WinDLL("kernel32", use_last_error=True) +probe.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] +probe.OpenProcess.restype = wintypes.HANDLE +probe.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] +probe.WaitForSingleObject.restype = wintypes.DWORD +probe.CloseHandle.argtypes = [wintypes.HANDLE] +probe.CloseHandle.restype = wintypes.BOOL +probe.GetCurrentProcess.argtypes = [] +probe.GetCurrentProcess.restype = wintypes.HANDLE +probe.GetProcessHandleCount.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] +probe.GetProcessHandleCount.restype = wintypes.BOOL + + +def write_program(tmp_path: Path, name: str, source: str) -> str: + path = tmp_path / f"{name}.py" + path.write_text(textwrap.dedent(source), encoding="utf-8") + return str(path) + + +def command(script: str, *args: str): + return [sys.executable, "-I", script, *args] + + +def wait_for(predicate, timeout=WAIT_TIMEOUT): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(POLL) + return bool(predicate()) + + +def wait_for_output(process, needle, timeout=WAIT_TIMEOUT): + return wait_for(lambda: needle in process.normalized_output(), timeout) + + +def wait_for_exit(process, timeout=WAIT_TIMEOUT): + assert wait_for(lambda: process.poll() is not None, timeout), "the script never exited" + return process.poll() + + +def process_is_gone(pid: int, timeout=WAIT_TIMEOUT) -> bool: + handle = probe.OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return True # already reaped, so there is nothing left to wait for + try: + return probe.WaitForSingleObject(handle, int(timeout * 1000)) == WAIT_OBJECT_0 + finally: + probe.CloseHandle(handle) + + +def handle_count() -> int: + count = wintypes.DWORD(0) + probe.GetProcessHandleCount(probe.GetCurrentProcess(), ctypes.byref(count)) + return int(count.value) + + +def live_backend_threads(): + return [thread for thread in threading.enumerate() if thread.name.startswith("codeplain-conpty-")] + + +@pytest.fixture +def backend(): + process = ConPtyProcess() + try: + yield process + finally: + try: + process.terminate_tree(grace=0.1) + except TerminalEnvironmentError: + pass + try: + process.close() + except TerminalEnvironmentError: + pass + + +# The probe reports what a script sees, one short line at a time: the pseudoconsole wraps at +# the configured width, so a single long line would come back folded. +TERMINAL_PROBE = """ + import ctypes + import os + import sys + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + mode = ctypes.c_uint(0) + console = kernel32.GetConsoleMode(kernel32.GetStdHandle(-11), ctypes.byref(mode)) + in_job = ctypes.c_int(0) + kernel32.IsProcessInJob(kernel32.GetCurrentProcess(), None, ctypes.byref(in_job)) + + print("ISATTY=%s" % (os.isatty(0) and os.isatty(1) and os.isatty(2))) + print("CONSOLE=%s" % bool(console)) + print("INJOB=%s" % bool(in_job.value)) + print("TERM=%s" % os.environ.get("TERM")) + print("DONE") + sys.stdout.flush() +""" + + +# ------------------------------------------------------------------ declarations + + +def test_every_handle_returning_call_is_declared_pointer_wide(): + """ctypes converts a return value as c_int unless told otherwise, which truncates a + 64-bit handle long before any ownership rule can help.""" + pointer_width = ctypes.sizeof(ctypes.c_void_p) + + for name in ("CreateJobObjectW", "OpenThread", "GetProcessHeap"): + assert ctypes.sizeof(getattr(kernel32, name).restype) == pointer_width, name + assert ctypes.sizeof(kernel32.HeapAlloc.restype) == pointer_width + assert ctypes.sizeof(_conpty.HANDLE) == pointer_width + + +def test_the_void_calls_are_declared_as_returning_nothing(): + assert kernel32.ClosePseudoConsole.restype is None + assert kernel32.DeleteProcThreadAttributeList.restype is None + + +def test_the_pseudoconsole_calls_are_declared_as_signed_32_bit_results(): + """HRESULT is the inverse of the BOOL convention every other call here uses.""" + assert ctypes.sizeof(kernel32.CreatePseudoConsole.restype) == 4 + assert kernel32.CreatePseudoConsole.restype(-1).value == -1 + + +def test_an_allocated_pointer_survives_the_declared_return_type(): + heap = kernel32.GetProcessHeap() + buffer = kernel32.HeapAlloc(heap, 0, 4096) + try: + assert buffer is not None and buffer > 0 + # A truncating declaration turns a pointer with high bits set into a negative int. + assert buffer == ctypes.c_void_p(buffer).value + finally: + assert kernel32.HeapFree(heap, 0, buffer) + + +def test_a_failing_bool_call_reports_the_captured_last_error(): + ok = probe.CloseHandle(wintypes.HANDLE(0)) + error = ctypes.get_last_error() + + assert not ok + assert str(error) in str(_conpty._win_error("Closing a handle", error)) + + +def test_a_failing_pseudoconsole_call_is_reported_as_its_hresult(): + """An invalid input handle: the failure has to be detected as a nonzero HRESULT rather + than read from the last error, which these calls do not promise to set.""" + slot = _conpty.HPCON() + + with pytest.raises(TerminalEnvironmentError) as error: + _conpty._create_pseudoconsole(80, 25, -2, -2, ctypes.byref(slot)) + + assert "HRESULT" in str(error.value) + + +def test_a_build_without_pseudoconsole_support_is_an_environment_error(monkeypatch): + monkeypatch.setattr(_conpty, "PSEUDOCONSOLE_AVAILABLE", False) + + with pytest.raises(TerminalEnvironmentError) as error: + _conpty._require_pseudoconsole_support() + + assert str(_conpty.MIN_CONPTY_BUILD) in str(error.value) + assert "fallback" in str(error.value) + + +# --------------------------------------------------------------- backend selection + + +def test_windows_selects_the_conpty_backend(monkeypatch): + monkeypatch.delenv(NO_PTY_ENV_VAR, raising=False) + + process = create_terminal_process() + try: + assert isinstance(process, ConPtyProcess) + finally: + process.close() + + +def test_the_escape_hatch_still_selects_the_pipe_backend_on_windows(monkeypatch): + """The hatch is cross-platform: it is read before the platform branch, not instead of it.""" + monkeypatch.setenv(NO_PTY_ENV_VAR, "1") + + process = create_terminal_process() + try: + assert isinstance(process, LegacyPipeProcess) + finally: + process.close() + + +# ------------------------------------------------------------------- the lifecycle + + +def test_a_script_runs_on_a_real_console_inside_the_job(backend, tmp_path): + script = write_program(tmp_path, "terminal_probe", TERMINAL_PROBE) + + backend.spawn(command(script)) + exit_code = wait_for_exit(backend) + backend.terminate_tree(grace=0.1) + backend.close() + output = backend.normalized_output() + + assert exit_code == 0 + assert "ISATTY=True" in output + assert "CONSOLE=True" in output + assert "INJOB=True" in output + assert "TERM=xterm-256color" in output + + +def test_the_exit_code_is_reported_verbatim(backend, tmp_path): + script = write_program(tmp_path, "exit_seven", "import sys\nsys.exit(7)\n") + + backend.spawn(command(script)) + + assert wait_for_exit(backend) == 7 + + +def test_input_written_through_the_stored_session_reaches_the_script(backend, tmp_path): + """The one field whose absence only shows up when everything else went right.""" + script = write_program( + tmp_path, + "echo_line", + """ + import sys + + print("READY", flush=True) + line = sys.stdin.readline().strip() + print("GOT[%s]" % line, flush=True) + """, + ) + + backend.spawn(command(script)) + assert wait_for_output(backend, "READY") + result = backend.write_input(b"hello\r") + + assert result.disposition is InputDisposition.ACCEPTED + assert wait_for_output(backend, "GOT[hello]") + assert wait_for_exit(backend) == 0 + + +def test_the_script_is_a_member_of_the_sessions_job(backend, tmp_path): + """Membership comes from the attribute list at creation, so there is no window in which + the process exists outside the job.""" + script = write_program(tmp_path, "waits", "print('READY', flush=True)\nimport time\ntime.sleep(120)\n") + + backend.spawn(command(script)) + assert wait_for_output(backend, "READY") + session = backend._owner.session + member = wintypes.BOOL(0) + + assert kernel32.IsProcessInJob(session.proc.process_handle(), session.hJob, ctypes.byref(member)) + assert member.value + + +def test_a_descendant_is_terminated_with_the_script(backend, tmp_path): + script = write_program( + tmp_path, + "spawns_a_child", + f""" + import subprocess + import sys + import time + + child = subprocess.Popen([r"{sys.executable}", "-c", "import time; time.sleep(120)"]) + print("CHILD=%d" % child.pid, flush=True) + time.sleep(120) + """, + ) + + backend.spawn(command(script)) + assert wait_for_output(backend, "CHILD=") + line = [part for part in backend.normalized_output().split() if part.startswith("CHILD=")][0] + descendant = int(line.split("=", 1)[1]) + + started = time.monotonic() + backend.terminate_tree(grace=0.2) + backend.close() + + assert time.monotonic() - started < TEARDOWN_BOUND + assert process_is_gone(descendant) + + +def test_teardown_completes_within_its_bound_for_a_script_that_ignores_everything(backend, tmp_path): + """The failure mode this guards is a hang, not an exception: `ClosePseudoConsole()` + blocks on pre-24H2 builds unless the output pipe is drained or closed.""" + script = write_program( + tmp_path, + "ignores_signals", + """ + import signal + import sys + import time + + signal.signal(signal.SIGINT, signal.SIG_IGN) + print("READY", flush=True) + while True: + print("noise" * 200, flush=True) + time.sleep(0.01) + """, + ) + + backend.spawn(command(script)) + assert wait_for_output(backend, "READY") + + started = time.monotonic() + backend.terminate_tree(grace=0.5) + backend.close() + + assert time.monotonic() - started < TEARDOWN_BOUND + + +def test_the_graceful_signal_reaches_a_registered_handler_before_the_grace_expires(backend, tmp_path): + script = write_program( + tmp_path, + "handles_ctrl_c", + """ + import signal + import sys + import time + + + def handler(signum, frame): + print("HANDLED", flush=True) + sys.exit(42) + + + signal.signal(signal.SIGINT, handler) + print("READY", flush=True) + time.sleep(120) + """, + ) + + backend.spawn(command(script)) + assert wait_for_output(backend, "READY") + + backend.terminate_tree(grace=10.0) + + assert wait_for_output(backend, "HANDLED", timeout=5.0) + assert backend.poll() == 42 # its own exit status, not the job's termination code + + +def test_the_renderers_own_console_is_untouched_by_the_graceful_signal(backend, tmp_path): + """The Windows analogue of signalling our own process group, and the one catastrophic + failure: the control byte goes into the pseudoconsole, never through + `GenerateConsoleCtrlEvent`.""" + script = write_program(tmp_path, "waits", "import time\ntime.sleep(120)\n") + interrupted = threading.Event() + previous = signal.getsignal(signal.SIGINT) + signal.signal(signal.SIGINT, lambda *_: interrupted.set()) + try: + backend.spawn(command(script)) + backend.terminate_tree(grace=0.5) + backend.close() + finally: + signal.signal(signal.SIGINT, previous) + + assert not interrupted.is_set() + + +# --------------------------------------------------------------- fault injection + + +def failing(name): + def raiser(*args, **kwargs): + raise TerminalEnvironmentError(f"{name} failed by injection") + + return raiser + + +def failing_on_call(monkeypatch, name, call_index): + """Fails one specific call of a step that runs more than once.""" + original = getattr(_conpty, name) + calls = {"count": 0} + + def wrapper(*args, **kwargs): + calls["count"] += 1 + if calls["count"] == call_index: + raise TerminalEnvironmentError(f"{name} call {call_index} failed by injection") + return original(*args, **kwargs) + + monkeypatch.setattr(_conpty, name, wrapper) + + +@pytest.mark.parametrize( + "step", + [ + "_create_job", + "_set_kill_on_job_close", + "_create_pseudoconsole", + "_initialize_attribute_list", + "_create_process", + "_open_thread_handle", + ], +) +def test_a_failed_step_leaves_no_process_thread_or_handle_behind(monkeypatch, tmp_path, step): + script = write_program(tmp_path, "never_runs", "print('unreachable')\n") + monkeypatch.setattr(_conpty, step, failing(step)) + before = handle_count() + process = ConPtyProcess() + + with pytest.raises(TerminalEnvironmentError): + process.spawn(command(script)) + process.close() + + assert "unreachable" not in process.normalized_output() + assert wait_for(lambda: not live_backend_threads(), timeout=10.0) + # Slack for handles the runtime opens for unrelated reasons between the two readings. + assert wait_for(lambda: handle_count() <= before + 2, timeout=10.0) + + +@pytest.mark.parametrize("call_index", [1, 2]) +def test_a_failed_pipe_leaves_nothing_behind(monkeypatch, tmp_path, call_index): + """Both `CreatePipe` calls are separate failure sites; the second is the one a coarser + cleanup scope mishandles while the first still looks correct.""" + script = write_program(tmp_path, "never_runs", "print('unreachable')\n") + failing_on_call(monkeypatch, "_create_pipe", call_index) + process = ConPtyProcess() + + with pytest.raises(TerminalEnvironmentError): + process.spawn(command(script)) + process.close() + + assert wait_for(lambda: not live_backend_threads(), timeout=10.0) + + +@pytest.mark.parametrize("call_index", [1, 2]) +def test_a_failed_attribute_update_leaves_nothing_behind(monkeypatch, tmp_path, call_index): + script = write_program(tmp_path, "never_runs", "print('unreachable')\n") + failing_on_call(monkeypatch, "_update_attribute", call_index) + process = ConPtyProcess() + + with pytest.raises(TerminalEnvironmentError): + process.spawn(command(script)) + process.close() + + assert wait_for(lambda: not live_backend_threads(), timeout=10.0) + + +def test_a_reader_that_cannot_start_fails_before_there_is_anything_to_roll_back(monkeypatch, tmp_path): + script = write_program(tmp_path, "never_runs", "print('unreachable')\n") + original = threading.Thread.start + + def refuse(self): + if self.name == "codeplain-conpty-reader": + raise RuntimeError("can't start new thread") + original(self) + + monkeypatch.setattr(threading.Thread, "start", refuse) + process = ConPtyProcess() + + with pytest.raises(RuntimeError): + process.spawn(command(script)) + process.close() + + assert not live_backend_threads() + + +def test_a_reader_that_dies_while_the_process_is_being_created_still_unwinds(monkeypatch, tmp_path): + """The widest window in the sequence: process creation is its slowest step.""" + script = write_program(tmp_path, "waits", "import time\ntime.sleep(120)\n") + process = ConPtyProcess() + original = _conpty._create_process + + def create_then_fail_the_reader(*args, **kwargs): + original(*args, **kwargs) + process.reader_exc = OSError("the reader died during creation") + process.reader_failed.set() + + monkeypatch.setattr(_conpty, "_create_process", create_then_fail_the_reader) + + with pytest.raises(TerminalEnvironmentError): + process.spawn(command(script)) + process.close() + + assert wait_for(lambda: not live_backend_threads(), timeout=10.0) + + +def test_a_zero_return_from_create_process_keeps_its_garbage_fields_unclosed(monkeypatch, tmp_path): + """`CreateProcessW` writes nothing meaningful on failure, so the fields it leaves behind + must never be treated as handles.""" + script = write_program(tmp_path, "never_runs", "print('unreachable')\n") + sentinel = 0x0BADF00D + + def fail_with_sentinels(command_line, directory, environment, attrs, proc): + proc.pi.hProcess = sentinel + proc.pi.hThread = sentinel + raise _conpty._win_error("Starting the script", 2) + + monkeypatch.setattr(_conpty, "_create_process", fail_with_sentinels) + process = ConPtyProcess() + + with pytest.raises(TerminalEnvironmentError): + process.spawn(command(script)) + process.close() + + assert wait_for(lambda: not live_backend_threads(), timeout=10.0) + + +def test_a_teardown_that_outlives_its_bound_is_handed_to_the_finalizer(monkeypatch, backend, tmp_path): + """The foreground returns promptly, reports the failure on the environment channel, and + closes neither the pseudoconsole nor the stack itself.""" + script = write_program(tmp_path, "waits", "import time\ntime.sleep(120)\n") + closed_on = [] + original_close = kernel32.ClosePseudoConsole + + def record(handle): + closed_on.append(threading.current_thread().name) + original_close(handle) + + monkeypatch.setattr(kernel32, "ClosePseudoConsole", record) + monkeypatch.setattr(_conpty._SessionBundle, "_await_job_empty", lambda self, bound: False) + + backend.spawn(command(script)) + started = time.monotonic() + with pytest.raises(TerminalEnvironmentError) as error: + backend.close() + + assert time.monotonic() - started < TEARDOWN_BOUND + assert "finalizer" in str(error.value) + assert threading.current_thread().name not in closed_on + assert wait_for( + lambda: any(thread.name == "codeplain-conpty-finalizer" for thread in threading.enumerate()), + timeout=5.0, + ) + + +# ------------------------------------------------------------------- marshaling + + +@pytest.mark.parametrize( + "kwargs", + [ + {"command": [sys.executable, "-c", "print('x')\x00"]}, + {"cwd": "C:\\builds\x00"}, + {"env": {"NAME\x00": "value"}}, + {"env": {"NAME": "value\x00"}}, + {"env": {"NA=ME": "value"}}, + {"env": {"": "value"}}, + ], +) +def test_an_input_windows_cannot_carry_is_refused_before_any_process_is_created(kwargs, tmp_path): + marker = tmp_path / "ran.txt" + argv = kwargs.pop("command", [sys.executable, "-c", f"open(r'{marker}', 'w').close()"]) + if "env" in kwargs: + kwargs["env"] = dict(os.environ, **kwargs["env"]) + process = ConPtyProcess() + + with pytest.raises(TerminalEnvironmentError): + process.spawn(argv, **kwargs) + process.close() + + assert not marker.exists() # asserted by observation, not only by the exception diff --git a/tests/test_no_pty_escape_hatch.py b/tests/test_no_pty_escape_hatch.py index 0e263a04..87000e61 100644 --- a/tests/test_no_pty_escape_hatch.py +++ b/tests/test_no_pty_escape_hatch.py @@ -13,6 +13,7 @@ import pytest +from render_machine._legacy_pipe import LegacyPipeProcess from render_machine.terminal_process import ( ENVIRONMENT_ERROR_EXIT_CODE, NO_PTY_ENV_VAR, @@ -26,9 +27,12 @@ reason="These cases run POSIX shell and Python scripts directly.", ) -if sys.platform != "win32": - from render_machine._legacy_pipe import LegacyPipeProcess - from render_machine._posix_pty import PosixPtyProcess +# The backend the platform selects when the hatch is closed. Both are reachable from every +# platform's suite, because the hatch is what decides, not the platform. +if sys.platform == "win32": + from render_machine._conpty import ConPtyProcess as DefaultBackend +else: + from render_machine._posix_pty import PosixPtyProcess as DefaultBackend SCRIPT_TYPE = characterization.SCRIPT_TYPE @@ -81,8 +85,8 @@ def hatch_warnings(monkeypatch): ) -@posix_only def test_the_hatch_selects_the_pipe_backend(hatch_warnings): + """The hatch is consulted before the platform, so it holds on Windows as well.""" process = create_terminal_process() try: assert isinstance(process, LegacyPipeProcess) @@ -90,7 +94,6 @@ def test_the_hatch_selects_the_pipe_backend(hatch_warnings): process.close() -@posix_only @pytest.mark.parametrize("value", ["", "0", "true", "yes", "11", " 1"]) def test_only_the_value_one_selects_the_pipe_backend(monkeypatch, value): monkeypatch.setenv(NO_PTY_ENV_VAR, value) @@ -98,12 +101,11 @@ def test_only_the_value_one_selects_the_pipe_backend(monkeypatch, value): assert pty_disabled_by_environment() is False process = create_terminal_process() try: - assert isinstance(process, PosixPtyProcess) + assert isinstance(process, DefaultBackend) finally: process.close() -@posix_only def test_the_warning_names_the_variable_on_every_use(hatch_warnings): for _ in range(2): create_terminal_process().close() @@ -114,7 +116,6 @@ def test_the_warning_names_the_variable_on_every_use(hatch_warnings): assert "isatty" in message -@posix_only def test_no_warning_is_emitted_when_the_hatch_is_closed(monkeypatch, hatch_warnings): monkeypatch.delenv(NO_PTY_ENV_VAR) From 824e196bd6ab673235393da7be493cdb6d7c6943 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 02:46:21 +0200 Subject: [PATCH 33/83] Run the ConPTY lifecycle tests on windows-2022 windows-latest is build 26100, where ClosePseudoConsole() no longer blocks, so the teardown-ordering assertions pass there whether or not the ordering is correct. Windows Server 2022 is build 20348, inside the affected range. CLAUDE.md no longer claims Windows users must use WSL. --- .github/workflows/lint-and-test.yml | 22 ++++++++++++++++++++++ CLAUDE.md | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 0b1c78d0..c1cfd4a8 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -99,6 +99,28 @@ jobs: - name: Type check with mypy run: mypy . --check-untyped-defs --platform ${{ matrix.platform }} + conpty-lifecycle: + # windows-latest is Windows Server 2025 (build 26100), the build that made + # ClosePseudoConsole() non-blocking, so the teardown-ordering assertions pass there + # whether or not the ordering is correct. Windows Server 2022 is build 20348, inside the + # affected range, which is the only place those assertions prove anything. + name: ConPTY Lifecycle (windows-2022) + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + cache-dependency-path: requirements.txt + - name: Upgrade pip + run: python -m pip install --upgrade pip + - name: Install requirements + run: pip install -r requirements.txt + - name: Run the ConPTY lifecycle tests + run: python -m pytest tests/test_conpty.py -v + tests: name: Run Tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/CLAUDE.md b/CLAUDE.md index 03f4c3d0..d781c007 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -248,7 +248,7 @@ git push origin subtree/standard-template-library ``` ### Windows Support -Windows users must use WSL (Windows Subsystem for Linux). The codebase has some platform-specific script handling (`.ps1` for Windows, `.sh` for Unix). +Native Windows is supported and tested in CI. The codebase has platform-specific script handling (`.ps1` for Windows, `.sh` for Unix), and scripts run on a ConPTY-backed terminal with a Job Object containing their process tree (`render_machine/_conpty.py`), which needs Windows 10 build 17763 (1809) or newer. WSL works too, and is then an ordinary Linux host. ### CRITICAL: No User-Specific Paths in Version Control **Never commit files containing user-specific absolute paths** (e.g., `/Users/username/...`, `/home/username/...`, `C:\Users\...`) to version-controlled files like: From 390aed39a48e784a295e27fd66f21cd462ea2fdc Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 03:24:37 +0200 Subject: [PATCH 34/83] Publish the preemption generation with the item it belongs to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generation was bumped after the enqueue, so an idle writer could dequeue the control item, acknowledge the previous generation and enter its write before the poster started cancelling — the wrong-call cancellation the lock exists to prevent. The ready wait now runs its stop check even when the writer is already ready, and receipt resolution is linearized with separate effective and attempted counters. --- render_machine/_conpty_support.py | 81 +++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 21 deletions(-) diff --git a/render_machine/_conpty_support.py b/render_machine/_conpty_support.py index 51ae53fb..9b9b8caf 100644 --- a/render_machine/_conpty_support.py +++ b/render_machine/_conpty_support.py @@ -153,22 +153,30 @@ class Receipt: """ def __init__(self, on_resolve: Optional[ResolveCallback] = None) -> None: + self._lock = threading.Lock() self._event = threading.Event() self.error: Optional[BaseException] = None self.disposition: Optional[InputDisposition] = None + # Two counters, because they answer two different questions: how many resolutions + # took effect (never more than one), and how many were attempted (a caller retiring + # an item twice is a bug worth failing a test on). self.resolutions = 0 + self.attempts = 0 self._on_resolve = on_resolve def resolve(self, disposition: InputDisposition, error: Optional[BaseException] = None) -> None: - self.resolutions += 1 - if self._event.is_set(): - return - self.disposition = disposition - self.error = error - self._event.set() - if self._on_resolve is not None: + with self._lock: # the check and the set are one step, so two threads cannot both win + self.attempts += 1 + if self._event.is_set(): + return + self.disposition = disposition + self.error = error + self.resolutions += 1 + self._event.set() + callback = self._on_resolve + if callback is not None: # outside the lock: a callback must not be able to re-enter it try: - self._on_resolve(disposition, error) + callback(disposition, error) except BaseException as exc: # a completion callback must never strand the queue console.debug(f"input completion callback raised: {exc!r}") @@ -182,14 +190,27 @@ def delivered(self) -> bool: class InputItem: - """One whole logical write, plus the cursor the writer keeps across partial writes.""" + """One whole logical write, plus the cursor the writer keeps across partial writes. - def __init__(self, data: bytes, receipt: Receipt, lane: InputLane, sequence: int, stop: bool = False) -> None: + An urgent control item also carries the preemption generation it was posted under, so + the writer acknowledges at least that generation before it starts writing the item. + """ + + def __init__( + self, + data: bytes, + receipt: Receipt, + lane: InputLane, + sequence: int, + stop: bool = False, + generation: int = 0, + ) -> None: self.data = data self.receipt = receipt self.lane = lane self.sequence = sequence self.stop = stop + self.generation = generation self.cursor = 0 @@ -228,6 +249,7 @@ def submit( reserved: bool = False, lane: InputLane = InputLane.DATA, on_resolve: Optional[ResolveCallback] = None, + generation: int = 0, ) -> Tuple[InputWriteResult, Receipt]: """One non-blocking whole-item admission. Never waits, whoever the producer is.""" receipt = Receipt(on_resolve) @@ -247,7 +269,7 @@ def submit( elif self._pending_bytes + size > byte_budget or queued >= item_budget: result = InputWriteResult(InputDisposition.BACKPRESSURE, 0) else: - self._append(InputItem(bytes(data), receipt, lane, self._next_sequence())) + self._append(InputItem(bytes(data), receipt, lane, self._next_sequence(), generation=generation)) self._pending_bytes += size result = InputWriteResult(InputDisposition.ACCEPTED, size) enqueued = True @@ -486,15 +508,17 @@ def await_ready(self, deadline: float, stop_check: Optional[Callable[[], None]] Returns the id, or None when the writer failed or the deadline expired. The wait is bounded and abortable because a writer that dies before publishing must not park the - creator. + creator. `stop_check` runs at least once even when the writer is already ready, so a + cancellation set while it was starting is not skipped. """ - while not self.ready.is_set(): + while True: if stop_check is not None: stop_check() + if self.ready.is_set(): + return None if self.failed.is_set() else self.native_id if time.monotonic() >= deadline: return None self.ready.wait(CANCEL_TICK_SECONDS) - return None if self.failed.is_set() else self.native_id def deliver_control(self, data: bytes, deadline_seconds: float) -> bool: """Posts an urgent control item, preempts any data write, and awaits its receipt. @@ -503,13 +527,21 @@ def deliver_control(self, data: bytes, deadline_seconds: float) -> bool: synchronous data write never reaches the queue again on its own, so the in-flight write is cancelled through the stored thread handle until the writer acknowledges this generation. + + The generation is published under the same lock that makes the item visible. Bumping + it afterwards would let an idle writer dequeue the item, acknowledge the previous + generation and enter its control write before this thread starts cancelling — which + is exactly the wrong-call cancellation the lock exists to prevent. """ - result, receipt = self.queue.submit(data, reserved=True, lane=InputLane.CONTROL) - if result.disposition is not InputDisposition.ACCEPTED: - return False with self._lock: self._requested_generation += 1 generation = self._requested_generation + result, receipt = self.queue.submit(data, reserved=True, lane=InputLane.CONTROL, generation=generation) + if result.disposition is not InputDisposition.ACCEPTED: + # Nothing became visible, so the request is withdrawn rather than left for + # the writer to acknowledge against an item that does not exist. + self._requested_generation = generation - 1 + return False deadline = time.monotonic() + deadline_seconds while not receipt.resolved: if time.monotonic() >= deadline: @@ -598,8 +630,10 @@ def _loop(self) -> None: def _service(self, item: InputItem) -> None: if item.lane is InputLane.CONTROL: # Published before the control write begins: it means "no earlier data I/O - # remains", and it is what stops the poster's cancel loop. - self._acknowledge_preemption() + # remains", and it is what stops the poster's cancel loop. The item's own + # generation is the floor, so the acknowledgment can never be older than the + # request that produced the item. + self._acknowledge_preemption(item.generation) self._write_item(item, preemptible=False) return self._write_item(item, preemptible=True) @@ -639,6 +673,11 @@ def _control_pending(self) -> bool: with self._lock: return self._preempted_generation < self._requested_generation - def _acknowledge_preemption(self) -> None: + def _acknowledge_preemption(self, at_least: int = 0) -> None: + with self._lock: + self._preempted_generation = max(self._preempted_generation, self._requested_generation, at_least) + + @property + def acknowledged_generation(self) -> int: with self._lock: - self._preempted_generation = self._requested_generation + return self._preempted_generation From eb551e03e4c580dd3d7eecdb7dc572c4e8ee6a0c Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 03:24:37 +0200 Subject: [PATCH 35/83] Keep the terminal session owned and its native failures explicit The finalizer hand-off publishes the owner before clearing the backend's reference and releases the job, pseudoconsole and input handles itself, so a session no thread took is never orphaned and kill-on-job-close still fires. A failed wait, exit-code read, job query or job termination is now reported on the environment channel instead of reading as a running process or a clean shutdown. Cancellation is checked at every spawn step boundary, query replies use the ordinary budget so they cannot consume the cancellation reserve, and the writer thread handle is taken before it is closed. --- render_machine/_conpty.py | 179 +++++++++++++++++++++++++++++--------- 1 file changed, 138 insertions(+), 41 deletions(-) diff --git a/render_machine/_conpty.py b/render_machine/_conpty.py index 37300971..fb7a878b 100644 --- a/render_machine/_conpty.py +++ b/render_machine/_conpty.py @@ -119,6 +119,7 @@ ERROR_NOT_FOUND = 1168 WAIT_OBJECT_0 = 0 +WAIT_TIMEOUT = 258 # the only wait result that means "still running" # ConPTY ships from Windows 10 1809. Below it there is no fallback: a silent downgrade to # pipes would make execution behaviour depend on the machine again. @@ -586,7 +587,8 @@ def _open_thread_handle(native_id: int) -> int: return handle -def _job_active_processes(job: int) -> Optional[int]: +def _job_active_processes(job: int) -> int: + """Members still running. Raises rather than reporting an empty job it never observed.""" info = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION() returned = DWORD(0) ok = kernel32.QueryInformationJobObject( @@ -598,8 +600,7 @@ def _job_active_processes(job: int) -> Optional[int]: ) if not ok: error = ctypes.get_last_error() - console.debug(f"querying the job object reported Windows error {error}") - return None + raise _win_error("Querying the job object for its remaining members", error) return int(info.ActiveProcesses) @@ -626,7 +627,7 @@ def write(self, data: bytes) -> int: return int(written.value) def cancel(self) -> None: - handle = self._session.writer_handle + handle = self._session.writer_handle.handle() if not handle: return ok = kernel32.CancelSynchronousIo(handle) @@ -649,7 +650,7 @@ def __init__(self, out_w: _Holder, in_pair: _PipePair, in_queue: InputQueue) -> self.in_w = _Holder(pair=in_pair) self.in_queue = in_queue self.writer: Optional[InputWriter] = None - self.writer_handle: Optional[int] = None + self.writer_handle = _Holder() # the same take-then-close discipline as every handle self.reader: Optional[threading.Thread] = None self.hPC = HPCON() self.hPC_valid = False # a failed HRESULT output is never closable @@ -657,28 +658,51 @@ def __init__(self, out_w: _Holder, in_pair: _PipePair, in_queue: InputQueue) -> self.job_array = (HANDLE * 1)() # must outlive the attribute list that points at it self.proc = _ProcInfo() self.exit_code: Optional[int] = None + # The first native failure any of the observation or teardown steps hit. A failed + # wait, exit-code read, job query or job termination cannot be recovered from and + # must not be read as "still running" or "shut down cleanly", so it is published + # here and surfaces on the environment channel. + self.failure: Optional[str] = None self._lock = threading.Lock() # ------------------------------------------------------------- observation + def record_failure(self, detail: str) -> None: + """Keeps the first failure: later ones are usually consequences of it.""" + if self.failure is None: + self.failure = detail + console.debug(f"terminal session failure: {detail}") + def poll_exit_code(self) -> Optional[int]: """Non-blocking exit status. `WaitForSingleObject` decides, so a target that exits - with 259 is not mistaken for one that is still running.""" + with 259 is not mistaken for one that is still running. + + Only `WAIT_TIMEOUT` means "still running". Every other non-signalled result, and a + failed exit-code read, is an infrastructure failure: reporting it as a running + process would turn it into a 124 timeout instead of a 69 environment error. + """ + failure = None with self._lock: if self.exit_code is not None: return self.exit_code handle = self.proc.process_handle() if not handle: return None - if kernel32.WaitForSingleObject(handle, 0) != WAIT_OBJECT_0: + waited = kernel32.WaitForSingleObject(handle, 0) + if waited == WAIT_TIMEOUT: return None - code = DWORD(0) - if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): + if waited != WAIT_OBJECT_0: error = ctypes.get_last_error() - console.debug(f"reading the script's exit code reported Windows error {error}") - return None - self.exit_code = int(code.value) - return self.exit_code + failure = f"waiting on the script's process reported result 0x{waited:08X} (Windows error {error})" + else: + code = DWORD(0) + if kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): + self.exit_code = int(code.value) + return self.exit_code + error = ctypes.get_last_error() + failure = f"reading the script's exit code failed: Windows error {error}" + self.record_failure(failure) # outside the lock: recording logs + return None def running(self) -> bool: """False once the process handle has been released, whatever the target is doing: @@ -729,7 +753,9 @@ def _terminate_job(self) -> None: return if not kernel32.TerminateJobObject(job, JOB_TERMINATION_EXIT_CODE): error = ctypes.get_last_error() - console.debug(f"terminating the job object reported Windows error {error}") + # The forced step of the shutdown: if it did not run, nothing else in this + # teardown can claim the tree is gone. + self.record_failure(f"terminating the script's job object failed: Windows error {error}") def _await_job_empty(self, bound: float) -> bool: """Closes the process handles, then waits for the job's membership to reach zero.""" @@ -739,8 +765,14 @@ def _await_job_empty(self, bound: float) -> bool: return True deadline = time.monotonic() + bound while True: - active = _job_active_processes(job) - if active is None or active == 0: + try: + active = _job_active_processes(job) + except TerminalEnvironmentError as exc: + # Unanswerable rather than empty. Teardown continues — closing the job handle + # is still the kill-on-close backstop — but the run is an environment failure. + self.record_failure(str(exc)) + return True + if active == 0: return True if time.monotonic() >= deadline: console.debug(f"the job object still held {active} processes after {bound}s") @@ -774,12 +806,35 @@ def _close_pseudoconsole(self) -> None: def _release_handles(self) -> None: self.in_w.close_if_owned() # after the writer has stopped, never before - _close_handle(self.writer_handle) - self.writer_handle = None - job, self.hJob = self.hJob, None - _close_handle(job) # last: closing it is also the kill-on-close backstop + self.writer_handle.close_if_owned() + self._close_job() self.proc.close_all() + def _close_job(self) -> None: + job, self.hJob = self.hJob, None # taken before it is closed, like every other handle + _close_handle(job) # closing it is also the kill-on-close backstop + + def release_abandoned(self) -> None: + """Last-resort release for a teardown nobody can finish. + + Reached only when the finalizer runs out of time or could not be started. Everything + the writer cannot be blocked inside is released unconditionally — closing the job + handle is what lets kill-on-job-close fire — while `inputWriteSide` and the writer's + thread handle are leaked deliberately when the writer never returned, because closing + a handle underneath a blocked `WriteFile` is the corruption the bounded teardown + exists to avoid. + """ + self._terminate_job() + self.proc.close_all() + self._close_pseudoconsole() + writer = self.writer + if writer is None or writer.finished.is_set(): + self.in_w.close_if_owned() + self.writer_handle.close_if_owned() + else: + console.debug("leaking the terminal input handles: the writer never returned") + self._close_job() + def join_reader(self, bound: float) -> bool: """Waits for the reader once every handle it could be blocked on is released. @@ -807,15 +862,27 @@ def __init__(self, session: _SessionBundle, stack: ExitStack) -> None: self.armed = True -def _hand_off_to_finalizer(owner: _SessionOwner) -> None: - threading.Thread(target=_finalize_session, args=(owner,), name="codeplain-conpty-finalizer", daemon=True).start() +def _hand_off_to_finalizer(owner: _SessionOwner) -> bool: + """Transfers the owner to a daemon finalizer. False when no thread could be started. + + The caller keeps the owner on a false result: dropping the only reference to a session + nobody has taken is how a job, a pseudoconsole and two handles survive until Codeplain + exits. + """ + thread = threading.Thread(target=_finalize_session, args=(owner,), name="codeplain-conpty-finalizer", daemon=True) + try: + thread.start() + except BaseException as exc: # thread exhaustion is the realistic one + console.debug(f"the terminal session finalizer could not be started: {exc!r}") + return False + return True def _finalize_session(owner: _SessionOwner) -> None: """Finishes a teardown that outlived the foreground's bound, then closes the stack. - The stack is closed only once the teardown has completed, so there is exactly one owner - at every instant and the transfer never races an unwind in progress. + The stack is closed only once the session's own release has run, so there is exactly one + owner at every instant and the transfer never races an unwind in progress. """ deadline = time.monotonic() + FINALIZER_DEADLINE_SECONDS try: @@ -829,6 +896,13 @@ def _finalize_session(owner: _SessionOwner) -> None: except BaseException as exc: # nothing here can be reported anywhere useful console.debug(f"the terminal session finalizer failed: {exc!r}") finally: + try: + # The stack holds startup and pipe state only, so giving up on the teardown + # without this leaves the job, the pseudoconsole and the input handle behind — + # and with the job handle open, kill-on-job-close never fires. + owner.session.release_abandoned() + except BaseException as exc: + console.debug(f"the terminal session finalizer could not release the session: {exc!r}") try: owner.stack.close() except BaseException as exc: @@ -934,6 +1008,11 @@ def infrastructure_failure(self) -> Optional[str]: writer = self._writer if writer is not None and writer.failed.is_set(): return f"the terminal input writer failed: {writer.exc!r}" + owner = self._owner + if owner is not None and owner.session.failure is not None: + # A native wait, exit-code read, job query or job termination that failed: the + # run cannot be described by an exit status nobody could read. + return owner.session.failure return None def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: @@ -1000,7 +1079,7 @@ def _start_session( self._start_reader(session, bundle, gate) self._start_writer(session, deadline) - self._check_pumps() + self._check_spawn_interrupted() session.hJob = _create_job() _set_kill_on_job_close(session.hJob) @@ -1028,13 +1107,15 @@ def _start_session( "job list", ) - self._check_pumps() + # The last gate before the target can run: a cancellation observed here must + # unwind rather than let the script execute its side effects. + self._check_spawn_interrupted() session.proc = proc # attached before the call, as the reader is _create_process(command_line, directory, environment, attrs, proc) - # The reader can die during process creation, which is the slowest step here, so - # the check runs again on the other side of it. The job already holds the child, - # so this unwind needs no special case. - self._check_pumps() + # A pump can die, and a render can be cancelled, during process creation — the + # slowest step here — so the check runs again on the other side of it. The job + # already holds the child, so this unwind needs no special case. + self._check_spawn_interrupted() # Documented timing: the pseudoconsole owns these two now, and holding # outputWriteSide open means the reader never observes EOF. @@ -1046,9 +1127,7 @@ def _start_session( owner.armed = False # the commit except BaseException: if session.teardown(None): # before any stack unwinding starts - owner.armed = False # disarm first: `finally` runs on this path too - self._owner = None - _hand_off_to_finalizer(owner) + self._transfer_to_finalizer(owner) raise TerminalEnvironmentError( "The script's terminal session could not be released within its bound and was " "handed to the background finalizer." @@ -1091,7 +1170,7 @@ def _start_writer(self, session: _SessionBundle, deadline: float) -> None: if writer.failed.is_set() else "The terminal input writer did not report itself before the spawn deadline." ) - session.writer_handle = _open_thread_handle(native_id) + session.writer_handle.value = HANDLE(_open_thread_handle(native_id)) decision = GateDecision.RUN # only after the handle is stored finally: writer.gate.set(decision) # always: ABORT wakes the writer to exit untouched @@ -1157,11 +1236,13 @@ def _flush_decoder(self, decoder) -> None: def _admit_reply(self, payload: bytes, on_complete: Callable[[Optional[str]], None]) -> None: """One non-blocking whole-item admission of a terminal reply, from the reader. - Replies take the reserved partition because they are terminal protocol: a caller - saturating the queue with input must not starve a required response. They keep their - place in the data lane, so a reply never overtakes input the caller sent first. + Replies are ordinary data admissions. The reserve exists so the graceful control byte + can always be posted; a target that queries in a loop against a blocked input pipe + would otherwise fill the whole budget with replies and leave cancellation with nothing + but forced termination. A rejected reply is recorded as undelivered, which is the + outcome the responder exists to report. """ - self._input_queue.submit(payload, reserved=True, on_resolve=reply_resolution(on_complete)) + self._input_queue.submit(payload, on_resolve=reply_resolution(on_complete)) def _child_env(self, env: Optional[dict]) -> dict: child_env = child_environment(env) @@ -1177,14 +1258,30 @@ def _shutdown(self, grace: Optional[float]) -> None: if owner is None: return if owner.session.teardown(grace): - owner.armed = False # disarm before publishing: the finalizer owns the stack now - self._owner = None - _hand_off_to_finalizer(owner) + self._transfer_to_finalizer(owner) raise TerminalEnvironmentError( "The script's terminal session did not shut down within its bound and was handed to " "the background finalizer." ) + def _transfer_to_finalizer(self, owner: _SessionOwner) -> None: + """Publishes the owner to the daemon finalizer, or keeps it here if none took it. + + Disarming comes first, because `finally` runs on this path too and must not unwind a + stack the finalizer now holds. Clearing `self._owner` comes last, and only once + another owner exists: a failed `Thread.start()` would otherwise drop the only + reference to a live job, pseudoconsole and input handle. + """ + owner.armed = False + if _hand_off_to_finalizer(owner): + self._owner = None + return + # Nobody can finish this later, so release what is provably safe to release now and + # keep the rest recorded on the session that stays reachable from this backend. + owner.session.record_failure("no finalizer thread could be started for the terminal session") + owner.session.release_abandoned() + owner.stack.close() + def _check_cancelled(self) -> None: if self._stop_event.is_set(): raise RenderCancelledError() From a9e8d9bb7d1faa664420f80e3d5d1c6a7dd28d90 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 03:24:49 +0200 Subject: [PATCH 36/83] Assert the ConPTY invariants the earlier tests only implied The vacuous assertions are gone: the retry case now proves a cancel that reached nothing is reissued, the garbage-handle case observes what CloseHandle was called with, the finalizer case follows the transfer through to a released session and a dead child, and handle accounting no longer tolerates a leak of two. New coverage: cancellation before and during the spawn launching nothing, a failure injected after a real native step, a saturated input channel with the writer blocked in a write, a client that cleared ENABLE_PROCESSED_INPUT falling through to the job, a querying target leaving no undeliverable obligation, and real .ps1 execution through execute_script including the no-end-of-file timeout diagnostic. --- tests/test_conpty.py | 314 ++++++++++++++++++++++++++++++++++- tests/test_conpty_support.py | 159 +++++++++++++++++- 2 files changed, 460 insertions(+), 13 deletions(-) diff --git a/tests/test_conpty.py b/tests/test_conpty.py index 9e5666ad..42b5bb28 100644 --- a/tests/test_conpty.py +++ b/tests/test_conpty.py @@ -29,7 +29,9 @@ from ctypes import wintypes # noqa: E402 +from plain2code_exceptions import RenderCancelledError # noqa: E402 from render_machine import _conpty # noqa: E402 +from render_machine import render_utils # noqa: E402 from render_machine._conpty import ConPtyProcess # noqa: E402 from render_machine._legacy_pipe import LegacyPipeProcess # noqa: E402 from render_machine.terminal_process import ( # noqa: E402 @@ -422,6 +424,21 @@ def raiser(*args, **kwargs): return raiser +def failing_after(monkeypatch, name): + """Fails once the named step has really run, so the rollback faces a real resource. + + Injecting before the call proves only that nothing was allocated; these cases are the + ones that prove the allocation is released. + """ + original = getattr(_conpty, name) + + def wrapper(*args, **kwargs): + result = original(*args, **kwargs) + raise TerminalEnvironmentError(f"{name} failed by injection after the real call") + + monkeypatch.setattr(_conpty, name, wrapper) + + def failing_on_call(monkeypatch, name, call_index): """Fails one specific call of a step that runs more than once.""" original = getattr(_conpty, name) @@ -459,8 +476,51 @@ def test_a_failed_step_leaves_no_process_thread_or_handle_behind(monkeypatch, tm assert "unreachable" not in process.normalized_output() assert wait_for(lambda: not live_backend_threads(), timeout=10.0) - # Slack for handles the runtime opens for unrelated reasons between the two readings. - assert wait_for(lambda: handle_count() <= before + 2, timeout=10.0) + assert wait_for(lambda: handle_count() <= before, timeout=10.0) + + +@pytest.mark.parametrize( + "step", + ["_create_job", "_create_pseudoconsole", "_initialize_attribute_list"], +) +def test_a_failure_after_a_real_native_step_releases_what_that_step_allocated(monkeypatch, tmp_path, step): + script = write_program(tmp_path, "never_runs", "print('unreachable')\n") + failing_after(monkeypatch, step) + before = handle_count() + process = ConPtyProcess() + + with pytest.raises(TerminalEnvironmentError): + process.spawn(command(script)) + process.close() + + assert wait_for(lambda: not live_backend_threads(), timeout=10.0) + assert wait_for(lambda: handle_count() <= before, timeout=10.0) + + +def test_a_failure_after_create_process_leaves_no_surviving_child(monkeypatch, tmp_path): + """The widest rollback: the child already exists, and the job it was created inside is + what takes it down.""" + script = write_program(tmp_path, "waits", "import time\ntime.sleep(120)\n") + original = _conpty._create_process + created = [] + + def create_then_fail(command_line, directory, environment, attrs, proc): + original(command_line, directory, environment, attrs, proc) + created.append(int(proc.pi.dwProcessId)) + raise TerminalEnvironmentError("injected after the child was created") + + monkeypatch.setattr(_conpty, "_create_process", create_then_fail) + before = handle_count() + process = ConPtyProcess() + + with pytest.raises(TerminalEnvironmentError): + process.spawn(command(script)) + process.close() + + assert created, "the injection never ran the real call" + assert process_is_gone(created[0]) + assert wait_for(lambda: not live_backend_threads(), timeout=10.0) + assert wait_for(lambda: handle_count() <= before, timeout=10.0) @pytest.mark.parametrize("call_index", [1, 2]) @@ -535,12 +595,19 @@ def test_a_zero_return_from_create_process_keeps_its_garbage_fields_unclosed(mon must never be treated as handles.""" script = write_program(tmp_path, "never_runs", "print('unreachable')\n") sentinel = 0x0BADF00D + closed = [] + original_close = kernel32.CloseHandle + + def record(handle): + closed.append(handle) + return original_close(handle) def fail_with_sentinels(command_line, directory, environment, attrs, proc): proc.pi.hProcess = sentinel proc.pi.hThread = sentinel raise _conpty._win_error("Starting the script", 2) + monkeypatch.setattr(kernel32, "CloseHandle", record) monkeypatch.setattr(_conpty, "_create_process", fail_with_sentinels) process = ConPtyProcess() @@ -548,24 +615,36 @@ def fail_with_sentinels(command_line, directory, environment, attrs, proc): process.spawn(command(script)) process.close() + assert closed, "the rollback closed nothing, so the absence below would prove nothing" + assert sentinel not in closed # the `proc.valid` gate keeps a failed call's fields unclosed assert wait_for(lambda: not live_backend_threads(), timeout=10.0) def test_a_teardown_that_outlives_its_bound_is_handed_to_the_finalizer(monkeypatch, backend, tmp_path): - """The foreground returns promptly, reports the failure on the environment channel, and - closes neither the pseudoconsole nor the stack itself.""" + """The foreground returns promptly and reports the failure on the environment channel; + the finalizer, not the foreground, closes the pseudoconsole and releases the session.""" script = write_program(tmp_path, "waits", "import time\ntime.sleep(120)\n") closed_on = [] original_close = kernel32.ClosePseudoConsole + original_wait = _conpty._SessionBundle._await_job_empty + waits = {"count": 0} def record(handle): closed_on.append(threading.current_thread().name) original_close(handle) + def expire_once(self, bound): + """Expires the foreground's wait, then lets the finalizer's own attempt succeed.""" + waits["count"] += 1 + return False if waits["count"] == 1 else original_wait(self, bound) + monkeypatch.setattr(kernel32, "ClosePseudoConsole", record) - monkeypatch.setattr(_conpty._SessionBundle, "_await_job_empty", lambda self, bound: False) + monkeypatch.setattr(_conpty._SessionBundle, "_await_job_empty", expire_once) + monkeypatch.setattr(_conpty, "FINALIZER_TICK_SECONDS", 0.05) + before = handle_count() backend.spawn(command(script)) + child = int(backend._owner.session.proc.pi.dwProcessId) started = time.monotonic() with pytest.raises(TerminalEnvironmentError) as error: backend.close() @@ -573,10 +652,15 @@ def record(handle): assert time.monotonic() - started < TEARDOWN_BOUND assert "finalizer" in str(error.value) assert threading.current_thread().name not in closed_on + # Ownership was transferred, not dropped: the session is released on the finalizer's own + # time, the child goes with it, and every handle comes back. + assert wait_for(lambda: closed_on == ["codeplain-conpty-finalizer"], timeout=30.0) assert wait_for( - lambda: any(thread.name == "codeplain-conpty-finalizer" for thread in threading.enumerate()), - timeout=5.0, + lambda: not any(thread.name == "codeplain-conpty-finalizer" for thread in threading.enumerate()), + timeout=30.0, ) + assert process_is_gone(child) + assert wait_for(lambda: handle_count() <= before, timeout=30.0) # ------------------------------------------------------------------- marshaling @@ -605,3 +689,219 @@ def test_an_input_windows_cannot_carry_is_refused_before_any_process_is_created( process.close() assert not marker.exists() # asserted by observation, not only by the exception + + +# ---------------------------------------------------------------- cancellation + + +def test_a_stop_event_set_before_the_spawn_launches_nothing(tmp_path): + marker = tmp_path / "ran.txt" + script = write_program(tmp_path, "marks", f"open(r'{marker}', 'w').close()\n") + stop = threading.Event() + stop.set() + process = ConPtyProcess() + + with pytest.raises(RenderCancelledError): + process.spawn(command(script), stop_event=stop) + process.close() + + assert not wait_for(marker.exists, timeout=2.0) + + +def test_a_cancellation_observed_while_the_session_is_built_never_launches_the_target(monkeypatch, tmp_path): + """The window between the first check and `CreateProcessW` is the whole session setup; + a render cancelled inside it must not run the script's side effects.""" + marker = tmp_path / "ran.txt" + script = write_program(tmp_path, "marks", f"open(r'{marker}', 'w').close()\n") + stop = threading.Event() + original = _conpty._create_pseudoconsole + + def create_then_cancel(*args, **kwargs): + original(*args, **kwargs) + stop.set() + + monkeypatch.setattr(_conpty, "_create_pseudoconsole", create_then_cancel) + process = ConPtyProcess() + + with pytest.raises(RenderCancelledError): + process.spawn(command(script), stop_event=stop) + process.close() + + assert not wait_for(marker.exists, timeout=2.0) + assert wait_for(lambda: not live_backend_threads(), timeout=10.0) + + +# ------------------------------------------------------- the blocked input channel + + +SILENT_READER = """ + import time + + print("READY", flush=True) + time.sleep(120) +""" + + +def test_a_saturated_input_channel_still_tears_down_within_the_bound(backend, tmp_path): + """The target never reads, so the writer ends up blocked inside a synchronous `WriteFile` + — the state the cancel loop and the bounded join exist for.""" + script = write_program(tmp_path, "silent_reader", SILENT_READER) + backend.spawn(command(script)) + assert wait_for_output(backend, "READY") + child = int(backend._owner.session.proc.pi.dwProcessId) + + accepted = 0 + for _ in range(64): + result = backend.write_input(b"x" * 4096 + b"\r") + if result.disposition is not InputDisposition.ACCEPTED: + break + accepted += 1 + + started = time.monotonic() + backend.terminate_tree(grace=0.5) + backend.close() + + assert accepted > 0 + assert time.monotonic() - started < TEARDOWN_BOUND + assert process_is_gone(child) + + +PROCESSED_INPUT_OFF = """ + import ctypes + import time + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + handle = kernel32.GetStdHandle(-10) + mode = ctypes.c_uint(0) + kernel32.GetConsoleMode(handle, ctypes.byref(mode)) + kernel32.SetConsoleMode(handle, mode.value & ~0x0001) # ENABLE_PROCESSED_INPUT + + print("READY", flush=True) + time.sleep(120) +""" + + +def test_a_client_that_cleared_processed_input_falls_through_to_forced_termination(backend, tmp_path): + """The best-effort boundary, proven rather than asserted: without ENABLE_PROCESSED_INPUT + the control byte is just a byte, so the grace expires and the job takes the tree.""" + script = write_program(tmp_path, "no_processed_input", PROCESSED_INPUT_OFF) + backend.spawn(command(script)) + assert wait_for_output(backend, "READY") + child = int(backend._owner.session.proc.pi.dwProcessId) + + started = time.monotonic() + backend.terminate_tree(grace=1.0) + backend.close() + + assert time.monotonic() - started < TEARDOWN_BOUND + assert process_is_gone(child) + # Never exited on its own, so no status was ever read: the contrast with the handled + # case, which reports the code its handler chose. + assert backend.poll() is None + + +TERMINAL_QUERY = """ + import sys + + sys.stdout.write("\\x1b[6n") # device status report: a terminal is expected to answer + sys.stdout.flush() + print("QUERIED", flush=True) +""" + + +def test_a_target_that_queries_the_terminal_leaves_no_unanswered_obligation(backend, tmp_path): + """Either the pseudoconsole answers the query itself or the renderer's responder does. + What must never happen is an obligation this side registered and could not deliver.""" + script = write_program(tmp_path, "queries", TERMINAL_QUERY) + + backend.spawn(command(script)) + exit_code = wait_for_exit(backend) + backend.terminate_tree(grace=0.1) + backend.close() + + assert exit_code == 0 + assert "QUERIED" in backend.normalized_output() + assert backend.terminal_reply_failed is False, backend.terminal_reply_detail() + + +# ----------------------------------------------------- through execute_script + + +SCRIPT_TYPE = "Unit" + + +def write_powershell(tmp_path: Path, name: str, body: str) -> str: + path = tmp_path / f"{name}.ps1" + path.write_text(textwrap.dedent(body), encoding="utf-8") + return str(path) + + +def run_script(script: str, timeout: int): + """One execution through the real renderer path, artifacts cleaned up afterwards.""" + exit_code, output, artifact = render_utils.execute_script(script, [], SCRIPT_TYPE, timeout=timeout) + if artifact is not None: + for path in (artifact, artifact + render_utils.RAW_OUTPUT_SUFFIX): + try: + os.unlink(path) + except OSError: + pass + return exit_code, output + + +REPORTING_SCRIPT = """ + Write-Output "HELLO-CONPTY" + exit 0 +""" + +FAILING_SCRIPT = """ + Write-Output "BEFORE-EXIT" + exit 3 +""" + +SINGLE_READ_SCRIPT = """ + Write-Output "READY" + $line = [Console]::In.ReadLine() + Write-Output "GOT $line" +""" + +REPEATED_READ_SCRIPT = """ + Write-Output "READY" + while ($true) { + $line = [Console]::In.ReadLine() + Write-Output "GOT $line" + } +""" + + +def test_a_powershell_script_runs_through_execute_script_and_reports_its_output(tmp_path): + script = write_powershell(tmp_path, "reports", REPORTING_SCRIPT) + + exit_code, output = run_script(script, timeout=90) + + assert exit_code == 0 + assert "HELLO-CONPTY" in output + + +def test_a_failing_powershell_script_returns_its_exit_code_verbatim(tmp_path): + script = write_powershell(tmp_path, "fails", FAILING_SCRIPT) + + exit_code, output = run_script(script, timeout=90) + + assert exit_code == 3 + assert "BEFORE-EXIT" in output + + +@pytest.mark.parametrize( + "name,body", + [("reads_once", SINGLE_READ_SCRIPT), ("reads_repeatedly", REPEATED_READ_SCRIPT)], +) +def test_a_script_that_reads_input_runs_to_the_timeout_and_says_why(tmp_path, name, body): + """The documented Windows asymmetry: ConPTY carries no synthetic end-of-file, so a read + blocks until the timeout instead of returning EOF the way it does on POSIX.""" + script = write_powershell(tmp_path, name, body) + + exit_code, output = run_script(script, timeout=15) + + assert exit_code == render_utils.TIMEOUT_ERROR_EXIT_CODE + assert "no input driver was attached" in output.lower() + assert "end-of-file" in output.lower() diff --git a/tests/test_conpty_support.py b/tests/test_conpty_support.py index f3c89193..449db17c 100644 --- a/tests/test_conpty_support.py +++ b/tests/test_conpty_support.py @@ -12,6 +12,7 @@ import pytest +from plain2code_exceptions import RenderCancelledError from render_machine import _conpty_support as support from render_machine._conpty_support import ( CANCEL_TICK_SECONDS, @@ -75,6 +76,61 @@ def cancel(self) -> None: self._release.set() +class LateCancelChannel(FakeChannel): + """Ignores its first cancels, the way `CancelSynchronousIo` reports ERROR_NOT_FOUND when + the writer has not entered its write yet.""" + + def __init__(self, ignore_first=1): + super().__init__() + self.ignored = ignore_first + + def cancel(self) -> None: + self.cancels += 1 + if self.ignored > 0: + self.ignored -= 1 + return # nothing was in flight, so the call reached nothing + self._release.set() + + +class SlowSubmitQueue(InputQueue): + """Widens the window between an item becoming visible and whatever the poster does next. + + With the generation published under the same lock as the enqueue, a writer that dequeues + inside this window blocks on that lock before it can acknowledge anything. Published + afterwards, it acknowledges a generation that does not exist yet. + """ + + def submit(self, *args, **kwargs): + result = super().submit(*args, **kwargs) + time.sleep(CANCEL_TICK_SECONDS * 5) + return result + + +class ControlParkChannel(WriteChannel): + """Parks inside the control write and never releases itself on cancel. + + That is what makes a cancel aimed at the control write observable: the test, not the + cancellation, decides when the write completes. + """ + + def __init__(self): + self.entered = threading.Event() + self.release = threading.Event() + self.cancels = 0 + self.written = bytearray() + + def write(self, data: bytes) -> int: + if data[:1] == b"\x03": + self.entered.set() + if not self.release.wait(SHORT_TIMEOUT): + raise AssertionError("the control write was never released") + self.written += data + return len(data) + + def cancel(self) -> None: + self.cancels += 1 + + def wait_until(predicate, timeout=SHORT_TIMEOUT): deadline = time.monotonic() + timeout while time.monotonic() < deadline: @@ -270,6 +326,7 @@ def test_closing_the_queue_resolves_every_receipt_once(): queue.close_and_fail_all() assert first.resolutions == 1 and second.resolutions == 1 + assert first.attempts == 1 and second.attempts == 1 assert not first.delivered and not second.delivered assert queue.submit(b"three")[0].disposition is InputDisposition.CLOSED @@ -283,6 +340,8 @@ def test_a_receipt_reports_its_resolution_to_the_producer(): assert seen == [(InputDisposition.ACCEPTED, None)] assert receipt.disposition is InputDisposition.ACCEPTED + assert receipt.resolutions == 1 # what took effect + assert receipt.attempts == 2 # what was tried, so a double retirement is still visible # ------------------------------------------------------------------ the writer @@ -385,6 +444,7 @@ def test_a_cancelled_write_is_never_retried_and_never_duplicates_its_prefix(): assert channel.writes.count(b"abcdef") == 1 # the buffer is never reissued assert bytes(channel.written) == b"abc\x03" assert data_receipt.resolutions == 1 + assert data_receipt.attempts == 1 # retired once, never resolved a second time finally: writer.stop(SHORT_TIMEOUT) @@ -476,24 +536,111 @@ def test_a_write_failure_is_published_to_the_foreground(): writer.stop(SHORT_TIMEOUT) -def test_the_cancel_is_retried_while_the_writer_is_still_parked_before_its_write(): - """A one-shot cancel issued before the writer enters a write reaches nothing.""" - queue, channel = InputQueue(), FakeChannel() +def test_stopping_retries_the_cancel_that_reached_nothing(): + """A cancel issued in the dequeue-to-write gap reports ERROR_NOT_FOUND and the writer + then blocks after it, so a one-shot cancel would hang to the bound.""" + queue, channel = InputQueue(), LateCancelChannel(ignore_first=1) + channel.park = True writer, _ = start_writer(queue, channel) + queue.submit(b"blocked payload") + assert channel.entered.wait(SHORT_TIMEOUT) assert writer.stop(SHORT_TIMEOUT) - assert writer.cancels >= 0 # the loop joins on the sentinel rather than on a cancel + assert channel.cancels >= 2 # the first reached nothing; a later tick landed + assert channel.ignored == 0 -def test_repeated_admissions_against_a_saturated_channel_still_stop_within_the_bound(): + +def test_a_control_item_is_delivered_even_when_the_first_cancel_reaches_nothing(): + queue, channel = InputQueue(), LateCancelChannel(ignore_first=1) + channel.park = True + writer, _ = start_writer(queue, channel) + try: + queue.submit(b"blocked payload") + assert channel.entered.wait(SHORT_TIMEOUT) + channel.park = False + + assert writer.deliver_control(b"\x03", SHORT_TIMEOUT) + + assert channel.cancels >= 2 + assert bytes(channel.written).endswith(b"\x03") + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_an_idle_writers_control_write_is_never_cancelled(): + """The generation is published under the same lock that makes the item visible, so a + writer that dequeues it immediately has already acknowledged the request the poster is + about to wait on — otherwise the poster cancels the very write it asked for.""" + channel = ControlParkChannel() + writer, _ = start_writer(SlowSubmitQueue(), channel) + delivered = [] + poster = threading.Thread(target=lambda: delivered.append(writer.deliver_control(b"\x03", SHORT_TIMEOUT))) + try: + poster.start() + assert channel.entered.wait(SHORT_TIMEOUT) + cancels_at_entry = channel.cancels + + time.sleep(CANCEL_TICK_SECONDS * 5) + + assert channel.cancels == cancels_at_entry + channel.release.set() + poster.join(SHORT_TIMEOUT) + assert delivered == [True] + finally: + channel.release.set() + poster.join(SHORT_TIMEOUT) + writer.stop(SHORT_TIMEOUT) + + +def test_a_full_data_queue_still_admits_the_graceful_control_byte(): + """Query replies are ordinary admissions, so a query-emitting target cannot fill the + capacity cancellation depends on.""" + queue = InputQueue(max_pending_bytes=64, reserved_bytes=16, max_pending_items=8, reserved_items=2) + channel = FakeChannel() + channel.park = True + writer, _ = start_writer(queue, channel) + try: + replies = [queue.submit(b"reply")[0] for _ in range(16)] + assert channel.entered.wait(SHORT_TIMEOUT) # the writer is genuinely blocked in a write + assert any(result.disposition is InputDisposition.BACKPRESSURE for result in replies) + channel.park = False + + assert writer.deliver_control(b"\x03", SHORT_TIMEOUT) + + # First on the wire: the control lane is serviced ahead of everything queued behind + # the write it preempted. + assert bytes(channel.written).startswith(b"\x03") + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_a_saturated_writer_still_stops_within_the_bound(): queue, channel = InputQueue(), FakeChannel() channel.park = True writer, _ = start_writer(queue, channel) for _ in range(50): - queue.submit(b"reply", reserved=True) + queue.submit(b"reply") assert channel.entered.wait(SHORT_TIMEOUT) started = time.monotonic() assert writer.stop(SHORT_TIMEOUT) assert time.monotonic() - started < SHORT_TIMEOUT + + +def test_the_ready_wait_reports_a_cancellation_that_arrives_after_readiness(): + """The stop check runs at least once even when the writer is already ready: a render + cancelled during writer startup must not proceed to launch the target.""" + writer = InputWriter(InputQueue(), FakeChannel()) + writer.start() + assert wait_until(writer.ready.is_set) + + def cancelled(): + raise RenderCancelledError() + + try: + with pytest.raises(RenderCancelledError): + writer.await_ready(time.monotonic() + SHORT_TIMEOUT, cancelled) + finally: + writer.stop(SHORT_TIMEOUT) From 5ccaae8d1c941243cce1f1d0704e38903ddfe21d Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 03:42:35 +0200 Subject: [PATCH 37/83] Close the job before anything that can block on release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClosePseudoConsole() can block on builds before 24H2 — the reason the windows-2022 job exists — so both release paths now close the job handle first and let kill-on-job-close fire before entering it. A hand-off that started no finalizer thread no longer reports that one took the session; the two call sites share one message that says which happened. --- render_machine/_conpty.py | 50 +++++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/render_machine/_conpty.py b/render_machine/_conpty.py index fb7a878b..e6fa3f7b 100644 --- a/render_machine/_conpty.py +++ b/render_machine/_conpty.py @@ -729,6 +729,10 @@ def teardown(self, grace: Optional[float]) -> bool: # close. Closing a handle underneath a blocked write is what the hand-off exists # to avoid. return True + # The job handle closes before the pseudoconsole, never after: ClosePseudoConsole() + # can block on builds before 24H2, and kill-on-job-close must not be waiting behind + # a call that might not return. + self._close_job() self._close_pseudoconsole() self._release_handles() return False @@ -817,15 +821,19 @@ def _close_job(self) -> None: def release_abandoned(self) -> None: """Last-resort release for a teardown nobody can finish. - Reached only when the finalizer runs out of time or could not be started. Everything - the writer cannot be blocked inside is released unconditionally — closing the job - handle is what lets kill-on-job-close fire — while `inputWriteSide` and the writer's - thread handle are leaked deliberately when the writer never returned, because closing - a handle underneath a blocked `WriteFile` is the corruption the bounded teardown - exists to avoid. + Reached only when the finalizer runs out of time or could not be started, which is + also when the process tree is most likely still alive — so the job goes first, and + `ClosePseudoConsole()` only after it. Reversed, a call that can block on builds before + 24H2 would stand between a failing teardown and the kill-on-job-close that is the + whole backstop. + + `inputWriteSide` and the writer's thread handle are leaked deliberately when the + writer never returned: closing a handle underneath a blocked `WriteFile` is the + corruption the bounded teardown exists to avoid. """ self._terminate_job() self.proc.close_all() + self._close_job() # kill-on-job-close fires before anything that can block self._close_pseudoconsole() writer = self.writer if writer is None or writer.finished.is_set(): @@ -833,7 +841,6 @@ def release_abandoned(self) -> None: self.writer_handle.close_if_owned() else: console.debug("leaking the terminal input handles: the writer never returned") - self._close_job() def join_reader(self, bound: float) -> bool: """Waits for the reader once every handle it could be blocked on is released. @@ -862,6 +869,18 @@ def __init__(self, session: _SessionBundle, stack: ExitStack) -> None: self.armed = True +def _unreleased_session_error(context: str, handed_off: bool) -> TerminalEnvironmentError: + """One message for both hand-off sites, saying which of the two things happened.""" + if handed_off: + return TerminalEnvironmentError( + f"The script's terminal session {context} and was handed to the background finalizer." + ) + return TerminalEnvironmentError( + f"The script's terminal session {context}, and no finalizer thread could be started for it: " + "it was released as far as it safely could be." + ) + + def _hand_off_to_finalizer(owner: _SessionOwner) -> bool: """Transfers the owner to a daemon finalizer. False when no thread could be started. @@ -1127,10 +1146,8 @@ def _start_session( owner.armed = False # the commit except BaseException: if session.teardown(None): # before any stack unwinding starts - self._transfer_to_finalizer(owner) - raise TerminalEnvironmentError( - "The script's terminal session could not be released within its bound and was " - "handed to the background finalizer." + raise _unreleased_session_error( + "could not be released within its bound", self._transfer_to_finalizer(owner) ) raise finally: @@ -1258,13 +1275,9 @@ def _shutdown(self, grace: Optional[float]) -> None: if owner is None: return if owner.session.teardown(grace): - self._transfer_to_finalizer(owner) - raise TerminalEnvironmentError( - "The script's terminal session did not shut down within its bound and was handed to " - "the background finalizer." - ) + raise _unreleased_session_error("did not shut down within its bound", self._transfer_to_finalizer(owner)) - def _transfer_to_finalizer(self, owner: _SessionOwner) -> None: + def _transfer_to_finalizer(self, owner: _SessionOwner) -> bool: """Publishes the owner to the daemon finalizer, or keeps it here if none took it. Disarming comes first, because `finally` runs on this path too and must not unwind a @@ -1275,12 +1288,13 @@ def _transfer_to_finalizer(self, owner: _SessionOwner) -> None: owner.armed = False if _hand_off_to_finalizer(owner): self._owner = None - return + return True # Nobody can finish this later, so release what is provably safe to release now and # keep the rest recorded on the session that stays reachable from this backend. owner.session.record_failure("no finalizer thread could be started for the terminal session") owner.session.release_abandoned() owner.stack.close() + return False def _check_cancelled(self) -> None: if self._stop_event.is_set(): From 2d63d59e939112d38ce3d9435df411d5d724ec7c Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 03:42:35 +0200 Subject: [PATCH 38/83] Cover the abandoned session and the post-launch native seams The finalizer paths are exercised directly: deadline exhaustion asserting the job handle closed before ClosePseudoConsole was attempted and the tree died with it, and a finalizer that cannot start asserting the foreground kept ownership and released the natives itself. The query target now reads the reply it asked for, the saturation case samples the writer's progress and asserts the cancel path ran when it is genuinely parked, the script helper takes a stop event so cancellation is exercised through execute_script, and failing WaitForSingleObject, GetExitCodeProcess, QueryInformationJobObject and TerminateJobObject each assert the exit-69 mapping. --- tests/test_conpty.py | 195 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 187 insertions(+), 8 deletions(-) diff --git a/tests/test_conpty.py b/tests/test_conpty.py index 42b5bb28..26a4a08b 100644 --- a/tests/test_conpty.py +++ b/tests/test_conpty.py @@ -35,6 +35,7 @@ from render_machine._conpty import ConPtyProcess # noqa: E402 from render_machine._legacy_pipe import LegacyPipeProcess # noqa: E402 from render_machine.terminal_process import ( # noqa: E402 + ENVIRONMENT_ERROR_EXIT_CODE, NO_PTY_ENV_VAR, InputDisposition, TerminalEnvironmentError, @@ -663,6 +664,94 @@ def expire_once(self, bound): assert wait_for(lambda: handle_count() <= before, timeout=30.0) +def native_call_log(monkeypatch): + """One ordered log of the two native calls whose relative order is load-bearing.""" + events = [] + original_close_handle = kernel32.CloseHandle + original_close_pty = kernel32.ClosePseudoConsole + + def close_handle(handle): + events.append(("CloseHandle", handle, threading.current_thread().name)) + return original_close_handle(handle) + + def close_pseudoconsole(handle): + events.append(("ClosePseudoConsole", handle, threading.current_thread().name)) + original_close_pty(handle) + + monkeypatch.setattr(kernel32, "CloseHandle", close_handle) + monkeypatch.setattr(kernel32, "ClosePseudoConsole", close_pseudoconsole) + return events + + +def index_of(events, name, handle=None): + for position, (called, argument, _thread) in enumerate(events): + if called == name and (handle is None or argument == handle): + return position + return None + + +def test_a_finalizer_that_runs_out_of_time_still_closes_the_job_first(monkeypatch, backend, tmp_path): + """The abandoned path is the one most likely to face a live process tree, and + `ClosePseudoConsole()` can block on this build — so kill-on-job-close must not be queued + behind it.""" + script = write_program(tmp_path, "waits", "import time\ntime.sleep(120)\n") + events = native_call_log(monkeypatch) + monkeypatch.setattr(_conpty._SessionBundle, "_await_job_empty", lambda self, bound: False) + monkeypatch.setattr(_conpty, "FINALIZER_DEADLINE_SECONDS", 0.2) + monkeypatch.setattr(_conpty, "FINALIZER_TICK_SECONDS", 0.05) + + backend.spawn(command(script)) + session = backend._owner.session + child = int(session.proc.pi.dwProcessId) + job = session.hJob + with pytest.raises(TerminalEnvironmentError): + backend.close() + + assert wait_for( + lambda: not any(thread.name == "codeplain-conpty-finalizer" for thread in threading.enumerate()), + timeout=30.0, + ) + job_closed = index_of(events, "CloseHandle", job) + pseudoconsole_closed = index_of(events, "ClosePseudoConsole") + assert job_closed is not None, "the abandoned session never released its job" + assert pseudoconsole_closed is not None + assert job_closed < pseudoconsole_closed + assert process_is_gone(child) # kill-on-job-close, which is what closing the job buys + + +def test_a_finalizer_that_cannot_start_leaves_the_session_owned_and_released(monkeypatch, backend, tmp_path): + """Nothing took the session, so the foreground keeps it and releases the natives itself + rather than dropping the only reference to a live job.""" + script = write_program(tmp_path, "waits", "import time\ntime.sleep(120)\n") + events = native_call_log(monkeypatch) + monkeypatch.setattr(_conpty._SessionBundle, "_await_job_empty", lambda self, bound: False) + original_start = threading.Thread.start + + def refuse(self): + if self.name == "codeplain-conpty-finalizer": + raise RuntimeError("can't start new thread") + original_start(self) + + monkeypatch.setattr(threading.Thread, "start", refuse) + + backend.spawn(command(script)) + session = backend._owner.session + child = int(session.proc.pi.dwProcessId) + job = session.hJob + started = time.monotonic() + with pytest.raises(TerminalEnvironmentError) as error: + backend.close() + + assert time.monotonic() - started < TEARDOWN_BOUND + assert "no finalizer thread could be started" in str(error.value) + assert backend._owner is not None # ownership retained rather than dropped + assert index_of(events, "CloseHandle", job) is not None + assert process_is_gone(child) + # The input handles are the documented exception: the writer never returned, so they are + # leaked rather than closed underneath a blocked write. + assert session.in_w.owned and session.writer_handle.owned + + # ------------------------------------------------------------------- marshaling @@ -742,9 +831,24 @@ def create_then_cancel(*args, **kwargs): """ +def writer_progress(backend): + """What the writer has consumed: the item under its cursor and the bytes still owed.""" + queue = backend._writer.queue + current = queue.current() + position = None if current is None else (current.sequence, current.cursor) + return position, queue.pending_bytes() + + def test_a_saturated_input_channel_still_tears_down_within_the_bound(backend, tmp_path): - """The target never reads, so the writer ends up blocked inside a synchronous `WriteFile` - — the state the cancel loop and the bounded join exist for.""" + """The target never reads, so the writer is expected to end up parked inside a synchronous + `WriteFile` — the state the cancel loop and the bounded join exist for. + + Whether it truly parks is not this side's decision: the pseudoconsole drains the pipe into + its own input buffer, so on some builds every item lands and the writer stays idle. The + bounded-progress sample below distinguishes the two worlds, and each is asserted for what + it can prove — the parked one that the cancel path ran at all, both of them that teardown + stays inside its bound and the tree dies. + """ script = write_program(tmp_path, "silent_reader", SILENT_READER) backend.spawn(command(script)) assert wait_for_output(backend, "READY") @@ -757,11 +861,19 @@ def test_a_saturated_input_channel_still_tears_down_within_the_bound(backend, tm break accepted += 1 + first = writer_progress(backend) + time.sleep(0.5) + second = writer_progress(backend) + parked = first == second and first[0] is not None and first[1] > 0 + started = time.monotonic() backend.terminate_tree(grace=0.5) backend.close() assert accepted > 0 + if parked: + # A writer that never moved could only be released by the cancel loop. + assert backend._writer.cancels > 0 assert time.monotonic() - started < TEARDOWN_BOUND assert process_is_gone(child) @@ -801,26 +913,42 @@ def test_a_client_that_cleared_processed_input_falls_through_to_forced_terminati TERMINAL_QUERY = """ + import msvcrt import sys + import time sys.stdout.write("\\x1b[6n") # device status report: a terminal is expected to answer sys.stdout.flush() + + reply = "" + deadline = time.monotonic() + 20 + while time.monotonic() < deadline and not reply.endswith("R"): + if msvcrt.kbhit(): + reply += msvcrt.getwch() + else: + time.sleep(0.01) + + print("ANSWERED=%s" % reply.endswith("R"), flush=True) print("QUERIED", flush=True) """ -def test_a_target_that_queries_the_terminal_leaves_no_unanswered_obligation(backend, tmp_path): - """Either the pseudoconsole answers the query itself or the renderer's responder does. - What must never happen is an obligation this side registered and could not deliver.""" +def test_a_target_that_queries_the_terminal_receives_its_reply(backend, tmp_path): + """The target reads its own console input back, so this proves delivery rather than the + absence of a recorded failure. Which side answers — the pseudoconsole's own emulator or + the renderer's responder — is not asserted; that a querying target is not left waiting is. + """ script = write_program(tmp_path, "queries", TERMINAL_QUERY) backend.spawn(command(script)) exit_code = wait_for_exit(backend) backend.terminate_tree(grace=0.1) backend.close() + output = backend.normalized_output() assert exit_code == 0 - assert "QUERIED" in backend.normalized_output() + assert "QUERIED" in output + assert "ANSWERED=True" in output assert backend.terminal_reply_failed is False, backend.terminal_reply_detail() @@ -836,9 +964,11 @@ def write_powershell(tmp_path: Path, name: str, body: str) -> str: return str(path) -def run_script(script: str, timeout: int): +def run_script(script: str, timeout: int, stop_event=None): """One execution through the real renderer path, artifacts cleaned up afterwards.""" - exit_code, output, artifact = render_utils.execute_script(script, [], SCRIPT_TYPE, timeout=timeout) + exit_code, output, artifact = render_utils.execute_script( + script, [], SCRIPT_TYPE, timeout=timeout, stop_event=stop_event + ) if artifact is not None: for path in (artifact, artifact + render_utils.RAW_OUTPUT_SUFFIX): try: @@ -905,3 +1035,52 @@ def test_a_script_that_reads_input_runs_to_the_timeout_and_says_why(tmp_path, na assert exit_code == render_utils.TIMEOUT_ERROR_EXIT_CODE assert "no input driver was attached" in output.lower() assert "end-of-file" in output.lower() + + +def test_a_cancelled_script_raises_instead_of_publishing_an_outcome(tmp_path): + """Cancellation while the script is blocked on a read it will never satisfy: the run + raises rather than waiting out the timeout it would otherwise reach.""" + marker = tmp_path / "started.txt" + script = write_powershell( + tmp_path, + "cancelled_read", + f""" + New-Item -ItemType File -Path "{marker}" | Out-Null + Write-Output "READY" + $line = [Console]::In.ReadLine() + Write-Output "GOT $line" + """, + ) + stop = threading.Event() + watcher = threading.Thread(target=lambda: stop.set() if wait_for(marker.exists, timeout=90.0) else None) + watcher.daemon = True + watcher.start() + + started = time.monotonic() + with pytest.raises(RenderCancelledError): + run_script(script, timeout=180, stop_event=stop) + + assert time.monotonic() - started < 90.0 # cancelled, not timed out + watcher.join(5.0) + + +@pytest.mark.parametrize( + "symbol,result", + [ + ("WaitForSingleObject", 0xFFFFFFFF), # WAIT_FAILED + ("GetExitCodeProcess", 0), + ("QueryInformationJobObject", 0), + ("TerminateJobObject", 0), + ], +) +def test_a_native_call_failing_after_launch_is_an_environment_error(monkeypatch, tmp_path, symbol, result): + """The post-launch seams: a wait, an exit-code read, a job query or a job termination that + fails describes a run whose outcome nobody could observe, so it takes the 69 channel rather + than being reported as a timeout or a clean pass.""" + script = write_powershell(tmp_path, "reports", REPORTING_SCRIPT) + monkeypatch.setattr(kernel32, symbol, lambda *arguments: result) + + exit_code, output = run_script(script, timeout=90) + + assert exit_code == ENVIRONMENT_ERROR_EXIT_CODE + assert "could not be executed" in output From 728c7f713c92c413f4f13e6ba789ee71fff2931c Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 04:19:22 +0200 Subject: [PATCH 39/83] Give the ConPTY child the terminal instead of the renderer's handles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreateProcessW copies the renderer's own standard handles into the child when they are not console handles, so on a redirected renderer — every CI run — the target wrote into the renderer's stdout and read end-of-file from its stdin while still attached to the pseudoconsole and its job. It is now handed STARTF_USESTDHANDLES with none, and only when the renderer is redirected, so the interactive path is untouched. ClosePseudoConsole() also does not always end a parked ReadFile: a session that never had a client left the reader waiting on a pipe whose write end was gone, and the stalled thread then outlived the run. The reader opens a handle to itself at startup and the bounded join cancels its read the way the writer's stop already does. --- render_machine/_conpty.py | 115 +++++++++++++++++++++++++++++++------- 1 file changed, 96 insertions(+), 19 deletions(-) diff --git a/render_machine/_conpty.py b/render_machine/_conpty.py index e6fa3f7b..48c238e2 100644 --- a/render_machine/_conpty.py +++ b/render_machine/_conpty.py @@ -39,6 +39,7 @@ from plain2code_console import console from plain2code_exceptions import RenderCancelledError from render_machine._conpty_support import ( + CANCEL_TICK_SECONDS, WRITER_JOIN_DEADLINE_SECONDS, GateDecision, InputQueue, @@ -47,6 +48,7 @@ WriteChannel, build_command_line, build_environment_block, + native_thread_id, reply_resolution, validate_working_directory, ) @@ -101,6 +103,7 @@ S_OK = 0 EXTENDED_STARTUPINFO_PRESENT = 0x00080000 +STARTF_USESTDHANDLES = 0x00000100 CREATE_UNICODE_ENVIRONMENT = 0x00000400 PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x00020016 PROC_THREAD_ATTRIBUTE_JOB_LIST = 0x0002000D @@ -112,6 +115,11 @@ THREAD_TERMINATE = 0x0001 +STD_INPUT_HANDLE = 0xFFFFFFF6 +STD_OUTPUT_HANDLE = 0xFFFFFFF5 +STD_ERROR_HANDLE = 0xFFFFFFF4 +INVALID_HANDLE_VALUE = 0xFFFFFFFFFFFFFFFF + ERROR_HANDLE_EOF = 38 ERROR_BROKEN_PIPE = 109 ERROR_INSUFFICIENT_BUFFER = 122 @@ -269,6 +277,8 @@ def _declare(name: str, argtypes: Sequence[object], restype: Optional[object]): _declare("GetExitCodeProcess", [HANDLE, LPDWORD], BOOL) _declare("WaitForSingleObject", [HANDLE, DWORD], DWORD) _declare("OpenThread", [DWORD, BOOL, DWORD], HANDLE) +_declare("GetStdHandle", [DWORD], HANDLE) +_declare("GetConsoleMode", [HANDLE, LPDWORD], BOOL) _declare("CancelSynchronousIo", [HANDLE], BOOL) @@ -551,6 +561,15 @@ def _create_process( startup = STARTUPINFOEXW() startup.StartupInfo.cb = ctypes.sizeof(STARTUPINFOEXW) startup.lpAttributeList = attrs.buffer + if _renderer_output_is_redirected(): + # Declared, and left NULL. Without this, CreateProcessW hands the child a copy of the + # renderer's own standard handles: verified on Windows Server 2022, where the target + # wrote into the renderer's redirected stdout and read end-of-file from its stdin + # while still attached to the pseudoconsole and to its job. Declaring the handles and + # supplying none stops that copy, and the console the child is attached to supplies + # its standard handles instead. A renderer that owns a console is left on the path + # that already reaches the pseudoconsole, so this never changes the interactive case. + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES # CreateProcessW may modify lpCommandLine in place, so it is handed a writable buffer. command_buffer = ctypes.create_unicode_buffer(command_line) # The buffer's own terminator supplies the block's second NUL. @@ -573,6 +592,25 @@ def _create_process( proc.valid = True # closers ignore the garbage a failed call leaves behind +def _renderer_output_is_redirected() -> bool: + """True when any of the renderer's own standard handles is not a console. + + It decides whether the child needs protecting from them. When the renderer sits on a + console, `CreateProcessW` swaps the child's standard handles for its own console's and + the pseudoconsole is reached as intended. When the renderer is redirected — every CI run, + every piped invocation — the same call copies those files or pipes into the child, which + then writes past the pseudoconsole entirely and reads end-of-file instead of input. + """ + for identifier in (STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE): + handle = kernel32.GetStdHandle(identifier) + if not handle or handle == INVALID_HANDLE_VALUE: + return True + mode = DWORD(0) + if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + return True + return False + + def _open_thread_handle(native_id: int) -> int: """THREAD_TERMINATE is what `CancelSynchronousIo()` requires. @@ -652,6 +690,7 @@ def __init__(self, out_w: _Holder, in_pair: _PipePair, in_queue: InputQueue) -> self.writer: Optional[InputWriter] = None self.writer_handle = _Holder() # the same take-then-close discipline as every handle self.reader: Optional[threading.Thread] = None + self.reader_handle = _Holder() # opened by the reader itself, for cancelling its read self.hPC = HPCON() self.hPC_valid = False # a failed HRESULT output is never closable self.hJob: Optional[int] = None @@ -789,6 +828,50 @@ def _stop_writer(self) -> bool: return True return writer.stop(WRITER_JOIN_DEADLINE_SECONDS) + def adopt_reader_thread(self) -> None: + """The reader opens a handle to itself, so a parked read can be cancelled later. + + Opened by the thread rather than derived from a recorded id at cancel time: a thread + id is recyclable the instant its thread exits, and this is the one moment the thread + is certainly alive. + """ + try: + self.reader_handle.value = HANDLE(_open_thread_handle(native_thread_id())) + except BaseException as exc: # cancellation degrades to the bounded join alone + console.debug(f"the terminal output reader could not open a handle to itself: {exc!r}") + + def join_reader(self, bound: float) -> bool: + """Joins the reader, cancelling its read if the output pipe never reached end-of-file. + + `ClosePseudoConsole()` does not always break a parked `ReadFile`: verified on Windows + Server 2022, where a session that never had a client left the reader waiting on a pipe + whose write end was gone. Cancellation is retried like the writer's, because a cancel + issued between two reads reaches nothing. True when the reader has finished. + """ + reader = self.reader + if reader is None or reader.ident is None: # None when it never started + return True + reader.join(timeout=bound) + deadline = time.monotonic() + bound + while reader.is_alive(): + self._cancel_reader() + reader.join(timeout=CANCEL_TICK_SECONDS) + if time.monotonic() >= deadline: + break + if reader.is_alive(): + return False + self.reader_handle.close_if_owned() # nothing can be cancelled through it any more + return True + + def _cancel_reader(self) -> None: + handle = self.reader_handle.handle() + if not handle: + return + if not kernel32.CancelSynchronousIo(handle): + error = ctypes.get_last_error() + if error != ERROR_NOT_FOUND: # nothing was in flight; the next tick tries again + console.debug(f"cancelling the terminal output read reported Windows error {error}") + def _close_pseudoconsole(self) -> None: """The precondition is the output pipe: drained *or* closed, never neither. @@ -796,7 +879,8 @@ def _close_pseudoconsole(self) -> None: the close happens while a live reader is still draining, and a reader that has already failed closed the read handle before it published anything, so the same call finds the pipe closed. The join only follows the close, never precedes it, and this - never runs on the reader thread. + never runs on the reader thread. What the close does not guarantee is that the read + itself ends, which is why the join cancels it. """ if not self.hPC_valid: return @@ -804,13 +888,13 @@ def _close_pseudoconsole(self) -> None: _close_handle(self.out_w.take()) # the write side must go, or the reader never sees EOF kernel32.ClosePseudoConsole(self.hPC) self.hPC = HPCON() - reader = self.reader - if reader is not None and reader.ident is not None: - reader.join(timeout=DRAIN_DEADLINE_SECONDS) + self.join_reader(DRAIN_DEADLINE_SECONDS) def _release_handles(self) -> None: self.in_w.close_if_owned() # after the writer has stopped, never before self.writer_handle.close_if_owned() + if not self.reader_alive(): # kept while a parked read may still need cancelling + self.reader_handle.close_if_owned() self._close_job() self.proc.close_all() @@ -818,6 +902,10 @@ def _close_job(self) -> None: job, self.hJob = self.hJob, None # taken before it is closed, like every other handle _close_handle(job) # closing it is also the kill-on-close backstop + def reader_alive(self) -> bool: + reader = self.reader + return reader is not None and reader.ident is not None and reader.is_alive() + def release_abandoned(self) -> None: """Last-resort release for a teardown nobody can finish. @@ -842,18 +930,6 @@ def release_abandoned(self) -> None: else: console.debug("leaking the terminal input handles: the writer never returned") - def join_reader(self, bound: float) -> bool: - """Waits for the reader once every handle it could be blocked on is released. - - True when it is still running, which means it still owns state and can still append - to the transcript. - """ - reader = self.reader - if reader is None or reader.ident is None: # None when it never started - return False - reader.join(timeout=bound) - return reader.is_alive() - class _SessionOwner: """The session and its rollback stack, as one reference. @@ -1059,7 +1135,7 @@ def close(self) -> None: owner.armed = False # Joined after the stack close, because that is what releases the last write # handle a reader parked on an early failure path is still waiting for. - if owner.session.join_reader(DRAIN_DEADLINE_SECONDS): + if not owner.session.join_reader(DRAIN_DEADLINE_SECONDS): self._publish_reader_stall() # ------------------------------------------------------------ spawn sequence @@ -1161,7 +1237,7 @@ def _start_reader(self, session: _SessionBundle, bundle: _ReaderHandles, gate: t here and `CreateProcessW` unwinds with a reader the teardown can still join. """ reader = threading.Thread( - target=self._reader_main, args=(bundle, gate), name="codeplain-conpty-reader", daemon=True + target=self._reader_main, args=(session, bundle, gate), name="codeplain-conpty-reader", daemon=True ) try: session.reader = reader @@ -1192,10 +1268,11 @@ def _start_writer(self, session: _SessionBundle, deadline: float) -> None: finally: writer.gate.set(decision) # always: ABORT wakes the writer to exit untouched - def _reader_main(self, bundle: _ReaderHandles, gate: threading.Event) -> None: + def _reader_main(self, session: _SessionBundle, bundle: _ReaderHandles, gate: threading.Event) -> None: gate.wait() if bundle.owner != _OWNER_READER: return # the parent still owns everything; touch nothing, publish nothing + session.adopt_reader_thread() # before the first read, so teardown can always cancel it reader_exc: Optional[BaseException] = None decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") handle = bundle.out_r.value From 9b218bdf1b29ef9808adad3d6b71835db43eca6c Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 04:19:32 +0200 Subject: [PATCH 40/83] Measure the ConPTY backend's own handles and attribute its leaks GetProcessHandleCount is not a leak detector for this backend: CPython allocates a kernel semaphore per lock object, so the count moved for reasons the tests were not about and every rollback case failed on it. A ledger of the handles the backend itself opens and closes replaces it. A pump left running now fails the test that leaked it rather than the next one, the pseudoconsole failure case asks for a size Windows Server 2022 actually rejects, the in-child probe declares the calls it makes instead of truncating handles to int, and a target that cannot reach the transcript writes its report to a file so the next run says what it saw. --- tests/test_conpty.py | 188 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 154 insertions(+), 34 deletions(-) diff --git a/tests/test_conpty.py b/tests/test_conpty.py index 26a4a08b..3f291bdc 100644 --- a/tests/test_conpty.py +++ b/tests/test_conpty.py @@ -65,10 +65,6 @@ probe.WaitForSingleObject.restype = wintypes.DWORD probe.CloseHandle.argtypes = [wintypes.HANDLE] probe.CloseHandle.restype = wintypes.BOOL -probe.GetCurrentProcess.argtypes = [] -probe.GetCurrentProcess.restype = wintypes.HANDLE -probe.GetProcessHandleCount.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] -probe.GetProcessHandleCount.restype = wintypes.BOOL def write_program(tmp_path: Path, name: str, source: str) -> str: @@ -91,7 +87,9 @@ def wait_for(predicate, timeout=WAIT_TIMEOUT): def wait_for_output(process, needle, timeout=WAIT_TIMEOUT): - return wait_for(lambda: needle in process.normalized_output(), timeout) + if wait_for(lambda: needle in process.normalized_output(), timeout): + return True + raise AssertionError(f"{needle!r} never reached the transcript, which held {process.normalized_output()!r}") def wait_for_exit(process, timeout=WAIT_TIMEOUT): @@ -109,16 +107,116 @@ def process_is_gone(pid: int, timeout=WAIT_TIMEOUT) -> bool: probe.CloseHandle(handle) -def handle_count() -> int: - count = wintypes.DWORD(0) - probe.GetProcessHandleCount(probe.GetCurrentProcess(), ctypes.byref(count)) - return int(count.value) - - def live_backend_threads(): return [thread for thread in threading.enumerate() if thread.name.startswith("codeplain-conpty-")] +class _HandleLedger: + """Every handle the backend opened through kernel32, minus the ones it closed again.""" + + def __init__(self): + self.open = {} + + def opened(self, handle, description): + if handle: + self.open[int(handle)] = description + + def closed(self, handle): + if handle: + self.open.pop(int(handle), None) + + def outstanding(self): + return dict(self.open) + + +@pytest.fixture +def handle_ledger(monkeypatch): + """A ledger of the backend's own handles rather than GetProcessHandleCount(). + + A process-wide count is not a leak detector here: CPython allocates a kernel semaphore + per lock object, so the count moves for reasons that have nothing to do with this + backend, and waiting for it to settle waits on the garbage collector. + """ + ledger = _HandleLedger() + originals = { + name: getattr(kernel32, name) + for name in ( + "CreatePipe", + "CreateJobObjectW", + "OpenThread", + "CreateProcessW", + "CreatePseudoConsole", + "CloseHandle", + "ClosePseudoConsole", + ) + } + + def create_pipe(read_slot, write_slot, attributes, size): + ok = originals["CreatePipe"](read_slot, write_slot, attributes, size) + if ok: + ledger.opened(read_slot._obj.value, "pipe read end") + ledger.opened(write_slot._obj.value, "pipe write end") + return ok + + def create_job(attributes, name): + handle = originals["CreateJobObjectW"](attributes, name) + ledger.opened(handle, "job object") + return handle + + def open_thread(access, inherit, thread_id): + handle = originals["OpenThread"](access, inherit, thread_id) + ledger.opened(handle, "thread handle") + return handle + + def create_process(*arguments): + ok = originals["CreateProcessW"](*arguments) + if ok: + information = arguments[-1]._obj + ledger.opened(information.hProcess, "process handle") + ledger.opened(information.hThread, "thread handle of the process") + return ok + + def create_pseudoconsole(size, input_handle, output_handle, flags, slot): + hresult = originals["CreatePseudoConsole"](size, input_handle, output_handle, flags, slot) + if hresult == 0: + ledger.opened(slot._obj.value, "pseudoconsole") + return hresult + + def close_handle(handle): + ledger.closed(handle if isinstance(handle, int) else handle.value) + return originals["CloseHandle"](handle) + + def close_pseudoconsole(handle): + ledger.closed(handle if isinstance(handle, int) else handle.value) + originals["ClosePseudoConsole"](handle) + + for name, replacement in ( + ("CreatePipe", create_pipe), + ("CreateJobObjectW", create_job), + ("OpenThread", open_thread), + ("CreateProcessW", create_process), + ("CreatePseudoConsole", create_pseudoconsole), + ("CloseHandle", close_handle), + ("ClosePseudoConsole", close_pseudoconsole), + ): + monkeypatch.setattr(kernel32, name, replacement) + return ledger + + +@pytest.fixture(autouse=True) +def no_backend_threads_outlive_the_test(): + """Fails the test that leaked a pump rather than the one that runs after it. + + A reader or writer left running owns handles and keeps appending to a transcript nobody + reads, and every later assertion about threads or handles then measures the leak instead + of its own subject. + """ + yield + assert wait_for( + lambda: not live_backend_threads(), timeout=20.0 + ), f"backend threads outlived the test: {[thread.name for thread in live_backend_threads()]}" + + @pytest.fixture def backend(): process = ConPtyProcess() @@ -142,17 +240,37 @@ def backend(): import os import sys + # Declared: an undeclared call returns c_int, which truncates a handle and reports a + # false negative for both questions below. kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.GetStdHandle.argtypes = [ctypes.c_uint] + kernel32.GetStdHandle.restype = ctypes.c_void_p + kernel32.GetCurrentProcess.argtypes = [] + kernel32.GetCurrentProcess.restype = ctypes.c_void_p + kernel32.GetConsoleMode.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint)] + kernel32.GetConsoleMode.restype = ctypes.c_int + kernel32.IsProcessInJob.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)] + kernel32.IsProcessInJob.restype = ctypes.c_int + mode = ctypes.c_uint(0) - console = kernel32.GetConsoleMode(kernel32.GetStdHandle(-11), ctypes.byref(mode)) + console = kernel32.GetConsoleMode(kernel32.GetStdHandle(0xFFFFFFF5), ctypes.byref(mode)) in_job = ctypes.c_int(0) kernel32.IsProcessInJob(kernel32.GetCurrentProcess(), None, ctypes.byref(in_job)) - print("ISATTY=%s" % (os.isatty(0) and os.isatty(1) and os.isatty(2))) - print("CONSOLE=%s" % bool(console)) - print("INJOB=%s" % bool(in_job.value)) - print("TERM=%s" % os.environ.get("TERM")) - print("DONE") + report = [ + "ISATTY=%s" % (os.isatty(0) and os.isatty(1) and os.isatty(2)), + "CONSOLE=%s" % bool(console), + "INJOB=%s" % bool(in_job.value), + "TERM=%s" % os.environ.get("TERM"), + "DONE", + ] + # Written as well as printed: if the target's standard handles are not the terminal's, + # its own account of them is the only evidence that survives. + with open(sys.argv[1], "w", encoding="utf-8") as handle: + handle.write("\n".join(report)) + + for line in report: + print(line) sys.stdout.flush() """ @@ -202,12 +320,14 @@ def test_a_failing_bool_call_reports_the_captured_last_error(): def test_a_failing_pseudoconsole_call_is_reported_as_its_hresult(): - """An invalid input handle: the failure has to be detected as a nonzero HRESULT rather - than read from the last error, which these calls do not promise to set.""" + """A zero-sized console is rejected outright, where invalid handles are not: Windows + Server 2022 accepts handles it will only fail on later. The failure has to be detected as + a nonzero HRESULT rather than read from the last error, which these calls do not promise + to set.""" slot = _conpty.HPCON() with pytest.raises(TerminalEnvironmentError) as error: - _conpty._create_pseudoconsole(80, 25, -2, -2, ctypes.byref(slot)) + _conpty._create_pseudoconsole(0, 0, -1, -1, ctypes.byref(slot)) assert "HRESULT" in str(error.value) @@ -251,15 +371,17 @@ def test_the_escape_hatch_still_selects_the_pipe_backend_on_windows(monkeypatch) def test_a_script_runs_on_a_real_console_inside_the_job(backend, tmp_path): script = write_program(tmp_path, "terminal_probe", TERMINAL_PROBE) + report_path = tmp_path / "probe.txt" - backend.spawn(command(script)) + backend.spawn(command(script, str(report_path))) exit_code = wait_for_exit(backend) backend.terminate_tree(grace=0.1) backend.close() output = backend.normalized_output() + report = report_path.read_text(encoding="utf-8") if report_path.exists() else "(no report was written)" assert exit_code == 0 - assert "ISATTY=True" in output + assert "ISATTY=True" in output, f"transcript={output!r}; the target reported:\n{report}" assert "CONSOLE=True" in output assert "INJOB=True" in output assert "TERM=xterm-256color" in output @@ -465,10 +587,9 @@ def wrapper(*args, **kwargs): "_open_thread_handle", ], ) -def test_a_failed_step_leaves_no_process_thread_or_handle_behind(monkeypatch, tmp_path, step): +def test_a_failed_step_leaves_no_process_thread_or_handle_behind(monkeypatch, handle_ledger, tmp_path, step): script = write_program(tmp_path, "never_runs", "print('unreachable')\n") monkeypatch.setattr(_conpty, step, failing(step)) - before = handle_count() process = ConPtyProcess() with pytest.raises(TerminalEnvironmentError): @@ -477,17 +598,18 @@ def test_a_failed_step_leaves_no_process_thread_or_handle_behind(monkeypatch, tm assert "unreachable" not in process.normalized_output() assert wait_for(lambda: not live_backend_threads(), timeout=10.0) - assert wait_for(lambda: handle_count() <= before, timeout=10.0) + assert wait_for(lambda: not handle_ledger.outstanding(), timeout=10.0), handle_ledger.outstanding() @pytest.mark.parametrize( "step", ["_create_job", "_create_pseudoconsole", "_initialize_attribute_list"], ) -def test_a_failure_after_a_real_native_step_releases_what_that_step_allocated(monkeypatch, tmp_path, step): +def test_a_failure_after_a_real_native_step_releases_what_that_step_allocated( + monkeypatch, handle_ledger, tmp_path, step +): script = write_program(tmp_path, "never_runs", "print('unreachable')\n") failing_after(monkeypatch, step) - before = handle_count() process = ConPtyProcess() with pytest.raises(TerminalEnvironmentError): @@ -495,10 +617,10 @@ def test_a_failure_after_a_real_native_step_releases_what_that_step_allocated(mo process.close() assert wait_for(lambda: not live_backend_threads(), timeout=10.0) - assert wait_for(lambda: handle_count() <= before, timeout=10.0) + assert wait_for(lambda: not handle_ledger.outstanding(), timeout=10.0), handle_ledger.outstanding() -def test_a_failure_after_create_process_leaves_no_surviving_child(monkeypatch, tmp_path): +def test_a_failure_after_create_process_leaves_no_surviving_child(monkeypatch, handle_ledger, tmp_path): """The widest rollback: the child already exists, and the job it was created inside is what takes it down.""" script = write_program(tmp_path, "waits", "import time\ntime.sleep(120)\n") @@ -511,7 +633,6 @@ def create_then_fail(command_line, directory, environment, attrs, proc): raise TerminalEnvironmentError("injected after the child was created") monkeypatch.setattr(_conpty, "_create_process", create_then_fail) - before = handle_count() process = ConPtyProcess() with pytest.raises(TerminalEnvironmentError): @@ -521,7 +642,7 @@ def create_then_fail(command_line, directory, environment, attrs, proc): assert created, "the injection never ran the real call" assert process_is_gone(created[0]) assert wait_for(lambda: not live_backend_threads(), timeout=10.0) - assert wait_for(lambda: handle_count() <= before, timeout=10.0) + assert wait_for(lambda: not handle_ledger.outstanding(), timeout=10.0), handle_ledger.outstanding() @pytest.mark.parametrize("call_index", [1, 2]) @@ -621,7 +742,7 @@ def fail_with_sentinels(command_line, directory, environment, attrs, proc): assert wait_for(lambda: not live_backend_threads(), timeout=10.0) -def test_a_teardown_that_outlives_its_bound_is_handed_to_the_finalizer(monkeypatch, backend, tmp_path): +def test_a_teardown_that_outlives_its_bound_is_handed_to_the_finalizer(monkeypatch, handle_ledger, backend, tmp_path): """The foreground returns promptly and reports the failure on the environment channel; the finalizer, not the foreground, closes the pseudoconsole and releases the session.""" script = write_program(tmp_path, "waits", "import time\ntime.sleep(120)\n") @@ -643,7 +764,6 @@ def expire_once(self, bound): monkeypatch.setattr(_conpty._SessionBundle, "_await_job_empty", expire_once) monkeypatch.setattr(_conpty, "FINALIZER_TICK_SECONDS", 0.05) - before = handle_count() backend.spawn(command(script)) child = int(backend._owner.session.proc.pi.dwProcessId) started = time.monotonic() @@ -661,7 +781,7 @@ def expire_once(self, bound): timeout=30.0, ) assert process_is_gone(child) - assert wait_for(lambda: handle_count() <= before, timeout=30.0) + assert wait_for(lambda: not handle_ledger.outstanding(), timeout=30.0), handle_ledger.outstanding() def native_call_log(monkeypatch): From 5f12ecb39c20ad8e64fa95cc8b6fbf3d7fb6baf3 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 04:40:22 +0200 Subject: [PATCH 41/83] Release every allocation through the owner that makes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A job handle created and then assigned by the caller, and a pseudoconsole armed one statement after the call that made it, both leaked when anything came between: the Windows run showed the job and the pseudoconsole outstanding after a failure injected straight after those calls. Each now lands in its owner inside the call itself, as the pipes, the attribute list and the process handles already did. The last-resort release stops the input writer instead of assuming it is stuck. A teardown that gave up at the job-empty wait never reached the stop, so the writer sat parked on its queue — where only the stop sentinel can release it — and outlived the run holding the input handle. --- render_machine/_conpty.py | 57 ++++++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/render_machine/_conpty.py b/render_machine/_conpty.py index 48c238e2..d7dfdbae 100644 --- a/render_machine/_conpty.py +++ b/render_machine/_conpty.py @@ -488,12 +488,18 @@ def _create_pipe(read_holder, write_holder, pair: _PipePair) -> None: pair.valid = True -def _create_job() -> int: +def _create_job(session: "_SessionBundle") -> None: + """Stores the job in the session before returning. + + Returning the handle for the caller to assign leaves a window in which nothing owns it: + an interruption between the two loses the job, and with it the containment its whole + purpose is. + """ handle = kernel32.CreateJobObjectW(None, None) if not handle: error = ctypes.get_last_error() raise _win_error("Creating the job object for the script's process tree", error) - return handle + session.hJob = handle def _set_kill_on_job_close(job: int) -> None: @@ -511,11 +517,17 @@ def _set_kill_on_job_close(job: int) -> None: raise _win_error("Configuring the job object", error) -def _create_pseudoconsole(columns: int, rows: int, in_r: int, out_w: int, slot) -> None: +def _create_pseudoconsole(session, columns: int, rows: int, in_r: int, out_w: int) -> None: + """Writes into the session's slot and arms it, both inside this call. + + Arming from the caller would leave a pseudoconsole nobody may close if anything came + between the two statements. + """ size = COORD(ctypes.c_short(columns), ctypes.c_short(rows)) - hresult = kernel32.CreatePseudoConsole(size, in_r, out_w, 0, slot) + hresult = kernel32.CreatePseudoConsole(size, in_r, out_w, 0, ctypes.byref(session.hPC)) if hresult != S_OK: # HRESULT, not BOOL: success is zero and failure is everything else raise _hresult_error("Creating the pseudoconsole", hresult) + session.hPC_valid = True # armed only after S_OK def _initialize_attribute_list(attrs: _AttrList, count: int) -> None: @@ -611,18 +623,18 @@ def _renderer_output_is_redirected() -> bool: return False -def _open_thread_handle(native_id: int) -> int: +def _open_thread_handle(holder: "_Holder", native_id: int) -> None: """THREAD_TERMINATE is what `CancelSynchronousIo()` requires. - The handle is opened while the writer is still parked on its gate: an open handle cannot - be recycled, so every later cancel lands on the writer rather than on whichever thread - inherited its id. + The handle is opened while the thread it names is certainly alive — the writer parked on + its gate, the reader before its first read — because an id is recyclable the instant its + thread exits. It lands in the holder inside this call, so no interruption can lose it. """ handle = kernel32.OpenThread(THREAD_TERMINATE, False, native_id) if not handle: error = ctypes.get_last_error() - raise _win_error("Opening a handle to the terminal input writer", error) - return handle + raise _win_error("Opening a handle to a terminal pump thread", error) + holder.value = HANDLE(handle) def _job_active_processes(job: int) -> int: @@ -836,7 +848,7 @@ def adopt_reader_thread(self) -> None: is certainly alive. """ try: - self.reader_handle.value = HANDLE(_open_thread_handle(native_thread_id())) + _open_thread_handle(self.reader_handle, native_thread_id()) except BaseException as exc: # cancellation degrades to the bounded join alone console.debug(f"the terminal output reader could not open a handle to itself: {exc!r}") @@ -915,16 +927,18 @@ def release_abandoned(self) -> None: 24H2 would stand between a failing teardown and the kill-on-job-close that is the whole backstop. - `inputWriteSide` and the writer's thread handle are leaked deliberately when the - writer never returned: closing a handle underneath a blocked `WriteFile` is the - corruption the bounded teardown exists to avoid. + The writer is stopped here as well as in the ordered teardown, because this path is + reached when the teardown gave up before it got that far: an idle writer is parked on + its queue rather than inside a write, and the stop sentinel is the only thing that + ever releases it. `inputWriteSide` and the writer's thread handle are leaked only when + the stop itself runs out of bound — closing a handle underneath a blocked `WriteFile` + is the corruption the bounded teardown exists to avoid. """ self._terminate_job() self.proc.close_all() self._close_job() # kill-on-job-close fires before anything that can block self._close_pseudoconsole() - writer = self.writer - if writer is None or writer.finished.is_set(): + if self._stop_writer(): self.in_w.close_if_owned() self.writer_handle.close_if_owned() else: @@ -1176,14 +1190,15 @@ def _start_session( self._start_writer(session, deadline) self._check_spawn_interrupted() - session.hJob = _create_job() - _set_kill_on_job_close(session.hJob) + _create_job(session) + job = session.hJob + assert job is not None # _create_job stores one or raises + _set_kill_on_job_close(job) in_read = in_r.handle() out_write = out_w.handle() assert in_read is not None and out_write is not None - _create_pseudoconsole(columns, rows, in_read, out_write, ctypes.byref(session.hPC)) - session.hPC_valid = True # armed only after S_OK + _create_pseudoconsole(session, columns, rows, in_read, out_write) _initialize_attribute_list(attrs, PROC_THREAD_ATTRIBUTE_COUNT) session.job_array[0] = session.hJob @@ -1263,7 +1278,7 @@ def _start_writer(self, session: _SessionBundle, deadline: float) -> None: if writer.failed.is_set() else "The terminal input writer did not report itself before the spawn deadline." ) - session.writer_handle.value = HANDLE(_open_thread_handle(native_id)) + _open_thread_handle(session.writer_handle, native_id) decision = GateDecision.RUN # only after the handle is stored finally: writer.gate.set(decision) # always: ABORT wakes the writer to exit untouched From 774ac633a189b9ebcbf34b92cba3b1c5561bf60d Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 04:40:22 +0200 Subject: [PATCH 42/83] Make the terminal probe report its own failure The probe exited 1 on Windows with nothing to say why, because the assertion that caught it printed neither the transcript nor the report. It now takes no argument, writes its findings beside itself, records a traceback if it raises at all, and every assertion in the case carries both the transcript and that report. The abandoned-session case expects the input handles released now that the writer is stopped there, and the pseudoconsole failure case checks the slot is left unarmed. --- tests/test_conpty.py | 95 ++++++++++++++++++++++++-------------------- 1 file changed, 53 insertions(+), 42 deletions(-) diff --git a/tests/test_conpty.py b/tests/test_conpty.py index 3f291bdc..01ff5507 100644 --- a/tests/test_conpty.py +++ b/tests/test_conpty.py @@ -19,6 +19,7 @@ import threading import time from pathlib import Path +from types import SimpleNamespace import pytest @@ -238,40 +239,48 @@ def backend(): TERMINAL_PROBE = """ import ctypes import os - import sys + import traceback + + + def report(): + # Declared: an undeclared call returns c_int, which truncates a handle and reports a + # false negative for both questions below. + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.GetStdHandle.argtypes = [ctypes.c_uint] + kernel32.GetStdHandle.restype = ctypes.c_void_p + kernel32.GetCurrentProcess.argtypes = [] + kernel32.GetCurrentProcess.restype = ctypes.c_void_p + kernel32.GetConsoleMode.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint)] + kernel32.GetConsoleMode.restype = ctypes.c_int + kernel32.IsProcessInJob.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)] + kernel32.IsProcessInJob.restype = ctypes.c_int + + mode = ctypes.c_uint(0) + console = kernel32.GetConsoleMode(kernel32.GetStdHandle(0xFFFFFFF5), ctypes.byref(mode)) + in_job = ctypes.c_int(0) + kernel32.IsProcessInJob(kernel32.GetCurrentProcess(), None, ctypes.byref(in_job)) + return [ + "ISATTY=%s" % (os.isatty(0) and os.isatty(1) and os.isatty(2)), + "CONSOLE=%s" % bool(console), + "INJOB=%s" % bool(in_job.value), + "TERM=%s" % os.environ.get("TERM"), + "DONE", + ] - # Declared: an undeclared call returns c_int, which truncates a handle and reports a - # false negative for both questions below. - kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) - kernel32.GetStdHandle.argtypes = [ctypes.c_uint] - kernel32.GetStdHandle.restype = ctypes.c_void_p - kernel32.GetCurrentProcess.argtypes = [] - kernel32.GetCurrentProcess.restype = ctypes.c_void_p - kernel32.GetConsoleMode.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint)] - kernel32.GetConsoleMode.restype = ctypes.c_int - kernel32.IsProcessInJob.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)] - kernel32.IsProcessInJob.restype = ctypes.c_int - mode = ctypes.c_uint(0) - console = kernel32.GetConsoleMode(kernel32.GetStdHandle(0xFFFFFFF5), ctypes.byref(mode)) - in_job = ctypes.c_int(0) - kernel32.IsProcessInJob(kernel32.GetCurrentProcess(), None, ctypes.byref(in_job)) - - report = [ - "ISATTY=%s" % (os.isatty(0) and os.isatty(1) and os.isatty(2)), - "CONSOLE=%s" % bool(console), - "INJOB=%s" % bool(in_job.value), - "TERM=%s" % os.environ.get("TERM"), - "DONE", - ] - # Written as well as printed: if the target's standard handles are not the terminal's, - # its own account of them is the only evidence that survives. - with open(sys.argv[1], "w", encoding="utf-8") as handle: - handle.write("\n".join(report)) - - for line in report: + try: + lines = report() + except BaseException: + lines = ["PROBE-FAILED", traceback.format_exc()] + + # Written beside the probe as well as printed: the file survives a target whose standard + # handles are not the terminal's, and it needs no argument that could itself go wrong. + beside_the_probe = os.path.join(os.path.dirname(os.path.abspath(__file__)), "probe.txt") + with open(beside_the_probe, "w", encoding="utf-8") as handle: + handle.write("\n".join(lines)) + + for line in lines: print(line) - sys.stdout.flush() """ @@ -324,12 +333,13 @@ def test_a_failing_pseudoconsole_call_is_reported_as_its_hresult(): Server 2022 accepts handles it will only fail on later. The failure has to be detected as a nonzero HRESULT rather than read from the last error, which these calls do not promise to set.""" - slot = _conpty.HPCON() + session = SimpleNamespace(hPC=_conpty.HPCON(), hPC_valid=False) with pytest.raises(TerminalEnvironmentError) as error: - _conpty._create_pseudoconsole(0, 0, -1, -1, ctypes.byref(slot)) + _conpty._create_pseudoconsole(session, 0, 0, -1, -1) assert "HRESULT" in str(error.value) + assert session.hPC_valid is False # a failed HRESULT output is never closable def test_a_build_without_pseudoconsole_support_is_an_environment_error(monkeypatch): @@ -373,18 +383,19 @@ def test_a_script_runs_on_a_real_console_inside_the_job(backend, tmp_path): script = write_program(tmp_path, "terminal_probe", TERMINAL_PROBE) report_path = tmp_path / "probe.txt" - backend.spawn(command(script, str(report_path))) + backend.spawn(command(script)) exit_code = wait_for_exit(backend) backend.terminate_tree(grace=0.1) backend.close() output = backend.normalized_output() report = report_path.read_text(encoding="utf-8") if report_path.exists() else "(no report was written)" + evidence = f"transcript={output!r}; the target reported:\n{report}" - assert exit_code == 0 - assert "ISATTY=True" in output, f"transcript={output!r}; the target reported:\n{report}" - assert "CONSOLE=True" in output - assert "INJOB=True" in output - assert "TERM=xterm-256color" in output + assert exit_code == 0, evidence + assert "ISATTY=True" in output, evidence + assert "CONSOLE=True" in output, evidence + assert "INJOB=True" in output, evidence + assert "TERM=xterm-256color" in output, evidence def test_the_exit_code_is_reported_verbatim(backend, tmp_path): @@ -867,9 +878,9 @@ def refuse(self): assert backend._owner is not None # ownership retained rather than dropped assert index_of(events, "CloseHandle", job) is not None assert process_is_gone(child) - # The input handles are the documented exception: the writer never returned, so they are - # leaked rather than closed underneath a blocked write. - assert session.in_w.owned and session.writer_handle.owned + # Released rather than leaked: the writer was idle on its queue, and the last-resort + # release stops it through the sentinel before it decides about the input handles. + assert not session.in_w.owned and not session.writer_handle.owned # ------------------------------------------------------------------- marshaling From 22e29f004537fd57bca52443118dae89ef611195 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 04:55:06 +0200 Subject: [PATCH 43/83] Embed the terminal probe so it cannot arrive malformed The probe reached the runner with an indent no longer shared by every line, so dedent removed nothing and the target died on line 2 before it could report anything. The source is provably correct in the blob, so rather than explain the transformation the program now sits at column zero and carries no escape sequence at all: neither dedent nor an escape resolved too early has anything left to act on. write_program compiles what it is about to write, so a malformed program fails the test at the write with its own text and line, and the probe case reports the first lines actually on disk beside the transcript and the literal. --- tests/test_conpty.py | 101 +++++++++++++++++++++++++------------------ 1 file changed, 59 insertions(+), 42 deletions(-) diff --git a/tests/test_conpty.py b/tests/test_conpty.py index 01ff5507..86efae37 100644 --- a/tests/test_conpty.py +++ b/tests/test_conpty.py @@ -69,8 +69,16 @@ def write_program(tmp_path: Path, name: str, source: str) -> str: + """Writes a probe program, and refuses to write one that will not parse. + + A target that dies on a parse error reports a bare non-zero exit code and whatever the + terminal happened to catch, which is the least useful evidence available. Compiling here, + while the text is still in hand, fails the test at the write with the source and the line. + """ path = tmp_path / f"{name}.py" - path.write_text(textwrap.dedent(source), encoding="utf-8") + program = textwrap.dedent(source) + compile(program, str(path), "exec") + path.write_text(program, encoding="utf-8") return str(path) @@ -236,51 +244,56 @@ def backend(): # The probe reports what a script sees, one short line at a time: the pseudoconsole wraps at # the configured width, so a single long line would come back folded. +# Written at column zero and without a single escape sequence: the target parses this file +# on its own, and the two ways a program embedded in a test can arrive malformed — an indent +# no longer shared by every line, and an escape the test source resolves too early — are both +# absent by construction rather than by review. TERMINAL_PROBE = """ - import ctypes - import os - import traceback - - - def report(): - # Declared: an undeclared call returns c_int, which truncates a handle and reports a - # false negative for both questions below. - kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) - kernel32.GetStdHandle.argtypes = [ctypes.c_uint] - kernel32.GetStdHandle.restype = ctypes.c_void_p - kernel32.GetCurrentProcess.argtypes = [] - kernel32.GetCurrentProcess.restype = ctypes.c_void_p - kernel32.GetConsoleMode.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint)] - kernel32.GetConsoleMode.restype = ctypes.c_int - kernel32.IsProcessInJob.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)] - kernel32.IsProcessInJob.restype = ctypes.c_int - - mode = ctypes.c_uint(0) - console = kernel32.GetConsoleMode(kernel32.GetStdHandle(0xFFFFFFF5), ctypes.byref(mode)) - in_job = ctypes.c_int(0) - kernel32.IsProcessInJob(kernel32.GetCurrentProcess(), None, ctypes.byref(in_job)) - return [ - "ISATTY=%s" % (os.isatty(0) and os.isatty(1) and os.isatty(2)), - "CONSOLE=%s" % bool(console), - "INJOB=%s" % bool(in_job.value), - "TERM=%s" % os.environ.get("TERM"), - "DONE", - ] +import ctypes +import os +import traceback - try: - lines = report() - except BaseException: - lines = ["PROBE-FAILED", traceback.format_exc()] - - # Written beside the probe as well as printed: the file survives a target whose standard - # handles are not the terminal's, and it needs no argument that could itself go wrong. - beside_the_probe = os.path.join(os.path.dirname(os.path.abspath(__file__)), "probe.txt") - with open(beside_the_probe, "w", encoding="utf-8") as handle: - handle.write("\n".join(lines)) +def report(): + # Declared: an undeclared call returns c_int, which truncates a handle and reports a + # false negative for both questions below. + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.GetStdHandle.argtypes = [ctypes.c_uint] + kernel32.GetStdHandle.restype = ctypes.c_void_p + kernel32.GetCurrentProcess.argtypes = [] + kernel32.GetCurrentProcess.restype = ctypes.c_void_p + kernel32.GetConsoleMode.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint)] + kernel32.GetConsoleMode.restype = ctypes.c_int + kernel32.IsProcessInJob.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)] + kernel32.IsProcessInJob.restype = ctypes.c_int + mode = ctypes.c_uint(0) + console = kernel32.GetConsoleMode(kernel32.GetStdHandle(0xFFFFFFF5), ctypes.byref(mode)) + in_job = ctypes.c_int(0) + kernel32.IsProcessInJob(kernel32.GetCurrentProcess(), None, ctypes.byref(in_job)) + return [ + "ISATTY=%s" % (os.isatty(0) and os.isatty(1) and os.isatty(2)), + "CONSOLE=%s" % bool(console), + "INJOB=%s" % bool(in_job.value), + "TERM=%s" % os.environ.get("TERM"), + "DONE", + ] + + +try: + lines = report() +except BaseException: + lines = ["PROBE-FAILED"] + traceback.format_exc().splitlines() + +# Written beside the probe as well as printed: the file survives a target whose standard +# handles are not the terminal's, and it needs no argument that could itself go wrong. +beside_the_probe = os.path.join(os.path.dirname(os.path.abspath(__file__)), "probe.txt") +with open(beside_the_probe, "w", encoding="utf-8") as handle: for line in lines: - print(line) + print(line, file=handle) + +for line in lines: + print(line) """ @@ -389,7 +402,11 @@ def test_a_script_runs_on_a_real_console_inside_the_job(backend, tmp_path): backend.close() output = backend.normalized_output() report = report_path.read_text(encoding="utf-8") if report_path.exists() else "(no report was written)" - evidence = f"transcript={output!r}; the target reported:\n{report}" + written = "".join(Path(script).read_text(encoding="utf-8").splitlines(keepends=True)[:5]) + evidence = ( + f"transcript={output!r}\nthe target reported:\n{report}\n" + f"first lines written={written!r}\nliteral={TERMINAL_PROBE[:80]!r}" + ) assert exit_code == 0, evidence assert "ISATTY=True" in output, evidence From c874ead6a4e552ec5024309f0ad32c190894ef8e Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 10:13:14 +0200 Subject: [PATCH 44/83] Share the backend surface the three terminals implemented separately Output accumulation, the reply-failure reporting, the cancellation check and the owner constants were byte-identical in each backend; they now live on TerminalProcess and in terminal_process/terminal_queries, and the backends keep only their read loops. The terminal child environment and the reply-resolution mapping become one shared function each. --- render_machine/_conpty.py | 83 ++++------------------- render_machine/_conpty_support.py | 19 +----- render_machine/_legacy_pipe.py | 50 +------------- render_machine/_posix_pty.py | 102 ++++------------------------- render_machine/terminal_process.py | 91 ++++++++++++++++++++++--- render_machine/terminal_queries.py | 19 ++++++ tests/test_conpty_support.py | 25 ------- tests/test_legacy_pipe.py | 48 +++----------- tests/test_terminal_queries.py | 36 +++++++++- 9 files changed, 170 insertions(+), 303 deletions(-) diff --git a/render_machine/_conpty.py b/render_machine/_conpty.py index d7dfdbae..14cd3ce6 100644 --- a/render_machine/_conpty.py +++ b/render_machine/_conpty.py @@ -34,10 +34,9 @@ import time from contextlib import ExitStack from ctypes import wintypes -from typing import Callable, List, Optional, Sequence, Tuple +from typing import Callable, Optional, Sequence, Tuple from plain2code_console import console -from plain2code_exceptions import RenderCancelledError from render_machine._conpty_support import ( CANCEL_TICK_SECONDS, WRITER_JOIN_DEADLINE_SECONDS, @@ -49,16 +48,16 @@ build_command_line, build_environment_block, native_thread_id, - reply_resolution, validate_working_directory, ) from render_machine.output_normalizer import OutputNormalizer from render_machine.terminal_process import ( CONTROL_DELIVERY_DEADLINE_SECONDS, - DEFAULT_TERM, DRAIN_DEADLINE_SECONDS, GRACE_TICK_SECONDS, HANDSHAKE_TIMEOUT_SECONDS, + OWNER_PARENT, + OWNER_READER, POLL_INTERVAL_SECONDS, READ_CHUNK_BYTES, REAP_DEADLINE_SECONDS, @@ -68,9 +67,9 @@ InputWriteResult, TerminalEnvironmentError, TerminalProcess, - child_environment, + terminal_child_environment, ) -from render_machine.terminal_queries import TerminalQueryResponder +from render_machine.terminal_queries import TerminalQueryResponder, reply_resolution if sys.platform != "win32": # pragma: no cover - the ConPTY backend is Windows-only raise ImportError("render_machine._conpty is Windows-only") @@ -140,9 +139,6 @@ FINALIZER_DEADLINE_SECONDS = 60.0 FINALIZER_TICK_SECONDS = 0.5 -_OWNER_PARENT = "parent" -_OWNER_READER = "reader" - # The graceful signal: writing 0x03 into the pseudoconsole input is how terminal emulators # deliver Ctrl-C to a ConPTY client. `GenerateConsoleCtrlEvent` cannot be used, because it # reaches only processes sharing the caller's console and the target is on the pseudoconsole. @@ -452,7 +448,7 @@ class _ReaderHandles: """ def __init__(self, pair: _PipePair) -> None: - self.owner = _OWNER_PARENT + self.owner = OWNER_PARENT self.out_r = HANDLE() self._pair = pair self._lock = threading.Lock() @@ -470,7 +466,7 @@ def take(self) -> Optional[int]: return handle def close_if_owner_is_parent(self) -> None: - if self.owner == _OWNER_PARENT: + if self.owner == OWNER_PARENT: _close_handle(self.take()) @@ -1025,26 +1021,18 @@ class ConPtyProcess(TerminalProcess): """One command, one pseudoconsole, one job, one reader thread, one writer thread.""" def __init__(self) -> None: - self.reader_failed = threading.Event() - self.reader_exc: Optional[BaseException] = None + super().__init__() self._spawned = False self._closed = False - self._stop_event = threading.Event() - self._input_driver: Optional[object] = None self._owner: Optional[_SessionOwner] = None self._writer: Optional[InputWriter] = None self._input_queue = InputQueue() - self._output_lock = threading.Lock() - self._decoded: List[str] = [] - self._raw = bytearray() - # The parser runs live in the reader, because terminals answer queries: a # render-afterwards parser would leave a querying target hanging. self.query_responder = TerminalQueryResponder(self._admit_reply) self.normalizer = OutputNormalizer(reply_handler=self.query_responder.answer) - self._byte_sink: Callable[[bytes], None] = self.normalizer.feed # ---------------------------------------------------------------- public API @@ -1071,7 +1059,7 @@ def spawn( # allocated, and long before there is a process to truncate a command line for. command_line = build_command_line(command) directory = validate_working_directory(cwd) - environment = build_environment_block(self._child_env(env)) + environment = build_environment_block(terminal_child_environment(env)) self._start_session(command_line, directory, environment, columns, rows, time.monotonic() + spawn_timeout) def poll(self) -> Optional[int]: @@ -1084,28 +1072,6 @@ def poll(self) -> Optional[int]: self.query_responder.quiesce() return code - def read_output(self) -> str: - with self._output_lock: - text = "".join(self._decoded) - self._decoded.clear() - return text - - def read_raw_output(self) -> bytes: - with self._output_lock: - data = bytes(self._raw) - self._raw.clear() - return data - - def normalized_output(self) -> str: - return self.normalizer.text() - - @property - def terminal_reply_failed(self) -> bool: - return self.query_responder.reply_failed - - def terminal_reply_detail(self) -> str: - return self.query_responder.failure_detail() - def write_input(self, data: bytes) -> InputWriteResult: result, _ = self._input_queue.submit(data) return result @@ -1257,7 +1223,7 @@ def _start_reader(self, session: _SessionBundle, bundle: _ReaderHandles, gate: t try: session.reader = reader reader.start() - bundle.owner = _OWNER_READER # the commit: one assignment, nothing after it + bundle.owner = OWNER_READER # the commit: one assignment, nothing after it finally: gate.set() # always: an unopened gate parks the thread forever @@ -1285,7 +1251,7 @@ def _start_writer(self, session: _SessionBundle, deadline: float) -> None: def _reader_main(self, session: _SessionBundle, bundle: _ReaderHandles, gate: threading.Event) -> None: gate.wait() - if bundle.owner != _OWNER_READER: + if bundle.owner != OWNER_READER: return # the parent still owns everything; touch nothing, publish nothing session.adopt_reader_thread() # before the first read, so teardown can always cancel it reader_exc: Optional[BaseException] = None @@ -1326,20 +1292,6 @@ def _reader_loop(self, handle: Optional[int], decoder) -> None: return self._feed_output(buffer.raw[: read.value], decoder) - def _feed_output(self, chunk: bytes, decoder) -> None: - text = decoder.decode(chunk) - with self._output_lock: - self._raw += chunk - if text: - self._decoded.append(text) - self._byte_sink(chunk) # outside the output lock: parsing must not block read_output() - - def _flush_decoder(self, decoder) -> None: - tail = decoder.decode(b"", final=True) # a trailing partial sequence becomes U+FFFD - if tail: - with self._output_lock: - self._decoded.append(tail) - # ------------------------------------------------------------------ internals def _admit_reply(self, payload: bytes, on_complete: Callable[[Optional[str]], None]) -> None: @@ -1353,15 +1305,6 @@ def _admit_reply(self, payload: bytes, on_complete: Callable[[Optional[str]], No """ self._input_queue.submit(payload, on_resolve=reply_resolution(on_complete)) - def _child_env(self, env: Optional[dict]) -> dict: - child_env = child_environment(env) - term = child_env.get("TERM") - child_env["TERM"] = term if term else DEFAULT_TERM - # git reads the console directly, so no redirection can reach a credential prompt; - # failing is the only bounded outcome. - child_env["GIT_TERMINAL_PROMPT"] = "0" - return child_env - def _shutdown(self, grace: Optional[float]) -> None: owner = self._owner if owner is None: @@ -1388,10 +1331,6 @@ def _transfer_to_finalizer(self, owner: _SessionOwner) -> bool: owner.stack.close() return False - def _check_cancelled(self) -> None: - if self._stop_event.is_set(): - raise RenderCancelledError() - def _check_pumps(self) -> None: detail = self.infrastructure_failure() if detail is not None: diff --git a/render_machine/_conpty_support.py b/render_machine/_conpty_support.py index 9b9b8caf..1f53837f 100644 --- a/render_machine/_conpty_support.py +++ b/render_machine/_conpty_support.py @@ -31,7 +31,7 @@ InputWriteResult, TerminalEnvironmentError, ) -from render_machine.terminal_queries import REASON_DISCARDED, REASON_WRITE_FAILED +from render_machine.terminal_queries import ResolveCallback NUL = "\x00" @@ -51,9 +51,6 @@ # loss is logged as a sample plus a count rather than once per item. DROP_LOG_INTERVAL_SECONDS = 5.0 -# Resolution of one queued item, as the producer sees it. -ResolveCallback = Callable[[InputDisposition, Optional[BaseException]], None] - # --------------------------------------------------------------------- marshaling @@ -121,20 +118,6 @@ def native_thread_id() -> int: return threading.get_native_id() -def reply_resolution(on_complete: Callable[[Optional[str]], None]) -> ResolveCallback: - """Maps one queue resolution onto the responder's delivered / not-delivered contract.""" - - def resolved(disposition: InputDisposition, error: Optional[BaseException]) -> None: - if error is not None: - on_complete(f"{REASON_WRITE_FAILED}: {error!r}") - elif disposition is InputDisposition.ACCEPTED: - on_complete(None) - else: - on_complete(f"{REASON_DISCARDED} ({disposition.value})") - - return resolved - - # ------------------------------------------------------------------- input queue diff --git a/render_machine/_legacy_pipe.py b/render_machine/_legacy_pipe.py index d3b9c913..a774154e 100644 --- a/render_machine/_legacy_pipe.py +++ b/render_machine/_legacy_pipe.py @@ -28,7 +28,7 @@ import subprocess import sys import threading -from typing import List, Optional, Sequence, Tuple +from typing import Optional, Sequence, Tuple from plain2code_console import console from plain2code_exceptions import RenderCancelledError @@ -71,8 +71,7 @@ class LegacyPipeProcess(TerminalProcess): """One command, one pipe carrying its merged stdout and stderr, one reader thread.""" def __init__(self) -> None: - self.reader_failed = threading.Event() - self.reader_exc: Optional[BaseException] = None + super().__init__() # No admission callable: the responder starts QUIESCED, so an escape sequence the # target prints is rendered and nothing is ever owed to it. self.query_responder = TerminalQueryResponder() @@ -87,10 +86,6 @@ def __init__(self) -> None: self._closing = threading.Event() self._reaped = False - self._output_lock = threading.Lock() - self._decoded: List[str] = [] - self._raw = bytearray() - # ---------------------------------------------------------------- public API def spawn( @@ -118,7 +113,7 @@ def spawn( stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=cwd, - env=self._child_env(env), + env=child_environment(env), start_new_session=(sys.platform != "win32"), creationflags=CREATION_FLAGS, ) @@ -138,28 +133,6 @@ def poll(self) -> Optional[int]: self._reaped = True return returncode - def read_output(self) -> str: - with self._output_lock: - text = "".join(self._decoded) - self._decoded.clear() - return text - - def read_raw_output(self) -> bytes: - with self._output_lock: - data = bytes(self._raw) - self._raw.clear() - return data - - def normalized_output(self) -> str: - return self.normalizer.text() - - @property - def terminal_reply_failed(self) -> bool: - return self.query_responder.reply_failed - - def terminal_reply_detail(self) -> str: - return self.query_responder.failure_detail() - def write_input(self, data: bytes) -> InputWriteResult: """Always closed: this backend hands the child `DEVNULL`, by design.""" return InputWriteResult(InputDisposition.CLOSED, 0) @@ -201,9 +174,6 @@ def close(self) -> None: # -------------------------------------------------------------------- internals - def _child_env(self, env: Optional[dict]) -> dict: - return child_environment(env) - def _widen_pipe(self) -> None: """Best-effort 1MB pipe buffer, so bursts of output need fewer reader wakeups.""" if sys.platform == "linux": @@ -264,17 +234,3 @@ def _reader_main(self) -> None: self.reader_exc = reader_exc # stored while still unobservable if reader_exc is not None: self.reader_failed.set() - - def _feed_output(self, chunk: bytes, decoder) -> None: - text = decoder.decode(chunk) - with self._output_lock: - self._raw += chunk - if text: - self._decoded.append(text) - self.normalizer.feed(chunk) # outside the lock: parsing must not block read_output() - - def _flush_decoder(self, decoder) -> None: - tail = decoder.decode(b"", final=True) # a trailing partial sequence becomes U+FFFD - if tail: - with self._output_lock: - self._decoded.append(tail) diff --git a/render_machine/_posix_pty.py b/render_machine/_posix_pty.py index 7ddd24a5..064e4ecf 100644 --- a/render_machine/_posix_pty.py +++ b/render_machine/_posix_pty.py @@ -26,11 +26,9 @@ from typing import Callable, Deque, List, Optional, Sequence, Tuple from plain2code_console import console -from plain2code_exceptions import RenderCancelledError from render_machine import pty_exec from render_machine.output_normalizer import OutputNormalizer from render_machine.terminal_process import ( - DEFAULT_TERM, DRAIN_DEADLINE_SECONDS, DRAIN_MAX_BYTES, DRAIN_QUIET_PERIOD_SECONDS, @@ -41,6 +39,8 @@ MAX_INPUT_ITEM_BYTES, MAX_PENDING_INPUT_BYTES, MAX_PENDING_INPUT_ITEMS, + OWNER_PARENT, + OWNER_READER, POLL_INTERVAL_SECONDS, READ_CHUNK_BYTES, REAP_DEADLINE_SECONDS, @@ -55,9 +55,9 @@ TerminalLaunchError, TerminalProcess, TerminalReaderError, - child_environment, + terminal_child_environment, ) -from render_machine.terminal_queries import REASON_DISCARDED, REASON_WRITE_FAILED, TerminalQueryResponder +from render_machine.terminal_queries import ResolveCallback, TerminalQueryResponder, reply_resolution if sys.platform == "win32": # pragma: no cover - the PTY backend is POSIX-only raise ImportError("render_machine._posix_pty is POSIX-only") @@ -68,12 +68,6 @@ # forks, so termination is immediate and the full grace would only slow failures down. ROLLBACK_GRACE_SECONDS = 0.1 -_OWNER_PARENT = "parent" -_OWNER_READER = "reader" - -# Completion callback for one queued input item, resolved by whoever retires it. -ResolveCallback = Callable[[InputDisposition, Optional[BaseException]], None] - class _ProtocolError(Exception): """The launcher's status stream did not follow the handshake protocol.""" @@ -325,7 +319,7 @@ class _ReaderBundle: """ def __init__(self, master_fd: int, wakeup_r: int, err_w: int) -> None: - self.owner = _OWNER_PARENT + self.owner = OWNER_PARENT self.master_fd: Optional[int] = master_fd self.wakeup_r: Optional[int] = wakeup_r self.err_w: Optional[int] = err_w @@ -446,8 +440,7 @@ class PosixPtyProcess(TerminalProcess): """One command, one pseudoterminal, one reader thread.""" def __init__(self) -> None: - self.reader_failed = threading.Event() - self.reader_exc: Optional[BaseException] = None + super().__init__() self._proc: Optional[subprocess.Popen] = None self._pgid: Optional[int] = None @@ -456,7 +449,6 @@ def __init__(self) -> None: self._closed = False self._acked = False self._input_driver: Optional[object] = None - self._stop_event = threading.Event() self._bundle: Optional[_ReaderBundle] = None self._reader: Optional[threading.Thread] = None @@ -476,16 +468,12 @@ def __init__(self) -> None: self._status_r: Optional[int] = None self._ack_w: Optional[int] = None - self._output_lock = threading.Lock() - self._decoded: List[str] = [] - self._raw = bytearray() self.launcher_stderr = _CappedDiagnostic() - # The parser runs live in the reader through this byte-feed hook, because terminals - # answer queries: a render-afterwards parser would leave a querying target hanging. + # The parser runs live in the reader, because terminals answer queries: a + # render-afterwards parser would leave a querying target hanging. self.query_responder = TerminalQueryResponder(self._admit_reply) self.normalizer = OutputNormalizer(reply_handler=self.query_responder.answer) - self._byte_sink: Callable[[bytes], None] = self.normalizer.feed # ---------------------------------------------------------------- public API @@ -536,35 +524,12 @@ def poll(self) -> Optional[int]: self.query_responder.quiesce() return returncode - def read_output(self) -> str: - with self._output_lock: - text = "".join(self._decoded) - self._decoded.clear() - return text - - def read_raw_output(self) -> bytes: - with self._output_lock: - data = bytes(self._raw) - self._raw.clear() - return data - def write_input(self, data: bytes) -> InputWriteResult: result, _ = self._input_queue.submit(data) if result.disposition is InputDisposition.ACCEPTED: self._ring_doorbell() return result - def normalized_output(self) -> str: - """The rendered transcript so far. Cumulative, unlike `read_output()`.""" - return self.normalizer.text() - - @property - def terminal_reply_failed(self) -> bool: - return self.query_responder.reply_failed - - def terminal_reply_detail(self) -> str: - return self.query_responder.failure_detail() - def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: """Signals the recorded group, escalates on the clock, and reaps last. @@ -609,7 +574,7 @@ def close(self) -> None: self._close_owned("_ack_w") if self._proc is not None and self._proc.stderr is not None: self._proc.stderr.close() - if self._bundle is not None and self._bundle.owner == _OWNER_PARENT: + if self._bundle is not None and self._bundle.owner == OWNER_PARENT: self._bundle.close_all() # no reader ever took them if stalled: # every handle this side owns is released first self._publish_reader_stall() @@ -709,7 +674,7 @@ def _start_child(self, command: Sequence[str], cwd: Optional[str], env: Optional pass_fds=(slave_fd, status_w, ack_r), close_fds=True, cwd=cwd, - env=self._child_env(env), + env=terminal_child_environment(env), ) except OSError as exc: raise TerminalEnvironmentError(f"Could not start the terminal launcher: {exc}") from exc @@ -721,21 +686,12 @@ def _start_child(self, command: Sequence[str], cwd: Optional[str], env: Optional self._child_fds = () self._pending_slave_fd = None - def _child_env(self, env: Optional[dict]) -> dict: - child_env = child_environment(env) - term = child_env.get("TERM") - child_env["TERM"] = term if term else DEFAULT_TERM - # git reads /dev/tty directly, so neither the VEOF nor a redirected stdin can - # reach a credential prompt; failing is the only bounded outcome. - child_env["GIT_TERMINAL_PROMPT"] = "0" - return child_env - def _hand_over_to_reader(self) -> None: """Starts the gated reader and commits ownership in a single field assignment.""" assert self._bundle is not None and self._reader is not None try: self._reader.start() - self._bundle.owner = _OWNER_READER + self._bundle.owner = OWNER_READER finally: self._gate.set() # an unreleased gate is unrecoverable, so this is never conditional self._check_reader_failed() @@ -858,10 +814,6 @@ def _launch_message(self, reason: str) -> str: return f"{reason}. Launcher output:\n{diagnostic}" return f"{reason}." - def _check_cancelled(self) -> None: - if self._stop_event.is_set(): - raise RenderCancelledError() - def _check_reader_failed(self) -> None: if self.reader_failed.is_set(): raise TerminalReaderError(f"The terminal output reader failed: {self.reader_exc!r}") @@ -871,7 +823,7 @@ def _check_reader_failed(self) -> None: def _reader_main(self) -> None: self._gate.wait() assert self._bundle is not None - if self._bundle.owner != _OWNER_READER: + if self._bundle.owner != OWNER_READER: return # the parent still owns everything; touch nothing, publish nothing reader_exc: Optional[BaseException] = None decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") @@ -938,20 +890,6 @@ def _read_once(self, master_fd: int, decoder) -> bool: self._feed_output(chunk, decoder) return True - def _feed_output(self, chunk: bytes, decoder) -> None: - text = decoder.decode(chunk) - with self._output_lock: - self._raw += chunk - if text: - self._decoded.append(text) - self._byte_sink(chunk) # outside the output lock: parsing must not block read_output() - - def _flush_decoder(self, decoder) -> None: - tail = decoder.decode(b"", final=True) # a trailing partial sequence becomes U+FFFD - if tail: - with self._output_lock: - self._decoded.append(tail) - def _flush_input(self, master_fd: int, budget: int) -> None: """Services the FIFO through one retained cursor, bounded so input cannot starve output.""" written = 0 @@ -1024,7 +962,7 @@ def _admit_reply(self, payload: bytes, on_complete: Callable[[Optional[str]], No They are never counted as caller input and never affect the input-driver diagnostic. The queue's cursor preserves the reply across short writes. """ - result, _ = self._input_queue.submit(payload, reserved=True, on_resolve=_reply_resolution(on_complete)) + result, _ = self._input_queue.submit(payload, reserved=True, on_resolve=reply_resolution(on_complete)) if result.disposition is InputDisposition.ACCEPTED: self._ring_doorbell() @@ -1094,20 +1032,6 @@ def _close_owned(self, name: str) -> None: _close_quietly(self._take_owned(name)) -def _reply_resolution(on_complete: Callable[[Optional[str]], None]) -> ResolveCallback: - """Maps one queue resolution onto the responder's delivered / not-delivered contract.""" - - def resolved(disposition: InputDisposition, error: Optional[BaseException]) -> None: - if error is not None: - on_complete(f"{REASON_WRITE_FAILED}: {error!r}") - elif disposition is InputDisposition.ACCEPTED: - on_complete(None) - else: - on_complete(f"{REASON_DISCARDED} ({disposition.value})") - - return resolved - - def _drain_doorbell(fd: int) -> None: while True: try: diff --git a/render_machine/terminal_process.py b/render_machine/terminal_process.py index 737d7a1e..a804216c 100644 --- a/render_machine/terminal_process.py +++ b/render_machine/terminal_process.py @@ -11,10 +11,14 @@ import threading from dataclasses import dataclass from enum import Enum -from typing import Optional, Sequence, Tuple +from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple from plain2code_console import console -from render_machine.terminal_queries import TerminalQueryResponder +from plain2code_exceptions import RenderCancelledError + +if TYPE_CHECKING: # both import this module at runtime, so neither may be imported here + from render_machine.output_normalizer import OutputNormalizer + from render_machine.terminal_queries import TerminalQueryResponder # Break-glass override, not a tuning knob: set CODEPLAIN_NO_PTY=1 to run scripts on the # legacy pipe backend when PTY allocation fails in an environment. It is never selected @@ -73,6 +77,19 @@ # be trusted and the execution is an environment failure. READER_STALL_DETAIL = "the terminal output reader did not terminate within its shutdown bound" +# The two owners a descriptor bundle can have. One field carrying one of these is what +# keeps a rollback and a reader from ever disagreeing about who releases what. +OWNER_PARENT = "parent" +OWNER_READER = "reader" + +# What a timeout diagnostic says when no input driver was attached. A backend that gives +# the target end-of-file at spawn needs nothing more; ConPTY, which cannot, appends its own +# clause to this one. +NO_INPUT_NOTE = ( + " No input driver was attached to the script's terminal, so a script that waits for input " + "never receives any and runs to the timeout." +) + class InputDisposition(Enum): """Immediate whole-item backend admission — never a delivery receipt.""" @@ -111,11 +128,24 @@ class TerminalProcess: `spawn()` is bounded and cancellable; `close()` is idempotent and releases every handle the backend owns. Instances are single-use. + + Output accumulation is identical on every backend — one lock over a decoded list and a + raw buffer, fed by whatever read loop the backend runs — so it is implemented here + rather than three times over. A backend supplies its read loop, its normalizer and its + query responder, calls `super().__init__()` before either, and inherits the rest. """ - reader_failed: threading.Event - reader_exc: Optional[BaseException] - query_responder: TerminalQueryResponder + normalizer: "OutputNormalizer" + query_responder: "TerminalQueryResponder" + + def __init__(self) -> None: + self.reader_failed = threading.Event() + self.reader_exc: Optional[BaseException] = None + self._stop_event = threading.Event() + + self._output_lock = threading.Lock() + self._decoded: List[str] = [] + self._raw = bytearray() def spawn( self, @@ -134,15 +164,21 @@ def poll(self) -> Optional[int]: def read_output(self) -> str: """Decoded output accumulated since the previous call.""" - raise NotImplementedError + with self._output_lock: + text = "".join(self._decoded) + self._decoded.clear() + return text def read_raw_output(self) -> bytes: """Raw output bytes accumulated since the previous call.""" - raise NotImplementedError + with self._output_lock: + data = bytes(self._raw) + self._raw.clear() + return data def normalized_output(self) -> str: """The rendered transcript so far. Cumulative, unlike `read_output()`.""" - raise NotImplementedError + return self.normalizer.text() @property def terminal_reply_failed(self) -> bool: @@ -151,11 +187,30 @@ def terminal_reply_failed(self) -> bool: Independent of `reader_failed`: both pumps can be healthy while one required protocol response was never accepted. """ - raise NotImplementedError + return self.query_responder.reply_failed def terminal_reply_detail(self) -> str: """Query kinds and pressure reasons behind `terminal_reply_failed`.""" - raise NotImplementedError + return self.query_responder.failure_detail() + + def _feed_output(self, chunk: bytes, decoder) -> None: + """The one entry point every read loop hands its bytes to.""" + text = decoder.decode(chunk) + with self._output_lock: + self._raw += chunk + if text: + self._decoded.append(text) + self.normalizer.feed(chunk) # outside the output lock: parsing must not block read_output() + + def _flush_decoder(self, decoder) -> None: + tail = decoder.decode(b"", final=True) # a trailing partial sequence becomes U+FFFD + if tail: + with self._output_lock: + self._decoded.append(tail) + + def _check_cancelled(self) -> None: + if self._stop_event.is_set(): + raise RenderCancelledError() def write_input(self, data: bytes) -> InputWriteResult: raise NotImplementedError @@ -218,6 +273,22 @@ def child_environment(env: Optional[dict]) -> dict: return child_env +def terminal_child_environment(env: Optional[dict]) -> dict: + """`child_environment()` plus the policy a target with a terminal of its own runs under. + + TERM is declared rather than inherited, so a toolchain's rendering does not depend on + the terminal Codeplain happens to be running in. GIT_TERMINAL_PROMPT is cleared because + git reads the terminal directly — /dev/tty on POSIX, the console on Windows — so neither + a synthetic end-of-file nor a redirected stdin can reach a credential prompt, and failing + is the only bounded outcome. + """ + child_env = child_environment(env) + term = child_env.get("TERM") + child_env["TERM"] = term if term else DEFAULT_TERM + child_env["GIT_TERMINAL_PROMPT"] = "0" + return child_env + + def create_terminal_process() -> TerminalProcess: """The one construction site: returns the backend this execution runs on.""" if pty_disabled_by_environment(): diff --git a/render_machine/terminal_queries.py b/render_machine/terminal_queries.py index b2d735a1..907da6db 100644 --- a/render_machine/terminal_queries.py +++ b/render_machine/terminal_queries.py @@ -36,6 +36,8 @@ from enum import Enum from typing import Callable, List, Optional, Set, Tuple +from render_machine.terminal_process import InputDisposition + # Reasons a reply can fail to reach the target. REASON_ADMISSION_RAISED = "admission raised" REASON_DISCARDED = "discarded before delivery" @@ -50,6 +52,23 @@ CompletionCallback = Callable[[Optional[str]], None] AdmitReply = Callable[[bytes, CompletionCallback], None] +# Resolution of one queued input item, as the backend's queue reports it. +ResolveCallback = Callable[[InputDisposition, Optional[BaseException]], None] + + +def reply_resolution(on_complete: CompletionCallback) -> ResolveCallback: + """Maps one queue resolution onto the responder's delivered / not-delivered contract.""" + + def resolved(disposition: InputDisposition, error: Optional[BaseException]) -> None: + if error is not None: + on_complete(f"{REASON_WRITE_FAILED}: {error!r}") + elif disposition is InputDisposition.ACCEPTED: + on_complete(None) + else: + on_complete(f"{REASON_DISCARDED} ({disposition.value})") + + return resolved + class ResponderState(Enum): ACTIVE = "active" diff --git a/tests/test_conpty_support.py b/tests/test_conpty_support.py index 449db17c..5464dbbf 100644 --- a/tests/test_conpty_support.py +++ b/tests/test_conpty_support.py @@ -25,7 +25,6 @@ WriteChannel, build_command_line, build_environment_block, - reply_resolution, validate_working_directory, ) from render_machine.terminal_process import InputDisposition, TerminalEnvironmentError @@ -216,30 +215,6 @@ def test_an_empty_environment_block_still_terminates(): assert build_environment_block({}) == NUL -# ------------------------------------------------------------- reply resolution - - -def test_a_delivered_reply_reports_no_reason(): - reasons = [] - reply_resolution(reasons.append)(InputDisposition.ACCEPTED, None) - - assert reasons == [None] - - -def test_a_failed_reply_reports_the_write_failure(): - reasons = [] - reply_resolution(reasons.append)(InputDisposition.ACCEPTED, OSError("gone")) - - assert "write failed" in reasons[0] - - -def test_a_discarded_reply_reports_the_disposition(): - reasons = [] - reply_resolution(reasons.append)(InputDisposition.CLOSED, None) - - assert "discarded" in reasons[0] and "closed" in reasons[0] - - # ------------------------------------------------------------------- the queue diff --git a/tests/test_legacy_pipe.py b/tests/test_legacy_pipe.py index 646c83fe..3ec9dd0f 100644 --- a/tests/test_legacy_pipe.py +++ b/tests/test_legacy_pipe.py @@ -8,7 +8,6 @@ Scripts are executed for real, so every case that runs one is POSIX-only. """ -import contextlib import errno import json import os @@ -24,6 +23,7 @@ from render_machine.terminal_process import READER_STALL_DETAIL, InputDisposition, TerminalReaderError from render_machine.terminal_queries import ResponderState +from tests import test_render_utils as characterization posix_only = pytest.mark.skipif( sys.platform == "win32", @@ -280,45 +280,13 @@ def stalling_feed(chunk, decoder): # The escape hatch and the Windows interim both run on this backend, so it has to keep # the child away from Codeplain's own terminal exactly as the PTY path does. -KEYSTROKES = "secret-keystrokes\n" -STDIN_READ_LIMIT = 1024 -IMMEDIATE_EOF_SECONDS = 5 - -STDIN_PROBE_PROGRAM = f""" -import json -import os -import sys -import time - -started = time.monotonic() -data = os.read(0, {STDIN_READ_LIMIT}) -report = {{ - "isatty": os.isatty(0), - "data": data.decode(errors="replace"), - "read_seconds": time.monotonic() - started, -}} -sys.stdout.write(json.dumps(report)) -sys.stdout.flush() -""" - - -@pytest.fixture -def terminal_on_stdin(): - """Puts a PTY slave on the test process's fd 0 and yields the master fd.""" - try: - saved_stdin_fd = os.dup(0) - except OSError as exc: - pytest.skip(f"fd 0 cannot be duplicated in this environment: {exc}") - - master_fd, slave_fd = os.openpty() - os.dup2(slave_fd, 0) - try: - yield master_fd - finally: - os.dup2(saved_stdin_fd, 0) - for fd in (saved_stdin_fd, slave_fd, master_fd): - with contextlib.suppress(OSError): - os.close(fd) +# The probe, its constants and the harness terminal are the ones the PTY backend is held +# to, imported rather than restated so the two backends are measured by one yardstick. +KEYSTROKES = characterization.KEYSTROKES +STDIN_READ_LIMIT = characterization.STDIN_READ_LIMIT +IMMEDIATE_EOF_SECONDS = characterization.IMMEDIATE_EOF_SECONDS +STDIN_PROBE_PROGRAM = characterization.STDIN_PROBE_PROGRAM +terminal_on_stdin = characterization.terminal_on_stdin def test_the_child_never_reads_the_renderers_terminal(tmp_path, backend, terminal_on_stdin): diff --git a/tests/test_terminal_queries.py b/tests/test_terminal_queries.py index 4f54cd36..3ca4aa90 100644 --- a/tests/test_terminal_queries.py +++ b/tests/test_terminal_queries.py @@ -14,7 +14,12 @@ from render_machine.output_normalizer import QUERY_CURSOR_POSITION, QUERY_DEVICE_ATTRIBUTES, QUERY_DEVICE_STATUS from render_machine.terminal_process import InputDisposition, InputWriteResult -from render_machine.terminal_queries import MAX_TRACKED_FAILURES, ResponderState, TerminalQueryResponder +from render_machine.terminal_queries import ( + MAX_TRACKED_FAILURES, + ResponderState, + TerminalQueryResponder, + reply_resolution, +) posix_only = pytest.mark.skipif(sys.platform == "win32", reason="The POSIX PTY backend is not built on Windows.") @@ -459,7 +464,7 @@ def test_a_query_seen_only_after_quiescence_renders_and_records_nothing(tmp_path assert process.query_responder.state is ResponderState.QUIESCED admitted_before = process.query_responder.admitted - process._byte_sink(b"\x1b[6ntrailing frame\r\n") # the reader's byte-feed hook + process.normalizer.feed(b"\x1b[6ntrailing frame\r\n") # the reader's byte-feed hook assert process.query_responder.admitted == admitted_before assert process.query_responder.render_only == 1 @@ -468,3 +473,30 @@ def test_a_query_seen_only_after_quiescence_renders_and_records_nothing(tmp_path finally: process.terminate_tree(grace=0.05) process.close() + + +# ------------------------------------------------------------- reply resolution +# +# One queue resolution, mapped onto the responder's delivered / not-delivered contract. +# Both backends admit their replies through it. + + +def test_a_delivered_reply_reports_no_reason(): + reasons = [] + reply_resolution(reasons.append)(InputDisposition.ACCEPTED, None) + + assert reasons == [None] + + +def test_a_failed_reply_reports_the_write_failure(): + reasons = [] + reply_resolution(reasons.append)(InputDisposition.ACCEPTED, OSError("gone")) + + assert "write failed" in reasons[0] + + +def test_a_discarded_reply_reports_the_disposition(): + reasons = [] + reply_resolution(reasons.append)(InputDisposition.CLOSED, None) + + assert "discarded" in reasons[0] and "closed" in reasons[0] From 90c9ab1dfccb220ef7533c5d9a45e3c67f27d0c0 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 10:16:06 +0200 Subject: [PATCH 45/83] Remove dead code the reviews flagged Deleted: the ResizePseudoConsole declaration, the ConPTY backend's unread input-driver field, InputQueue.accepting(), InputWriter.acknowledged_generation, the responder's never-passed active flag, and the set mirroring the capped failure list. IsProcessInJob stays: tests call it through _conpty.kernel32. The repeated job and process-handle closes in _release_handles stay too, now with a comment saying they are belt and braces. --- render_machine/_conpty.py | 5 +++-- render_machine/_conpty_support.py | 9 --------- render_machine/terminal_queries.py | 16 +++++++--------- 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/render_machine/_conpty.py b/render_machine/_conpty.py index 14cd3ce6..85ff0cb7 100644 --- a/render_machine/_conpty.py +++ b/render_machine/_conpty.py @@ -288,7 +288,6 @@ def _declare_pseudoconsole_api() -> bool: if not hasattr(kernel32, "CreatePseudoConsole"): return False _declare("CreatePseudoConsole", [COORD, HANDLE, HANDLE, DWORD, PHPCON], ctypes.c_long) - _declare("ResizePseudoConsole", [HPCON, COORD], ctypes.c_long) _declare("ClosePseudoConsole", [HPCON], None) return True @@ -903,6 +902,9 @@ def _release_handles(self) -> None: self.writer_handle.close_if_owned() if not self.reader_alive(): # kept while a parked read may still need cancelling self.reader_handle.close_if_owned() + # Belt and braces: the ordered teardown closed both of these earlier, and each + # takes what it releases, so this is a no-op there and a release on any path that + # reaches here without having got that far. self._close_job() self.proc.close_all() @@ -1050,7 +1052,6 @@ def spawn( raise RuntimeError("ConPtyProcess instances are single-use") self._spawned = True self._stop_event = stop_event if stop_event is not None else threading.Event() - self._input_driver = input_driver self._check_cancelled() _require_pseudoconsole_support() columns, rows = terminal_size diff --git a/render_machine/_conpty_support.py b/render_machine/_conpty_support.py index 1f53837f..80e9e29d 100644 --- a/render_machine/_conpty_support.py +++ b/render_machine/_conpty_support.py @@ -332,10 +332,6 @@ def stop_accepting(self) -> None: with self._condition: self._accepting = False - def accepting(self) -> bool: - with self._condition: - return self._accepting - def has_pending(self) -> bool: with self._condition: return self._current is not None or bool(self._control) or bool(self._data) @@ -659,8 +655,3 @@ def _control_pending(self) -> bool: def _acknowledge_preemption(self, at_least: int = 0) -> None: with self._lock: self._preempted_generation = max(self._preempted_generation, self._requested_generation, at_least) - - @property - def acknowledged_generation(self) -> int: - with self._lock: - return self._preempted_generation diff --git a/render_machine/terminal_queries.py b/render_machine/terminal_queries.py index 907da6db..05e4123e 100644 --- a/render_machine/terminal_queries.py +++ b/render_machine/terminal_queries.py @@ -34,7 +34,7 @@ import threading from dataclasses import dataclass from enum import Enum -from typing import Callable, List, Optional, Set, Tuple +from typing import Callable, List, Optional, Set from render_machine.terminal_process import InputDisposition @@ -101,13 +101,12 @@ class TerminalQueryResponder: input channel — starts quiesced, so a printed escape query creates no obligation. """ - def __init__(self, admit: Optional[AdmitReply] = None, active: bool = True) -> None: + def __init__(self, admit: Optional[AdmitReply] = None) -> None: self._lock = threading.RLock() self._admit = admit - self._state = ResponderState.ACTIVE if active and admit is not None else ResponderState.QUIESCED + self._state = ResponderState.ACTIVE if admit is not None else ResponderState.QUIESCED self._outstanding: Set[_Obligation] = set() self._failures: List[TerminalReplyFailure] = [] - self._recorded_kinds: Set[Tuple[str, str]] = set() self.admitted = 0 self.render_only = 0 self.failures_recorded = 0 @@ -175,9 +174,8 @@ def _record_failure(self, kind: str, reason: str) -> None: """Counts every failure; retains one record per distinct kind and reason, capped.""" self.failures_recorded += 1 if len(self._failures) >= MAX_TRACKED_FAILURES: - return # the counter carries the rest, so neither list nor index can grow - key = (kind, reason) - if key in self._recorded_kinds: + return # the counter carries the rest, so the list cannot grow + failure = TerminalReplyFailure(kind, reason) + if failure in self._failures: # the cap keeps this scan bounded return - self._recorded_kinds.add(key) - self._failures.append(TerminalReplyFailure(kind, reason)) + self._failures.append(failure) From 40a5bf3c2dd80182bdebcded5c1363f371016f9a Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 10:23:51 +0200 Subject: [PATCH 46/83] Let the backends own their teardown budget and no-input story Each backend now publishes the teardown budget its own constants add up to and the CLI waits out the longest one reachable on this platform, so a ConPTY teardown is no longer reported as a render that did not stop. The absent-input note comes from the backend that ran rather than from sys.platform, which was wrong under the escape hatch on Windows. The POSIX grace probes the process group each tick and ends early once it is spent, and the precondition check asks each repository once for the whole list of previous frids. --- git_utils.py | 13 ++++++++++ plain2code.py | 22 +++++----------- plain_modules.py | 41 ++++++++++++++++++------------ render_machine/_conpty.py | 28 ++++++++++++++++++++ render_machine/_legacy_pipe.py | 10 ++++++++ render_machine/_posix_pty.py | 34 ++++++++++++++++++++++--- render_machine/render_utils.py | 40 ++++++++++------------------- render_machine/terminal_process.py | 36 ++++++++++++++++++++++++++ tests/test_conpty.py | 17 +++++++++++++ tests/test_plain2code.py | 15 ++++++++--- tests/test_render_utils.py | 30 ++++++++++++++-------- tests/test_terminal_process.py | 26 +++++++++++++++++++ 12 files changed, 237 insertions(+), 75 deletions(-) diff --git a/git_utils.py b/git_utils.py index ae447a89..29626e89 100644 --- a/git_utils.py +++ b/git_utils.py @@ -331,6 +331,19 @@ def has_commit_for_frid(repo_path: Union[str, os.PathLike], frid: str, module_na return bool(_get_commit_with_frid(repo, frid, module_name)) +def frids_missing_commits( + repo_path: Union[str, os.PathLike], frids: list[str], module_name: Optional[str] = None +) -> list[str]: + """The frids from `frids` with no commit in the repository, in the order given. + + One Repo answers for the whole list. Asking per frid instead opens and closes a Repo + each time, and close() runs gc.collect() twice on win32, so a render resumed late paid + that for every functionality before it. + """ + with Repo(repo_path) as repo: + return [frid for frid in frids if not _get_commit_with_frid(repo, frid, module_name)] + + def _get_base_folder_commit(repo: Repo) -> str: """Finds commit related to copy of the base folder.""" return _get_commit_with_message(repo, BASE_FOLDER_COMMIT_MESSAGE) diff --git a/plain2code.py b/plain2code.py index 509a0972..80ec3e47 100644 --- a/plain2code.py +++ b/plain2code.py @@ -51,11 +51,7 @@ ) from plain2code_state import RunState from plain2code_telemetry import capture_crash, initialize_telemetry -from render_machine.terminal_process import ( - DRAIN_DEADLINE_SECONDS, - REAP_DEADLINE_SECONDS, - SIGTERM_GRACE_PERIOD_SECONDS, -) +from render_machine.terminal_process import teardown_budget_seconds from system_config import system_config from tui.plain2code_tui import Plain2CodeTUI from tui.plain_module_render_choice_tui import PlainModuleRenderChoiceTUI @@ -63,18 +59,12 @@ DEFAULT_TEMPLATE_DIRS = "standard_template_library" # The render thread is cancelled, never killed, so the wait after cancellation has to -# outlast the teardown a script execution is entitled to: the SIGTERM grace runs to its -# end before the SIGKILL, the killed group is then reaped, and the output reader is joined -# on its own drain and reap budgets. A shorter wait lets the CLI exit mid-escalation and -# leave a descendant that ignores TERM alive, so the bound is those budgets in sequence. +# outlast the teardown a script execution is entitled to. Each backend adds its own phases +# up and publishes the total, and the longest one reachable on this platform is what has to +# be waited out: a shorter wait lets the CLI exit mid-escalation and leave a descendant that +# ignores TERM alive. RENDER_THREAD_UNWIND_MARGIN_SECONDS = 1.0 -RENDER_THREAD_SHUTDOWN_TIMEOUT = ( - SIGTERM_GRACE_PERIOD_SECONDS - + REAP_DEADLINE_SECONDS - + DRAIN_DEADLINE_SECONDS - + REAP_DEADLINE_SECONDS - + RENDER_THREAD_UNWIND_MARGIN_SECONDS -) +RENDER_THREAD_SHUTDOWN_TIMEOUT = teardown_budget_seconds() + RENDER_THREAD_UNWIND_MARGIN_SECONDS # Exceptions that represent expected, user-facing error conditions. They are # reported to the user directly and must never be sent to Sentry as crashes. diff --git a/plain_modules.py b/plain_modules.py index cda701ca..26a57c7a 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -283,34 +283,44 @@ def _ensure_module_folders_exist(self, first_render_frid: str, render_conformanc f" codeplain {self.module_name}{plain_file.PLAIN_SOURCE_FILE_EXTENSION}" ) - def _ensure_frid_commit_exists( + def _raise_for_missing_frid_commits( self, - frid: str, + previous_frids: list[str], first_render_frid: str, render_conformance_tests: bool, ) -> None: """ - Ensure commit exists for a single FRID in both repositories. + Ensure commits exist for every previous FRID in both repositories. + + Each repository is asked once for the whole list rather than once per FRID, and the + first FRID that is missing anywhere decides the error, in the order given. Args: - frid: The FRID to check + previous_frids: The FRIDs that should already have been rendered first_render_frid: The first FRID in the render range (for error messages) render_conformance_tests: Whether to check for conformance tests Raises: - MissingPreviousFridCommitsError: If the commit is missing + MissingPreviousFunctionalitiesError: If any commit is missing """ - # Check in build folder - if not git_utils.has_commit_for_frid(self.module_build_folder, frid, self.module_name): - raise MissingPreviousFunctionalitiesError( - f"Cannot start rendering from functionality {first_render_frid} for module '{self.module_name}' because the implementation of the previous functionality ({frid}) hasn't been completed yet.\n\n" - f"To fix this, please render the missing functionality ({frid}) first by running:\n" - f" codeplain {self.module_name}{plain_file.PLAIN_SOURCE_FILE_EXTENSION} --render-from {frid}" + missing_in_build = set( + git_utils.frids_missing_commits(self.module_build_folder, previous_frids, self.module_name) + ) + missing_in_tests = set() + if render_conformance_tests: + missing_in_tests = set( + git_utils.frids_missing_commits(self.module_conformance_tests_folder, previous_frids, self.module_name) ) - # Check in conformance tests folder (only if conformance tests are enabled) - if render_conformance_tests: - if not git_utils.has_commit_for_frid(self.module_conformance_tests_folder, frid, self.module_name): + for frid in previous_frids: + if frid in missing_in_build: + raise MissingPreviousFunctionalitiesError( + f"Cannot start rendering from functionality {first_render_frid} for module '{self.module_name}' because the implementation of the previous functionality ({frid}) hasn't been completed yet.\n\n" + f"To fix this, please render the missing functionality ({frid}) first by running:\n" + f" codeplain {self.module_name}{plain_file.PLAIN_SOURCE_FILE_EXTENSION} --render-from {frid}" + ) + + if frid in missing_in_tests: raise MissingPreviousFunctionalitiesError( f"Cannot start rendering from functionality {first_render_frid} for module '{self.module_name}' because the conformance tests for the previous functionality ({frid}) haven't been completed yet.\n\n" f"To fix this, please render the missing functionality ({frid}) first by running:\n" @@ -342,8 +352,7 @@ def ensure_previous_frid_commits_exist(self, render_range: list[str], render_con self._ensure_module_folders_exist(first_render_frid, render_conformance_tests) # Verify commits exist for all previous FRIDs - for prev_frid in previous_frids: - self._ensure_frid_commit_exists(prev_frid, first_render_frid, render_conformance_tests) + self._raise_for_missing_frid_commits(previous_frids, first_render_frid, render_conformance_tests) def get_required_module_by_name(self, module_name: str) -> PlainModule: for module in self.all_required_modules: diff --git a/render_machine/_conpty.py b/render_machine/_conpty.py index 85ff0cb7..64d5bd3f 100644 --- a/render_machine/_conpty.py +++ b/render_machine/_conpty.py @@ -56,6 +56,9 @@ DRAIN_DEADLINE_SECONDS, GRACE_TICK_SECONDS, HANDSHAKE_TIMEOUT_SECONDS, +) +from render_machine.terminal_process import NO_INPUT_NOTE as DEFAULT_NO_INPUT_NOTE +from render_machine.terminal_process import ( OWNER_PARENT, OWNER_READER, POLL_INTERVAL_SECONDS, @@ -139,11 +142,33 @@ FINALIZER_DEADLINE_SECONDS = 60.0 FINALIZER_TICK_SECONDS = 0.5 +# What one full teardown of this backend may spend, phase by phase and in sequence. The +# pipeline is longer than the POSIX one — a control byte has to be delivered before the +# grace it earns, the job's membership is waited out, the writer is stopped, and each +# join_reader() round is bounded twice: a join on the bound, then a cancel-and-join loop +# under the same bound again. A caller waiting on a render derives its own bound from this, +# so it cannot report a stuck teardown while the backend is still inside its own budget. +TEARDOWN_BUDGET_SECONDS = ( + CONTROL_DELIVERY_DEADLINE_SECONDS # teardown(): delivering the graceful control byte + + SIGTERM_GRACE_PERIOD_SECONDS # teardown(): the grace a delivered byte earns + + REAP_DEADLINE_SECONDS # teardown(): waiting for the job's membership to reach zero + + WRITER_JOIN_DEADLINE_SECONDS # teardown(): stopping the input writer + + 2 * DRAIN_DEADLINE_SECONDS # teardown(): join_reader() inside _close_pseudoconsole() + + 2 * DRAIN_DEADLINE_SECONDS # close(): the join_reader() that follows the stack close +) + # The graceful signal: writing 0x03 into the pseudoconsole input is how terminal emulators # deliver Ctrl-C to a ConPTY client. `GenerateConsoleCtrlEvent` cannot be used, because it # reaches only processes sharing the caller's console and the target is on the pseudoconsole. CONTROL_C_BYTE = b"\x03" +# The absent-input note this backend adds to, stated where the asymmetry is documented: a +# script that reads input blocks until the execution timeout rather than seeing end-of-file. +NO_INPUT_NOTE = DEFAULT_NO_INPUT_NOTE + ( + " On Windows the terminal carries no synthetic end-of-file, so such a script blocks until the " + "timeout instead of reading end-of-file." +) + class COORD(ctypes.Structure): _fields_ = [("X", ctypes.c_short), ("Y", ctypes.c_short)] @@ -1077,6 +1102,9 @@ def write_input(self, data: bytes) -> InputWriteResult: result, _ = self._input_queue.submit(data) return result + def no_input_note(self) -> str: + return NO_INPUT_NOTE + def infrastructure_failure(self) -> Optional[str]: detail = super().infrastructure_failure() if detail is not None: diff --git a/render_machine/_legacy_pipe.py b/render_machine/_legacy_pipe.py index a774154e..0431f594 100644 --- a/render_machine/_legacy_pipe.py +++ b/render_machine/_legacy_pipe.py @@ -58,6 +58,16 @@ # that inherited the write end keeps the pipe open past the leader's exit. CLOSE_JOIN_SECONDS = 1.0 +# What one full teardown of this backend may spend, phase by phase and in sequence. A +# caller waiting on a render derives its own bound from this, so it cannot report a stuck +# teardown while the backend is still inside the budget its own constants grant it. +TEARDOWN_BUDGET_SECONDS = ( + SIGTERM_GRACE_PERIOD_SECONDS # terminate_tree(): the grace before the SIGKILL + + REAP_DEADLINE_SECONDS # terminate_tree(): reaping the killed process + + DRAIN_DEADLINE_SECONDS # close(): the first join, while the pipe is still open + + CLOSE_JOIN_SECONDS # close(): the second, after the pipe is closed under the reader +) + # Windows gives a child the parent's console unless told otherwise, and a child on that # console can read the renderer's keystrokes through CONIN$ regardless of where its # standard input handle points. CREATE_NO_WINDOW gives it a console of its own instead. diff --git a/render_machine/_posix_pty.py b/render_machine/_posix_pty.py index 064e4ecf..4c022b43 100644 --- a/render_machine/_posix_pty.py +++ b/render_machine/_posix_pty.py @@ -68,6 +68,16 @@ # forks, so termination is immediate and the full grace would only slow failures down. ROLLBACK_GRACE_SECONDS = 0.1 +# What one full teardown of this backend may spend, phase by phase and in sequence. A +# caller waiting on a render derives its own bound from this, so it cannot report a stuck +# teardown while the backend is still inside the budget its own constants grant it. +TEARDOWN_BUDGET_SECONDS = ( + SIGTERM_GRACE_PERIOD_SECONDS # terminate_tree(): the grace before the SIGKILL + + REAP_DEADLINE_SECONDS # terminate_tree(): reaping the killed group + + DRAIN_DEADLINE_SECONDS # close(): the reader's final drain + + REAP_DEADLINE_SECONDS # close(): the rest of the same reader join +) + class _ProtocolError(Exception): """The launcher's status stream did not follow the handshake protocol.""" @@ -82,20 +92,25 @@ def _close_quietly(fd: Optional[int]) -> None: pass -def _signal_group(pgid: int, sig: int) -> None: - """The only killpg site in this module. +def _signal_group(pgid: int, sig: int) -> bool: + """The only killpg site in this module. False once the group has nothing left to signal. ESRCH: the group is gone. EPERM: verified on macOS — killpg() returns EPERM, not ESRCH, when the group's only remaining member is our own unreaped zombie leader. That is the NORMAL state after a graceful exit, so it must not raise. + + Both are terminal for the group, which is what lets signal 0 serve as a liveness probe + without a second killpg site. """ try: os.killpg(pgid, sig) except ProcessLookupError: # ESRCH — nothing left - return + return False except PermissionError: # EPERM — zombie-only group console.debug(f"killpg({pgid}, {sig}): EPERM, treating as terminal") + return False + return True def _background_reap(proc: subprocess.Popen) -> None: @@ -548,6 +563,8 @@ def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: self._deliver(proc, pgid, signal.SIGCONT) deadline = time.monotonic() + grace # independent clock — NOT stop_event while time.monotonic() < deadline: # never waits on the leader either + if self._group_spent(pgid): + break # the tree handled the SIGTERM; the escalation still follows self._grace_tick() finally: # Unconditional: an interruption mid-grace must still escalate. @@ -998,6 +1015,17 @@ def _deliver(self, proc: subprocess.Popen, pgid: Optional[int], sig: int) -> Non def _grace_tick(self) -> None: time.sleep(GRACE_TICK_SECONDS) + def _group_spent(self, pgid: Optional[int]) -> bool: + """Signal 0 as a liveness probe: True once the group can no longer be signalled. + + Only the grace loop uses it, and only to stop waiting early. Nothing is reaped here + — the SIGKILL and the reap that follow are unconditional — because reaping before + the escalation would recycle the group the escalation still has to reach. + """ + if pgid is None: # pre-ack: no group recorded, so the grace runs to its end + return False + return not _signal_group(pgid, 0) + def _rollback(self) -> None: try: if self._proc is not None: diff --git a/render_machine/render_utils.py b/render_machine/render_utils.py index cf330353..df3c968d 100644 --- a/render_machine/render_utils.py +++ b/render_machine/render_utils.py @@ -10,6 +10,7 @@ from plain2code_exceptions import RenderCancelledError from render_machine.terminal_process import ( ENVIRONMENT_ERROR_EXIT_CODE, + NO_INPUT_NOTE, TerminalProcess, TerminalProcessError, create_terminal_process, @@ -28,30 +29,6 @@ # than on bytes written: a script that blocks on input has written nothing either way. INPUT_DRIVER: Optional[object] = None -NO_INPUT_DIAGNOSTIC_BASE = ( - " No input driver was attached to the script's terminal, so a script that waits for input " - "never receives any and runs to the timeout." -) - -# A documented platform asymmetry, not an implementation detail. POSIX injects the -# terminal's EOF byte at spawn when no input driver is attached, so a script that reads -# input sees end-of-file at once. ConPTY has no parent-side equivalent that leaves the input -# channel open, and the channel has to stay open for the graceful control byte and for -# terminal-query replies, so the same script blocks until the timeout. -WINDOWS_NO_EOF_DIAGNOSTIC = ( - " On Windows the terminal carries no synthetic end-of-file, so such a script blocks until the " - "timeout instead of reading end-of-file." -) - - -def no_input_diagnostic(platform: str) -> str: - if platform == "win32": - return NO_INPUT_DIAGNOSTIC_BASE + WINDOWS_NO_EOF_DIAGNOSTIC - return NO_INPUT_DIAGNOSTIC_BASE - - -NO_INPUT_DIAGNOSTIC = no_input_diagnostic(sys.platform) - # Conditions the arbiter chooses between, highest precedence last. CONDITION_EXIT = "exit" CONDITION_TIMEOUT = "timeout" @@ -148,6 +125,9 @@ def __init__(self) -> None: self.raw_output = b"" self.reply_failed = False self.reply_detail = "" + # The backend that ran states this itself. Keyed on the platform it would describe + # the wrong backend whenever the escape hatch selected another one. + self.no_input_note = NO_INPUT_NOTE def _await_target( @@ -221,6 +201,7 @@ def _collect_backend_state(process: TerminalProcess, execution: _ScriptExecution execution.raw_output = process.read_raw_output() execution.reply_failed = process.terminal_reply_failed execution.reply_detail = process.terminal_reply_detail() + execution.no_input_note = process.no_input_note() except Exception as exc: _record_backend_failure(execution.outcome, exc, "while reporting its result") @@ -340,8 +321,9 @@ def _publish_timeout( output: str, reply_failed: bool, reply_detail: str, + no_input_note: str, ) -> tuple[int, str, Optional[str]]: - diagnostics = NO_INPUT_DIAGNOSTIC if INPUT_DRIVER is None else "" + diagnostics = no_input_note if INPUT_DRIVER is None else "" if reply_failed: diagnostics += f" Terminal replies the script asked for could not be delivered: {reply_detail}." @@ -398,7 +380,13 @@ def execute_script( raise RenderCancelledError() elif outcome.condition == CONDITION_TIMEOUT: result = _publish_timeout( - script, script_type, script_timeout, execution.output, execution.reply_failed, execution.reply_detail + script, + script_type, + script_timeout, + execution.output, + execution.reply_failed, + execution.reply_detail, + execution.no_input_note, ) elif outcome.exit_code is None: result = _publish_environment_error( diff --git a/render_machine/terminal_process.py b/render_machine/terminal_process.py index a804216c..4abf99be 100644 --- a/render_machine/terminal_process.py +++ b/render_machine/terminal_process.py @@ -6,6 +6,7 @@ `render_machine._conpty`. Only this module is imported by callers. """ +import importlib import os import sys import threading @@ -221,6 +222,17 @@ def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: def close(self) -> None: raise NotImplementedError + def no_input_note(self) -> str: + """What a timeout diagnostic says about this backend's absent input driver. + + A backend that gives the target end-of-file at spawn needs nothing beyond the + default; one that cannot says so itself. The note belongs to the backend that ran, + not to the platform the renderer is on: under the escape hatch on Windows the pipe + backend delivers end-of-file immediately, and a note keyed on `sys.platform` would + describe a backend that never ran. + """ + return NO_INPUT_NOTE + def infrastructure_failure(self) -> Optional[str]: """Detail of a failed backend pump, or None while they are all healthy. @@ -289,6 +301,30 @@ def terminal_child_environment(env: Optional[dict]) -> dict: return child_env +# Every backend module, each publishing the teardown budget its own constants add up to. +# A module whose platform this is not refuses to import, which is what keeps the budget +# below a question about this machine rather than about the codebase. +_BACKEND_MODULES = ("render_machine._legacy_pipe", "render_machine._posix_pty", "render_machine._conpty") + + +def teardown_budget_seconds() -> float: + """The longest teardown any backend reachable on this platform may spend. + + A caller that waits for a render to stop has to outlast it. The three pipelines are + different lengths — the ConPTY one is much the longest — so a wait assembled from the + POSIX constants would report a render that did not stop while the backend was still + inside the bound its own constants grant it. + """ + budgets = [] + for module_name in _BACKEND_MODULES: + try: + module = importlib.import_module(module_name) + except ImportError: # this backend is not built on this platform + continue + budgets.append(module.TEARDOWN_BUDGET_SECONDS) + return max(budgets) + + def create_terminal_process() -> TerminalProcess: """The one construction site: returns the backend this execution runs on.""" if pty_disabled_by_environment(): diff --git a/tests/test_conpty.py b/tests/test_conpty.py index 86efae37..50c424bf 100644 --- a/tests/test_conpty.py +++ b/tests/test_conpty.py @@ -33,10 +33,12 @@ from plain2code_exceptions import RenderCancelledError # noqa: E402 from render_machine import _conpty # noqa: E402 from render_machine import render_utils # noqa: E402 +from render_machine import terminal_process # noqa: E402 from render_machine._conpty import ConPtyProcess # noqa: E402 from render_machine._legacy_pipe import LegacyPipeProcess # noqa: E402 from render_machine.terminal_process import ( # noqa: E402 ENVIRONMENT_ERROR_EXIT_CODE, + NO_INPUT_NOTE, NO_PTY_ENV_VAR, InputDisposition, TerminalEnvironmentError, @@ -378,6 +380,21 @@ def test_windows_selects_the_conpty_backend(monkeypatch): process.close() +def test_the_backend_notes_that_it_has_no_synthetic_end_of_file(): + """The timeout diagnostic asks the backend that ran, so this one has to state the + asymmetry itself: a script reading input blocks instead of seeing end-of-file.""" + note = ConPtyProcess().no_input_note() + + assert note.startswith(NO_INPUT_NOTE) + assert "end-of-file" in note + + +def test_the_teardown_budget_covers_every_phase_the_shutdown_spends(): + """The CLI derives its own wait from this, and the ConPTY pipeline is the longest one.""" + assert _conpty.TEARDOWN_BUDGET_SECONDS > terminal_process.SIGTERM_GRACE_PERIOD_SECONDS + assert terminal_process.teardown_budget_seconds() >= _conpty.TEARDOWN_BUDGET_SECONDS + + def test_the_escape_hatch_still_selects_the_pipe_backend_on_windows(monkeypatch): """The hatch is cross-platform: it is read before the platform branch, not instead of it.""" monkeypatch.setenv(NO_PTY_ENV_VAR, "1") diff --git a/tests/test_plain2code.py b/tests/test_plain2code.py index 9063254a..bdf5c1e7 100644 --- a/tests/test_plain2code.py +++ b/tests/test_plain2code.py @@ -8,7 +8,7 @@ import plain2code import plain_spec from plain_modules import PlainModule -from render_machine import terminal_process +from render_machine import _legacy_pipe, terminal_process def _make_module(module_name, has_acceptance_tests, required_modules=None): @@ -115,15 +115,22 @@ def test_warning_covers_required_modules_for_real_plain_module(get_test_data_pat def test_the_shutdown_bound_covers_the_teardown_budgets_of_a_script(): - """The wait is derived from what a backend teardown may spend, not picked.""" - worst_case_teardown = ( + """The wait is derived from what a backend teardown may spend, not picked. + + The budget comes from the backends themselves, so the wait covers whichever one this + platform can reach rather than the phases of the POSIX pipeline alone. + """ + budget = terminal_process.teardown_budget_seconds() + posix_pipeline = ( terminal_process.SIGTERM_GRACE_PERIOD_SECONDS + terminal_process.REAP_DEADLINE_SECONDS + terminal_process.DRAIN_DEADLINE_SECONDS + terminal_process.REAP_DEADLINE_SECONDS ) - assert plain2code.RENDER_THREAD_SHUTDOWN_TIMEOUT > worst_case_teardown + assert budget >= posix_pipeline + assert budget >= _legacy_pipe.TEARDOWN_BUDGET_SECONDS + assert plain2code.RENDER_THREAD_SHUTDOWN_TIMEOUT > budget assert plain2code.RENDER_THREAD_SHUTDOWN_TIMEOUT > SUPERSEDED_SHUTDOWN_TIMEOUT diff --git a/tests/test_render_utils.py b/tests/test_render_utils.py index 97a8e448..1e087bc9 100644 --- a/tests/test_render_utils.py +++ b/tests/test_render_utils.py @@ -31,7 +31,8 @@ import pytest from plain2code_exceptions import RenderCancelledError -from render_machine import render_utils +from render_machine import render_utils, terminal_process +from render_machine._legacy_pipe import LegacyPipeProcess from render_machine.terminal_process import ( ENVIRONMENT_ERROR_EXIT_CODE, READER_STALL_DETAIL, @@ -664,13 +665,22 @@ def test_the_timeout_message_names_the_absent_input_driver(tmp_path, run_script) assert "no input driver was attached" in Path(output_file).read_text().lower() -def test_the_no_input_diagnostic_names_the_platform_asymmetry_on_windows(): - """POSIX injects the terminal's EOF byte at spawn and ConPTY has no equivalent, so the - same script behaves differently and the message has to say so.""" - posix = render_utils.no_input_diagnostic("darwin") - windows = render_utils.no_input_diagnostic("win32") +def test_a_backend_that_delivers_end_of_file_states_the_default_note(): + """POSIX injects the terminal's EOF byte at spawn and the pipe backend hands the child + DEVNULL, so neither has anything to add to the default note.""" + assert LegacyPipeProcess().no_input_note() == terminal_process.NO_INPUT_NOTE - assert posix == render_utils.NO_INPUT_DIAGNOSTIC_BASE - assert windows.startswith(posix) - assert "end-of-file" in windows - assert render_utils.NO_INPUT_DIAGNOSTIC == render_utils.no_input_diagnostic(sys.platform) + +def test_the_timeout_diagnostic_carries_the_note_of_the_backend_that_ran(injected_backend, run_script): + """The note comes from the backend, not from sys.platform: under the escape hatch on + Windows the pipe backend delivers end-of-file at once, so a platform-keyed note would + describe a backend that never ran.""" + note = " This backend states its own note." + process = injected_backend() + process.no_input_note = lambda: note + + exit_code, output, output_file = run_script(FAKE_SCRIPT, [], SCRIPT_TYPE, timeout=0) + + assert exit_code == render_utils.TIMEOUT_ERROR_EXIT_CODE + assert note in output + assert note in Path(output_file).read_text() diff --git a/tests/test_terminal_process.py b/tests/test_terminal_process.py index b17c61fe..93b50a00 100644 --- a/tests/test_terminal_process.py +++ b/tests/test_terminal_process.py @@ -348,6 +348,7 @@ def test_launcher_runs_no_startup_customization(tmp_path): from plain2code_exceptions import RenderCancelledError from render_machine import _posix_pty from render_machine.terminal_process import ( + SIGTERM_GRACE_PERIOD_SECONDS, InputDisposition, TerminalEnvironmentError, TerminalLaunchError, @@ -826,6 +827,31 @@ def test_cancellation_after_the_ack_reaps_a_forked_descendant(tmp_path): assert wait_until_gone(descendant) +# The grace is a bound, not a delay, so a tree that handled the SIGTERM must not hold the +# teardown for the rest of it. +GRACE_EARLY_EXIT_CEILING = 1.0 + + +@pytest.mark.skipif( + sys.platform == "linux", + reason="An unreaped group leader stays signallable on Linux, so the probe cannot observe the exit there.", +) +def test_a_tree_that_exits_on_sigterm_does_not_wait_out_the_whole_grace(tmp_path): + """The group is probed on every tick with signal 0; the SIGKILL and the reap still follow.""" + script = make_script(tmp_path, "sleeper", "sleep 120\n") + process = _posix_pty.PosixPtyProcess() + process.spawn([script]) + try: + started = time.monotonic() + process.terminate_tree(grace=SIGTERM_GRACE_PERIOD_SECONDS) + elapsed = time.monotonic() - started + finally: + process.close() + + assert elapsed < GRACE_EARLY_EXIT_CEILING + assert process._proc.returncode is not None + + def test_launcher_ack_timeout_beats_the_parents_ack(): """The parent's write hits a closed pipe; the launcher's own reason must surface.""" env = dict(os.environ, **{pty_exec.ACK_TIMEOUT_ENV: "0.2"}) From acc4cbadc048630ea70e4347e7bd4c44faa84647 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 10:26:36 +0200 Subject: [PATCH 47/83] Drive the acknowledgment window from a hook instead of a parameter spawn() no longer carries pre_ack_delay through the handshake for the tests' benefit. An empty _pre_ack_hook() sits where the delay ran, and the cases that need the window open override it on a subclass, as they already do for the select and master-descriptor seams. --- render_machine/_posix_pty.py | 34 +++++++++------------------ tests/test_terminal_process.py | 43 ++++++++++++++++++++++++---------- 2 files changed, 41 insertions(+), 36 deletions(-) diff --git a/render_machine/_posix_pty.py b/render_machine/_posix_pty.py index 4c022b43..aefac7fb 100644 --- a/render_machine/_posix_pty.py +++ b/render_machine/_posix_pty.py @@ -501,14 +501,8 @@ def spawn( stop_event: Optional[threading.Event] = None, input_driver: Optional[object] = None, handshake_timeout: float = HANDSHAKE_TIMEOUT_SECONDS, - pre_ack_delay: float = 0.0, ) -> None: - """Allocates the terminal, launches the target, and returns once it is running. - - `pre_ack_delay` holds the parent's acknowledgment for a bounded time. It exists so - the barrier's window can be driven deterministically from tests; production - callers leave it at zero. - """ + """Allocates the terminal, launches the target, and returns once it is running.""" if self._spawned: raise RuntimeError("PosixPtyProcess instances are single-use") self._spawned = True @@ -521,7 +515,7 @@ def spawn( self._open_channels() self._start_child(command, cwd, env) self._hand_over_to_reader() - self._run_handshake(deadline, pre_ack_delay) + self._run_handshake(deadline) self._close_owned("_status_r") # the handshake has resolved except BaseException: self._rollback() @@ -715,7 +709,7 @@ def _hand_over_to_reader(self) -> None: # ---------------------------------------------------------------- handshake - def _run_handshake(self, deadline: float, pre_ack_delay: float) -> None: + def _run_handshake(self, deadline: float) -> None: parser = _HandshakeParser() assert self._proc is not None and self._proc.stderr is not None status_r, err_r = self._status_r, self._err_r @@ -733,12 +727,10 @@ def _run_handshake(self, deadline: float, pre_ack_delay: float) -> None: watched.discard(stderr_fd) if err_r in readable: self._consume_reader_edge(watched, err_r) - if status_r in readable and self._advance_handshake(parser, status_r, deadline, pre_ack_delay): + if status_r in readable and self._advance_handshake(parser, status_r, deadline): return - def _advance_handshake( - self, parser: _HandshakeParser, status_r: int, deadline: float, pre_ack_delay: float - ) -> bool: + def _advance_handshake(self, parser: _HandshakeParser, status_r: int, deadline: float) -> bool: """Feeds one status chunk. Returns True once exec has been observed.""" chunk = os.read(status_r, READ_CHUNK_BYTES) try: @@ -752,17 +744,16 @@ def _advance_handshake( reason = parser.failure_payload.decode("utf-8", "replace") raise TerminalLaunchError(self._launch_message(f"the launcher failed: {reason}")) if parser.session_ready and not self._acked: - self._acknowledge(deadline, pre_ack_delay) + self._acknowledge(deadline) return False - def _acknowledge(self, deadline: float, pre_ack_delay: float) -> None: + def _acknowledge(self, deadline: float) -> None: """Records the group, delivers the no-driver VEOF, and only then releases the target.""" assert self._proc is not None self._pgid = self._proc.pid # recorded BEFORE the target can run if self._input_driver is None: self._inject_veof(deadline) - if pre_ack_delay > 0: - self._wait_pre_ack(pre_ack_delay, deadline) + self._pre_ack_hook() self._acked = True ack_w = self._ack_w assert ack_w is not None @@ -774,12 +765,9 @@ def _acknowledge(self, deadline: float, pre_ack_delay: float) -> None: console.debug("the launcher closed the acknowledgment pipe before the parent acknowledged") self._close_owned("_ack_w") - def _wait_pre_ack(self, delay: float, deadline: float) -> None: - until = min(time.monotonic() + delay, deadline) - while time.monotonic() < until: - self._check_cancelled() - self._check_reader_failed() - time.sleep(min(POLL_INTERVAL_SECONDS, max(0.0, until - time.monotonic()))) + def _pre_ack_hook(self) -> None: + """The window between the recorded group and the acknowledgment that releases the + target. Empty in production; a test overrides it to hold the window open.""" def _inject_veof(self, deadline: float) -> None: result, receipt = self._input_queue.submit( diff --git a/tests/test_terminal_process.py b/tests/test_terminal_process.py index 93b50a00..b292c39e 100644 --- a/tests/test_terminal_process.py +++ b/tests/test_terminal_process.py @@ -773,6 +773,29 @@ def test_reader_exits_cleanly_when_the_leader_exits_with_a_descendant_on_the_sla assert process.reader_exc is None +# How long the delayed-ack backend below holds the acknowledgment window open. Nothing +# waits it out: every case that uses it ends the window itself. +ACK_WINDOW_SECONDS = 5.0 + + +class _DelayedAckProcess(_posix_pty.PosixPtyProcess): + """Holds the acknowledgment for a bounded time, so the barrier's window is opened + rather than raced. The wait is cancellable and reader-aware, like the path it sits in.""" + + def __init__(self, delay=ACK_WINDOW_SECONDS): + super().__init__() + self.delay = delay + self.entered = threading.Event() + + def _pre_ack_hook(self): + self.entered.set() + until = time.monotonic() + self.delay + while time.monotonic() < until: + self._check_cancelled() + self._check_reader_failed() + time.sleep(min(0.02, max(0.0, until - time.monotonic()))) + + def test_cancellation_inside_the_ack_window_leaves_nothing_behind(): """Deterministic through the delayed-ack hook: the window is opened, not raced. @@ -780,24 +803,18 @@ def test_cancellation_inside_the_ack_window_leaves_nothing_behind(): SESSION_READY however slowly the launcher gets there. """ stop_event = threading.Event() - process = _posix_pty.PosixPtyProcess() - entered = threading.Event() - real_wait_pre_ack = process._wait_pre_ack - - def recording_wait_pre_ack(delay, deadline): - entered.set() - real_wait_pre_ack(delay, deadline) + process = _DelayedAckProcess() + entered = process.entered def cancel_inside_the_window(): if entered.wait(SPAWN_TIMEOUT): stop_event.set() - process._wait_pre_ack = recording_wait_pre_ack canceller = threading.Thread(target=cancel_inside_the_window, daemon=True) canceller.start() try: with pytest.raises(RenderCancelledError): - process.spawn(["/bin/sh", "-c", "sleep 30"], stop_event=stop_event, pre_ack_delay=5.0) + process.spawn(["/bin/sh", "-c", "sleep 30"], stop_event=stop_event) finally: canceller.join(timeout=SHORT_TIMEOUT) process.close() @@ -855,10 +872,10 @@ def test_a_tree_that_exits_on_sigterm_does_not_wait_out_the_whole_grace(tmp_path def test_launcher_ack_timeout_beats_the_parents_ack(): """The parent's write hits a closed pipe; the launcher's own reason must surface.""" env = dict(os.environ, **{pty_exec.ACK_TIMEOUT_ENV: "0.2"}) - process = _posix_pty.PosixPtyProcess() + process = _DelayedAckProcess(delay=2.0) try: with pytest.raises(TerminalLaunchError) as failure: - process.spawn(["/bin/sh", "-c", "exit 0"], env=env, pre_ack_delay=2.0, handshake_timeout=10.0) + process.spawn(["/bin/sh", "-c", "exit 0"], env=env, handshake_timeout=10.0) finally: process.close() @@ -894,10 +911,10 @@ def recording_killpg(pgid, sig): monkeypatch.setattr(_posix_pty.os, "killpg", recording_killpg) stop_event = threading.Event() threading.Timer(0.2, stop_event.set).start() - process = _posix_pty.PosixPtyProcess() + process = _DelayedAckProcess() try: with pytest.raises(RenderCancelledError): - process.spawn(["/bin/sh", "-c", "sleep 30"], stop_event=stop_event, pre_ack_delay=5.0) + process.spawn(["/bin/sh", "-c", "sleep 30"], stop_event=stop_event) finally: process.close() From 78ae6330cc33a941e78618b5000ca556fc035222 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 10:35:25 +0200 Subject: [PATCH 48/83] Pin the grace probe in the tick-driven teardown tests Both tests need the grace loop to reach their injected tick; on a fast runner the group can read as spent on the first probe and the loop breaks before the tick fires. The tests' subject is the tick's effect, not liveness, so the probe is pinned open. --- tests/test_terminal_process.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_terminal_process.py b/tests/test_terminal_process.py index b292c39e..f31b73f8 100644 --- a/tests/test_terminal_process.py +++ b/tests/test_terminal_process.py @@ -1194,6 +1194,9 @@ def interrupting_tick(): raise KeyboardInterrupt() process._grace_tick = interrupting_tick + # The subject is exception propagation, not liveness: pin the probe so the + # grace loop cannot break before the tick fires on a fast tree. + process._group_spent = lambda pgid: False with pytest.raises(KeyboardInterrupt): process.terminate_tree(grace=1.0) finally: @@ -1223,6 +1226,7 @@ def failing_tick(): real_tick() process._grace_tick = failing_tick + process._group_spent = lambda pgid: False # the fault must get its tick process.terminate_tree(grace=0.5) finally: process.close() From 10051ed73f85a373f3e46affcd05e64c1ea35341 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 16:43:21 +0200 Subject: [PATCH 49/83] Fix legacy pipe teardown deadlock and group escalation close() no longer calls BufferedReader.close() under a reader parked in read1(): the read end is redirected to devnull, which never blocks and cannot recycle the descriptor under the parked read. terminate_tree() watches the whole group through the grace with the leader left unreaped, so descendants get their grace, a group that empties is never SIGKILLed, and the escalation cannot reach a recycled process group. The reap runs in its own finally so an interrupted grace still collects the leader. --- render_machine/_legacy_pipe.py | 101 +++++++++++++++++++++++++++++---- tests/test_legacy_pipe.py | 85 +++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 11 deletions(-) diff --git a/render_machine/_legacy_pipe.py b/render_machine/_legacy_pipe.py index 0431f594..b8d9cb1c 100644 --- a/render_machine/_legacy_pipe.py +++ b/render_machine/_legacy_pipe.py @@ -28,6 +28,7 @@ import subprocess import sys import threading +import time from typing import Optional, Sequence, Tuple from plain2code_console import console @@ -35,6 +36,7 @@ from render_machine.output_normalizer import OutputNormalizer from render_machine.terminal_process import ( DRAIN_DEADLINE_SECONDS, + GRACE_TICK_SECONDS, READ_CHUNK_BYTES, REAP_DEADLINE_SECONDS, SIGTERM_GRACE_PERIOD_SECONDS, @@ -95,6 +97,7 @@ def __init__(self) -> None: self._closed = False self._closing = threading.Event() self._reaped = False + self._stdout_redirected = False # ---------------------------------------------------------------- public API @@ -151,18 +154,62 @@ def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: proc = self._proc if proc is None or self._reaped: return - self._signal(proc, terminal=False) + if sys.platform == "win32": + self._terminate_windows(proc, grace) + return + # The leader is deliberately left unreaped until after the escalation: a zombie + # leader pins the group id against reuse, so the SIGKILL below can never reach a + # recycled group. The grace therefore watches the whole group, not the leader — + # members that outlive an instantly-dying leader get their grace too, and a group + # that empties within it is never SIGKILLed at all. + escalate = True try: - proc.wait(timeout=grace) - except subprocess.TimeoutExpired: - self._signal(proc, terminal=True) + try: + self._signal(proc, terminal=False) + deadline = time.monotonic() + grace + while time.monotonic() < deadline: + if self._group_spent(proc.pid): + escalate = False + break + time.sleep(GRACE_TICK_SECONDS) + finally: + if escalate: + self._signal(proc, terminal=True) + finally: + # Reaped in a finally of its own: an exception escaping the grace loop has + # already escalated above, and skipping the reap here would leave a zombie + # whose eventual collection unpins the group id mid-retry. try: proc.wait(timeout=REAP_DEADLINE_SECONDS) except subprocess.TimeoutExpired: console.debug(f"process {proc.pid} outlived the reap deadline") - return + else: + self._reaped = True + + def _terminate_windows(self, proc: subprocess.Popen, grace: float) -> None: + """TerminateProcess is already terminal, so there is nothing to escalate to.""" + self._signal_process(proc, terminal=False) + try: + proc.wait(timeout=grace + REAP_DEADLINE_SECONDS) + except subprocess.TimeoutExpired: + console.debug(f"process {proc.pid} outlived the reap deadline") + return self._reaped = True + def _group_spent(self, pgid: int) -> bool: + """True once the group has no live member left to signal. + + macOS reports a group whose remaining members are all zombies as EPERM rather + than ESRCH; both mean the grace has done its work. + """ + try: + os.killpg(pgid, 0) + except (ProcessLookupError, PermissionError): + return True + except OSError: + return False + return False + def close(self) -> None: if self._closed: return @@ -172,8 +219,9 @@ def close(self) -> None: if self._reader is not None and self._reader.ident is not None: self._reader.join(timeout=DRAIN_DEADLINE_SECONDS) if self._reader.is_alive(): - # A descendant is holding the write end open; the parked read has to be - # broken rather than waited out. + # A descendant is holding the write end open. The read end is redirected to + # devnull so any read that returns sees end-of-file; a read the kernel keeps + # parked past the second join is published as a stall below. self._close_stdout() self._reader.join(timeout=CLOSE_JOIN_SECONDS) stalled = self._reader.is_alive() @@ -194,12 +242,17 @@ def _widen_pipe(self) -> None: console.debug(f"could not widen the output pipe: {exc}") def _signal(self, proc: subprocess.Popen, terminal: bool) -> None: - """Signals the child's whole group, falling back to the child alone.""" + """Signals the child's whole group, falling back to the child alone. + + `start_new_session` makes the child its own group leader, so the group id is the + child's pid itself — resolvable even after the leader has exited and been reaped, + which `os.getpgid()` on the pid no longer is. + """ if sys.platform == "win32": self._signal_process(proc, terminal) return try: - os.killpg(os.getpgid(proc.pid), signal.SIGKILL if terminal else signal.SIGTERM) + os.killpg(proc.pid, signal.SIGKILL if terminal else signal.SIGTERM) except OSError: self._signal_process(proc, terminal) @@ -210,9 +263,35 @@ def _signal_process(self, proc: subprocess.Popen, terminal: bool) -> None: proc.terminate() def _close_stdout(self) -> None: - if self._proc is not None and self._proc.stdout is not None: + """Releases the read end without touching the buffered stream's lock. + + `BufferedReader.close()` takes the same internal lock the reader thread holds while + parked inside `read1()`, so a foreground close would deadlock exactly when the pipe + has to be broken. A raw `os.close()` would free the fd number for reuse under that + parked read instead. `os.dup2()` of devnull replaces the descriptor atomically: it + never blocks, never recycles the number, and a reader that wakes later reads + end-of-file. The buffered object itself is only closed once the reader is gone. + """ + proc = self._proc + if proc is None or proc.stdout is None: + return + stream = proc.stdout + if not self._stdout_redirected: + self._stdout_redirected = True + try: + devnull = os.open(os.devnull, os.O_RDONLY) + except OSError: + devnull = -1 + if devnull >= 0: + try: + os.dup2(devnull, stream.fileno()) + except (OSError, ValueError): + pass + finally: + os.close(devnull) + if (self._reader is None or not self._reader.is_alive()) and not stream.closed: try: - self._proc.stdout.close() + stream.close() except OSError: pass diff --git a/tests/test_legacy_pipe.py b/tests/test_legacy_pipe.py index 3ec9dd0f..8d1e8cf2 100644 --- a/tests/test_legacy_pipe.py +++ b/tests/test_legacy_pipe.py @@ -8,6 +8,7 @@ Scripts are executed for real, so every case that runs one is POSIX-only. """ +import contextlib import errno import json import os @@ -326,3 +327,87 @@ def test_the_control_case_proves_the_harness_terminal_delivers_keystrokes(tmp_pa report = json.loads(output.strip()) assert report["isatty"] is True assert report["data"] == KEYSTROKES + + +def test_close_returns_while_a_descendant_holds_the_pipe_open(tmp_path, backend): + """The parked reader holds the buffered stream's lock, so close() must release the + read end without acquiring it — a blocking close deadlocked here. What follows the + release is platform-dependent: BSD kernels wake the parked read and the reader exits + cleanly; Linux keeps it parked and close() publishes the stall. Either way close() + returns within its budget.""" + script = make_shell_script(tmp_path, "leaves_a_holder", "sleep 30 &\necho started\nexit 0\n") + backend.spawn([script]) + returncode = wait_for_exit(backend) + assert returncode == 0 + + started = time.monotonic() + stalled = False + try: + backend.close() + except TerminalReaderError: + stalled = True + elapsed = time.monotonic() - started + + assert elapsed < _legacy_pipe.TEARDOWN_BUDGET_SECONDS + if stalled: + assert READER_STALL_DETAIL in repr(backend.reader_exc) + # The escapee is not this test's subject; reap it so it cannot outlive the run. + with contextlib.suppress(OSError): + os.killpg(backend._proc.pid, 9) + + +def test_terminate_tree_escalates_even_when_the_leader_dies_within_the_grace(tmp_path, backend): + """A group member that traps the graceful signal must still be reached: the + escalation is owed to the group, not only to a leader that failed to die.""" + script = make_shell_script( + tmp_path, + "trapping_member", + "sh -c 'trap \"\" TERM; sleep 30' &\necho started\nexec sleep 30\n", + ) + backend.spawn([script]) + + deadline = time.monotonic() + SPAWN_TIMEOUT + reported = "" + while time.monotonic() < deadline and "started" not in reported: + reported += backend.read_output() + time.sleep(0.02) + assert "started" in reported + + backend.terminate_tree(grace=2.0) + + gone_by = time.monotonic() + SPAWN_TIMEOUT + while time.monotonic() < gone_by: + try: + os.killpg(backend._proc.pid, 0) + except OSError: # ESRCH once empty; EPERM on macOS while only zombies remain + return + time.sleep(0.05) + raise AssertionError("a TERM-trapping group member outlived terminate_tree()") + + +def test_the_grace_covers_group_members_that_outlive_the_leader(tmp_path, backend): + """The grace watches the whole group: a member still cleaning up after the leader + died must finish inside it, and a group that empties is never SIGKILLed at all.""" + script = make_shell_script( + tmp_path, + "member_cleanup", + "sh -c 'trap \"echo member-term; sleep 0.5; echo member-clean; exit 0\" TERM; while :; do sleep 1; done' &\n" + 'trap "exit 0" TERM\n' + "echo started\n" + "while :; do sleep 1; done\n", + ) + backend.spawn([script]) + + deadline = time.monotonic() + SPAWN_TIMEOUT + reported = "" + while time.monotonic() < deadline and "started" not in reported: + reported += backend.read_output() + time.sleep(0.02) + assert "started" in reported + + backend.terminate_tree(grace=5.0) + backend.close() + + output = backend.normalized_output() + assert "member-term" in output + assert "member-clean" in output From a2036ed770193eb3ad411995f2810d26bc497c9e Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 16:43:21 +0200 Subject: [PATCH 50/83] Keep transcript output visible past broken control strings CAN and SUB abort an escape sequence, an ESC that does not begin ST exits a control string, and a second ESC restarts the escape state. An oversized control string is abandoned with its payload reclaimed as plain output, and finalize() reclaims an unterminated string's payload. Previously one truncated OSC silently swallowed all subsequent output, including the failure text the patching loop depends on. --- render_machine/output_normalizer.py | 50 ++++++++++++++++++++++- tests/test_output_normalizer.py | 61 ++++++++++++++++++++++++++--- 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/render_machine/output_normalizer.py b/render_machine/output_normalizer.py index 2a4b3ece..0471539f 100644 --- a/render_machine/output_normalizer.py +++ b/render_machine/output_normalizer.py @@ -87,6 +87,8 @@ def lines(self) -> List[str]: _GROUND, _ESCAPE, _INTERMEDIATE, _CSI, _STRING = range(5) _ESC = 0x1B _BEL = 0x07 +_CAN = 0x18 +_SUB = 0x1A _STRING_INTRODUCERS = frozenset(b"]PX^_") # OSC, DCS, SOS, PM, APC _ESCAPE_INTERMEDIATES = frozenset(b"#%()") # each takes exactly one more byte @@ -142,11 +144,37 @@ def _consume(self, data: bytes, index: int, units: List[bytes]) -> int: while index < length and self._state != _GROUND: byte = data[index] index += 1 + if byte in (_CAN, _SUB): + # CAN and SUB abort a sequence in any state, like a real parser; an + # aborted sequence is discarded, never handed to the parser. + self._reset() + continue + if self._state == _STRING and self._after_escape and byte != 0x5C: + # Only ESC \ terminates a string, but any other ESC-introduced byte still + # ends it: the ESC begins a new escape sequence, exactly as a real + # parser's exit from its string state does. + self._reset() + self._state = _ESCAPE + self._pending += b"\x1b" + if self._state == _ESCAPE and byte == _ESC and not self._dropping: + # ESC restarts the escape state: the previous ESC led nowhere and is + # dropped, and whatever follows this one is parsed as its own sequence. + self._pending.clear() + self._pending += b"\x1b" + continue if not self._dropping and len(self._pending) >= self._max_sequence_bytes: + self.dropped += 1 + if self._state == _STRING: + # A control string this long is abandoned rather than dropped to a + # terminator that may never come: a stream cut mid-string would + # otherwise swallow the remainder of the transcript. Its payload is + # reclaimed as plain output, so nothing the target wrote is lost. + units.append(bytes(self._pending[2:])) + self._reset() + return index - 1 # Nothing renders a sequence this long, so the rest of it is parsed by # nobody and the buffer that held it is released here. self._dropping = True - self.dropped += 1 self._pending.clear() if not self._dropping: self._pending.append(byte) @@ -156,6 +184,17 @@ def _consume(self, data: bytes, index: int, units: List[bytes]) -> int: self._reset() return index + def flush(self) -> bytes: + """The payload of an unterminated control string, reclaimed as plain output. + + Called at end of stream: a target cut off mid-string never sends the terminator, + and whatever followed the introducer would otherwise vanish from the transcript. + Incomplete sequences of every other kind stay dropped — they carry no payload. + """ + payload = bytes(self._pending[2:]) if self._state == _STRING and not self._dropping else b"" + self._reset() + return payload + def _ends_sequence(self, byte: int) -> bool: if self._state == _ESCAPE: if byte == 0x5B: # [ @@ -354,7 +393,8 @@ def feed(self, data: bytes) -> None: self.parse_failures += 1 def finalize(self) -> None: - """Ends the stream: flushes the parser's decoder. Idempotent. + """Ends the stream: reclaims an unterminated control string's payload as plain + output, then flushes the parser's decoder. Idempotent. A trailing incomplete UTF-8 sequence sits in pyte's incremental decoder until it is finalized, so without this it never reaches the screen and vanishes from the @@ -365,6 +405,12 @@ def finalize(self) -> None: if self._finalized: return self._finalized = True + leftover = self._guard.flush() + if leftover: + try: + self._stream.feed(leftover) + except Exception: + self.parse_failures += 1 decoder = getattr(self._stream, "utf8_decoder", None) if decoder is None: return diff --git a/tests/test_output_normalizer.py b/tests/test_output_normalizer.py index fe023ce6..1441056c 100644 --- a/tests/test_output_normalizer.py +++ b/tests/test_output_normalizer.py @@ -313,8 +313,10 @@ def test_parser_failure_recovery_does_not_depend_on_the_read_boundaries(monkeypa assert whole.parse_failures == split.parse_failures == mid.parse_failures == 1 -def test_an_unterminated_osc_string_is_bounded_and_draining_continues(): - """pyte would hold every byte of it; the guard holds a capped buffer instead.""" +def test_an_unterminated_osc_string_is_bounded_and_its_bytes_stay_visible(): + """pyte would hold every byte of it; the guard caps its buffer and, past the cap, + treats the stream as plain output again — a string cut off mid-write must not + swallow the transcript that follows it.""" normalizer = OutputNormalizer(columns=40, lines=5) normalizer.feed(b"\x1b]0;") for _ in range(200): @@ -323,7 +325,9 @@ def test_an_unterminated_osc_string_is_bounded_and_draining_continues(): assert normalizer._guard.pending_bytes <= MAX_SEQUENCE_BYTES assert normalizer.bounded_sequences == 1 - assert normalizer.text() == "after\n" + text = normalizer.text() + assert text.endswith("after\n") + assert "AAAA" in text # the abandoned string's bytes render instead of vanishing assert len(normalizer._screen.title) <= MAX_SEQUENCE_BYTES @@ -341,7 +345,9 @@ def test_an_unterminated_csi_parameter_is_bounded_and_draining_continues(): assert normalizer.text() == "still here\n" -def test_a_completed_oversized_osc_string_is_dropped_rather_than_kept_as_metadata(): +def test_an_oversized_osc_string_is_abandoned_and_never_becomes_metadata(): + """Whether its terminator ever arrives cannot be known at the cap, so an oversized + string is reclaimed as plain output either way — it must never grow the title.""" normalizer = OutputNormalizer(columns=40, lines=5) normalizer.feed(b"\x1b]0;short title\x07") normalizer.feed(b"\x1b]0;" + b"B" * (MAX_SEQUENCE_BYTES * 4) + b"\x07") @@ -349,7 +355,7 @@ def test_a_completed_oversized_osc_string_is_dropped_rather_than_kept_as_metadat assert normalizer._screen.title == "short title" assert normalizer.bounded_sequences == 1 - assert normalizer.text() == "work goes on\n" + assert normalizer.text().endswith("work goes on\n") def test_repeated_combining_marks_do_not_grow_one_cell_without_bound(): @@ -391,3 +397,48 @@ def test_finalizing_a_complete_stream_changes_nothing(): def test_fed_bytes_counts_every_byte_handed_to_the_parser(): raw = read_fixture("spinner.raw") assert normalize(raw).fed_bytes == len(raw) + + +def test_a_truncated_control_string_does_not_swallow_the_output_after_it(): + """A tool killed mid-title-write must not blind the transcript: whatever followed the + unterminated introducer is reclaimed as plain output when the stream ends.""" + normalizer = OutputNormalizer(columns=120, lines=5) + normalizer.feed(b"start\r\n") + normalizer.feed(b"\x1b]0;title") # cut off before its terminator ever arrives + normalizer.feed(b"FAILED: assertion xyz\r\n") + normalizer.finalize() + + text = normalizer.text() + assert "start" in text + assert "FAILED: assertion xyz" in text + + +def test_can_aborts_a_control_string_and_rendering_resumes(): + normalizer = OutputNormalizer(columns=40, lines=5) + normalizer.feed(b"\x1b]0;half a title\x18after\r\n") + + assert normalizer._screen.title == "" # an aborted string is discarded, not dispatched + assert normalizer.text() == "after\n" + + +def test_an_escape_that_is_not_a_terminator_ends_the_string_and_starts_a_sequence(): + normalizer = OutputNormalizer(columns=40, lines=5) + normalizer.feed(b"\x1b]0;half a title\x1b[31mred text\r\n") + + assert normalizer._screen.title == "" # the string was exited, never dispatched + assert normalizer.text() == "red text\n" + + +def test_a_second_escape_restarts_the_sequence_rather_than_corrupting_it(): + normalizer = OutputNormalizer(columns=40, lines=5) + normalizer.feed(b"\x1b\x1b[31mred\r\n") # the first ESC led nowhere + + assert normalizer.text() == "red\n" + + +def test_an_escape_pair_inside_a_string_exits_it_and_the_next_sequence_still_parses(): + normalizer = OutputNormalizer(columns=40, lines=5) + normalizer.feed(b"\x1b]0;discard\x1b\x1b[31mred\r\n") + + assert normalizer._screen.title == "" + assert normalizer.text() == "red\n" From ca078378f1297531b92f553c40648fc55fc8093b Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 16:43:21 +0200 Subject: [PATCH 51/83] Report missing build commits before a broken tests repo can mask them A broken conformance-tests repository raised a raw GitPython error before the actionable missing-functionality error for the build repo could be raised. The tests-repo query now propagates only when the build repo has nothing missing. --- plain_modules.py | 14 +++++++++++--- tests/test_plain_modules.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/plain_modules.py b/plain_modules.py index 26a57c7a..242a26f6 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -308,9 +308,17 @@ def _raise_for_missing_frid_commits( ) missing_in_tests = set() if render_conformance_tests: - missing_in_tests = set( - git_utils.frids_missing_commits(self.module_conformance_tests_folder, previous_frids, self.module_name) - ) + try: + missing_in_tests = set( + git_utils.frids_missing_commits( + self.module_conformance_tests_folder, previous_frids, self.module_name + ) + ) + except Exception: + # A broken tests repo must not mask the actionable build-repo error below; + # with nothing missing in the build repo it is a real failure and propagates. + if not missing_in_build: + raise for frid in previous_frids: if frid in missing_in_build: diff --git a/tests/test_plain_modules.py b/tests/test_plain_modules.py index 732d9b35..fc3a275f 100644 --- a/tests/test_plain_modules.py +++ b/tests/test_plain_modules.py @@ -527,3 +527,38 @@ def test_reconcile_metadata_with_git_no_metadata_is_noop(solo_module): solo_module.reconcile_metadata_with_git() assert solo_module.load_module_metadata() is None + + +# -------------------------------------------------------------------------- +# _raise_for_missing_frid_commits +# -------------------------------------------------------------------------- + + +def test_a_broken_tests_repo_does_not_mask_the_missing_build_commit_error(solo_module, monkeypatch): + """The actionable --render-from guidance wins over a raw failure from the tests repo.""" + import git_utils + from plain2code_exceptions import MissingPreviousFunctionalitiesError + + def fake_missing(folder, frids, module_name): + if folder == solo_module.module_build_folder: + return ["1"] + raise RuntimeError("the conformance tests repository is broken") + + monkeypatch.setattr(git_utils, "frids_missing_commits", fake_missing) + + with pytest.raises(MissingPreviousFunctionalitiesError): + solo_module._raise_for_missing_frid_commits(["1"], "2", render_conformance_tests=True) + + +def test_a_broken_tests_repo_with_a_healthy_build_repo_still_fails(solo_module, monkeypatch): + import git_utils + + def fake_missing(folder, frids, module_name): + if folder == solo_module.module_build_folder: + return [] + raise RuntimeError("the conformance tests repository is broken") + + monkeypatch.setattr(git_utils, "frids_missing_commits", fake_missing) + + with pytest.raises(RuntimeError, match="conformance tests repository"): + solo_module._raise_for_missing_frid_commits(["1"], "2", render_conformance_tests=True) From 4f4236075e34780af2d96ef83bea79e7b6b17195 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 16:43:34 +0200 Subject: [PATCH 52/83] Let an observed exit win over a coincident deadline or reply failure A script exiting within the last poll interval of its budget was published as a 124 timeout: the same poll recorded both the exit and the expired deadline and the rank table let the timeout win. A reply discarded by teardown itself after a clean exit converted exit 0 into an environment error; the escalation now requires a failing exit, since a passing one proves the reply did not matter. --- render_machine/render_utils.py | 22 +++++++++++++++++----- tests/test_render_utils.py | 11 ++++++++--- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/render_machine/render_utils.py b/render_machine/render_utils.py index df3c968d..a5442c20 100644 --- a/render_machine/render_utils.py +++ b/render_machine/render_utils.py @@ -149,7 +149,10 @@ def _await_target( outcome.target_exited(returncode) if stop_event is not None and stop_event.is_set(): outcome.cancelled() - if time.monotonic() >= deadline: + # An exit observed by this same poll wins over the expired deadline: the target had + # already finished on its own before anything acted on the timeout, however late + # the poll that noticed it ran. + if returncode is None and time.monotonic() >= deadline: outcome.timed_out() pump_failure = process.infrastructure_failure() if pump_failure is not None: @@ -392,10 +395,13 @@ def execute_script( result = _publish_environment_error( script, script_type, "the script's exit status was never observed", execution.output ) - elif execution.reply_failed: - # The pumps were healthy and the script exited normally, but a reply it was - # waiting for never reached it — so its exit status describes a run that did not - # get the terminal it asked for. + elif outcome.exit_code != 0 and execution.reply_failed: + # The pumps were healthy but a reply the script asked for never reached it, so a + # failing exit status may describe a run that did not get the terminal it asked + # for — an environment failure, never handed to the patcher. A passing exit is + # published normally: the script succeeded without the reply, so the reply did not + # matter — teardown itself discards replies admitted in a final output burst, and + # that must not turn a green run into an aborted render. result = _publish_environment_error( script, script_type, @@ -403,6 +409,12 @@ def execute_script( execution.output, ) else: + if execution.reply_failed: + console.debug( + f"terminal replies the {script_type} script asked for were not delivered " + f"({execution.reply_detail}); the script exited 0 regardless", + color=MUTED_COLOR, + ) result = _publish_exit(script, script_type, outcome.exit_code, execution.output, elapsed_time, frid, module) _store_raw_output(script_type, execution.raw_output, result[2]) diff --git a/tests/test_render_utils.py b/tests/test_render_utils.py index 1e087bc9..7fa1a383 100644 --- a/tests/test_render_utils.py +++ b/tests/test_render_utils.py @@ -491,7 +491,9 @@ def _install(**kwargs): # name, backend kwargs, stop_event set, timeout, expected exit code ("the deadline alone", {}, False, 0, render_utils.TIMEOUT_ERROR_EXIT_CODE), ("the deadline with a reader failure", {"reader_fails_on_close": True}, False, 0, ENVIRONMENT_ERROR_EXIT_CODE), - ("the deadline with an exit observed in the same poll", {"exit_code": 3}, False, 0, 124), + # An exit observed in the same poll as the expired deadline wins: the target had + # already finished on its own before anything acted on the timeout. + ("the deadline with an exit observed in the same poll", {"exit_code": 3}, False, 0, 3), ("a cancellation alone", {}, True, 30, RAISES_CANCELLED), ("a cancellation with a query failure", {"reply_failed": True}, True, 30, RAISES_CANCELLED), ("a cancellation with a reader failure", {"reader_fails_on_close": True}, True, 30, ENVIRONMENT_ERROR_EXIT_CODE), @@ -505,12 +507,14 @@ def _install(**kwargs): 30, ENVIRONMENT_ERROR_EXIT_CODE, ), + # A passing exit is published even when a reply failed delivery: the script succeeded + # without it, and teardown itself discards replies admitted in a final output burst. ( "a zero exit with a query failure", {"exit_code": 0, "reply_failed": True}, False, 30, - ENVIRONMENT_ERROR_EXIT_CODE, + 0, ), ( "a launch failure", @@ -558,7 +562,8 @@ def test_a_reader_failure_during_teardown_names_the_reader(injected_backend, run def test_an_undeliverable_reply_names_the_query_that_went_unanswered(injected_backend, run_script): - injected_backend(exit_code=0, reply_failed=True) + """Escalated only on a failing exit: a passing one proves the reply did not matter.""" + injected_backend(exit_code=3, reply_failed=True) exit_code, issue, _ = run_script(FAKE_SCRIPT, [], SCRIPT_TYPE, timeout=30) From 9f182a5f6c57469e3c10040fcc70587a12500f3f Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 16:43:34 +0200 Subject: [PATCH 53/83] Scale the render shutdown wait to a live script The full teardown budget is waited out only while execute_script() holds a terminal backend. Without one the render thread is typically parked in an API call no cancellation reaches, so the CLI now gives up after two seconds instead of holding the exiting user for the budget. --- plain2code.py | 42 ++++++++++++++++++++--------- render_machine/render_utils.py | 49 +++++++++++++++++++++++++++------- tests/test_plain2code.py | 42 +++++++++++++++++++++++++++++ tests/test_render_utils.py | 20 ++++++++++++++ 4 files changed, 131 insertions(+), 22 deletions(-) diff --git a/plain2code.py b/plain2code.py index 80ec3e47..f28f3bad 100644 --- a/plain2code.py +++ b/plain2code.py @@ -51,6 +51,7 @@ ) from plain2code_state import RunState from plain2code_telemetry import capture_crash, initialize_telemetry +from render_machine import render_utils from render_machine.terminal_process import teardown_budget_seconds from system_config import system_config from tui.plain2code_tui import Plain2CodeTUI @@ -66,6 +67,11 @@ RENDER_THREAD_UNWIND_MARGIN_SECONDS = 1.0 RENDER_THREAD_SHUTDOWN_TIMEOUT = teardown_budget_seconds() + RENDER_THREAD_UNWIND_MARGIN_SECONDS +# The wait while no script is running. A render thread that is only unwinding Python and +# HTTP state owns no processes, so the full teardown budget above would hold the exiting +# CLI for it — most visibly when the user quits mid-API-call. +RENDER_THREAD_IDLE_SHUTDOWN_TIMEOUT = 2.0 + # Exceptions that represent expected, user-facing error conditions. They are # reported to the user directly and must never be sent to Sentry as crashes. EXPECTED_EXCEPTIONS = ( @@ -199,21 +205,33 @@ def warn_if_acceptance_tests_without_conformance_script(plain_module, args) -> N def shutdown_render_thread(render_thread: threading.Thread, stop_event: threading.Event) -> bool: """Cancels the render and waits for the thread to finish tearing its script down. - Returns True when the thread completed within the bound. A thread still running past - it is reported rather than waited on further: the wait covers every budget the backend - can spend, so anything beyond it is unbounded and the process must not hang on it. + Returns True when the thread completed within its bound. The full teardown budget is + only waited out while a script's terminal backend is live; a thread that runs no script + gets the short bound, because it is typically parked in an API call that no cancellation + reaches. A thread still running past its bound is reported rather than waited on + further: anything beyond the bound is unbounded and the process must not hang on it. """ stop_event.set() if render_thread.is_alive(): - console.info("Stopping the render. Waiting for the running script to shut down...") - render_thread.join(timeout=RENDER_THREAD_SHUTDOWN_TIMEOUT) - if render_thread.is_alive(): - console.warning( - f"The render did not stop within {RENDER_THREAD_SHUTDOWN_TIMEOUT:.0f} seconds. " - "A script it started may still be running." - ) - return False - return True + console.info("Stopping the render...") + render_thread.join(timeout=RENDER_THREAD_IDLE_SHUTDOWN_TIMEOUT) + if not render_thread.is_alive(): + return True + if render_utils.terminal_script_active(): + console.info("Waiting for the running script to shut down...") + render_thread.join(timeout=RENDER_THREAD_SHUTDOWN_TIMEOUT) + if render_thread.is_alive(): + console.warning( + f"The render did not stop within {RENDER_THREAD_SHUTDOWN_TIMEOUT:.0f} seconds. " + "A script it started may still be running." + ) + return False + return True + console.warning( + f"The render did not stop within {RENDER_THREAD_IDLE_SHUTDOWN_TIMEOUT:.0f} seconds. " + "No script is running; the render is likely waiting on a network call and will not outlive the process." + ) + return False def render( # noqa: C901 diff --git a/render_machine/render_utils.py b/render_machine/render_utils.py index a5442c20..3601d6a0 100644 --- a/render_machine/render_utils.py +++ b/render_machine/render_utils.py @@ -42,6 +42,30 @@ CONDITION_INFRASTRUCTURE: 3, } +# How many script executions currently own a terminal backend. A caller waiting for the +# render thread to stop derives its wait from this: the full teardown budget applies only +# while a backend still owns processes and handles. +_active_scripts_lock = threading.Lock() +_active_script_count = 0 + + +def terminal_script_active() -> bool: + """True while any execute_script() call holds a live terminal backend.""" + with _active_scripts_lock: + return _active_script_count > 0 + + +def _script_started() -> None: + global _active_script_count + with _active_scripts_lock: + _active_script_count += 1 + + +def _script_finished() -> None: + global _active_script_count + with _active_scripts_lock: + _active_script_count -= 1 + def revert_changes_for_frid(render_context): if render_context.frid_context.frid is not None: @@ -219,18 +243,23 @@ def _run_script(cmd: list[str], script_timeout: float, stop_event: Optional[thre _record_backend_failure(outcome, exc, "while being created") if process is None: return execution + _script_started() try: - process.spawn(cmd, stop_event=stop_event, input_driver=INPUT_DRIVER) - _await_target(process, script_timeout, stop_event, outcome) - except RenderCancelledError: - outcome.cancelled() - except Exception as exc: - # Recorded here rather than around the teardown, so the failure that ended the run - # is the one that explains the outcome and a teardown diagnostic can only follow it. - _record_backend_failure(outcome, exc, "while running the script") + try: + process.spawn(cmd, stop_event=stop_event, input_driver=INPUT_DRIVER) + _await_target(process, script_timeout, stop_event, outcome) + except RenderCancelledError: + outcome.cancelled() + except Exception as exc: + # Recorded here rather than around the teardown, so the failure that ended the + # run is the one that explains the outcome and a teardown diagnostic can only + # follow it. + _record_backend_failure(outcome, exc, "while running the script") + finally: + _teardown(process, outcome) + _collect_backend_state(process, execution) finally: - _teardown(process, outcome) - _collect_backend_state(process, execution) + _script_finished() return execution diff --git a/tests/test_plain2code.py b/tests/test_plain2code.py index bdf5c1e7..f32b2137 100644 --- a/tests/test_plain2code.py +++ b/tests/test_plain2code.py @@ -173,3 +173,45 @@ def test_shutdown_stays_bounded_when_the_teardown_never_completes(monkeypatch): finally: release.set() render_thread.join(timeout=WEDGED_THREAD_TIMEOUT) + + +def test_shutdown_waits_the_full_budget_only_while_a_script_is_active(monkeypatch): + """A live terminal backend earns the teardown budget; without one the short bound + applies, so quitting mid-API-call does not hold the exiting CLI.""" + monkeypatch.setattr(plain2code.render_utils, "terminal_script_active", lambda: True) + stop_event = threading.Event() + torn_down = threading.Event() + + def render_then_tear_down(): + stop_event.wait(timeout=WEDGED_THREAD_TIMEOUT) + time.sleep(plain2code.RENDER_THREAD_IDLE_SHUTDOWN_TIMEOUT + 0.5) + torn_down.set() + + render_thread = threading.Thread(target=render_then_tear_down, daemon=True) + render_thread.start() + + with patch("plain2code.console"): + completed = plain2code.shutdown_render_thread(render_thread, stop_event) + + assert completed is True + assert torn_down.is_set() + + +def test_shutdown_gives_up_after_the_short_bound_when_no_script_runs(): + release = threading.Event() + render_thread = threading.Thread(target=lambda: release.wait(WEDGED_THREAD_TIMEOUT), daemon=True) + render_thread.start() + + try: + started = time.monotonic() + with patch("plain2code.console") as mock_console: + completed = plain2code.shutdown_render_thread(render_thread, threading.Event()) + elapsed = time.monotonic() - started + + assert completed is False + assert elapsed < plain2code.RENDER_THREAD_SHUTDOWN_TIMEOUT / 2 + warning = mock_console.warning.call_args[0][0] + assert "No script is running" in warning + finally: + release.set() + render_thread.join(timeout=WEDGED_THREAD_TIMEOUT) diff --git a/tests/test_render_utils.py b/tests/test_render_utils.py index 7fa1a383..375ddbd6 100644 --- a/tests/test_render_utils.py +++ b/tests/test_render_utils.py @@ -689,3 +689,23 @@ def test_the_timeout_diagnostic_carries_the_note_of_the_backend_that_ran(injecte assert exit_code == render_utils.TIMEOUT_ERROR_EXIT_CODE assert note in output assert note in Path(output_file).read_text() + + +def test_the_terminal_script_active_flag_spans_spawn_through_teardown(injected_backend, run_script): + """The full shutdown budget is only owed while a backend is live, so the flag must + cover teardown — the phase that budget exists for — and clear once it is done.""" + process = injected_backend(exit_code=0) + seen = {} + original_close = process.close + + def recording_close(): + seen["active_during_teardown"] = render_utils.terminal_script_active() + original_close() + + process.close = recording_close + + assert not render_utils.terminal_script_active() + run_script(FAKE_SCRIPT, [], SCRIPT_TYPE, timeout=30) + + assert seen["active_during_teardown"] is True + assert not render_utils.terminal_script_active() From 7c83f5386972c9b29df880be3f0ecac41d647dbd Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Sun, 16 Aug 2026 16:43:34 +0200 Subject: [PATCH 54/83] State the absent-input note per backend semantics The shared note claimed a script waiting for input runs to the timeout, which is wrong for the backends that hand end-of-file at spawn and steered the patcher toward nonexistent stdin reads. ConPTY, which cannot deliver an end-of-file, states its own note. --- render_machine/_conpty.py | 14 ++++++-------- render_machine/terminal_process.py | 10 +++++----- tests/test_conpty.py | 6 ++++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/render_machine/_conpty.py b/render_machine/_conpty.py index 64d5bd3f..ae454e83 100644 --- a/render_machine/_conpty.py +++ b/render_machine/_conpty.py @@ -56,9 +56,6 @@ DRAIN_DEADLINE_SECONDS, GRACE_TICK_SECONDS, HANDSHAKE_TIMEOUT_SECONDS, -) -from render_machine.terminal_process import NO_INPUT_NOTE as DEFAULT_NO_INPUT_NOTE -from render_machine.terminal_process import ( OWNER_PARENT, OWNER_READER, POLL_INTERVAL_SECONDS, @@ -162,11 +159,12 @@ # reaches only processes sharing the caller's console and the target is on the pseudoconsole. CONTROL_C_BYTE = b"\x03" -# The absent-input note this backend adds to, stated where the asymmetry is documented: a -# script that reads input blocks until the execution timeout rather than seeing end-of-file. -NO_INPUT_NOTE = DEFAULT_NO_INPUT_NOTE + ( - " On Windows the terminal carries no synthetic end-of-file, so such a script blocks until the " - "timeout instead of reading end-of-file." +# The absent-input note this backend states itself, where the asymmetry is documented: +# unlike the other backends it cannot hand the target end-of-file, so a script that reads +# terminal input really does block until the execution timeout. +NO_INPUT_NOTE = ( + " No input driver was attached to the script's terminal, and on Windows the terminal carries " + "no synthetic end-of-file, so a script that waits for terminal input blocks until the timeout." ) diff --git a/render_machine/terminal_process.py b/render_machine/terminal_process.py index 4abf99be..6ebc57a2 100644 --- a/render_machine/terminal_process.py +++ b/render_machine/terminal_process.py @@ -83,12 +83,12 @@ OWNER_PARENT = "parent" OWNER_READER = "reader" -# What a timeout diagnostic says when no input driver was attached. A backend that gives -# the target end-of-file at spawn needs nothing more; ConPTY, which cannot, appends its own -# clause to this one. +# What a timeout diagnostic says when no input driver was attached. A backend that hands +# the target end-of-file at spawn needs nothing more; ConPTY, which cannot deliver an +# end-of-file, states its own note instead. NO_INPUT_NOTE = ( - " No input driver was attached to the script's terminal, so a script that waits for input " - "never receives any and runs to the timeout." + " No input driver was attached to the script's terminal; a script that reads terminal " + "input is handed end-of-file rather than left waiting for it." ) diff --git a/tests/test_conpty.py b/tests/test_conpty.py index 50c424bf..811522d8 100644 --- a/tests/test_conpty.py +++ b/tests/test_conpty.py @@ -382,10 +382,12 @@ def test_windows_selects_the_conpty_backend(monkeypatch): def test_the_backend_notes_that_it_has_no_synthetic_end_of_file(): """The timeout diagnostic asks the backend that ran, so this one has to state the - asymmetry itself: a script reading input blocks instead of seeing end-of-file.""" + asymmetry itself: a script reading input blocks instead of seeing end-of-file — the + base note describes backends that hand end-of-file at spawn, which this one cannot.""" note = ConPtyProcess().no_input_note() - assert note.startswith(NO_INPUT_NOTE) + assert note != NO_INPUT_NOTE + assert "blocks until the timeout" in note assert "end-of-file" in note From bc4608712981a4b7c165b39521bcbbe54f4ada25 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Mon, 17 Aug 2026 15:31:41 +0200 Subject: [PATCH 55/83] fix: emulate ONLCR in the pipe backend's normalizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pipe has no line discipline, so the bare linefeeds a target writes reached the VT renderer untranslated: each line started in the column the previous one ended in, and normalized_output() returned a whitespace staircase — the text the fixing LLM receives under CODEPLAIN_NO_PTY=1. The normalizer now optionally performs the ONLCR translation itself (NL -> CR-NL on the raw byte stream, exactly as the PTY's line discipline does), and the legacy pipe backend opts in. --- render_machine/_legacy_pipe.py | 6 ++++-- render_machine/output_normalizer.py | 12 +++++++++++- tests/test_legacy_pipe.py | 23 +++++++++++++++++++++++ tests/test_output_normalizer.py | 21 +++++++++++++++++++++ 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/render_machine/_legacy_pipe.py b/render_machine/_legacy_pipe.py index b8d9cb1c..34d8fc9d 100644 --- a/render_machine/_legacy_pipe.py +++ b/render_machine/_legacy_pipe.py @@ -88,8 +88,10 @@ def __init__(self) -> None: # target prints is rendered and nothing is ever owed to it. self.query_responder = TerminalQueryResponder() # The parser still reports the queries it sees, so they are accounted for on the - # render-only side rather than silently dropped. - self.normalizer = OutputNormalizer(reply_handler=self.query_responder.answer) + # render-only side rather than silently dropped. A pipe has no line discipline to + # apply ONLCR, so the normalizer performs that translation itself — without it a + # stream of bare linefeeds renders as a whitespace staircase. + self.normalizer = OutputNormalizer(reply_handler=self.query_responder.answer, translate_newlines=True) self._proc: Optional[subprocess.Popen] = None self._reader: Optional[threading.Thread] = None diff --git a/render_machine/output_normalizer.py b/render_machine/output_normalizer.py index 0471539f..18a2aed1 100644 --- a/render_machine/output_normalizer.py +++ b/render_machine/output_normalizer.py @@ -357,7 +357,15 @@ def __init__( head_lines: int = SCROLLBACK_HEAD_LINES, tail_lines: int = SCROLLBACK_TAIL_LINES, reply_handler: Optional[Callable[[str, bytes], None]] = None, + translate_newlines: bool = False, ) -> None: + # A PTY's line discipline turns every NL into CR-NL (ONLCR) before the bytes reach + # a terminal, so a VT parser may treat a bare linefeed as index-only. A pipe has no + # line discipline: fed verbatim, each line would start in the column the previous + # one ended in — a whitespace staircase. A backend whose stream never crossed a + # line discipline asks for the same translation here, exactly as ONLCR performs + # it: on the raw byte stream, with no regard for the sequences it may split. + self._translate_newlines = translate_newlines self._lock = threading.Lock() self._scrollback = _RetainedLines(head_lines, tail_lines) self._screen = _RenderingScreen(columns, lines, self._scrollback, reply_handler) @@ -381,7 +389,9 @@ def feed(self, data: bytes) -> None: if not data: return with self._lock: - self.fed_bytes += len(data) + self.fed_bytes += len(data) # what the target wrote, before any translation + if self._translate_newlines: + data = data.replace(b"\n", b"\r\n") for unit in self._guard.frame(data): try: self._stream.feed(unit) diff --git a/tests/test_legacy_pipe.py b/tests/test_legacy_pipe.py index 8d1e8cf2..2f298d59 100644 --- a/tests/test_legacy_pipe.py +++ b/tests/test_legacy_pipe.py @@ -134,6 +134,29 @@ def test_raw_bytes_are_kept_verbatim_and_the_transcript_is_normalized(tmp_path, assert backend.normalized_output() == "red\n" +def test_multiline_pipe_output_is_rendered_without_a_staircase(tmp_path, backend): + """A pipe carries bare linefeeds — no line discipline adds the carriage returns. + + The normalizer is a VT renderer, so feeding it the pipe bytes verbatim would move the + cursor down without returning it to column zero: every line would start where the + previous one ended, padded with the whitespace of a staircase. The backend emulates + ONLCR instead, the same translation the PTY's line discipline applies. + """ + script = make_script( + tmp_path, + "multiline", + """ + import sys + + sys.stdout.write("one\\ntwo\\nthree\\n") + """, + ) + + assert run(backend, [script]) == 0 + + assert backend.normalized_output() == "one\ntwo\nthree\n" + + def test_a_printed_query_is_rendered_without_creating_an_obligation(tmp_path, backend): script = make_script( tmp_path, diff --git a/tests/test_output_normalizer.py b/tests/test_output_normalizer.py index 1441056c..ddd8c07e 100644 --- a/tests/test_output_normalizer.py +++ b/tests/test_output_normalizer.py @@ -212,6 +212,27 @@ def test_crlf_collapses_and_a_trailing_partial_line_is_kept(): assert normalizer.text() == "one\ntwo\nno newline here\n" +def test_translate_newlines_renders_a_pipe_stream_of_bare_linefeeds(): + normalizer = OutputNormalizer(columns=20, lines=5, translate_newlines=True) + normalizer.feed(b"one\ntwo\nthree\n") + + assert normalizer.text() == "one\ntwo\nthree\n" + + +def test_translate_newlines_leaves_a_crlf_stream_unchanged(): + normalizer = OutputNormalizer(columns=20, lines=5, translate_newlines=True) + normalizer.feed(b"one\r\ntwo\r\nno newline here") + + assert normalizer.text() == "one\ntwo\nno newline here\n" + + +def test_translate_newlines_counts_the_bytes_the_target_wrote(): + normalizer = OutputNormalizer(columns=20, lines=5, translate_newlines=True) + normalizer.feed(b"a\nb\n") + + assert normalizer.fed_bytes == 4 + + def test_nothing_fed_renders_to_nothing(): normalizer = OutputNormalizer(columns=20, lines=5) normalizer.feed(b"") From 2bb65b497f7535dc2a620db9c2ee1a13e9360251 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Mon, 17 Aug 2026 16:03:13 +0200 Subject: [PATCH 56/83] feat: platform-test runtime capability model and REST plumbing (codeplain-tty Phase A) The codeplain-tty plan's Phase A, client side: a typed version-1 capability descriptor (protocol_version + the six broker commands), REST support for an optional platform_test_runtime field on /render_conformance_tests, /fix_conformance_tests_issue and /render_acceptance_tests, and the advertisement gate. The gate returns None until the broker and executable preflight ship, so no request advertises a runtime the client cannot provide and the API stays on its backward-compatible path. --- codeplain_REST_api.py | 12 ++ render_machine/platform_test_runtime.py | 49 ++++++++ tests/test_platform_test_runtime.py | 145 ++++++++++++++++++++++++ 3 files changed, 206 insertions(+) create mode 100644 render_machine/platform_test_runtime.py create mode 100644 tests/test_platform_test_runtime.py diff --git a/codeplain_REST_api.py b/codeplain_REST_api.py index 341fcc17..1d089c24 100644 --- a/codeplain_REST_api.py +++ b/codeplain_REST_api.py @@ -321,6 +321,7 @@ def render_conformance_tests( conformance_tests_json, all_acceptance_tests, run_state: RunState, + platform_test_runtime: Optional[dict] = None, ): endpoint_url = f"{self.api_url}/render_conformance_tests" headers = {"X-API-Key": self.api_key, "Content-Type": "application/json"} @@ -339,6 +340,9 @@ def render_conformance_tests( "all_acceptance_tests": all_acceptance_tests, } + if platform_test_runtime is not None: + payload["platform_test_runtime"] = platform_test_runtime + response = self.post_request(endpoint_url, headers, payload, run_state) return response["patched_response_files"], response["conformance_tests_plan_summary_string"] @@ -382,6 +386,7 @@ def fix_conformance_tests_issue( current_testing_frid_high_level_implementation_plan: Optional[str], conflicting_requirements_count: int, run_state: RunState, + platform_test_runtime: Optional[dict] = None, ): endpoint_url = f"{self.api_url}/fix_conformance_tests_issue" headers = {"X-API-Key": self.api_key, "Content-Type": "application/json"} @@ -408,6 +413,9 @@ def fix_conformance_tests_issue( if acceptance_tests is not None: payload["acceptance_tests"] = acceptance_tests + if platform_test_runtime is not None: + payload["platform_test_runtime"] = platform_test_runtime + return self.post_request(endpoint_url, headers, payload, run_state) def render_acceptance_tests( @@ -422,6 +430,7 @@ def render_acceptance_tests( required_modules, acceptance_test, run_state: RunState, + platform_test_runtime: Optional[dict] = None, ): """ Renders acceptance tests based on the provided parameters. @@ -461,6 +470,9 @@ def render_acceptance_tests( "acceptance_test": acceptance_test, } + if platform_test_runtime is not None: + payload["platform_test_runtime"] = platform_test_runtime + return self.post_request(endpoint_url, headers, payload, run_state) def analyze_rendering( diff --git a/render_machine/platform_test_runtime.py b/render_machine/platform_test_runtime.py new file mode 100644 index 00000000..5b85977c --- /dev/null +++ b/render_machine/platform_test_runtime.py @@ -0,0 +1,49 @@ +"""The platform-test runtime capability the client advertises to the API. + +The `codeplain-tty` helper lets a generated conformance or acceptance test drive the +target through its controlling terminal. The API injects instructions about the helper +only into requests that advertise this capability, so the descriptor here is a contract: +protocol version 1 and exactly the commands the client's broker implements. Support is +never derived from a client version — a client advertises only after its broker and +executable pass a local preflight, which is why the gate below is separate from the +descriptor it guards. + +The descriptor carries no secrets. The broker endpoint, its authentication token, and +every filesystem or pipe name stay client-side, scoped to one script execution. +""" + +from typing import Optional + +PROTOCOL_VERSION = 1 + +# The commands protocol version 1 promises. The API rejects a descriptor naming a command +# outside this set, so the tuple changes only together with the protocol version. +CODEPLAIN_TTY_COMMANDS = ( + "wait-for", + "wait-until-absent", + "send-text", + "send-control", + "send-hex", + "size", +) + + +def codeplain_tty_descriptor() -> dict: + """The version-1 capability object, as the request models carry it.""" + return { + "codeplain_tty": { + "protocol_version": PROTOCOL_VERSION, + "commands": list(CODEPLAIN_TTY_COMMANDS), + } + } + + +def advertised_platform_test_runtime() -> Optional[dict]: + """What the client actually advertises: the descriptor, or None while it cannot. + + None keeps the API on its backward-compatible path — no `codeplain-tty` prompt + content is generated. The broker and executable preflight that turns this on ships + with the broker itself; until then the client never advertises a runtime it could + not provide. + """ + return None diff --git a/tests/test_platform_test_runtime.py b/tests/test_platform_test_runtime.py new file mode 100644 index 00000000..f0c4bdd1 --- /dev/null +++ b/tests/test_platform_test_runtime.py @@ -0,0 +1,145 @@ +"""Tests for the platform-test runtime capability the client advertises. + +Phase A of the `codeplain-tty` plan: the descriptor and the REST plumbing exist, but the +client advertises nothing until the broker's preflight lands. What is asserted here is +the contract those later phases build on — the version-1 descriptor shape, the gate +returning None, and the request payloads carrying the capability only when it is given. +""" + +from unittest.mock import MagicMock + +from codeplain_REST_api import CodeplainAPI +from render_machine.platform_test_runtime import ( + CODEPLAIN_TTY_COMMANDS, + PROTOCOL_VERSION, + advertised_platform_test_runtime, + codeplain_tty_descriptor, +) + + +def make_api(recorded): + api = CodeplainAPI(api_key="test-key", console=MagicMock()) + api.api_url = "http://api.invalid" + + def post_request(endpoint_url, headers, payload, run_state): + recorded.append((endpoint_url, payload)) + return {"patched_response_files": [], "conformance_tests_plan_summary_string": ""} + + api.post_request = post_request + return api + + +def render_conformance_tests(api, **kwargs): + return api.render_conformance_tests( + frid="2", + functional_requirement_id="1", + plain_source_tree={}, + linked_resources={}, + existing_files_content={}, + memory_files_content={}, + module_name="module", + required_modules={}, + conformance_tests_folder_name="folder", + conformance_tests_json={}, + all_acceptance_tests=[], + run_state=MagicMock(), + **kwargs, + ) + + +def test_the_descriptor_is_the_version_1_contract(): + descriptor = codeplain_tty_descriptor() + + assert descriptor == { + "codeplain_tty": { + "protocol_version": PROTOCOL_VERSION, + "commands": list(CODEPLAIN_TTY_COMMANDS), + } + } + assert PROTOCOL_VERSION == 1 + assert descriptor["codeplain_tty"]["commands"] == [ + "wait-for", + "wait-until-absent", + "send-text", + "send-control", + "send-hex", + "size", + ] + + +def test_nothing_is_advertised_before_the_broker_preflight_exists(): + assert advertised_platform_test_runtime() is None + + +def test_the_capability_is_omitted_from_the_payload_by_default(): + recorded = [] + api = make_api(recorded) + + render_conformance_tests(api) + + _, payload = recorded[0] + assert "platform_test_runtime" not in payload + + +def test_the_capability_is_sent_when_provided(): + recorded = [] + api = make_api(recorded) + + render_conformance_tests(api, platform_test_runtime=codeplain_tty_descriptor()) + + _, payload = recorded[0] + assert payload["platform_test_runtime"] == codeplain_tty_descriptor() + + +def test_fix_conformance_tests_issue_carries_the_capability_when_provided(): + recorded = [] + api = make_api(recorded) + api.post_request = lambda endpoint_url, headers, payload, run_state: recorded.append((endpoint_url, payload)) or [] + + api.fix_conformance_tests_issue( + frid="2", + functional_requirement_id="1", + plain_source_tree={}, + linked_resources={}, + existing_files_content={}, + memory_files_content={}, + module_name="module", + conformance_tests_module_name="module", + required_modules={}, + code_diff={}, + conformance_tests_files={}, + acceptance_tests=None, + conformance_tests_issue="issue", + implementation_fix_count=0, + conformance_tests_folder_name="folder", + current_testing_frid_high_level_implementation_plan=None, + conflicting_requirements_count=0, + run_state=MagicMock(), + platform_test_runtime=codeplain_tty_descriptor(), + ) + + _, payload = recorded[0] + assert payload["platform_test_runtime"] == codeplain_tty_descriptor() + + +def test_render_acceptance_tests_carries_the_capability_when_provided(): + recorded = [] + api = make_api(recorded) + api.post_request = lambda endpoint_url, headers, payload, run_state: recorded.append((endpoint_url, payload)) or {} + + api.render_acceptance_tests( + frid="2", + plain_source_tree={}, + linked_resources={}, + existing_files_content={}, + memory_files_content={}, + conformance_tests_files={}, + module_name="module", + required_modules={}, + acceptance_test="test", + run_state=MagicMock(), + platform_test_runtime=codeplain_tty_descriptor(), + ) + + _, payload = recorded[0] + assert payload["platform_test_runtime"] == codeplain_tty_descriptor() From e93f7844f0e1d536bc9438c6f869e4e488f929a6 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Mon, 17 Aug 2026 16:18:02 +0200 Subject: [PATCH 57/83] feat: codeplain-tty broker, helper CLI, and typed input-driver contract (Phase B, POSIX) The deferred language-neutral input driver from the codeplain-tty plan. A per-execution broker (Unix-domain socket in a fresh 0700 directory, constant- time token auth, length-framed versioned protocol, every wait and send bounded) exposes the renderer-owned transcript and the terminal backend's ordered input queue to a codeplain-tty helper the broker installs per execution, so the same executable serves a source checkout, an editable install, and a built wheel. The helper implements wait-for, wait-until-absent, send-text (typing semantics: newlines become CR), send-control, send-hex, and size, with exit codes matching the testing-script conventions (69 = runtime unavailable). Supporting backend changes: a typed TerminalInputDriver contract replaces the Optional[object] marker (an attached driver suppresses the spawn-time VEOF, which getpass/curses TCSAFLUSH would discard anyway); runtime resize lands on the POSIX backend under the reader bundle's ownership lock (TIOCSWINSZ on the master also raises SIGWINCH); the pipe backend resizes only its renderer; the ConPTY backend states resize as unimplemented rather than lying; and the no-input timeout note now describes the spawn-time EOF as the best effort it is. End-to-end tests drive real interactive targets on the PTY backend through the installed helper: the motivating getpass/TCSAFLUSH reproduction, plain input(), Ctrl-D as EOF, and a SIGWINCH-observed resize. Windows named-pipe transport and ConPTY resize are tracked as pending; the capability is simply not advertised there. --- codeplain_tty.py | 137 ++++++++++ pyproject.toml | 1 + render_machine/_conpty.py | 12 +- render_machine/_legacy_pipe.py | 7 +- render_machine/_posix_pty.py | 31 ++- render_machine/render_utils.py | 10 +- render_machine/terminal_process.py | 33 ++- render_machine/tty_broker.py | 286 +++++++++++++++++++++ render_machine/tty_protocol.py | 150 +++++++++++ tests/test_tty_broker.py | 385 +++++++++++++++++++++++++++++ 10 files changed, 1038 insertions(+), 14 deletions(-) create mode 100644 codeplain_tty.py create mode 100644 render_machine/tty_broker.py create mode 100644 render_machine/tty_protocol.py create mode 100644 tests/test_tty_broker.py diff --git a/codeplain_tty.py b/codeplain_tty.py new file mode 100644 index 00000000..f9968386 --- /dev/null +++ b/codeplain_tty.py @@ -0,0 +1,137 @@ +"""`codeplain-tty` — the terminal-automation helper for Codeplain's internal tests. + +Available on PATH only while Codeplain runs its own conformance and acceptance tests. A +generated test invokes it to drive the tested process through its controlling terminal: +wait for a prompt to appear in the transcript, type text, press a control key, send +exact bytes, or resize the terminal. It talks to a private per-execution broker over the +endpoint named in the environment; outside a Codeplain test run those variables do not +exist and the helper reports the runtime as unavailable. + +Exit codes: 0 success; 1 the command ran and did not succeed (a wait that timed out, +input the target no longer accepts); 2 usage error; 69 the runtime itself is +unavailable (missing environment, unreachable broker, protocol mismatch). +""" + +import argparse +import os +import socket +import sys +from typing import Optional + +from render_machine import tty_protocol + +# How much longer than the command's own deadline the helper waits for the response +# frame, so a broker-side wait always resolves before the client gives up on it. +RESPONSE_MARGIN_SECONDS = 10.0 + +_ERROR_EXIT_CODES = { + tty_protocol.ERROR_TIMEOUT: tty_protocol.EXIT_COMMAND_FAILED, + tty_protocol.ERROR_INPUT_CLOSED: tty_protocol.EXIT_COMMAND_FAILED, + tty_protocol.ERROR_BACKPRESSURE: tty_protocol.EXIT_COMMAND_FAILED, + tty_protocol.ERROR_UNSUPPORTED: tty_protocol.EXIT_COMMAND_FAILED, + tty_protocol.ERROR_INVALID_REQUEST: tty_protocol.EXIT_USAGE, + tty_protocol.ERROR_UNAUTHORIZED: tty_protocol.EXIT_RUNTIME_UNAVAILABLE, + tty_protocol.ERROR_SHUTTING_DOWN: tty_protocol.EXIT_RUNTIME_UNAVAILABLE, + tty_protocol.ERROR_INTERNAL: tty_protocol.EXIT_RUNTIME_UNAVAILABLE, +} + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="codeplain-tty", + description="Drive the terminal of a process under Codeplain's internal functional tests.", + ) + commands = parser.add_subparsers(dest="command", required=True) + + wait_for = commands.add_parser(tty_protocol.COMMAND_WAIT_FOR, help="wait until TEXT appears in the transcript") + wait_for.add_argument("text") + wait_for.add_argument("--timeout", type=float, default=30.0, help="seconds to wait (default: 30)") + + wait_absent = commands.add_parser( + tty_protocol.COMMAND_WAIT_UNTIL_ABSENT, help="wait until TEXT is no longer in the transcript" + ) + wait_absent.add_argument("text") + wait_absent.add_argument("--timeout", type=float, default=30.0, help="seconds to wait (default: 30)") + + send_text = commands.add_parser( + tty_protocol.COMMAND_SEND_TEXT, + help="type TEXT into the terminal (newlines are typed as the Enter key)", + ) + send_text.add_argument("text") + + send_control = commands.add_parser( + tty_protocol.COMMAND_SEND_CONTROL, help="press Ctrl-KEY (e.g. 'd' for Ctrl-D, 'c' for Ctrl-C)" + ) + send_control.add_argument("key") + + send_hex = commands.add_parser(tty_protocol.COMMAND_SEND_HEX, help="send exact bytes, hex-encoded") + send_hex.add_argument("hex") + + size = commands.add_parser(tty_protocol.COMMAND_SIZE, help="resize the terminal") + size.add_argument("columns", type=int) + size.add_argument("rows", type=int) + + return parser + + +def _request_args(options: argparse.Namespace) -> dict: + if options.command in (tty_protocol.COMMAND_WAIT_FOR, tty_protocol.COMMAND_WAIT_UNTIL_ABSENT): + return {"text": options.text, "timeout": options.timeout} + if options.command == tty_protocol.COMMAND_SEND_TEXT: + return {"text": options.text} + if options.command == tty_protocol.COMMAND_SEND_CONTROL: + return {"key": options.key} + if options.command == tty_protocol.COMMAND_SEND_HEX: + return {"hex": options.hex} + return {"columns": options.columns, "rows": options.rows} + + +def _response_deadline(options: argparse.Namespace) -> float: + timeout = getattr(options, "timeout", 0.0) or 0.0 + return timeout + RESPONSE_MARGIN_SECONDS + + +def _fail(message: str, exit_code: int) -> int: + print(f"codeplain-tty: {message}", file=sys.stderr) + return exit_code + + +def run(argv: Optional[list] = None) -> int: + options = build_parser().parse_args(argv) + + endpoint = os.environ.get(tty_protocol.ENDPOINT_ENV_VAR) + token = os.environ.get(tty_protocol.TOKEN_ENV_VAR) + if not endpoint or not token: + return _fail( + "the Codeplain platform-test runtime is not available here " + f"({tty_protocol.ENDPOINT_ENV_VAR} is not set)", + tty_protocol.EXIT_RUNTIME_UNAVAILABLE, + ) + if not hasattr(socket, "AF_UNIX"): + return _fail("this platform's transport is not supported yet", tty_protocol.EXIT_RUNTIME_UNAVAILABLE) + + request = tty_protocol.request(token, options.command, _request_args(options)) + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(_response_deadline(options)) + connection.connect(endpoint) + connection.sendall(tty_protocol.encode_frame(request)) + response = tty_protocol.read_frame(connection.recv) + except (OSError, tty_protocol.ProtocolError) as exc: + return _fail(f"could not reach the test runtime broker: {exc}", tty_protocol.EXIT_RUNTIME_UNAVAILABLE) + + if response is None: + return _fail("the broker closed the connection without answering", tty_protocol.EXIT_RUNTIME_UNAVAILABLE) + if response.get("ok") is True: + return tty_protocol.EXIT_OK + error = str(response.get("error")) + message = response.get("message", "the command failed") + return _fail(f"{options.command}: {message}", _ERROR_EXIT_CODES.get(error, tty_protocol.EXIT_RUNTIME_UNAVAILABLE)) + + +def main() -> None: + sys.exit(run()) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 710db10d..de617621 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dev = [ [project.scripts] codeplain = "plain2code:main" +codeplain-tty = "codeplain_tty:main" # Derive the version from the git tag (e.g. v0.3.8 -> 0.3.8). The version is # baked into the package metadata at build time; system_config.py reads it back diff --git a/render_machine/_conpty.py b/render_machine/_conpty.py index ae454e83..4277e7f6 100644 --- a/render_machine/_conpty.py +++ b/render_machine/_conpty.py @@ -66,7 +66,9 @@ TERMINAL_ROWS, InputWriteResult, TerminalEnvironmentError, + TerminalInputDriver, TerminalProcess, + TerminalProcessError, terminal_child_environment, ) from render_machine.terminal_queries import TerminalQueryResponder, reply_resolution @@ -1068,7 +1070,7 @@ def spawn( env: Optional[dict] = None, terminal_size: Tuple[int, int] = (TERMINAL_COLUMNS, TERMINAL_ROWS), stop_event: Optional[threading.Event] = None, - input_driver: Optional[object] = None, + input_driver: Optional[TerminalInputDriver] = None, spawn_timeout: float = HANDSHAKE_TIMEOUT_SECONDS, ) -> None: if self._spawned: @@ -1100,6 +1102,14 @@ def write_input(self, data: bytes) -> InputWriteResult: result, _ = self._input_queue.submit(data) return result + def resize(self, columns: int, rows: int) -> None: + """Not implemented yet: needs a ResizePseudoConsole binding on the live session. + + Raising keeps the failure explicit — a silent normalizer-only resize would tell + the caller the target saw a size it never received. + """ + raise TerminalProcessError("runtime terminal resize is not implemented on the ConPTY backend yet") + def no_input_note(self) -> str: return NO_INPUT_NOTE diff --git a/render_machine/_legacy_pipe.py b/render_machine/_legacy_pipe.py index 34d8fc9d..f14cc9ec 100644 --- a/render_machine/_legacy_pipe.py +++ b/render_machine/_legacy_pipe.py @@ -44,6 +44,7 @@ TERMINAL_ROWS, InputDisposition, InputWriteResult, + TerminalInputDriver, TerminalLaunchError, TerminalProcess, child_environment, @@ -110,7 +111,7 @@ def spawn( env: Optional[dict] = None, terminal_size: Tuple[int, int] = (TERMINAL_COLUMNS, TERMINAL_ROWS), stop_event: Optional[threading.Event] = None, - input_driver: Optional[object] = None, + input_driver: Optional[TerminalInputDriver] = None, ) -> None: if self._spawned: raise RuntimeError("LegacyPipeProcess instances are single-use") @@ -152,6 +153,10 @@ def write_input(self, data: bytes) -> InputWriteResult: """Always closed: this backend hands the child `DEVNULL`, by design.""" return InputWriteResult(InputDisposition.CLOSED, 0) + def resize(self, columns: int, rows: int) -> None: + """There is no terminal to resize; only the rendering parser follows the size.""" + self.normalizer.resize(columns, rows) + def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: proc = self._proc if proc is None or self._reaped: diff --git a/render_machine/_posix_pty.py b/render_machine/_posix_pty.py index aefac7fb..0a856b14 100644 --- a/render_machine/_posix_pty.py +++ b/render_machine/_posix_pty.py @@ -52,8 +52,10 @@ InputDisposition, InputWriteResult, TerminalEnvironmentError, + TerminalInputDriver, TerminalLaunchError, TerminalProcess, + TerminalProcessError, TerminalReaderError, terminal_child_environment, ) @@ -349,6 +351,18 @@ def _take(self, name: str) -> Optional[int]: def take_master(self) -> Optional[int]: return self._take("master_fd") + def with_master(self, operation) -> bool: + """Runs `operation(master_fd)` while the descriptor cannot be taken from under it. + + The lock is the same one `_take` swaps under, so the descriptor is either still + owned for the whole call or the call never starts. False when it is already gone. + """ + with self._lock: + if self.master_fd is None: + return False + operation(self.master_fd) + return True + def take_wakeup_r(self) -> Optional[int]: return self._take("wakeup_r") @@ -463,7 +477,7 @@ def __init__(self) -> None: self._spawned = False self._closed = False self._acked = False - self._input_driver: Optional[object] = None + self._input_driver: Optional[TerminalInputDriver] = None self._bundle: Optional[_ReaderBundle] = None self._reader: Optional[threading.Thread] = None @@ -499,7 +513,7 @@ def spawn( env: Optional[dict] = None, terminal_size: Tuple[int, int] = (TERMINAL_COLUMNS, TERMINAL_ROWS), stop_event: Optional[threading.Event] = None, - input_driver: Optional[object] = None, + input_driver: Optional[TerminalInputDriver] = None, handshake_timeout: float = HANDSHAKE_TIMEOUT_SECONDS, ) -> None: """Allocates the terminal, launches the target, and returns once it is running.""" @@ -539,6 +553,19 @@ def write_input(self, data: bytes) -> InputWriteResult: self._ring_doorbell() return result + def resize(self, columns: int, rows: int) -> None: + """Applies the new size on the master, which also raises SIGWINCH in the target. + + Issued under the bundle's ownership lock, so the ioctl can never race the reader + closing the descriptor at teardown. + """ + packed = struct.pack("HHHH", rows, columns, 0, 0) + bundle = self._bundle + applied = bundle is not None and bundle.with_master(lambda fd: fcntl.ioctl(fd, termios.TIOCSWINSZ, packed)) + if not applied: + raise TerminalProcessError("the terminal is no longer available to resize") + self.normalizer.resize(columns, rows) + def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: """Signals the recorded group, escalates on the clock, and reaps last. diff --git a/render_machine/render_utils.py b/render_machine/render_utils.py index 3601d6a0..c6d08173 100644 --- a/render_machine/render_utils.py +++ b/render_machine/render_utils.py @@ -11,6 +11,7 @@ from render_machine.terminal_process import ( ENVIRONMENT_ERROR_EXIT_CODE, NO_INPUT_NOTE, + TerminalInputDriver, TerminalProcess, TerminalProcessError, create_terminal_process, @@ -24,10 +25,11 @@ # discoverable from the returned path and cleanable by the same convention. RAW_OUTPUT_SUFFIX = ".raw" -# The `codeplain-tty` broker that would drive a script's terminal input is deferred, so no -# input driver is ever attached. The timeout diagnostic is keyed on this declaration rather -# than on bytes written: a script that blocks on input has written nothing either way. -INPUT_DRIVER: Optional[object] = None +# The `codeplain-tty` broker exists but is not wired into script execution yet — that is +# the runtime-scoping phase of the codeplain-tty plan. Until then no input driver is +# attached. The timeout diagnostic is keyed on this declaration rather than on bytes +# written: a script that blocks on input has written nothing either way. +INPUT_DRIVER: Optional[TerminalInputDriver] = None # Conditions the arbiter chooses between, highest precedence last. CONDITION_EXIT = "exit" diff --git a/render_machine/terminal_process.py b/render_machine/terminal_process.py index 6ebc57a2..6e829a29 100644 --- a/render_machine/terminal_process.py +++ b/render_machine/terminal_process.py @@ -83,12 +83,15 @@ OWNER_PARENT = "parent" OWNER_READER = "reader" -# What a timeout diagnostic says when no input driver was attached. A backend that hands -# the target end-of-file at spawn needs nothing more; ConPTY, which cannot deliver an -# end-of-file, states its own note instead. +# What a timeout diagnostic says when no input driver was attached. The spawn-time +# end-of-file is best-effort by nature: a program that flushes or reconfigures its +# terminal before reading — getpass's TCSAFLUSH, a curses initialization — discards the +# queued byte and then blocks on input nothing will send. ConPTY, which cannot deliver +# an end-of-file at all, states its own note instead. NO_INPUT_NOTE = ( - " No input driver was attached to the script's terminal; a script that reads terminal " - "input is handed end-of-file rather than left waiting for it." + " No input driver was attached to the script's terminal; a single end-of-file was " + "queued at spawn, but a program that flushes or reconfigures its terminal before " + "reading discards it and is left waiting for input that never arrives." ) @@ -106,6 +109,20 @@ class InputWriteResult: accepted_bytes: int +class TerminalInputDriver: + """The typed contract for what a backend accepts as `input_driver`. + + An attached driver is a promise that something will answer the target's terminal + reads for the whole execution, which changes spawn behavior: the POSIX backend does + not queue its spawn-time VEOF. Only an object that keeps that promise — today the + per-execution `codeplain-tty` broker — may implement this. + """ + + def description(self) -> str: + """One clause for diagnostics: what is driving the terminal's input.""" + raise NotImplementedError + + class TerminalProcessError(Exception): """Base class for failures the terminal backend reports to the renderer.""" @@ -155,7 +172,7 @@ def spawn( env: Optional[dict] = None, terminal_size: Tuple[int, int] = (TERMINAL_COLUMNS, TERMINAL_ROWS), stop_event: Optional[threading.Event] = None, - input_driver: Optional[object] = None, + input_driver: Optional[TerminalInputDriver] = None, ) -> None: raise NotImplementedError @@ -216,6 +233,10 @@ def _check_cancelled(self) -> None: def write_input(self, data: bytes) -> InputWriteResult: raise NotImplementedError + def resize(self, columns: int, rows: int) -> None: + """Applies a new terminal size to the live target and the rendering parser.""" + raise NotImplementedError + def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: raise NotImplementedError diff --git a/render_machine/tty_broker.py b/render_machine/tty_broker.py new file mode 100644 index 00000000..0feb5093 --- /dev/null +++ b/render_machine/tty_broker.py @@ -0,0 +1,286 @@ +"""The per-execution broker behind the `codeplain-tty` helper. + +One broker serves one script execution: created after the terminal backend and before +the target is spawned, so its endpoint and token can be placed in the child environment, +and closed before `execute_script()` returns, taking every filesystem artifact with it. +A generated conformance or acceptance test drives the target's terminal through it — +`wait-for` observes the renderer-owned transcript without consuming it, the `send-*` +commands enqueue bytes on the backend's ordered input queue, and `size` resizes the +live terminal. + +Security model: the endpoint is a Unix-domain socket inside a fresh mode-0700 directory, +and every request must carry the per-execution token, compared in constant time. The +token travels only through the scoped child environment — never on a command line, never +in a log line, never in a prompt. Native Windows uses a named pipe transport, which is +not implemented yet; the capability is simply not advertised there. + +Everything a client can make the broker do is bounded: frame sizes by the protocol, +waits by a capped deadline, sends by a delivery deadline, and the accept loop serves one +request at a time so a flood of connections queues in the listener's backlog instead of +growing threads. +""" + +import hmac +import os +import secrets +import shutil +import socket +import sys +import tempfile +import threading +import time +from typing import Optional + +from plain2code_console import console +from render_machine import tty_protocol +from render_machine.terminal_process import ( + InputDisposition, + TerminalInputDriver, + TerminalProcess, + TerminalProcessError, +) + +# The longest a single wait-for / wait-until-absent may block, whatever the client asks +# for. Sits under the script-execution timeout so a test that waits forever fails as a +# test before the whole script is torn down around it. +MAX_WAIT_SECONDS = 110.0 +DEFAULT_WAIT_SECONDS = 30.0 + +# How long a send-* retries around backpressure before reporting it. The input queue +# drains at terminal speed, so sustained backpressure this long means the target stopped +# reading its terminal. +SEND_DEADLINE_SECONDS = 10.0 + +# How long the accept loop lets one client take to deliver its request frame. The +# response side is bounded by the command's own deadline. +REQUEST_READ_TIMEOUT_SECONDS = 5.0 + +POLL_INTERVAL_SECONDS = 0.05 + +# How long close() waits for the server thread after closing the listener under it. +CLOSE_JOIN_SECONDS = 5.0 + + +def broker_supported() -> bool: + """True where the transport exists: Unix-domain sockets on POSIX (and WSL).""" + return sys.platform != "win32" and hasattr(socket, "AF_UNIX") + + +class TtyBroker(TerminalInputDriver): + """One execution's terminal-automation endpoint. Single-use, like the backend it drives.""" + + def __init__(self, process: TerminalProcess) -> None: + self._process = process + self._token = secrets.token_hex(16) + self._closing = threading.Event() + self._server: Optional[threading.Thread] = None + self._listener: Optional[socket.socket] = None + self._directory: Optional[str] = None + self.endpoint: Optional[str] = None + # A directory holding a `codeplain-tty` executable, for prepending to the child's + # PATH. Written per execution so a source checkout, an editable install, and a + # built wheel all resolve the same way, and cleaned up with everything else. + self.helper_bin_dir: Optional[str] = None + + # ------------------------------------------------------------------ lifecycle + + def start(self) -> None: + if not broker_supported(): + raise TerminalProcessError("the codeplain-tty broker transport is not available on this platform") + if self._server is not None: + raise RuntimeError("TtyBroker instances are single-use") + self._directory = tempfile.mkdtemp(prefix="codeplain-tty-") + os.chmod(self._directory, 0o700) + self.endpoint = os.path.join(self._directory, "broker.sock") + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + listener.bind(self.endpoint) + listener.listen(8) + except OSError: + listener.close() + self._remove_artifacts() + raise + self._install_helper() + self._listener = listener + self._server = threading.Thread(target=self._serve, name="codeplain-tty-broker", daemon=True) + self._server.start() + + def _install_helper(self) -> None: + """Writes the `codeplain-tty` executable the child resolves from its PATH. + + A shim onto this interpreter and this checkout's module, rather than a console + entry point looked up on the parent's PATH: the helper a test runs must be the + one matching the broker that is serving it, whatever way Codeplain was installed. + """ + assert self._directory is not None + module = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "codeplain_tty.py") + bin_dir = os.path.join(self._directory, "bin") + os.makedirs(bin_dir) + helper = os.path.join(bin_dir, "codeplain-tty") + with open(helper, "w", encoding="utf-8") as shim: + shim.write(f'#!/bin/sh\nexec "{sys.executable}" "{module}" "$@"\n') + os.chmod(helper, 0o700) + self.helper_bin_dir = bin_dir + + def child_env(self) -> dict: + """The two variables the helper reads. Scoped to broker-enabled executions only.""" + assert self.endpoint is not None, "start() must succeed before the child environment exists" + return { + tty_protocol.ENDPOINT_ENV_VAR: self.endpoint, + tty_protocol.TOKEN_ENV_VAR: self._token, + } + + def description(self) -> str: + return "the codeplain-tty broker is attached to the script's terminal" + + def close(self) -> None: + """Stops accepting, unblocks the server thread, and removes every artifact. Idempotent.""" + self._closing.set() + listener = self._listener + if listener is not None: + self._listener = None + try: + listener.close() # unblocks accept() with an error the loop expects + except OSError: + pass + server = self._server + if server is not None and server.ident is not None: + server.join(timeout=CLOSE_JOIN_SECONDS) + if server.is_alive(): + console.debug("the codeplain-tty broker thread outlived its shutdown bound") + self._remove_artifacts() + + def _remove_artifacts(self) -> None: + directory = self._directory + self._directory = None + if directory is not None: + shutil.rmtree(directory, ignore_errors=True) + + # ------------------------------------------------------------------ the server + + def _serve(self) -> None: + listener = self._listener + assert listener is not None + while not self._closing.is_set(): + try: + connection, _ = listener.accept() + except OSError: # the listener was closed under the loop — expected shutdown + return + try: + self._serve_connection(connection) + except Exception as exc: # one bad client must not take the broker down + console.debug(f"codeplain-tty broker: a connection failed: {exc!r}") + finally: + try: + connection.close() + except OSError: + pass + + def _serve_connection(self, connection: socket.socket) -> None: + connection.settimeout(REQUEST_READ_TIMEOUT_SECONDS) + try: + request = tty_protocol.read_frame(connection.recv) + except (tty_protocol.ProtocolError, socket.timeout) as exc: + self._respond(connection, tty_protocol.error_response(tty_protocol.ERROR_INVALID_REQUEST, str(exc))) + return + if request is None: + return # the client connected and left + connection.settimeout(None) # the command's own deadline bounds the rest + self._respond(connection, self._handle(request)) + + def _respond(self, connection: socket.socket, response: dict) -> None: + try: + connection.sendall(tty_protocol.encode_frame(response)) + except OSError: + pass # the client is gone; its exit code is its own problem + + # ------------------------------------------------------------------ commands + + def _handle(self, request: dict) -> dict: + token = request.get("token") + if not isinstance(token, str) or not hmac.compare_digest(token, self._token): + return tty_protocol.error_response(tty_protocol.ERROR_UNAUTHORIZED, "the request token is not valid") + if request.get("protocol_version") != tty_protocol.PROTOCOL_VERSION: + return tty_protocol.error_response( + tty_protocol.ERROR_UNSUPPORTED, + f"this broker speaks protocol version {tty_protocol.PROTOCOL_VERSION}", + ) + if self._closing.is_set(): + return tty_protocol.error_response(tty_protocol.ERROR_SHUTTING_DOWN, "the execution is shutting down") + command = request.get("command") + args = request.get("args") + if not isinstance(args, dict): + return tty_protocol.error_response(tty_protocol.ERROR_INVALID_REQUEST, "args must be an object") + try: + if command == tty_protocol.COMMAND_WAIT_FOR: + return self._wait(args, present=True) + if command == tty_protocol.COMMAND_WAIT_UNTIL_ABSENT: + return self._wait(args, present=False) + if command == tty_protocol.COMMAND_SEND_TEXT: + return self._send(tty_protocol.typed_text_bytes(self._text_arg(args))) + if command == tty_protocol.COMMAND_SEND_CONTROL: + return self._send(tty_protocol.control_byte(self._text_arg(args, key="key"))) + if command == tty_protocol.COMMAND_SEND_HEX: + return self._send(bytes.fromhex(self._text_arg(args, key="hex"))) + if command == tty_protocol.COMMAND_SIZE: + return self._resize(args) + except ValueError as exc: + return tty_protocol.error_response(tty_protocol.ERROR_INVALID_REQUEST, str(exc)) + except Exception as exc: # a command bug is the broker's failure, not the client's + console.debug(f"codeplain-tty broker: command {command!r} failed: {exc!r}") + return tty_protocol.error_response(tty_protocol.ERROR_INTERNAL, f"the broker failed: {exc}") + return tty_protocol.error_response(tty_protocol.ERROR_INVALID_REQUEST, f"unknown command: {command!r}") + + @staticmethod + def _text_arg(args: dict, key: str = "text") -> str: + value = args.get(key) + if not isinstance(value, str): + raise ValueError(f"'{key}' must be a string") + return value + + def _wait(self, args: dict, present: bool) -> dict: + text = self._text_arg(args) + if not text: + raise ValueError("the text to wait for must not be empty") + timeout = args.get("timeout", DEFAULT_WAIT_SECONDS) + if not isinstance(timeout, (int, float)) or isinstance(timeout, bool) or timeout <= 0: + raise ValueError("'timeout' must be a positive number of seconds") + deadline = time.monotonic() + min(float(timeout), MAX_WAIT_SECONDS) + while True: + transcript = self._process.normalized_output() + if (text in transcript) == present: + return tty_protocol.ok_response() + if self._closing.is_set(): + return tty_protocol.error_response(tty_protocol.ERROR_SHUTTING_DOWN, "the execution is shutting down") + if time.monotonic() >= deadline: + condition = "appear in" if present else "leave" + return tty_protocol.error_response( + tty_protocol.ERROR_TIMEOUT, f"the text did not {condition} the transcript in time" + ) + time.sleep(POLL_INTERVAL_SECONDS) + + def _send(self, data: bytes) -> dict: + if not data: + raise ValueError("there are no bytes to send") + deadline = time.monotonic() + SEND_DEADLINE_SECONDS + while True: + result = self._process.write_input(data) + if result.disposition is InputDisposition.ACCEPTED: + return tty_protocol.ok_response({"accepted_bytes": result.accepted_bytes}) + if result.disposition is InputDisposition.CLOSED: + return tty_protocol.error_response( + tty_protocol.ERROR_INPUT_CLOSED, "the target's terminal no longer accepts input" + ) + if self._closing.is_set() or time.monotonic() >= deadline: + return tty_protocol.error_response( + tty_protocol.ERROR_BACKPRESSURE, "the target stopped reading its terminal input" + ) + time.sleep(POLL_INTERVAL_SECONDS) + + def _resize(self, args: dict) -> dict: + columns, rows = tty_protocol.parse_size_args(args) + try: + self._process.resize(columns, rows) + except TerminalProcessError as exc: + return tty_protocol.error_response(tty_protocol.ERROR_UNSUPPORTED, str(exc)) + return tty_protocol.ok_response() diff --git a/render_machine/tty_protocol.py b/render_machine/tty_protocol.py new file mode 100644 index 00000000..3cfb4cbc --- /dev/null +++ b/render_machine/tty_protocol.py @@ -0,0 +1,150 @@ +"""The wire contract between the `codeplain-tty` helper and the per-execution broker. + +One request per connection: the helper connects to the endpoint named by +``CODEPLAIN_TTY_ENDPOINT``, sends one length-framed JSON request carrying the token from +``CODEPLAIN_TTY_TOKEN``, reads one length-framed JSON response, and disconnects. The +framing, the field names, and the command vocabulary here ARE protocol version 1 — the +same version the client advertises to the API — so nothing in this module may change +without a new protocol version. + +This module is imported by both sides (the broker inside the renderer and the helper +process a generated test spawns), so it stays dependency-free: standard library only, +and no imports from the rest of the codebase. +""" + +import json +import struct +from typing import Optional, Tuple + +PROTOCOL_VERSION = 1 + +ENDPOINT_ENV_VAR = "CODEPLAIN_TTY_ENDPOINT" +TOKEN_ENV_VAR = "CODEPLAIN_TTY_TOKEN" + +# Every environment variable the runtime owns starts with this prefix. Scoping strips +# caller-supplied values and the portability audit rejects references outside internal +# test folders by this prefix, so it is defined once, here. +ENV_VAR_PREFIX = "CODEPLAIN_TTY_" + +COMMAND_WAIT_FOR = "wait-for" +COMMAND_WAIT_UNTIL_ABSENT = "wait-until-absent" +COMMAND_SEND_TEXT = "send-text" +COMMAND_SEND_CONTROL = "send-control" +COMMAND_SEND_HEX = "send-hex" +COMMAND_SIZE = "size" + +COMMANDS = ( + COMMAND_WAIT_FOR, + COMMAND_WAIT_UNTIL_ABSENT, + COMMAND_SEND_TEXT, + COMMAND_SEND_CONTROL, + COMMAND_SEND_HEX, + COMMAND_SIZE, +) + +# Error codes a response can carry. The helper maps them onto its exit codes. +ERROR_UNAUTHORIZED = "unauthorized" +ERROR_UNSUPPORTED = "unsupported" +ERROR_INVALID_REQUEST = "invalid-request" +ERROR_TIMEOUT = "timeout" +ERROR_INPUT_CLOSED = "input-closed" +ERROR_BACKPRESSURE = "backpressure" +ERROR_SHUTTING_DOWN = "shutting-down" +ERROR_INTERNAL = "internal" + +# Helper exit codes. 0 is success; 1 is a command that ran and did not succeed (a +# wait-for that timed out, input the target no longer accepts); 2 is a usage error the +# caller can fix; 69 is the runtime itself being unavailable — matching the testing +# scripts' convention that 69 is an environment failure, never a test failure. +EXIT_OK = 0 +EXIT_COMMAND_FAILED = 1 +EXIT_USAGE = 2 +EXIT_RUNTIME_UNAVAILABLE = 69 + +# One frame: 4-byte big-endian payload length, then that many bytes of UTF-8 JSON. +_HEADER = struct.Struct(">I") +MAX_FRAME_BYTES = 64 * 1024 + + +class ProtocolError(Exception): + """A frame or payload the peer must not act on.""" + + +def encode_frame(payload: dict) -> bytes: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + if len(body) > MAX_FRAME_BYTES: + raise ProtocolError(f"frame of {len(body)} bytes exceeds the {MAX_FRAME_BYTES}-byte bound") + return _HEADER.pack(len(body)) + body + + +def read_frame(recv) -> Optional[dict]: + """Reads one frame from `recv(max_bytes) -> bytes`. None on a clean end of stream.""" + header = _read_exactly(recv, _HEADER.size) + if header is None: + return None + (length,) = _HEADER.unpack(header) + if length > MAX_FRAME_BYTES: + raise ProtocolError(f"frame of {length} bytes exceeds the {MAX_FRAME_BYTES}-byte bound") + body = _read_exactly(recv, length) + if body is None: + raise ProtocolError("the stream ended inside a frame") + try: + payload = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ProtocolError(f"the frame does not carry UTF-8 JSON: {exc}") from exc + if not isinstance(payload, dict): + raise ProtocolError("the frame must carry a JSON object") + return payload + + +def _read_exactly(recv, count: int) -> Optional[bytes]: + data = bytearray() + while len(data) < count: + chunk = recv(count - len(data)) + if not chunk: + if not data: + return None # a clean end of stream, before anything was read + raise ProtocolError("the stream ended inside a frame") + data += chunk + return bytes(data) + + +def request(token: str, command: str, args: dict) -> dict: + return {"protocol_version": PROTOCOL_VERSION, "token": token, "command": command, "args": args} + + +def ok_response(result: Optional[dict] = None) -> dict: + return {"ok": True, "result": result or {}} + + +def error_response(code: str, message: str) -> dict: + return {"ok": False, "error": code, "message": message} + + +def control_byte(key: str) -> bytes: + """The control byte an ASCII Ctrl- keypress produces (Ctrl-D -> 0x04).""" + if len(key) != 1: + raise ValueError("send-control takes a single character, e.g. 'd' for Ctrl-D") + upper = key.upper() + code = ord(upper) ^ 0x40 + if not 0 <= code <= 0x1F: + raise ValueError(f"'{key}' does not name a control character") + return bytes([code]) + + +def typed_text_bytes(text: str) -> bytes: + """What typing `text` at a terminal sends: newlines become carriage returns. + + A terminal's Enter key sends CR; the line discipline's ICRNL turns it back into the + newline a canonical read returns. Sending LF verbatim would bypass what every + interactive program is written against, so `send-text` emulates typing. `send-hex` + exists for exact bytes. + """ + return text.replace("\r\n", "\n").replace("\n", "\r").encode("utf-8") + + +def parse_size_args(args: dict) -> Tuple[int, int]: + columns, rows = args.get("columns"), args.get("rows") + if not isinstance(columns, int) or not isinstance(rows, int) or columns <= 0 or rows <= 0: + raise ValueError("size takes positive integer columns and rows") + return columns, rows diff --git a/tests/test_tty_broker.py b/tests/test_tty_broker.py new file mode 100644 index 00000000..4e30935d --- /dev/null +++ b/tests/test_tty_broker.py @@ -0,0 +1,385 @@ +"""Tests for the per-execution `codeplain-tty` broker and its helper CLI. + +Two layers. The protocol and broker cases talk to the broker directly over its socket +with a fake terminal process, so authentication, bounds, and every error channel are +asserted without a real target. The end-to-end cases spawn real interactive programs on +the POSIX PTY backend and drive them through the actual helper executable the broker +installs — including the `getpass` reproduction whose `TCSAFLUSH` defeats the spawn-time +VEOF, the exact mechanism that motivated the broker. +""" + +import os +import socket +import stat +import subprocess +import sys +import textwrap +import threading +import time +from pathlib import Path + +import pytest + +from render_machine import tty_protocol +from render_machine.terminal_process import InputDisposition, InputWriteResult, TerminalInputDriver +from render_machine.tty_broker import TtyBroker, broker_supported + +posix_only = pytest.mark.skipif( + sys.platform == "win32", + reason="The broker transport and these interactive targets are POSIX-only.", +) + +pytestmark = posix_only + +if sys.platform != "win32": + from render_machine._posix_pty import PosixPtyProcess + +SPAWN_TIMEOUT = 20.0 + + +class FakeProcess: + """A terminal process double: a settable transcript and a recording input sink.""" + + def __init__(self) -> None: + self.transcript = "" + self.written = b"" + self.resized_to = None + self.dispositions = [InputDisposition.ACCEPTED] + + def normalized_output(self) -> str: + return self.transcript + + def write_input(self, data: bytes) -> InputWriteResult: + disposition = self.dispositions[0] if len(self.dispositions) == 1 else self.dispositions.pop(0) + if disposition is InputDisposition.ACCEPTED: + self.written += data + return InputWriteResult(disposition, len(data)) + return InputWriteResult(disposition, 0) + + def resize(self, columns: int, rows: int) -> None: + self.resized_to = (columns, rows) + + +@pytest.fixture +def broker(): + process = FakeProcess() + instance = TtyBroker(process) + instance.start() + try: + yield instance, process + finally: + instance.close() + + +def call(instance: TtyBroker, payload: dict) -> dict: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(10.0) + connection.connect(instance.endpoint) + connection.sendall(tty_protocol.encode_frame(payload)) + response = tty_protocol.read_frame(connection.recv) + assert response is not None + return response + + +def command(instance: TtyBroker, name: str, args: dict) -> dict: + return call(instance, tty_protocol.request(instance._token, name, args)) + + +def test_the_transport_is_supported_on_this_platform(): + assert broker_supported() + + +def test_the_endpoint_lives_in_a_private_directory_with_the_helper(broker): + instance, _ = broker + endpoint_dir = os.path.dirname(instance.endpoint) + assert stat.S_IMODE(os.stat(endpoint_dir).st_mode) == 0o700 + helper = os.path.join(instance.helper_bin_dir, "codeplain-tty") + assert os.access(helper, os.X_OK) + env = instance.child_env() + assert env[tty_protocol.ENDPOINT_ENV_VAR] == instance.endpoint + assert env[tty_protocol.TOKEN_ENV_VAR] + + +def test_a_wrong_token_is_rejected_in_every_command(broker): + instance, process = broker + process.transcript = "ready" + response = call(instance, tty_protocol.request("not-the-token", "wait-for", {"text": "ready"})) + assert response["ok"] is False + assert response["error"] == tty_protocol.ERROR_UNAUTHORIZED + + +def test_a_future_protocol_version_is_rejected(broker): + instance, _ = broker + payload = tty_protocol.request(instance._token, "send-text", {"text": "x"}) + payload["protocol_version"] = 2 + response = call(instance, payload) + assert response["error"] == tty_protocol.ERROR_UNSUPPORTED + + +def test_wait_for_resolves_once_the_text_appears(broker): + instance, process = broker + + def appear_later(): + time.sleep(0.2) + process.transcript = "Master password:" + + threading.Thread(target=appear_later, daemon=True).start() + response = command(instance, "wait-for", {"text": "password:", "timeout": 5}) + assert response["ok"] is True + + +def test_wait_for_times_out_with_the_timeout_error(broker): + instance, _ = broker + response = command(instance, "wait-for", {"text": "never", "timeout": 0.2}) + assert response["error"] == tty_protocol.ERROR_TIMEOUT + + +def test_wait_until_absent_resolves_when_the_text_leaves(broker): + instance, process = broker + process.transcript = "spinner" + + def clear_later(): + time.sleep(0.2) + process.transcript = "done" + + threading.Thread(target=clear_later, daemon=True).start() + response = command(instance, "wait-until-absent", {"text": "spinner", "timeout": 5}) + assert response["ok"] is True + + +def test_send_text_types_newlines_as_carriage_returns(broker): + instance, process = broker + response = command(instance, "send-text", {"text": "hunter2\n"}) + assert response["ok"] is True + assert process.written == b"hunter2\r" + + +def test_send_control_sends_the_control_byte(broker): + instance, process = broker + assert command(instance, "send-control", {"key": "d"})["ok"] is True + assert process.written == b"\x04" + + +def test_send_hex_sends_exact_bytes(broker): + instance, process = broker + assert command(instance, "send-hex", {"hex": "1b5b41"})["ok"] is True + assert process.written == b"\x1b[A" + + +def test_invalid_hex_is_a_usage_error_not_a_broker_failure(broker): + instance, _ = broker + response = command(instance, "send-hex", {"hex": "zz"}) + assert response["error"] == tty_protocol.ERROR_INVALID_REQUEST + + +def test_closed_input_is_reported_as_input_closed(broker): + instance, process = broker + process.dispositions = [InputDisposition.CLOSED] + response = command(instance, "send-text", {"text": "x"}) + assert response["error"] == tty_protocol.ERROR_INPUT_CLOSED + + +def test_backpressure_is_retried_until_accepted(broker): + instance, process = broker + process.dispositions = [InputDisposition.BACKPRESSURE, InputDisposition.BACKPRESSURE, InputDisposition.ACCEPTED] + response = command(instance, "send-text", {"text": "x"}) + assert response["ok"] is True + assert process.written == b"x" + + +def test_size_resizes_the_process(broker): + instance, process = broker + assert command(instance, "size", {"columns": 100, "rows": 30})["ok"] is True + assert process.resized_to == (100, 30) + + +def test_an_unknown_command_is_rejected(broker): + instance, _ = broker + response = command(instance, "reboot", {}) + assert response["error"] == tty_protocol.ERROR_INVALID_REQUEST + + +def test_an_oversized_frame_is_rejected_by_the_protocol(broker): + instance, _ = broker + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(10.0) + connection.connect(instance.endpoint) + # A header claiming more than the bound; the broker must refuse without reading it. + connection.sendall((tty_protocol.MAX_FRAME_BYTES + 1).to_bytes(4, "big")) + response = tty_protocol.read_frame(connection.recv) + assert response is not None + assert response["error"] == tty_protocol.ERROR_INVALID_REQUEST + + +def test_close_removes_every_artifact_and_stops_the_server(): + process = FakeProcess() + instance = TtyBroker(process) + instance.start() + directory = os.path.dirname(instance.endpoint) + instance.close() + assert not os.path.exists(directory) + instance.close() # idempotent + + +def test_the_broker_is_a_typed_input_driver(broker): + instance, _ = broker + assert isinstance(instance, TerminalInputDriver) + assert "codeplain-tty" in instance.description() + + +# ------------------------------------------------------------------ end to end + + +def make_script(directory: Path, name: str, program: str) -> str: + script_path = directory / f"{name}.py" + script_path.write_text(f"#!{sys.executable}\n" + textwrap.dedent(program)) + script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR) + return str(script_path) + + +def run_with_broker(tmp_path: Path, name: str, program: str, driver_script: str) -> tuple: + """Spawns `program` on the real PTY backend and runs `driver_script` (a shell script + using codeplain-tty) against it from the outside, the way a generated test would.""" + target = make_script(tmp_path, name, program) + process = PosixPtyProcess() + broker = TtyBroker(process) + broker.start() + try: + env = dict(os.environ) + env.update(broker.child_env()) + env["PATH"] = broker.helper_bin_dir + os.pathsep + env.get("PATH", "") + process.spawn([target], input_driver=broker) + driver = subprocess.run( + ["/bin/sh", "-c", driver_script], + env=env, + capture_output=True, + text=True, + timeout=SPAWN_TIMEOUT, + ) + deadline = time.monotonic() + SPAWN_TIMEOUT + returncode = None + while time.monotonic() < deadline: + returncode = process.poll() + if returncode is not None: + break + time.sleep(0.02) + process.terminate_tree(grace=1.0) + process.close() + return returncode, process.normalized_output(), driver + finally: + broker.close() + process.close() + + +def test_getpass_is_answered_through_the_helper_despite_tcsaflush(tmp_path): + """The motivating reproduction: getpass's TCSAFLUSH discards the spawn-time VEOF, so + without the broker this target blocks until the script timeout. With the broker the + test waits for the prompt, types the password, and the target exits cleanly.""" + returncode, transcript, driver = run_with_broker( + tmp_path, + "getpass_target", + """ + import getpass + + secret = getpass.getpass("Master password: ") + print(f"GOT:{secret}") + """, + 'codeplain-tty wait-for "Master password:" --timeout 15 && codeplain-tty send-text "hunter2\n"', + ) + assert driver.returncode == 0, driver.stderr + assert returncode == 0 + assert "GOT:hunter2" in transcript + + +def test_a_plain_input_read_is_answered_too(tmp_path): + returncode, transcript, driver = run_with_broker( + tmp_path, + "input_target", + """ + name = input("Name: ") + print(f"HELLO:{name}") + """, + 'codeplain-tty wait-for "Name:" --timeout 15 && codeplain-tty send-text "world\n"', + ) + assert driver.returncode == 0, driver.stderr + assert returncode == 0 + assert "HELLO:world" in transcript + + +def test_send_control_delivers_ctrl_d_as_eof(tmp_path): + returncode, transcript, driver = run_with_broker( + tmp_path, + "eof_target", + """ + import sys + + print("READY", flush=True) + data = sys.stdin.read() + print(f"EOF-AFTER:{len(data)}") + """, + "codeplain-tty wait-for READY --timeout 15 && codeplain-tty send-control d", + ) + assert driver.returncode == 0, driver.stderr + assert returncode == 0 + assert "EOF-AFTER:0" in transcript + + +def test_size_reaches_the_target_as_sigwinch_and_a_new_size(tmp_path): + returncode, transcript, driver = run_with_broker( + tmp_path, + "size_target", + """ + import os + import signal + import sys + + resized = [] + + def on_winch(signum, frame): + resized.append(os.get_terminal_size(sys.stdout.fileno())) + + signal.signal(signal.SIGWINCH, on_winch) + print("READY", flush=True) + while not resized: + signal.pause() + print(f"SIZE:{resized[0].columns}x{resized[0].lines}") + """, + "codeplain-tty wait-for READY --timeout 15 && codeplain-tty size 100 30", + ) + assert driver.returncode == 0, driver.stderr + assert returncode == 0 + assert "SIZE:100x30" in transcript + + +def test_the_helper_reports_the_runtime_unavailable_outside_a_test_run(tmp_path): + env = {key: value for key, value in os.environ.items() if not key.startswith(tty_protocol.ENV_VAR_PREFIX)} + result = subprocess.run( + [ + sys.executable, + os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "codeplain_tty.py"), + "wait-for", + "x", + ], + env=env, + capture_output=True, + text=True, + timeout=SPAWN_TIMEOUT, + ) + assert result.returncode == tty_protocol.EXIT_RUNTIME_UNAVAILABLE + assert "not available" in result.stderr + + +def test_a_wait_that_times_out_exits_one(tmp_path): + returncode, transcript, driver = run_with_broker( + tmp_path, + "quiet_target", + """ + import time + + print("READY", flush=True) + time.sleep(2) + """, + 'codeplain-tty wait-for "never-printed" --timeout 1', + ) + assert driver.returncode == tty_protocol.EXIT_COMMAND_FAILED + assert "did not" in driver.stderr From 71a195932b3c53c22a6328507e1ef7be610c8cf6 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Mon, 17 Aug 2026 16:25:56 +0200 Subject: [PATCH 58/83] feat: scope the platform-test runtime to conformance execution (codeplain-tty Phase C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execute_script() gains an explicit platform_test_runtime option — never a process-global switch. A runtime execution gets a per-execution broker, the helper prepended to PATH from the broker's own bin directory, and a scoped environment with caller-supplied CODEPLAIN_TTY_* values stripped before the broker's own are added; a broker that cannot start is an environment error (exit 69) and the script never runs. Unit-test and environment-preparation executions are unchanged. Only the conformance action asks for the runtime (acceptance tests extend and execute the same suite), and only when the cached preflight passed: one real round trip that starts a broker, installs the helper, and completes a wait-for through the socket exactly the way a generated test will. The same preflight gates the capability advertisement on the three conformance/ acceptance API calls, so the API is told about a runtime only after this machine has proven it can provide it. The acceptance-gate reproduction now passes through the real execution path: a conformance-style script drives a getpass child (whose TCSAFLUSH discards the spawn-time VEOF) through codeplain-tty instead of hanging to the 120-second timeout. --- .../actions/fix_conformance_test.py | 2 + .../actions/render_conformance_tests.py | 3 + .../actions/run_conformance_tests.py | 5 + render_machine/platform_test_runtime.py | 61 +++++++++- render_machine/render_utils.py | 57 +++++++-- tests/test_platform_test_runtime.py | 26 +++- tests/test_platform_test_scoping.py | 115 ++++++++++++++++++ 7 files changed, 253 insertions(+), 16 deletions(-) create mode 100644 tests/test_platform_test_scoping.py diff --git a/render_machine/actions/fix_conformance_test.py b/render_machine/actions/fix_conformance_test.py index 5d68cc88..428feec6 100644 --- a/render_machine/actions/fix_conformance_test.py +++ b/render_machine/actions/fix_conformance_test.py @@ -8,6 +8,7 @@ from plain2code_exceptions import InternalClientError from render_machine.actions.base_action import BaseAction from render_machine.implementation_code_helpers import ImplementationCodeHelpers +from render_machine.platform_test_runtime import advertised_platform_test_runtime from render_machine.render_context import RenderContext from render_machine.render_types import RenderError, TestExecutionPhase @@ -133,6 +134,7 @@ def execute(self, render_context: RenderContext, previous_action_payload: Any | render_context.conformance_tests_running_context.current_testing_frid_high_level_implementation_plan, render_context.conformance_tests_running_context.conflicting_requirement_count, run_state=render_context.run_state, + platform_test_runtime=advertised_platform_test_runtime(), ) code_diff_files_content = {} diff --git a/render_machine/actions/render_conformance_tests.py b/render_machine/actions/render_conformance_tests.py index 178a9716..62ac7b9e 100644 --- a/render_machine/actions/render_conformance_tests.py +++ b/render_machine/actions/render_conformance_tests.py @@ -7,6 +7,7 @@ from plain2code_console import console from render_machine.actions.base_action import BaseAction from render_machine.implementation_code_helpers import ImplementationCodeHelpers +from render_machine.platform_test_runtime import advertised_platform_test_runtime from render_machine.render_context import RenderContext from render_machine.render_types import AcceptanceTestPhase, TestExecutionPhase @@ -126,6 +127,7 @@ def _render_conformance_tests(self, render_context: RenderContext): ), all_acceptance_tests, run_state=render_context.run_state, + platform_test_runtime=advertised_platform_test_runtime(), ) render_context.conformance_tests_running_context.current_testing_frid_high_level_implementation_plan = ( @@ -178,6 +180,7 @@ def _render_acceptance_test(self, render_context: RenderContext): render_context.get_required_modules_functionalities(), acceptance_test, run_state=render_context.run_state, + platform_test_runtime=advertised_platform_test_runtime(), ) conformance_tests_folder_name = ( render_context.conformance_tests_running_context.get_current_conformance_test_folder_name() diff --git a/render_machine/actions/run_conformance_tests.py b/render_machine/actions/run_conformance_tests.py index 3c7bb1f0..15eddfb1 100644 --- a/render_machine/actions/run_conformance_tests.py +++ b/render_machine/actions/run_conformance_tests.py @@ -4,6 +4,7 @@ import render_machine.render_utils as render_utils from plain2code_console import console from render_machine.actions.base_action import BaseAction +from render_machine.platform_test_runtime import platform_test_runtime_available from render_machine.render_context import RenderContext from render_machine.render_types import RenderError @@ -48,6 +49,10 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | module=render_context.conformance_tests_running_context.current_testing_module_name, timeout=render_context.test_script_timeout, stop_event=render_context.stop_event, + # Conformance (and the acceptance tests that extend the same suite) may drive + # the target's terminal through codeplain-tty; unit tests and environment + # preparation never get the runtime. + platform_test_runtime=platform_test_runtime_available(), ) render_context.script_execution_history.latest_conformance_test_output_path = ( conformance_tests_temp_log_file_path diff --git a/render_machine/platform_test_runtime.py b/render_machine/platform_test_runtime.py index 5b85977c..2113aa43 100644 --- a/render_machine/platform_test_runtime.py +++ b/render_machine/platform_test_runtime.py @@ -12,8 +12,16 @@ every filesystem or pipe name stay client-side, scoped to one script execution. """ +import functools +import os +import subprocess from typing import Optional +from plain2code_console import console +from render_machine import tty_protocol +from render_machine.terminal_process import TerminalProcess +from render_machine.tty_broker import TtyBroker, broker_supported + PROTOCOL_VERSION = 1 # The commands protocol version 1 promises. The API rejects a descriptor naming a command @@ -38,12 +46,57 @@ def codeplain_tty_descriptor() -> dict: } +PREFLIGHT_MARKER = "codeplain-tty-preflight" +PREFLIGHT_TIMEOUT_SECONDS = 30.0 + + +class _PreflightProbe(TerminalProcess): + """A stand-in target whose transcript already contains the preflight marker.""" + + def normalized_output(self) -> str: + return PREFLIGHT_MARKER + + +@functools.lru_cache(maxsize=1) +def platform_test_runtime_available() -> bool: + """One real round trip through the runtime, cached for the process's lifetime. + + Support is never derived from a version: the capability is advertised only after the + broker starts, installs its helper, and the helper — executed exactly the way a + generated test will execute it — authenticates and completes a command over the + socket. Any failure keeps the runtime off and the request un-advertised. + """ + if not broker_supported(): + return False + broker = None + try: + broker = TtyBroker(_PreflightProbe()) + broker.start() + env = {key: value for key, value in os.environ.items() if not key.startswith(tty_protocol.ENV_VAR_PREFIX)} + env.update(broker.child_env()) + assert broker.helper_bin_dir is not None + helper = os.path.join(broker.helper_bin_dir, "codeplain-tty") + result = subprocess.run( + [helper, "wait-for", PREFLIGHT_MARKER, "--timeout", "5"], + env=env, + capture_output=True, + timeout=PREFLIGHT_TIMEOUT_SECONDS, + ) + if result.returncode != 0: + console.debug(f"codeplain-tty preflight failed (exit {result.returncode}): {result.stderr!r}") + return result.returncode == 0 + except Exception as exc: + console.debug(f"codeplain-tty preflight failed: {exc!r}") + return False + finally: + if broker is not None: + broker.close() + + def advertised_platform_test_runtime() -> Optional[dict]: """What the client actually advertises: the descriptor, or None while it cannot. None keeps the API on its backward-compatible path — no `codeplain-tty` prompt - content is generated. The broker and executable preflight that turns this on ships - with the broker itself; until then the client never advertises a runtime it could - not provide. + content is generated for a runtime this client could not provide. """ - return None + return codeplain_tty_descriptor() if platform_test_runtime_available() else None diff --git a/render_machine/render_utils.py b/render_machine/render_utils.py index c6d08173..7c7c8855 100644 --- a/render_machine/render_utils.py +++ b/render_machine/render_utils.py @@ -1,3 +1,4 @@ +import os import sys import tempfile import threading @@ -8,6 +9,7 @@ import plain_spec from plain2code_console import MUTED_COLOR, RETRY_COLOR, SUCCESS_COLOR, console from plain2code_exceptions import RenderCancelledError +from render_machine import tty_protocol from render_machine.terminal_process import ( ENVIRONMENT_ERROR_EXIT_CODE, NO_INPUT_NOTE, @@ -16,6 +18,7 @@ TerminalProcessError, create_terminal_process, ) +from render_machine.tty_broker import TtyBroker SCRIPT_EXECUTION_TIMEOUT = 120 TIMEOUT_ERROR_EXIT_CODE = 124 @@ -25,10 +28,9 @@ # discoverable from the returned path and cleanable by the same convention. RAW_OUTPUT_SUFFIX = ".raw" -# The `codeplain-tty` broker exists but is not wired into script execution yet — that is -# the runtime-scoping phase of the codeplain-tty plan. Until then no input driver is -# attached. The timeout diagnostic is keyed on this declaration rather than on bytes -# written: a script that blocks on input has written nothing either way. +# The driver a non-broker execution gets: none. Only an execution that asked for the +# platform-test runtime (conformance and acceptance runs) attaches the per-execution +# `codeplain-tty` broker; unit tests and environment preparation always run without one. INPUT_DRIVER: Optional[TerminalInputDriver] = None # Conditions the arbiter chooses between, highest precedence last. @@ -235,7 +237,26 @@ def _collect_backend_state(process: TerminalProcess, execution: _ScriptExecution _record_backend_failure(execution.outcome, exc, "while reporting its result") -def _run_script(cmd: list[str], script_timeout: float, stop_event: Optional[threading.Event]) -> _ScriptExecution: +def _platform_test_environment(broker: TtyBroker) -> dict: + """The scoped child environment of a broker-enabled execution. + + Caller-supplied CODEPLAIN_TTY_* values are stripped before the broker's own are + added — the runtime owns that prefix — and the helper's directory is prepended to + PATH only here, so no other execution can resolve the executable. + """ + env = {key: value for key, value in os.environ.items() if not key.startswith(tty_protocol.ENV_VAR_PREFIX)} + env.update(broker.child_env()) + assert broker.helper_bin_dir is not None + env["PATH"] = broker.helper_bin_dir + os.pathsep + env.get("PATH", "") + return env + + +def _run_script( + cmd: list[str], + script_timeout: float, + stop_event: Optional[threading.Event], + platform_test_runtime: bool = False, +) -> _ScriptExecution: execution = _ScriptExecution() outcome = execution.outcome process: Optional[TerminalProcess] = None @@ -246,9 +267,20 @@ def _run_script(cmd: list[str], script_timeout: float, stop_event: Optional[thre if process is None: return execution _script_started() + broker: Optional[TtyBroker] = None try: try: - process.spawn(cmd, stop_event=stop_event, input_driver=INPUT_DRIVER) + child_env: Optional[dict] = None + input_driver: Optional[TerminalInputDriver] = INPUT_DRIVER + if platform_test_runtime: + # A broker that cannot start is an environment failure (the except below), + # never a spawn with the runtime silently missing: the generated test was + # promised the helper and would fail confusingly without it. + broker = TtyBroker(process) + broker.start() + child_env = _platform_test_environment(broker) + input_driver = broker + process.spawn(cmd, env=child_env, stop_event=stop_event, input_driver=input_driver) _await_target(process, script_timeout, stop_event, outcome) except RenderCancelledError: outcome.cancelled() @@ -258,8 +290,18 @@ def _run_script(cmd: list[str], script_timeout: float, stop_event: Optional[thre # follow it. _record_backend_failure(outcome, exc, "while running the script") finally: + # The broker stops accepting before the target is torn down, so no command + # can race the teardown; its artifacts are gone before publication. + if broker is not None: + broker.close() _teardown(process, outcome) _collect_backend_state(process, execution) + if broker is not None: + # The backend's absent-driver note would misdescribe this execution. + execution.no_input_note = ( + " The codeplain-tty broker was attached; the script may be waiting for" + " terminal input its test never sent." + ) finally: _script_finished() return execution @@ -390,6 +432,7 @@ def execute_script( module: Optional[str] = None, timeout: Optional[int] = None, stop_event: Optional[threading.Event] = None, + platform_test_runtime: bool = False, ) -> tuple[int, str, Optional[str]]: script_timeout = timeout if timeout is not None else SCRIPT_EXECUTION_TIMEOUT @@ -402,7 +445,7 @@ def execute_script( cmd = [script_path] + scripts_args start_time = time.time() - execution = _run_script(cmd, script_timeout, stop_event) + execution = _run_script(cmd, script_timeout, stop_event, platform_test_runtime) elapsed_time = time.time() - start_time outcome = execution.outcome diff --git a/tests/test_platform_test_runtime.py b/tests/test_platform_test_runtime.py index f0c4bdd1..957324e1 100644 --- a/tests/test_platform_test_runtime.py +++ b/tests/test_platform_test_runtime.py @@ -1,14 +1,17 @@ """Tests for the platform-test runtime capability the client advertises. -Phase A of the `codeplain-tty` plan: the descriptor and the REST plumbing exist, but the -client advertises nothing until the broker's preflight lands. What is asserted here is -the contract those later phases build on — the version-1 descriptor shape, the gate -returning None, and the request payloads carrying the capability only when it is given. +What is asserted here is the capability contract: the version-1 descriptor shape, the +advertisement following the broker preflight (never a client version), and the request +payloads carrying the capability only when it is given. """ +import sys from unittest.mock import MagicMock +import pytest + from codeplain_REST_api import CodeplainAPI +from render_machine import platform_test_runtime from render_machine.platform_test_runtime import ( CODEPLAIN_TTY_COMMANDS, PROTOCOL_VERSION, @@ -67,9 +70,22 @@ def test_the_descriptor_is_the_version_1_contract(): ] -def test_nothing_is_advertised_before_the_broker_preflight_exists(): +def test_advertisement_follows_the_preflight(monkeypatch): + monkeypatch.setattr(platform_test_runtime, "platform_test_runtime_available", lambda: False) assert advertised_platform_test_runtime() is None + monkeypatch.setattr(platform_test_runtime, "platform_test_runtime_available", lambda: True) + assert advertised_platform_test_runtime() == codeplain_tty_descriptor() + + +@pytest.mark.skipif(sys.platform == "win32", reason="The broker transport is POSIX-only.") +def test_the_real_preflight_passes_on_this_platform(): + platform_test_runtime.platform_test_runtime_available.cache_clear() + try: + assert platform_test_runtime.platform_test_runtime_available() is True + finally: + platform_test_runtime.platform_test_runtime_available.cache_clear() + def test_the_capability_is_omitted_from_the_payload_by_default(): recorded = [] diff --git a/tests/test_platform_test_scoping.py b/tests/test_platform_test_scoping.py new file mode 100644 index 00000000..b3a39ee4 --- /dev/null +++ b/tests/test_platform_test_scoping.py @@ -0,0 +1,115 @@ +"""Tests for the platform-test runtime's execution scoping in `execute_script()`. + +The runtime is an explicit per-execution option, never a process-global switch: only an +execution that asks for it gets the broker, the helper on PATH, and the scoped +CODEPLAIN_TTY_* environment — and an execution that does not ask sees none of it, even +when the caller's own environment carries stale values. The closing case is the +acceptance gate that motivated the whole plan: the getpass/TCSAFLUSH reproduction +passing through the real `execute_script()` path. +""" + +import stat +import sys +import textwrap +from pathlib import Path + +import pytest + +from render_machine import render_utils, tty_protocol + +posix_only = pytest.mark.skipif( + sys.platform == "win32", + reason="These cases run POSIX shell scripts and the POSIX-only broker transport.", +) + +pytestmark = posix_only + + +def make_script(directory: Path, name: str, body: str) -> str: + script_path = directory / f"{name}.sh" + script_path.write_text("#!/bin/bash\n" + textwrap.dedent(body)) + script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR) + return str(script_path) + + +def test_a_plain_execution_gets_no_runtime_environment(tmp_path, monkeypatch): + monkeypatch.setenv("CODEPLAIN_TTY_ENDPOINT", "/stale/endpoint") + script = make_script( + tmp_path, + "probe", + """ + if command -v codeplain-tty >/dev/null 2>&1; then echo "HELPER-ON-PATH"; fi + echo "ENDPOINT:${CODEPLAIN_TTY_ENDPOINT:-unset}" + """, + ) + + exit_code, output, _ = render_utils.execute_script(script, [], "Repro", timeout=30) + + assert exit_code == 0 + assert "HELPER-ON-PATH" not in output + # The stale caller value still reaches a plain execution untouched (today's + # behavior); only the scoped runtime owns and rewrites the prefix. + assert "ENDPOINT:/stale/endpoint" in output + + +def test_a_runtime_execution_gets_the_helper_and_a_scoped_environment(tmp_path, monkeypatch): + monkeypatch.setenv("CODEPLAIN_TTY_ENDPOINT", "/stale/endpoint") + monkeypatch.setenv("CODEPLAIN_TTY_TOKEN", "stale-token") + script = make_script( + tmp_path, + "scoped_probe", + """ + command -v codeplain-tty >/dev/null 2>&1 || { echo "NO-HELPER"; exit 1; } + [ "${CODEPLAIN_TTY_ENDPOINT}" = "/stale/endpoint" ] && { echo "STALE-ENDPOINT"; exit 1; } + [ "${CODEPLAIN_TTY_TOKEN}" = "stale-token" ] && { echo "STALE-TOKEN"; exit 1; } + [ -S "${CODEPLAIN_TTY_ENDPOINT}" ] || { echo "ENDPOINT-NOT-A-SOCKET"; exit 1; } + echo "SCOPED-OK" + """, + ) + + exit_code, output, _ = render_utils.execute_script(script, [], "Repro", timeout=30, platform_test_runtime=True) + + assert exit_code == 0, output + assert "SCOPED-OK" in output + + +def test_the_getpass_reproduction_passes_through_the_real_execution_path(tmp_path): + """The acceptance gate: a conformance-style script feeds a getpass child through + codeplain-tty instead of hanging to the 120-second timeout on the discarded VEOF.""" + child = tmp_path / "child_getpass.py" + child.write_text(textwrap.dedent(""" + import getpass + + secret = getpass.getpass("Master password: ") + print(f"GOT:{secret}") + """)) + script = make_script( + tmp_path, + "conformance_style", + f""" + "{sys.executable}" "{child}" & + target=$! + codeplain-tty wait-for "Master password:" --timeout 15 || exit 1 + codeplain-tty send-text "hunter2 + " || exit 1 + wait "$target" + """, + ) + + exit_code, output, _ = render_utils.execute_script(script, [], "Repro", timeout=60, platform_test_runtime=True) + + assert exit_code == 0, output + assert "GOT:hunter2" in output + + +def test_a_runtime_execution_that_cannot_start_its_broker_is_an_environment_error(tmp_path, monkeypatch): + def refuse_to_start(self): + raise OSError("no sockets today") + + monkeypatch.setattr(render_utils.TtyBroker, "start", refuse_to_start) + script = make_script(tmp_path, "never_runs", 'echo "MUST-NOT-RUN"\n') + + exit_code, output, _ = render_utils.execute_script(script, [], "Repro", timeout=30, platform_test_runtime=True) + + assert exit_code == tty_protocol.EXIT_RUNTIME_UNAVAILABLE + assert "MUST-NOT-RUN" not in output From 7be2136a706319cfcfc4b8a9c8c68c3b66649699 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Mon, 17 Aug 2026 16:45:07 +0200 Subject: [PATCH 59/83] feat: final portability audit before publishing a build (codeplain-tty Phase E, client side) The last net behind the API's response validation: at module completion, before the build is copied anywhere, the build folder is walked and any reference to codeplain-tty, the codeplain_tty module, or the CODEPLAIN_TTY_ environment prefix fails the render with a clear message. Vendor and build directories are skipped; internal conformance/acceptance tests live outside the build folder, so any hit is a violation by definition. --- render_machine/actions/create_dist.py | 4 ++ render_machine/platform_test_audit.py | 63 +++++++++++++++++++++++++++ tests/test_platform_test_audit.py | 45 +++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 render_machine/platform_test_audit.py create mode 100644 tests/test_platform_test_audit.py diff --git a/render_machine/actions/create_dist.py b/render_machine/actions/create_dist.py index 9a1dfd4d..6e978d84 100644 --- a/render_machine/actions/create_dist.py +++ b/render_machine/actions/create_dist.py @@ -3,6 +3,7 @@ import file_utils from plain2code_console import SUCCESS_COLOR, console from render_machine.actions.base_action import BaseAction +from render_machine.platform_test_audit import audit_build_folder from render_machine.render_context import RenderContext @@ -10,6 +11,9 @@ class CreateDist(BaseAction): SUCCESSFUL_OUTCOME = "dist_created" def execute(self, render_context: RenderContext, _previous_action_payload: Any | None): + # The final portability audit: a build that references Codeplain's private test + # runtime is never published or copied. + audit_build_folder(render_context.build_folder) # Copy build and conformance tests folders to output folders if specified if render_context.copy_build: file_utils.copy_folder_to_output( diff --git a/render_machine/platform_test_audit.py b/render_machine/platform_test_audit.py new file mode 100644 index 00000000..bce3df6d --- /dev/null +++ b/render_machine/platform_test_audit.py @@ -0,0 +1,63 @@ +"""The client-side final audit of the platform-test runtime's portability boundary. + +The API discards responses that leak `codeplain-tty` into delivered code, so by the +time a module render completes its build folder should be clean. This audit is the last +net before the build is published or copied: it walks the implementation tree and fails +the render on any reference to the helper or its environment prefix, because a delivered +application must run in a clean environment where none of Codeplain's test tooling +exists. + +Internal conformance and acceptance tests live outside the build folder (in the +module's tests tree), so nothing here needs an allowlist: any hit inside the build +folder is a violation. +""" + +import os +from typing import List + +# The executable name, the module name, and the environment prefix — the same markers +# the API's response validation uses. +HELPER_REFERENCE_MARKERS = ("codeplain-tty", "codeplain_tty", "CODEPLAIN_TTY_") + +# Directories that carry no delivered source and may be large. +SKIPPED_DIRECTORIES = {".git", ".venv", "node_modules", "__pycache__", ".tmp", "dist", "build", "target"} + +MAX_AUDITED_FILE_BYTES = 4 * 1024 * 1024 # a delivered source file larger than this is not source + + +class PlatformBoundaryViolation(Exception): + """A delivered build references Codeplain's private test tooling.""" + + +def find_platform_references(build_folder: str) -> List[str]: + """Build-folder-relative paths of files referencing the platform test helper.""" + violations = [] + for root, directories, file_names in os.walk(build_folder): + directories[:] = [name for name in directories if name not in SKIPPED_DIRECTORIES] + for file_name in file_names: + path = os.path.join(root, file_name) + relative = os.path.relpath(path, build_folder) + if any(marker in file_name for marker in HELPER_REFERENCE_MARKERS): + violations.append(relative) + continue + try: + if os.path.getsize(path) > MAX_AUDITED_FILE_BYTES: + continue + with open(path, "r", encoding="utf-8", errors="ignore") as source: + content = source.read() + except OSError: + continue # unreadable files cannot ship a reference the target could read + if any(marker in content for marker in HELPER_REFERENCE_MARKERS): + violations.append(relative) + return sorted(violations) + + +def audit_build_folder(build_folder: str) -> None: + """Raises when the delivered build references the platform test runtime.""" + violations = find_platform_references(build_folder) + if violations: + raise PlatformBoundaryViolation( + "The generated build references Codeplain's private test runtime and cannot be published. " + f"Offending files: {', '.join(violations)}. " + "The implementation must not depend on codeplain-tty or CODEPLAIN_TTY_* in any way." + ) diff --git a/tests/test_platform_test_audit.py b/tests/test_platform_test_audit.py new file mode 100644 index 00000000..e1471130 --- /dev/null +++ b/tests/test_platform_test_audit.py @@ -0,0 +1,45 @@ +"""Tests for the client-side portability audit of a completed build.""" + +import pytest + +from render_machine.platform_test_audit import PlatformBoundaryViolation, audit_build_folder, find_platform_references + + +def test_a_clean_build_passes(tmp_path): + (tmp_path / "app.py").write_text("print('hello')\n") + (tmp_path / "requirements.txt").write_text("pytest==8.3.2\n") + + assert find_platform_references(str(tmp_path)) == [] + audit_build_folder(str(tmp_path)) # does not raise + + +def test_a_helper_reference_in_content_fails_the_audit(tmp_path): + (tmp_path / "app.py").write_text("subprocess.run(['codeplain-tty', 'send-text', 'x'])\n") + + with pytest.raises(PlatformBoundaryViolation, match="app.py"): + audit_build_folder(str(tmp_path)) + + +def test_an_environment_prefix_reference_fails_the_audit(tmp_path): + subdir = tmp_path / "src" + subdir.mkdir() + (subdir / "config.py").write_text("token = os.environ.get('CODEPLAIN_TTY_TOKEN')\n") + + with pytest.raises(PlatformBoundaryViolation, match="config.py"): + audit_build_folder(str(tmp_path)) + + +def test_a_helper_named_file_fails_the_audit(tmp_path): + (tmp_path / "codeplain-tty").write_text("#!/bin/sh\n") + + with pytest.raises(PlatformBoundaryViolation, match="codeplain-tty"): + audit_build_folder(str(tmp_path)) + + +def test_vendor_directories_are_not_audited(tmp_path): + vendored = tmp_path / "node_modules" / "junk" + vendored.mkdir(parents=True) + (vendored / "noise.js").write_text("// codeplain-tty mentioned in a vendored comment\n") + (tmp_path / "app.js").write_text("console.log('clean');\n") + + audit_build_folder(str(tmp_path)) # does not raise From c52fb97b401049cddbbb93c32d43b5e833072be7 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Mon, 17 Aug 2026 22:18:43 +0200 Subject: [PATCH 60/83] fix: strip end-of-line whitespace from wait-for needles (codeplain-tty) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transcript is rendered per line with trailing whitespace stripped, so a needle quoting a prompt verbatim — 'Master password: ' — could never match as written; every such wait burned its full timeout and stacked past the script budget. Deterministically reproduced from the cli-password-manager failure in the pty-with-codeplain-tty benchmark run (17 of the render's conformance failures were 120-second timeouts): the generated tests drove the CLI through codeplain-tty exactly as instructed, and hung only on the trailing space in 'wait-for "Master password: "'. The broker now strips end-of-line whitespace from wait-for and wait-until-absent needles before matching; a needle that is empty after the normalization is a usage error. --- render_machine/tty_broker.py | 7 +++++-- tests/test_tty_broker.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/render_machine/tty_broker.py b/render_machine/tty_broker.py index 0feb5093..1591e17d 100644 --- a/render_machine/tty_broker.py +++ b/render_machine/tty_broker.py @@ -239,9 +239,12 @@ def _text_arg(args: dict, key: str = "text") -> str: return value def _wait(self, args: dict, present: bool) -> dict: - text = self._text_arg(args) + # The transcript is rendered per line with trailing whitespace stripped, so a + # needle quoting a prompt verbatim — "Master password: " — could never match as + # written. End-of-line whitespace is stripped from the needle to compensate. + text = "\n".join(part.rstrip() for part in self._text_arg(args).split("\n")) if not text: - raise ValueError("the text to wait for must not be empty") + raise ValueError("the text to wait for must not be empty or whitespace-only") timeout = args.get("timeout", DEFAULT_WAIT_SECONDS) if not isinstance(timeout, (int, float)) or isinstance(timeout, bool) or timeout <= 0: raise ValueError("'timeout' must be a positive number of seconds") diff --git a/tests/test_tty_broker.py b/tests/test_tty_broker.py index 4e30935d..ce9b2ebc 100644 --- a/tests/test_tty_broker.py +++ b/tests/test_tty_broker.py @@ -128,6 +128,35 @@ def appear_later(): assert response["ok"] is True +def test_wait_for_matches_a_prompt_despite_trailing_whitespace(broker): + """The transcript is rendered per line with trailing whitespace stripped, so a + needle quoting a prompt verbatim — 'Master password: ' — could never match as + written. The broker strips end-of-line whitespace from the needle to compensate.""" + instance, process = broker + process.transcript = "Master password:" + response = command(instance, "wait-for", {"text": "Master password: ", "timeout": 2}) + assert response["ok"] is True + + +def test_wait_until_absent_applies_the_same_needle_normalization(broker): + instance, process = broker + process.transcript = "spinner" + + def clear_later(): + time.sleep(0.2) + process.transcript = "done" + + threading.Thread(target=clear_later, daemon=True).start() + response = command(instance, "wait-until-absent", {"text": "spinner ", "timeout": 5}) + assert response["ok"] is True + + +def test_a_whitespace_only_wait_needle_is_a_usage_error(broker): + instance, _ = broker + response = command(instance, "wait-for", {"text": " ", "timeout": 2}) + assert response["error"] == tty_protocol.ERROR_INVALID_REQUEST + + def test_wait_for_times_out_with_the_timeout_error(broker): instance, _ = broker response = command(instance, "wait-for", {"text": "never", "timeout": 0.2}) From d6777d19e0e32d236d9225c431d61819375fbb7b Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Tue, 18 Aug 2026 00:39:13 +0200 Subject: [PATCH 61/83] feat: typed client handling for ConformanceTestsFixExhausted The server's internal conformance-fix loop can spend its whole attempt budget on one functionality; that outcome now arrives as a structured 400 (error_code ConformanceTestsFixExhausted) instead of a raw 500. The client maps it to a typed exception carrying the server's message, so the render fails with 'Could not fix conformance tests issue ... Please review and rewrite the specification' rather than an opaque HTTPError. Unknown future codes still fall through unchanged. Found via the bookshelf-api failure in pty-with-codeplain-tty-retry2: the customers module's fix loop exhausted 10 server-side attempts, Flask 500'd, and the client died with no explanation in codeplain.log. --- codeplain_REST_api.py | 1 + plain2code_exceptions.py | 4 ++++ tests/test_rest_error_codes.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 tests/test_rest_error_codes.py diff --git a/codeplain_REST_api.py b/codeplain_REST_api.py index 1d089c24..65e41b47 100644 --- a/codeplain_REST_api.py +++ b/codeplain_REST_api.py @@ -19,6 +19,7 @@ ERROR_CODE_EXCEPTIONS = { "FunctionalRequirementTooComplex": plain2code_exceptions.FunctionalRequirementTooComplex, "ConflictingRequirements": plain2code_exceptions.ConflictingRequirements, + "ConformanceTestsFixExhausted": plain2code_exceptions.ConformanceTestsFixExhausted, "RenderingCreditBalanceTooLow": plain2code_exceptions.RenderingCreditBalanceTooLow, "LLMInternalError": plain2code_exceptions.LLMInternalError, "MissingResource": plain2code_exceptions.MissingResource, diff --git a/plain2code_exceptions.py b/plain2code_exceptions.py index b29bea98..1c3b7466 100644 --- a/plain2code_exceptions.py +++ b/plain2code_exceptions.py @@ -13,6 +13,10 @@ class RenderingCreditBalanceTooLow(Exception): pass +class ConformanceTestsFixExhausted(Exception): + """The server's conformance-fix loop spent its attempt budget on one functionality.""" + + class LLMInternalError(Exception): pass diff --git a/tests/test_rest_error_codes.py b/tests/test_rest_error_codes.py new file mode 100644 index 00000000..82f42818 --- /dev/null +++ b/tests/test_rest_error_codes.py @@ -0,0 +1,29 @@ +"""Tests for mapping API error codes onto typed client exceptions.""" + +from unittest.mock import MagicMock + +import pytest + +import plain2code_exceptions +from codeplain_REST_api import CodeplainAPI + + +def test_conformance_fix_exhaustion_maps_to_its_typed_exception(): + """The server reports fix-attempt exhaustion as a structured 400; the client raises + the matching typed exception so the render fails with the server's message instead + of a raw HTTP error.""" + api = CodeplainAPI(api_key="test-key", console=MagicMock()) + + with pytest.raises(plain2code_exceptions.ConformanceTestsFixExhausted, match="after 10 attempts"): + api._raise_for_error_code( + { + "error_code": "ConformanceTestsFixExhausted", + "message": "Could not fix conformance tests issue for functional requirement 1 after 10 attempts.", + } + ) + + +def test_an_unknown_error_code_still_falls_through_silently(): + api = CodeplainAPI(api_key="test-key", console=MagicMock()) + + api._raise_for_error_code({"error_code": "SomeFutureCode", "message": "whatever"}) # does not raise From 4ed6ae6c52025a9288cb704e49110ed96f728da0 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Tue, 18 Aug 2026 01:16:11 +0200 Subject: [PATCH 62/83] fix: expect-style sequencing for wait-for (codeplain-tty) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wait-for matched anywhere in the cumulative transcript, so the second of two sequential interactive targets in one test file matched the first target's stale prompt instantly, typed into a terminal nobody was reading yet, had those bytes discarded by the new target's TCSAFLUSH, and hung the whole script to its 120-second timeout. Deterministically reproduced with two getpass children with realistic (Argon2id-like) delays between prompts — exactly the cli-password-manager failure in pty-with-codeplain-tty-retry2 (FRID 2 exhausted; the trailing-space fix had removed the earlier failure mode, leaving this one). Each successful wait-for now consumes the transcript through its match, and later waits (including wait-until-absent) look only beyond that cursor; the transcript re-renders as the screen changes, so the cursor is clamped rather than trusted exactly. --- render_machine/tty_broker.py | 15 ++++++++++++++- tests/test_tty_broker.py | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/render_machine/tty_broker.py b/render_machine/tty_broker.py index 1591e17d..edb60bb9 100644 --- a/render_machine/tty_broker.py +++ b/render_machine/tty_broker.py @@ -73,6 +73,9 @@ def __init__(self, process: TerminalProcess) -> None: self._process = process self._token = secrets.token_hex(16) self._closing = threading.Event() + # Transcript position consumed by the last successful wait-for. Mutated only by + # the single server thread, which serves one request at a time. + self._match_cursor = 0 self._server: Optional[threading.Thread] = None self._listener: Optional[socket.socket] = None self._directory: Optional[str] = None @@ -250,8 +253,18 @@ def _wait(self, args: dict, present: bool) -> dict: raise ValueError("'timeout' must be a positive number of seconds") deadline = time.monotonic() + min(float(timeout), MAX_WAIT_SECONDS) while True: + # Expect-style sequencing: a successful wait-for consumes the transcript + # through its match, so every later wait matches only output produced after + # it. Without the cursor, the second of two sequential interactive targets + # matches the first one's stale prompt instantly and the test types into a + # terminal nobody is reading yet. The transcript re-renders as the screen + # changes, so the cursor is clamped rather than trusted exactly. transcript = self._process.normalized_output() - if (text in transcript) == present: + start = min(self._match_cursor, len(transcript)) + found_at = transcript.find(text, start) + if (found_at >= 0) == present: + if found_at >= 0: + self._match_cursor = found_at + len(text) return tty_protocol.ok_response() if self._closing.is_set(): return tty_protocol.error_response(tty_protocol.ERROR_SHUTTING_DOWN, "the execution is shutting down") diff --git a/tests/test_tty_broker.py b/tests/test_tty_broker.py index ce9b2ebc..58cc654a 100644 --- a/tests/test_tty_broker.py +++ b/tests/test_tty_broker.py @@ -157,6 +157,32 @@ def test_a_whitespace_only_wait_needle_is_a_usage_error(broker): assert response["error"] == tty_protocol.ERROR_INVALID_REQUEST +def test_wait_for_consumes_the_transcript_through_its_match(broker): + """Expect-style sequencing: a successful wait-for advances a cursor, so the next + wait-for matches only output produced after it. Without this, the second of two + sequential interactive children matches the first child's stale prompt instantly, + types into a terminal nobody is reading yet, and hangs the whole script.""" + instance, process = broker + process.transcript = "Master password:" + assert command(instance, "wait-for", {"text": "Master password:", "timeout": 2})["ok"] is True + + # The same text again, with no new output: must NOT match the stale occurrence. + response = command(instance, "wait-for", {"text": "Master password:", "timeout": 0.3}) + assert response["error"] == tty_protocol.ERROR_TIMEOUT + + # A second occurrence beyond the cursor matches. + process.transcript = "Master password:\nVault initialized\nMaster password:" + assert command(instance, "wait-for", {"text": "Master password:", "timeout": 2})["ok"] is True + + +def test_wait_until_absent_looks_only_beyond_the_cursor(broker): + instance, process = broker + process.transcript = "spinner" + assert command(instance, "wait-for", {"text": "spinner", "timeout": 2})["ok"] is True + # The consumed occurrence no longer counts as present. + assert command(instance, "wait-until-absent", {"text": "spinner", "timeout": 2})["ok"] is True + + def test_wait_for_times_out_with_the_timeout_error(broker): instance, _ = broker response = command(instance, "wait-for", {"text": "never", "timeout": 0.2}) From d3c15ab4563af5f474dc8deecf28caa361863caf Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Tue, 18 Aug 2026 14:42:31 +0200 Subject: [PATCH 63/83] fix: report why a render failed instead of a raw payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A/B arm ab-no-analyze-plus-tty-no-prompts gave up on python_tui at FRID 1 inside the unit-test fix loop and told the user only: ERROR codeplain: None ExitWithError printed whatever payload the failing action handed over, and actions reach that state by three routes: an encoded RenderError dict, a plain string, or nothing at all. The last route printed "None"; the first printed a raw dict repr, which is what the conformance-fix-exhausted path has been showing all along: ERROR codeplain: {'error': {'message': "The renderer was unable to ...", 'type': None, 'details': None}} Unwraps the reason at the log site and falls back to last_error_message, the same message the returned payload already carried — so the line a user reads is never less informative than the error the renderer returns. --- render_machine/actions/exit_with_error.py | 21 +++++++- tests/test_exit_with_error.py | 65 +++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 tests/test_exit_with_error.py diff --git a/render_machine/actions/exit_with_error.py b/render_machine/actions/exit_with_error.py index d49b956b..38e35f89 100644 --- a/render_machine/actions/exit_with_error.py +++ b/render_machine/actions/exit_with_error.py @@ -10,7 +10,7 @@ class ExitWithError(BaseAction): SUCCESSFUL_OUTCOME = "error_handled" def execute(self, render_context: RenderContext, previous_action_payload: Any | None): - console.error(previous_action_payload) + console.error(self._error_message(render_context, previous_action_payload)) render_context.codeplain_api.fail_functional_requirement( render_context.frid_context.frid, @@ -33,3 +33,22 @@ def execute(self, render_context: RenderContext, previous_action_payload: Any | message=render_context.last_error_message or "Unknown error", ).to_payload(), ) + + @staticmethod + def _error_message(render_context: RenderContext, previous_action_payload: Any | None) -> str: + """What the user is told the render stopped for. + + Actions reach this state by three routes: some hand over an encoded RenderError + payload, some a plain string, and some nothing at all. Printing the payload as it + arrives showed a raw dict for the first and the word "None" for the last, so the + reason is unwrapped here and falls back to the same message the returned payload + carries. + """ + if isinstance(previous_action_payload, dict): + error = previous_action_payload.get("error") + if isinstance(error, dict) and error.get("message"): + return error["message"] + elif previous_action_payload: + return str(previous_action_payload) + + return render_context.last_error_message or "Unknown error" diff --git a/tests/test_exit_with_error.py b/tests/test_exit_with_error.py new file mode 100644 index 00000000..5637e17b --- /dev/null +++ b/tests/test_exit_with_error.py @@ -0,0 +1,65 @@ +"""The operator-facing message on a failed render. + +`ExitWithError` prints the payload the failing action handed over, but not every path +into it supplies one — a render that gave up inside the unit-test fix loop arrives with +`None`, and the user was shown a bare `ERROR codeplain: None` with no reason. The encoded +payload already falls back to `last_error_message`; the console line must agree, so the +message a user sees is never less informative than the one the renderer returns. +""" + +from unittest.mock import MagicMock, patch + +from render_machine.actions.exit_with_error import ExitWithError +from render_machine.render_types import RenderError + + +def render_context(last_error_message=None): + context = MagicMock() + context.last_error_message = last_error_message + context.frid_context.frid = "2" + context.run_state.render_id = "render-id" + return context + + +def executed_with(payload, last_error_message=None): + context = render_context(last_error_message) + with patch("render_machine.actions.exit_with_error.console") as console: + outcome, encoded = ExitWithError().execute(context, payload) + return console.error.call_args[0][0], outcome, encoded + + +def test_the_failing_action_s_own_message_is_shown(): + shown, outcome, _ = executed_with("Conformance tests could not be fixed.") + + assert shown == "Conformance tests could not be fixed." + assert outcome == ExitWithError.SUCCESSFUL_OUTCOME + + +def test_a_missing_payload_falls_back_to_the_last_error_message(): + shown, _, _ = executed_with(None, last_error_message="The Unit Tests script has failed.") + + assert shown == "The Unit Tests script has failed." + + +def test_with_nothing_to_report_the_user_still_gets_words(): + shown, _, _ = executed_with(None) + + assert shown == "Unknown error" + + +def test_an_encoded_error_payload_is_unwrapped_to_its_reason(): + """The conformance-fix-exhausted path arrives as an encoded RenderError, which used + to reach the user as a raw dict repr.""" + payload = RenderError.encode(message="Could not produce an implementation that passes.").to_payload() + + shown, _, _ = executed_with(payload) + + assert shown == "Could not produce an implementation that passes." + + +def test_the_shown_message_matches_the_encoded_one(): + """Two sources of truth for the same failure would let the log and the returned + error disagree about why a render stopped.""" + shown, _, encoded = executed_with(None, last_error_message="The Unit Tests script has failed.") + + assert shown == encoded["error"]["message"] From a6fb7ba08e26a0c849bc310c2c2dba8bd59f6d08 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Tue, 18 Aug 2026 14:58:43 +0200 Subject: [PATCH 64/83] fix: type the unit-test exhaustion outcome, and interpolate the refactoring one The conformance-fix loop reports exhaustion as a typed, actionable error (105b215 server-side, d6777d1 client-side). The two client-side loops that can also give up did not. RenderFunctionalRequirement gives up when a functionality's unit tests still fail after MAX_CODE_GENERATION_RETRIES re-renders. It set last_error_message but returned no payload, which is the `ERROR codeplain: None` seen when ab-no-analyze-plus-tty-no-prompts abandoned python_tui at FRID 1. It now returns an encoded RenderError typed UNIT_TESTS_FIX_EXHAUSTED, carrying the frid and a message that mirrors the conformance wording: what could not be produced, for which functionality, and that the specification needs review. RefactorCode already returned a payload, but built its message without an f-string prefix, so the user was shown literal `{MAX_REFACTORING_ITERATIONS}` and `{render_context.frid_context.frid}`. No API change: unlike conformance, this loop is entirely client-side, so a REST error code would be dead code. --- render_machine/actions/refactor_code.py | 2 +- .../actions/render_functional_requirement.py | 15 ++++- tests/test_render_failure_payloads.py | 65 +++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 tests/test_render_failure_payloads.py diff --git a/render_machine/actions/refactor_code.py b/render_machine/actions/refactor_code.py index 60e95ebf..8d062367 100644 --- a/render_machine/actions/refactor_code.py +++ b/render_machine/actions/refactor_code.py @@ -22,7 +22,7 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | render_context.frid_context.refactoring_iteration += 1 if render_context.frid_context.refactoring_iteration >= MAX_REFACTORING_ITERATIONS: - error_message = "Refactoring iterations limit of {MAX_REFACTORING_ITERATIONS} reached for functionality {render_context.frid_context.frid}." + error_message = f"Refactoring iterations limit of {MAX_REFACTORING_ITERATIONS} reached for functionality {render_context.frid_context.frid}." render_context.last_error_message = error_message return ( diff --git a/render_machine/actions/render_functional_requirement.py b/render_machine/actions/render_functional_requirement.py index 07fa924e..c53217f0 100644 --- a/render_machine/actions/render_functional_requirement.py +++ b/render_machine/actions/render_functional_requirement.py @@ -20,9 +20,20 @@ class RenderFunctionalRequirement(BaseAction): def execute(self, render_context: RenderContext, _previous_action_payload: Any | None): if render_context.frid_context.functional_requirement_render_attempts >= MAX_CODE_GENERATION_RETRIES: - error_msg = f"Unittests could not be fixed after rendering the functionality {render_context.frid_context.frid} for the {MAX_CODE_GENERATION_RETRIES} times." + error_msg = ( + f"The renderer was unable to produce an implementation whose unit tests pass for functionality " + f"'{render_context.frid_context.frid}' after rendering it from scratch {MAX_CODE_GENERATION_RETRIES} " + f"times. Please review and rewrite the specification." + ) render_context.last_error_message = error_msg - return self.ITERATION_LIMIT_EXCEEDED_OUTCOME, None + return ( + self.ITERATION_LIMIT_EXCEEDED_OUTCOME, + RenderError.encode( + message=error_msg, + error_type="UNIT_TESTS_FIX_EXHAUSTED", + frid=render_context.frid_context.frid, + ).to_payload(), + ) render_context.frid_context.functional_requirement_render_attempts += 1 diff --git a/tests/test_render_failure_payloads.py b/tests/test_render_failure_payloads.py new file mode 100644 index 00000000..8841ce48 --- /dev/null +++ b/tests/test_render_failure_payloads.py @@ -0,0 +1,65 @@ +"""What the renderer reports when a fix loop gives up. + +The conformance-fix loop reports exhaustion as a typed, actionable error. The two +client-side loops that can also give up — re-rendering a functionality because its unit +tests never pass, and refactoring — did not: one returned no payload at all (the user saw +`ERROR codeplain: None`), the other built its message without an f-string prefix and +showed literal braces. Both are user-facing render outcomes, so both carry a reason. +""" + +from unittest.mock import MagicMock + +from render_machine.actions.refactor_code import MAX_REFACTORING_ITERATIONS, RefactorCode +from render_machine.actions.render_functional_requirement import ( + MAX_CODE_GENERATION_RETRIES, + RenderFunctionalRequirement, +) + + +def exhausted_context(**frid_attributes): + context = MagicMock() + context.frid_context.frid = "1" + context.last_error_message = None + for name, value in frid_attributes.items(): + setattr(context.frid_context, name, value) + return context + + +def test_unit_test_exhaustion_reports_a_typed_reason(): + context = exhausted_context(functional_requirement_render_attempts=MAX_CODE_GENERATION_RETRIES) + + outcome, payload = RenderFunctionalRequirement().execute(context, None) + + assert outcome == RenderFunctionalRequirement.ITERATION_LIMIT_EXCEEDED_OUTCOME + assert payload is not None, "a render that stopped here used to hand ExitWithError nothing to print" + assert payload["error"]["type"] == "UNIT_TESTS_FIX_EXHAUSTED" + + +def test_unit_test_exhaustion_names_the_functionality_and_what_to_do(): + context = exhausted_context(functional_requirement_render_attempts=MAX_CODE_GENERATION_RETRIES) + + _, payload = RenderFunctionalRequirement().execute(context, None) + message = payload["error"]["message"] + + assert "'1'" in message + assert "unit tests" in message + assert "specification" in message + + +def test_unit_test_exhaustion_logs_the_same_reason_it_returns(): + context = exhausted_context(functional_requirement_render_attempts=MAX_CODE_GENERATION_RETRIES) + + _, payload = RenderFunctionalRequirement().execute(context, None) + + assert context.last_error_message == payload["error"]["message"] + + +def test_refactoring_exhaustion_interpolates_its_message(): + context = exhausted_context(refactoring_iteration=MAX_REFACTORING_ITERATIONS - 1) + + _, payload = RefactorCode().execute(context, None) + message = payload["error"]["message"] + + assert "{" not in message, "the message was built without an f-string prefix" + assert str(MAX_REFACTORING_ITERATIONS) in message + assert "1" in message From 462be01b7f26ce620a3c17af5793cbe3de65ead1 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Tue, 18 Aug 2026 16:03:53 +0200 Subject: [PATCH 65/83] =?UTF-8?q?feat:=20instrument=20the=20fix=20loops=20?= =?UTF-8?q?=E2=80=94=20count=20attempts,=20detect=20a=20stuck=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every loss in the retry5-era benchmarks was a fix loop spending its whole budget re-patching one file against one unchanging failure: cli-password-manager 20 conformance attempts on vault_cli FRID 2, loglens the same on loglens_cli FRID 2, python_tui 16+ unit-test attempts on FRID 1. The loop could not tell it was stuck, and the only externally visible outcome was "did the render abort" — a rare binary event, too coarse to compare configurations against. Adds per-(module, FRID, loop) accounting for both loops. Each script run is recorded with a fingerprint of its failure output, normalised past the tokens that differ between two runs of the same failure (renderer temp paths, durations, addresses) while leaving genuinely different failures apart. Consecutive identical fingerprints are counted, and three in a row are reported as they happen rather than at exhaustion. Counts are emitted as a greppable line per FRID: [fix-loop] module=vault_cli frid=2 conformance=20 conformance_failed=20 max_repeat=20 on FRID completion, and for the whole render on both the completed and the failed path — the FRID that exhausted its budget never reaches FinishFunctionalRequirement, and its numbers are the ones worth having. This is measurement, not behaviour: nothing here changes what the renderer produces, so results stay comparable across the change. It makes iterations-to-convergence observable per FRID, which is what a rare binary outcome could not give: a near-continuous measure that says something after a single run. Switching strategy on detection (instrument/diagnose/delete rather than re-patch) is deliberately not included — that would change renderer output and break comparability with the runs in flight. --- render_machine/actions/exit_with_error.py | 6 + .../actions/finish_functional_requirement.py | 3 + .../actions/run_conformance_tests.py | 9 ++ render_machine/actions/run_unit_tests.py | 10 ++ render_machine/code_renderer.py | 3 + render_machine/fix_loop_metrics.py | 143 ++++++++++++++++++ render_machine/render_context.py | 5 + tests/test_fix_loop_metrics.py | 120 +++++++++++++++ tests/test_fix_loop_reporting.py | 76 ++++++++++ 9 files changed, 375 insertions(+) create mode 100644 render_machine/fix_loop_metrics.py create mode 100644 tests/test_fix_loop_metrics.py create mode 100644 tests/test_fix_loop_reporting.py diff --git a/render_machine/actions/exit_with_error.py b/render_machine/actions/exit_with_error.py index 38e35f89..25e03f0f 100644 --- a/render_machine/actions/exit_with_error.py +++ b/render_machine/actions/exit_with_error.py @@ -12,6 +12,12 @@ class ExitWithError(BaseAction): def execute(self, render_context: RenderContext, previous_action_payload: Any | None): console.error(self._error_message(render_context, previous_action_payload)) + # The FRID that failed is the one whose fix-loop counts matter most, and it never + # reaches FinishFunctionalRequirement — so the whole render's counts are reported + # here rather than lost with it. + for summary in render_context.fix_loop_metrics.render_summary(): + console.info(summary) + render_context.codeplain_api.fail_functional_requirement( render_context.frid_context.frid, module_name=render_context.module_name, diff --git a/render_machine/actions/finish_functional_requirement.py b/render_machine/actions/finish_functional_requirement.py index 7439c583..6842f3fb 100644 --- a/render_machine/actions/finish_functional_requirement.py +++ b/render_machine/actions/finish_functional_requirement.py @@ -1,6 +1,7 @@ from typing import Any from render_machine.actions.commit_implementation_code_changes import CommitImplementationCodeChanges +from render_machine.fix_loop_metrics import report_frid_fix_loop_summary from render_machine.render_context import RenderContext @@ -8,6 +9,8 @@ class FinishFunctionalRequirement(CommitImplementationCodeChanges): SUCCESSFUL_OUTCOME = "functional_requirement_finished" def execute(self, render_context: RenderContext, previous_action_payload: Any | None): + report_frid_fix_loop_summary(render_context, render_context.frid_context.frid) + render_context.plain_module.update_frid_in_module_metadata(render_context.frid_context.frid) super().execute(render_context, previous_action_payload) diff --git a/render_machine/actions/run_conformance_tests.py b/render_machine/actions/run_conformance_tests.py index 15eddfb1..38a6d685 100644 --- a/render_machine/actions/run_conformance_tests.py +++ b/render_machine/actions/run_conformance_tests.py @@ -4,6 +4,7 @@ import render_machine.render_utils as render_utils from plain2code_console import console from render_machine.actions.base_action import BaseAction +from render_machine.fix_loop_metrics import CONFORMANCE_LOOP, report_fix_loop_attempt from render_machine.platform_test_runtime import platform_test_runtime_available from render_machine.render_context import RenderContext from render_machine.render_types import RenderError @@ -63,6 +64,14 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | render_context, exit_code, conformance_tests_issue ) + report_fix_loop_attempt( + render_context, + loop=CONFORMANCE_LOOP, + frid=render_context.conformance_tests_running_context.current_testing_frid, + passed=exit_code == 0, + output=conformance_tests_issue, + ) + if exit_code == 0: if ( render_context.conformance_tests_running_context.current_testing_module_name diff --git a/render_machine/actions/run_unit_tests.py b/render_machine/actions/run_unit_tests.py index 79e29dbc..322dd990 100644 --- a/render_machine/actions/run_unit_tests.py +++ b/render_machine/actions/run_unit_tests.py @@ -4,6 +4,7 @@ import render_machine.render_utils as render_utils from plain2code_console import console from render_machine.actions.base_action import BaseAction +from render_machine.fix_loop_metrics import UNIT_LOOP, report_fix_loop_attempt from render_machine.render_context import RenderContext from render_machine.render_types import RenderError @@ -31,6 +32,15 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | render_context.script_execution_history.latest_unit_test_output_path = unittests_temp_log_file_path render_context.script_execution_history.should_update_script_outputs = True + + report_fix_loop_attempt( + render_context, + loop=UNIT_LOOP, + frid=render_context.frid_context.frid if render_context.frid_context else None, + passed=exit_code == 0, + output=unittests_issue, + ) + if exit_code == 0: return self.SUCCESSFUL_OUTCOME, None diff --git a/render_machine/code_renderer.py b/render_machine/code_renderer.py index cb8de1a3..d87c742c 100644 --- a/render_machine/code_renderer.py +++ b/render_machine/code_renderer.py @@ -3,6 +3,7 @@ from transitions.extensions.diagrams import HierarchicalGraphMachine +from plain2code_console import console from plain2code_events import ( RenderModuleCompleted, RenderModuleFailed, @@ -79,6 +80,8 @@ def run(self): break if self.render_context.state == States.RENDER_COMPLETED.value: + for summary in self.render_context.fix_loop_metrics.render_summary(): + console.info(summary) self.render_context.event_bus.publish( RenderModuleCompleted(module_name=self.render_context.module_name) ) diff --git a/render_machine/fix_loop_metrics.py b/render_machine/fix_loop_metrics.py new file mode 100644 index 00000000..821005e5 --- /dev/null +++ b/render_machine/fix_loop_metrics.py @@ -0,0 +1,143 @@ +"""Per-FRID accounting for the two fix loops, and detection of a loop that is stuck. + +Both loops — unit tests during implementation, conformance tests afterwards — patch, +re-run the script, and repeat until a budget runs out. Neither noticed when an attempt +changed nothing: benchmark renders spent twenty attempts rewriting one file against one +unchanging assertion before abandoning the render. Two things were missing, and this +module supplies both. + +*Detection*: a failure is fingerprinted, and consecutive identical fingerprints for the +same loop and FRID are counted. A streak means the loop is re-patching without effect, +which is the moment worth reporting — not the exhaustion twenty attempts later. + +*Measurement*: attempts and failures are counted per (module, FRID, loop), so a render +reports how many iterations convergence took rather than only whether it eventually gave +up. Exhaustion is a rare binary event and a poor basis for comparing configurations; +iterations-to-convergence is close to continuous and says something after a single run. + +Recording never affects rendering. These are observations. +""" + +import hashlib +import re +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple + +from plain2code_console import console + +UNIT_LOOP = "unit" +CONFORMANCE_LOOP = "conformance" + +# Parts of a test script's output that differ between two runs of the very same failure. +# Left in place, any one of them would make every attempt look novel and hide a stuck +# loop; over-normalising would do the reverse and merge failures that differ for real, so +# only demonstrably volatile tokens are erased. +_VOLATILE_PATTERNS = ( + re.compile(r"/tmp/[^\s'\"]+"), # renderer scratch paths: /tmp/tmpk8flk7f1.script_output + re.compile(r"\b0x[0-9a-fA-F]+\b"), # memory addresses + re.compile(r"\b[0-9a-fA-F]{8,}\b"), # hashes, uuids, run ids + re.compile(r"\b\d+(?:\.\d+)?\s*m?s\b"), # durations: "1335.821531 ms", "22.5s" + re.compile(r"duration_ms\s+[\d.]+"), +) + + +def failure_fingerprint(output: str) -> str: + """A stable identity for one failure, insensitive to run-to-run noise.""" + normalized = output or "" + for pattern in _VOLATILE_PATTERNS: + normalized = pattern.sub("", normalized) + normalized = " ".join(normalized.split()) + return hashlib.sha1(normalized.encode("utf-8", errors="replace")).hexdigest()[:12] + + +@dataclass +class _LoopCounters: + attempts: int = 0 + failures: int = 0 + max_repeat: int = 1 + last_fingerprint: Optional[str] = None + current_repeat: int = 0 + + +@dataclass +class FixLoopMetrics: + """One per render. Keyed by (module, frid) so a re-rendered FRID keeps accumulating.""" + + _counters: Dict[Tuple[str, str], Dict[str, _LoopCounters]] = field(default_factory=dict) + _order: List[Tuple[str, str]] = field(default_factory=list) + + def record(self, loop: str, module: str, frid: str, passed: bool, output: str) -> Optional[int]: + """Records one script run. Returns the streak length when this failure is a + repeat of the one before it in the same loop, otherwise None.""" + key = (module, str(frid)) + if key not in self._counters: + self._counters[key] = {} + self._order.append(key) + counters = self._counters[key].setdefault(loop, _LoopCounters()) + + counters.attempts += 1 + if passed: + counters.last_fingerprint = None + counters.current_repeat = 0 + return None + + counters.failures += 1 + fingerprint = failure_fingerprint(output) + if fingerprint == counters.last_fingerprint: + counters.current_repeat += 1 + counters.max_repeat = max(counters.max_repeat, counters.current_repeat) + return counters.current_repeat + + counters.last_fingerprint = fingerprint + counters.current_repeat = 1 + return None + + def frid_summary(self, module: str, frid: str) -> Optional[str]: + """One greppable line per FRID, or None if no script ran for it.""" + counters = self._counters.get((module, str(frid))) + if not counters: + return None + + parts = [f"[fix-loop] module={module} frid={frid}"] + for loop in (UNIT_LOOP, CONFORMANCE_LOOP): + if loop in counters: + parts.append(f"{loop}={counters[loop].attempts} {loop}_failed={counters[loop].failures}") + parts.append(f"max_repeat={max(loop.max_repeat for loop in counters.values())}") + return " ".join(parts) + + def render_summary(self) -> List[str]: + """Every FRID that ran a test script, in the order it was first reached.""" + summaries = (self.frid_summary(module, frid) for module, frid in self._order) + return [summary for summary in summaries if summary] + + +# How many identical failures in a row before the loop is called out. Two can happen when +# a patch legitimately addresses something else first; by three the loop is re-patching +# against a failure it is not moving. +REPEATED_FAILURE_WARNING_THRESHOLD = 3 + + +def report_fix_loop_attempt(render_context, loop: str, frid: Optional[str], passed: bool, output: str) -> None: + """Records one script run and tells the user when the loop stops making progress.""" + if frid is None: + return + + streak = render_context.fix_loop_metrics.record( + loop, module=render_context.module_name, frid=frid, passed=passed, output=output + ) + + if streak is not None and streak >= REPEATED_FAILURE_WARNING_THRESHOLD: + console.warning( + f"The {loop} tests for functionality {frid} have failed the same way {streak} times in a row. " + f"The last {streak - 1} fix attempts changed nothing that the tests can see." + ) + + +def report_frid_fix_loop_summary(render_context, frid: Optional[str]) -> None: + """Emits the per-FRID counts once the FRID is done, successfully or not.""" + if frid is None: + return + + summary = render_context.fix_loop_metrics.frid_summary(render_context.module_name, frid) + if summary: + console.info(summary) diff --git a/render_machine/render_context.py b/render_machine/render_context.py index 2ea7e806..3ce88595 100644 --- a/render_machine/render_context.py +++ b/render_machine/render_context.py @@ -13,6 +13,7 @@ from plain_modules import PlainModule from render_machine import triggers from render_machine.conformance_tests import CONFORMANCE_TESTS_DEFINITION_FILE_NAME, ConformanceTests +from render_machine.fix_loop_metrics import FixLoopMetrics from render_machine.render_types import ( AcceptanceTestPhase, ConformanceTestsRunningContext, @@ -95,6 +96,10 @@ def __init__( self.machine = None self.last_error_message: str | None = None + # Observations only — see render_machine/fix_loop_metrics.py. Deliberately not + # part of the snapshot: a rolled-back FRID still consumed the attempts it made, + # and hiding them would understate what convergence cost. + self.fix_loop_metrics = FixLoopMetrics() def set_machine(self, machine): self.machine = machine diff --git a/tests/test_fix_loop_metrics.py b/tests/test_fix_loop_metrics.py new file mode 100644 index 00000000..bdb30c89 --- /dev/null +++ b/tests/test_fix_loop_metrics.py @@ -0,0 +1,120 @@ +"""Tests for the fix-loop instrumentation. + +Every loss in the retry5-era benchmarks was a fix loop spending its whole budget +re-patching one file against one failure. The loop could not tell it was stuck, and the +only externally visible outcome was a rare binary "did the render abort" — too coarse to +compare configurations against. This turns both into observations: a streak counter that +names a repeated-identical failure while it is happening, and per-FRID attempt counts +that make convergence a continuous measure. + +The fingerprint has to survive the parts of a test-script's output that change on every +run — temp paths, durations, addresses — while still separating genuinely different +failures, since both mistakes destroy the signal in opposite directions. +""" + +from render_machine.fix_loop_metrics import CONFORMANCE_LOOP, UNIT_LOOP, FixLoopMetrics, failure_fingerprint + + +def test_the_same_failure_fingerprints_the_same(): + first = "FAILED test_header.py::test_subtitle\nAssertionError: subtitle not shown" + second = "FAILED test_header.py::test_subtitle\nAssertionError: subtitle not shown" + + assert failure_fingerprint(first) == failure_fingerprint(second) + + +def test_volatile_noise_does_not_change_the_fingerprint(): + """Two runs of one failing suite differ in temp path, duration and address.""" + first = "Output stored in /tmp/tmpk8flk7f1.script_output\n# duration_ms 1335.821531\nat 0x7f3a2b1c AssertionError: x" + second = "Output stored in /tmp/tmpy0wo02yi.script_output\n# duration_ms 22.5\nat 0x55e1ff90 AssertionError: x" + + assert failure_fingerprint(first) == failure_fingerprint(second) + + +def test_a_different_failure_fingerprints_differently(): + assert failure_fingerprint("AssertionError: subtitle not shown") != failure_fingerprint( + "AssertionError: button not found" + ) + + +def test_a_first_failure_is_not_a_repeat(): + metrics = FixLoopMetrics() + + streak = metrics.record(UNIT_LOOP, module="m", frid="1", passed=False, output="boom") + + assert streak is None + + +def test_the_same_failure_twice_reports_a_streak(): + metrics = FixLoopMetrics() + + metrics.record(UNIT_LOOP, module="m", frid="1", passed=False, output="boom") + streak = metrics.record(UNIT_LOOP, module="m", frid="1", passed=False, output="boom") + + assert streak == 2 + + +def test_a_different_failure_restarts_the_streak(): + metrics = FixLoopMetrics() + + metrics.record(UNIT_LOOP, module="m", frid="1", passed=False, output="boom") + metrics.record(UNIT_LOOP, module="m", frid="1", passed=False, output="boom") + streak = metrics.record(UNIT_LOOP, module="m", frid="1", passed=False, output="different") + + assert streak is None + + +def test_the_two_loops_are_counted_apart(): + """A unit-test failure must not extend a conformance streak, or vice versa.""" + metrics = FixLoopMetrics() + + metrics.record(UNIT_LOOP, module="m", frid="1", passed=False, output="boom") + streak = metrics.record(CONFORMANCE_LOOP, module="m", frid="1", passed=False, output="boom") + + assert streak is None + + +def test_each_frid_counts_its_own_attempts(): + metrics = FixLoopMetrics() + + metrics.record(UNIT_LOOP, module="m", frid="1", passed=False, output="a") + metrics.record(UNIT_LOOP, module="m", frid="1", passed=True, output="") + metrics.record(UNIT_LOOP, module="m", frid="2", passed=True, output="") + + assert metrics.frid_summary("m", "1") == "[fix-loop] module=m frid=1 unit=2 unit_failed=1 max_repeat=1" + assert metrics.frid_summary("m", "2") == "[fix-loop] module=m frid=2 unit=1 unit_failed=0 max_repeat=1" + + +def test_a_frid_summary_reports_both_loops_and_the_worst_streak(): + metrics = FixLoopMetrics() + + for _ in range(3): + metrics.record(CONFORMANCE_LOOP, module="m", frid="2", passed=False, output="same") + metrics.record(UNIT_LOOP, module="m", frid="2", passed=True, output="") + + summary = metrics.frid_summary("m", "2") + + assert "conformance=3" in summary + assert "conformance_failed=3" in summary + assert "unit=1" in summary + assert "max_repeat=3" in summary + + +def test_an_unseen_frid_has_no_summary(): + assert FixLoopMetrics().frid_summary("m", "9") is None + + +def test_the_render_summary_covers_every_frid_touched(): + metrics = FixLoopMetrics() + metrics.record(UNIT_LOOP, module="m", frid="1", passed=True, output="") + metrics.record(CONFORMANCE_LOOP, module="m", frid="2", passed=False, output="x") + + lines = metrics.render_summary() + + assert len(lines) == 2 + assert any("frid=1" in line for line in lines) + assert any("frid=2" in line for line in lines) + + +def test_the_render_summary_is_empty_when_no_script_ran(): + """A render that failed before any test script must not emit a misleading summary.""" + assert FixLoopMetrics().render_summary() == [] diff --git a/tests/test_fix_loop_reporting.py b/tests/test_fix_loop_reporting.py new file mode 100644 index 00000000..513d3530 --- /dev/null +++ b/tests/test_fix_loop_reporting.py @@ -0,0 +1,76 @@ +"""Tests for where the fix-loop instrumentation is wired in. + +Counting is only useful if every script run reaches the counter and the counts survive +the paths a render actually takes — including the failing one, where the FRID that +exhausted its budget never reaches FinishFunctionalRequirement and would otherwise take +its numbers with it. +""" + +from unittest.mock import MagicMock, patch + +from render_machine.actions.exit_with_error import ExitWithError +from render_machine.fix_loop_metrics import ( + CONFORMANCE_LOOP, + REPEATED_FAILURE_WARNING_THRESHOLD, + UNIT_LOOP, + FixLoopMetrics, + report_fix_loop_attempt, +) + + +def render_context(): + context = MagicMock() + context.module_name = "m" + context.fix_loop_metrics = FixLoopMetrics() + context.last_error_message = "stopped" + context.frid_context.frid = "1" + return context + + +def test_an_attempt_without_a_frid_is_not_counted(): + """Test scripts also run outside a functionality (module setup); those attempts + belong to no FRID and must not be attributed to one.""" + context = render_context() + + report_fix_loop_attempt(context, loop=UNIT_LOOP, frid=None, passed=False, output="boom") + + assert context.fix_loop_metrics.render_summary() == [] + + +def test_a_repeated_failure_warns_only_once_it_is_clearly_stuck(): + context = render_context() + + with patch("render_machine.fix_loop_metrics.console") as console: + for _ in range(REPEATED_FAILURE_WARNING_THRESHOLD - 1): + report_fix_loop_attempt(context, loop=CONFORMANCE_LOOP, frid="2", passed=False, output="same") + + assert console.warning.call_count == 0, "warned before the loop was demonstrably stuck" + + report_fix_loop_attempt(context, loop=CONFORMANCE_LOOP, frid="2", passed=False, output="same") + + assert console.warning.call_count == 1 + warning = console.warning.call_args[0][0] + assert "functionality 2" in warning + assert f"{REPEATED_FAILURE_WARNING_THRESHOLD} times in a row" in warning + + +def test_progress_keeps_the_loop_quiet(): + context = render_context() + + with patch("render_machine.fix_loop_metrics.console") as console: + for attempt in range(6): + report_fix_loop_attempt(context, loop=UNIT_LOOP, frid="1", passed=False, output=f"failure {attempt}") + + assert console.warning.call_count == 0 + + +def test_a_failed_render_still_reports_its_counts(): + """The exhausted FRID never reaches FinishFunctionalRequirement.""" + context = render_context() + report_fix_loop_attempt(context, loop=CONFORMANCE_LOOP, frid="2", passed=False, output="x") + + with patch("render_machine.actions.exit_with_error.console") as console: + ExitWithError().execute(context, None) + + reported = [call[0][0] for call in console.info.call_args_list] + assert any("[fix-loop]" in line and "frid=2" in line for line in reported) From 801d892d2a32462f91d86e7e3a8b3ded553a84c6 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Tue, 18 Aug 2026 17:11:02 +0200 Subject: [PATCH 66/83] fix: end every render's log file with what the render did MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exit summary reaches the terminal through Rich's print, which never touches logging, so codeplain.log simply stopped at whatever happened to be logged last. An artifact that ends mid-render is indistinguishable from a process that died silently — originally noted for hycu's ConflictingRequirements abort, and it just blocked a real diagnosis: cli-password-manager in codeplain-tty-capability-run2 rendered to completion (22 functionalities, 48m54s) yet delivered a build with no CLI entry point, and the captured log ended at 01:29:57 mid-render with no record of how it finished. Adds a trailer written through the codeplain logger — so it lands in the log file — on every exit path, since print_exit_summary is called from a finally: [render-trailer] outcome=completed render_id=... functionalities=22 render_time_s=2934 generated_code=- spec=vault_cli...plain [render-trailer] error= (failed renders only) Handlers are flushed explicitly; a process exiting immediately after would otherwise defeat the point of writing it. The trailer doubles as a probe: because it is written unconditionally, a captured log *without* one proves the file was truncated rather than merely uninformative — which is the open question behind the missing entry point. --- cli_output/render_summary.py | 47 ++++++++++++++++ tests/test_render_trailer.py | 101 +++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 tests/test_render_trailer.py diff --git a/cli_output/render_summary.py b/cli_output/render_summary.py index 6b62e33c..c825feaa 100644 --- a/cli_output/render_summary.py +++ b/cli_output/render_summary.py @@ -1,11 +1,19 @@ """Render completion summary display.""" +import logging from typing import Optional +import plain2code_logger from plain2code_console import console from plain2code_state import RunState from usage_summary import format_usage_summary +logger = logging.getLogger(plain2code_logger.LOGGER_NAME) + +# Marks the last line of a render's log file. Greppable on purpose: benchmark runs and +# support artifacts are read by tooling before they are read by a person. +RENDER_TRAILER_PREFIX = "[render-trailer]" + def print_exit_summary( run_state: RunState, @@ -30,3 +38,42 @@ def print_exit_summary( if not run_state.render_succeeded and error_message: console.error(error_message) console.quiet = True + + log_render_trailer(run_state, spec_filename, error_message) + + +def log_render_trailer( + run_state: RunState, + spec_filename: str, + error_message: Optional[str] = None, +) -> None: + """Writes the render's outcome to the log file, as its last line. + + The summary above reaches the terminal through Rich, which never touches logging, so + a captured `codeplain.log` used to stop at whatever happened to be logged last — + indistinguishable from a process that died silently. This ends every log with what + the render did, and because it is written on every exit path a log *without* a + trailer is itself evidence that the file was truncated. + """ + if run_state.render_succeeded: + outcome = "completed" + elif run_state.render_cancelled: + outcome = "cancelled" + else: + outcome = "failed" + + logger.info( + f"{RENDER_TRAILER_PREFIX} outcome={outcome} " + f"render_id={run_state.render_id} " + f"functionalities={run_state.rendered_functionalities} " + f"render_time_s={run_state.render_time_accumulated} " + f"generated_code={run_state.render_generated_code_path or '-'} " + f"spec={spec_filename}" + ) + if outcome == "failed" and error_message: + logger.error(f"{RENDER_TRAILER_PREFIX} error={error_message}") + + # The process may exit immediately after this; an unflushed trailer would defeat the + # purpose of writing one. + for handler in logger.handlers: + handler.flush() diff --git a/tests/test_render_trailer.py b/tests/test_render_trailer.py new file mode 100644 index 00000000..aeb58452 --- /dev/null +++ b/tests/test_render_trailer.py @@ -0,0 +1,101 @@ +"""Tests for the render trailer written to the log file. + +The pretty exit summary goes out through Rich's print, which bypasses logging entirely, +so `codeplain.log` simply stopped at whatever was logged last. An artifact that ends +mid-render is indistinguishable from a process that died silently — and when +cli-password-manager delivered a build with no entry point, that missing ending is +exactly what blocked the diagnosis. + +The trailer is therefore both the fix and a probe: it is the last thing written on every +exit path, so an artifact without one proves the log was truncated rather than merely +uninformative. +""" + +import logging +from unittest.mock import MagicMock + +import pytest + +from cli_output.render_summary import RENDER_TRAILER_PREFIX, log_render_trailer + + +def run_state(succeeded=True, cancelled=False): + state = MagicMock() + state.render_succeeded = succeeded + state.render_cancelled = cancelled + state.render_id = "5f1c25b7" + state.rendered_functionalities = 22 + state.render_time_accumulated = 2934 + state.render_generated_code_path = "/int-plainlang-examples/cli-password-manager/dist/" + return state + + +@pytest.fixture +def trailer_lines(caplog): + caplog.set_level(logging.INFO, logger="codeplain") + + def emit(state, spec="vault_cli.plain", error_message=None): + caplog.clear() + log_render_trailer(state, spec, error_message) + return [record.getMessage() for record in caplog.records if RENDER_TRAILER_PREFIX in record.getMessage()] + + return emit + + +def test_a_completed_render_records_its_outcome(trailer_lines): + lines = trailer_lines(run_state()) + + assert len(lines) == 1 + assert "outcome=completed" in lines[0] + + +def test_the_trailer_carries_what_a_later_diagnosis_needs(trailer_lines): + lines = trailer_lines(run_state()) + + assert "render_id=5f1c25b7" in lines[0] + assert "functionalities=22" in lines[0] + assert "render_time_s=2934" in lines[0] + assert "generated_code=/int-plainlang-examples/cli-password-manager/dist/" in lines[0] + + +def test_a_missing_generated_code_path_is_explicit(trailer_lines): + """The run that delivered no entry point reported this field empty; it has to be + legible in the log rather than a blank gap.""" + state = run_state() + state.render_generated_code_path = None + + lines = trailer_lines(state) + + assert "generated_code=-" in lines[0] + + +def test_a_failed_render_records_the_reason(trailer_lines): + lines = trailer_lines(run_state(succeeded=False), error_message="Conformance tests could not be fixed.") + + assert any("outcome=failed" in line for line in lines) + assert any("error=Conformance tests could not be fixed." in line for line in lines) + + +def test_a_cancelled_render_is_not_reported_as_failed(trailer_lines): + lines = trailer_lines(run_state(succeeded=False, cancelled=True)) + + assert "outcome=cancelled" in lines[0] + + +def test_a_failure_without_a_message_still_ends_the_log(trailer_lines): + lines = trailer_lines(run_state(succeeded=False)) + + assert any("outcome=failed" in line for line in lines) + + +def test_the_trailer_is_flushed_so_it_survives_an_abrupt_exit(): + handler = MagicMock() + handler.level = logging.NOTSET # logging compares record.levelno against this + logger = logging.getLogger("codeplain") + logger.addHandler(handler) + try: + log_render_trailer(run_state(), "vault_cli.plain") + finally: + logger.removeHandler(handler) + + assert handler.flush.called From 312759585ffdfb705b4ea844db11f7ffeb2d137d Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Tue, 18 Aug 2026 19:13:32 +0200 Subject: [PATCH 67/83] fix: stop the portability audit from failing renders over their own tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The final audit walked the whole build folder and treated any codeplain-tty reference as a violation, on the stated assumption that internal tests live outside it. They do not: a module's build folder carries a conformance_tests/ subtree, and those tests drive the helper because that is what it is for. So CreateDist raised PlatformBoundaryViolation *after* the render succeeded. The build was never copied, run_state.render_generated_code_path stayed empty, and the harness fell back to whatever tree it could find. Run against the cli-password-manager artifact from codeplain-tty-capability-run2, the audit flags four files, all of them conformance tests — which is why that render reported 22 functionalities in 48m54s and delivered a build with no CLI entry point, and why code_retrieval_example scored 0/10 with generated_code=- in the wave2 re-baseline. Two changes: - Internal test trees (conformance_tests, acceptance_tests, dist_conformance_tests) are exempt from the audit. Delivered code beside them is still audited — a test asserted for that explicitly. - The exit summary and the render trailer now report an error whenever there is one, not only when the render failed. A render that completes its functionalities and then raises on the way out printed a success banner, logged no reason at all, and exited 1 — which is exactly how this went unnoticed across a whole benchmark run. --- cli_output/render_summary.py | 8 ++++++-- render_machine/platform_test_audit.py | 17 +++++++++++++---- tests/test_platform_test_audit.py | 25 +++++++++++++++++++++++++ tests/test_render_trailer.py | 11 +++++++++++ 4 files changed, 55 insertions(+), 6 deletions(-) diff --git a/cli_output/render_summary.py b/cli_output/render_summary.py index c825feaa..b0fbe5d5 100644 --- a/cli_output/render_summary.py +++ b/cli_output/render_summary.py @@ -35,7 +35,11 @@ def print_exit_summary( msg += format_usage_summary(run_state.rendered_functionalities, run_state.render_time_accumulated) + "\n" console.print(msg) - if not run_state.render_succeeded and error_message: + # Reported whenever there is one. A render can finish its functionalities and still + # raise on the way out — publishing the build, for instance — and that combination + # used to print the success banner and swallow the reason entirely, leaving a caller + # with a tick mark and a non-zero exit code. + if error_message: console.error(error_message) console.quiet = True @@ -70,7 +74,7 @@ def log_render_trailer( f"generated_code={run_state.render_generated_code_path or '-'} " f"spec={spec_filename}" ) - if outcome == "failed" and error_message: + if error_message: logger.error(f"{RENDER_TRAILER_PREFIX} error={error_message}") # The process may exit immediately after this; an unflushed trailer would defeat the diff --git a/render_machine/platform_test_audit.py b/render_machine/platform_test_audit.py index bce3df6d..28859ec4 100644 --- a/render_machine/platform_test_audit.py +++ b/render_machine/platform_test_audit.py @@ -7,9 +7,11 @@ application must run in a clean environment where none of Codeplain's test tooling exists. -Internal conformance and acceptance tests live outside the build folder (in the -module's tests tree), so nothing here needs an allowlist: any hit inside the build -folder is a violation. +Internal conformance and acceptance tests are exempt. They are what the helper exists +for, and they do turn up inside the audited tree — a module's build folder can carry a +`conformance_tests/` subtree, and benchmark renders showed the audit failing them. +Auditing those is not a stricter boundary, it is a false one: it aborts a successful +render over test code that is never delivered. """ import os @@ -22,6 +24,11 @@ # Directories that carry no delivered source and may be large. SKIPPED_DIRECTORIES = {".git", ".venv", "node_modules", "__pycache__", ".tmp", "dist", "build", "target"} +# Internal test trees, which are allowed to drive the helper and are never delivered. +# Named separately from the above because skipping them is a boundary decision, not a +# performance one. +INTERNAL_TEST_DIRECTORIES = {"conformance_tests", "acceptance_tests", "dist_conformance_tests"} + MAX_AUDITED_FILE_BYTES = 4 * 1024 * 1024 # a delivered source file larger than this is not source @@ -33,7 +40,9 @@ def find_platform_references(build_folder: str) -> List[str]: """Build-folder-relative paths of files referencing the platform test helper.""" violations = [] for root, directories, file_names in os.walk(build_folder): - directories[:] = [name for name in directories if name not in SKIPPED_DIRECTORIES] + directories[:] = [ + name for name in directories if name not in SKIPPED_DIRECTORIES and name not in INTERNAL_TEST_DIRECTORIES + ] for file_name in file_names: path = os.path.join(root, file_name) relative = os.path.relpath(path, build_folder) diff --git a/tests/test_platform_test_audit.py b/tests/test_platform_test_audit.py index e1471130..054a3209 100644 --- a/tests/test_platform_test_audit.py +++ b/tests/test_platform_test_audit.py @@ -43,3 +43,28 @@ def test_vendor_directories_are_not_audited(tmp_path): (tmp_path / "app.js").write_text("console.log('clean');\n") audit_build_folder(str(tmp_path)) # does not raise + + +def test_internal_test_trees_inside_the_build_folder_are_exempt(tmp_path): + """A module's build folder can carry its conformance tests, and those are what the + helper exists for. Auditing them aborted successful benchmark renders at CreateDist: + the build was never published, `generated_code` stayed empty, and the delivered + artifact was whatever the harness could salvage.""" + (tmp_path / "conformance_tests" / "init").mkdir(parents=True) + (tmp_path / "conformance_tests" / "init" / "test_init.py").write_text( + "subprocess.run(['codeplain-tty', 'wait-for', 'Password:'])\n" + ) + (tmp_path / "conformance_tests" / "conformance_tests.json").write_text('{"codeplain-tty": true}\n') + (tmp_path / "vault.py").write_text("print('hello')\n") + + assert find_platform_references(str(tmp_path)) == [] + audit_build_folder(str(tmp_path)) # does not raise + + +def test_delivered_code_is_still_audited_alongside_them(tmp_path): + """Exempting the test tree must not exempt the application beside it.""" + (tmp_path / "conformance_tests").mkdir() + (tmp_path / "conformance_tests" / "test_init.py").write_text("codeplain-tty wait-for\n") + (tmp_path / "vault.py").write_text("os.environ['CODEPLAIN_TTY_ENDPOINT']\n") + + assert find_platform_references(str(tmp_path)) == ["vault.py"] diff --git a/tests/test_render_trailer.py b/tests/test_render_trailer.py index aeb58452..675f3131 100644 --- a/tests/test_render_trailer.py +++ b/tests/test_render_trailer.py @@ -99,3 +99,14 @@ def test_the_trailer_is_flushed_so_it_survives_an_abrupt_exit(): logger.removeHandler(handler) assert handler.flush.called + + +def test_a_completed_render_that_still_raised_reports_the_reason(trailer_lines): + """A render can finish its functionalities and raise on the way out — publishing the + build, for instance. That combination printed a success banner, logged no reason, and + exited non-zero, which is how a boundary-audit failure went unnoticed across a whole + benchmark run.""" + lines = trailer_lines(run_state(succeeded=True), error_message="The generated build references ...") + + assert any("outcome=completed" in line for line in lines) + assert any("error=The generated build references ..." in line for line in lines) From b025eeeb95cd2ff2ed7d25e55ea170bc0e0b584b Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Tue, 18 Aug 2026 19:39:20 +0200 Subject: [PATCH 68/83] style: apply black to test_fix_loop_metrics CI caught what my local check missed: I ran black with --quiet piped into tail, which suppresses the "would reformat" message and discards the exit code, so the gate looked clean when it was not. --- tests/test_fix_loop_metrics.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_fix_loop_metrics.py b/tests/test_fix_loop_metrics.py index bdb30c89..1fedac4d 100644 --- a/tests/test_fix_loop_metrics.py +++ b/tests/test_fix_loop_metrics.py @@ -24,7 +24,9 @@ def test_the_same_failure_fingerprints_the_same(): def test_volatile_noise_does_not_change_the_fingerprint(): """Two runs of one failing suite differ in temp path, duration and address.""" - first = "Output stored in /tmp/tmpk8flk7f1.script_output\n# duration_ms 1335.821531\nat 0x7f3a2b1c AssertionError: x" + first = ( + "Output stored in /tmp/tmpk8flk7f1.script_output\n# duration_ms 1335.821531\nat 0x7f3a2b1c AssertionError: x" + ) second = "Output stored in /tmp/tmpy0wo02yi.script_output\n# duration_ms 22.5\nat 0x55e1ff90 AssertionError: x" assert failure_fingerprint(first) == failure_fingerprint(second) From 97dfca81c63189ee088baf53a12e662165b3ec45 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Tue, 18 Aug 2026 19:44:08 +0200 Subject: [PATCH 69/83] fix: keep the renderer's own log out of the portability audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second false-positive class, found on loglens in the wave2 re-baseline: the only file the audit flagged was codeplain.log, which records broker activity and module names and so contains codeplain-tty by construction. The directory exemption added in 3127595 does not cover it — it is a file, not a tree. Against the real artifacts the audit now reports nothing for either loglens or cli-password-manager, where it previously flagged one and four files. --- render_machine/platform_test_audit.py | 7 +++++++ tests/test_platform_test_audit.py | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/render_machine/platform_test_audit.py b/render_machine/platform_test_audit.py index 28859ec4..054a0844 100644 --- a/render_machine/platform_test_audit.py +++ b/render_machine/platform_test_audit.py @@ -31,6 +31,11 @@ MAX_AUDITED_FILE_BYTES = 4 * 1024 * 1024 # a delivered source file larger than this is not source +# Suffixes that are never delivered source. Logs matter most: the renderer's own +# codeplain.log records broker activity and module names, so auditing it reports the +# render's diagnostics as if the application had referenced the helper. +SKIPPED_FILE_SUFFIXES = (".log",) + class PlatformBoundaryViolation(Exception): """A delivered build references Codeplain's private test tooling.""" @@ -44,6 +49,8 @@ def find_platform_references(build_folder: str) -> List[str]: name for name in directories if name not in SKIPPED_DIRECTORIES and name not in INTERNAL_TEST_DIRECTORIES ] for file_name in file_names: + if file_name.endswith(SKIPPED_FILE_SUFFIXES): + continue path = os.path.join(root, file_name) relative = os.path.relpath(path, build_folder) if any(marker in file_name for marker in HELPER_REFERENCE_MARKERS): diff --git a/tests/test_platform_test_audit.py b/tests/test_platform_test_audit.py index 054a3209..b3a74ed8 100644 --- a/tests/test_platform_test_audit.py +++ b/tests/test_platform_test_audit.py @@ -68,3 +68,13 @@ def test_delivered_code_is_still_audited_alongside_them(tmp_path): (tmp_path / "vault.py").write_text("os.environ['CODEPLAIN_TTY_ENDPOINT']\n") assert find_platform_references(str(tmp_path)) == ["vault.py"] + + +def test_the_renderers_own_log_is_not_audited(tmp_path): + """codeplain.log records broker activity and module names, so auditing it reports the + render's own diagnostics as if the application had referenced the helper. Seen on + loglens, where the log was the single flagged file.""" + (tmp_path / "codeplain.log").write_text("DEBUG codeplain: the codeplain-tty broker thread started\n") + (tmp_path / "cli.js").write_text("console.log('hi')\n") + + assert find_platform_references(str(tmp_path)) == [] From b4d8d7df01e003e0ac7c63b1fb0be6376507c366 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Tue, 18 Aug 2026 19:47:52 +0200 Subject: [PATCH 70/83] refactor: name the helper module plain2code_tty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the plain2code* naming the rest of the client entry points use. The console script stays `codeplain-tty` — only the module providing it moves, so generated tests and the broker's argv are unaffected. Deliberately unchanged: the `codeplain_tty` key in the platform_test_runtime capability descriptor. That is the wire contract the server parses, not a module name, and renaming it would break capability negotiation against every deployed API. The portability audit gains `plain2code_tty` as a marker, since that is now the importable name a generated build could reach for. The old module name is kept alongside it so a build that copied an older reference is still caught. --- codeplain_tty.py => plain2code_tty.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename codeplain_tty.py => plain2code_tty.py (100%) diff --git a/codeplain_tty.py b/plain2code_tty.py similarity index 100% rename from codeplain_tty.py rename to plain2code_tty.py From ac3f37ec418f7b3deaf558529442655b862d858a Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Tue, 18 Aug 2026 19:57:56 +0200 Subject: [PATCH 71/83] refactor: drop the dead pre-rename marker from the audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codeplain-tty is new on this branch, so no build can carry a reference to the old module name — the marker guarded against nothing and would have flagged the capability descriptor key if it ever appeared in a file. --- render_machine/platform_test_audit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/render_machine/platform_test_audit.py b/render_machine/platform_test_audit.py index 054a0844..8ecff3b0 100644 --- a/render_machine/platform_test_audit.py +++ b/render_machine/platform_test_audit.py @@ -19,7 +19,7 @@ # The executable name, the module name, and the environment prefix — the same markers # the API's response validation uses. -HELPER_REFERENCE_MARKERS = ("codeplain-tty", "codeplain_tty", "CODEPLAIN_TTY_") +HELPER_REFERENCE_MARKERS = ("codeplain-tty", "plain2code_tty", "CODEPLAIN_TTY_") # Directories that carry no delivered source and may be large. SKIPPED_DIRECTORIES = {".git", ".venv", "node_modules", "__pycache__", ".tmp", "dist", "build", "target"} From 2d08d6da6d54f0ec0ffc60ada84f8058aedc361f Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Tue, 18 Aug 2026 19:59:36 +0200 Subject: [PATCH 72/83] fix: complete the plain2code_tty rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit b4d8d7d moved the module but committed only the rename — the entry point and the broker's argv path stayed on the old name, so the pushed tree declared a console script for a module that no longer existed. Local runs passed because they ran against the working tree, not the commit. --- pyproject.toml | 2 +- render_machine/tty_broker.py | 2 +- tests/test_tty_broker.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index de617621..94859244 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dev = [ [project.scripts] codeplain = "plain2code:main" -codeplain-tty = "codeplain_tty:main" +codeplain-tty = "plain2code_tty:main" # Derive the version from the git tag (e.g. v0.3.8 -> 0.3.8). The version is # baked into the package metadata at build time; system_config.py reads it back diff --git a/render_machine/tty_broker.py b/render_machine/tty_broker.py index edb60bb9..1620026e 100644 --- a/render_machine/tty_broker.py +++ b/render_machine/tty_broker.py @@ -116,7 +116,7 @@ def _install_helper(self) -> None: one matching the broker that is serving it, whatever way Codeplain was installed. """ assert self._directory is not None - module = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "codeplain_tty.py") + module = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "plain2code_tty.py") bin_dir = os.path.join(self._directory, "bin") os.makedirs(bin_dir) helper = os.path.join(bin_dir, "codeplain-tty") diff --git a/tests/test_tty_broker.py b/tests/test_tty_broker.py index 58cc654a..d98d22b7 100644 --- a/tests/test_tty_broker.py +++ b/tests/test_tty_broker.py @@ -411,7 +411,7 @@ def test_the_helper_reports_the_runtime_unavailable_outside_a_test_run(tmp_path) result = subprocess.run( [ sys.executable, - os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "codeplain_tty.py"), + os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "plain2code_tty.py"), "wait-for", "x", ], From 7558e08f88392a4c2143933f2277a2eaaa16bf50 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Tue, 18 Aug 2026 22:12:26 +0200 Subject: [PATCH 73/83] Narrate headless renders, and split the fix-loop streak per loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A headless render suppresses Rich output and attaches no TUI handler, so its only sink was the log file: the process said nothing on stdout for its entire run, and the benchmark harness surfaced that file only after the render ended. A cli-password-manager render wedged for four hours and a healthy one are indistinguishable from outside — the wedged one emitted 858 lines, all in its first two minutes. Headless is the mode CI and benchmarks use, so the diagnostics added for exactly this problem could only ever be read once the budget was spent. Attaching a second handler exposed a latent hazard: one record is passed to every handler in turn, and both formatters rewrote record.msg in place, so each would re-indent the other's continuation lines. They now format a copy. frid_summary reported one max_repeat as the max across both loops, which reads as whichever loop the reader has in mind. The counts sat right beside it: run4's cli-password-manager showed max_repeat=7 next to conformance=42, and the log showed conformance had in fact repeated three times while the unit loop was the one stuck at seven. Each loop now reports its own streak. The aggregate stays -- the benchmark series already collected is indexed on it, and dropping it mid experiment would strand those runs. --- plain2code.py | 10 +++ plain2code_logger.py | 36 ++++---- render_machine/fix_loop_metrics.py | 10 ++- tests/test_fix_loop_metrics.py | 29 ++++++- tests/test_headless_logging.py | 134 +++++++++++++++++++++++++++++ 5 files changed, 201 insertions(+), 18 deletions(-) create mode 100644 tests/test_headless_logging.py diff --git a/plain2code.py b/plain2code.py index f28f3bad..6a4621a7 100644 --- a/plain2code.py +++ b/plain2code.py @@ -137,6 +137,16 @@ def setup_logging( handler = LoggingHandler(event_bus, run_state) handler.setFormatter(formatter) root_logger.addHandler(handler) + else: + # Headless has no TUI to narrate the render and suppresses Rich output, so + # without this the process says nothing on stdout for its entire run: a CI or + # benchmark job cannot distinguish a render wedged for four hours from a + # healthy one until the log file is collected at the end. StreamHandler flushes + # per record, so these arrive as they happen. + stdout_handler = logging.StreamHandler(sys.stdout) + stdout_handler.setFormatter(file_formatter) + stdout_handler.setLevel(configured_log_level) + root_logger.addHandler(stdout_handler) if log_to_file: try: diff --git a/plain2code_logger.py b/plain2code_logger.py index 0203b81f..51a00358 100644 --- a/plain2code_logger.py +++ b/plain2code_logger.py @@ -1,3 +1,4 @@ +import copy import logging from event_bus import EventBus @@ -17,18 +18,28 @@ FILE_LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" +def _with_indented_message(record, indent: str): + """A copy of the record whose continuation lines are indented. + + One record is handed to every attached handler in turn, so a formatter that + rewrites `record.msg` in place is rewriting it for the handlers that come after + it too — a headless render logging to both stdout and a file would indent each + continuation line twice, once per formatter. Copying keeps each handler's + formatting local to that handler. + """ + indented = copy.copy(record) + indented.msg = record.getMessage().replace("\n", "\n" + indent) + indented.args = None # getMessage() already interpolated them into msg + return indented + + class IndentedFormatter(logging.Formatter): def __init__(self, fmt=None, datefmt=None, indent=16): super().__init__(fmt=fmt, datefmt=datefmt) self._indent = " " * indent def format(self, record): - original_message = record.getMessage() - - modified_message = original_message.replace("\n", "\n" + self._indent) - - record.msg = modified_message - return super().format(record) + return super().format(_with_indented_message(record, self._indent)) class ElapsedTimeFormatter(logging.Formatter): @@ -51,16 +62,11 @@ def format(self, record): seconds = offset_seconds % 60 elapsed_time = f"[{hours:02d}:{minutes:02d}:{seconds:02d}]" - # Add elapsed_time to the record so it can be used in the format string - record.elapsed_time = elapsed_time - - # Handle multi-line messages with proper indentation - original_message = record.getMessage() - indent = " " * len(elapsed_time + " ") - modified_message = original_message.replace("\n", "\n" + indent) - record.msg = modified_message + # Continuation lines line up under the message, past the timestamp column. + indented = _with_indented_message(record, " " * len(elapsed_time + " ")) + indented.elapsed_time = elapsed_time - return super().format(record) + return super().format(indented) class LoggingHandler(logging.Handler): diff --git a/render_machine/fix_loop_metrics.py b/render_machine/fix_loop_metrics.py index 821005e5..025bd0d9 100644 --- a/render_machine/fix_loop_metrics.py +++ b/render_machine/fix_loop_metrics.py @@ -101,7 +101,15 @@ def frid_summary(self, module: str, frid: str) -> Optional[str]: parts = [f"[fix-loop] module={module} frid={frid}"] for loop in (UNIT_LOOP, CONFORMANCE_LOOP): if loop in counters: - parts.append(f"{loop}={counters[loop].attempts} {loop}_failed={counters[loop].failures}") + parts.append( + f"{loop}={counters[loop].attempts} " + f"{loop}_failed={counters[loop].failures} " + f"{loop}_max_repeat={counters[loop].max_repeat}" + ) + # The per-loop streaks are what a reader needs — the two loops wedge for + # different reasons and are worth different responses — but the aggregate stays + # because the benchmark series already collected is indexed on this one number, + # and dropping it would strand those runs mid-experiment. parts.append(f"max_repeat={max(loop.max_repeat for loop in counters.values())}") return " ".join(parts) diff --git a/tests/test_fix_loop_metrics.py b/tests/test_fix_loop_metrics.py index 1fedac4d..35c447b8 100644 --- a/tests/test_fix_loop_metrics.py +++ b/tests/test_fix_loop_metrics.py @@ -82,8 +82,14 @@ def test_each_frid_counts_its_own_attempts(): metrics.record(UNIT_LOOP, module="m", frid="1", passed=True, output="") metrics.record(UNIT_LOOP, module="m", frid="2", passed=True, output="") - assert metrics.frid_summary("m", "1") == "[fix-loop] module=m frid=1 unit=2 unit_failed=1 max_repeat=1" - assert metrics.frid_summary("m", "2") == "[fix-loop] module=m frid=2 unit=1 unit_failed=0 max_repeat=1" + assert ( + metrics.frid_summary("m", "1") + == "[fix-loop] module=m frid=1 unit=2 unit_failed=1 unit_max_repeat=1 max_repeat=1" + ) + assert ( + metrics.frid_summary("m", "2") + == "[fix-loop] module=m frid=2 unit=1 unit_failed=0 unit_max_repeat=1 max_repeat=1" + ) def test_a_frid_summary_reports_both_loops_and_the_worst_streak(): @@ -101,6 +107,25 @@ def test_a_frid_summary_reports_both_loops_and_the_worst_streak(): assert "max_repeat=3" in summary +def test_each_loop_reports_its_own_streak(): + """The aggregate cannot say which loop wedged, and the two wedge for different + reasons: a stuck unit loop means the implementation is not moving, a stuck + conformance loop can mean the test script never even ran. Reading a run where the + unit loop repeated seven times and conformance only three, the single number says + 7 and invites the reader to attribute it to conformance.""" + metrics = FixLoopMetrics() + for _ in range(7): + metrics.record(UNIT_LOOP, module="m", frid="3", passed=False, output="same unit failure") + for _ in range(3): + metrics.record(CONFORMANCE_LOOP, module="m", frid="3", passed=False, output="same conformance failure") + + summary = metrics.frid_summary("m", "3") + + assert "unit_max_repeat=7" in summary + assert "conformance_max_repeat=3" in summary + assert "max_repeat=7" in summary # the aggregate stays, for continuity of the series + + def test_an_unseen_frid_has_no_summary(): assert FixLoopMetrics().frid_summary("m", "9") is None diff --git a/tests/test_headless_logging.py b/tests/test_headless_logging.py new file mode 100644 index 00000000..54a7b4b6 --- /dev/null +++ b/tests/test_headless_logging.py @@ -0,0 +1,134 @@ +"""Tests for what a headless render says while it is running. + +Headless suppresses Rich output and attaches no TUI handler, so before this the only +sink was the log file — the process was silent on stdout for its entire run and a +benchmark job could not tell a render wedged for four hours from a healthy one until +the file was collected at the end. A four-hour cli-password-manager render produced +858 lines, all of them in its first two minutes. + +The second half of this file guards the hazard that adding a second handler exposes: +one record is handed to every handler in turn, so a formatter that rewrites +`record.msg` in place corrupts the output of the handlers after it. +""" + +import logging +import sys +from unittest.mock import MagicMock + +import pytest + +from plain2code import setup_logging +from plain2code_logger import LOGGER_NAME, ElapsedTimeFormatter, IndentedFormatter + + +@pytest.fixture +def run_state(): + state = MagicMock() + state.get_live_render_time.return_value = 3661 # 01:01:01 + return state + + +def record(message, args=None): + return logging.LogRecord( + name="codeplain", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg=message, + args=args, + exc_info=None, + ) + + +def test_the_elapsed_formatter_stamps_the_render_time(run_state): + assert ElapsedTimeFormatter(run_state).format(record("hello")) == "[01:01:01] INFO codeplain: hello" + + +def test_continuation_lines_are_indented_past_the_timestamp(run_state): + formatted = ElapsedTimeFormatter(run_state).format(record("first\nsecond")) + + assert formatted == "[01:01:01] INFO codeplain: first\n second" + + +def test_formatting_twice_does_not_indent_twice(run_state): + """Two handlers share one record. Formatting must be idempotent from the record's + point of view, or the file log inherits the stdout log's indentation.""" + formatter = ElapsedTimeFormatter(run_state) + entry = record("first\nsecond") + + first_pass = formatter.format(entry) + second_pass = formatter.format(entry) + + assert first_pass == second_pass + + +def test_two_different_formatters_do_not_corrupt_each_other(run_state): + """The real pairing in a headless render that also logs to a file.""" + entry = record("first\nsecond") + + IndentedFormatter("%(levelname)s:%(name)s:%(message)s").format(entry) + elapsed = ElapsedTimeFormatter(run_state).format(entry) + + assert elapsed == "[01:01:01] INFO codeplain: first\n second" + + +def test_the_indented_formatter_leaves_the_record_alone(run_state): + entry = record("first\nsecond") + + IndentedFormatter("%(levelname)s:%(name)s:%(message)s").format(entry) + + assert entry.msg == "first\nsecond" + + +def test_arguments_are_interpolated_exactly_once(run_state): + """The copy carries an already-interpolated message, so its args must be cleared — + otherwise the parent formatter interpolates a second time and raises.""" + assert ElapsedTimeFormatter(run_state).format(record("value is %s", ("x",))).endswith("value is x") + + +@pytest.fixture +def configured_handlers(run_state): + """setup_logging mutates the process-wide "codeplain" logger; restore it after.""" + logger = logging.getLogger(LOGGER_NAME) + saved_handlers, saved_level = list(logger.handlers), logger.level + + def configure(headless): + logger.handlers = [] + args = MagicMock() + args.verbose = False + args.logging_config_path = None + setup_logging(args, MagicMock(), run_state, log_to_file=False, log_file_path="", headless=headless) + return logger.handlers + + yield configure + + logger.handlers, logger.level = saved_handlers, saved_level + + +def stdout_handlers(handlers): + return [h for h in handlers if type(h) is logging.StreamHandler and h.stream is sys.stdout] + + +def test_a_headless_render_narrates_to_stdout(configured_handlers): + assert len(stdout_handlers(configured_handlers(headless=True))) == 1 + + +def test_the_stdout_handler_carries_the_elapsed_time_format(configured_handlers): + """It has to be readable as a render log, not just present.""" + handler = stdout_handlers(configured_handlers(headless=True))[0] + + assert isinstance(handler.formatter, ElapsedTimeFormatter) + + +def test_an_interactive_render_does_not_duplicate_output_on_stdout(configured_handlers): + """The TUI already draws the log; a second copy on stdout would fight it for the + terminal.""" + assert stdout_handlers(configured_handlers(headless=False)) == [] + + +def test_the_formatter_survives_a_run_state_that_cannot_report_time(): + """A record can be logged before the render clock exists; it must still be readable.""" + broken = MagicMock() + broken.get_live_render_time.side_effect = RuntimeError("no clock yet") + + assert ElapsedTimeFormatter(broken).format(record("hello")) == "[00:00:00] INFO codeplain: hello" From ff1f2fcc1b8af78a27d82d3c1a1c3e0ec9f1e532 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Wed, 19 Aug 2026 09:49:46 +0200 Subject: [PATCH 74/83] Regenerate the conformance test once the fix loop stops making progress The conformance fix loop's failure mode is repetition, not slowness: it re-sends the same fix request, gets back a patch that changes nothing the test can see, and does it again. A wedged cli-password-manager render failed conformance 20 times on one functionality with a streak of 8 -- while its unit loop never failed once -- spent 5h20m, and still scored 1/16. The same signature appears on bookshelf-api, so it is not one spec's quirk. Until now nothing in the loop could tell that state from ordinary slow convergence, so it patched until the attempt limit. Regenerating the conformance test is a genuinely different move: it discards the test the loop cannot satisfy rather than editing code against it again. That path already existed for the attempt limit, so this reaches it as soon as there is evidence instead of after twenty blind patches, and it draws on the same re-render budget -- once spent, the loop patches to the limit and stops, exactly as before. The threshold is the one the warning already uses, and three is validated in both directions by a single run: a healthy functionality repeated a failure twice and then converged, so two would abandon tests that were about to pass, while the wedged one went from three to eight and never recovered. Reading the streak needed an accessor. record() returns it to whoever is recording, but the fix action runs after the test action and has to ask again from its own call site. --- .../actions/fix_conformance_test.py | 57 ++++++++ render_machine/fix_loop_metrics.py | 16 ++ tests/test_conformance_strategy_switch.py | 138 ++++++++++++++++++ tests/test_fix_loop_metrics.py | 36 +++++ 4 files changed, 247 insertions(+) create mode 100644 tests/test_conformance_strategy_switch.py diff --git a/render_machine/actions/fix_conformance_test.py b/render_machine/actions/fix_conformance_test.py index 428feec6..0f4796fc 100644 --- a/render_machine/actions/fix_conformance_test.py +++ b/render_machine/actions/fix_conformance_test.py @@ -7,6 +7,7 @@ from plain2code_console import RETRY_COLOR, console from plain2code_exceptions import InternalClientError from render_machine.actions.base_action import BaseAction +from render_machine.fix_loop_metrics import CONFORMANCE_LOOP, REPEATED_FAILURE_WARNING_THRESHOLD from render_machine.implementation_code_helpers import ImplementationCodeHelpers from render_machine.platform_test_runtime import advertised_platform_test_runtime from render_machine.render_context import RenderContext @@ -15,6 +16,10 @@ MAX_CONFORMANCE_TEST_FIX_ATTEMPTS = 20 MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS = 1 +# Marks the moment the loop stops patching and regenerates the test instead. Greppable +# on purpose, like the other benchmark markers. +STRATEGY_SWITCH_PREFIX = "[strategy-switch]" + class FixConformanceTest(BaseAction): IMPLEMENTATION_CODE_NOT_UPDATED = "implementation_code_not_updated" @@ -27,6 +32,54 @@ class FixConformanceTest(BaseAction): ISSUE_REASON_CODE_CONFLICTING_REQUIREMENTS = 2 ISSUE_REASON_CODE_CONFLICTING_ACCEPTANCE_TESTS = 3 + @staticmethod + def _should_regenerate_instead_of_patching(render_context: RenderContext) -> bool: + """Whether the loop has proven that patching the implementation is not working. + + A conformance failure that repeats identically means the last fixes changed + nothing the test can see. Benchmark runs put numbers on that: across a wedged + cli-password-manager render the conformance loop failed 20 times on one + functionality with a streak of 8, while its unit loop never failed once — the + implementation was not what needed changing. Left alone the loop spent 5h20m + that way and still scored 1/16, and the same signature appears on bookshelf-api, + so it is not one spec's quirk. + + Regenerating the conformance test is a genuinely different move: it discards the + test the loop cannot satisfy rather than editing code against it again. That path + already exists for the attempt limit; this reaches it as soon as there is + evidence, instead of after twenty blind patches. + + The threshold is the one the warning already uses. Three is validated in both + directions by a single run: a healthy functionality repeated twice and then + recovered, so two would fire on renders that are fine, while the wedged one went + from three to eight and never recovered. + """ + ctx = render_context.conformance_tests_running_context + + # Bounded by the same re-render budget as the attempt-limit path, so the switch + # cannot cycle: once it is spent, the loop patches until the limit and stops. + if ctx.conformance_tests_render_attempts >= MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS: + return False + + streak = render_context.fix_loop_metrics.current_streak( + CONFORMANCE_LOOP, + module=render_context.module_name, + frid=ctx.current_testing_frid, + ) + if streak < REPEATED_FAILURE_WARNING_THRESHOLD: + return False + + console.warning( + f"{STRATEGY_SWITCH_PREFIX} module={render_context.module_name} " + f"frid={ctx.current_testing_frid} conformance_streak={streak} " + f"action=regenerate_conformance_tests" + ) + console.info( + f"Patching the implementation has not changed what the conformance tests for functionality " + f"{ctx.current_testing_frid} report, {streak} times running. Regenerating those tests instead." + ) + return True + def execute(self, render_context: RenderContext, previous_action_payload: Any | None): ctx = render_context.conformance_tests_running_context ctx.fix_attempts += 1 @@ -43,6 +96,10 @@ def execute(self, render_context: RenderContext, previous_action_payload: Any | ctx.regenerating_conformance_tests = True return self.REGENERATE_CONFORMANCE_TESTS_OUTCOME, None + if self._should_regenerate_instead_of_patching(render_context): + ctx.regenerating_conformance_tests = True + return self.REGENERATE_CONFORMANCE_TESTS_OUTCOME, None + console.info(f"Running conformance tests attempt {ctx.fix_attempts + 1}.") console.info( diff --git a/render_machine/fix_loop_metrics.py b/render_machine/fix_loop_metrics.py index 025bd0d9..f4a7701f 100644 --- a/render_machine/fix_loop_metrics.py +++ b/render_machine/fix_loop_metrics.py @@ -92,6 +92,22 @@ def record(self, loop: str, module: str, frid: str, passed: bool, output: str) - counters.current_repeat = 1 return None + def current_streak(self, loop: str, module: str, frid: Optional[str]) -> int: + """How many times in a row this loop has just failed the same way. + + `record` returns the streak as it happens, which is enough to warn but not to + decide: the fix action runs after the test action and needs to ask the question + again, from its own call site. A missing frid answers zero rather than raising, + because nothing was recorded under one either — `report_fix_loop_attempt` skips + those runs. + """ + if frid is None: + return 0 + counters = self._counters.get((module, str(frid))) + if not counters or loop not in counters: + return 0 + return counters[loop].current_repeat + def frid_summary(self, module: str, frid: str) -> Optional[str]: """One greppable line per FRID, or None if no script ran for it.""" counters = self._counters.get((module, str(frid))) diff --git a/tests/test_conformance_strategy_switch.py b/tests/test_conformance_strategy_switch.py new file mode 100644 index 00000000..6d1ac6fb --- /dev/null +++ b/tests/test_conformance_strategy_switch.py @@ -0,0 +1,138 @@ +"""Tests for switching strategy when the conformance fix loop stops making progress. + +The loop's failure mode is not slowness, it is repetition: it re-sends the same fix +request, gets back a patch that changes nothing the test can see, and does it again. A +wedged cli-password-manager render failed conformance 20 times on one functionality with +a streak of 8 while its unit loop never failed once, spent 5h20m, and still scored 1/16. +The same signature appears on bookshelf-api, so it is not one spec's quirk. + +Regenerating the conformance test is the different move — it discards the test the loop +cannot satisfy instead of editing code against it again. These tests pin when that +happens, and just as importantly when it does not: the threshold has to sit above what a +healthy functionality does, or every good render pays for it. +""" + +from unittest.mock import MagicMock, patch + +from render_machine.actions.fix_conformance_test import ( + MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS, + STRATEGY_SWITCH_PREFIX, + FixConformanceTest, +) +from render_machine.fix_loop_metrics import CONFORMANCE_LOOP, UNIT_LOOP, FixLoopMetrics + +MODULE = "vault_cli" +FRID = "2" + + +def render_context(identical_failures=0, render_attempts=0, output="AssertionError: prompt not shown"): + context = MagicMock() + context.module_name = MODULE + context.fix_loop_metrics = FixLoopMetrics() + for _ in range(identical_failures): + context.fix_loop_metrics.record(CONFORMANCE_LOOP, module=MODULE, frid=FRID, passed=False, output=output) + + ctx = context.conformance_tests_running_context + ctx.current_testing_frid = FRID + ctx.current_testing_module_name = MODULE + ctx.conformance_tests_render_attempts = render_attempts + ctx.fix_attempts = 4 # mid-loop: well below the attempt limit + ctx.regenerating_conformance_tests = False + return context + + +def decides_to_regenerate(context): + with patch("render_machine.actions.fix_conformance_test.console"): + return FixConformanceTest._should_regenerate_instead_of_patching(context) + + +def test_a_loop_that_repeats_a_failure_three_times_regenerates_the_test(): + assert decides_to_regenerate(render_context(identical_failures=3)) is True + + +def test_a_healthy_functionality_that_repeats_twice_is_left_alone(): + """Observed in a real render: a functionality repeated a failure twice and then + converged. A threshold of two would abandon tests that were about to pass.""" + assert decides_to_regenerate(render_context(identical_failures=2)) is False + + +def test_a_first_failure_does_not_trigger_it(): + assert decides_to_regenerate(render_context(identical_failures=1)) is False + + +def test_a_loop_that_has_not_run_yet_does_not_trigger_it(): + assert decides_to_regenerate(render_context(identical_failures=0)) is False + + +def test_failures_that_differ_do_not_count_as_repeats(): + """A loop making progress produces new failures; only identical ones prove it is + stuck.""" + context = render_context(identical_failures=0) + for output in ("first failure", "second failure", "third failure"): + context.fix_loop_metrics.record(CONFORMANCE_LOOP, module=MODULE, frid=FRID, passed=False, output=output) + + assert decides_to_regenerate(context) is False + + +def test_a_render_with_no_functionality_under_test_does_not_trigger_it(): + """current_testing_frid is optional; nothing is recorded under a missing one.""" + context = render_context(identical_failures=3) + context.conformance_tests_running_context.current_testing_frid = None + + assert decides_to_regenerate(context) is False + + +def test_a_stuck_unit_loop_does_not_regenerate_conformance_tests(): + """The two loops fail for different reasons and warrant different responses.""" + context = render_context(identical_failures=0) + for _ in range(5): + context.fix_loop_metrics.record(UNIT_LOOP, module=MODULE, frid=FRID, passed=False, output="same") + + assert decides_to_regenerate(context) is False + + +def test_the_switch_is_spent_once_and_cannot_cycle(): + """Regeneration draws on the same budget as the attempt-limit path. Once it is used + the loop patches to the limit and stops, rather than regenerating forever.""" + spent = render_context(identical_failures=8, render_attempts=MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS) + + assert decides_to_regenerate(spent) is False + + +def test_the_switch_is_announced_in_a_greppable_form(): + """Benchmark runs are read by tooling before they are read by a person.""" + context = render_context(identical_failures=4) + + with patch("render_machine.actions.fix_conformance_test.console") as console: + FixConformanceTest._should_regenerate_instead_of_patching(context) + + announced = console.warning.call_args[0][0] + assert STRATEGY_SWITCH_PREFIX in announced + assert f"module={MODULE}" in announced + assert f"frid={FRID}" in announced + assert "conformance_streak=4" in announced + assert "action=regenerate_conformance_tests" in announced + + +def test_the_action_returns_the_regeneration_outcome_and_marks_the_context(): + """The early return has to reach the state machine the same way the attempt-limit + path does, or the render carries on patching regardless of the decision.""" + context = render_context(identical_failures=3) + + with patch("render_machine.actions.fix_conformance_test.console"): + outcome, payload = FixConformanceTest().execute(context, {"previous_conformance_tests_issue": "boom"}) + + assert outcome == FixConformanceTest.REGENERATE_CONFORMANCE_TESTS_OUTCOME + assert payload is None + assert context.conformance_tests_running_context.regenerating_conformance_tests is True + + +def test_the_api_is_not_asked_for_another_patch_when_switching(): + """The point of the switch is to stop spending fix requests on a test the loop + cannot satisfy.""" + context = render_context(identical_failures=3) + + with patch("render_machine.actions.fix_conformance_test.console"): + FixConformanceTest().execute(context, {"previous_conformance_tests_issue": "boom"}) + + context.codeplain_api.fix_conformance_tests_issue.assert_not_called() diff --git a/tests/test_fix_loop_metrics.py b/tests/test_fix_loop_metrics.py index 35c447b8..c8314cda 100644 --- a/tests/test_fix_loop_metrics.py +++ b/tests/test_fix_loop_metrics.py @@ -126,6 +126,42 @@ def test_each_loop_reports_its_own_streak(): assert "max_repeat=7" in summary # the aggregate stays, for continuity of the series +def test_the_current_streak_is_readable_after_the_fact(): + """The fix action runs after the test action and has to ask again, from its own call + site, rather than relying on what record() returned to someone else.""" + metrics = FixLoopMetrics() + for _ in range(3): + metrics.record(CONFORMANCE_LOOP, module="m", frid="1", passed=False, output="same") + + assert metrics.current_streak(CONFORMANCE_LOOP, "m", "1") == 3 + + +def test_the_current_streak_resets_when_the_failure_changes(): + metrics = FixLoopMetrics() + for _ in range(3): + metrics.record(CONFORMANCE_LOOP, module="m", frid="1", passed=False, output="same") + metrics.record(CONFORMANCE_LOOP, module="m", frid="1", passed=False, output="different") + + assert metrics.current_streak(CONFORMANCE_LOOP, "m", "1") == 1 + + +def test_the_current_streak_clears_when_the_loop_passes(): + metrics = FixLoopMetrics() + for _ in range(3): + metrics.record(CONFORMANCE_LOOP, module="m", frid="1", passed=False, output="same") + metrics.record(CONFORMANCE_LOOP, module="m", frid="1", passed=True, output="") + + assert metrics.current_streak(CONFORMANCE_LOOP, "m", "1") == 0 + + +def test_an_unrun_loop_has_no_streak(): + metrics = FixLoopMetrics() + metrics.record(UNIT_LOOP, module="m", frid="1", passed=False, output="boom") + + assert metrics.current_streak(CONFORMANCE_LOOP, "m", "1") == 0 + assert metrics.current_streak(CONFORMANCE_LOOP, "m", "9") == 0 + + def test_an_unseen_frid_has_no_summary(): assert FixLoopMetrics().frid_summary("m", "9") is None From 598b4f5e739c9ab11133a869829846180bcbcb63 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Wed, 19 Aug 2026 12:09:25 +0200 Subject: [PATCH 75/83] Bound the broker's accept() so shutdown does not leak a thread per execution Closing a socket interrupts a blocked accept() on macOS and BSD but not on Linux, where the FD is released and the parked syscall is not woken. The broker closed its listener from close() and joined the server thread with a five second bound, which on Linux always expired: the thread stayed in accept() on a socket whose path close() had just unlinked, so nothing would ever connect to it again and it never returned. A render runs one broker per test-script execution, so that is one thread and one socket held for the life of the render, every time. A benchmark render logged the give-up message on 275 of 275 conformance runs and was still going; the count tracks executions exactly and is uncorrelated with anything going wrong -- a run with zero script timeouts logged it 49 times out of 48. Against a typical thousand-FD limit a long render could plausibly exhaust it, and the failure would surface as the broker refusing to start rather than as anything resembling the cause. The message deserved attention for a second reason: firing on every close, it could no longer distinguish a genuinely stuck thread from ordinary operation. Giving accept() a poll bound lets the loop look at the closing flag on its own schedule rather than depending on a client arriving. socket.timeout subclasses OSError, so it has to be caught ahead of the handler that treats an error as shutdown. The three leak tests would only fail on Linux, where the platform is unkind, so they cannot fail on a developer laptop. The decisive test sets the closing flag and leaves the listener open, which pins the loop's own behaviour everywhere. --- render_machine/tty_broker.py | 9 ++++ tests/test_tty_broker.py | 80 +++++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/render_machine/tty_broker.py b/render_machine/tty_broker.py index 1620026e..ed30a99f 100644 --- a/render_machine/tty_broker.py +++ b/render_machine/tty_broker.py @@ -57,6 +57,12 @@ POLL_INTERVAL_SECONDS = 0.05 +# How long an idle accept() waits before looking at the closing flag again. Closing a +# socket does not wake a blocked accept() in another thread on Linux, so without a bound +# here the server thread parks forever and close() can only give up on it: one thread and +# one socket held open per broker, for the life of the render. +ACCEPT_POLL_SECONDS = 0.25 + # How long close() waits for the server thread after closing the listener under it. CLOSE_JOIN_SECONDS = 5.0 @@ -99,6 +105,7 @@ def start(self) -> None: try: listener.bind(self.endpoint) listener.listen(8) + listener.settimeout(ACCEPT_POLL_SECONDS) # so shutdown does not depend on a client arriving except OSError: listener.close() self._remove_artifacts() @@ -167,6 +174,8 @@ def _serve(self) -> None: while not self._closing.is_set(): try: connection, _ = listener.accept() + except socket.timeout: + continue # nobody called; go back and look at the closing flag except OSError: # the listener was closed under the loop — expected shutdown return try: diff --git a/tests/test_tty_broker.py b/tests/test_tty_broker.py index d98d22b7..e3c16179 100644 --- a/tests/test_tty_broker.py +++ b/tests/test_tty_broker.py @@ -22,7 +22,7 @@ from render_machine import tty_protocol from render_machine.terminal_process import InputDisposition, InputWriteResult, TerminalInputDriver -from render_machine.tty_broker import TtyBroker, broker_supported +from render_machine.tty_broker import ACCEPT_POLL_SECONDS, CLOSE_JOIN_SECONDS, TtyBroker, broker_supported posix_only = pytest.mark.skipif( sys.platform == "win32", @@ -276,6 +276,84 @@ def test_close_removes_every_artifact_and_stops_the_server(): instance.close() # idempotent +def live_broker_threads() -> list: + return [thread for thread in threading.enumerate() if thread.name == "codeplain-tty-broker" and thread.is_alive()] + + +def test_the_server_stops_when_told_to_without_the_listener_being_closed(): + """The decisive case, and the only one that reproduces the defect off Linux. + + Closing a socket interrupts a blocked accept() on macOS/BSD but NOT on Linux, so the + leak is invisible on a developer laptop and certain in the benchmark container — a + render there logged the give-up message on 275 of 275 broker closes. Asserting the + loop honours the closing flag on its own, with the listener left open, pins the + behaviour everywhere instead of on whichever platform happens to be kind.""" + instance = TtyBroker(FakeProcess()) + instance.start() + try: + instance._closing.set() + instance._server.join(timeout=ACCEPT_POLL_SECONDS * 8) + + assert not instance._server.is_alive() + finally: + instance.close() + + +def test_close_leaves_no_server_thread_behind(): + """Closing a socket does not wake a blocked accept() in another thread on Linux, so a + broker whose accept() has no bound parks forever and close() can only give up on it. + A render runs one broker per test-script execution, so that is a thread and a socket + leaked on every conformance run — a benchmark render reached 275 before finishing. + Artifacts being gone is not evidence the thread went with them.""" + before = len(live_broker_threads()) + instance = TtyBroker(FakeProcess()) + instance.start() + instance.close() + + assert len(live_broker_threads()) == before + + +def test_close_does_not_wait_out_the_join_bound(): + """The leak was silent because close() still returns — it just burns the full join + timeout first and logs a debug line. Shutdown has to be prompt, not merely eventual.""" + instance = TtyBroker(FakeProcess()) + instance.start() + + started = time.monotonic() + instance.close() + elapsed = time.monotonic() - started + + assert elapsed < CLOSE_JOIN_SECONDS / 2 + + +def test_brokers_do_not_accumulate_threads_across_executions(): + """One broker per test-script execution is the real usage pattern; the cost of the + leak is that it compounds over a render.""" + before = len(live_broker_threads()) + for _ in range(5): + instance = TtyBroker(FakeProcess()) + instance.start() + instance.close() + + assert len(live_broker_threads()) == before + + +def test_the_server_still_serves_after_idling_through_accept_polls(): + """The bound makes accept() wake repeatedly; a client arriving after several idle + cycles must still be served rather than dropped by the polling loop.""" + process = FakeProcess() + instance = TtyBroker(process) + instance.start() + try: + time.sleep(ACCEPT_POLL_SECONDS * 3) + response = command(instance, tty_protocol.COMMAND_SEND_TEXT, {"text": "x"}) + + assert "error" not in response + assert process.written + finally: + instance.close() + + def test_the_broker_is_a_typed_input_driver(broker): instance, _ = broker assert isinstance(instance, TerminalInputDriver) From f9414ce9ce56b77a6773116c4a36b1f5d4c4c840 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Wed, 19 Aug 2026 13:49:26 +0200 Subject: [PATCH 76/83] Give up on patching when a fix loop stops making progress, in either loop The conformance switch shipped in ff1f2fc fired on three identical failures, which generalised from the one wedge that had been captured in full. Two runs since then show the generalisation was too narrow in both directions. A loop can fail every single time without ever repeating itself. A task-manager render failed one functionality's conformance tests 40 times out of 40 with a longest identical run of two, exhausted its whole budget, and reached regeneration only through the attempt limit -- exactly as it would have without the switch. No streak threshold can see that: at three it never fires, at two it fires on renders that are converging. So repetition joins a second arm that counts consecutive failures however they look, set at six because the highest failure count on a functionality that then recovered is four. The unit loop wedges too, which the earlier reasoning had ruled out from a single render where it never failed at all. A cli-password-manager render wedged in both loops on the same functionality: unit repeated 17 times, conformance 3. The unit loop already gives up at its attempt limit and restarts the functionality, so it reaches the same decision on the same evidence, eleven minutes sooner in that render. Restarting is destructive enough to want a careful threshold, and the data makes it easy: across three renders every healthy functionality finished with unit_max_repeat=1, and nothing was observed between that and the 17. Both loops now ask one question, so a future change to what counts as stuck cannot drift between them. Neither switch turns a wedge into a pass, and the wall-clock case is weaker than it first looked: the run that exercised the conformance switch finished in 2h35m against prior wedged runs of 2h43m, 4h09m, 4h47m and 5h20m -- the fastest, but only just below a range already 2h37m wide, and a single run cannot separate the switch from that spread. What they bound is the waste. --- .../actions/fix_conformance_test.py | 36 +++--- render_machine/fix_loop_metrics.py | 51 +++++++- render_machine/render_context.py | 23 +++- tests/test_conformance_strategy_switch.py | 73 +++++++++-- tests/test_unit_strategy_switch.py | 113 ++++++++++++++++++ 5 files changed, 258 insertions(+), 38 deletions(-) create mode 100644 tests/test_unit_strategy_switch.py diff --git a/render_machine/actions/fix_conformance_test.py b/render_machine/actions/fix_conformance_test.py index 0f4796fc..e8a68a7c 100644 --- a/render_machine/actions/fix_conformance_test.py +++ b/render_machine/actions/fix_conformance_test.py @@ -7,7 +7,7 @@ from plain2code_console import RETRY_COLOR, console from plain2code_exceptions import InternalClientError from render_machine.actions.base_action import BaseAction -from render_machine.fix_loop_metrics import CONFORMANCE_LOOP, REPEATED_FAILURE_WARNING_THRESHOLD +from render_machine.fix_loop_metrics import CONFORMANCE_LOOP, STRATEGY_SWITCH_PREFIX, stalled_reason from render_machine.implementation_code_helpers import ImplementationCodeHelpers from render_machine.platform_test_runtime import advertised_platform_test_runtime from render_machine.render_context import RenderContext @@ -16,10 +16,6 @@ MAX_CONFORMANCE_TEST_FIX_ATTEMPTS = 20 MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS = 1 -# Marks the moment the loop stops patching and regenerates the test instead. Greppable -# on purpose, like the other benchmark markers. -STRATEGY_SWITCH_PREFIX = "[strategy-switch]" - class FixConformanceTest(BaseAction): IMPLEMENTATION_CODE_NOT_UPDATED = "implementation_code_not_updated" @@ -36,23 +32,18 @@ class FixConformanceTest(BaseAction): def _should_regenerate_instead_of_patching(render_context: RenderContext) -> bool: """Whether the loop has proven that patching the implementation is not working. - A conformance failure that repeats identically means the last fixes changed - nothing the test can see. Benchmark runs put numbers on that: across a wedged - cli-password-manager render the conformance loop failed 20 times on one - functionality with a streak of 8, while its unit loop never failed once — the - implementation was not what needed changing. Left alone the loop spent 5h20m - that way and still scored 1/16, and the same signature appears on bookshelf-api, - so it is not one spec's quirk. + Two shapes of stuck, both observed on real renders. A failure that repeats + identically means the last fixes changed nothing the test can see — one wedged + cli-password-manager render failed conformance 20 times on a functionality with a + streak of 8 while its unit loop never failed once. A loop can also fail every + single time while the failures keep changing: a task-manager render went 40 for + 40 with a longest identical run of two, which no streak threshold can catch. + `stalled_reason` covers both. Regenerating the conformance test is a genuinely different move: it discards the test the loop cannot satisfy rather than editing code against it again. That path already exists for the attempt limit; this reaches it as soon as there is evidence, instead of after twenty blind patches. - - The threshold is the one the warning already uses. Three is validated in both - directions by a single run: a healthy functionality repeated twice and then - recovered, so two would fire on renders that are fine, while the wedged one went - from three to eight and never recovered. """ ctx = render_context.conformance_tests_running_context @@ -61,22 +52,23 @@ def _should_regenerate_instead_of_patching(render_context: RenderContext) -> boo if ctx.conformance_tests_render_attempts >= MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS: return False - streak = render_context.fix_loop_metrics.current_streak( + reason = stalled_reason( + render_context.fix_loop_metrics, CONFORMANCE_LOOP, module=render_context.module_name, frid=ctx.current_testing_frid, ) - if streak < REPEATED_FAILURE_WARNING_THRESHOLD: + if reason is None: return False console.warning( f"{STRATEGY_SWITCH_PREFIX} module={render_context.module_name} " - f"frid={ctx.current_testing_frid} conformance_streak={streak} " + f"frid={ctx.current_testing_frid} loop={CONFORMANCE_LOOP} {reason} " f"action=regenerate_conformance_tests" ) console.info( - f"Patching the implementation has not changed what the conformance tests for functionality " - f"{ctx.current_testing_frid} report, {streak} times running. Regenerating those tests instead." + f"Patching the implementation has not made the conformance tests for functionality " + f"{ctx.current_testing_frid} pass ({reason}). Regenerating those tests instead." ) return True diff --git a/render_machine/fix_loop_metrics.py b/render_machine/fix_loop_metrics.py index f4a7701f..1e352f42 100644 --- a/render_machine/fix_loop_metrics.py +++ b/render_machine/fix_loop_metrics.py @@ -57,6 +57,11 @@ class _LoopCounters: max_repeat: int = 1 last_fingerprint: Optional[str] = None current_repeat: int = 0 + # Failures since the last pass, whether or not they look alike. A loop can fail every + # single time without ever repeating itself — one benchmark render went 40 for 40 on + # a functionality whose longest identical run was two — and a streak counter cannot + # see that at any threshold. + consecutive_failures: int = 0 @dataclass @@ -79,9 +84,11 @@ def record(self, loop: str, module: str, frid: str, passed: bool, output: str) - if passed: counters.last_fingerprint = None counters.current_repeat = 0 + counters.consecutive_failures = 0 return None counters.failures += 1 + counters.consecutive_failures += 1 fingerprint = failure_fingerprint(output) if fingerprint == counters.last_fingerprint: counters.current_repeat += 1 @@ -101,12 +108,21 @@ def current_streak(self, loop: str, module: str, frid: Optional[str]) -> int: because nothing was recorded under one either — `report_fix_loop_attempt` skips those runs. """ + counters = self._counters_for(loop, module, frid) + return counters.current_repeat if counters else 0 + + def consecutive_failures(self, loop: str, module: str, frid: Optional[str]) -> int: + """How many times in a row this loop has failed, regardless of how it failed.""" + counters = self._counters_for(loop, module, frid) + return counters.consecutive_failures if counters else 0 + + def _counters_for(self, loop: str, module: str, frid: Optional[str]) -> Optional[_LoopCounters]: if frid is None: - return 0 + return None # nothing is recorded without one — report_fix_loop_attempt skips those runs counters = self._counters.get((module, str(frid))) if not counters or loop not in counters: - return 0 - return counters[loop].current_repeat + return None + return counters[loop] def frid_summary(self, module: str, frid: str) -> Optional[str]: """One greppable line per FRID, or None if no script ran for it.""" @@ -140,6 +156,35 @@ def render_summary(self) -> List[str]: # against a failure it is not moving. REPEATED_FAILURE_WARNING_THRESHOLD = 3 +# How many failures in a row — alike or not — before the loop is called stuck anyway. +# Repetition proves futility quickly but is not necessary for it: a benchmark render +# failed a functionality's conformance tests 40 times out of 40 with a longest identical +# run of two, which no streak threshold can catch. The highest failure count seen on a +# functionality that then recovered is four, so this sits above that with margin. +CONSECUTIVE_FAILURE_THRESHOLD = 6 + +# Marks the moment a loop stops patching and does something else instead. Greppable on +# purpose, like the other benchmark markers. +STRATEGY_SWITCH_PREFIX = "[strategy-switch]" + + +def stalled_reason(metrics: "FixLoopMetrics", loop: str, module: str, frid: Optional[str]) -> Optional[str]: + """Why this loop looks stuck, or None if it still looks like it is working. + + One definition for both loops. The streak arm fires soonest when a loop is + re-submitting the same fix; the consecutive arm is the catch-all for a loop that + fails every time while the failures keep changing shape. + """ + streak = metrics.current_streak(loop, module, frid) + if streak >= REPEATED_FAILURE_WARNING_THRESHOLD: + return f"repeated_failure streak={streak}" + + failures = metrics.consecutive_failures(loop, module, frid) + if failures >= CONSECUTIVE_FAILURE_THRESHOLD: + return f"no_progress consecutive_failures={failures}" + + return None + def report_fix_loop_attempt(render_context, loop: str, frid: Optional[str], passed: bool, output: str) -> None: """Records one script run and tells the user when the loop stops making progress.""" diff --git a/render_machine/render_context.py b/render_machine/render_context.py index 3ce88595..ad1ca258 100644 --- a/render_machine/render_context.py +++ b/render_machine/render_context.py @@ -13,7 +13,7 @@ from plain_modules import PlainModule from render_machine import triggers from render_machine.conformance_tests import CONFORMANCE_TESTS_DEFINITION_FILE_NAME, ConformanceTests -from render_machine.fix_loop_metrics import FixLoopMetrics +from render_machine.fix_loop_metrics import STRATEGY_SWITCH_PREFIX, UNIT_LOOP, FixLoopMetrics, stalled_reason from render_machine.render_types import ( AcceptanceTestPhase, ConformanceTestsRunningContext, @@ -272,6 +272,27 @@ def start_fixing_unit_tests(self, on_limit_exceeded: Callable): self.unit_tests_running_context.fix_attempts += 1 if self.unit_tests_running_context.fix_attempts > MAX_UNITTEST_FIX_ATTEMPTS: on_limit_exceeded() + return + + # A unit loop that has stopped moving gets the same answer as one that ran out of + # attempts, just sooner. The separation is unusually clean here: across three + # benchmark renders every healthy functionality finished with unit_max_repeat=1, + # while the one that wedged reached 17 and burned eleven minutes getting to the + # attempt limit. Nothing has been observed in between, so acting on a streak of + # three risks little and skips that wait. + reason = stalled_reason( + self.fix_loop_metrics, + UNIT_LOOP, + module=self.module_name, + frid=self.frid_context.frid if self.frid_context else None, + ) + if reason is not None: + console.warning( + f"{STRATEGY_SWITCH_PREFIX} module={self.module_name} " + f"frid={self.frid_context.frid if self.frid_context else None} loop={UNIT_LOOP} " + f"{reason} action=give_up_on_patching" + ) + on_limit_exceeded() def _on_unit_test_limit_exceeded_in_implementation(self): self.machine.dispatch(triggers.RESTART_FRID_PROCESSING) diff --git a/tests/test_conformance_strategy_switch.py b/tests/test_conformance_strategy_switch.py index 6d1ac6fb..5eb6bfe5 100644 --- a/tests/test_conformance_strategy_switch.py +++ b/tests/test_conformance_strategy_switch.py @@ -1,25 +1,28 @@ """Tests for switching strategy when the conformance fix loop stops making progress. -The loop's failure mode is not slowness, it is repetition: it re-sends the same fix -request, gets back a patch that changes nothing the test can see, and does it again. A -wedged cli-password-manager render failed conformance 20 times on one functionality with -a streak of 8 while its unit loop never failed once, spent 5h20m, and still scored 1/16. -The same signature appears on bookshelf-api, so it is not one spec's quirk. +The loop's failure mode is not slowness, and it comes in two shapes. It can re-send the +same fix request and get back a patch that changes nothing the test can see — a wedged +cli-password-manager render failed conformance 20 times on one functionality with a +streak of 8 while its unit loop never failed once. Or it can fail every single time while +the failures keep changing shape — a task-manager render went 40 for 40 with a longest +identical run of two, which no streak threshold can catch. Both burn the whole budget. Regenerating the conformance test is the different move — it discards the test the loop cannot satisfy instead of editing code against it again. These tests pin when that -happens, and just as importantly when it does not: the threshold has to sit above what a -healthy functionality does, or every good render pays for it. +happens, and just as importantly when it does not: both thresholds have to sit above what +a healthy functionality does, or every good render pays for it. """ from unittest.mock import MagicMock, patch -from render_machine.actions.fix_conformance_test import ( - MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS, +from render_machine.actions.fix_conformance_test import MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS, FixConformanceTest +from render_machine.fix_loop_metrics import ( + CONFORMANCE_LOOP, + CONSECUTIVE_FAILURE_THRESHOLD, STRATEGY_SWITCH_PREFIX, - FixConformanceTest, + UNIT_LOOP, + FixLoopMetrics, ) -from render_machine.fix_loop_metrics import CONFORMANCE_LOOP, UNIT_LOOP, FixLoopMetrics MODULE = "vault_cli" FRID = "2" @@ -110,10 +113,56 @@ def test_the_switch_is_announced_in_a_greppable_form(): assert STRATEGY_SWITCH_PREFIX in announced assert f"module={MODULE}" in announced assert f"frid={FRID}" in announced - assert "conformance_streak=4" in announced + assert "loop=conformance" in announced + assert "repeated_failure streak=4" in announced assert "action=regenerate_conformance_tests" in announced +def failing_differently(context, times): + for index in range(times): + context.fix_loop_metrics.record( + CONFORMANCE_LOOP, module=MODULE, frid=FRID, passed=False, output=f"failure number {index}" + ) + return context + + +def test_a_loop_that_always_fails_regenerates_even_without_a_repeat(): + """The case a streak trigger cannot see at any threshold: a task-manager render + failed a functionality's conformance tests 40 times out of 40 while its longest + identical run was two, exhausted its whole budget, and the switch stayed silent.""" + context = failing_differently(render_context(), CONSECUTIVE_FAILURE_THRESHOLD) + + assert decides_to_regenerate(context) is True + + +def test_a_loop_short_of_the_consecutive_bound_is_left_alone(): + context = failing_differently(render_context(), CONSECUTIVE_FAILURE_THRESHOLD - 1) + + assert decides_to_regenerate(context) is False + + +def test_a_pass_clears_the_consecutive_count(): + """A loop that gets a test passing is making progress, however many failures it took + to get there.""" + context = failing_differently(render_context(), CONSECUTIVE_FAILURE_THRESHOLD) + context.fix_loop_metrics.record(CONFORMANCE_LOOP, module=MODULE, frid=FRID, passed=True, output="") + failing_differently(context, 1) + + assert decides_to_regenerate(context) is False + + +def test_the_consecutive_arm_is_announced_with_its_own_reason(): + """The two arms mean different things to a reader, so the marker distinguishes + them rather than reporting one cause for both.""" + context = failing_differently(render_context(), CONSECUTIVE_FAILURE_THRESHOLD) + + with patch("render_machine.actions.fix_conformance_test.console") as console: + FixConformanceTest._should_regenerate_instead_of_patching(context) + + announced = console.warning.call_args[0][0] + assert f"no_progress consecutive_failures={CONSECUTIVE_FAILURE_THRESHOLD}" in announced + + def test_the_action_returns_the_regeneration_outcome_and_marks_the_context(): """The early return has to reach the state machine the same way the attempt-limit path does, or the render carries on patching regardless of the decision.""" diff --git a/tests/test_unit_strategy_switch.py b/tests/test_unit_strategy_switch.py new file mode 100644 index 00000000..eef057e6 --- /dev/null +++ b/tests/test_unit_strategy_switch.py @@ -0,0 +1,113 @@ +"""Tests for giving up on patching when the unit fix loop stops making progress. + +The unit loop has the same problem as the conformance one and a different remedy: its +escape hatch restarts the functionality from scratch rather than discarding a test file. +That is destructive enough to be worth a careful threshold, and the benchmark data makes +the call easy — across three renders every healthy functionality finished with +`unit_max_repeat=1`, nothing was ever observed between that and the 17 reached by the one +that wedged. So a streak of three is a state healthy renders do not enter, and reaching +the same decision on it saves the eleven minutes that render spent grinding to the +attempt limit. +""" + +from unittest.mock import MagicMock, patch + +import render_machine.render_context as render_context_module +from render_machine.fix_loop_metrics import ( + CONFORMANCE_LOOP, + CONSECUTIVE_FAILURE_THRESHOLD, + STRATEGY_SWITCH_PREFIX, + UNIT_LOOP, + FixLoopMetrics, +) +from render_machine.render_context import MAX_UNITTEST_FIX_ATTEMPTS, RenderContext + +MODULE = "vault_cli" +FRID = "2" + + +def context(identical_failures=0, attempts=1, output="AssertionError: vault not initialized"): + instance = MagicMock(spec=RenderContext) + instance.module_name = MODULE + instance.fix_loop_metrics = FixLoopMetrics() + instance.frid_context = MagicMock() + instance.frid_context.frid = FRID + instance.unit_tests_running_context = MagicMock() + instance.unit_tests_running_context.fix_attempts = attempts + for _ in range(identical_failures): + instance.fix_loop_metrics.record(UNIT_LOOP, module=MODULE, frid=FRID, passed=False, output=output) + return instance + + +def gave_up(instance): + """Whether start_fixing_unit_tests reached the give-up handler.""" + on_limit_exceeded = MagicMock() + with patch.object(render_context_module, "console"): + RenderContext.start_fixing_unit_tests(instance, on_limit_exceeded) + return on_limit_exceeded.called + + +def test_a_unit_loop_repeating_a_failure_three_times_gives_up_early(): + assert gave_up(context(identical_failures=3)) is True + + +def test_two_repeats_are_left_alone(): + """No healthy functionality in the benchmark data ever reached two, so this is + already past normal — but the remedy discards the whole functionality, so it waits + for the same evidence the conformance side does.""" + assert gave_up(context(identical_failures=2)) is False + + +def test_a_healthy_loop_is_left_alone(): + assert gave_up(context(identical_failures=0)) is False + + +def test_a_unit_loop_that_always_fails_gives_up_without_a_repeat(): + instance = context() + for index in range(CONSECUTIVE_FAILURE_THRESHOLD): + instance.fix_loop_metrics.record( + UNIT_LOOP, module=MODULE, frid=FRID, passed=False, output=f"failure number {index}" + ) + + assert gave_up(instance) is True + + +def test_a_stuck_conformance_loop_does_not_restart_the_functionality(): + """The conformance loop has its own, far cheaper remedy; it must not reach this one.""" + instance = context() + for _ in range(8): + instance.fix_loop_metrics.record(CONFORMANCE_LOOP, module=MODULE, frid=FRID, passed=False, output="same") + + assert gave_up(instance) is False + + +def test_the_attempt_limit_still_ends_the_loop_on_its_own(): + """The streak arm is an early exit, not a replacement: a loop that never repeats and + never accumulates enough consecutive failures still stops at the limit.""" + assert gave_up(context(attempts=MAX_UNITTEST_FIX_ATTEMPTS)) is True + + +def test_the_early_exit_is_announced_in_a_greppable_form(): + instance = context(identical_failures=4) + + with patch.object(render_context_module, "console") as console: + RenderContext.start_fixing_unit_tests(instance, MagicMock()) + + announced = console.warning.call_args[0][0] + assert STRATEGY_SWITCH_PREFIX in announced + assert f"module={MODULE}" in announced + assert f"frid={FRID}" in announced + assert "loop=unit" in announced + assert "repeated_failure streak=4" in announced + assert "action=give_up_on_patching" in announced + + +def test_hitting_the_attempt_limit_is_not_announced_as_a_switch(): + """The limit path is ordinary exhaustion, not a decision the loop made about its own + progress; labelling it a strategy switch would inflate every benchmark count.""" + instance = context(attempts=MAX_UNITTEST_FIX_ATTEMPTS) + + with patch.object(render_context_module, "console") as console: + RenderContext.start_fixing_unit_tests(instance, MagicMock()) + + assert not console.warning.called From b1985433f4d74637bd5e8e12eb6185d59f8cba57 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Wed, 19 Aug 2026 16:48:19 +0200 Subject: [PATCH 77/83] Re-deliver end-of-file to a driverless target that has gone quiet A driverless execution gets one end-of-file at spawn. `getpass` calls `tcsetattr(..., TCSAFLUSH, ...)` before reading, and TCSAFLUSH means discard pending input -- so the end-of-file is gone by the time the read happens and the target waits for input nobody will send. It costs the script its whole timeout, and because the timeout is handed to the fix loop as an ordinary failure, the loop reads it as a defect in the generated code and patches against it. One benchmark render did that seventeen times in a row on one functionality while its conformance loop never failed at all; the control arm of the current A/B logged the same diagnostic 69 times across five renders. The broker exists for exactly this, but unit tests never get one: they are part of the delivered codebase and must not depend on Codeplain's test tooling, which the build audit enforces. So the driverless path has to answer for itself. Quiet is the only evidence available from outside -- the parent cannot see the child's tcsetattr -- so a target that is alive and has stopped producing output gets the end-of-file again. That describes a program blocked on a read, and also one that has simply finished talking; with no driver attached both want the same answer, which is what makes repeating it safe. Bounded at three, because a target still quiet after that is not waiting on the terminal and a stuck script should not also be a noisy one. The flag has to be threaded through rather than read from INPUT_DRIVER, which is a module default nothing assigns: the driver is chosen per execution, and pushing an end-of-file into a terminal the broker is driving would answer a prompt the generated test meant to answer itself. The end-to-end case spawns a real getpass target and asserts it exits; without the re-delivery it reproduces the production diagnostic verbatim and hangs to timeout. --- render_machine/render_utils.py | 73 ++++++++++++++++- tests/test_quiet_eof_resend.py | 140 +++++++++++++++++++++++++++++++++ tests/test_render_utils.py | 36 +++++++++ 3 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 tests/test_quiet_eof_resend.py diff --git a/render_machine/render_utils.py b/render_machine/render_utils.py index 7c7c8855..81d1ffae 100644 --- a/render_machine/render_utils.py +++ b/render_machine/render_utils.py @@ -33,6 +33,21 @@ # `codeplain-tty` broker; unit tests and environment preparation always run without one. INPUT_DRIVER: Optional[TerminalInputDriver] = None +# A driverless execution gets one end-of-file at spawn. A program that reconfigures its +# terminal before reading discards whatever is queued — `getpass` calls +# `tcsetattr(..., TCSAFLUSH, ...)`, and TCSAFLUSH means exactly that — so the EOF is gone +# by the time the read happens and the program waits for input nobody will send. It costs +# the script its whole timeout, and the fix loop reads that as a defect in the code: one +# benchmark render patched against the resulting failure seventeen times in a row while +# its conformance loop never failed at all. +# +# So the EOF is re-delivered while the target is quiet. With no driver attached there is +# nothing else a read could be answered with, which is what makes repeating it safe: a +# program that is reading gets the EOF it was owed, and one that is not is unaffected. +EOF_BYTE = b"\x04" +QUIET_BEFORE_EOF_RESEND_SECONDS = 5.0 +MAX_EOF_RESENDS = 3 + # Conditions the arbiter chooses between, highest precedence last. CONDITION_EXIT = "exit" CONDITION_TIMEOUT = "timeout" @@ -163,18 +178,27 @@ def _await_target( script_timeout: float, stop_event: Optional[threading.Event], outcome: _ScriptOutcome, + driverless: bool = True, ) -> None: """Waits for the target, recording every condition each poll can observe. No fact ends the wait before the others have been recorded: a target that exits after its deadline, or while a cancellation is already set, races with the condition it coincides with, and only the rank table decides which of them is published. + + `driverless` reports whether this execution attached an input driver. It has to be + passed rather than read from `INPUT_DRIVER`, which is a module default nothing + assigns: the driver is chosen per execution, and a broker-backed run must not have + end-of-file pushed into a terminal the broker is driving. """ deadline = time.monotonic() + script_timeout + eof_resender = _QuietEofResender(process, driverless=driverless) while True: returncode = process.poll() if returncode is not None: outcome.target_exited(returncode) + else: + eof_resender.consider() if stop_event is not None and stop_event.is_set(): outcome.cancelled() # An exit observed by this same poll wins over the expired deadline: the target had @@ -193,6 +217,53 @@ def _await_target( time.sleep(POLL_INTERVAL_SECONDS) +class _QuietEofResender: + """Re-delivers end-of-file to a driverless target that has gone quiet. + + Quiet is the only evidence available from outside: the parent cannot see the child's + `tcsetattr`, so it watches for a target that is alive and has stopped producing + output. That describes a program blocked on a read, and — with no driver attached — + also describes a program that has nothing left to say. Both want the same answer. + + Bounded rather than continuous. A target that stays quiet through several deliveries + is not waiting on the terminal, and repeating forever would turn a stuck script into + a noisy stuck script. + """ + + def __init__(self, process: TerminalProcess, driverless: bool) -> None: + self._process = process + self._enabled = driverless + self._resends = 0 + self._seen = -1 + self._since = time.monotonic() + + def consider(self) -> None: + if not self._enabled or self._resends >= MAX_EOF_RESENDS: + return + + produced = len(self._process.normalized_output()) + if produced != self._seen: + self._seen = produced + self._since = time.monotonic() + return + + if time.monotonic() - self._since < QUIET_BEFORE_EOF_RESEND_SECONDS: + return + + self._since = time.monotonic() + self._resends += 1 + try: + self._process.write_input(EOF_BYTE) + except Exception as exc: # a target that cannot be written to is the wait's problem, not ours + self._enabled = False + console.debug(f"the end-of-file could not be re-delivered to a quiet target: {exc!r}") + return + console.debug( + f"re-delivered end-of-file to a quiet target " + f"(attempt {self._resends} of {MAX_EOF_RESENDS}); the spawn-time one may have been flushed" + ) + + def _teardown(process: TerminalProcess, outcome: _ScriptOutcome) -> None: """Releases every handle the backend owns, then classifies what teardown revealed.""" try: @@ -281,7 +352,7 @@ def _run_script( child_env = _platform_test_environment(broker) input_driver = broker process.spawn(cmd, env=child_env, stop_event=stop_event, input_driver=input_driver) - _await_target(process, script_timeout, stop_event, outcome) + _await_target(process, script_timeout, stop_event, outcome, driverless=input_driver is None) except RenderCancelledError: outcome.cancelled() except Exception as exc: diff --git a/tests/test_quiet_eof_resend.py b/tests/test_quiet_eof_resend.py new file mode 100644 index 00000000..62f53bcd --- /dev/null +++ b/tests/test_quiet_eof_resend.py @@ -0,0 +1,140 @@ +"""Tests for re-delivering end-of-file to a driverless target that has gone quiet. + +A driverless execution gets one end-of-file at spawn. `getpass` calls +`tcsetattr(..., TCSAFLUSH, ...)` before reading, and TCSAFLUSH discards pending input — +so the EOF is gone by the time the read happens and the program waits for input nobody +will send. The script loses its whole timeout, and the unit-test fix loop reads that as a +defect in the code: one benchmark render patched against the resulting failure seventeen +times in a row while its conformance loop never failed once. + +The broker exists for exactly this, but unit tests never get one — they are part of the +delivered codebase and must not depend on Codeplain's test tooling. So the driverless +path has to answer for itself. +""" + +import time +from unittest.mock import MagicMock + +import pytest + +from render_machine.render_utils import ( + EOF_BYTE, + MAX_EOF_RESENDS, + QUIET_BEFORE_EOF_RESEND_SECONDS, + _QuietEofResender, +) + + +@pytest.fixture +def target(): + process = MagicMock() + process.transcript = "" + process.normalized_output = lambda: process.transcript + return process + + +def quiet_for(resender, seconds): + """Runs a poll as though `seconds` of silence had passed.""" + resender._since -= seconds + resender.consider() + + +def test_a_quiet_target_is_sent_end_of_file_again(target): + resender = _QuietEofResender(target, driverless=True) + resender.consider() # establishes the baseline + + quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) + + target.write_input.assert_called_once_with(EOF_BYTE) + + +def test_a_target_still_producing_output_is_left_alone(target): + """Output means the program is working, not waiting.""" + resender = _QuietEofResender(target, driverless=True) + resender.consider() + + target.transcript = "still going" + quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) + + target.write_input.assert_not_called() + + +def test_a_briefly_quiet_target_is_left_alone(target): + resender = _QuietEofResender(target, driverless=True) + resender.consider() + + quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS / 2) + + target.write_input.assert_not_called() + + +def test_output_after_a_resend_restarts_the_clock(target): + """A program that answers the end-of-file and carries on is making progress.""" + resender = _QuietEofResender(target, driverless=True) + resender.consider() + quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) + target.write_input.reset_mock() + + target.transcript = "off it goes" + resender.consider() + quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS / 2) + + target.write_input.assert_not_called() + + +def test_a_target_attached_to_a_driver_is_never_written_to(target): + """A broker-backed run has something driving its terminal deliberately; pushing an + end-of-file into it would answer a prompt the test meant to answer itself.""" + resender = _QuietEofResender(target, driverless=False) + resender.consider() + + quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS * 10) + + target.write_input.assert_not_called() + + +def test_the_resends_are_bounded(target): + """A target quiet through every delivery is not waiting on the terminal, and a stuck + script should not also be a noisy one.""" + resender = _QuietEofResender(target, driverless=True) + resender.consider() + + for _ in range(MAX_EOF_RESENDS + 5): + quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) + + assert target.write_input.call_count == MAX_EOF_RESENDS + + +def test_a_target_that_cannot_be_written_to_stops_being_tried(target): + """The wait loop owns what happens to an unwritable target; this must not turn one + broken write into a warning on every poll.""" + target.write_input.side_effect = OSError("the terminal is gone") + resender = _QuietEofResender(target, driverless=True) + resender.consider() + + for _ in range(3): + quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) + + assert target.write_input.call_count == 1 + + +def test_the_first_poll_does_not_immediately_resend(target): + """A target gets its quiet period before anything is concluded about it.""" + resender = _QuietEofResender(target, driverless=True) + + resender.consider() + + target.write_input.assert_not_called() + + +def test_a_real_clock_is_used_for_the_quiet_period(target): + """Guards the constant itself: a period short enough to fire between two polls would + write into every healthy script that pauses to think.""" + assert QUIET_BEFORE_EOF_RESEND_SECONDS >= 1.0 + + resender = _QuietEofResender(target, driverless=True) + resender.consider() + time.sleep(0.05) + resender.consider() + + target.write_input.assert_not_called() diff --git a/tests/test_render_utils.py b/tests/test_render_utils.py index 375ddbd6..5aa19932 100644 --- a/tests/test_render_utils.py +++ b/tests/test_render_utils.py @@ -709,3 +709,39 @@ def recording_close(): assert seen["active_during_teardown"] is True assert not render_utils.terminal_script_active() + + +@posix_only +def test_a_getpass_target_survives_its_terminal_flush_without_a_driver(tmp_path, run_script): + """The failure this whole path exists for, on the side that has no broker. + + `getpass` calls `tcsetattr(..., TCSAFLUSH, ...)` before reading, and TCSAFLUSH + discards pending input — so the end-of-file queued at spawn is gone by the time the + read happens and the target waits for input nobody will send. Unit tests never get a + broker (they ship inside the delivered codebase and must not depend on Codeplain's + tooling), so before the quiet-period re-delivery this target burned the entire script + timeout and the fix loop read that as a defect in the generated code. + + The timeout here is well above the quiet period and well below what a hang costs, so + a regression fails the test rather than slowing it down. + """ + script = _make_python_script( + tmp_path, + "getpass_no_driver", + """ + import getpass + + try: + secret = getpass.getpass("Master password: ") + except EOFError: + secret = "" + print(f"GOT:{secret}") + """, + ) + + exit_code, output, _ = run_script( + script, [], SCRIPT_TYPE, timeout=render_utils.QUIET_BEFORE_EOF_RESEND_SECONDS + 20 + ) + + assert exit_code == 0, output + assert "GOT:" in output From f27465fd86ac1b096ba1036d3ea24220016ee26f Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Wed, 19 Aug 2026 19:01:57 +0200 Subject: [PATCH 78/83] Ask the fix loop differently once, before discarding its test The switch had two rungs: patch, then discard the conformance test. It skipped the one in between -- asking the same question in a way that says the previous answers achieved nothing. Every fix request was identical apart from the failure text, so a stuck loop re-sent the same question and got the same patch, and the only escalation was a temperature nudge that cannot tell stuck from slow. Now a stalled loop first sends one request carrying the reason it is stalled, which the API turns into a section asking which kind of failure this actually is. That matters here specifically: the failures these loops kept patching were often script timeouts and missing entry points rather than wrong answers, and no amount of editing implementation code resolves those. Only if that request changes nothing does the loop discard the test, and only then does it give up. Once spent the rung does not repeat, so a loop that stays stuck still reaches regeneration and then the attempt limit on the same budget as before. An ordinary request omits the field entirely and is byte-identical to what it was. The fixture had to set asked_with_stall_context explicitly: a MagicMock answers truthily, which silently skipped the new rung and made two existing tests pass for the wrong reason. --- codeplain_REST_api.py | 6 ++ .../actions/fix_conformance_test.py | 33 +++++-- render_machine/render_types.py | 5 + tests/test_conformance_strategy_switch.py | 96 +++++++++++++++++-- 4 files changed, 122 insertions(+), 18 deletions(-) diff --git a/codeplain_REST_api.py b/codeplain_REST_api.py index 65e41b47..4c36ca2f 100644 --- a/codeplain_REST_api.py +++ b/codeplain_REST_api.py @@ -388,6 +388,7 @@ def fix_conformance_tests_issue( conflicting_requirements_count: int, run_state: RunState, platform_test_runtime: Optional[dict] = None, + stalled_reason: Optional[str] = None, ): endpoint_url = f"{self.api_url}/fix_conformance_tests_issue" headers = {"X-API-Key": self.api_key, "Content-Type": "application/json"} @@ -417,6 +418,11 @@ def fix_conformance_tests_issue( if platform_test_runtime is not None: payload["platform_test_runtime"] = platform_test_runtime + # Sent only once the loop has stopped moving. Omitted otherwise, so an + # ordinary fix request is byte-identical to what it was. + if stalled_reason is not None: + payload["stalled_reason"] = stalled_reason + return self.post_request(endpoint_url, headers, payload, run_state) def render_acceptance_tests( diff --git a/render_machine/actions/fix_conformance_test.py b/render_machine/actions/fix_conformance_test.py index e8a68a7c..40d53545 100644 --- a/render_machine/actions/fix_conformance_test.py +++ b/render_machine/actions/fix_conformance_test.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Optional import diff_utils import file_utils @@ -29,7 +29,7 @@ class FixConformanceTest(BaseAction): ISSUE_REASON_CODE_CONFLICTING_ACCEPTANCE_TESTS = 3 @staticmethod - def _should_regenerate_instead_of_patching(render_context: RenderContext) -> bool: + def _should_regenerate_instead_of_patching(render_context: RenderContext, reason: Optional[str]) -> bool: """Whether the loop has proven that patching the implementation is not working. Two shapes of stuck, both observed on real renders. A failure that repeats @@ -52,12 +52,6 @@ def _should_regenerate_instead_of_patching(render_context: RenderContext) -> boo if ctx.conformance_tests_render_attempts >= MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS: return False - reason = stalled_reason( - render_context.fix_loop_metrics, - CONFORMANCE_LOOP, - module=render_context.module_name, - frid=ctx.current_testing_frid, - ) if reason is None: return False @@ -88,7 +82,27 @@ def execute(self, render_context: RenderContext, previous_action_payload: Any | ctx.regenerating_conformance_tests = True return self.REGENERATE_CONFORMANCE_TESTS_OUTCOME, None - if self._should_regenerate_instead_of_patching(render_context): + stalled = stalled_reason( + render_context.fix_loop_metrics, + CONFORMANCE_LOOP, + module=render_context.module_name, + frid=ctx.current_testing_frid, + ) + # Three rungs, cheapest first. A stuck loop first gets one request that says so — + # the same fix asked differently, which the benchmark evidence says is worth + # trying because the failures it kept patching were often timeouts and missing + # entry points rather than wrong answers. Only when that changes nothing does it + # discard the test, and only then does it give up. + stall_context = None + if stalled and not ctx.asked_with_stall_context: + ctx.asked_with_stall_context = True + stall_context = stalled + console.warning( + f"{STRATEGY_SWITCH_PREFIX} module={render_context.module_name} " + f"frid={ctx.current_testing_frid} loop={CONFORMANCE_LOOP} {stalled} " + f"action=ask_with_stall_context" + ) + elif self._should_regenerate_instead_of_patching(render_context, stalled): ctx.regenerating_conformance_tests = True return self.REGENERATE_CONFORMANCE_TESTS_OUTCOME, None @@ -184,6 +198,7 @@ def execute(self, render_context: RenderContext, previous_action_payload: Any | render_context.conformance_tests_running_context.conflicting_requirement_count, run_state=render_context.run_state, platform_test_runtime=advertised_platform_test_runtime(), + stalled_reason=stall_context, ) code_diff_files_content = {} diff --git a/render_machine/render_types.py b/render_machine/render_types.py index 44cda7a7..8d659b39 100644 --- a/render_machine/render_types.py +++ b/render_machine/render_types.py @@ -89,6 +89,11 @@ def __init__( self.regenerating_conformance_tests: bool = False + # Whether this functionality has already had one fix request that told the + # API the loop was stuck. Once spent, a still-stuck loop stops asking and + # discards the test instead, so the middle rung cannot repeat. + self.asked_with_stall_context: bool = False + self.current_testing_frid_high_level_implementation_plan: Optional[str] = None self.previous_conformance_tests_issue_old: Optional[str] = None self.previous_conformance_tests_issue_frid: Optional[str] = None diff --git a/tests/test_conformance_strategy_switch.py b/tests/test_conformance_strategy_switch.py index 5eb6bfe5..9468e777 100644 --- a/tests/test_conformance_strategy_switch.py +++ b/tests/test_conformance_strategy_switch.py @@ -22,6 +22,7 @@ STRATEGY_SWITCH_PREFIX, UNIT_LOOP, FixLoopMetrics, + stalled_reason, ) MODULE = "vault_cli" @@ -32,6 +33,12 @@ def render_context(identical_failures=0, render_attempts=0, output="AssertionErr context = MagicMock() context.module_name = MODULE context.fix_loop_metrics = FixLoopMetrics() + # (issue reason, response files) — an implementation-code answer that changed + # nothing, which is the shape the tests below care about reaching. + context.codeplain_api.fix_conformance_tests_issue.return_value = [ + FixConformanceTest.ISSUE_REASON_CODE_IMPLEMENTATION_CODE, + {}, + ] for _ in range(identical_failures): context.fix_loop_metrics.record(CONFORMANCE_LOOP, module=MODULE, frid=FRID, passed=False, output=output) @@ -41,12 +48,33 @@ def render_context(identical_failures=0, render_attempts=0, output="AssertionErr ctx.conformance_tests_render_attempts = render_attempts ctx.fix_attempts = 4 # mid-loop: well below the attempt limit ctx.regenerating_conformance_tests = False + # A MagicMock would answer truthily and silently skip the ask-first rung. + ctx.asked_with_stall_context = False return context def decides_to_regenerate(context): + """The predicate alone, given whatever `stalled_reason` makes of the recorded runs.""" + reason = stalled_reason( + context.fix_loop_metrics, + CONFORMANCE_LOOP, + module=context.module_name, + frid=context.conformance_tests_running_context.current_testing_frid, + ) with patch("render_machine.actions.fix_conformance_test.console"): - return FixConformanceTest._should_regenerate_instead_of_patching(context) + return FixConformanceTest._should_regenerate_instead_of_patching(context, reason) + + +def announced_by_predicate(context): + reason = stalled_reason( + context.fix_loop_metrics, + CONFORMANCE_LOOP, + module=context.module_name, + frid=context.conformance_tests_running_context.current_testing_frid, + ) + with patch("render_machine.actions.fix_conformance_test.console") as console: + FixConformanceTest._should_regenerate_instead_of_patching(context, reason) + return console.warning.call_args[0][0] def test_a_loop_that_repeats_a_failure_three_times_regenerates_the_test(): @@ -106,10 +134,7 @@ def test_the_switch_is_announced_in_a_greppable_form(): """Benchmark runs are read by tooling before they are read by a person.""" context = render_context(identical_failures=4) - with patch("render_machine.actions.fix_conformance_test.console") as console: - FixConformanceTest._should_regenerate_instead_of_patching(context) - - announced = console.warning.call_args[0][0] + announced = announced_by_predicate(context) assert STRATEGY_SWITCH_PREFIX in announced assert f"module={MODULE}" in announced assert f"frid={FRID}" in announced @@ -156,17 +181,69 @@ def test_the_consecutive_arm_is_announced_with_its_own_reason(): them rather than reporting one cause for both.""" context = failing_differently(render_context(), CONSECUTIVE_FAILURE_THRESHOLD) - with patch("render_machine.actions.fix_conformance_test.console") as console: - FixConformanceTest._should_regenerate_instead_of_patching(context) - - announced = console.warning.call_args[0][0] + announced = announced_by_predicate(context) assert f"no_progress consecutive_failures={CONSECUTIVE_FAILURE_THRESHOLD}" in announced +def execute_through_to_the_request(context): + """Runs execute() past the early returns, standing in for the file and spec helpers + it would otherwise reach. Only the request the action builds is under test here.""" + module = "render_machine.actions.fix_conformance_test" + with ( + patch(f"{module}.console"), + patch(f"{module}.plain_spec"), + patch(f"{module}.diff_utils"), + patch(f"{module}.file_utils"), + patch(f"{module}.MemoryManager") as memory, + patch(f"{module}.ImplementationCodeHelpers") as helpers, + ): + memory.fetch_memory_files.return_value = ({}, {}) + helpers.fetch_existing_files.return_value = ({}, {}) + helpers.get_code_diff.return_value = {} + context.conformance_tests.fetch_existing_conformance_test_files.return_value = ({}, {}) + return FixConformanceTest().execute(context, {"previous_conformance_tests_issue": "boom"}) + + +def test_a_stuck_loop_first_asks_again_saying_so(): + """The middle rung. Before discarding the test, the loop sends one more fix request + that reports it is stuck, so the request differs from the ones that achieved nothing + — the failures it kept patching were often timeouts and missing entry points rather + than wrong answers, and nothing in an unchanged request says so.""" + context = render_context(identical_failures=3) + + outcome, _ = execute_through_to_the_request(context) + + assert outcome != FixConformanceTest.REGENERATE_CONFORMANCE_TESTS_OUTCOME + assert context.conformance_tests_running_context.asked_with_stall_context is True + sent = context.codeplain_api.fix_conformance_tests_issue.call_args.kwargs + assert sent["stalled_reason"] == "repeated_failure streak=3" + + +def test_an_ordinary_request_carries_no_stall_reason(): + """A loop still converging must send exactly what it sent before.""" + context = render_context(identical_failures=1) + + execute_through_to_the_request(context) + + assert context.codeplain_api.fix_conformance_tests_issue.call_args.kwargs["stalled_reason"] is None + + +def test_a_loop_still_stuck_after_asking_regenerates(): + """The third rung: asking differently was tried and changed nothing.""" + context = render_context(identical_failures=3) + context.conformance_tests_running_context.asked_with_stall_context = True + + with patch("render_machine.actions.fix_conformance_test.console"): + outcome, _ = FixConformanceTest().execute(context, {"previous_conformance_tests_issue": "boom"}) + + assert outcome == FixConformanceTest.REGENERATE_CONFORMANCE_TESTS_OUTCOME + + def test_the_action_returns_the_regeneration_outcome_and_marks_the_context(): """The early return has to reach the state machine the same way the attempt-limit path does, or the render carries on patching regardless of the decision.""" context = render_context(identical_failures=3) + context.conformance_tests_running_context.asked_with_stall_context = True with patch("render_machine.actions.fix_conformance_test.console"): outcome, payload = FixConformanceTest().execute(context, {"previous_conformance_tests_issue": "boom"}) @@ -180,6 +257,7 @@ def test_the_api_is_not_asked_for_another_patch_when_switching(): """The point of the switch is to stop spending fix requests on a test the loop cannot satisfy.""" context = render_context(identical_failures=3) + context.conformance_tests_running_context.asked_with_stall_context = True with patch("render_machine.actions.fix_conformance_test.console"): FixConformanceTest().execute(context, {"previous_conformance_tests_issue": "boom"}) From c817b2ba115ee6684469db279dc1c613b1c6b4e5 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Wed, 19 Aug 2026 20:52:54 +0200 Subject: [PATCH 79/83] Judge only the conformance loop on failures that are merely consecutive The consecutive-failure arm was applied to both loops on one threshold, and the threshold was calibrated on conformance data because no unit-loop equivalent existed: the highest failure count on a functionality that then recovered was four, so six looked safe. It was not. On the unit side it fired on loops that were working. Two renders showed the same signature -- unit=7 unit_failed=7 unit_max_repeat=1, seven failures with none of them alike -- and in both the arm restarted the functionality and the render scored 0/10. One of them burned its whole restart budget and gave up in under five minutes. Every render without a unit restart scored 2 or 3. Seven different failures is a loop working through issues one at a time; restarting throws all of it away, which is a far worse trade than the one the conformance side makes by discarding a single test file. In both harmful cases unit_max_repeat was 1, so the streak arm would never have fired. That arm stays: across three renders every healthy functionality finished with unit_max_repeat=1, and nothing was observed between that and the 17 reached by the one that wedged, so a streak of three is a state healthy renders do not enter. The conformance loop keeps both arms. There the consecutive arm is what catches a loop failing 40 times out of 40 with a longest identical run of two, which no streak threshold reaches, and its remedy costs one regenerated test rather than a functionality's implementation. Removing it does not make such a unit loop run forever; it stops where it always did, at the attempt limit. --- render_machine/fix_loop_metrics.py | 28 +++++++++++++++----- tests/test_unit_strategy_switch.py | 41 ++++++++++++++++++++++++------ 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/render_machine/fix_loop_metrics.py b/render_machine/fix_loop_metrics.py index 1e352f42..eead312b 100644 --- a/render_machine/fix_loop_metrics.py +++ b/render_machine/fix_loop_metrics.py @@ -168,20 +168,36 @@ def render_summary(self) -> List[str]: STRATEGY_SWITCH_PREFIX = "[strategy-switch]" +# Which loops a run of failures — as opposed to a run of *identical* failures — is taken +# as evidence against. Conformance only, and the asymmetry is measured rather than +# cautious. On the conformance side the arm is what catches a loop failing 40 times out of +# 40 with a longest identical run of two, which no streak threshold reaches. On the unit +# side it fired on loops that were working: two renders showed `unit=7 unit_failed=7 +# unit_max_repeat=1` — seven failures, none alike — and both had their functionality +# restarted and scored 0/10, where every render without a restart scored 2–3. Six was +# calibrated on conformance recoveries (the highest failure count on a functionality that +# then recovered was four) and there was never a unit-loop equivalent to calibrate on. +# +# The unit loop keeps the streak arm, where the margin is not in doubt: across three +# renders every healthy functionality finished with unit_max_repeat=1. +LOOPS_JUDGED_ON_CONSECUTIVE_FAILURES = (CONFORMANCE_LOOP,) + + def stalled_reason(metrics: "FixLoopMetrics", loop: str, module: str, frid: Optional[str]) -> Optional[str]: """Why this loop looks stuck, or None if it still looks like it is working. - One definition for both loops. The streak arm fires soonest when a loop is - re-submitting the same fix; the consecutive arm is the catch-all for a loop that - fails every time while the failures keep changing shape. + The streak arm applies to both loops: a loop re-submitting the same fix is stuck + whichever loop it is. The consecutive arm applies only where failing every time has + been shown to mean stuck rather than busy. """ streak = metrics.current_streak(loop, module, frid) if streak >= REPEATED_FAILURE_WARNING_THRESHOLD: return f"repeated_failure streak={streak}" - failures = metrics.consecutive_failures(loop, module, frid) - if failures >= CONSECUTIVE_FAILURE_THRESHOLD: - return f"no_progress consecutive_failures={failures}" + if loop in LOOPS_JUDGED_ON_CONSECUTIVE_FAILURES: + failures = metrics.consecutive_failures(loop, module, frid) + if failures >= CONSECUTIVE_FAILURE_THRESHOLD: + return f"no_progress consecutive_failures={failures}" return None diff --git a/tests/test_unit_strategy_switch.py b/tests/test_unit_strategy_switch.py index eef057e6..6a652587 100644 --- a/tests/test_unit_strategy_switch.py +++ b/tests/test_unit_strategy_switch.py @@ -2,12 +2,16 @@ The unit loop has the same problem as the conformance one and a different remedy: its escape hatch restarts the functionality from scratch rather than discarding a test file. -That is destructive enough to be worth a careful threshold, and the benchmark data makes -the call easy — across three renders every healthy functionality finished with -`unit_max_repeat=1`, nothing was ever observed between that and the 17 reached by the one -that wedged. So a streak of three is a state healthy renders do not enter, and reaching -the same decision on it saves the eleven minutes that render spent grinding to the -attempt limit. +That is destructive enough that only one kind of evidence justifies it. Repetition does: +across three renders every healthy functionality finished with `unit_max_repeat=1`, and +nothing was observed between that and the 17 reached by the one that wedged, so a streak +of three is a state healthy renders do not enter. + +A run of failures that are merely consecutive does not. That arm was applied here too at +first, calibrated on conformance recoveries because no unit-loop equivalent existed, and +it fired on loops that were working: `unit=7 unit_failed=7 unit_max_repeat=1` in two +renders, both restarted, both 0/10, against 2-3 for every render without a restart. It now +applies to the conformance loop only. """ from unittest.mock import MagicMock, patch @@ -62,9 +66,30 @@ def test_a_healthy_loop_is_left_alone(): assert gave_up(context(identical_failures=0)) is False -def test_a_unit_loop_that_always_fails_gives_up_without_a_repeat(): +def test_a_unit_loop_failing_every_time_but_differently_is_left_alone(): + """Measured, not cautious. Two renders showed `unit=7 unit_failed=7 + unit_max_repeat=1` — seven failures, none alike — and both had their functionality + restarted and scored 0/10, where every render without a restart scored 2-3. Seven + different failures is a loop working through issues one at a time, and restarting + discards all of it. + + The conformance loop keeps this arm; there, failing every time really does mean + stuck, and it is what catches a 40-out-of-40 run whose longest identical streak is + two.""" instance = context() - for index in range(CONSECUTIVE_FAILURE_THRESHOLD): + for index in range(CONSECUTIVE_FAILURE_THRESHOLD * 2): + instance.fix_loop_metrics.record( + UNIT_LOOP, module=MODULE, frid=FRID, passed=False, output=f"failure number {index}" + ) + + assert gave_up(instance) is False + + +def test_the_attempt_limit_still_catches_a_unit_loop_that_never_repeats(): + """Removing the arm does not make such a loop run forever: it stops where it always + did, at the attempt limit.""" + instance = context(attempts=MAX_UNITTEST_FIX_ATTEMPTS) + for index in range(CONSECUTIVE_FAILURE_THRESHOLD * 2): instance.fix_loop_metrics.record( UNIT_LOOP, module=MODULE, frid=FRID, passed=False, output=f"failure number {index}" ) From 59e840ef2854c75936dcfe708e72c6b4fd624998 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Wed, 19 Aug 2026 22:03:08 +0200 Subject: [PATCH 80/83] Let a wedged functionality regenerate its conformance test more than once The regeneration budget was one per functionality, and that one was the difference between a render that finished and a render that did not. In a ten-task benchmark run, every render that failed to publish died at the conformance attempt limit -- a path only reachable once this budget is spent: one regeneration, then twenty fixes that changed nothing, then abandonment. Both examples failed this way. The single render that completed spent exactly one regeneration on each of three separate functionalities and cleared the bar with nothing to spare; it scored 9/10 where every wedged render scored its example's floor. Regeneration discards a test the loop has already proven it cannot satisfy. Stopping after the first one abandons the render at the point the move is still working. Three rather than more: each regeneration resets the attempt counter, so the worst case for a genuinely unfixable functionality is four rounds of patching instead of two, and that cost falls on renders that were going to fail anyway. The accompanying test names the count 1 literally rather than deriving a range from the constant -- a derived bound is vacuous at exactly the value it is meant to rule out, and an earlier draft passed against a budget of 1 for that reason. Claude-Session: https://claude.ai/code/session_01RwffYaMySLGNKhGyXzDLGG --- .../actions/fix_conformance_test.py | 22 ++++++++++++++++- tests/test_conformance_strategy_switch.py | 24 +++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/render_machine/actions/fix_conformance_test.py b/render_machine/actions/fix_conformance_test.py index 40d53545..e2281a0a 100644 --- a/render_machine/actions/fix_conformance_test.py +++ b/render_machine/actions/fix_conformance_test.py @@ -14,7 +14,27 @@ from render_machine.render_types import RenderError, TestExecutionPhase MAX_CONFORMANCE_TEST_FIX_ATTEMPTS = 20 -MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS = 1 + +# How many times one functionality may have its conformance test regenerated before the +# loop falls back to patching until the attempt limit. The budget is per functionality — +# `ConformanceTestsRunningContext` is rebuilt for each one — so this is not a per-render +# allowance. +# +# It was 1, and that was the difference between a render that finished and a render that +# did not. In a ten-task benchmark run, every render that failed to publish — both +# examples, unit-test and conformance wedges alike — died at the attempt limit below, +# which is only reachable once this budget is spent: one regeneration, then twenty fixes +# that changed nothing, then abandonment. The one render that completed spent exactly one +# regeneration on each of three separate functionalities and cleared the bar with nothing +# to spare; the four that wedged hit a functionality needing a second and had none left. +# Since a regeneration discards a test the loop has already proven it cannot satisfy, +# stopping at the first one abandons the render at precisely the point the move is working. +# +# Three rather than more: each regeneration resets `fix_attempts`, so the worst case for a +# genuinely unfixable functionality is four rounds of patching instead of two, and that +# cost lands on renders that were going to fail anyway. Raise it further only on evidence +# that a fourth regeneration ever rescued anything. +MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS = 3 class FixConformanceTest(BaseAction): diff --git a/tests/test_conformance_strategy_switch.py b/tests/test_conformance_strategy_switch.py index 9468e777..ad19dcf6 100644 --- a/tests/test_conformance_strategy_switch.py +++ b/tests/test_conformance_strategy_switch.py @@ -122,14 +122,34 @@ def test_a_stuck_unit_loop_does_not_regenerate_conformance_tests(): assert decides_to_regenerate(context) is False -def test_the_switch_is_spent_once_and_cannot_cycle(): - """Regeneration draws on the same budget as the attempt-limit path. Once it is used +def test_the_switch_stops_once_its_budget_is_spent_and_cannot_cycle(): + """Regeneration draws on the same budget as the attempt-limit path. Once it is spent the loop patches to the limit and stops, rather than regenerating forever.""" spent = render_context(identical_failures=8, render_attempts=MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS) assert decides_to_regenerate(spent) is False +def test_a_functionality_may_be_regenerated_more_than_once(): + """The budget that mattered. Every benchmark render that failed to publish died at the + attempt limit, reachable only after this budget ran out — one regeneration, then twenty + fruitless patches. The render that completed needed one regeneration on each of three + functionalities; the ones that wedged needed a second on a single functionality and had + none. Stopping after the first abandons the render where the move is still working.""" + # Deliberately not `range(1, MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS)`: a bound derived + # from the constant makes the test vacuous at the value it is meant to rule out, and it + # passed against a budget of 1 for exactly that reason. One is the count that has to be + # named literally here, because one is what the wedged renders got. + already_regenerated_once = render_context(identical_failures=8, render_attempts=1) + + assert decides_to_regenerate(already_regenerated_once) is True + + assert MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS >= 2, ( + "the budget has to allow a second regeneration for the assertion above to mean " + "anything; at 1 the loop abandons a functionality the move was still working on" + ) + + def test_the_switch_is_announced_in_a_greppable_form(): """Benchmark runs are read by tooling before they are read by a person.""" context = render_context(identical_failures=4) From fdcd2f6610a07efd5254db86d3461e16254019e6 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Wed, 19 Aug 2026 22:43:46 +0200 Subject: [PATCH 81/83] Give a regenerated conformance test a chance before judging it stuck Raising the regeneration budget to three exposed a latent bug rather than delivering the benefit it was meant to. Regeneration reset the attempt counter but left the stall counters alone, and those counters had been measuring a test that no longer existed. So the replacement test's very first failure landed on a consecutive-failure count already past its threshold, the loop concluded it was stuck again, and it regenerated once more. Four benchmark renders spent their entire budget that way -- consecutive_failures 7, 8, 9, three tests discarded after one attempt each, all inside half a minute -- and then failed exactly as they had before. The stall evidence is now cleared when the loop is handed a genuinely new problem. Cumulative attempt and failure counts survive, because they answer a different question and the benchmark series is indexed on them. At the old budget of one this was invisible: a single regeneration, then the budget was gone and the loop had no choice but to patch. Claude-Session: https://claude.ai/code/session_01RwffYaMySLGNKhGyXzDLGG --- render_machine/fix_loop_metrics.py | 23 ++++++++++++++ render_machine/render_context.py | 12 +++++++- tests/test_fix_loop_metrics.py | 49 +++++++++++++++++++++++++++++- 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/render_machine/fix_loop_metrics.py b/render_machine/fix_loop_metrics.py index eead312b..c46b017f 100644 --- a/render_machine/fix_loop_metrics.py +++ b/render_machine/fix_loop_metrics.py @@ -99,6 +99,29 @@ def record(self, loop: str, module: str, frid: str, passed: bool, output: str) - counters.current_repeat = 1 return None + def start_over(self, loop: str, module: str, frid: Optional[str]) -> None: + """Forgets that this loop is stuck, without forgetting what it has done. + + Called when the loop is handed a genuinely new problem — a regenerated conformance + test — rather than another patch against the old one. The evidence of being stuck + was evidence about a test that no longer exists, and carrying it over is not a + harmless conservatism: the first failure of the replacement test lands on a stall + counter that is already past its threshold, so the replacement is condemned on one + attempt and regenerated again. A benchmark render spent its whole regeneration + budget in twenty-eight seconds that way, three tests discarded after one attempt + each, none of them given a chance to be the one that worked. + + The cumulative counts survive, because they answer a different question — how much + work this functionality took in total — and the per-render series is indexed on + them. + """ + counters = self._counters_for(loop, module, frid) + if counters is None: + return + counters.consecutive_failures = 0 + counters.current_repeat = 0 + counters.last_fingerprint = None + def current_streak(self, loop: str, module: str, frid: Optional[str]) -> int: """How many times in a row this loop has just failed the same way. diff --git a/render_machine/render_context.py b/render_machine/render_context.py index ad1ca258..0ebecf32 100644 --- a/render_machine/render_context.py +++ b/render_machine/render_context.py @@ -13,7 +13,13 @@ from plain_modules import PlainModule from render_machine import triggers from render_machine.conformance_tests import CONFORMANCE_TESTS_DEFINITION_FILE_NAME, ConformanceTests -from render_machine.fix_loop_metrics import STRATEGY_SWITCH_PREFIX, UNIT_LOOP, FixLoopMetrics, stalled_reason +from render_machine.fix_loop_metrics import ( + CONFORMANCE_LOOP, + STRATEGY_SWITCH_PREFIX, + UNIT_LOOP, + FixLoopMetrics, + stalled_reason, +) from render_machine.render_types import ( AcceptanceTestPhase, ConformanceTestsRunningContext, @@ -416,6 +422,10 @@ def _handle_test_regeneration(self): ctx.conformance_tests_render_attempts += 1 ctx.fix_attempts = 0 ctx.regenerating_conformance_tests = False + # The stall that triggered this was measured against the test just deleted. Left + # standing, it condemns the replacement on its first failure and the whole + # regeneration budget is spent in seconds on tests that never got a second look. + self.fix_loop_metrics.start_over(CONFORMANCE_LOOP, module=self.module_name, frid=ctx.current_testing_frid) def _handle_retry_after_code_change(self): """Re-run the test that failed and triggered a code change.""" diff --git a/tests/test_fix_loop_metrics.py b/tests/test_fix_loop_metrics.py index c8314cda..150d41e7 100644 --- a/tests/test_fix_loop_metrics.py +++ b/tests/test_fix_loop_metrics.py @@ -12,7 +12,14 @@ failures, since both mistakes destroy the signal in opposite directions. """ -from render_machine.fix_loop_metrics import CONFORMANCE_LOOP, UNIT_LOOP, FixLoopMetrics, failure_fingerprint +from render_machine.fix_loop_metrics import ( + CONFORMANCE_LOOP, + CONSECUTIVE_FAILURE_THRESHOLD, + UNIT_LOOP, + FixLoopMetrics, + failure_fingerprint, + stalled_reason, +) def test_the_same_failure_fingerprints_the_same(): @@ -181,3 +188,43 @@ def test_the_render_summary_covers_every_frid_touched(): def test_the_render_summary_is_empty_when_no_script_ran(): """A render that failed before any test script must not emit a misleading summary.""" assert FixLoopMetrics().render_summary() == [] + + +def test_a_regenerated_test_is_not_condemned_by_the_old_test_s_stall(): + """The bug that spent a whole regeneration budget in twenty-eight seconds. + + Regeneration hands the loop a different test. The stall that justified it was measured + against the test just deleted, so if it survives, the replacement's very first failure + lands on a counter already past the threshold and the replacement is discarded after + one attempt. Four benchmark renders burned three regenerations each that way, 7 -> 8 -> + 9 consecutive failures, all inside half a minute. + """ + metrics = FixLoopMetrics() + for _ in range(CONSECUTIVE_FAILURE_THRESHOLD): + metrics.record(CONFORMANCE_LOOP, module="m", frid="2", passed=False, output="always different %s") + + assert stalled_reason(metrics, CONFORMANCE_LOOP, module="m", frid="2") is not None + + metrics.start_over(CONFORMANCE_LOOP, module="m", frid="2") + + assert stalled_reason(metrics, CONFORMANCE_LOOP, module="m", frid="2") is None + + # One failure of the replacement must not re-trigger the switch on its own. + metrics.record(CONFORMANCE_LOOP, module="m", frid="2", passed=False, output="a new failure") + + assert stalled_reason(metrics, CONFORMANCE_LOOP, module="m", frid="2") is None + + +def test_starting_over_keeps_the_work_already_counted(): + """The cumulative counts answer a different question and the benchmark series is + indexed on them, so a reset must not erase them.""" + metrics = FixLoopMetrics() + for _ in range(4): + metrics.record(CONFORMANCE_LOOP, module="m", frid="2", passed=False, output="identical") + + metrics.start_over(CONFORMANCE_LOOP, module="m", frid="2") + summary = metrics.frid_summary("m", "2") + + assert "conformance=4" in summary + assert "conformance_failed=4" in summary + assert "conformance_max_repeat=4" in summary From ecaa0a849a67dbb89c9f68120612b83635c3e4fd Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Wed, 19 Aug 2026 23:08:35 +0200 Subject: [PATCH 82/83] End the input of a conformance target no test is driving Attaching the broker suppresses the spawn-time VEOF, on the promise that something will answer the target's terminal reads. The broker is attached to every conformance execution, but only a test written against codeplain-tty ever makes that promise good -- and almost none are. Those targets got a terminal whose input never ended: anything reading stdin blocked until the 120-second timeout, the conformance loop read the timeout as a defect in the generated code, and patched against it until the render was abandoned. cli-password-manager is the clearest casualty. It scores 15/16 and 16/16 on main across five consecutive stdbmark runs; on this branch it scored 1/16 in fourteen consecutive renders, every one of them dying in the vault_cli module, whose target prompts for a master password. The missing dist/vault.py that all fifteen failing tests cite is downstream of that -- the render never got far enough to publish it. The broker now reports whether it has served a command. Until it has, nothing is driving the terminal and a quiet target is sent end-of-file exactly as a driverless one is; once a test has spoken, deliveries stop for the rest of the execution so an unsolicited EOF cannot land in the middle of its dialogue. The decision is re-made each poll, since a test may drive only late. Claude-Session: https://claude.ai/code/session_01RwffYaMySLGNKhGyXzDLGG --- render_machine/render_utils.py | 24 +++++++++++++--- render_machine/tty_broker.py | 17 +++++++++++ tests/test_quiet_eof_resend.py | 52 ++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/render_machine/render_utils.py b/render_machine/render_utils.py index 81d1ffae..a50ec702 100644 --- a/render_machine/render_utils.py +++ b/render_machine/render_utils.py @@ -179,6 +179,7 @@ def _await_target( stop_event: Optional[threading.Event], outcome: _ScriptOutcome, driverless: bool = True, + broker: Optional[TtyBroker] = None, ) -> None: """Waits for the target, recording every condition each poll can observe. @@ -192,7 +193,7 @@ def _await_target( end-of-file pushed into a terminal the broker is driving. """ deadline = time.monotonic() + script_timeout - eof_resender = _QuietEofResender(process, driverless=driverless) + eof_resender = _QuietEofResender(process, driverless=driverless, broker=broker) while True: returncode = process.poll() if returncode is not None: @@ -230,9 +231,18 @@ class _QuietEofResender: a noisy stuck script. """ - def __init__(self, process: TerminalProcess, driverless: bool) -> None: + def __init__(self, process: TerminalProcess, driverless: bool, broker: Optional["TtyBroker"] = None) -> None: self._process = process - self._enabled = driverless + # An attached broker suppresses the spawn-time end-of-file, and that is right only + # for a test that drives the terminal. The broker is attached to every conformance + # execution, so the ones that never call `codeplain-tty` — the great majority — + # got a target whose stdin simply never ended, and any target that read it waited + # out its whole timeout. On the legacy pipe backend the same tests saw EOF at once + # and passed. `broker.served_a_command` is what separates the two: until a command + # arrives nothing is driving this terminal, so the target should be told the input + # is over, exactly as a driverless one is. + self._broker = broker + self._enabled = driverless or broker is not None self._resends = 0 self._seen = -1 self._since = time.monotonic() @@ -241,6 +251,12 @@ def consider(self) -> None: if not self._enabled or self._resends >= MAX_EOF_RESENDS: return + # A test that has started driving owns this terminal for the rest of the + # execution; an unsolicited end-of-file would land in the middle of its dialogue. + if self._broker is not None and self._broker.served_a_command: + self._enabled = False + return + produced = len(self._process.normalized_output()) if produced != self._seen: self._seen = produced @@ -352,7 +368,7 @@ def _run_script( child_env = _platform_test_environment(broker) input_driver = broker process.spawn(cmd, env=child_env, stop_event=stop_event, input_driver=input_driver) - _await_target(process, script_timeout, stop_event, outcome, driverless=input_driver is None) + _await_target(process, script_timeout, stop_event, outcome, driverless=input_driver is None, broker=broker) except RenderCancelledError: outcome.cancelled() except Exception as exc: diff --git a/render_machine/tty_broker.py b/render_machine/tty_broker.py index ed30a99f..c3ecf4c7 100644 --- a/render_machine/tty_broker.py +++ b/render_machine/tty_broker.py @@ -82,6 +82,14 @@ def __init__(self, process: TerminalProcess) -> None: # Transcript position consumed by the last successful wait-for. Mutated only by # the single server thread, which serves one request at a time. self._match_cursor = 0 + # Whether any authenticated command has been served. Attaching the broker + # suppresses the spawn-time end-of-file, on the promise that something will answer + # the target's reads — but the broker is attached to every conformance execution, + # while only a test written against `codeplain-tty` ever makes that promise good. + # Until the first command arrives, nothing is driving this terminal and a target + # that reads stdin would wait out its whole timeout. Set by the single server + # thread, read by the waiter. + self._served_a_command = False self._server: Optional[threading.Thread] = None self._listener: Optional[socket.socket] = None self._directory: Optional[str] = None @@ -140,6 +148,12 @@ def child_env(self) -> dict: tty_protocol.TOKEN_ENV_VAR: self._token, } + @property + def served_a_command(self) -> bool: + """Whether a test has actually driven this terminal, as opposed to merely having + been offered the means to.""" + return self._served_a_command + def description(self) -> str: return "the codeplain-tty broker is attached to the script's terminal" @@ -219,6 +233,9 @@ def _handle(self, request: dict) -> dict: ) if self._closing.is_set(): return tty_protocol.error_response(tty_protocol.ERROR_SHUTTING_DOWN, "the execution is shutting down") + # Authenticated and in-protocol: from here on a test is genuinely driving this + # terminal, whatever the command turns out to be and whether or not it succeeds. + self._served_a_command = True command = request.get("command") args = request.get("args") if not isinstance(args, dict): diff --git a/tests/test_quiet_eof_resend.py b/tests/test_quiet_eof_resend.py index 62f53bcd..1cd4e533 100644 --- a/tests/test_quiet_eof_resend.py +++ b/tests/test_quiet_eof_resend.py @@ -138,3 +138,55 @@ def test_a_real_clock_is_used_for_the_quiet_period(target): resender.consider() target.write_input.assert_not_called() + + +class _FakeBroker: + """Stands in for TtyBroker; only `served_a_command` matters to the resender.""" + + def __init__(self, served: bool = False) -> None: + self.served_a_command = served + + +def test_a_broker_no_test_ever_used_still_gets_the_target_its_end_of_file(target): + """The cli-password-manager regression. + + Attaching the broker suppresses the spawn-time VEOF. That is right for a test that + drives the terminal and wrong for every conformance test that does not -- and the + broker is attached to all of them. On the pipe backend those targets saw EOF at once; + here they read a terminal that never ended and waited out the full timeout. + cli-password-manager scores 15/16 on main and scored 1/16 here, fourteen renders + running, because its target prompts for a master password. + """ + resender = _QuietEofResender(target, driverless=False, broker=_FakeBroker(served=False)) + resender.consider() + + quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) + + target.write_input.assert_called_once_with(EOF_BYTE) + + +def test_a_terminal_a_test_is_driving_is_left_alone(target): + """Once a test has spoken to the broker it owns the dialogue, and an unsolicited + end-of-file would land in the middle of it.""" + resender = _QuietEofResender(target, driverless=False, broker=_FakeBroker(served=True)) + resender.consider() + + quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) + + target.write_input.assert_not_called() + + +def test_a_test_that_starts_driving_stops_further_deliveries(target): + """The broker can go unused for a while and then be called, so the decision is re-made + every poll rather than fixed at spawn.""" + broker = _FakeBroker(served=False) + resender = _QuietEofResender(target, driverless=False, broker=broker) + resender.consider() + quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) + target.write_input.assert_called_once_with(EOF_BYTE) + target.write_input.reset_mock() + + broker.served_a_command = True + quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) + + target.write_input.assert_not_called() From 0267db71308738d0d0be3dc8bd8938579d863838 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Wed, 19 Aug 2026 23:38:06 +0200 Subject: [PATCH 83/83] Remove the terminal-driving helper for generated tests The helper let a generated conformance or acceptance test drive its target through the controlling terminal. Attaching it to an execution suppressed the end-of-file the terminal is otherwise given at spawn, on the basis that something would answer the target's reads instead. That trade was applied to every conformance and acceptance execution, while only a test written against the helper ever made it good. Tests that never used it -- nearly all of them -- ran against a terminal whose input simply never ended, so any target reading standard input blocked until the execution timeout. The fix loop then read that timeout as a defect in the generated code and patched against it until the render was abandoned. An interactive command-line application is the worst case: its first prompt is unanswerable, so no functionality after it can pass. Weighed against that, the capability was used once in roughly thirty renders. Removing it deletes the broker, its wire protocol, the helper executable, the capability descriptor, and the portability audit that existed only to keep the helper out of delivered code -- along with the outstanding Windows transport work none of it can ship without. The pseudoterminal backend stays. Giving the target a real terminal is what makes isatty() true and what interactive programs need to start at all; that is independent of anything driving the terminal from outside. The spawn-time end-of-file becomes unconditional, matching the behaviour the pipe backend has always had, and quiet-target re-delivery now applies uniformly rather than only where no driver was attached. Tests that need to drive an interactive program can do so with an established pseudoterminal library, which also leaves the generated suite runnable outside this renderer. Claude-Session: https://claude.ai/code/session_01RwffYaMySLGNKhGyXzDLGG --- codeplain_REST_api.py | 12 - plain2code_tty.py | 137 ----- pyproject.toml | 1 - render_machine/_conpty.py | 8 +- render_machine/_legacy_pipe.py | 2 - render_machine/_posix_pty.py | 9 +- render_machine/actions/create_dist.py | 4 - .../actions/fix_conformance_test.py | 2 - .../actions/render_conformance_tests.py | 3 - .../actions/run_conformance_tests.py | 5 - render_machine/platform_test_audit.py | 79 --- render_machine/platform_test_runtime.py | 102 ---- render_machine/render_utils.py | 102 +--- render_machine/terminal_process.py | 25 +- render_machine/tty_broker.py | 328 ----------- render_machine/tty_protocol.py | 150 ----- tests/test_conpty.py | 2 +- tests/test_platform_test_audit.py | 80 --- tests/test_platform_test_runtime.py | 161 ------ tests/test_platform_test_scoping.py | 115 ---- tests/test_quiet_eof_resend.py | 88 +-- tests/test_render_utils.py | 19 +- tests/test_terminal_process.py | 19 +- tests/test_terminal_validation.py | 2 +- tests/test_tty_broker.py | 518 ------------------ 25 files changed, 60 insertions(+), 1913 deletions(-) delete mode 100644 plain2code_tty.py delete mode 100644 render_machine/platform_test_audit.py delete mode 100644 render_machine/platform_test_runtime.py delete mode 100644 render_machine/tty_broker.py delete mode 100644 render_machine/tty_protocol.py delete mode 100644 tests/test_platform_test_audit.py delete mode 100644 tests/test_platform_test_runtime.py delete mode 100644 tests/test_platform_test_scoping.py delete mode 100644 tests/test_tty_broker.py diff --git a/codeplain_REST_api.py b/codeplain_REST_api.py index 4c36ca2f..00569881 100644 --- a/codeplain_REST_api.py +++ b/codeplain_REST_api.py @@ -322,7 +322,6 @@ def render_conformance_tests( conformance_tests_json, all_acceptance_tests, run_state: RunState, - platform_test_runtime: Optional[dict] = None, ): endpoint_url = f"{self.api_url}/render_conformance_tests" headers = {"X-API-Key": self.api_key, "Content-Type": "application/json"} @@ -341,9 +340,6 @@ def render_conformance_tests( "all_acceptance_tests": all_acceptance_tests, } - if platform_test_runtime is not None: - payload["platform_test_runtime"] = platform_test_runtime - response = self.post_request(endpoint_url, headers, payload, run_state) return response["patched_response_files"], response["conformance_tests_plan_summary_string"] @@ -387,7 +383,6 @@ def fix_conformance_tests_issue( current_testing_frid_high_level_implementation_plan: Optional[str], conflicting_requirements_count: int, run_state: RunState, - platform_test_runtime: Optional[dict] = None, stalled_reason: Optional[str] = None, ): endpoint_url = f"{self.api_url}/fix_conformance_tests_issue" @@ -415,9 +410,6 @@ def fix_conformance_tests_issue( if acceptance_tests is not None: payload["acceptance_tests"] = acceptance_tests - if platform_test_runtime is not None: - payload["platform_test_runtime"] = platform_test_runtime - # Sent only once the loop has stopped moving. Omitted otherwise, so an # ordinary fix request is byte-identical to what it was. if stalled_reason is not None: @@ -437,7 +429,6 @@ def render_acceptance_tests( required_modules, acceptance_test, run_state: RunState, - platform_test_runtime: Optional[dict] = None, ): """ Renders acceptance tests based on the provided parameters. @@ -477,9 +468,6 @@ def render_acceptance_tests( "acceptance_test": acceptance_test, } - if platform_test_runtime is not None: - payload["platform_test_runtime"] = platform_test_runtime - return self.post_request(endpoint_url, headers, payload, run_state) def analyze_rendering( diff --git a/plain2code_tty.py b/plain2code_tty.py deleted file mode 100644 index f9968386..00000000 --- a/plain2code_tty.py +++ /dev/null @@ -1,137 +0,0 @@ -"""`codeplain-tty` — the terminal-automation helper for Codeplain's internal tests. - -Available on PATH only while Codeplain runs its own conformance and acceptance tests. A -generated test invokes it to drive the tested process through its controlling terminal: -wait for a prompt to appear in the transcript, type text, press a control key, send -exact bytes, or resize the terminal. It talks to a private per-execution broker over the -endpoint named in the environment; outside a Codeplain test run those variables do not -exist and the helper reports the runtime as unavailable. - -Exit codes: 0 success; 1 the command ran and did not succeed (a wait that timed out, -input the target no longer accepts); 2 usage error; 69 the runtime itself is -unavailable (missing environment, unreachable broker, protocol mismatch). -""" - -import argparse -import os -import socket -import sys -from typing import Optional - -from render_machine import tty_protocol - -# How much longer than the command's own deadline the helper waits for the response -# frame, so a broker-side wait always resolves before the client gives up on it. -RESPONSE_MARGIN_SECONDS = 10.0 - -_ERROR_EXIT_CODES = { - tty_protocol.ERROR_TIMEOUT: tty_protocol.EXIT_COMMAND_FAILED, - tty_protocol.ERROR_INPUT_CLOSED: tty_protocol.EXIT_COMMAND_FAILED, - tty_protocol.ERROR_BACKPRESSURE: tty_protocol.EXIT_COMMAND_FAILED, - tty_protocol.ERROR_UNSUPPORTED: tty_protocol.EXIT_COMMAND_FAILED, - tty_protocol.ERROR_INVALID_REQUEST: tty_protocol.EXIT_USAGE, - tty_protocol.ERROR_UNAUTHORIZED: tty_protocol.EXIT_RUNTIME_UNAVAILABLE, - tty_protocol.ERROR_SHUTTING_DOWN: tty_protocol.EXIT_RUNTIME_UNAVAILABLE, - tty_protocol.ERROR_INTERNAL: tty_protocol.EXIT_RUNTIME_UNAVAILABLE, -} - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="codeplain-tty", - description="Drive the terminal of a process under Codeplain's internal functional tests.", - ) - commands = parser.add_subparsers(dest="command", required=True) - - wait_for = commands.add_parser(tty_protocol.COMMAND_WAIT_FOR, help="wait until TEXT appears in the transcript") - wait_for.add_argument("text") - wait_for.add_argument("--timeout", type=float, default=30.0, help="seconds to wait (default: 30)") - - wait_absent = commands.add_parser( - tty_protocol.COMMAND_WAIT_UNTIL_ABSENT, help="wait until TEXT is no longer in the transcript" - ) - wait_absent.add_argument("text") - wait_absent.add_argument("--timeout", type=float, default=30.0, help="seconds to wait (default: 30)") - - send_text = commands.add_parser( - tty_protocol.COMMAND_SEND_TEXT, - help="type TEXT into the terminal (newlines are typed as the Enter key)", - ) - send_text.add_argument("text") - - send_control = commands.add_parser( - tty_protocol.COMMAND_SEND_CONTROL, help="press Ctrl-KEY (e.g. 'd' for Ctrl-D, 'c' for Ctrl-C)" - ) - send_control.add_argument("key") - - send_hex = commands.add_parser(tty_protocol.COMMAND_SEND_HEX, help="send exact bytes, hex-encoded") - send_hex.add_argument("hex") - - size = commands.add_parser(tty_protocol.COMMAND_SIZE, help="resize the terminal") - size.add_argument("columns", type=int) - size.add_argument("rows", type=int) - - return parser - - -def _request_args(options: argparse.Namespace) -> dict: - if options.command in (tty_protocol.COMMAND_WAIT_FOR, tty_protocol.COMMAND_WAIT_UNTIL_ABSENT): - return {"text": options.text, "timeout": options.timeout} - if options.command == tty_protocol.COMMAND_SEND_TEXT: - return {"text": options.text} - if options.command == tty_protocol.COMMAND_SEND_CONTROL: - return {"key": options.key} - if options.command == tty_protocol.COMMAND_SEND_HEX: - return {"hex": options.hex} - return {"columns": options.columns, "rows": options.rows} - - -def _response_deadline(options: argparse.Namespace) -> float: - timeout = getattr(options, "timeout", 0.0) or 0.0 - return timeout + RESPONSE_MARGIN_SECONDS - - -def _fail(message: str, exit_code: int) -> int: - print(f"codeplain-tty: {message}", file=sys.stderr) - return exit_code - - -def run(argv: Optional[list] = None) -> int: - options = build_parser().parse_args(argv) - - endpoint = os.environ.get(tty_protocol.ENDPOINT_ENV_VAR) - token = os.environ.get(tty_protocol.TOKEN_ENV_VAR) - if not endpoint or not token: - return _fail( - "the Codeplain platform-test runtime is not available here " - f"({tty_protocol.ENDPOINT_ENV_VAR} is not set)", - tty_protocol.EXIT_RUNTIME_UNAVAILABLE, - ) - if not hasattr(socket, "AF_UNIX"): - return _fail("this platform's transport is not supported yet", tty_protocol.EXIT_RUNTIME_UNAVAILABLE) - - request = tty_protocol.request(token, options.command, _request_args(options)) - try: - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: - connection.settimeout(_response_deadline(options)) - connection.connect(endpoint) - connection.sendall(tty_protocol.encode_frame(request)) - response = tty_protocol.read_frame(connection.recv) - except (OSError, tty_protocol.ProtocolError) as exc: - return _fail(f"could not reach the test runtime broker: {exc}", tty_protocol.EXIT_RUNTIME_UNAVAILABLE) - - if response is None: - return _fail("the broker closed the connection without answering", tty_protocol.EXIT_RUNTIME_UNAVAILABLE) - if response.get("ok") is True: - return tty_protocol.EXIT_OK - error = str(response.get("error")) - message = response.get("message", "the command failed") - return _fail(f"{options.command}: {message}", _ERROR_EXIT_CODES.get(error, tty_protocol.EXIT_RUNTIME_UNAVAILABLE)) - - -def main() -> None: - sys.exit(run()) - - -if __name__ == "__main__": - main() diff --git a/pyproject.toml b/pyproject.toml index 94859244..710db10d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,6 @@ dev = [ [project.scripts] codeplain = "plain2code:main" -codeplain-tty = "plain2code_tty:main" # Derive the version from the git tag (e.g. v0.3.8 -> 0.3.8). The version is # baked into the package metadata at build time; system_config.py reads it back diff --git a/render_machine/_conpty.py b/render_machine/_conpty.py index 4277e7f6..76f078e0 100644 --- a/render_machine/_conpty.py +++ b/render_machine/_conpty.py @@ -21,7 +21,7 @@ * The job is a stronger containment than a POSIX process group. It survives `setpgid`-style escapes and the kernel enforces it, whereas PTY hangup delivers a signal a target may ignore. -* There is no synthetic end-of-file. POSIX injects `VEOF` when no input driver is attached; +* There is no synthetic end-of-file. POSIX injects `VEOF` at spawn; ConPTY has no parent-side equivalent that keeps the input channel open, and the channel has to stay open for the graceful control byte and for terminal-query replies. A script that reads input therefore blocks until the execution timeout rather than seeing EOF. @@ -66,7 +66,6 @@ TERMINAL_ROWS, InputWriteResult, TerminalEnvironmentError, - TerminalInputDriver, TerminalProcess, TerminalProcessError, terminal_child_environment, @@ -165,8 +164,8 @@ # unlike the other backends it cannot hand the target end-of-file, so a script that reads # terminal input really does block until the execution timeout. NO_INPUT_NOTE = ( - " No input driver was attached to the script's terminal, and on Windows the terminal carries " - "no synthetic end-of-file, so a script that waits for terminal input blocks until the timeout." + " On Windows the terminal carries no synthetic end-of-file, so a script that waits for " + "terminal input blocks until the timeout." ) @@ -1070,7 +1069,6 @@ def spawn( env: Optional[dict] = None, terminal_size: Tuple[int, int] = (TERMINAL_COLUMNS, TERMINAL_ROWS), stop_event: Optional[threading.Event] = None, - input_driver: Optional[TerminalInputDriver] = None, spawn_timeout: float = HANDSHAKE_TIMEOUT_SECONDS, ) -> None: if self._spawned: diff --git a/render_machine/_legacy_pipe.py b/render_machine/_legacy_pipe.py index f14cc9ec..566dba0b 100644 --- a/render_machine/_legacy_pipe.py +++ b/render_machine/_legacy_pipe.py @@ -44,7 +44,6 @@ TERMINAL_ROWS, InputDisposition, InputWriteResult, - TerminalInputDriver, TerminalLaunchError, TerminalProcess, child_environment, @@ -111,7 +110,6 @@ def spawn( env: Optional[dict] = None, terminal_size: Tuple[int, int] = (TERMINAL_COLUMNS, TERMINAL_ROWS), stop_event: Optional[threading.Event] = None, - input_driver: Optional[TerminalInputDriver] = None, ) -> None: if self._spawned: raise RuntimeError("LegacyPipeProcess instances are single-use") diff --git a/render_machine/_posix_pty.py b/render_machine/_posix_pty.py index 0a856b14..875d6ed2 100644 --- a/render_machine/_posix_pty.py +++ b/render_machine/_posix_pty.py @@ -52,7 +52,6 @@ InputDisposition, InputWriteResult, TerminalEnvironmentError, - TerminalInputDriver, TerminalLaunchError, TerminalProcess, TerminalProcessError, @@ -477,7 +476,6 @@ def __init__(self) -> None: self._spawned = False self._closed = False self._acked = False - self._input_driver: Optional[TerminalInputDriver] = None self._bundle: Optional[_ReaderBundle] = None self._reader: Optional[threading.Thread] = None @@ -513,7 +511,6 @@ def spawn( env: Optional[dict] = None, terminal_size: Tuple[int, int] = (TERMINAL_COLUMNS, TERMINAL_ROWS), stop_event: Optional[threading.Event] = None, - input_driver: Optional[TerminalInputDriver] = None, handshake_timeout: float = HANDSHAKE_TIMEOUT_SECONDS, ) -> None: """Allocates the terminal, launches the target, and returns once it is running.""" @@ -521,7 +518,6 @@ def spawn( raise RuntimeError("PosixPtyProcess instances are single-use") self._spawned = True self._stop_event = stop_event if stop_event is not None else threading.Event() - self._input_driver = input_driver deadline = time.monotonic() + handshake_timeout try: self._check_cancelled() @@ -775,11 +771,10 @@ def _advance_handshake(self, parser: _HandshakeParser, status_r: int, deadline: return False def _acknowledge(self, deadline: float) -> None: - """Records the group, delivers the no-driver VEOF, and only then releases the target.""" + """Records the group, delivers the VEOF, and only then releases the target.""" assert self._proc is not None self._pgid = self._proc.pid # recorded BEFORE the target can run - if self._input_driver is None: - self._inject_veof(deadline) + self._inject_veof(deadline) self._pre_ack_hook() self._acked = True ack_w = self._ack_w diff --git a/render_machine/actions/create_dist.py b/render_machine/actions/create_dist.py index 6e978d84..9a1dfd4d 100644 --- a/render_machine/actions/create_dist.py +++ b/render_machine/actions/create_dist.py @@ -3,7 +3,6 @@ import file_utils from plain2code_console import SUCCESS_COLOR, console from render_machine.actions.base_action import BaseAction -from render_machine.platform_test_audit import audit_build_folder from render_machine.render_context import RenderContext @@ -11,9 +10,6 @@ class CreateDist(BaseAction): SUCCESSFUL_OUTCOME = "dist_created" def execute(self, render_context: RenderContext, _previous_action_payload: Any | None): - # The final portability audit: a build that references Codeplain's private test - # runtime is never published or copied. - audit_build_folder(render_context.build_folder) # Copy build and conformance tests folders to output folders if specified if render_context.copy_build: file_utils.copy_folder_to_output( diff --git a/render_machine/actions/fix_conformance_test.py b/render_machine/actions/fix_conformance_test.py index e2281a0a..4fa8736f 100644 --- a/render_machine/actions/fix_conformance_test.py +++ b/render_machine/actions/fix_conformance_test.py @@ -9,7 +9,6 @@ from render_machine.actions.base_action import BaseAction from render_machine.fix_loop_metrics import CONFORMANCE_LOOP, STRATEGY_SWITCH_PREFIX, stalled_reason from render_machine.implementation_code_helpers import ImplementationCodeHelpers -from render_machine.platform_test_runtime import advertised_platform_test_runtime from render_machine.render_context import RenderContext from render_machine.render_types import RenderError, TestExecutionPhase @@ -217,7 +216,6 @@ def execute(self, render_context: RenderContext, previous_action_payload: Any | render_context.conformance_tests_running_context.current_testing_frid_high_level_implementation_plan, render_context.conformance_tests_running_context.conflicting_requirement_count, run_state=render_context.run_state, - platform_test_runtime=advertised_platform_test_runtime(), stalled_reason=stall_context, ) code_diff_files_content = {} diff --git a/render_machine/actions/render_conformance_tests.py b/render_machine/actions/render_conformance_tests.py index 62ac7b9e..178a9716 100644 --- a/render_machine/actions/render_conformance_tests.py +++ b/render_machine/actions/render_conformance_tests.py @@ -7,7 +7,6 @@ from plain2code_console import console from render_machine.actions.base_action import BaseAction from render_machine.implementation_code_helpers import ImplementationCodeHelpers -from render_machine.platform_test_runtime import advertised_platform_test_runtime from render_machine.render_context import RenderContext from render_machine.render_types import AcceptanceTestPhase, TestExecutionPhase @@ -127,7 +126,6 @@ def _render_conformance_tests(self, render_context: RenderContext): ), all_acceptance_tests, run_state=render_context.run_state, - platform_test_runtime=advertised_platform_test_runtime(), ) render_context.conformance_tests_running_context.current_testing_frid_high_level_implementation_plan = ( @@ -180,7 +178,6 @@ def _render_acceptance_test(self, render_context: RenderContext): render_context.get_required_modules_functionalities(), acceptance_test, run_state=render_context.run_state, - platform_test_runtime=advertised_platform_test_runtime(), ) conformance_tests_folder_name = ( render_context.conformance_tests_running_context.get_current_conformance_test_folder_name() diff --git a/render_machine/actions/run_conformance_tests.py b/render_machine/actions/run_conformance_tests.py index 38a6d685..050bd633 100644 --- a/render_machine/actions/run_conformance_tests.py +++ b/render_machine/actions/run_conformance_tests.py @@ -5,7 +5,6 @@ from plain2code_console import console from render_machine.actions.base_action import BaseAction from render_machine.fix_loop_metrics import CONFORMANCE_LOOP, report_fix_loop_attempt -from render_machine.platform_test_runtime import platform_test_runtime_available from render_machine.render_context import RenderContext from render_machine.render_types import RenderError @@ -50,10 +49,6 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | module=render_context.conformance_tests_running_context.current_testing_module_name, timeout=render_context.test_script_timeout, stop_event=render_context.stop_event, - # Conformance (and the acceptance tests that extend the same suite) may drive - # the target's terminal through codeplain-tty; unit tests and environment - # preparation never get the runtime. - platform_test_runtime=platform_test_runtime_available(), ) render_context.script_execution_history.latest_conformance_test_output_path = ( conformance_tests_temp_log_file_path diff --git a/render_machine/platform_test_audit.py b/render_machine/platform_test_audit.py deleted file mode 100644 index 8ecff3b0..00000000 --- a/render_machine/platform_test_audit.py +++ /dev/null @@ -1,79 +0,0 @@ -"""The client-side final audit of the platform-test runtime's portability boundary. - -The API discards responses that leak `codeplain-tty` into delivered code, so by the -time a module render completes its build folder should be clean. This audit is the last -net before the build is published or copied: it walks the implementation tree and fails -the render on any reference to the helper or its environment prefix, because a delivered -application must run in a clean environment where none of Codeplain's test tooling -exists. - -Internal conformance and acceptance tests are exempt. They are what the helper exists -for, and they do turn up inside the audited tree — a module's build folder can carry a -`conformance_tests/` subtree, and benchmark renders showed the audit failing them. -Auditing those is not a stricter boundary, it is a false one: it aborts a successful -render over test code that is never delivered. -""" - -import os -from typing import List - -# The executable name, the module name, and the environment prefix — the same markers -# the API's response validation uses. -HELPER_REFERENCE_MARKERS = ("codeplain-tty", "plain2code_tty", "CODEPLAIN_TTY_") - -# Directories that carry no delivered source and may be large. -SKIPPED_DIRECTORIES = {".git", ".venv", "node_modules", "__pycache__", ".tmp", "dist", "build", "target"} - -# Internal test trees, which are allowed to drive the helper and are never delivered. -# Named separately from the above because skipping them is a boundary decision, not a -# performance one. -INTERNAL_TEST_DIRECTORIES = {"conformance_tests", "acceptance_tests", "dist_conformance_tests"} - -MAX_AUDITED_FILE_BYTES = 4 * 1024 * 1024 # a delivered source file larger than this is not source - -# Suffixes that are never delivered source. Logs matter most: the renderer's own -# codeplain.log records broker activity and module names, so auditing it reports the -# render's diagnostics as if the application had referenced the helper. -SKIPPED_FILE_SUFFIXES = (".log",) - - -class PlatformBoundaryViolation(Exception): - """A delivered build references Codeplain's private test tooling.""" - - -def find_platform_references(build_folder: str) -> List[str]: - """Build-folder-relative paths of files referencing the platform test helper.""" - violations = [] - for root, directories, file_names in os.walk(build_folder): - directories[:] = [ - name for name in directories if name not in SKIPPED_DIRECTORIES and name not in INTERNAL_TEST_DIRECTORIES - ] - for file_name in file_names: - if file_name.endswith(SKIPPED_FILE_SUFFIXES): - continue - path = os.path.join(root, file_name) - relative = os.path.relpath(path, build_folder) - if any(marker in file_name for marker in HELPER_REFERENCE_MARKERS): - violations.append(relative) - continue - try: - if os.path.getsize(path) > MAX_AUDITED_FILE_BYTES: - continue - with open(path, "r", encoding="utf-8", errors="ignore") as source: - content = source.read() - except OSError: - continue # unreadable files cannot ship a reference the target could read - if any(marker in content for marker in HELPER_REFERENCE_MARKERS): - violations.append(relative) - return sorted(violations) - - -def audit_build_folder(build_folder: str) -> None: - """Raises when the delivered build references the platform test runtime.""" - violations = find_platform_references(build_folder) - if violations: - raise PlatformBoundaryViolation( - "The generated build references Codeplain's private test runtime and cannot be published. " - f"Offending files: {', '.join(violations)}. " - "The implementation must not depend on codeplain-tty or CODEPLAIN_TTY_* in any way." - ) diff --git a/render_machine/platform_test_runtime.py b/render_machine/platform_test_runtime.py deleted file mode 100644 index 2113aa43..00000000 --- a/render_machine/platform_test_runtime.py +++ /dev/null @@ -1,102 +0,0 @@ -"""The platform-test runtime capability the client advertises to the API. - -The `codeplain-tty` helper lets a generated conformance or acceptance test drive the -target through its controlling terminal. The API injects instructions about the helper -only into requests that advertise this capability, so the descriptor here is a contract: -protocol version 1 and exactly the commands the client's broker implements. Support is -never derived from a client version — a client advertises only after its broker and -executable pass a local preflight, which is why the gate below is separate from the -descriptor it guards. - -The descriptor carries no secrets. The broker endpoint, its authentication token, and -every filesystem or pipe name stay client-side, scoped to one script execution. -""" - -import functools -import os -import subprocess -from typing import Optional - -from plain2code_console import console -from render_machine import tty_protocol -from render_machine.terminal_process import TerminalProcess -from render_machine.tty_broker import TtyBroker, broker_supported - -PROTOCOL_VERSION = 1 - -# The commands protocol version 1 promises. The API rejects a descriptor naming a command -# outside this set, so the tuple changes only together with the protocol version. -CODEPLAIN_TTY_COMMANDS = ( - "wait-for", - "wait-until-absent", - "send-text", - "send-control", - "send-hex", - "size", -) - - -def codeplain_tty_descriptor() -> dict: - """The version-1 capability object, as the request models carry it.""" - return { - "codeplain_tty": { - "protocol_version": PROTOCOL_VERSION, - "commands": list(CODEPLAIN_TTY_COMMANDS), - } - } - - -PREFLIGHT_MARKER = "codeplain-tty-preflight" -PREFLIGHT_TIMEOUT_SECONDS = 30.0 - - -class _PreflightProbe(TerminalProcess): - """A stand-in target whose transcript already contains the preflight marker.""" - - def normalized_output(self) -> str: - return PREFLIGHT_MARKER - - -@functools.lru_cache(maxsize=1) -def platform_test_runtime_available() -> bool: - """One real round trip through the runtime, cached for the process's lifetime. - - Support is never derived from a version: the capability is advertised only after the - broker starts, installs its helper, and the helper — executed exactly the way a - generated test will execute it — authenticates and completes a command over the - socket. Any failure keeps the runtime off and the request un-advertised. - """ - if not broker_supported(): - return False - broker = None - try: - broker = TtyBroker(_PreflightProbe()) - broker.start() - env = {key: value for key, value in os.environ.items() if not key.startswith(tty_protocol.ENV_VAR_PREFIX)} - env.update(broker.child_env()) - assert broker.helper_bin_dir is not None - helper = os.path.join(broker.helper_bin_dir, "codeplain-tty") - result = subprocess.run( - [helper, "wait-for", PREFLIGHT_MARKER, "--timeout", "5"], - env=env, - capture_output=True, - timeout=PREFLIGHT_TIMEOUT_SECONDS, - ) - if result.returncode != 0: - console.debug(f"codeplain-tty preflight failed (exit {result.returncode}): {result.stderr!r}") - return result.returncode == 0 - except Exception as exc: - console.debug(f"codeplain-tty preflight failed: {exc!r}") - return False - finally: - if broker is not None: - broker.close() - - -def advertised_platform_test_runtime() -> Optional[dict]: - """What the client actually advertises: the descriptor, or None while it cannot. - - None keeps the API on its backward-compatible path — no `codeplain-tty` prompt - content is generated for a runtime this client could not provide. - """ - return codeplain_tty_descriptor() if platform_test_runtime_available() else None diff --git a/render_machine/render_utils.py b/render_machine/render_utils.py index a50ec702..ca657f41 100644 --- a/render_machine/render_utils.py +++ b/render_machine/render_utils.py @@ -1,4 +1,3 @@ -import os import sys import tempfile import threading @@ -9,16 +8,13 @@ import plain_spec from plain2code_console import MUTED_COLOR, RETRY_COLOR, SUCCESS_COLOR, console from plain2code_exceptions import RenderCancelledError -from render_machine import tty_protocol from render_machine.terminal_process import ( ENVIRONMENT_ERROR_EXIT_CODE, NO_INPUT_NOTE, - TerminalInputDriver, TerminalProcess, TerminalProcessError, create_terminal_process, ) -from render_machine.tty_broker import TtyBroker SCRIPT_EXECUTION_TIMEOUT = 120 TIMEOUT_ERROR_EXIT_CODE = 124 @@ -28,22 +24,15 @@ # discoverable from the returned path and cleanable by the same convention. RAW_OUTPUT_SUFFIX = ".raw" -# The driver a non-broker execution gets: none. Only an execution that asked for the -# platform-test runtime (conformance and acceptance runs) attaches the per-execution -# `codeplain-tty` broker; unit tests and environment preparation always run without one. -INPUT_DRIVER: Optional[TerminalInputDriver] = None - -# A driverless execution gets one end-of-file at spawn. A program that reconfigures its -# terminal before reading discards whatever is queued — `getpass` calls +# Every execution gets one end-of-file at spawn. A program that reconfigures its terminal +# before reading discards whatever is queued — `getpass` calls # `tcsetattr(..., TCSAFLUSH, ...)`, and TCSAFLUSH means exactly that — so the EOF is gone # by the time the read happens and the program waits for input nobody will send. It costs -# the script its whole timeout, and the fix loop reads that as a defect in the code: one -# benchmark render patched against the resulting failure seventeen times in a row while -# its conformance loop never failed at all. +# the script its whole timeout, and the fix loop reads that as a defect in the code. # -# So the EOF is re-delivered while the target is quiet. With no driver attached there is -# nothing else a read could be answered with, which is what makes repeating it safe: a -# program that is reading gets the EOF it was owed, and one that is not is unaffected. +# So the EOF is re-delivered while the target is quiet. Nothing else answers a test +# script's terminal reads, which is what makes repeating it safe: a program that is +# reading gets the EOF it was owed, and one that is not is unaffected. EOF_BYTE = b"\x04" QUIET_BEFORE_EOF_RESEND_SECONDS = 5.0 MAX_EOF_RESENDS = 3 @@ -178,22 +167,15 @@ def _await_target( script_timeout: float, stop_event: Optional[threading.Event], outcome: _ScriptOutcome, - driverless: bool = True, - broker: Optional[TtyBroker] = None, ) -> None: """Waits for the target, recording every condition each poll can observe. No fact ends the wait before the others have been recorded: a target that exits after its deadline, or while a cancellation is already set, races with the condition it coincides with, and only the rank table decides which of them is published. - - `driverless` reports whether this execution attached an input driver. It has to be - passed rather than read from `INPUT_DRIVER`, which is a module default nothing - assigns: the driver is chosen per execution, and a broker-backed run must not have - end-of-file pushed into a terminal the broker is driving. """ deadline = time.monotonic() + script_timeout - eof_resender = _QuietEofResender(process, driverless=driverless, broker=broker) + eof_resender = _QuietEofResender(process) while True: returncode = process.poll() if returncode is not None: @@ -219,30 +201,21 @@ def _await_target( class _QuietEofResender: - """Re-delivers end-of-file to a driverless target that has gone quiet. + """Re-delivers end-of-file to a target that has gone quiet. Quiet is the only evidence available from outside: the parent cannot see the child's `tcsetattr`, so it watches for a target that is alive and has stopped producing - output. That describes a program blocked on a read, and — with no driver attached — - also describes a program that has nothing left to say. Both want the same answer. + output. That describes a program blocked on a read, and also describes a program that + has nothing left to say. Both want the same answer. Bounded rather than continuous. A target that stays quiet through several deliveries is not waiting on the terminal, and repeating forever would turn a stuck script into a noisy stuck script. """ - def __init__(self, process: TerminalProcess, driverless: bool, broker: Optional["TtyBroker"] = None) -> None: + def __init__(self, process: TerminalProcess) -> None: self._process = process - # An attached broker suppresses the spawn-time end-of-file, and that is right only - # for a test that drives the terminal. The broker is attached to every conformance - # execution, so the ones that never call `codeplain-tty` — the great majority — - # got a target whose stdin simply never ended, and any target that read it waited - # out its whole timeout. On the legacy pipe backend the same tests saw EOF at once - # and passed. `broker.served_a_command` is what separates the two: until a command - # arrives nothing is driving this terminal, so the target should be told the input - # is over, exactly as a driverless one is. - self._broker = broker - self._enabled = driverless or broker is not None + self._enabled = True self._resends = 0 self._seen = -1 self._since = time.monotonic() @@ -251,12 +224,6 @@ def consider(self) -> None: if not self._enabled or self._resends >= MAX_EOF_RESENDS: return - # A test that has started driving owns this terminal for the rest of the - # execution; an unsolicited end-of-file would land in the middle of its dialogue. - if self._broker is not None and self._broker.served_a_command: - self._enabled = False - return - produced = len(self._process.normalized_output()) if produced != self._seen: self._seen = produced @@ -324,25 +291,10 @@ def _collect_backend_state(process: TerminalProcess, execution: _ScriptExecution _record_backend_failure(execution.outcome, exc, "while reporting its result") -def _platform_test_environment(broker: TtyBroker) -> dict: - """The scoped child environment of a broker-enabled execution. - - Caller-supplied CODEPLAIN_TTY_* values are stripped before the broker's own are - added — the runtime owns that prefix — and the helper's directory is prepended to - PATH only here, so no other execution can resolve the executable. - """ - env = {key: value for key, value in os.environ.items() if not key.startswith(tty_protocol.ENV_VAR_PREFIX)} - env.update(broker.child_env()) - assert broker.helper_bin_dir is not None - env["PATH"] = broker.helper_bin_dir + os.pathsep + env.get("PATH", "") - return env - - def _run_script( cmd: list[str], script_timeout: float, stop_event: Optional[threading.Event], - platform_test_runtime: bool = False, ) -> _ScriptExecution: execution = _ScriptExecution() outcome = execution.outcome @@ -354,21 +306,10 @@ def _run_script( if process is None: return execution _script_started() - broker: Optional[TtyBroker] = None try: try: - child_env: Optional[dict] = None - input_driver: Optional[TerminalInputDriver] = INPUT_DRIVER - if platform_test_runtime: - # A broker that cannot start is an environment failure (the except below), - # never a spawn with the runtime silently missing: the generated test was - # promised the helper and would fail confusingly without it. - broker = TtyBroker(process) - broker.start() - child_env = _platform_test_environment(broker) - input_driver = broker - process.spawn(cmd, env=child_env, stop_event=stop_event, input_driver=input_driver) - _await_target(process, script_timeout, stop_event, outcome, driverless=input_driver is None, broker=broker) + process.spawn(cmd, stop_event=stop_event) + _await_target(process, script_timeout, stop_event, outcome) except RenderCancelledError: outcome.cancelled() except Exception as exc: @@ -377,18 +318,8 @@ def _run_script( # follow it. _record_backend_failure(outcome, exc, "while running the script") finally: - # The broker stops accepting before the target is torn down, so no command - # can race the teardown; its artifacts are gone before publication. - if broker is not None: - broker.close() _teardown(process, outcome) _collect_backend_state(process, execution) - if broker is not None: - # The backend's absent-driver note would misdescribe this execution. - execution.no_input_note = ( - " The codeplain-tty broker was attached; the script may be waiting for" - " terminal input its test never sent." - ) finally: _script_finished() return execution @@ -486,7 +417,7 @@ def _publish_timeout( reply_detail: str, no_input_note: str, ) -> tuple[int, str, Optional[str]]: - diagnostics = no_input_note if INPUT_DRIVER is None else "" + diagnostics = no_input_note if reply_failed: diagnostics += f" Terminal replies the script asked for could not be delivered: {reply_detail}." @@ -519,7 +450,6 @@ def execute_script( module: Optional[str] = None, timeout: Optional[int] = None, stop_event: Optional[threading.Event] = None, - platform_test_runtime: bool = False, ) -> tuple[int, str, Optional[str]]: script_timeout = timeout if timeout is not None else SCRIPT_EXECUTION_TIMEOUT @@ -532,7 +462,7 @@ def execute_script( cmd = [script_path] + scripts_args start_time = time.time() - execution = _run_script(cmd, script_timeout, stop_event, platform_test_runtime) + execution = _run_script(cmd, script_timeout, stop_event) elapsed_time = time.time() - start_time outcome = execution.outcome diff --git a/render_machine/terminal_process.py b/render_machine/terminal_process.py index 6e829a29..05eb1991 100644 --- a/render_machine/terminal_process.py +++ b/render_machine/terminal_process.py @@ -83,15 +83,15 @@ OWNER_PARENT = "parent" OWNER_READER = "reader" -# What a timeout diagnostic says when no input driver was attached. The spawn-time +# What a timeout diagnostic says about the input the target was given. The spawn-time # end-of-file is best-effort by nature: a program that flushes or reconfigures its # terminal before reading — getpass's TCSAFLUSH, a curses initialization — discards the # queued byte and then blocks on input nothing will send. ConPTY, which cannot deliver # an end-of-file at all, states its own note instead. NO_INPUT_NOTE = ( - " No input driver was attached to the script's terminal; a single end-of-file was " - "queued at spawn, but a program that flushes or reconfigures its terminal before " - "reading discards it and is left waiting for input that never arrives." + " An end-of-file was queued at the script's terminal at spawn and re-delivered while " + "the target stayed quiet; a program still waiting after that is blocked on something " + "other than the input it was given." ) @@ -109,20 +109,6 @@ class InputWriteResult: accepted_bytes: int -class TerminalInputDriver: - """The typed contract for what a backend accepts as `input_driver`. - - An attached driver is a promise that something will answer the target's terminal - reads for the whole execution, which changes spawn behavior: the POSIX backend does - not queue its spawn-time VEOF. Only an object that keeps that promise — today the - per-execution `codeplain-tty` broker — may implement this. - """ - - def description(self) -> str: - """One clause for diagnostics: what is driving the terminal's input.""" - raise NotImplementedError - - class TerminalProcessError(Exception): """Base class for failures the terminal backend reports to the renderer.""" @@ -172,7 +158,6 @@ def spawn( env: Optional[dict] = None, terminal_size: Tuple[int, int] = (TERMINAL_COLUMNS, TERMINAL_ROWS), stop_event: Optional[threading.Event] = None, - input_driver: Optional[TerminalInputDriver] = None, ) -> None: raise NotImplementedError @@ -244,7 +229,7 @@ def close(self) -> None: raise NotImplementedError def no_input_note(self) -> str: - """What a timeout diagnostic says about this backend's absent input driver. + """What a timeout diagnostic says about the end-of-file this backend can give. A backend that gives the target end-of-file at spawn needs nothing beyond the default; one that cannot says so itself. The note belongs to the backend that ran, diff --git a/render_machine/tty_broker.py b/render_machine/tty_broker.py deleted file mode 100644 index c3ecf4c7..00000000 --- a/render_machine/tty_broker.py +++ /dev/null @@ -1,328 +0,0 @@ -"""The per-execution broker behind the `codeplain-tty` helper. - -One broker serves one script execution: created after the terminal backend and before -the target is spawned, so its endpoint and token can be placed in the child environment, -and closed before `execute_script()` returns, taking every filesystem artifact with it. -A generated conformance or acceptance test drives the target's terminal through it — -`wait-for` observes the renderer-owned transcript without consuming it, the `send-*` -commands enqueue bytes on the backend's ordered input queue, and `size` resizes the -live terminal. - -Security model: the endpoint is a Unix-domain socket inside a fresh mode-0700 directory, -and every request must carry the per-execution token, compared in constant time. The -token travels only through the scoped child environment — never on a command line, never -in a log line, never in a prompt. Native Windows uses a named pipe transport, which is -not implemented yet; the capability is simply not advertised there. - -Everything a client can make the broker do is bounded: frame sizes by the protocol, -waits by a capped deadline, sends by a delivery deadline, and the accept loop serves one -request at a time so a flood of connections queues in the listener's backlog instead of -growing threads. -""" - -import hmac -import os -import secrets -import shutil -import socket -import sys -import tempfile -import threading -import time -from typing import Optional - -from plain2code_console import console -from render_machine import tty_protocol -from render_machine.terminal_process import ( - InputDisposition, - TerminalInputDriver, - TerminalProcess, - TerminalProcessError, -) - -# The longest a single wait-for / wait-until-absent may block, whatever the client asks -# for. Sits under the script-execution timeout so a test that waits forever fails as a -# test before the whole script is torn down around it. -MAX_WAIT_SECONDS = 110.0 -DEFAULT_WAIT_SECONDS = 30.0 - -# How long a send-* retries around backpressure before reporting it. The input queue -# drains at terminal speed, so sustained backpressure this long means the target stopped -# reading its terminal. -SEND_DEADLINE_SECONDS = 10.0 - -# How long the accept loop lets one client take to deliver its request frame. The -# response side is bounded by the command's own deadline. -REQUEST_READ_TIMEOUT_SECONDS = 5.0 - -POLL_INTERVAL_SECONDS = 0.05 - -# How long an idle accept() waits before looking at the closing flag again. Closing a -# socket does not wake a blocked accept() in another thread on Linux, so without a bound -# here the server thread parks forever and close() can only give up on it: one thread and -# one socket held open per broker, for the life of the render. -ACCEPT_POLL_SECONDS = 0.25 - -# How long close() waits for the server thread after closing the listener under it. -CLOSE_JOIN_SECONDS = 5.0 - - -def broker_supported() -> bool: - """True where the transport exists: Unix-domain sockets on POSIX (and WSL).""" - return sys.platform != "win32" and hasattr(socket, "AF_UNIX") - - -class TtyBroker(TerminalInputDriver): - """One execution's terminal-automation endpoint. Single-use, like the backend it drives.""" - - def __init__(self, process: TerminalProcess) -> None: - self._process = process - self._token = secrets.token_hex(16) - self._closing = threading.Event() - # Transcript position consumed by the last successful wait-for. Mutated only by - # the single server thread, which serves one request at a time. - self._match_cursor = 0 - # Whether any authenticated command has been served. Attaching the broker - # suppresses the spawn-time end-of-file, on the promise that something will answer - # the target's reads — but the broker is attached to every conformance execution, - # while only a test written against `codeplain-tty` ever makes that promise good. - # Until the first command arrives, nothing is driving this terminal and a target - # that reads stdin would wait out its whole timeout. Set by the single server - # thread, read by the waiter. - self._served_a_command = False - self._server: Optional[threading.Thread] = None - self._listener: Optional[socket.socket] = None - self._directory: Optional[str] = None - self.endpoint: Optional[str] = None - # A directory holding a `codeplain-tty` executable, for prepending to the child's - # PATH. Written per execution so a source checkout, an editable install, and a - # built wheel all resolve the same way, and cleaned up with everything else. - self.helper_bin_dir: Optional[str] = None - - # ------------------------------------------------------------------ lifecycle - - def start(self) -> None: - if not broker_supported(): - raise TerminalProcessError("the codeplain-tty broker transport is not available on this platform") - if self._server is not None: - raise RuntimeError("TtyBroker instances are single-use") - self._directory = tempfile.mkdtemp(prefix="codeplain-tty-") - os.chmod(self._directory, 0o700) - self.endpoint = os.path.join(self._directory, "broker.sock") - listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - try: - listener.bind(self.endpoint) - listener.listen(8) - listener.settimeout(ACCEPT_POLL_SECONDS) # so shutdown does not depend on a client arriving - except OSError: - listener.close() - self._remove_artifacts() - raise - self._install_helper() - self._listener = listener - self._server = threading.Thread(target=self._serve, name="codeplain-tty-broker", daemon=True) - self._server.start() - - def _install_helper(self) -> None: - """Writes the `codeplain-tty` executable the child resolves from its PATH. - - A shim onto this interpreter and this checkout's module, rather than a console - entry point looked up on the parent's PATH: the helper a test runs must be the - one matching the broker that is serving it, whatever way Codeplain was installed. - """ - assert self._directory is not None - module = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "plain2code_tty.py") - bin_dir = os.path.join(self._directory, "bin") - os.makedirs(bin_dir) - helper = os.path.join(bin_dir, "codeplain-tty") - with open(helper, "w", encoding="utf-8") as shim: - shim.write(f'#!/bin/sh\nexec "{sys.executable}" "{module}" "$@"\n') - os.chmod(helper, 0o700) - self.helper_bin_dir = bin_dir - - def child_env(self) -> dict: - """The two variables the helper reads. Scoped to broker-enabled executions only.""" - assert self.endpoint is not None, "start() must succeed before the child environment exists" - return { - tty_protocol.ENDPOINT_ENV_VAR: self.endpoint, - tty_protocol.TOKEN_ENV_VAR: self._token, - } - - @property - def served_a_command(self) -> bool: - """Whether a test has actually driven this terminal, as opposed to merely having - been offered the means to.""" - return self._served_a_command - - def description(self) -> str: - return "the codeplain-tty broker is attached to the script's terminal" - - def close(self) -> None: - """Stops accepting, unblocks the server thread, and removes every artifact. Idempotent.""" - self._closing.set() - listener = self._listener - if listener is not None: - self._listener = None - try: - listener.close() # unblocks accept() with an error the loop expects - except OSError: - pass - server = self._server - if server is not None and server.ident is not None: - server.join(timeout=CLOSE_JOIN_SECONDS) - if server.is_alive(): - console.debug("the codeplain-tty broker thread outlived its shutdown bound") - self._remove_artifacts() - - def _remove_artifacts(self) -> None: - directory = self._directory - self._directory = None - if directory is not None: - shutil.rmtree(directory, ignore_errors=True) - - # ------------------------------------------------------------------ the server - - def _serve(self) -> None: - listener = self._listener - assert listener is not None - while not self._closing.is_set(): - try: - connection, _ = listener.accept() - except socket.timeout: - continue # nobody called; go back and look at the closing flag - except OSError: # the listener was closed under the loop — expected shutdown - return - try: - self._serve_connection(connection) - except Exception as exc: # one bad client must not take the broker down - console.debug(f"codeplain-tty broker: a connection failed: {exc!r}") - finally: - try: - connection.close() - except OSError: - pass - - def _serve_connection(self, connection: socket.socket) -> None: - connection.settimeout(REQUEST_READ_TIMEOUT_SECONDS) - try: - request = tty_protocol.read_frame(connection.recv) - except (tty_protocol.ProtocolError, socket.timeout) as exc: - self._respond(connection, tty_protocol.error_response(tty_protocol.ERROR_INVALID_REQUEST, str(exc))) - return - if request is None: - return # the client connected and left - connection.settimeout(None) # the command's own deadline bounds the rest - self._respond(connection, self._handle(request)) - - def _respond(self, connection: socket.socket, response: dict) -> None: - try: - connection.sendall(tty_protocol.encode_frame(response)) - except OSError: - pass # the client is gone; its exit code is its own problem - - # ------------------------------------------------------------------ commands - - def _handle(self, request: dict) -> dict: - token = request.get("token") - if not isinstance(token, str) or not hmac.compare_digest(token, self._token): - return tty_protocol.error_response(tty_protocol.ERROR_UNAUTHORIZED, "the request token is not valid") - if request.get("protocol_version") != tty_protocol.PROTOCOL_VERSION: - return tty_protocol.error_response( - tty_protocol.ERROR_UNSUPPORTED, - f"this broker speaks protocol version {tty_protocol.PROTOCOL_VERSION}", - ) - if self._closing.is_set(): - return tty_protocol.error_response(tty_protocol.ERROR_SHUTTING_DOWN, "the execution is shutting down") - # Authenticated and in-protocol: from here on a test is genuinely driving this - # terminal, whatever the command turns out to be and whether or not it succeeds. - self._served_a_command = True - command = request.get("command") - args = request.get("args") - if not isinstance(args, dict): - return tty_protocol.error_response(tty_protocol.ERROR_INVALID_REQUEST, "args must be an object") - try: - if command == tty_protocol.COMMAND_WAIT_FOR: - return self._wait(args, present=True) - if command == tty_protocol.COMMAND_WAIT_UNTIL_ABSENT: - return self._wait(args, present=False) - if command == tty_protocol.COMMAND_SEND_TEXT: - return self._send(tty_protocol.typed_text_bytes(self._text_arg(args))) - if command == tty_protocol.COMMAND_SEND_CONTROL: - return self._send(tty_protocol.control_byte(self._text_arg(args, key="key"))) - if command == tty_protocol.COMMAND_SEND_HEX: - return self._send(bytes.fromhex(self._text_arg(args, key="hex"))) - if command == tty_protocol.COMMAND_SIZE: - return self._resize(args) - except ValueError as exc: - return tty_protocol.error_response(tty_protocol.ERROR_INVALID_REQUEST, str(exc)) - except Exception as exc: # a command bug is the broker's failure, not the client's - console.debug(f"codeplain-tty broker: command {command!r} failed: {exc!r}") - return tty_protocol.error_response(tty_protocol.ERROR_INTERNAL, f"the broker failed: {exc}") - return tty_protocol.error_response(tty_protocol.ERROR_INVALID_REQUEST, f"unknown command: {command!r}") - - @staticmethod - def _text_arg(args: dict, key: str = "text") -> str: - value = args.get(key) - if not isinstance(value, str): - raise ValueError(f"'{key}' must be a string") - return value - - def _wait(self, args: dict, present: bool) -> dict: - # The transcript is rendered per line with trailing whitespace stripped, so a - # needle quoting a prompt verbatim — "Master password: " — could never match as - # written. End-of-line whitespace is stripped from the needle to compensate. - text = "\n".join(part.rstrip() for part in self._text_arg(args).split("\n")) - if not text: - raise ValueError("the text to wait for must not be empty or whitespace-only") - timeout = args.get("timeout", DEFAULT_WAIT_SECONDS) - if not isinstance(timeout, (int, float)) or isinstance(timeout, bool) or timeout <= 0: - raise ValueError("'timeout' must be a positive number of seconds") - deadline = time.monotonic() + min(float(timeout), MAX_WAIT_SECONDS) - while True: - # Expect-style sequencing: a successful wait-for consumes the transcript - # through its match, so every later wait matches only output produced after - # it. Without the cursor, the second of two sequential interactive targets - # matches the first one's stale prompt instantly and the test types into a - # terminal nobody is reading yet. The transcript re-renders as the screen - # changes, so the cursor is clamped rather than trusted exactly. - transcript = self._process.normalized_output() - start = min(self._match_cursor, len(transcript)) - found_at = transcript.find(text, start) - if (found_at >= 0) == present: - if found_at >= 0: - self._match_cursor = found_at + len(text) - return tty_protocol.ok_response() - if self._closing.is_set(): - return tty_protocol.error_response(tty_protocol.ERROR_SHUTTING_DOWN, "the execution is shutting down") - if time.monotonic() >= deadline: - condition = "appear in" if present else "leave" - return tty_protocol.error_response( - tty_protocol.ERROR_TIMEOUT, f"the text did not {condition} the transcript in time" - ) - time.sleep(POLL_INTERVAL_SECONDS) - - def _send(self, data: bytes) -> dict: - if not data: - raise ValueError("there are no bytes to send") - deadline = time.monotonic() + SEND_DEADLINE_SECONDS - while True: - result = self._process.write_input(data) - if result.disposition is InputDisposition.ACCEPTED: - return tty_protocol.ok_response({"accepted_bytes": result.accepted_bytes}) - if result.disposition is InputDisposition.CLOSED: - return tty_protocol.error_response( - tty_protocol.ERROR_INPUT_CLOSED, "the target's terminal no longer accepts input" - ) - if self._closing.is_set() or time.monotonic() >= deadline: - return tty_protocol.error_response( - tty_protocol.ERROR_BACKPRESSURE, "the target stopped reading its terminal input" - ) - time.sleep(POLL_INTERVAL_SECONDS) - - def _resize(self, args: dict) -> dict: - columns, rows = tty_protocol.parse_size_args(args) - try: - self._process.resize(columns, rows) - except TerminalProcessError as exc: - return tty_protocol.error_response(tty_protocol.ERROR_UNSUPPORTED, str(exc)) - return tty_protocol.ok_response() diff --git a/render_machine/tty_protocol.py b/render_machine/tty_protocol.py deleted file mode 100644 index 3cfb4cbc..00000000 --- a/render_machine/tty_protocol.py +++ /dev/null @@ -1,150 +0,0 @@ -"""The wire contract between the `codeplain-tty` helper and the per-execution broker. - -One request per connection: the helper connects to the endpoint named by -``CODEPLAIN_TTY_ENDPOINT``, sends one length-framed JSON request carrying the token from -``CODEPLAIN_TTY_TOKEN``, reads one length-framed JSON response, and disconnects. The -framing, the field names, and the command vocabulary here ARE protocol version 1 — the -same version the client advertises to the API — so nothing in this module may change -without a new protocol version. - -This module is imported by both sides (the broker inside the renderer and the helper -process a generated test spawns), so it stays dependency-free: standard library only, -and no imports from the rest of the codebase. -""" - -import json -import struct -from typing import Optional, Tuple - -PROTOCOL_VERSION = 1 - -ENDPOINT_ENV_VAR = "CODEPLAIN_TTY_ENDPOINT" -TOKEN_ENV_VAR = "CODEPLAIN_TTY_TOKEN" - -# Every environment variable the runtime owns starts with this prefix. Scoping strips -# caller-supplied values and the portability audit rejects references outside internal -# test folders by this prefix, so it is defined once, here. -ENV_VAR_PREFIX = "CODEPLAIN_TTY_" - -COMMAND_WAIT_FOR = "wait-for" -COMMAND_WAIT_UNTIL_ABSENT = "wait-until-absent" -COMMAND_SEND_TEXT = "send-text" -COMMAND_SEND_CONTROL = "send-control" -COMMAND_SEND_HEX = "send-hex" -COMMAND_SIZE = "size" - -COMMANDS = ( - COMMAND_WAIT_FOR, - COMMAND_WAIT_UNTIL_ABSENT, - COMMAND_SEND_TEXT, - COMMAND_SEND_CONTROL, - COMMAND_SEND_HEX, - COMMAND_SIZE, -) - -# Error codes a response can carry. The helper maps them onto its exit codes. -ERROR_UNAUTHORIZED = "unauthorized" -ERROR_UNSUPPORTED = "unsupported" -ERROR_INVALID_REQUEST = "invalid-request" -ERROR_TIMEOUT = "timeout" -ERROR_INPUT_CLOSED = "input-closed" -ERROR_BACKPRESSURE = "backpressure" -ERROR_SHUTTING_DOWN = "shutting-down" -ERROR_INTERNAL = "internal" - -# Helper exit codes. 0 is success; 1 is a command that ran and did not succeed (a -# wait-for that timed out, input the target no longer accepts); 2 is a usage error the -# caller can fix; 69 is the runtime itself being unavailable — matching the testing -# scripts' convention that 69 is an environment failure, never a test failure. -EXIT_OK = 0 -EXIT_COMMAND_FAILED = 1 -EXIT_USAGE = 2 -EXIT_RUNTIME_UNAVAILABLE = 69 - -# One frame: 4-byte big-endian payload length, then that many bytes of UTF-8 JSON. -_HEADER = struct.Struct(">I") -MAX_FRAME_BYTES = 64 * 1024 - - -class ProtocolError(Exception): - """A frame or payload the peer must not act on.""" - - -def encode_frame(payload: dict) -> bytes: - body = json.dumps(payload, ensure_ascii=False).encode("utf-8") - if len(body) > MAX_FRAME_BYTES: - raise ProtocolError(f"frame of {len(body)} bytes exceeds the {MAX_FRAME_BYTES}-byte bound") - return _HEADER.pack(len(body)) + body - - -def read_frame(recv) -> Optional[dict]: - """Reads one frame from `recv(max_bytes) -> bytes`. None on a clean end of stream.""" - header = _read_exactly(recv, _HEADER.size) - if header is None: - return None - (length,) = _HEADER.unpack(header) - if length > MAX_FRAME_BYTES: - raise ProtocolError(f"frame of {length} bytes exceeds the {MAX_FRAME_BYTES}-byte bound") - body = _read_exactly(recv, length) - if body is None: - raise ProtocolError("the stream ended inside a frame") - try: - payload = json.loads(body.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError) as exc: - raise ProtocolError(f"the frame does not carry UTF-8 JSON: {exc}") from exc - if not isinstance(payload, dict): - raise ProtocolError("the frame must carry a JSON object") - return payload - - -def _read_exactly(recv, count: int) -> Optional[bytes]: - data = bytearray() - while len(data) < count: - chunk = recv(count - len(data)) - if not chunk: - if not data: - return None # a clean end of stream, before anything was read - raise ProtocolError("the stream ended inside a frame") - data += chunk - return bytes(data) - - -def request(token: str, command: str, args: dict) -> dict: - return {"protocol_version": PROTOCOL_VERSION, "token": token, "command": command, "args": args} - - -def ok_response(result: Optional[dict] = None) -> dict: - return {"ok": True, "result": result or {}} - - -def error_response(code: str, message: str) -> dict: - return {"ok": False, "error": code, "message": message} - - -def control_byte(key: str) -> bytes: - """The control byte an ASCII Ctrl- keypress produces (Ctrl-D -> 0x04).""" - if len(key) != 1: - raise ValueError("send-control takes a single character, e.g. 'd' for Ctrl-D") - upper = key.upper() - code = ord(upper) ^ 0x40 - if not 0 <= code <= 0x1F: - raise ValueError(f"'{key}' does not name a control character") - return bytes([code]) - - -def typed_text_bytes(text: str) -> bytes: - """What typing `text` at a terminal sends: newlines become carriage returns. - - A terminal's Enter key sends CR; the line discipline's ICRNL turns it back into the - newline a canonical read returns. Sending LF verbatim would bypass what every - interactive program is written against, so `send-text` emulates typing. `send-hex` - exists for exact bytes. - """ - return text.replace("\r\n", "\n").replace("\n", "\r").encode("utf-8") - - -def parse_size_args(args: dict) -> Tuple[int, int]: - columns, rows = args.get("columns"), args.get("rows") - if not isinstance(columns, int) or not isinstance(rows, int) or columns <= 0 or rows <= 0: - raise ValueError("size takes positive integer columns and rows") - return columns, rows diff --git a/tests/test_conpty.py b/tests/test_conpty.py index 811522d8..576ca89a 100644 --- a/tests/test_conpty.py +++ b/tests/test_conpty.py @@ -1200,7 +1200,7 @@ def test_a_script_that_reads_input_runs_to_the_timeout_and_says_why(tmp_path, na exit_code, output = run_script(script, timeout=15) assert exit_code == render_utils.TIMEOUT_ERROR_EXIT_CODE - assert "no input driver was attached" in output.lower() + assert "no synthetic end-of-file" in output.lower() assert "end-of-file" in output.lower() diff --git a/tests/test_platform_test_audit.py b/tests/test_platform_test_audit.py deleted file mode 100644 index b3a74ed8..00000000 --- a/tests/test_platform_test_audit.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Tests for the client-side portability audit of a completed build.""" - -import pytest - -from render_machine.platform_test_audit import PlatformBoundaryViolation, audit_build_folder, find_platform_references - - -def test_a_clean_build_passes(tmp_path): - (tmp_path / "app.py").write_text("print('hello')\n") - (tmp_path / "requirements.txt").write_text("pytest==8.3.2\n") - - assert find_platform_references(str(tmp_path)) == [] - audit_build_folder(str(tmp_path)) # does not raise - - -def test_a_helper_reference_in_content_fails_the_audit(tmp_path): - (tmp_path / "app.py").write_text("subprocess.run(['codeplain-tty', 'send-text', 'x'])\n") - - with pytest.raises(PlatformBoundaryViolation, match="app.py"): - audit_build_folder(str(tmp_path)) - - -def test_an_environment_prefix_reference_fails_the_audit(tmp_path): - subdir = tmp_path / "src" - subdir.mkdir() - (subdir / "config.py").write_text("token = os.environ.get('CODEPLAIN_TTY_TOKEN')\n") - - with pytest.raises(PlatformBoundaryViolation, match="config.py"): - audit_build_folder(str(tmp_path)) - - -def test_a_helper_named_file_fails_the_audit(tmp_path): - (tmp_path / "codeplain-tty").write_text("#!/bin/sh\n") - - with pytest.raises(PlatformBoundaryViolation, match="codeplain-tty"): - audit_build_folder(str(tmp_path)) - - -def test_vendor_directories_are_not_audited(tmp_path): - vendored = tmp_path / "node_modules" / "junk" - vendored.mkdir(parents=True) - (vendored / "noise.js").write_text("// codeplain-tty mentioned in a vendored comment\n") - (tmp_path / "app.js").write_text("console.log('clean');\n") - - audit_build_folder(str(tmp_path)) # does not raise - - -def test_internal_test_trees_inside_the_build_folder_are_exempt(tmp_path): - """A module's build folder can carry its conformance tests, and those are what the - helper exists for. Auditing them aborted successful benchmark renders at CreateDist: - the build was never published, `generated_code` stayed empty, and the delivered - artifact was whatever the harness could salvage.""" - (tmp_path / "conformance_tests" / "init").mkdir(parents=True) - (tmp_path / "conformance_tests" / "init" / "test_init.py").write_text( - "subprocess.run(['codeplain-tty', 'wait-for', 'Password:'])\n" - ) - (tmp_path / "conformance_tests" / "conformance_tests.json").write_text('{"codeplain-tty": true}\n') - (tmp_path / "vault.py").write_text("print('hello')\n") - - assert find_platform_references(str(tmp_path)) == [] - audit_build_folder(str(tmp_path)) # does not raise - - -def test_delivered_code_is_still_audited_alongside_them(tmp_path): - """Exempting the test tree must not exempt the application beside it.""" - (tmp_path / "conformance_tests").mkdir() - (tmp_path / "conformance_tests" / "test_init.py").write_text("codeplain-tty wait-for\n") - (tmp_path / "vault.py").write_text("os.environ['CODEPLAIN_TTY_ENDPOINT']\n") - - assert find_platform_references(str(tmp_path)) == ["vault.py"] - - -def test_the_renderers_own_log_is_not_audited(tmp_path): - """codeplain.log records broker activity and module names, so auditing it reports the - render's own diagnostics as if the application had referenced the helper. Seen on - loglens, where the log was the single flagged file.""" - (tmp_path / "codeplain.log").write_text("DEBUG codeplain: the codeplain-tty broker thread started\n") - (tmp_path / "cli.js").write_text("console.log('hi')\n") - - assert find_platform_references(str(tmp_path)) == [] diff --git a/tests/test_platform_test_runtime.py b/tests/test_platform_test_runtime.py deleted file mode 100644 index 957324e1..00000000 --- a/tests/test_platform_test_runtime.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Tests for the platform-test runtime capability the client advertises. - -What is asserted here is the capability contract: the version-1 descriptor shape, the -advertisement following the broker preflight (never a client version), and the request -payloads carrying the capability only when it is given. -""" - -import sys -from unittest.mock import MagicMock - -import pytest - -from codeplain_REST_api import CodeplainAPI -from render_machine import platform_test_runtime -from render_machine.platform_test_runtime import ( - CODEPLAIN_TTY_COMMANDS, - PROTOCOL_VERSION, - advertised_platform_test_runtime, - codeplain_tty_descriptor, -) - - -def make_api(recorded): - api = CodeplainAPI(api_key="test-key", console=MagicMock()) - api.api_url = "http://api.invalid" - - def post_request(endpoint_url, headers, payload, run_state): - recorded.append((endpoint_url, payload)) - return {"patched_response_files": [], "conformance_tests_plan_summary_string": ""} - - api.post_request = post_request - return api - - -def render_conformance_tests(api, **kwargs): - return api.render_conformance_tests( - frid="2", - functional_requirement_id="1", - plain_source_tree={}, - linked_resources={}, - existing_files_content={}, - memory_files_content={}, - module_name="module", - required_modules={}, - conformance_tests_folder_name="folder", - conformance_tests_json={}, - all_acceptance_tests=[], - run_state=MagicMock(), - **kwargs, - ) - - -def test_the_descriptor_is_the_version_1_contract(): - descriptor = codeplain_tty_descriptor() - - assert descriptor == { - "codeplain_tty": { - "protocol_version": PROTOCOL_VERSION, - "commands": list(CODEPLAIN_TTY_COMMANDS), - } - } - assert PROTOCOL_VERSION == 1 - assert descriptor["codeplain_tty"]["commands"] == [ - "wait-for", - "wait-until-absent", - "send-text", - "send-control", - "send-hex", - "size", - ] - - -def test_advertisement_follows_the_preflight(monkeypatch): - monkeypatch.setattr(platform_test_runtime, "platform_test_runtime_available", lambda: False) - assert advertised_platform_test_runtime() is None - - monkeypatch.setattr(platform_test_runtime, "platform_test_runtime_available", lambda: True) - assert advertised_platform_test_runtime() == codeplain_tty_descriptor() - - -@pytest.mark.skipif(sys.platform == "win32", reason="The broker transport is POSIX-only.") -def test_the_real_preflight_passes_on_this_platform(): - platform_test_runtime.platform_test_runtime_available.cache_clear() - try: - assert platform_test_runtime.platform_test_runtime_available() is True - finally: - platform_test_runtime.platform_test_runtime_available.cache_clear() - - -def test_the_capability_is_omitted_from_the_payload_by_default(): - recorded = [] - api = make_api(recorded) - - render_conformance_tests(api) - - _, payload = recorded[0] - assert "platform_test_runtime" not in payload - - -def test_the_capability_is_sent_when_provided(): - recorded = [] - api = make_api(recorded) - - render_conformance_tests(api, platform_test_runtime=codeplain_tty_descriptor()) - - _, payload = recorded[0] - assert payload["platform_test_runtime"] == codeplain_tty_descriptor() - - -def test_fix_conformance_tests_issue_carries_the_capability_when_provided(): - recorded = [] - api = make_api(recorded) - api.post_request = lambda endpoint_url, headers, payload, run_state: recorded.append((endpoint_url, payload)) or [] - - api.fix_conformance_tests_issue( - frid="2", - functional_requirement_id="1", - plain_source_tree={}, - linked_resources={}, - existing_files_content={}, - memory_files_content={}, - module_name="module", - conformance_tests_module_name="module", - required_modules={}, - code_diff={}, - conformance_tests_files={}, - acceptance_tests=None, - conformance_tests_issue="issue", - implementation_fix_count=0, - conformance_tests_folder_name="folder", - current_testing_frid_high_level_implementation_plan=None, - conflicting_requirements_count=0, - run_state=MagicMock(), - platform_test_runtime=codeplain_tty_descriptor(), - ) - - _, payload = recorded[0] - assert payload["platform_test_runtime"] == codeplain_tty_descriptor() - - -def test_render_acceptance_tests_carries_the_capability_when_provided(): - recorded = [] - api = make_api(recorded) - api.post_request = lambda endpoint_url, headers, payload, run_state: recorded.append((endpoint_url, payload)) or {} - - api.render_acceptance_tests( - frid="2", - plain_source_tree={}, - linked_resources={}, - existing_files_content={}, - memory_files_content={}, - conformance_tests_files={}, - module_name="module", - required_modules={}, - acceptance_test="test", - run_state=MagicMock(), - platform_test_runtime=codeplain_tty_descriptor(), - ) - - _, payload = recorded[0] - assert payload["platform_test_runtime"] == codeplain_tty_descriptor() diff --git a/tests/test_platform_test_scoping.py b/tests/test_platform_test_scoping.py deleted file mode 100644 index b3a39ee4..00000000 --- a/tests/test_platform_test_scoping.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Tests for the platform-test runtime's execution scoping in `execute_script()`. - -The runtime is an explicit per-execution option, never a process-global switch: only an -execution that asks for it gets the broker, the helper on PATH, and the scoped -CODEPLAIN_TTY_* environment — and an execution that does not ask sees none of it, even -when the caller's own environment carries stale values. The closing case is the -acceptance gate that motivated the whole plan: the getpass/TCSAFLUSH reproduction -passing through the real `execute_script()` path. -""" - -import stat -import sys -import textwrap -from pathlib import Path - -import pytest - -from render_machine import render_utils, tty_protocol - -posix_only = pytest.mark.skipif( - sys.platform == "win32", - reason="These cases run POSIX shell scripts and the POSIX-only broker transport.", -) - -pytestmark = posix_only - - -def make_script(directory: Path, name: str, body: str) -> str: - script_path = directory / f"{name}.sh" - script_path.write_text("#!/bin/bash\n" + textwrap.dedent(body)) - script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR) - return str(script_path) - - -def test_a_plain_execution_gets_no_runtime_environment(tmp_path, monkeypatch): - monkeypatch.setenv("CODEPLAIN_TTY_ENDPOINT", "/stale/endpoint") - script = make_script( - tmp_path, - "probe", - """ - if command -v codeplain-tty >/dev/null 2>&1; then echo "HELPER-ON-PATH"; fi - echo "ENDPOINT:${CODEPLAIN_TTY_ENDPOINT:-unset}" - """, - ) - - exit_code, output, _ = render_utils.execute_script(script, [], "Repro", timeout=30) - - assert exit_code == 0 - assert "HELPER-ON-PATH" not in output - # The stale caller value still reaches a plain execution untouched (today's - # behavior); only the scoped runtime owns and rewrites the prefix. - assert "ENDPOINT:/stale/endpoint" in output - - -def test_a_runtime_execution_gets_the_helper_and_a_scoped_environment(tmp_path, monkeypatch): - monkeypatch.setenv("CODEPLAIN_TTY_ENDPOINT", "/stale/endpoint") - monkeypatch.setenv("CODEPLAIN_TTY_TOKEN", "stale-token") - script = make_script( - tmp_path, - "scoped_probe", - """ - command -v codeplain-tty >/dev/null 2>&1 || { echo "NO-HELPER"; exit 1; } - [ "${CODEPLAIN_TTY_ENDPOINT}" = "/stale/endpoint" ] && { echo "STALE-ENDPOINT"; exit 1; } - [ "${CODEPLAIN_TTY_TOKEN}" = "stale-token" ] && { echo "STALE-TOKEN"; exit 1; } - [ -S "${CODEPLAIN_TTY_ENDPOINT}" ] || { echo "ENDPOINT-NOT-A-SOCKET"; exit 1; } - echo "SCOPED-OK" - """, - ) - - exit_code, output, _ = render_utils.execute_script(script, [], "Repro", timeout=30, platform_test_runtime=True) - - assert exit_code == 0, output - assert "SCOPED-OK" in output - - -def test_the_getpass_reproduction_passes_through_the_real_execution_path(tmp_path): - """The acceptance gate: a conformance-style script feeds a getpass child through - codeplain-tty instead of hanging to the 120-second timeout on the discarded VEOF.""" - child = tmp_path / "child_getpass.py" - child.write_text(textwrap.dedent(""" - import getpass - - secret = getpass.getpass("Master password: ") - print(f"GOT:{secret}") - """)) - script = make_script( - tmp_path, - "conformance_style", - f""" - "{sys.executable}" "{child}" & - target=$! - codeplain-tty wait-for "Master password:" --timeout 15 || exit 1 - codeplain-tty send-text "hunter2 - " || exit 1 - wait "$target" - """, - ) - - exit_code, output, _ = render_utils.execute_script(script, [], "Repro", timeout=60, platform_test_runtime=True) - - assert exit_code == 0, output - assert "GOT:hunter2" in output - - -def test_a_runtime_execution_that_cannot_start_its_broker_is_an_environment_error(tmp_path, monkeypatch): - def refuse_to_start(self): - raise OSError("no sockets today") - - monkeypatch.setattr(render_utils.TtyBroker, "start", refuse_to_start) - script = make_script(tmp_path, "never_runs", 'echo "MUST-NOT-RUN"\n') - - exit_code, output, _ = render_utils.execute_script(script, [], "Repro", timeout=30, platform_test_runtime=True) - - assert exit_code == tty_protocol.EXIT_RUNTIME_UNAVAILABLE - assert "MUST-NOT-RUN" not in output diff --git a/tests/test_quiet_eof_resend.py b/tests/test_quiet_eof_resend.py index 1cd4e533..28f1658d 100644 --- a/tests/test_quiet_eof_resend.py +++ b/tests/test_quiet_eof_resend.py @@ -1,15 +1,14 @@ -"""Tests for re-delivering end-of-file to a driverless target that has gone quiet. +"""Tests for re-delivering end-of-file to a target that has gone quiet. -A driverless execution gets one end-of-file at spawn. `getpass` calls +Every execution gets one end-of-file at spawn. `getpass` calls `tcsetattr(..., TCSAFLUSH, ...)` before reading, and TCSAFLUSH discards pending input — so the EOF is gone by the time the read happens and the program waits for input nobody will send. The script loses its whole timeout, and the unit-test fix loop reads that as a defect in the code: one benchmark render patched against the resulting failure seventeen times in a row while its conformance loop never failed once. -The broker exists for exactly this, but unit tests never get one — they are part of the -delivered codebase and must not depend on Codeplain's test tooling. So the driverless -path has to answer for itself. +Nothing else answers a test script's terminal reads, so the wait has to answer for +itself. """ import time @@ -40,7 +39,7 @@ def quiet_for(resender, seconds): def test_a_quiet_target_is_sent_end_of_file_again(target): - resender = _QuietEofResender(target, driverless=True) + resender = _QuietEofResender(target) resender.consider() # establishes the baseline quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) @@ -50,7 +49,7 @@ def test_a_quiet_target_is_sent_end_of_file_again(target): def test_a_target_still_producing_output_is_left_alone(target): """Output means the program is working, not waiting.""" - resender = _QuietEofResender(target, driverless=True) + resender = _QuietEofResender(target) resender.consider() target.transcript = "still going" @@ -60,7 +59,7 @@ def test_a_target_still_producing_output_is_left_alone(target): def test_a_briefly_quiet_target_is_left_alone(target): - resender = _QuietEofResender(target, driverless=True) + resender = _QuietEofResender(target) resender.consider() quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS / 2) @@ -70,7 +69,7 @@ def test_a_briefly_quiet_target_is_left_alone(target): def test_output_after_a_resend_restarts_the_clock(target): """A program that answers the end-of-file and carries on is making progress.""" - resender = _QuietEofResender(target, driverless=True) + resender = _QuietEofResender(target) resender.consider() quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) target.write_input.reset_mock() @@ -82,21 +81,10 @@ def test_output_after_a_resend_restarts_the_clock(target): target.write_input.assert_not_called() -def test_a_target_attached_to_a_driver_is_never_written_to(target): - """A broker-backed run has something driving its terminal deliberately; pushing an - end-of-file into it would answer a prompt the test meant to answer itself.""" - resender = _QuietEofResender(target, driverless=False) - resender.consider() - - quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS * 10) - - target.write_input.assert_not_called() - - def test_the_resends_are_bounded(target): """A target quiet through every delivery is not waiting on the terminal, and a stuck script should not also be a noisy one.""" - resender = _QuietEofResender(target, driverless=True) + resender = _QuietEofResender(target) resender.consider() for _ in range(MAX_EOF_RESENDS + 5): @@ -109,7 +97,7 @@ def test_a_target_that_cannot_be_written_to_stops_being_tried(target): """The wait loop owns what happens to an unwritable target; this must not turn one broken write into a warning on every poll.""" target.write_input.side_effect = OSError("the terminal is gone") - resender = _QuietEofResender(target, driverless=True) + resender = _QuietEofResender(target) resender.consider() for _ in range(3): @@ -120,7 +108,7 @@ def test_a_target_that_cannot_be_written_to_stops_being_tried(target): def test_the_first_poll_does_not_immediately_resend(target): """A target gets its quiet period before anything is concluded about it.""" - resender = _QuietEofResender(target, driverless=True) + resender = _QuietEofResender(target) resender.consider() @@ -132,61 +120,9 @@ def test_a_real_clock_is_used_for_the_quiet_period(target): write into every healthy script that pauses to think.""" assert QUIET_BEFORE_EOF_RESEND_SECONDS >= 1.0 - resender = _QuietEofResender(target, driverless=True) + resender = _QuietEofResender(target) resender.consider() time.sleep(0.05) resender.consider() target.write_input.assert_not_called() - - -class _FakeBroker: - """Stands in for TtyBroker; only `served_a_command` matters to the resender.""" - - def __init__(self, served: bool = False) -> None: - self.served_a_command = served - - -def test_a_broker_no_test_ever_used_still_gets_the_target_its_end_of_file(target): - """The cli-password-manager regression. - - Attaching the broker suppresses the spawn-time VEOF. That is right for a test that - drives the terminal and wrong for every conformance test that does not -- and the - broker is attached to all of them. On the pipe backend those targets saw EOF at once; - here they read a terminal that never ended and waited out the full timeout. - cli-password-manager scores 15/16 on main and scored 1/16 here, fourteen renders - running, because its target prompts for a master password. - """ - resender = _QuietEofResender(target, driverless=False, broker=_FakeBroker(served=False)) - resender.consider() - - quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) - - target.write_input.assert_called_once_with(EOF_BYTE) - - -def test_a_terminal_a_test_is_driving_is_left_alone(target): - """Once a test has spoken to the broker it owns the dialogue, and an unsolicited - end-of-file would land in the middle of it.""" - resender = _QuietEofResender(target, driverless=False, broker=_FakeBroker(served=True)) - resender.consider() - - quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) - - target.write_input.assert_not_called() - - -def test_a_test_that_starts_driving_stops_further_deliveries(target): - """The broker can go unused for a while and then be called, so the decision is re-made - every poll rather than fixed at spawn.""" - broker = _FakeBroker(served=False) - resender = _QuietEofResender(target, driverless=False, broker=broker) - resender.consider() - quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) - target.write_input.assert_called_once_with(EOF_BYTE) - target.write_input.reset_mock() - - broker.served_a_command = True - quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) - - target.write_input.assert_not_called() diff --git a/tests/test_render_utils.py b/tests/test_render_utils.py index 5aa19932..69706577 100644 --- a/tests/test_render_utils.py +++ b/tests/test_render_utils.py @@ -422,7 +422,7 @@ def __init__( self.closed = False self.reader_running = True - def spawn(self, command, cwd=None, env=None, terminal_size=(80, 24), stop_event=None, input_driver=None): + def spawn(self, command, cwd=None, env=None, terminal_size=(80, 24), stop_event=None): if self.spawn_error is not None: raise self.spawn_error @@ -648,7 +648,7 @@ def test_a_script_that_cannot_be_executed_is_an_environment_error(tmp_path, run_ @posix_only -def test_the_timeout_message_names_the_absent_input_driver(tmp_path, run_script): +def test_the_timeout_message_explains_the_end_of_file_the_target_was_given(tmp_path, run_script): script = _make_python_script( tmp_path, "reads_forever", @@ -666,8 +666,8 @@ def test_the_timeout_message_names_the_absent_input_driver(tmp_path, run_script) exit_code, output, output_file = run_script(script, [], SCRIPT_TYPE, timeout=2) assert exit_code == render_utils.TIMEOUT_ERROR_EXIT_CODE - assert "no input driver was attached" in output.lower() - assert "no input driver was attached" in Path(output_file).read_text().lower() + assert "an end-of-file was queued" in output.lower() + assert "an end-of-file was queued" in Path(output_file).read_text().lower() def test_a_backend_that_delivers_end_of_file_states_the_default_note(): @@ -712,15 +712,14 @@ def recording_close(): @posix_only -def test_a_getpass_target_survives_its_terminal_flush_without_a_driver(tmp_path, run_script): - """The failure this whole path exists for, on the side that has no broker. +def test_a_getpass_target_survives_its_terminal_flush(tmp_path, run_script): + """The failure this whole path exists for. `getpass` calls `tcsetattr(..., TCSAFLUSH, ...)` before reading, and TCSAFLUSH discards pending input — so the end-of-file queued at spawn is gone by the time the - read happens and the target waits for input nobody will send. Unit tests never get a - broker (they ship inside the delivered codebase and must not depend on Codeplain's - tooling), so before the quiet-period re-delivery this target burned the entire script - timeout and the fix loop read that as a defect in the generated code. + read happens and the target waits for input nobody will send. Before the quiet-period + re-delivery this target burned the entire script timeout, and the fix loop read that + as a defect in the generated code. The timeout here is well above the quiet period and well below what a hang costs, so a regression fails the test rather than slowing it down. diff --git a/tests/test_terminal_process.py b/tests/test_terminal_process.py index f31b73f8..57bc4825 100644 --- a/tests/test_terminal_process.py +++ b/tests/test_terminal_process.py @@ -629,7 +629,10 @@ def failing_thread(*args, **kwargs): def test_write_input_reports_whole_item_admission(): - with terminal(command=["/bin/sh", "-c", "read line; printf 'got:%s' \"$line\""], input_driver=object()) as process: + # The first read consumes the end-of-file queued at spawn; the second proves the + # terminal is still open afterwards and later input is delivered normally. On a + # pseudoterminal VEOF ends a read, it does not close the channel. + with terminal(command=["/bin/sh", "-c", "read spawn_eof; read line; printf 'got:%s' \"$line\""]) as process: result = process.write_input(b"payload\n") assert result.disposition is InputDisposition.ACCEPTED assert result.accepted_bytes == len(b"payload\n") @@ -638,7 +641,7 @@ def test_write_input_reports_whole_item_admission(): def test_write_input_reports_backpressure_for_an_oversized_item(): - with terminal(command=["/bin/sh", "-c", "sleep 5"], input_driver=object()) as process: + with terminal(command=["/bin/sh", "-c", "sleep 5"]) as process: result = process.write_input(b"x" * (_posix_pty.MAX_INPUT_ITEM_BYTES + 1)) assert result.disposition is InputDisposition.BACKPRESSURE assert result.accepted_bytes == 0 @@ -1390,7 +1393,7 @@ def test_close_during_an_in_flight_fragmented_item_fails_its_receipt_once(tmp_pa script = make_script(tmp_path, "fragmented_close", "sleep 10\n") process = _posix_pty.PosixPtyProcess() try: - process.spawn([script], input_driver=object()) + process.spawn([script]) real_write = process._write_master state = {"calls": 0} @@ -1443,7 +1446,7 @@ def finish(): finished.append(termios.tcgetattr(process._bundle.master_fd)) # the reader still owns it try: - process.spawn([script], input_driver=object()) + process.spawn([script]) def held_write(fd, data): raise BlockingIOError(errno.EAGAIN, "held mid-item") @@ -1471,7 +1474,7 @@ def test_a_saturated_doorbell_is_only_a_coalesced_notification(tmp_path): script = make_script(tmp_path, "doorbell", "sleep 10\n") process = _posix_pty.PosixPtyProcess() try: - process.spawn([script], input_driver=object()) + process.spawn([script]) while True: # fill the doorbell to EAGAIN try: os.write(process._wakeup_w, b"\x01" * 4096) @@ -1499,7 +1502,7 @@ def test_a_fragmented_logical_write_keeps_its_suffix_ahead_of_later_items(tmp_pa written = [] released = threading.Event() try: - process.spawn([script], input_driver=object()) + process.spawn([script]) real_write = process._write_master state = {"held": False} @@ -1586,7 +1589,7 @@ def parked_read_once(master_fd, decoder): process._read_once = parked_read_once try: - process.spawn([script], input_driver=object()) + process.spawn([script]) deadline = time.monotonic() + SPAWN_TIMEOUT while parked["calls"] < 2 and time.monotonic() < deadline: time.sleep(0.02) @@ -1607,7 +1610,7 @@ def test_write_input_after_the_reader_closed_the_master_touches_nothing(tmp_path process = _posix_pty.PosixPtyProcess() unrelated = None try: - process.spawn(["/bin/sh", "-c", "printf bye"], input_driver=object()) + process.spawn(["/bin/sh", "-c", "printf bye"]) assert wait_for_exit(process) == 0 deadline = time.monotonic() + SPAWN_TIMEOUT while process._bundle.master_fd is not None and time.monotonic() < deadline: diff --git a/tests/test_terminal_validation.py b/tests/test_terminal_validation.py index 38c6f291..c325cc99 100644 --- a/tests/test_terminal_validation.py +++ b/tests/test_terminal_validation.py @@ -681,7 +681,7 @@ def test_a_script_never_reads_the_renderers_terminal_on_either_backend( # --- The no-input contract ------------------------------------------------------- # -# The repeatedly-reading case — the timeout message naming the absent input driver — is +# The repeatedly-reading case — the timeout message describing the end-of-file given — is # asserted in `tests/test_render_utils.py` and is not repeated here. SINGLE_READ_PROGRAM = """ diff --git a/tests/test_tty_broker.py b/tests/test_tty_broker.py deleted file mode 100644 index e3c16179..00000000 --- a/tests/test_tty_broker.py +++ /dev/null @@ -1,518 +0,0 @@ -"""Tests for the per-execution `codeplain-tty` broker and its helper CLI. - -Two layers. The protocol and broker cases talk to the broker directly over its socket -with a fake terminal process, so authentication, bounds, and every error channel are -asserted without a real target. The end-to-end cases spawn real interactive programs on -the POSIX PTY backend and drive them through the actual helper executable the broker -installs — including the `getpass` reproduction whose `TCSAFLUSH` defeats the spawn-time -VEOF, the exact mechanism that motivated the broker. -""" - -import os -import socket -import stat -import subprocess -import sys -import textwrap -import threading -import time -from pathlib import Path - -import pytest - -from render_machine import tty_protocol -from render_machine.terminal_process import InputDisposition, InputWriteResult, TerminalInputDriver -from render_machine.tty_broker import ACCEPT_POLL_SECONDS, CLOSE_JOIN_SECONDS, TtyBroker, broker_supported - -posix_only = pytest.mark.skipif( - sys.platform == "win32", - reason="The broker transport and these interactive targets are POSIX-only.", -) - -pytestmark = posix_only - -if sys.platform != "win32": - from render_machine._posix_pty import PosixPtyProcess - -SPAWN_TIMEOUT = 20.0 - - -class FakeProcess: - """A terminal process double: a settable transcript and a recording input sink.""" - - def __init__(self) -> None: - self.transcript = "" - self.written = b"" - self.resized_to = None - self.dispositions = [InputDisposition.ACCEPTED] - - def normalized_output(self) -> str: - return self.transcript - - def write_input(self, data: bytes) -> InputWriteResult: - disposition = self.dispositions[0] if len(self.dispositions) == 1 else self.dispositions.pop(0) - if disposition is InputDisposition.ACCEPTED: - self.written += data - return InputWriteResult(disposition, len(data)) - return InputWriteResult(disposition, 0) - - def resize(self, columns: int, rows: int) -> None: - self.resized_to = (columns, rows) - - -@pytest.fixture -def broker(): - process = FakeProcess() - instance = TtyBroker(process) - instance.start() - try: - yield instance, process - finally: - instance.close() - - -def call(instance: TtyBroker, payload: dict) -> dict: - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: - connection.settimeout(10.0) - connection.connect(instance.endpoint) - connection.sendall(tty_protocol.encode_frame(payload)) - response = tty_protocol.read_frame(connection.recv) - assert response is not None - return response - - -def command(instance: TtyBroker, name: str, args: dict) -> dict: - return call(instance, tty_protocol.request(instance._token, name, args)) - - -def test_the_transport_is_supported_on_this_platform(): - assert broker_supported() - - -def test_the_endpoint_lives_in_a_private_directory_with_the_helper(broker): - instance, _ = broker - endpoint_dir = os.path.dirname(instance.endpoint) - assert stat.S_IMODE(os.stat(endpoint_dir).st_mode) == 0o700 - helper = os.path.join(instance.helper_bin_dir, "codeplain-tty") - assert os.access(helper, os.X_OK) - env = instance.child_env() - assert env[tty_protocol.ENDPOINT_ENV_VAR] == instance.endpoint - assert env[tty_protocol.TOKEN_ENV_VAR] - - -def test_a_wrong_token_is_rejected_in_every_command(broker): - instance, process = broker - process.transcript = "ready" - response = call(instance, tty_protocol.request("not-the-token", "wait-for", {"text": "ready"})) - assert response["ok"] is False - assert response["error"] == tty_protocol.ERROR_UNAUTHORIZED - - -def test_a_future_protocol_version_is_rejected(broker): - instance, _ = broker - payload = tty_protocol.request(instance._token, "send-text", {"text": "x"}) - payload["protocol_version"] = 2 - response = call(instance, payload) - assert response["error"] == tty_protocol.ERROR_UNSUPPORTED - - -def test_wait_for_resolves_once_the_text_appears(broker): - instance, process = broker - - def appear_later(): - time.sleep(0.2) - process.transcript = "Master password:" - - threading.Thread(target=appear_later, daemon=True).start() - response = command(instance, "wait-for", {"text": "password:", "timeout": 5}) - assert response["ok"] is True - - -def test_wait_for_matches_a_prompt_despite_trailing_whitespace(broker): - """The transcript is rendered per line with trailing whitespace stripped, so a - needle quoting a prompt verbatim — 'Master password: ' — could never match as - written. The broker strips end-of-line whitespace from the needle to compensate.""" - instance, process = broker - process.transcript = "Master password:" - response = command(instance, "wait-for", {"text": "Master password: ", "timeout": 2}) - assert response["ok"] is True - - -def test_wait_until_absent_applies_the_same_needle_normalization(broker): - instance, process = broker - process.transcript = "spinner" - - def clear_later(): - time.sleep(0.2) - process.transcript = "done" - - threading.Thread(target=clear_later, daemon=True).start() - response = command(instance, "wait-until-absent", {"text": "spinner ", "timeout": 5}) - assert response["ok"] is True - - -def test_a_whitespace_only_wait_needle_is_a_usage_error(broker): - instance, _ = broker - response = command(instance, "wait-for", {"text": " ", "timeout": 2}) - assert response["error"] == tty_protocol.ERROR_INVALID_REQUEST - - -def test_wait_for_consumes_the_transcript_through_its_match(broker): - """Expect-style sequencing: a successful wait-for advances a cursor, so the next - wait-for matches only output produced after it. Without this, the second of two - sequential interactive children matches the first child's stale prompt instantly, - types into a terminal nobody is reading yet, and hangs the whole script.""" - instance, process = broker - process.transcript = "Master password:" - assert command(instance, "wait-for", {"text": "Master password:", "timeout": 2})["ok"] is True - - # The same text again, with no new output: must NOT match the stale occurrence. - response = command(instance, "wait-for", {"text": "Master password:", "timeout": 0.3}) - assert response["error"] == tty_protocol.ERROR_TIMEOUT - - # A second occurrence beyond the cursor matches. - process.transcript = "Master password:\nVault initialized\nMaster password:" - assert command(instance, "wait-for", {"text": "Master password:", "timeout": 2})["ok"] is True - - -def test_wait_until_absent_looks_only_beyond_the_cursor(broker): - instance, process = broker - process.transcript = "spinner" - assert command(instance, "wait-for", {"text": "spinner", "timeout": 2})["ok"] is True - # The consumed occurrence no longer counts as present. - assert command(instance, "wait-until-absent", {"text": "spinner", "timeout": 2})["ok"] is True - - -def test_wait_for_times_out_with_the_timeout_error(broker): - instance, _ = broker - response = command(instance, "wait-for", {"text": "never", "timeout": 0.2}) - assert response["error"] == tty_protocol.ERROR_TIMEOUT - - -def test_wait_until_absent_resolves_when_the_text_leaves(broker): - instance, process = broker - process.transcript = "spinner" - - def clear_later(): - time.sleep(0.2) - process.transcript = "done" - - threading.Thread(target=clear_later, daemon=True).start() - response = command(instance, "wait-until-absent", {"text": "spinner", "timeout": 5}) - assert response["ok"] is True - - -def test_send_text_types_newlines_as_carriage_returns(broker): - instance, process = broker - response = command(instance, "send-text", {"text": "hunter2\n"}) - assert response["ok"] is True - assert process.written == b"hunter2\r" - - -def test_send_control_sends_the_control_byte(broker): - instance, process = broker - assert command(instance, "send-control", {"key": "d"})["ok"] is True - assert process.written == b"\x04" - - -def test_send_hex_sends_exact_bytes(broker): - instance, process = broker - assert command(instance, "send-hex", {"hex": "1b5b41"})["ok"] is True - assert process.written == b"\x1b[A" - - -def test_invalid_hex_is_a_usage_error_not_a_broker_failure(broker): - instance, _ = broker - response = command(instance, "send-hex", {"hex": "zz"}) - assert response["error"] == tty_protocol.ERROR_INVALID_REQUEST - - -def test_closed_input_is_reported_as_input_closed(broker): - instance, process = broker - process.dispositions = [InputDisposition.CLOSED] - response = command(instance, "send-text", {"text": "x"}) - assert response["error"] == tty_protocol.ERROR_INPUT_CLOSED - - -def test_backpressure_is_retried_until_accepted(broker): - instance, process = broker - process.dispositions = [InputDisposition.BACKPRESSURE, InputDisposition.BACKPRESSURE, InputDisposition.ACCEPTED] - response = command(instance, "send-text", {"text": "x"}) - assert response["ok"] is True - assert process.written == b"x" - - -def test_size_resizes_the_process(broker): - instance, process = broker - assert command(instance, "size", {"columns": 100, "rows": 30})["ok"] is True - assert process.resized_to == (100, 30) - - -def test_an_unknown_command_is_rejected(broker): - instance, _ = broker - response = command(instance, "reboot", {}) - assert response["error"] == tty_protocol.ERROR_INVALID_REQUEST - - -def test_an_oversized_frame_is_rejected_by_the_protocol(broker): - instance, _ = broker - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: - connection.settimeout(10.0) - connection.connect(instance.endpoint) - # A header claiming more than the bound; the broker must refuse without reading it. - connection.sendall((tty_protocol.MAX_FRAME_BYTES + 1).to_bytes(4, "big")) - response = tty_protocol.read_frame(connection.recv) - assert response is not None - assert response["error"] == tty_protocol.ERROR_INVALID_REQUEST - - -def test_close_removes_every_artifact_and_stops_the_server(): - process = FakeProcess() - instance = TtyBroker(process) - instance.start() - directory = os.path.dirname(instance.endpoint) - instance.close() - assert not os.path.exists(directory) - instance.close() # idempotent - - -def live_broker_threads() -> list: - return [thread for thread in threading.enumerate() if thread.name == "codeplain-tty-broker" and thread.is_alive()] - - -def test_the_server_stops_when_told_to_without_the_listener_being_closed(): - """The decisive case, and the only one that reproduces the defect off Linux. - - Closing a socket interrupts a blocked accept() on macOS/BSD but NOT on Linux, so the - leak is invisible on a developer laptop and certain in the benchmark container — a - render there logged the give-up message on 275 of 275 broker closes. Asserting the - loop honours the closing flag on its own, with the listener left open, pins the - behaviour everywhere instead of on whichever platform happens to be kind.""" - instance = TtyBroker(FakeProcess()) - instance.start() - try: - instance._closing.set() - instance._server.join(timeout=ACCEPT_POLL_SECONDS * 8) - - assert not instance._server.is_alive() - finally: - instance.close() - - -def test_close_leaves_no_server_thread_behind(): - """Closing a socket does not wake a blocked accept() in another thread on Linux, so a - broker whose accept() has no bound parks forever and close() can only give up on it. - A render runs one broker per test-script execution, so that is a thread and a socket - leaked on every conformance run — a benchmark render reached 275 before finishing. - Artifacts being gone is not evidence the thread went with them.""" - before = len(live_broker_threads()) - instance = TtyBroker(FakeProcess()) - instance.start() - instance.close() - - assert len(live_broker_threads()) == before - - -def test_close_does_not_wait_out_the_join_bound(): - """The leak was silent because close() still returns — it just burns the full join - timeout first and logs a debug line. Shutdown has to be prompt, not merely eventual.""" - instance = TtyBroker(FakeProcess()) - instance.start() - - started = time.monotonic() - instance.close() - elapsed = time.monotonic() - started - - assert elapsed < CLOSE_JOIN_SECONDS / 2 - - -def test_brokers_do_not_accumulate_threads_across_executions(): - """One broker per test-script execution is the real usage pattern; the cost of the - leak is that it compounds over a render.""" - before = len(live_broker_threads()) - for _ in range(5): - instance = TtyBroker(FakeProcess()) - instance.start() - instance.close() - - assert len(live_broker_threads()) == before - - -def test_the_server_still_serves_after_idling_through_accept_polls(): - """The bound makes accept() wake repeatedly; a client arriving after several idle - cycles must still be served rather than dropped by the polling loop.""" - process = FakeProcess() - instance = TtyBroker(process) - instance.start() - try: - time.sleep(ACCEPT_POLL_SECONDS * 3) - response = command(instance, tty_protocol.COMMAND_SEND_TEXT, {"text": "x"}) - - assert "error" not in response - assert process.written - finally: - instance.close() - - -def test_the_broker_is_a_typed_input_driver(broker): - instance, _ = broker - assert isinstance(instance, TerminalInputDriver) - assert "codeplain-tty" in instance.description() - - -# ------------------------------------------------------------------ end to end - - -def make_script(directory: Path, name: str, program: str) -> str: - script_path = directory / f"{name}.py" - script_path.write_text(f"#!{sys.executable}\n" + textwrap.dedent(program)) - script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR) - return str(script_path) - - -def run_with_broker(tmp_path: Path, name: str, program: str, driver_script: str) -> tuple: - """Spawns `program` on the real PTY backend and runs `driver_script` (a shell script - using codeplain-tty) against it from the outside, the way a generated test would.""" - target = make_script(tmp_path, name, program) - process = PosixPtyProcess() - broker = TtyBroker(process) - broker.start() - try: - env = dict(os.environ) - env.update(broker.child_env()) - env["PATH"] = broker.helper_bin_dir + os.pathsep + env.get("PATH", "") - process.spawn([target], input_driver=broker) - driver = subprocess.run( - ["/bin/sh", "-c", driver_script], - env=env, - capture_output=True, - text=True, - timeout=SPAWN_TIMEOUT, - ) - deadline = time.monotonic() + SPAWN_TIMEOUT - returncode = None - while time.monotonic() < deadline: - returncode = process.poll() - if returncode is not None: - break - time.sleep(0.02) - process.terminate_tree(grace=1.0) - process.close() - return returncode, process.normalized_output(), driver - finally: - broker.close() - process.close() - - -def test_getpass_is_answered_through_the_helper_despite_tcsaflush(tmp_path): - """The motivating reproduction: getpass's TCSAFLUSH discards the spawn-time VEOF, so - without the broker this target blocks until the script timeout. With the broker the - test waits for the prompt, types the password, and the target exits cleanly.""" - returncode, transcript, driver = run_with_broker( - tmp_path, - "getpass_target", - """ - import getpass - - secret = getpass.getpass("Master password: ") - print(f"GOT:{secret}") - """, - 'codeplain-tty wait-for "Master password:" --timeout 15 && codeplain-tty send-text "hunter2\n"', - ) - assert driver.returncode == 0, driver.stderr - assert returncode == 0 - assert "GOT:hunter2" in transcript - - -def test_a_plain_input_read_is_answered_too(tmp_path): - returncode, transcript, driver = run_with_broker( - tmp_path, - "input_target", - """ - name = input("Name: ") - print(f"HELLO:{name}") - """, - 'codeplain-tty wait-for "Name:" --timeout 15 && codeplain-tty send-text "world\n"', - ) - assert driver.returncode == 0, driver.stderr - assert returncode == 0 - assert "HELLO:world" in transcript - - -def test_send_control_delivers_ctrl_d_as_eof(tmp_path): - returncode, transcript, driver = run_with_broker( - tmp_path, - "eof_target", - """ - import sys - - print("READY", flush=True) - data = sys.stdin.read() - print(f"EOF-AFTER:{len(data)}") - """, - "codeplain-tty wait-for READY --timeout 15 && codeplain-tty send-control d", - ) - assert driver.returncode == 0, driver.stderr - assert returncode == 0 - assert "EOF-AFTER:0" in transcript - - -def test_size_reaches_the_target_as_sigwinch_and_a_new_size(tmp_path): - returncode, transcript, driver = run_with_broker( - tmp_path, - "size_target", - """ - import os - import signal - import sys - - resized = [] - - def on_winch(signum, frame): - resized.append(os.get_terminal_size(sys.stdout.fileno())) - - signal.signal(signal.SIGWINCH, on_winch) - print("READY", flush=True) - while not resized: - signal.pause() - print(f"SIZE:{resized[0].columns}x{resized[0].lines}") - """, - "codeplain-tty wait-for READY --timeout 15 && codeplain-tty size 100 30", - ) - assert driver.returncode == 0, driver.stderr - assert returncode == 0 - assert "SIZE:100x30" in transcript - - -def test_the_helper_reports_the_runtime_unavailable_outside_a_test_run(tmp_path): - env = {key: value for key, value in os.environ.items() if not key.startswith(tty_protocol.ENV_VAR_PREFIX)} - result = subprocess.run( - [ - sys.executable, - os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "plain2code_tty.py"), - "wait-for", - "x", - ], - env=env, - capture_output=True, - text=True, - timeout=SPAWN_TIMEOUT, - ) - assert result.returncode == tty_protocol.EXIT_RUNTIME_UNAVAILABLE - assert "not available" in result.stderr - - -def test_a_wait_that_times_out_exits_one(tmp_path): - returncode, transcript, driver = run_with_broker( - tmp_path, - "quiet_target", - """ - import time - - print("READY", flush=True) - time.sleep(2) - """, - 'codeplain-tty wait-for "never-printed" --timeout 1', - ) - assert driver.returncode == tty_protocol.EXIT_COMMAND_FAILED - assert "did not" in driver.stderr