diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9e0f6f9f..b47c99a7 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,7 +52,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: '3.11' - run: pip install pytest @@ -61,22 +61,57 @@ jobs: CODEPLAIN_API_KEY: ${{ secrets.CODEPLAIN_API_KEY }} 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 + + # 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: nohup python tests/e2e/macos_node_smoke.py < /dev/null + notify-on-failure: name: Notify Slack on failure - needs: [e2e-linux, e2e-windows] + needs: [e2e-linux, e2e-windows, e2e-macos] if: failure() runs-on: ubuntu-latest env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_E2E_WEBHOOK_URL }} + LINUX_RESULT: ${{ needs.e2e-linux.result }} + WINDOWS_RESULT: ${{ needs.e2e-windows.result }} + MACOS_RESULT: ${{ needs.e2e-macos.result }} steps: - name: Determine failed platforms id: platforms run: | failed="" - [ "${{ needs.e2e-linux.result }}" = "failure" ] && failed="Linux" - if [ "${{ needs.e2e-windows.result }}" = "failure" ]; then - [ -n "$failed" ] && failed="$failed and Windows" || failed="Windows" - fi + for entry in "Linux:$LINUX_RESULT" "Windows:$WINDOWS_RESULT" "macOS:$MACOS_RESULT"; do + if [ "${entry#*:}" = "failure" ]; then + name="${entry%%:*}" + if [ -n "$failed" ]; then failed="$failed and $name"; else failed="$name"; fi + fi + done echo "failed=$failed" >> "$GITHUB_OUTPUT" - name: Send failure notification to Slack diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index cc2fa014..fdc69dbf 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -112,7 +112,7 @@ jobs: - name: Install requirements run: pip install -r requirements.txt - name: Run the ConPTY lifecycle tests - run: python -m pytest tests/test_conpty.py -v + run: python -m pytest tests/test_conpty.py tests/test_conpty_execute_script.py -v tests: name: Run Tests 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: diff --git a/plain2code.py b/plain2code.py index ef60ddf4..f28f3bad 100644 --- a/plain2code.py +++ b/plain2code.py @@ -51,12 +51,26 @@ ) 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 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. 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 = 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. @@ -188,6 +202,38 @@ 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 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...") + 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 plain_module: plain_modules.PlainModule, args, @@ -306,8 +352,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/render_machine/render_utils.py b/render_machine/render_utils.py index b6b83b02..1506fbd3 100644 --- a/render_machine/render_utils.py +++ b/render_machine/render_utils.py @@ -1,28 +1,66 @@ 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, + NO_INPUT_NOTE, + TerminalProcess, + TerminalProcessError, + 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 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" + +# 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, +} + +# 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): @@ -54,39 +92,299 @@ 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 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 + + 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: + # 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 + + +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 = "" + # 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( + process: TerminalProcess, + script_timeout: float, + stop_event: Optional[threading.Event], + outcome: _ScriptOutcome, +) -> 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. + """ + deadline = time.monotonic() + script_timeout + while True: + returncode = process.poll() + if returncode is not None: + outcome.target_exited(returncode) + 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 + # 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: + outcome.infrastructure_failed(pump_failure) + if outcome.decided(): + return + if stop_event is not None: + stop_event.wait(timeout=POLL_INTERVAL_SECONDS) + 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() + 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 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: + """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: - proc.terminate() + 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() + execution.no_input_note = process.no_input_note() + 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() + except Exception as exc: + _record_backend_failure(outcome, exc, "while being created") + if process is None: + return execution + _script_started() 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() + try: + process.spawn(cmd, stop_event=stop_event) + _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: + _script_finished() + return execution + + +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. + + Created 0600 like the transcript beside it. A plain `open()` takes the process umask, + which publishes the script's whole output on a shared host. + """ + if output_file_path is None: + return + raw_file_path = output_file_path + RAW_OUTPUT_SUFFIX + try: + descriptor = os.open(raw_file_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "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( + 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: - proc.kill() + 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: + console.info(f"✓ All {script_type} scripts have passed successfully.", color=SUCCESS_COLOR) + + return exit_code, output, temp_file_path + + +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 _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])*" +def _publish_timeout( + script: str, + script_type: str, + script_timeout: float, + output: str, + reply_failed: bool, + reply_detail: str, + no_input_note: str, +) -> tuple[int, str, Optional[str]]: + diagnostics = no_input_note + if reply_failed: + diagnostics += f" Terminal replies the script asked for could not be delivered: {reply_detail}." - pattern = re.compile(clear_console_escape_codes_pattern) - parts = pattern.split(script_output) + 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 +393,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 +404,51 @@ 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] = [] + execution = _run_script(cmd, script_timeout, stop_event) + elapsed_time = time.time() - start_time + outcome = execution.outcome - 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() - - 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: + 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() + elif outcome.condition == CONDITION_TIMEOUT: + result = _publish_timeout( + script, + script_type, + script_timeout, + execution.output, + execution.reply_failed, + execution.reply_detail, + execution.no_input_note, ) - - 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, + elif outcome.exit_code is None: + result = _publish_environment_error( + script, script_type, "the script's exit status was never observed", execution.output + ) + 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, + f"terminal replies the script asked for could not be delivered: {execution.reply_detail}", + 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]) + return result 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()) diff --git a/tests/test_conpty_execute_script.py b/tests/test_conpty_execute_script.py new file mode 100644 index 00000000..5d390fe4 --- /dev/null +++ b/tests/test_conpty_execute_script.py @@ -0,0 +1,173 @@ +"""The ConPTY backend reached through `execute_script()`. + +These cases run the real renderer path instead of constructing a backend directly, so they +only mean anything once script execution is routed through `TerminalProcess`. That is why +they live apart from `test_conpty.py`: that module tests the backend, this one tests that +the backend is what execution actually reaches. + +Windows-only, like the backend itself. +""" + +import os +import sys +import textwrap +import threading +import time +from pathlib import Path + +import pytest + +if sys.platform != "win32": + # The backend 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 plain2code_exceptions import RenderCancelledError # noqa: E402 +from render_machine import _conpty # noqa: E402 +from render_machine import render_utils # noqa: E402 +from render_machine.terminal_process import ENVIRONMENT_ERROR_EXIT_CODE # noqa: E402 + +WAIT_TIMEOUT = 30.0 +POLL = 0.05 + +# The backend's own binding, so a patched symbol is the one production code calls. +kernel32 = _conpty.kernel32 + +SCRIPT_TYPE = "Unit" + + +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 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, 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, stop_event=stop_event + ) + 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 synthetic end-of-file" 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 diff --git a/tests/test_no_pty_escape_hatch.py b/tests/test_no_pty_escape_hatch.py new file mode 100644 index 00000000..87000e61 --- /dev/null +++ b/tests/test_no_pty_escape_hatch.py @@ -0,0 +1,192 @@ +"""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._legacy_pipe import LegacyPipeProcess +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.", +) + +# 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 + + +@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_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 +) +test_a_repainted_screen_yields_one_frame_and_no_escape_sequences = ( + characterization.test_a_repainted_screen_yields_one_frame_and_no_escape_sequences +) + + +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) + finally: + process.close() + + +@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, DefaultBackend) + finally: + process.close() + + +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 + + +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() +""" diff --git a/tests/test_plain2code.py b/tests/test_plain2code.py index 84c93975..f32b2137 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 _legacy_pipe, terminal_process def _make_module(module_name, has_acceptance_tests, required_modules=None): @@ -91,3 +94,124 @@ 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. + + 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 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 + + +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) + + +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 f227ea02..662deb06 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 @@ -16,6 +22,7 @@ import stat import subprocess import sys +import tempfile import textwrap import threading import time @@ -24,7 +31,15 @@ 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, + TerminalLaunchError, + TerminalProcess, + TerminalProcessError, +) posix_only = pytest.mark.skipif( sys.platform == "win32", @@ -61,6 +76,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.""" @@ -75,8 +109,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 @@ -90,6 +126,31 @@ 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 +def test_both_transcripts_are_readable_only_by_their_owner(tmp_path, run_script): + """Script output carries whatever the script was given, so neither transcript may take + the process umask.""" + script = _make_shell_script(tmp_path, "secretish", 'printf "token=abc123\\n"\n') + + _, _, output_file = run_script(script, [], SCRIPT_TYPE, timeout=30) + + for path in (output_file, output_file + render_utils.RAW_OUTPUT_SUFFIX): + mode = stat.S_IMODE(os.stat(path).st_mode) + assert mode & 0o077 == 0, f"{path} is {oct(mode)}, readable beyond its owner" + + @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): @@ -136,7 +197,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") @@ -158,13 +222,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 @@ -178,20 +265,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 +370,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 +381,344 @@ 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. + +# 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" + + +class _FakeTerminalProcess(TerminalProcess): + """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, + 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): + 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): + 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() + 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 +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" + +# 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), + # 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), + ("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", + {"exit_code": 3, "reply_failed": True}, + False, + 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, + 0, + ), + ( + "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 + + # 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): + 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): + """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) + + assert exit_code == ENVIRONMENT_ERROR_EXIT_CODE + 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")) + + 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_explains_the_end_of_file_the_target_was_given(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 "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(): + """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 + + +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() + + +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() diff --git a/tests/test_terminal_validation.py b/tests/test_terminal_validation.py new file mode 100644 index 00000000..c325cc99 --- /dev/null +++ b/tests/test_terminal_validation.py @@ -0,0 +1,869 @@ +"""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") +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 +# 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 +_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, 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) + + 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 + + +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 + + 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 + +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: + 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) +""" + + +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 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): + 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 _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(survivors.directory), "own-group"], SCRIPT_TYPE, timeout=2) + + assert exit_code == render_utils.TIMEOUT_ERROR_EXIT_CODE + 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, survivors): + """Once `poll()` has reaped the leader the pgid may be recycled, so nothing is signalled.""" + beats = survivors.directory / "descendant.beats" + script = _make_python_script(tmp_path, "leader_exits", DESCENDANT_PROGRAM) + + exit_code, output, _ = run_script( + script, [str(survivors.directory), "ignore-hup,leader-exits"], SCRIPT_TYPE, timeout=30 + ) + + assert exit_code == 0 + assert _still_beating(beats) + assert _descendant_pid(output) == survivors.pid("descendant") + + +HANGUP_SCRIPT_PROGRAM = """ +import os +import signal +import sys +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: + 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, survivors): + """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(survivors.directory)])], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + cwd=str(tmp_path), + start_new_session=True, + ) + 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" + runner.kill() # the renderer dies without ever running its teardown + 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 _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" + + +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 describing the end-of-file given — 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 --------------------------------------------------------------------- + + +@needs_nohup +def test_a_detached_renderer_still_gives_the_script_a_terminal(tmp_path): + """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. + """ + 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( + [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), + 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() + runner.wait(timeout=SETTLE_SECONDS) + + 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