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 c757e5e1..c1cfd4a8 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -72,8 +72,15 @@ jobs: run: flake8 . mypy: - name: MyPy Type Checking + name: MyPy Type Checking (${{ matrix.platform }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # --platform is a static narrowing setting, so every target is checked from + # one runner: code is verified under the platform where it runs and proven + # guarded under the platforms where it does not. + platform: [linux, darwin, win32] steps: - uses: actions/checkout@v4 - name: Set up Python @@ -90,37 +97,67 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt - name: Type check with mypy - run: mypy . --check-untyped-defs + run: mypy . --check-untyped-defs --platform ${{ matrix.platform }} - tests: - name: Run Tests - runs-on: ubuntu-latest + conpty-lifecycle: + # windows-latest is Windows Server 2025 (build 26100), the build that made + # ClosePseudoConsole() non-blocking, so the teardown-ordering assertions pass there + # whether or not the ordering is correct. Windows Server 2022 is build 20348, inside the + # affected range, which is the only place those assertions prove anything. + name: ConPTY Lifecycle (windows-2022) + runs-on: windows-2022 steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} - - name: Configure git for tests - run: | - git config --global user.email "test@example.com" - git config --global user.name "Test Runner" - git config --global init.defaultBranch main - - name: Cache pip - uses: actions/cache@v4 + cache: pip + cache-dependency-path: requirements.txt + - name: Upgrade pip + run: python -m pip install --upgrade pip + - name: Install requirements + run: pip install -r requirements.txt + - name: Run the ConPTY lifecycle tests + run: python -m pytest tests/test_conpty.py -v + + tests: + name: Run Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install coverage + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + cache-dependency-path: requirements.txt + # Every step below runs a single command. Windows runners default to + # PowerShell, where a failing native command does not abort the remaining + # commands of a multi-command run block; one command per step makes a + # failure fail the job on every OS without shell-specific workarounds. + - name: Configure git user email + run: git config --global user.email "test@example.com" + - name: Configure git user name + run: git config --global user.name "Test Runner" + - name: Configure git default branch + run: git config --global init.defaultBranch main + - name: Upgrade pip + run: python -m pip install --upgrade pip + - name: Install requirements + run: pip install -r requirements.txt + - name: Install coverage + run: pip install coverage - name: Run tests with coverage - run: | - export $(cat .env.dev.example | xargs) - coverage run -m pytest tests/ -v - coverage xml - coverage report + run: coverage run -m pytest tests/ -v + - name: Generate coverage XML + run: coverage xml + - name: Show coverage report + run: coverage report - name: Upload coverage reports + if: matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@v3 diff --git a/.gitignore b/.gitignore index 7893c1e3..feb8d066 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ dist .coverage logging_config.yaml +/.idea/ 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/cli_output/render_summary.py b/cli_output/render_summary.py index 6b62e33c..b0fbe5d5 100644 --- a/cli_output/render_summary.py +++ b/cli_output/render_summary.py @@ -1,11 +1,19 @@ """Render completion summary display.""" +import logging from typing import Optional +import plain2code_logger from plain2code_console import console from plain2code_state import RunState from usage_summary import format_usage_summary +logger = logging.getLogger(plain2code_logger.LOGGER_NAME) + +# Marks the last line of a render's log file. Greppable on purpose: benchmark runs and +# support artifacts are read by tooling before they are read by a person. +RENDER_TRAILER_PREFIX = "[render-trailer]" + def print_exit_summary( run_state: RunState, @@ -27,6 +35,49 @@ def print_exit_summary( msg += format_usage_summary(run_state.rendered_functionalities, run_state.render_time_accumulated) + "\n" console.print(msg) - if not run_state.render_succeeded and error_message: + # Reported whenever there is one. A render can finish its functionalities and still + # raise on the way out — publishing the build, for instance — and that combination + # used to print the success banner and swallow the reason entirely, leaving a caller + # with a tick mark and a non-zero exit code. + if error_message: console.error(error_message) console.quiet = True + + log_render_trailer(run_state, spec_filename, error_message) + + +def log_render_trailer( + run_state: RunState, + spec_filename: str, + error_message: Optional[str] = None, +) -> None: + """Writes the render's outcome to the log file, as its last line. + + The summary above reaches the terminal through Rich, which never touches logging, so + a captured `codeplain.log` used to stop at whatever happened to be logged last — + indistinguishable from a process that died silently. This ends every log with what + the render did, and because it is written on every exit path a log *without* a + trailer is itself evidence that the file was truncated. + """ + if run_state.render_succeeded: + outcome = "completed" + elif run_state.render_cancelled: + outcome = "cancelled" + else: + outcome = "failed" + + logger.info( + f"{RENDER_TRAILER_PREFIX} outcome={outcome} " + f"render_id={run_state.render_id} " + f"functionalities={run_state.rendered_functionalities} " + f"render_time_s={run_state.render_time_accumulated} " + f"generated_code={run_state.render_generated_code_path or '-'} " + f"spec={spec_filename}" + ) + if error_message: + logger.error(f"{RENDER_TRAILER_PREFIX} error={error_message}") + + # The process may exit immediately after this; an unflushed trailer would defeat the + # purpose of writing one. + for handler in logger.handlers: + handler.flush() diff --git a/codeplain_REST_api.py b/codeplain_REST_api.py index 341fcc17..00569881 100644 --- a/codeplain_REST_api.py +++ b/codeplain_REST_api.py @@ -19,6 +19,7 @@ ERROR_CODE_EXCEPTIONS = { "FunctionalRequirementTooComplex": plain2code_exceptions.FunctionalRequirementTooComplex, "ConflictingRequirements": plain2code_exceptions.ConflictingRequirements, + "ConformanceTestsFixExhausted": plain2code_exceptions.ConformanceTestsFixExhausted, "RenderingCreditBalanceTooLow": plain2code_exceptions.RenderingCreditBalanceTooLow, "LLMInternalError": plain2code_exceptions.LLMInternalError, "MissingResource": plain2code_exceptions.MissingResource, @@ -382,6 +383,7 @@ def fix_conformance_tests_issue( current_testing_frid_high_level_implementation_plan: Optional[str], conflicting_requirements_count: int, run_state: RunState, + stalled_reason: Optional[str] = None, ): endpoint_url = f"{self.api_url}/fix_conformance_tests_issue" headers = {"X-API-Key": self.api_key, "Content-Type": "application/json"} @@ -408,6 +410,11 @@ def fix_conformance_tests_issue( if acceptance_tests is not None: payload["acceptance_tests"] = acceptance_tests + # Sent only once the loop has stopped moving. Omitted otherwise, so an + # ordinary fix request is byte-identical to what it was. + if stalled_reason is not None: + payload["stalled_reason"] = stalled_reason + return self.post_request(endpoint_url, headers, payload, run_state) def render_acceptance_tests( diff --git a/git_utils.py b/git_utils.py index 53b5b331..29626e89 100644 --- a/git_utils.py +++ b/git_utils.py @@ -43,21 +43,20 @@ def _get_full_commit_message(message, module_name, frid, render_id) -> str: def _ensure_git_config(repo: Repo) -> None: - config = repo.config_reader() - - try: - config.get_value("user", "name") - except (NoSectionError, NoOptionError): - # user.name not configured, set a default at repo level - with repo.config_writer(config_level="repository") as writer: - writer.set_value("user", "name", "Codeplain") - - try: - config.get_value("user", "email") - except (NoSectionError, NoOptionError): - # user.email not configured, set a default at repo level - with repo.config_writer(config_level="repository") as writer: - writer.set_value("user", "email", "codeplain@localhost") + with repo.config_reader() as config: + try: + config.get_value("user", "name") + except (NoSectionError, NoOptionError): + # user.name not configured, set a default at repo level + with repo.config_writer(config_level="repository") as writer: + writer.set_value("user", "name", "Codeplain") + + try: + config.get_value("user", "email") + except (NoSectionError, NoOptionError): + # user.email not configured, set a default at repo level + with repo.config_writer(config_level="repository") as writer: + writer.set_value("user", "email", "codeplain@localhost") def init_git_repo( @@ -75,12 +74,16 @@ def init_git_repo( else: os.makedirs(path_to_repo) - repo = Repo.init(path_to_repo) - _ensure_git_config(repo) + # Every function here closes its Repo before returning: GitPython's persistent + # `git cat-file` children are only reaped by close(), and on Windows a live child + # keeps the repository directory undeletable. A closed Repo stays usable — it + # re-acquires its resources lazily — so returning it is safe. + with Repo.init(path_to_repo) as repo: + _ensure_git_config(repo) - repo.git.commit( - "--allow-empty", "-m", _get_full_commit_message(INITIAL_COMMIT_MESSAGE, module_name, None, render_id) - ) + repo.git.commit( + "--allow-empty", "-m", _get_full_commit_message(INITIAL_COMMIT_MESSAGE, module_name, None, render_id) + ) return repo @@ -91,17 +94,18 @@ def clone_repo( module_name: Optional[str] = None, render_id: Optional[str] = None, ) -> Repo: - repo = Repo.clone_from(source_repo_path, new_repo_path) + with Repo.clone_from(source_repo_path, new_repo_path) as repo: + repo.git.commit( + "--allow-empty", "-m", _get_full_commit_message(INITIAL_COMMIT_MESSAGE, module_name, None, render_id) + ) - repo.git.commit( - "--allow-empty", "-m", _get_full_commit_message(INITIAL_COMMIT_MESSAGE, module_name, None, render_id) - ) + return repo def is_dirty(repo_path: Union[str, os.PathLike]) -> bool: """Checks if the repository is dirty.""" - repo = Repo(repo_path) - return repo.is_dirty(untracked_files=True) + with Repo(repo_path) as repo: + return repo.is_dirty(untracked_files=True) def add_all_files_and_commit( @@ -112,25 +116,25 @@ def add_all_files_and_commit( render_id: Optional[str] = None, ) -> Repo: """Adds all files to the git repository and commits them.""" - repo = Repo(repo_path) - repo.git.add(".") + with Repo(repo_path) as repo: + repo.git.add(".") - message = _get_full_commit_message(commit_message, module_name, frid, render_id) + message = _get_full_commit_message(commit_message, module_name, frid, render_id) - # Check if there are any changes to commit - if not repo.is_dirty(untracked_files=True): - repo.git.commit("--allow-empty", "-m", message) - else: - repo.git.commit("-m", message) + # Check if there are any changes to commit + if not repo.is_dirty(untracked_files=True): + repo.git.commit("--allow-empty", "-m", message) + else: + repo.git.commit("-m", message) return repo def revert_changes(repo_path: Union[str, os.PathLike]) -> Repo: """Reverts all changes made since the last commit.""" - repo = Repo(repo_path) - repo.git.reset("--hard") - repo.git.clean("-xdf") + with Repo(repo_path) as repo: + repo.git.reset("--hard") + repo.git.clean("-xdf") return repo @@ -144,15 +148,16 @@ def revert_to_commit_with_frid(repo_path: Union[str, os.PathLike], frid: Optiona It is expected that the repo has at least one commit related to provided frid if frid is not None. In case the frid related commit is not found, an exception is raised. """ - repo = Repo(repo_path) + with Repo(repo_path) as repo: + commit = _get_commit(repo, frid) - commit = _get_commit(repo, frid) - - if not commit: - raise InvalidGitRepositoryError("Git repository is in an invalid state. Relevant commit could not be found.") + if not commit: + raise InvalidGitRepositoryError( + "Git repository is in an invalid state. Relevant commit could not be found." + ) - repo.git.reset("--hard", commit) - repo.git.clean("-xdf") + repo.git.reset("--hard", commit) + repo.git.clean("-xdf") return repo @@ -166,14 +171,15 @@ def checkout_commit_with_frid(repo_path: Union[str, os.PathLike], frid: Optional It is expected that the repo has at least one commit related to provided frid if frid is not None. In case the frid related commit is not found, an exception is raised. """ - repo = Repo(repo_path) + with Repo(repo_path) as repo: + commit = _get_commit(repo, frid) - commit = _get_commit(repo, frid) - - if not commit: - raise InvalidGitRepositoryError("Git repository is in an invalid state. Relevant commit could not be found.") + if not commit: + raise InvalidGitRepositoryError( + "Git repository is in an invalid state. Relevant commit could not be found." + ) - repo.git.checkout(commit) + repo.git.checkout(commit) return repo @@ -187,8 +193,8 @@ def checkout_previous_branch(repo_path: Union[str, os.PathLike]) -> Repo: Returns: Repo: The git repository object """ - repo = Repo(repo_path) - repo.git.checkout("-") + with Repo(repo_path) as repo: + repo.git.checkout("-") return repo @@ -255,15 +261,14 @@ def diff(repo_path: Union[str, os.PathLike], previous_frid: str = None) -> dict: Returns: dict: Dictionary with file names as keys and their clean diff strings as values """ - repo = Repo(repo_path) - - commit = _get_commit(repo, previous_frid) + with Repo(repo_path) as repo: + commit = _get_commit(repo, previous_frid) - # Add all files to the index to get a clean diff - repo.git.add("-N", ".") + # Add all files to the index to get a clean diff + repo.git.add("-N", ".") - # Get the raw git diff output, excluding .pyc files - diff_output = repo.git.diff(commit, "--text", ":!*.pyc") + # Get the raw git diff output, excluding .pyc files + diff_output = repo.git.diff(commit, "--text", ":!*.pyc") if not diff_output: return {} @@ -322,7 +327,21 @@ def _get_commit_with_frid(repo: Repo, frid: str, module_name: Optional[str] = No def has_commit_for_frid(repo_path: Union[str, os.PathLike], frid: str, module_name: Optional[str] = None) -> bool: - return bool(_get_commit_with_frid(Repo(repo_path), frid, module_name)) + with Repo(repo_path) as repo: + return bool(_get_commit_with_frid(repo, frid, module_name)) + + +def frids_missing_commits( + repo_path: Union[str, os.PathLike], frids: list[str], module_name: Optional[str] = None +) -> list[str]: + """The frids from `frids` with no commit in the repository, in the order given. + + One Repo answers for the whole list. Asking per frid instead opens and closes a Repo + each time, and close() runs gc.collect() twice on win32, so a render resumed late paid + that for every functionality before it. + """ + with Repo(repo_path) as repo: + return [frid for frid in frids if not _get_commit_with_frid(repo, frid, module_name)] def _get_base_folder_commit(repo: Repo) -> str: @@ -343,18 +362,17 @@ def _get_commit_with_message(repo: Repo, message: str) -> str: def get_implementation_code_diff(repo_path: Union[str, os.PathLike], frid: str, previous_frid: str) -> dict: - repo = Repo(repo_path) - - implementation_commit = _get_commit_with_message(repo, REFACTORED_CODE_COMMIT_MESSAGE.format(frid)) - if not implementation_commit: - implementation_commit = _get_commit_with_message( - repo, FUNCTIONAL_REQUIREMENT_IMPLEMENTED_COMMIT_MESSAGE.format(frid) - ) + with Repo(repo_path) as repo: + implementation_commit = _get_commit_with_message(repo, REFACTORED_CODE_COMMIT_MESSAGE.format(frid)) + if not implementation_commit: + implementation_commit = _get_commit_with_message( + repo, FUNCTIONAL_REQUIREMENT_IMPLEMENTED_COMMIT_MESSAGE.format(frid) + ) - previous_frid_commit = _get_commit(repo, previous_frid) + previous_frid_commit = _get_commit(repo, previous_frid) - # Get the raw git diff output, excluding .pyc files - diff_output = repo.git.diff(previous_frid_commit, implementation_commit, "--text", ":!*.pyc") + # Get the raw git diff output, excluding .pyc files + diff_output = repo.git.diff(previous_frid_commit, implementation_commit, "--text", ":!*.pyc") if not diff_output: return {} @@ -363,22 +381,21 @@ def get_implementation_code_diff(repo_path: Union[str, os.PathLike], frid: str, def get_fixed_implementation_code_diff(repo_path: Union[str, os.PathLike], frid: str) -> dict: - repo = Repo(repo_path) + with Repo(repo_path) as repo: + implementation_commit = _get_commit_with_message(repo, REFACTORED_CODE_COMMIT_MESSAGE.format(frid)) + if not implementation_commit: + implementation_commit = _get_commit_with_message( + repo, FUNCTIONAL_REQUIREMENT_IMPLEMENTED_COMMIT_MESSAGE.format(frid) + ) - implementation_commit = _get_commit_with_message(repo, REFACTORED_CODE_COMMIT_MESSAGE.format(frid)) - if not implementation_commit: - implementation_commit = _get_commit_with_message( - repo, FUNCTIONAL_REQUIREMENT_IMPLEMENTED_COMMIT_MESSAGE.format(frid) + conformance_tests_passed_commit = _get_commit_with_message( + repo, CONFORMANCE_TESTS_PASSED_COMMIT_MESSAGE.format(frid) ) + if not conformance_tests_passed_commit: + return None - conformance_tests_passed_commit = _get_commit_with_message( - repo, CONFORMANCE_TESTS_PASSED_COMMIT_MESSAGE.format(frid) - ) - if not conformance_tests_passed_commit: - return None - - # Get the raw git diff output, excluding .pyc files - diff_output = repo.git.diff(implementation_commit, conformance_tests_passed_commit, "--text", ":!*.pyc") + # Get the raw git diff output, excluding .pyc files + diff_output = repo.git.diff(implementation_commit, conformance_tests_passed_commit, "--text", ":!*.pyc") if not diff_output: return {} @@ -396,31 +413,30 @@ def get_repo_info(repo_path: Union[str, os.PathLike]) -> dict: - is_dirty: boolean (includes untracked files) - remotes: dict mapping remote name to list of URLs """ - repo = Repo(repo_path) - - info = {"path": os.path.abspath(repo_path)} - - # Active branch (handle detached HEAD safely) - try: - if getattr(repo.head, "is_detached", False): - # Provide short commit identifier for detached head if available - try: - commit_sha = repo.head.commit.hexsha[:7] - info["active_branch"] = f"DETACHED_{commit_sha}" - except Exception: - info["active_branch"] = "DETACHED" - else: - info["active_branch"] = repo.active_branch.name - except Exception: - info["active_branch"] = None - - info["is_dirty"] = repo.is_dirty(untracked_files=True) - - # Remotes - remotes = {} - for remote in repo.remotes: - remotes[remote.name] = list(remote.urls) - info["remotes"] = remotes + with Repo(repo_path) as repo: + info = {"path": os.path.abspath(repo_path)} + + # Active branch (handle detached HEAD safely) + try: + if getattr(repo.head, "is_detached", False): + # Provide short commit identifier for detached head if available + try: + commit_sha = repo.head.commit.hexsha[:7] + info["active_branch"] = f"DETACHED_{commit_sha}" + except Exception: + info["active_branch"] = "DETACHED" + else: + info["active_branch"] = repo.active_branch.name + except Exception: + info["active_branch"] = None + + info["is_dirty"] = repo.is_dirty(untracked_files=True) + + # Remotes + remotes = {} + for remote in repo.remotes: + remotes[remote.name] = list(remote.urls) + info["remotes"] = remotes return info @@ -429,36 +445,39 @@ def get_last_rendered_functionality(repo_path: Union[str, os.PathLike]) -> tuple if not os.path.exists(repo_path): return None, None - repo = Repo(repo_path) - grep_pattern = FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format(".*") - grep_pattern = grep_pattern.replace("[", "\\[").replace("]", "\\]") - commit_sha = repo.git.rev_list(repo.active_branch.name, "--grep", grep_pattern, "-n", "1") - - if not commit_sha: - # Repo was interrupted during the first functionality, fallback to initial commit and provide only module name - grep_pattern = INITIAL_COMMIT_MESSAGE + with Repo(repo_path) as repo: + grep_pattern = FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format(".*") grep_pattern = grep_pattern.replace("[", "\\[").replace("]", "\\]") commit_sha = repo.git.rev_list(repo.active_branch.name, "--grep", grep_pattern, "-n", "1") + if not commit_sha: - raise InvalidGitRepositoryError("Git repository is in an invalid state. Initial commit could not be found.") + # Repo was interrupted during the first functionality, fallback to initial commit + # and provide only module name + grep_pattern = INITIAL_COMMIT_MESSAGE + grep_pattern = grep_pattern.replace("[", "\\[").replace("]", "\\]") + commit_sha = repo.git.rev_list(repo.active_branch.name, "--grep", grep_pattern, "-n", "1") + if not commit_sha: + raise InvalidGitRepositoryError( + "Git repository is in an invalid state. Initial commit could not be found." + ) + + commit_message = repo.commit(commit_sha).message + if isinstance(commit_message, bytes): + commit_message = commit_message.decode("utf-8") + + match = re.search(r"Module name:\s*(\S+)\n", commit_message) + if not match: + raise InvalidGitRepositoryError( + "Git repository is in an invalid state. Could not find module name in initial commit." + ) + + module_name = match.group(1) + return module_name, None commit_message = repo.commit(commit_sha).message if isinstance(commit_message, bytes): commit_message = commit_message.decode("utf-8") - match = re.search(r"Module name:\s*(\S+)\n", commit_message) - if not match: - raise InvalidGitRepositoryError( - "Git repository is in an invalid state. Could not find module name in initial commit." - ) - - module_name = match.group(1) - return module_name, None - - commit_message = repo.commit(commit_sha).message - if isinstance(commit_message, bytes): - commit_message = commit_message.decode("utf-8") - match = re.search(r"FRID\):(\S+) fully implemented", commit_message) if not match: raise InvalidGitRepositoryError( diff --git a/plain2code.py b/plain2code.py index ef60ddf4..6a4621a7 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. @@ -123,6 +137,16 @@ def setup_logging( handler = LoggingHandler(event_bus, run_state) handler.setFormatter(formatter) root_logger.addHandler(handler) + else: + # Headless has no TUI to narrate the render and suppresses Rich output, so + # without this the process says nothing on stdout for its entire run: a CI or + # benchmark job cannot distinguish a render wedged for four hours from a + # healthy one until the log file is collected at the end. StreamHandler flushes + # per record, so these arrive as they happen. + stdout_handler = logging.StreamHandler(sys.stdout) + stdout_handler.setFormatter(file_formatter) + stdout_handler.setLevel(configured_log_level) + root_logger.addHandler(stdout_handler) if log_to_file: try: @@ -188,6 +212,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 +362,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/plain2code_console.py b/plain2code_console.py index e5047ac7..a2c40a91 100644 --- a/plain2code_console.py +++ b/plain2code_console.py @@ -67,8 +67,10 @@ def _log_and_print(self, level, base_style, args, color, kwargs): logger.log(level, " ".join(map(str, args)), extra={"log_color": color}) style = base_style + Style(color=color) if color else base_style # Log messages must render exactly as logged: don't interpret square brackets - # in interpolated content (error texts, file names) as Rich markup. + # in interpolated content (error texts, file names) as Rich markup, and don't + # let the repr highlighter restyle brackets and numbers inside them. kwargs.setdefault("markup", False) + kwargs.setdefault("highlight", False) super().print(*args, **kwargs, style=style) def print_list(self, items, style=None): diff --git a/plain2code_exceptions.py b/plain2code_exceptions.py index b29bea98..1c3b7466 100644 --- a/plain2code_exceptions.py +++ b/plain2code_exceptions.py @@ -13,6 +13,10 @@ class RenderingCreditBalanceTooLow(Exception): pass +class ConformanceTestsFixExhausted(Exception): + """The server's conformance-fix loop spent its attempt budget on one functionality.""" + + class LLMInternalError(Exception): pass diff --git a/plain2code_logger.py b/plain2code_logger.py index 0203b81f..51a00358 100644 --- a/plain2code_logger.py +++ b/plain2code_logger.py @@ -1,3 +1,4 @@ +import copy import logging from event_bus import EventBus @@ -17,18 +18,28 @@ FILE_LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" +def _with_indented_message(record, indent: str): + """A copy of the record whose continuation lines are indented. + + One record is handed to every attached handler in turn, so a formatter that + rewrites `record.msg` in place is rewriting it for the handlers that come after + it too — a headless render logging to both stdout and a file would indent each + continuation line twice, once per formatter. Copying keeps each handler's + formatting local to that handler. + """ + indented = copy.copy(record) + indented.msg = record.getMessage().replace("\n", "\n" + indent) + indented.args = None # getMessage() already interpolated them into msg + return indented + + class IndentedFormatter(logging.Formatter): def __init__(self, fmt=None, datefmt=None, indent=16): super().__init__(fmt=fmt, datefmt=datefmt) self._indent = " " * indent def format(self, record): - original_message = record.getMessage() - - modified_message = original_message.replace("\n", "\n" + self._indent) - - record.msg = modified_message - return super().format(record) + return super().format(_with_indented_message(record, self._indent)) class ElapsedTimeFormatter(logging.Formatter): @@ -51,16 +62,11 @@ def format(self, record): seconds = offset_seconds % 60 elapsed_time = f"[{hours:02d}:{minutes:02d}:{seconds:02d}]" - # Add elapsed_time to the record so it can be used in the format string - record.elapsed_time = elapsed_time - - # Handle multi-line messages with proper indentation - original_message = record.getMessage() - indent = " " * len(elapsed_time + " ") - modified_message = original_message.replace("\n", "\n" + indent) - record.msg = modified_message + # Continuation lines line up under the message, past the timestamp column. + indented = _with_indented_message(record, " " * len(elapsed_time + " ")) + indented.elapsed_time = elapsed_time - return super().format(record) + return super().format(indented) class LoggingHandler(logging.Handler): diff --git a/plain_modules.py b/plain_modules.py index cda701ca..242a26f6 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -283,34 +283,52 @@ def _ensure_module_folders_exist(self, first_render_frid: str, render_conformanc f" codeplain {self.module_name}{plain_file.PLAIN_SOURCE_FILE_EXTENSION}" ) - def _ensure_frid_commit_exists( + def _raise_for_missing_frid_commits( self, - frid: str, + previous_frids: list[str], first_render_frid: str, render_conformance_tests: bool, ) -> None: """ - Ensure commit exists for a single FRID in both repositories. + Ensure commits exist for every previous FRID in both repositories. + + Each repository is asked once for the whole list rather than once per FRID, and the + first FRID that is missing anywhere decides the error, in the order given. Args: - frid: The FRID to check + previous_frids: The FRIDs that should already have been rendered first_render_frid: The first FRID in the render range (for error messages) render_conformance_tests: Whether to check for conformance tests Raises: - MissingPreviousFridCommitsError: If the commit is missing + MissingPreviousFunctionalitiesError: If any commit is missing """ - # Check in build folder - if not git_utils.has_commit_for_frid(self.module_build_folder, frid, self.module_name): - raise MissingPreviousFunctionalitiesError( - f"Cannot start rendering from functionality {first_render_frid} for module '{self.module_name}' because the implementation of the previous functionality ({frid}) hasn't been completed yet.\n\n" - f"To fix this, please render the missing functionality ({frid}) first by running:\n" - f" codeplain {self.module_name}{plain_file.PLAIN_SOURCE_FILE_EXTENSION} --render-from {frid}" - ) - - # Check in conformance tests folder (only if conformance tests are enabled) + missing_in_build = set( + git_utils.frids_missing_commits(self.module_build_folder, previous_frids, self.module_name) + ) + missing_in_tests = set() if render_conformance_tests: - if not git_utils.has_commit_for_frid(self.module_conformance_tests_folder, frid, self.module_name): + try: + missing_in_tests = set( + git_utils.frids_missing_commits( + self.module_conformance_tests_folder, previous_frids, self.module_name + ) + ) + except Exception: + # A broken tests repo must not mask the actionable build-repo error below; + # with nothing missing in the build repo it is a real failure and propagates. + if not missing_in_build: + raise + + for frid in previous_frids: + if frid in missing_in_build: + raise MissingPreviousFunctionalitiesError( + f"Cannot start rendering from functionality {first_render_frid} for module '{self.module_name}' because the implementation of the previous functionality ({frid}) hasn't been completed yet.\n\n" + f"To fix this, please render the missing functionality ({frid}) first by running:\n" + f" codeplain {self.module_name}{plain_file.PLAIN_SOURCE_FILE_EXTENSION} --render-from {frid}" + ) + + if frid in missing_in_tests: raise MissingPreviousFunctionalitiesError( f"Cannot start rendering from functionality {first_render_frid} for module '{self.module_name}' because the conformance tests for the previous functionality ({frid}) haven't been completed yet.\n\n" f"To fix this, please render the missing functionality ({frid}) first by running:\n" @@ -342,8 +360,7 @@ def ensure_previous_frid_commits_exist(self, render_range: list[str], render_con self._ensure_module_folders_exist(first_render_frid, render_conformance_tests) # Verify commits exist for all previous FRIDs - for prev_frid in previous_frids: - self._ensure_frid_commit_exists(prev_frid, first_render_frid, render_conformance_tests) + self._raise_for_missing_frid_commits(previous_frids, first_render_frid, render_conformance_tests) def get_required_module_by_name(self, module_name: str) -> PlainModule: for module in self.all_required_modules: diff --git a/pyproject.toml b/pyproject.toml index 254ff05c..710db10d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "rich==15.0.0", "python-frontmatter==1.3.0", "networkx==3.6.1", + "pyte==0.8.2", "sentry-sdk==2.66.1", ] @@ -110,6 +111,19 @@ exclude = [ "^examples/", ] +# The global disable_error_code list hides every platform-symbol error, which is +# exactly what the platform-split modules need reported: under the --platform matrix +# a reference to a symbol the target platform lacks must fail. Per-module +# enable_error_code takes precedence over the global disable. +[[tool.mypy.overrides]] +module = [ + "render_machine.terminal_process", + "render_machine._posix_pty", + "render_machine._conpty", + "render_machine.pty_exec", +] +enable_error_code = ["attr-defined", "unreachable", "misc"] + [tool.pytest.ini_options] pythonpath = ["src"] testpaths = ["tests"] diff --git a/render_machine/_conpty.py b/render_machine/_conpty.py new file mode 100644 index 00000000..76f078e0 --- /dev/null +++ b/render_machine/_conpty.py @@ -0,0 +1,1376 @@ +"""Windows ConPTY backend for `TerminalProcess`. + +One pseudoconsole backs the target's standard handles, and one Job Object contains the +process tree it starts. Both are attached at creation: the job goes into the same +proc-thread attribute list as the pseudoconsole, so the child is either created inside the +job or not created at all, and `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` makes the kernel tear +the tree down when the last job handle closes. + +A single reader thread owns the output pipe's read handle for its whole lifetime, and a +single writer thread owns every write to the input pipe. The writer exists because the +input pipe is anonymous and therefore synchronous: a target that stops reading leaves +`WriteFile` blocked until it is cancelled, and only the writer's own thread handle is +registered for that cancellation. + +Ownership is incremental from before the first allocation. Every handle is wrapped in a +holder carrying an owned flag, the rollback stack always registers `close_if_owned`, and +handing a resource on is `take()` — flag first, value second, never the reverse. + +Two platform asymmetries are deliberate and documented here rather than hidden: + +* The job is a stronger containment than a POSIX process group. It survives `setpgid`-style + escapes and the kernel enforces it, whereas PTY hangup delivers a signal a target may + ignore. +* There is no synthetic end-of-file. POSIX injects `VEOF` at spawn; + ConPTY has no parent-side equivalent that keeps the input channel open, and the channel + has to stay open for the graceful control byte and for terminal-query replies. A script + that reads input therefore blocks until the execution timeout rather than seeing EOF. +""" + +import codecs +import ctypes +import sys +import threading +import time +from contextlib import ExitStack +from ctypes import wintypes +from typing import Callable, Optional, Sequence, Tuple + +from plain2code_console import console +from render_machine._conpty_support import ( + CANCEL_TICK_SECONDS, + WRITER_JOIN_DEADLINE_SECONDS, + GateDecision, + InputQueue, + InputWriter, + WriteAborted, + WriteChannel, + build_command_line, + build_environment_block, + native_thread_id, + validate_working_directory, +) +from render_machine.output_normalizer import OutputNormalizer +from render_machine.terminal_process import ( + CONTROL_DELIVERY_DEADLINE_SECONDS, + DRAIN_DEADLINE_SECONDS, + GRACE_TICK_SECONDS, + HANDSHAKE_TIMEOUT_SECONDS, + OWNER_PARENT, + OWNER_READER, + POLL_INTERVAL_SECONDS, + READ_CHUNK_BYTES, + REAP_DEADLINE_SECONDS, + SIGTERM_GRACE_PERIOD_SECONDS, + TERMINAL_COLUMNS, + TERMINAL_ROWS, + InputWriteResult, + TerminalEnvironmentError, + TerminalProcess, + TerminalProcessError, + terminal_child_environment, +) +from render_machine.terminal_queries import TerminalQueryResponder, reply_resolution + +if sys.platform != "win32": # pragma: no cover - the ConPTY backend is Windows-only + raise ImportError("render_machine._conpty is Windows-only") + +# ------------------------------------------------------------------ FFI: types +# +# Declared before any lifecycle code. ctypes converts return values as c_int by default, +# which truncates 64-bit handles, heap pointers and attribute-list addresses before any +# ownership rule can help, so every imported function below carries explicit argtypes and +# restype: pointer-width types for HANDLE / HPCON / PVOID / SIZE_T, BOOL for the Win32 BOOL +# APIs, signed 32-bit for the HRESULT-returning pseudoconsole calls, and None for the void +# ones. + +kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + +HANDLE = wintypes.HANDLE +PHANDLE = ctypes.POINTER(HANDLE) +HPCON = wintypes.HANDLE +PHPCON = ctypes.POINTER(HPCON) +DWORD = wintypes.DWORD +LPDWORD = ctypes.POINTER(DWORD) +BOOL = wintypes.BOOL +PBOOL = ctypes.POINTER(BOOL) +LPVOID = ctypes.c_void_p +SIZE_T = ctypes.c_size_t +PSIZE_T = ctypes.POINTER(SIZE_T) +ULONG_PTR = ctypes.c_size_t +LARGE_INTEGER = wintypes.LARGE_INTEGER + +S_OK = 0 + +EXTENDED_STARTUPINFO_PRESENT = 0x00080000 +STARTF_USESTDHANDLES = 0x00000100 +CREATE_UNICODE_ENVIRONMENT = 0x00000400 +PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x00020016 +PROC_THREAD_ATTRIBUTE_JOB_LIST = 0x0002000D +PROC_THREAD_ATTRIBUTE_COUNT = 2 + +JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 +JOBOBJECT_BASIC_ACCOUNTING_INFORMATION_CLASS = 1 +JOBOBJECT_EXTENDED_LIMIT_INFORMATION_CLASS = 9 + +THREAD_TERMINATE = 0x0001 + +STD_INPUT_HANDLE = 0xFFFFFFF6 +STD_OUTPUT_HANDLE = 0xFFFFFFF5 +STD_ERROR_HANDLE = 0xFFFFFFF4 +INVALID_HANDLE_VALUE = 0xFFFFFFFFFFFFFFFF + +ERROR_HANDLE_EOF = 38 +ERROR_BROKEN_PIPE = 109 +ERROR_INSUFFICIENT_BUFFER = 122 +ERROR_OPERATION_ABORTED = 995 +ERROR_NOT_FOUND = 1168 + +WAIT_OBJECT_0 = 0 +WAIT_TIMEOUT = 258 # the only wait result that means "still running" + +# ConPTY ships from Windows 10 1809. Below it there is no fallback: a silent downgrade to +# pipes would make execution behaviour depend on the machine again. +MIN_CONPTY_BUILD = 17763 + +# The forced-termination exit code the job reports for its members. +JOB_TERMINATION_EXIT_CODE = 1 + +# How long the finalizer keeps retrying a teardown the foreground had to abandon. +FINALIZER_DEADLINE_SECONDS = 60.0 +FINALIZER_TICK_SECONDS = 0.5 + +# What one full teardown of this backend may spend, phase by phase and in sequence. The +# pipeline is longer than the POSIX one — a control byte has to be delivered before the +# grace it earns, the job's membership is waited out, the writer is stopped, and each +# join_reader() round is bounded twice: a join on the bound, then a cancel-and-join loop +# under the same bound again. A caller waiting on a render derives its own bound from this, +# so it cannot report a stuck teardown while the backend is still inside its own budget. +TEARDOWN_BUDGET_SECONDS = ( + CONTROL_DELIVERY_DEADLINE_SECONDS # teardown(): delivering the graceful control byte + + SIGTERM_GRACE_PERIOD_SECONDS # teardown(): the grace a delivered byte earns + + REAP_DEADLINE_SECONDS # teardown(): waiting for the job's membership to reach zero + + WRITER_JOIN_DEADLINE_SECONDS # teardown(): stopping the input writer + + 2 * DRAIN_DEADLINE_SECONDS # teardown(): join_reader() inside _close_pseudoconsole() + + 2 * DRAIN_DEADLINE_SECONDS # close(): the join_reader() that follows the stack close +) + +# The graceful signal: writing 0x03 into the pseudoconsole input is how terminal emulators +# deliver Ctrl-C to a ConPTY client. `GenerateConsoleCtrlEvent` cannot be used, because it +# reaches only processes sharing the caller's console and the target is on the pseudoconsole. +CONTROL_C_BYTE = b"\x03" + +# The absent-input note this backend states itself, where the asymmetry is documented: +# unlike the other backends it cannot hand the target end-of-file, so a script that reads +# terminal input really does block until the execution timeout. +NO_INPUT_NOTE = ( + " On Windows the terminal carries no synthetic end-of-file, so a script that waits for " + "terminal input blocks until the timeout." +) + + +class COORD(ctypes.Structure): + _fields_ = [("X", ctypes.c_short), ("Y", ctypes.c_short)] + + +class STARTUPINFOW(ctypes.Structure): + _fields_ = [ + ("cb", DWORD), + ("lpReserved", wintypes.LPWSTR), + ("lpDesktop", wintypes.LPWSTR), + ("lpTitle", wintypes.LPWSTR), + ("dwX", DWORD), + ("dwY", DWORD), + ("dwXSize", DWORD), + ("dwYSize", DWORD), + ("dwXCountChars", DWORD), + ("dwYCountChars", DWORD), + ("dwFillAttribute", DWORD), + ("dwFlags", DWORD), + ("wShowWindow", wintypes.WORD), + ("cbReserved2", wintypes.WORD), + ("lpReserved2", ctypes.POINTER(ctypes.c_byte)), + ("hStdInput", HANDLE), + ("hStdOutput", HANDLE), + ("hStdError", HANDLE), + ] + + +class STARTUPINFOEXW(ctypes.Structure): + _fields_ = [("StartupInfo", STARTUPINFOW), ("lpAttributeList", LPVOID)] + + +class PROCESS_INFORMATION(ctypes.Structure): + _fields_ = [("hProcess", HANDLE), ("hThread", HANDLE), ("dwProcessId", DWORD), ("dwThreadId", DWORD)] + + +class IO_COUNTERS(ctypes.Structure): + _fields_ = [ + ("ReadOperationCount", ctypes.c_ulonglong), + ("WriteOperationCount", ctypes.c_ulonglong), + ("OtherOperationCount", ctypes.c_ulonglong), + ("ReadTransferCount", ctypes.c_ulonglong), + ("WriteTransferCount", ctypes.c_ulonglong), + ("OtherTransferCount", ctypes.c_ulonglong), + ] + + +class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("PerProcessUserTimeLimit", LARGE_INTEGER), + ("PerJobUserTimeLimit", LARGE_INTEGER), + ("LimitFlags", DWORD), + ("MinimumWorkingSetSize", SIZE_T), + ("MaximumWorkingSetSize", SIZE_T), + ("ActiveProcessLimit", DWORD), + ("Affinity", ULONG_PTR), + ("PriorityClass", DWORD), + ("SchedulingClass", DWORD), + ] + + +class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION), + ("IoInfo", IO_COUNTERS), + ("ProcessMemoryLimit", SIZE_T), + ("JobMemoryLimit", SIZE_T), + ("PeakProcessMemoryUsed", SIZE_T), + ("PeakJobMemoryUsed", SIZE_T), + ] + + +class JOBOBJECT_BASIC_ACCOUNTING_INFORMATION(ctypes.Structure): + _fields_ = [ + ("TotalUserTime", LARGE_INTEGER), + ("TotalKernelTime", LARGE_INTEGER), + ("ThisPeriodTotalUserTime", LARGE_INTEGER), + ("ThisPeriodTotalKernelTime", LARGE_INTEGER), + ("TotalPageFaultCount", DWORD), + ("TotalProcesses", DWORD), + ("ActiveProcesses", DWORD), + ("TotalTerminatedProcesses", DWORD), + ] + + +# -------------------------------------------------------------- FFI: functions + + +def _declare(name: str, argtypes: Sequence[object], restype: Optional[object]): + function = getattr(kernel32, name) + function.argtypes = list(argtypes) + function.restype = restype + return function + + +_declare("CloseHandle", [HANDLE], BOOL) +_declare("CreatePipe", [PHANDLE, PHANDLE, LPVOID, DWORD], BOOL) +_declare("ReadFile", [HANDLE, LPVOID, DWORD, LPDWORD, LPVOID], BOOL) +_declare("WriteFile", [HANDLE, LPVOID, DWORD, LPDWORD, LPVOID], BOOL) +_declare("GetProcessHeap", [], HANDLE) +_declare("HeapAlloc", [HANDLE, DWORD, SIZE_T], LPVOID) +_declare("HeapFree", [HANDLE, DWORD, LPVOID], BOOL) +_declare("InitializeProcThreadAttributeList", [LPVOID, DWORD, DWORD, PSIZE_T], BOOL) +_declare("UpdateProcThreadAttribute", [LPVOID, DWORD, ULONG_PTR, LPVOID, SIZE_T, LPVOID, PSIZE_T], BOOL) +_declare("DeleteProcThreadAttributeList", [LPVOID], None) +_declare( + "CreateProcessW", + [ + wintypes.LPCWSTR, + wintypes.LPWSTR, + LPVOID, + LPVOID, + BOOL, + DWORD, + LPVOID, + wintypes.LPCWSTR, + ctypes.POINTER(STARTUPINFOEXW), + ctypes.POINTER(PROCESS_INFORMATION), + ], + BOOL, +) +_declare("CreateJobObjectW", [LPVOID, wintypes.LPCWSTR], HANDLE) +_declare("SetInformationJobObject", [HANDLE, ctypes.c_int, LPVOID, DWORD], BOOL) +_declare("QueryInformationJobObject", [HANDLE, ctypes.c_int, LPVOID, DWORD, LPDWORD], BOOL) +_declare("TerminateJobObject", [HANDLE, wintypes.UINT], BOOL) +_declare("IsProcessInJob", [HANDLE, HANDLE, PBOOL], BOOL) +_declare("GetExitCodeProcess", [HANDLE, LPDWORD], BOOL) +_declare("WaitForSingleObject", [HANDLE, DWORD], DWORD) +_declare("OpenThread", [DWORD, BOOL, DWORD], HANDLE) +_declare("GetStdHandle", [DWORD], HANDLE) +_declare("GetConsoleMode", [HANDLE, LPDWORD], BOOL) +_declare("CancelSynchronousIo", [HANDLE], BOOL) + + +def _declare_pseudoconsole_api() -> bool: + """Binds the three ConPTY entry points, or reports that this build has none. + + Their restype is signed 32-bit rather than `ctypes.HRESULT`, which would raise an + `OSError` of its own: the failure has to be reported as the HRESULT itself, because + these calls do not promise to set the last error. + """ + if not hasattr(kernel32, "CreatePseudoConsole"): + return False + _declare("CreatePseudoConsole", [COORD, HANDLE, HANDLE, DWORD, PHPCON], ctypes.c_long) + _declare("ClosePseudoConsole", [HPCON], None) + return True + + +PSEUDOCONSOLE_AVAILABLE = _declare_pseudoconsole_api() + + +# ------------------------------------------------------------------- FFI: errors + + +def _win_error(action: str, error: int) -> TerminalEnvironmentError: + return TerminalEnvironmentError(f"{action} failed: Windows error {error} ({ctypes.FormatError(error)}).") + + +def _hresult_error(action: str, hresult: int) -> TerminalEnvironmentError: + return TerminalEnvironmentError(f"{action} failed: HRESULT 0x{hresult & 0xFFFFFFFF:08X}.") + + +def _windows_build() -> int: + return int(sys.getwindowsversion().build) + + +def _require_pseudoconsole_support() -> None: + build = _windows_build() + if PSEUDOCONSOLE_AVAILABLE and build >= MIN_CONPTY_BUILD: + return + raise TerminalEnvironmentError( + f"This Windows build ({build}) has no pseudoconsole support, so a script cannot be given a " + f"terminal. Codeplain needs Windows 10 build {MIN_CONPTY_BUILD} (1809) or newer. There is no " + "pipe fallback, because execution behaviour must not depend on the machine." + ) + + +def _close_handle(handle: Optional[int]) -> None: + if not handle: + return + kernel32.CloseHandle(handle) + + +# ------------------------------------------------------------------- ownership + + +class _PipePair: + """Validity shared by both endpoints of one pipe. + + A failed `CreatePipe` leaves whatever was in the two slots behind, so neither endpoint + may be closed on that path. One flag, consulted by both closers, is what keeps them from + disagreeing. + """ + + def __init__(self) -> None: + self.valid = False + + +class _Holder: + """One handle plus the flag that says whether this side still owns it. + + `take()` flips the flag and then returns the value. Closing first and disarming + afterwards leaves a window in which rollback holds a handle Windows may already have + recycled — the corruption this helper exists to prevent. + """ + + def __init__(self, pair: Optional[_PipePair] = None) -> None: + self.value = HANDLE() + self.owned = True + self._pair = pair + self._lock = threading.Lock() + + @property + def slot(self): + """The address every API writes straight into, so there is no copy-out step.""" + return ctypes.byref(self.value) + + def handle(self) -> Optional[int]: + if not self.owned or (self._pair is not None and not self._pair.valid): + return None + return self.value.value + + def take(self) -> Optional[int]: + with self._lock: + if not self.owned or (self._pair is not None and not self._pair.valid): + return None + self.owned = False + return self.value.value + + def close_if_owned(self) -> None: + _close_handle(self.take()) + + +class _AttrList: + """The attribute list's two ownership states. + + `InitializeProcThreadAttributeList()` does not allocate, so the buffer and the + initialized list are separate states with separate cleanups: a buffer alone is freed, + while an initialized list is deleted first and only then freed. + """ + + def __init__(self) -> None: + self.buffer: Optional[int] = None + self.initialized = False + self.owned = True + + def dispose(self) -> None: + buffer, self.buffer = self.buffer, None + initialized, self.initialized = self.initialized, False + self.owned = False + if buffer is None: + return + if initialized: + kernel32.DeleteProcThreadAttributeList(buffer) + kernel32.HeapFree(kernel32.GetProcessHeap(), 0, buffer) + + def dispose_if_owned(self) -> None: + if self.owned: + self.dispose() + + +class _ProcInfo: + """`PROCESS_INFORMATION`: two handles the kernel fills into one pre-owned struct. + + Both are owned from the moment the call returns. Recording only the process handle and + leaving the thread handle for later means an unwind at the next check leaks it. + """ + + def __init__(self) -> None: + self.pi = PROCESS_INFORMATION() + self.valid = False + self._lock = threading.Lock() + + def _take(self, name: str) -> Optional[int]: + with self._lock: + if not self.valid: # a failed call leaves garbage in both fields + return None + handle = getattr(self.pi, name) + setattr(self.pi, name, None) + return handle + + def take_process(self) -> Optional[int]: + return self._take("hProcess") + + def take_thread(self) -> Optional[int]: + return self._take("hThread") + + def process_handle(self) -> Optional[int]: + return self.pi.hProcess if self.valid else None + + def close_all(self) -> None: + _close_handle(self.take_process()) + _close_handle(self.take_thread()) + + +class _ReaderHandles: + """The output read handle, whose ownership moves to the reader in one assignment. + + `owner` is the single field that decides, so rollback and reader can never disagree and + there is no state in which the handle has left one owner without reaching the other. + """ + + def __init__(self, pair: _PipePair) -> None: + self.owner = OWNER_PARENT + self.out_r = HANDLE() + self._pair = pair + self._lock = threading.Lock() + + @property + def slot(self): + return ctypes.byref(self.out_r) + + def take(self) -> Optional[int]: + with self._lock: + if not self._pair.valid: + return None + handle = self.out_r.value + self.out_r = HANDLE() + return handle + + def close_if_owner_is_parent(self) -> None: + if self.owner == OWNER_PARENT: + _close_handle(self.take()) + + +# -------------------------------------------------------------- native helpers +# +# Every native step of the spawn sequence goes through one of these, so a test can fail a +# single step and assert what the rollback releases. + + +def _create_pipe(read_holder, write_holder, pair: _PipePair) -> None: + ok = kernel32.CreatePipe(read_holder.slot, write_holder.slot, None, 0) + if not ok: + error = ctypes.get_last_error() # captured before formatting or any other call + raise _win_error("Creating a terminal pipe", error) + pair.valid = True + + +def _create_job(session: "_SessionBundle") -> None: + """Stores the job in the session before returning. + + Returning the handle for the caller to assign leaves a window in which nothing owns it: + an interruption between the two loses the job, and with it the containment its whole + purpose is. + """ + handle = kernel32.CreateJobObjectW(None, None) + if not handle: + error = ctypes.get_last_error() + raise _win_error("Creating the job object for the script's process tree", error) + session.hJob = handle + + +def _set_kill_on_job_close(job: int) -> None: + """The crash-safety backstop: the kernel tears the tree down when the last handle closes.""" + limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION() + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + ok = kernel32.SetInformationJobObject( + job, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION_CLASS, + ctypes.byref(limits), + ctypes.sizeof(limits), + ) + if not ok: + error = ctypes.get_last_error() + raise _win_error("Configuring the job object", error) + + +def _create_pseudoconsole(session, columns: int, rows: int, in_r: int, out_w: int) -> None: + """Writes into the session's slot and arms it, both inside this call. + + Arming from the caller would leave a pseudoconsole nobody may close if anything came + between the two statements. + """ + size = COORD(ctypes.c_short(columns), ctypes.c_short(rows)) + hresult = kernel32.CreatePseudoConsole(size, in_r, out_w, 0, ctypes.byref(session.hPC)) + if hresult != S_OK: # HRESULT, not BOOL: success is zero and failure is everything else + raise _hresult_error("Creating the pseudoconsole", hresult) + session.hPC_valid = True # armed only after S_OK + + +def _initialize_attribute_list(attrs: _AttrList, count: int) -> None: + """The three-call protocol: size, allocate, initialize. + + The sizing call fails by design, but only one failure is the expected one — anything + else is a real error that must abort rather than flow into a zero-byte allocation. + """ + size = SIZE_T(0) + ok = kernel32.InitializeProcThreadAttributeList(None, count, 0, ctypes.byref(size)) + error = ctypes.get_last_error() + if ok: + raise TerminalEnvironmentError("Sizing the process attribute list unexpectedly succeeded.") + if error != ERROR_INSUFFICIENT_BUFFER: + raise _win_error("Sizing the process attribute list", error) + if size.value == 0: + raise TerminalEnvironmentError("Sizing the process attribute list reported a zero-byte list.") + buffer = kernel32.HeapAlloc(kernel32.GetProcessHeap(), 0, size.value) + if not buffer: # HeapAlloc reports failure by returning NULL rather than raising + raise TerminalEnvironmentError(f"Allocating {size.value} bytes for the process attribute list failed.") + attrs.buffer = buffer # state one: free only + ok = kernel32.InitializeProcThreadAttributeList(buffer, count, 0, ctypes.byref(size)) + if not ok: + error = ctypes.get_last_error() + raise _win_error("Initializing the process attribute list", error) + attrs.initialized = True # state two: delete, then free + + +def _update_attribute(attrs: _AttrList, attribute: int, value, size: int, description: str) -> None: + ok = kernel32.UpdateProcThreadAttribute(attrs.buffer, 0, attribute, value, size, None, None) + if not ok: + error = ctypes.get_last_error() + raise _win_error(f"Adding the {description} to the process attribute list", error) + + +def _create_process( + command_line: str, + directory: Optional[str], + environment: str, + attrs: _AttrList, + proc: _ProcInfo, +) -> None: + startup = STARTUPINFOEXW() + startup.StartupInfo.cb = ctypes.sizeof(STARTUPINFOEXW) + startup.lpAttributeList = attrs.buffer + if _renderer_output_is_redirected(): + # Declared, and left NULL. Without this, CreateProcessW hands the child a copy of the + # renderer's own standard handles: verified on Windows Server 2022, where the target + # wrote into the renderer's redirected stdout and read end-of-file from its stdin + # while still attached to the pseudoconsole and to its job. Declaring the handles and + # supplying none stops that copy, and the console the child is attached to supplies + # its standard handles instead. A renderer that owns a console is left on the path + # that already reaches the pseudoconsole, so this never changes the interactive case. + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES + # CreateProcessW may modify lpCommandLine in place, so it is handed a writable buffer. + command_buffer = ctypes.create_unicode_buffer(command_line) + # The buffer's own terminator supplies the block's second NUL. + environment_buffer = ctypes.create_unicode_buffer(environment) + ok = kernel32.CreateProcessW( + None, + ctypes.cast(command_buffer, wintypes.LPWSTR), + None, + None, + False, + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + ctypes.cast(environment_buffer, LPVOID), + directory, + ctypes.byref(startup), + ctypes.byref(proc.pi), + ) + if not ok: # zero is failure, and ctypes does not raise on it + error = ctypes.get_last_error() + raise _win_error("Starting the script", error) + proc.valid = True # closers ignore the garbage a failed call leaves behind + + +def _renderer_output_is_redirected() -> bool: + """True when any of the renderer's own standard handles is not a console. + + It decides whether the child needs protecting from them. When the renderer sits on a + console, `CreateProcessW` swaps the child's standard handles for its own console's and + the pseudoconsole is reached as intended. When the renderer is redirected — every CI run, + every piped invocation — the same call copies those files or pipes into the child, which + then writes past the pseudoconsole entirely and reads end-of-file instead of input. + """ + for identifier in (STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE): + handle = kernel32.GetStdHandle(identifier) + if not handle or handle == INVALID_HANDLE_VALUE: + return True + mode = DWORD(0) + if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + return True + return False + + +def _open_thread_handle(holder: "_Holder", native_id: int) -> None: + """THREAD_TERMINATE is what `CancelSynchronousIo()` requires. + + The handle is opened while the thread it names is certainly alive — the writer parked on + its gate, the reader before its first read — because an id is recyclable the instant its + thread exits. It lands in the holder inside this call, so no interruption can lose it. + """ + handle = kernel32.OpenThread(THREAD_TERMINATE, False, native_id) + if not handle: + error = ctypes.get_last_error() + raise _win_error("Opening a handle to a terminal pump thread", error) + holder.value = HANDLE(handle) + + +def _job_active_processes(job: int) -> int: + """Members still running. Raises rather than reporting an empty job it never observed.""" + info = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION() + returned = DWORD(0) + ok = kernel32.QueryInformationJobObject( + job, + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION_CLASS, + ctypes.byref(info), + ctypes.sizeof(info), + ctypes.byref(returned), + ) + if not ok: + error = ctypes.get_last_error() + raise _win_error("Querying the job object for its remaining members", error) + return int(info.ActiveProcesses) + + +# ------------------------------------------------------------------ the session + + +class _PseudoconsoleInput(WriteChannel): + """`WriteFile` on the input pipe, cancelled through the writer's own thread handle.""" + + def __init__(self, session: "_SessionBundle") -> None: + self._session = session + + def write(self, data: bytes) -> int: + handle = self._session.in_w.handle() + if not handle: + raise BrokenPipeError("the terminal input channel is closed") + written = DWORD(0) + ok = kernel32.WriteFile(handle, data, len(data), ctypes.byref(written), None) + if not ok: + error = ctypes.get_last_error() + if error == ERROR_OPERATION_ABORTED: + raise WriteAborted("the terminal input write was cancelled") + raise _win_error("Writing to the script's terminal input", error) + return int(written.value) + + def cancel(self) -> None: + handle = self._session.writer_handle.handle() + if not handle: + return + ok = kernel32.CancelSynchronousIo(handle) + if not ok: + error = ctypes.get_last_error() + if error != ERROR_NOT_FOUND: # nothing was in flight; the next tick tries again + console.debug(f"cancelling the terminal input write reported Windows error {error}") + + +class _SessionBundle: + """Everything that lives as long as the session, plus the one ordered teardown. + + The teardown is invoked explicitly rather than registered as a stack callback: it is the + single step that can time out and hand its resources away, and a callback cannot do that + safely while `ExitStack.close()` is mid-unwind on the same stack. + """ + + def __init__(self, out_w: _Holder, in_pair: _PipePair, in_queue: InputQueue) -> None: + self.out_w = out_w + self.in_w = _Holder(pair=in_pair) + self.in_queue = in_queue + self.writer: Optional[InputWriter] = None + self.writer_handle = _Holder() # the same take-then-close discipline as every handle + self.reader: Optional[threading.Thread] = None + self.reader_handle = _Holder() # opened by the reader itself, for cancelling its read + self.hPC = HPCON() + self.hPC_valid = False # a failed HRESULT output is never closable + self.hJob: Optional[int] = None + self.job_array = (HANDLE * 1)() # must outlive the attribute list that points at it + self.proc = _ProcInfo() + self.exit_code: Optional[int] = None + # The first native failure any of the observation or teardown steps hit. A failed + # wait, exit-code read, job query or job termination cannot be recovered from and + # must not be read as "still running" or "shut down cleanly", so it is published + # here and surfaces on the environment channel. + self.failure: Optional[str] = None + self._lock = threading.Lock() + + # ------------------------------------------------------------- observation + + def record_failure(self, detail: str) -> None: + """Keeps the first failure: later ones are usually consequences of it.""" + if self.failure is None: + self.failure = detail + console.debug(f"terminal session failure: {detail}") + + def poll_exit_code(self) -> Optional[int]: + """Non-blocking exit status. `WaitForSingleObject` decides, so a target that exits + with 259 is not mistaken for one that is still running. + + Only `WAIT_TIMEOUT` means "still running". Every other non-signalled result, and a + failed exit-code read, is an infrastructure failure: reporting it as a running + process would turn it into a 124 timeout instead of a 69 environment error. + """ + failure = None + with self._lock: + if self.exit_code is not None: + return self.exit_code + handle = self.proc.process_handle() + if not handle: + return None + waited = kernel32.WaitForSingleObject(handle, 0) + if waited == WAIT_TIMEOUT: + return None + if waited != WAIT_OBJECT_0: + error = ctypes.get_last_error() + failure = f"waiting on the script's process reported result 0x{waited:08X} (Windows error {error})" + else: + code = DWORD(0) + if kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): + self.exit_code = int(code.value) + return self.exit_code + error = ctypes.get_last_error() + failure = f"reading the script's exit code failed: Windows error {error}" + self.record_failure(failure) # outside the lock: recording logs + return None + + def running(self) -> bool: + """False once the process handle has been released, whatever the target is doing: + nothing after that point may wait on it.""" + return self.proc.process_handle() is not None and self.poll_exit_code() is None + + # ---------------------------------------------------------------- teardown + + def teardown(self, grace: Optional[float]) -> bool: + """The ordered shutdown. True when it ran out of bound and must be handed off. + + Idempotent: every step takes what it releases, so a repeated call finds nothing left + to do. `grace` of None skips the graceful phase, which is what every forced path and + every rollback wants. + """ + self.poll_exit_code() # the only place a status is read; never after a forced kill + if grace is not None and self.running(): + self._graceful_phase(grace) + self._terminate_job() + if not self._await_job_empty(REAP_DEADLINE_SECONDS): + return True + if not self._stop_writer(): + # The writer is still inside a write on `in_w`, which teardown is about to + # close. Closing a handle underneath a blocked write is what the hand-off exists + # to avoid. + return True + # The job handle closes before the pseudoconsole, never after: ClosePseudoConsole() + # can block on builds before 24H2, and kill-on-job-close must not be waiting behind + # a call that might not return. + self._close_job() + self._close_pseudoconsole() + self._release_handles() + return False + + def _graceful_phase(self, grace: float) -> None: + """Delivery and grace are two different bounds: queue delay must not consume the + target's cleanup time.""" + writer = self.writer + if writer is None: + return + if not writer.deliver_control(CONTROL_C_BYTE, CONTROL_DELIVERY_DEADLINE_SECONDS): + return # undelivered: escalate now rather than waiting out a grace nobody received + deadline = time.monotonic() + grace # a fresh monotonic interval, started on delivery + while time.monotonic() < deadline: + if not self.running(): + return + time.sleep(GRACE_TICK_SECONDS) + + def _terminate_job(self) -> None: + job = self.hJob + if job is None: + return + if not kernel32.TerminateJobObject(job, JOB_TERMINATION_EXIT_CODE): + error = ctypes.get_last_error() + # The forced step of the shutdown: if it did not run, nothing else in this + # teardown can claim the tree is gone. + self.record_failure(f"terminating the script's job object failed: Windows error {error}") + + def _await_job_empty(self, bound: float) -> bool: + """Closes the process handles, then waits for the job's membership to reach zero.""" + self.proc.close_all() + job = self.hJob + if job is None: + return True + deadline = time.monotonic() + bound + while True: + try: + active = _job_active_processes(job) + except TerminalEnvironmentError as exc: + # Unanswerable rather than empty. Teardown continues — closing the job handle + # is still the kill-on-close backstop — but the run is an environment failure. + self.record_failure(str(exc)) + return True + if active == 0: + return True + if time.monotonic() >= deadline: + console.debug(f"the job object still held {active} processes after {bound}s") + return False + time.sleep(POLL_INTERVAL_SECONDS) + + def _stop_writer(self) -> bool: + writer = self.writer + if writer is None: + return True + return writer.stop(WRITER_JOIN_DEADLINE_SECONDS) + + def adopt_reader_thread(self) -> None: + """The reader opens a handle to itself, so a parked read can be cancelled later. + + Opened by the thread rather than derived from a recorded id at cancel time: a thread + id is recyclable the instant its thread exits, and this is the one moment the thread + is certainly alive. + """ + try: + _open_thread_handle(self.reader_handle, native_thread_id()) + except BaseException as exc: # cancellation degrades to the bounded join alone + console.debug(f"the terminal output reader could not open a handle to itself: {exc!r}") + + def join_reader(self, bound: float) -> bool: + """Joins the reader, cancelling its read if the output pipe never reached end-of-file. + + `ClosePseudoConsole()` does not always break a parked `ReadFile`: verified on Windows + Server 2022, where a session that never had a client left the reader waiting on a pipe + whose write end was gone. Cancellation is retried like the writer's, because a cancel + issued between two reads reaches nothing. True when the reader has finished. + """ + reader = self.reader + if reader is None or reader.ident is None: # None when it never started + return True + reader.join(timeout=bound) + deadline = time.monotonic() + bound + while reader.is_alive(): + self._cancel_reader() + reader.join(timeout=CANCEL_TICK_SECONDS) + if time.monotonic() >= deadline: + break + if reader.is_alive(): + return False + self.reader_handle.close_if_owned() # nothing can be cancelled through it any more + return True + + def _cancel_reader(self) -> None: + handle = self.reader_handle.handle() + if not handle: + return + if not kernel32.CancelSynchronousIo(handle): + error = ctypes.get_last_error() + if error != ERROR_NOT_FOUND: # nothing was in flight; the next tick tries again + console.debug(f"cancelling the terminal output read reported Windows error {error}") + + def _close_pseudoconsole(self) -> None: + """The precondition is the output pipe: drained *or* closed, never neither. + + One sequence serves both branches, which is why there is no test on the reader here: + the close happens while a live reader is still draining, and a reader that has + already failed closed the read handle before it published anything, so the same call + finds the pipe closed. The join only follows the close, never precedes it, and this + never runs on the reader thread. What the close does not guarantee is that the read + itself ends, which is why the join cancels it. + """ + if not self.hPC_valid: + return + self.hPC_valid = False + _close_handle(self.out_w.take()) # the write side must go, or the reader never sees EOF + kernel32.ClosePseudoConsole(self.hPC) + self.hPC = HPCON() + self.join_reader(DRAIN_DEADLINE_SECONDS) + + def _release_handles(self) -> None: + self.in_w.close_if_owned() # after the writer has stopped, never before + self.writer_handle.close_if_owned() + if not self.reader_alive(): # kept while a parked read may still need cancelling + self.reader_handle.close_if_owned() + # Belt and braces: the ordered teardown closed both of these earlier, and each + # takes what it releases, so this is a no-op there and a release on any path that + # reaches here without having got that far. + self._close_job() + self.proc.close_all() + + def _close_job(self) -> None: + job, self.hJob = self.hJob, None # taken before it is closed, like every other handle + _close_handle(job) # closing it is also the kill-on-close backstop + + def reader_alive(self) -> bool: + reader = self.reader + return reader is not None and reader.ident is not None and reader.is_alive() + + def release_abandoned(self) -> None: + """Last-resort release for a teardown nobody can finish. + + Reached only when the finalizer runs out of time or could not be started, which is + also when the process tree is most likely still alive — so the job goes first, and + `ClosePseudoConsole()` only after it. Reversed, a call that can block on builds before + 24H2 would stand between a failing teardown and the kill-on-job-close that is the + whole backstop. + + The writer is stopped here as well as in the ordered teardown, because this path is + reached when the teardown gave up before it got that far: an idle writer is parked on + its queue rather than inside a write, and the stop sentinel is the only thing that + ever releases it. `inputWriteSide` and the writer's thread handle are leaked only when + the stop itself runs out of bound — closing a handle underneath a blocked `WriteFile` + is the corruption the bounded teardown exists to avoid. + """ + self._terminate_job() + self.proc.close_all() + self._close_job() # kill-on-job-close fires before anything that can block + self._close_pseudoconsole() + if self._stop_writer(): + self.in_w.close_if_owned() + self.writer_handle.close_if_owned() + else: + console.debug("leaking the terminal input handles: the writer never returned") + + +class _SessionOwner: + """The session and its rollback stack, as one reference. + + `armed` is the commit flag: while it is set the stack alone owns everything and the + session is not yet a session. Storing the owner early is harmless for exactly that + reason, and the commit is the single flip. + """ + + def __init__(self, session: _SessionBundle, stack: ExitStack) -> None: + self.session = session + self.stack = stack + self.armed = True + + +def _unreleased_session_error(context: str, handed_off: bool) -> TerminalEnvironmentError: + """One message for both hand-off sites, saying which of the two things happened.""" + if handed_off: + return TerminalEnvironmentError( + f"The script's terminal session {context} and was handed to the background finalizer." + ) + return TerminalEnvironmentError( + f"The script's terminal session {context}, and no finalizer thread could be started for it: " + "it was released as far as it safely could be." + ) + + +def _hand_off_to_finalizer(owner: _SessionOwner) -> bool: + """Transfers the owner to a daemon finalizer. False when no thread could be started. + + The caller keeps the owner on a false result: dropping the only reference to a session + nobody has taken is how a job, a pseudoconsole and two handles survive until Codeplain + exits. + """ + thread = threading.Thread(target=_finalize_session, args=(owner,), name="codeplain-conpty-finalizer", daemon=True) + try: + thread.start() + except BaseException as exc: # thread exhaustion is the realistic one + console.debug(f"the terminal session finalizer could not be started: {exc!r}") + return False + return True + + +def _finalize_session(owner: _SessionOwner) -> None: + """Finishes a teardown that outlived the foreground's bound, then closes the stack. + + The stack is closed only once the session's own release has run, so there is exactly one + owner at every instant and the transfer never races an unwind in progress. + """ + deadline = time.monotonic() + FINALIZER_DEADLINE_SECONDS + try: + while True: + if not owner.session.teardown(None): + break + if time.monotonic() >= deadline: + console.debug("the terminal session finalizer gave up on an unfinished teardown") + break + time.sleep(FINALIZER_TICK_SECONDS) + except BaseException as exc: # nothing here can be reported anywhere useful + console.debug(f"the terminal session finalizer failed: {exc!r}") + finally: + try: + # The stack holds startup and pipe state only, so giving up on the teardown + # without this leaves the job, the pseudoconsole and the input handle behind — + # and with the job handle open, kill-on-job-close never fires. + owner.session.release_abandoned() + except BaseException as exc: + console.debug(f"the terminal session finalizer could not release the session: {exc!r}") + try: + owner.stack.close() + except BaseException as exc: + console.debug(f"the terminal session finalizer could not release its handles: {exc!r}") + + +# ------------------------------------------------------------------- the backend + + +class ConPtyProcess(TerminalProcess): + """One command, one pseudoconsole, one job, one reader thread, one writer thread.""" + + def __init__(self) -> None: + super().__init__() + + self._spawned = False + self._closed = False + self._owner: Optional[_SessionOwner] = None + self._writer: Optional[InputWriter] = None + self._input_queue = InputQueue() + + # The parser runs live in the reader, because terminals answer queries: a + # render-afterwards parser would leave a querying target hanging. + self.query_responder = TerminalQueryResponder(self._admit_reply) + self.normalizer = OutputNormalizer(reply_handler=self.query_responder.answer) + + # ---------------------------------------------------------------- public API + + def spawn( + self, + command: Sequence[str], + cwd: Optional[str] = None, + env: Optional[dict] = None, + terminal_size: Tuple[int, int] = (TERMINAL_COLUMNS, TERMINAL_ROWS), + stop_event: Optional[threading.Event] = None, + spawn_timeout: float = HANDSHAKE_TIMEOUT_SECONDS, + ) -> None: + if self._spawned: + raise RuntimeError("ConPtyProcess instances are single-use") + self._spawned = True + self._stop_event = stop_event if stop_event is not None else threading.Event() + self._check_cancelled() + _require_pseudoconsole_support() + columns, rows = terminal_size + self.normalizer.resize(columns, rows) + # Marshaling first: an input Windows cannot carry is rejected before anything is + # allocated, and long before there is a process to truncate a command line for. + command_line = build_command_line(command) + directory = validate_working_directory(cwd) + environment = build_environment_block(terminal_child_environment(env)) + self._start_session(command_line, directory, environment, columns, rows, time.monotonic() + spawn_timeout) + + def poll(self) -> Optional[int]: + owner = self._owner + if owner is None: + return None + code = owner.session.poll_exit_code() + if code is not None: + # The execution outcome is observed, so no client is left to answer. + self.query_responder.quiesce() + return code + + def write_input(self, data: bytes) -> InputWriteResult: + result, _ = self._input_queue.submit(data) + return result + + def resize(self, columns: int, rows: int) -> None: + """Not implemented yet: needs a ResizePseudoConsole binding on the live session. + + Raising keeps the failure explicit — a silent normalizer-only resize would tell + the caller the target saw a size it never received. + """ + raise TerminalProcessError("runtime terminal resize is not implemented on the ConPTY backend yet") + + def no_input_note(self) -> str: + return NO_INPUT_NOTE + + def infrastructure_failure(self) -> Optional[str]: + detail = super().infrastructure_failure() + if detail is not None: + return detail + writer = self._writer + if writer is not None and writer.failed.is_set(): + return f"the terminal input writer failed: {writer.exc!r}" + owner = self._owner + if owner is not None and owner.session.failure is not None: + # A native wait, exit-code read, job query or job termination that failed: the + # run cannot be described by an exit status nobody could read. + return owner.session.failure + return None + + def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: + """Graceful control byte, then the job. The grace period is never skipped silently: + an undelivered control byte escalates immediately, a delivered one is given its own + fresh interval.""" + self.query_responder.quiesce() + owner = self._owner + if owner is None: + return + self._shutdown(None if owner.session.poll_exit_code() is not None else grace) + + def close(self) -> None: + if self._closed: + return + self._closed = True + self.query_responder.quiesce() # before either input pump stops + try: + self._shutdown(None) + finally: + self.normalizer.finalize() + owner = self._owner + if owner is not None: + owner.stack.close() # only ever after the teardown has completed + owner.armed = False + # Joined after the stack close, because that is what releases the last write + # handle a reader parked on an early failure path is still waiting for. + if not owner.session.join_reader(DRAIN_DEADLINE_SECONDS): + self._publish_reader_stall() + + # ------------------------------------------------------------ spawn sequence + + def _start_session( + self, + command_line: str, + directory: Optional[str], + environment: str, + columns: int, + rows: int, + deadline: float, + ) -> None: + stack = ExitStack() # opens before the first allocation + in_pair, out_pair = _PipePair(), _PipePair() + in_r = _Holder(pair=in_pair) + out_w = _Holder(pair=out_pair) + attrs = _AttrList() + proc = _ProcInfo() + bundle = _ReaderHandles(out_pair) + session = _SessionBundle(out_w, in_pair, self._input_queue) + gate = threading.Event() + # Every owner is registered before the API that fills it, in reverse of unwind order. + for holder in (in_r, out_w): + stack.callback(holder.close_if_owned) + stack.callback(bundle.close_if_owner_is_parent) + stack.callback(attrs.dispose_if_owned) + # The session teardown is deliberately not a stack callback: it is the one step that + # can time out and transfer ownership, which a callback cannot do mid-unwind. + owner = _SessionOwner(session, stack) + self._owner = owner # stored early; harmless while armed + + try: + _create_pipe(in_r, session.in_w, in_pair) + _create_pipe(bundle, out_w, out_pair) + + self._start_reader(session, bundle, gate) + self._start_writer(session, deadline) + self._check_spawn_interrupted() + + _create_job(session) + job = session.hJob + assert job is not None # _create_job stores one or raises + _set_kill_on_job_close(job) + + in_read = in_r.handle() + out_write = out_w.handle() + assert in_read is not None and out_write is not None + _create_pseudoconsole(session, columns, rows, in_read, out_write) + + _initialize_attribute_list(attrs, PROC_THREAD_ATTRIBUTE_COUNT) + session.job_array[0] = session.hJob + _update_attribute( + attrs, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + session.hPC.value, + ctypes.sizeof(HPCON), + "pseudoconsole", + ) + _update_attribute( + attrs, + PROC_THREAD_ATTRIBUTE_JOB_LIST, + ctypes.addressof(session.job_array), + ctypes.sizeof(session.job_array), + "job list", + ) + + # The last gate before the target can run: a cancellation observed here must + # unwind rather than let the script execute its side effects. + self._check_spawn_interrupted() + session.proc = proc # attached before the call, as the reader is + _create_process(command_line, directory, environment, attrs, proc) + # A pump can die, and a render can be cancelled, during process creation — the + # slowest step here — so the check runs again on the other side of it. The job + # already holds the child, so this unwind needs no special case. + self._check_spawn_interrupted() + + # Documented timing: the pseudoconsole owns these two now, and holding + # outputWriteSide open means the reader never observes EOF. + _close_handle(in_r.take()) + _close_handle(out_w.take()) + attrs.dispose() # startup-only, retired after every check has passed + _close_handle(proc.take_thread()) + + owner.armed = False # the commit + except BaseException: + if session.teardown(None): # before any stack unwinding starts + raise _unreleased_session_error( + "could not be released within its bound", self._transfer_to_finalizer(owner) + ) + raise + finally: + if owner.armed: + stack.close() # releases exactly what never transferred + + def _start_reader(self, session: _SessionBundle, bundle: _ReaderHandles, gate: threading.Event) -> None: + """Starts before the pseudoconsole exists, because its teardown depends on a drainer. + + The thread is attached to the session before the commit, so every failure between + here and `CreateProcessW` unwinds with a reader the teardown can still join. + """ + reader = threading.Thread( + target=self._reader_main, args=(session, bundle, gate), name="codeplain-conpty-reader", daemon=True + ) + try: + session.reader = reader + reader.start() + bundle.owner = OWNER_READER # the commit: one assignment, nothing after it + finally: + gate.set() # always: an unopened gate parks the thread forever + + def _start_writer(self, session: _SessionBundle, deadline: float) -> None: + """Gate protocol: the writer publishes its native id and parks, the creator opens a + thread handle while the gate still holds it, and the gate is released with a decision + on every path.""" + writer = InputWriter(session.in_queue, _PseudoconsoleInput(session)) + session.writer = writer + self._writer = writer + decision = GateDecision.ABORT # initialized before any fallible step + try: + writer.start() + native_id = writer.await_ready(deadline, self._check_spawn_interrupted) + if native_id is None: + raise TerminalEnvironmentError( + f"The terminal input writer did not start: {writer.exc!r}" + if writer.failed.is_set() + else "The terminal input writer did not report itself before the spawn deadline." + ) + _open_thread_handle(session.writer_handle, native_id) + decision = GateDecision.RUN # only after the handle is stored + finally: + writer.gate.set(decision) # always: ABORT wakes the writer to exit untouched + + def _reader_main(self, session: _SessionBundle, bundle: _ReaderHandles, gate: threading.Event) -> None: + gate.wait() + if bundle.owner != OWNER_READER: + return # the parent still owns everything; touch nothing, publish nothing + session.adopt_reader_thread() # before the first read, so teardown can always cancel it + reader_exc: Optional[BaseException] = None + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + handle = bundle.out_r.value + try: + self._reader_loop(handle, decoder) + except BaseException as exc: # nothing here reaches threading.excepthook + reader_exc = exc + finally: + # Cleanup before publication: a rollback that sees the failure flag can rely on + # the read handle already being closed, which is the branch that makes + # ClosePseudoConsole() safe without a drainer. + _close_handle(bundle.take()) + try: + self._flush_decoder(decoder) + self.normalizer.finalize() + except BaseException as exc: # finalization can fail too + reader_exc = reader_exc or exc + finally: + self.reader_exc = reader_exc # stored while still unobservable + if reader_exc is not None: + self.reader_failed.set() + + def _reader_loop(self, handle: Optional[int], decoder) -> None: + if not handle: + return + buffer = ctypes.create_string_buffer(READ_CHUNK_BYTES) + read = DWORD(0) + while True: + ok = kernel32.ReadFile(handle, buffer, READ_CHUNK_BYTES, ctypes.byref(read), None) + if not ok: + error = ctypes.get_last_error() + if error in (ERROR_BROKEN_PIPE, ERROR_HANDLE_EOF, ERROR_OPERATION_ABORTED): + return # the pseudoconsole released its end + raise _win_error("Reading the script's terminal output", error) + if read.value == 0: + return + self._feed_output(buffer.raw[: read.value], decoder) + + # ------------------------------------------------------------------ internals + + def _admit_reply(self, payload: bytes, on_complete: Callable[[Optional[str]], None]) -> None: + """One non-blocking whole-item admission of a terminal reply, from the reader. + + Replies are ordinary data admissions. The reserve exists so the graceful control byte + can always be posted; a target that queries in a loop against a blocked input pipe + would otherwise fill the whole budget with replies and leave cancellation with nothing + but forced termination. A rejected reply is recorded as undelivered, which is the + outcome the responder exists to report. + """ + self._input_queue.submit(payload, on_resolve=reply_resolution(on_complete)) + + def _shutdown(self, grace: Optional[float]) -> None: + owner = self._owner + if owner is None: + return + if owner.session.teardown(grace): + raise _unreleased_session_error("did not shut down within its bound", self._transfer_to_finalizer(owner)) + + def _transfer_to_finalizer(self, owner: _SessionOwner) -> bool: + """Publishes the owner to the daemon finalizer, or keeps it here if none took it. + + Disarming comes first, because `finally` runs on this path too and must not unwind a + stack the finalizer now holds. Clearing `self._owner` comes last, and only once + another owner exists: a failed `Thread.start()` would otherwise drop the only + reference to a live job, pseudoconsole and input handle. + """ + owner.armed = False + if _hand_off_to_finalizer(owner): + self._owner = None + return True + # Nobody can finish this later, so release what is provably safe to release now and + # keep the rest recorded on the session that stays reachable from this backend. + owner.session.record_failure("no finalizer thread could be started for the terminal session") + owner.session.release_abandoned() + owner.stack.close() + return False + + def _check_pumps(self) -> None: + detail = self.infrastructure_failure() + if detail is not None: + raise TerminalEnvironmentError(f"The terminal backend failed while starting the script: {detail}") + + def _check_spawn_interrupted(self) -> None: + self._check_cancelled() + self._check_pumps() diff --git a/render_machine/_conpty_support.py b/render_machine/_conpty_support.py new file mode 100644 index 00000000..80e9e29d --- /dev/null +++ b/render_machine/_conpty_support.py @@ -0,0 +1,657 @@ +"""Platform-neutral parts of the Windows ConPTY backend. + +`_conpty.py` binds kernel32 at import time and can only be imported on Windows, so the +rules that need no Windows API live here instead: the marshaling `CreateProcessW` requires, +the bounded input queue, and the writer protocol that owns every write to the +pseudoconsole's input pipe. Splitting them out is what lets them run in the test suite on +every platform rather than only on a Windows runner. + +The input writer is a thread because the pseudoconsole's input pipe is an anonymous pipe, +and anonymous pipes are synchronous: a target that stops reading its input leaves +`WriteFile` blocked until somebody cancels it. Only this thread's handle is registered for +cancellation, so every producer — caller input, terminal-query replies, the graceful +control byte — enqueues a whole item here instead of writing to the pipe itself. +""" + +import collections +import subprocess +import threading +import time +from enum import Enum +from typing import Callable, Deque, List, Mapping, Optional, Sequence, Tuple + +from plain2code_console import console +from render_machine.terminal_process import ( + MAX_INPUT_ITEM_BYTES, + MAX_PENDING_INPUT_BYTES, + MAX_PENDING_INPUT_ITEMS, + RESERVED_INPUT_BYTES, + RESERVED_INPUT_ITEMS, + InputDisposition, + InputWriteResult, + TerminalEnvironmentError, +) +from render_machine.terminal_queries import ResolveCallback + +NUL = "\x00" + +# How often the foreground retries `CancelSynchronousIo()` while waiting for a control item +# or for the writer to join. A cancel issued before the writer has entered its write reports +# ERROR_NOT_FOUND and does nothing, so the call is a tick rather than a one-shot. +CANCEL_TICK_SECONDS = 0.02 + +# How long an idle writer parks on the queue before looking at its stopping flag again. +WRITER_IDLE_TICK_SECONDS = 0.05 + +# How long teardown waits for the writer to leave a synchronous write before the whole +# session is handed to the finalizer. +WRITER_JOIN_DEADLINE_SECONDS = 3.0 + +# A target that queries in a loop against a closed channel loses one item per query, so the +# loss is logged as a sample plus a count rather than once per item. +DROP_LOG_INTERVAL_SECONDS = 5.0 + + +# --------------------------------------------------------------------- marshaling + + +def _reject_nul(value: str, description: str) -> None: + """`CreateProcessW` takes NUL-terminated strings, so an embedded NUL truncates silently. + + `subprocess` performs this check for its callers; calling the API through ctypes bypasses + it, so it is re-established here rather than assumed. + """ + if NUL in value: + raise TerminalEnvironmentError(f"{description} contains a NUL character, which Windows cannot carry.") + + +def build_command_line(command: Sequence[str]) -> str: + """One command line quoted to the MS C runtime rules. + + `subprocess.list2cmdline()` rather than a second dialect, so a command spawned through + the ConPTY backend produces the same argv as the same command spawned through `Popen`. + """ + argv = list(command) + if not argv: + raise TerminalEnvironmentError("The command to run is empty.") + for index, argument in enumerate(argv): + _reject_nul(argument, f"Argument {index} of the command") + return subprocess.list2cmdline(argv) + + +def validate_working_directory(cwd: Optional[str]) -> Optional[str]: + if cwd is not None: + _reject_nul(cwd, "The working directory") + return cwd + + +def build_environment_block(env: Mapping[str, str]) -> str: + """`KEY=VALUE` entries, each NUL-terminated, sorted case-insensitively. + + The sort order is documented as a requirement of the environment block, not a + convention. The caller copies the result into a unicode buffer, whose own terminator + supplies the second NUL the block ends with. + """ + entries = [] + for name, value in sorted(env.items(), key=lambda item: item[0].upper()): + if not name: + raise TerminalEnvironmentError("An environment variable name is empty.") + if "=" in name: + # The block's own name/value separator: a name carrying one silently reshapes + # the block into different variables. + raise TerminalEnvironmentError(f"Environment variable name {name!r} contains '='.") + _reject_nul(name, f"Environment variable name {name!r}") + _reject_nul(value, f"The value of environment variable {name!r}") + entries.append(f"{name}={value}") + if not entries: + return NUL + return "".join(entry + NUL for entry in entries) + + +def native_thread_id() -> int: + """The kernel thread id `OpenThread` needs. + + `Thread.ident` is a Python-level cookie with no OS meaning, so it cannot be used here. + Wrapped in a function of its own so a failure before publication can be injected without + patching the threading module the test runner also uses. + """ + return threading.get_native_id() + + +# ------------------------------------------------------------------- input queue + + +class InputLane(Enum): + """Which lane an item is admitted to. Control items are serviced ahead of data.""" + + DATA = "data" + CONTROL = "control" + + +class Receipt: + """Resolution of one queued item. Resolved exactly once, by whoever retires it. + + `on_resolve` lets a producer observe that transition without ever waiting for it, which + is what the output reader needs when it is the producer. + """ + + def __init__(self, on_resolve: Optional[ResolveCallback] = None) -> None: + self._lock = threading.Lock() + self._event = threading.Event() + self.error: Optional[BaseException] = None + self.disposition: Optional[InputDisposition] = None + # Two counters, because they answer two different questions: how many resolutions + # took effect (never more than one), and how many were attempted (a caller retiring + # an item twice is a bug worth failing a test on). + self.resolutions = 0 + self.attempts = 0 + self._on_resolve = on_resolve + + def resolve(self, disposition: InputDisposition, error: Optional[BaseException] = None) -> None: + with self._lock: # the check and the set are one step, so two threads cannot both win + self.attempts += 1 + if self._event.is_set(): + return + self.disposition = disposition + self.error = error + self.resolutions += 1 + self._event.set() + callback = self._on_resolve + if callback is not None: # outside the lock: a callback must not be able to re-enter it + try: + callback(disposition, error) + except BaseException as exc: # a completion callback must never strand the queue + console.debug(f"input completion callback raised: {exc!r}") + + @property + def resolved(self) -> bool: + return self._event.is_set() + + @property + def delivered(self) -> bool: + return self.resolved and self.disposition is InputDisposition.ACCEPTED and self.error is None + + +class InputItem: + """One whole logical write, plus the cursor the writer keeps across partial writes. + + An urgent control item also carries the preemption generation it was posted under, so + the writer acknowledges at least that generation before it starts writing the item. + """ + + def __init__( + self, + data: bytes, + receipt: Receipt, + lane: InputLane, + sequence: int, + stop: bool = False, + generation: int = 0, + ) -> None: + self.data = data + self.receipt = receipt + self.lane = lane + self.sequence = sequence + self.stop = stop + self.generation = generation + self.cursor = 0 + + +class InputQueue: + """Bounded, byte-accounted queue with a reserved admission partition and a control lane. + + Dequeue is not completion: the item under the writer's cursor stays accounted for and + keeps its receipt attached until its last byte completes or teardown fails it, so + capacity is released exactly once at that terminal transition. + """ + + def __init__( + self, + max_item_bytes: int = MAX_INPUT_ITEM_BYTES, + max_pending_bytes: int = MAX_PENDING_INPUT_BYTES, + reserved_bytes: int = RESERVED_INPUT_BYTES, + max_pending_items: int = MAX_PENDING_INPUT_ITEMS, + reserved_items: int = RESERVED_INPUT_ITEMS, + ) -> None: + self._condition = threading.Condition() + self._data: Deque[InputItem] = collections.deque() + self._control: Deque[InputItem] = collections.deque() + self._current: Optional[InputItem] = None + self._pending_bytes = 0 + self._sequence = 0 + self._accepting = True + self._max_item_bytes = max_item_bytes + self._max_pending_bytes = max_pending_bytes + self._reserved_bytes = reserved_bytes + self._max_pending_items = max_pending_items + self._reserved_items = reserved_items + + def submit( + self, + data: bytes, + reserved: bool = False, + lane: InputLane = InputLane.DATA, + on_resolve: Optional[ResolveCallback] = None, + generation: int = 0, + ) -> Tuple[InputWriteResult, Receipt]: + """One non-blocking whole-item admission. Never waits, whoever the producer is.""" + receipt = Receipt(on_resolve) + size = len(data) + enqueued = False + with self._condition: + byte_budget, item_budget = self._budget(reserved) + queued = len(self._data) + len(self._control) + (0 if self._current is None else 1) + if not self._accepting: + result = InputWriteResult(InputDisposition.CLOSED, 0) + elif size == 0: + # Nothing to deliver, so it never becomes an entry: an empty item would grow + # the queue without ever touching the byte budget. + result = InputWriteResult(InputDisposition.ACCEPTED, 0) + elif size > self._max_item_bytes: + result = InputWriteResult(InputDisposition.BACKPRESSURE, 0) + elif self._pending_bytes + size > byte_budget or queued >= item_budget: + result = InputWriteResult(InputDisposition.BACKPRESSURE, 0) + else: + self._append(InputItem(bytes(data), receipt, lane, self._next_sequence(), generation=generation)) + self._pending_bytes += size + result = InputWriteResult(InputDisposition.ACCEPTED, size) + enqueued = True + if not enqueued: # nothing will retire it later, so it resolves here + receipt.resolve(result.disposition) + return result, receipt + + def post_stop(self) -> Receipt: + """Teardown's own sentinel. Admitted after the queue stops accepting producers.""" + receipt = Receipt() + with self._condition: + self._append(InputItem(b"", receipt, InputLane.CONTROL, self._next_sequence(), stop=True)) + return receipt + + def _next_sequence(self) -> int: + self._sequence += 1 + return self._sequence + + def _append(self, item: InputItem) -> None: + """Called under the condition. Appending is what wakes a parked writer.""" + if item.lane is InputLane.CONTROL: + self._control.append(item) + else: + self._data.append(item) + self._condition.notify_all() + + def _budget(self, reserved: bool) -> Tuple[int, int]: + if reserved: + return self._max_pending_bytes, self._max_pending_items + return self._max_pending_bytes - self._reserved_bytes, self._max_pending_items - self._reserved_items + + def next_item(self, timeout: float) -> Optional[InputItem]: + """The item under the cursor, waiting up to `timeout` for one to arrive. + + Control items are serviced ahead of data; order inside a lane is FIFO. + """ + with self._condition: + if self._current is None and not self._control and not self._data: + self._condition.wait(timeout) + if self._current is None: + if self._control: + self._current = self._control.popleft() + elif self._data: + self._current = self._data.popleft() + return self._current + + def current(self) -> Optional[InputItem]: + with self._condition: + return self._current + + def retire_current(self, delivered: bool, error: Optional[BaseException] = None) -> None: + """Releases the item's accounting once and resolves its receipt once.""" + with self._condition: + item = self._current + if item is None: + return + self._current = None + self._pending_bytes -= len(item.data) + item.receipt.resolve( + InputDisposition.ACCEPTED if delivered and error is None else InputDisposition.CLOSED, error + ) + + def requeue_current_front(self) -> None: + """Returns an untouched item to the head of its lane, accounting unchanged.""" + with self._condition: + item = self._current + if item is None: + return + self._current = None + if item.lane is InputLane.CONTROL: + self._control.appendleft(item) + else: + self._data.appendleft(item) + self._condition.notify_all() + + def stop_accepting(self) -> None: + with self._condition: + self._accepting = False + + def has_pending(self) -> bool: + with self._condition: + return self._current is not None or bool(self._control) or bool(self._data) + + def pending_bytes(self) -> int: + with self._condition: + return self._pending_bytes + + def pending_items(self) -> int: + with self._condition: + return len(self._data) + len(self._control) + (0 if self._current is None else 1) + + def discard_pending_data(self) -> List[InputItem]: + """Drops queued data items, resolving each receipt as not delivered. + + The item under the cursor is left alone: it may be inside a synchronous write, and + only the writer can retire it. + """ + with self._condition: + items = list(self._data) + self._data.clear() + for item in items: + self._pending_bytes -= len(item.data) + for item in items: + item.receipt.resolve(InputDisposition.CLOSED) + return items + + def close_and_fail_all(self, error: Optional[BaseException] = None) -> List[InputItem]: + with self._condition: + self._accepting = False + items = list(self._control) + list(self._data) + self._control.clear() + self._data.clear() + if self._current is not None: + items.append(self._current) + self._current = None + self._pending_bytes = 0 + for item in items: # callbacks run outside the lock and cannot re-enter the queue + try: + item.receipt.resolve(InputDisposition.CLOSED, error) + except BaseException as exc: # a receipt must never strand its siblings + console.debug(f"input receipt callback raised: {exc!r}") + return items + + +# ------------------------------------------------------------------- input writer + + +class WriteAborted(Exception): + """A synchronous write completed as cancelled. + + `WriteFile` initializes its byte count to zero and a cancelled completion carries no + trustworthy cursor, so the item it belonged to is retired rather than retried. + """ + + +class WriteChannel: + """The two native operations the writer performs, behind one seam. + + `cancel()` is issued from another thread against the writer's own thread handle, which + is why the writer never derives that handle itself. + """ + + def write(self, data: bytes) -> int: + raise NotImplementedError + + def cancel(self) -> None: + raise NotImplementedError + + +class GateDecision(Enum): + RUN = "run" + ABORT = "abort" + + +class DecisionGate: + """A gate carrying a decision, so a writer released without a stored cancel handle exits + instead of blocking in a write nothing can cancel.""" + + def __init__(self) -> None: + self._event = threading.Event() + self._decision = GateDecision.ABORT + + def set(self, decision: GateDecision) -> None: + self._decision = decision + self._event.set() + + def wait(self, timeout: Optional[float] = None) -> GateDecision: + self._event.wait(timeout) + return self._decision + + @property + def released(self) -> bool: + return self._event.is_set() + + +class _DropLog: + """Rate-limited loss reporting: a query storm must not turn the log into its own flood.""" + + def __init__(self, interval: float = DROP_LOG_INTERVAL_SECONDS) -> None: + self._interval = interval + self._lock = threading.Lock() + self._last = 0.0 + self.dropped = 0 + + def record(self, reason: str) -> None: + with self._lock: + self.dropped += 1 + now = time.monotonic() + if self._last and now - self._last < self._interval: + return + self._last = now + dropped = self.dropped + console.debug(f"terminal input item not delivered ({reason}); {dropped} lost so far") + + +class InputWriter: + """Sole owner of every write to the pseudoconsole's input pipe. + + Startup is a gate protocol: the thread publishes its native id and parks, the creator + opens a thread handle while the gate still holds it, stores the handle, and releases the + gate with `RUN`. Any failure in between releases the gate with `ABORT`, and a writer that + wakes to `ABORT` returns without touching the pipe. + """ + + def __init__(self, queue: InputQueue, channel: WriteChannel, name: str = "codeplain-conpty-writer") -> None: + self.queue = queue + self.channel = channel + self.ready = threading.Event() + self.finished = threading.Event() + self.gate = DecisionGate() + self.failed = threading.Event() + self.exc: Optional[BaseException] = None + self.native_id: Optional[int] = None + self.cancels = 0 + self.drops = _DropLog() + self._stopping = threading.Event() + self._lock = threading.Lock() # guards both generations, held across check and cancel + self._requested_generation = 0 + self._preempted_generation = 0 + self.thread = threading.Thread(target=self._run, name=name, daemon=True) + + # ------------------------------------------------------------ creator side + + def start(self) -> None: + self.thread.start() + + def started(self) -> bool: + return self.thread.ident is not None + + def await_ready(self, deadline: float, stop_check: Optional[Callable[[], None]] = None) -> Optional[int]: + """Waits for the writer to publish its native id or its failure, under a deadline. + + Returns the id, or None when the writer failed or the deadline expired. The wait is + bounded and abortable because a writer that dies before publishing must not park the + creator. `stop_check` runs at least once even when the writer is already ready, so a + cancellation set while it was starting is not skipped. + """ + while True: + if stop_check is not None: + stop_check() + if self.ready.is_set(): + return None if self.failed.is_set() else self.native_id + if time.monotonic() >= deadline: + return None + self.ready.wait(CANCEL_TICK_SECONDS) + + def deliver_control(self, data: bytes, deadline_seconds: float) -> bool: + """Posts an urgent control item, preempts any data write, and awaits its receipt. + + Reserved capacity buys admission, not service: a writer already blocked in a + synchronous data write never reaches the queue again on its own, so the in-flight + write is cancelled through the stored thread handle until the writer acknowledges + this generation. + + The generation is published under the same lock that makes the item visible. Bumping + it afterwards would let an idle writer dequeue the item, acknowledge the previous + generation and enter its control write before this thread starts cancelling — which + is exactly the wrong-call cancellation the lock exists to prevent. + """ + with self._lock: + self._requested_generation += 1 + generation = self._requested_generation + result, receipt = self.queue.submit(data, reserved=True, lane=InputLane.CONTROL, generation=generation) + if result.disposition is not InputDisposition.ACCEPTED: + # Nothing became visible, so the request is withdrawn rather than left for + # the writer to acknowledge against an item that does not exist. + self._requested_generation = generation - 1 + return False + deadline = time.monotonic() + deadline_seconds + while not receipt.resolved: + if time.monotonic() >= deadline: + return False + if self.finished.is_set() and not receipt.resolved: + break # a retired writer resolves every receipt, so this is a lost race, not a wait + with self._lock: + # The lock spans the check and the cancel, so a cancel can never land on the + # control write the acknowledgment has just cleared the way for. + if self._preempted_generation < generation: + self._cancel() + time.sleep(CANCEL_TICK_SECONDS) + return receipt.delivered + + def stop(self, bound_seconds: float = WRITER_JOIN_DEADLINE_SECONDS) -> bool: + """Sentinel, discard, retried cancel, bounded join. False when the writer is still in a write. + + An idle writer is parked on the queue rather than inside a write, so a cancel-only + loop would report ERROR_NOT_FOUND forever and never join it. + """ + self._stopping.set() + self.queue.stop_accepting() + self.queue.discard_pending_data() + self.queue.post_stop() + if not self.started(): + return True + if not self.gate.released: # an unreleased gate parks the writer forever + self.gate.set(GateDecision.ABORT) + deadline = time.monotonic() + bound_seconds + while True: + self.thread.join(CANCEL_TICK_SECONDS) + if not self.thread.is_alive(): + return True + if time.monotonic() >= deadline: + return False + with self._lock: + self._cancel() + + def _cancel(self) -> None: + """Called under the preemption lock, by whoever is waiting on the writer.""" + self.cancels += 1 + try: + self.channel.cancel() + except BaseException as exc: # cancellation is best effort; the bound decides the outcome + console.debug(f"cancelling the terminal input write raised: {exc!r}") + + # ------------------------------------------------------------- writer thread + + def _run(self) -> None: + try: + try: + self.native_id = native_thread_id() + finally: + # From the writer's own finally, so a writer that dies before publishing the + # id still releases the creator. + self.ready.set() + if self.gate.wait() is not GateDecision.RUN: + return + self._loop() + except BaseException as exc: # nothing here reaches threading.excepthook + self._publish(exc) + finally: + self.queue.close_and_fail_all() + self.finished.set() + + def _publish(self, exc: BaseException) -> None: + self.exc = exc # stored while still unobservable + self.failed.set() + + def _loop(self) -> None: + while True: + item = self.queue.next_item(WRITER_IDLE_TICK_SECONDS) + if item is None: + if self._stopping.is_set(): + return + continue + if item.stop: + self.queue.retire_current(delivered=True) + return + self._service(item) + if self._stopping.is_set(): + # Consulted before taking another item, so a cancelled write during teardown + # exits instead of consuming the backlog. + return + + def _service(self, item: InputItem) -> None: + if item.lane is InputLane.CONTROL: + # Published before the control write begins: it means "no earlier data I/O + # remains", and it is what stops the poster's cancel loop. The item's own + # generation is the floor, so the acknowledgment can never be older than the + # request that produced the item. + self._acknowledge_preemption(item.generation) + self._write_item(item, preemptible=False) + return + self._write_item(item, preemptible=True) + + def _write_item(self, item: InputItem, preemptible: bool) -> None: + while item.cursor < len(item.data): + if preemptible and self._control_pending(): + if item.cursor == 0: # nothing was written, so nothing can be lost or duplicated + self.queue.requeue_current_front() + else: + self._retire_undelivered(item, "preempted mid-item") + self._acknowledge_preemption() + return + try: + written = self.channel.write(item.data[item.cursor :]) + except WriteAborted: + expected = self._stopping.is_set() or self._control_pending() + self._retire_undelivered(item, "write cancelled") + self._acknowledge_preemption() + if not expected: + # A cancellation nobody asked for is a genuine writer failure; one the + # stop protocol or a preemption asked for is control flow. + raise + return + except BaseException as exc: + self.queue.retire_current(delivered=False, error=exc) + raise + item.cursor += written + self.queue.retire_current(delivered=True) + + def _retire_undelivered(self, item: InputItem, reason: str) -> None: + self.queue.retire_current(delivered=False) + if item.lane is InputLane.DATA: + self.drops.record(reason) + + def _control_pending(self) -> bool: + with self._lock: + return self._preempted_generation < self._requested_generation + + def _acknowledge_preemption(self, at_least: int = 0) -> None: + with self._lock: + self._preempted_generation = max(self._preempted_generation, self._requested_generation, at_least) diff --git a/render_machine/_legacy_pipe.py b/render_machine/_legacy_pipe.py new file mode 100644 index 00000000..566dba0b --- /dev/null +++ b/render_machine/_legacy_pipe.py @@ -0,0 +1,330 @@ +"""Legacy pipe backend for `TerminalProcess`. + +Wraps the `Popen(stdout=PIPE, stderr=STDOUT, start_new_session=True)` path Codeplain +shipped before the PTY, behind the same interface. It survives for one reason: it is the +`CODEPLAIN_NO_PTY` escape hatch, on POSIX and on Windows alike. Neither platform selects +it automatically. + +The child's stdin is `DEVNULL`, permanently and on every platform. A child without a +terminal of its own would otherwise inherit Codeplain's fd 0, and `start_new_session=True` +removes the controlling terminal whose absence makes the kernel permit the read instead of +stopping it — so the child would consume the user's keystrokes. Closing that hole is the +one thing this backend may never give back. + +On Windows the same hole needs a second lock, because redirecting the standard input +handle does not stop a child from opening `CONIN$`: that opens the console input buffer of +whatever console the child is attached to, which by default is the renderer's own. Only +detaching the child from that console closes it, so every Windows child is created with +`CREATE_NO_WINDOW`. + +There is no input channel, so `write_input()` accepts nothing and a query the target +prints is rendered without a reply: a backend that cannot answer must not register an +obligation it can only fail. +""" + +import codecs +import os +import signal +import subprocess +import sys +import threading +import time +from typing import Optional, Sequence, Tuple + +from plain2code_console import console +from plain2code_exceptions import RenderCancelledError +from render_machine.output_normalizer import OutputNormalizer +from render_machine.terminal_process import ( + DRAIN_DEADLINE_SECONDS, + GRACE_TICK_SECONDS, + READ_CHUNK_BYTES, + REAP_DEADLINE_SECONDS, + SIGTERM_GRACE_PERIOD_SECONDS, + TERMINAL_COLUMNS, + TERMINAL_ROWS, + InputDisposition, + InputWriteResult, + TerminalLaunchError, + TerminalProcess, + child_environment, +) +from render_machine.terminal_queries import TerminalQueryResponder + +if sys.platform == "linux": + import fcntl + +F_SETPIPE_SIZE = 1031 # Linux-only constant +PIPE_SIZE_KB = 1024 # 1MB + +# How long close() waits for the reader before it closes the pipe under it. A descendant +# that inherited the write end keeps the pipe open past the leader's exit. +CLOSE_JOIN_SECONDS = 1.0 + +# What one full teardown of this backend may spend, phase by phase and in sequence. A +# caller waiting on a render derives its own bound from this, so it cannot report a stuck +# teardown while the backend is still inside the budget its own constants grant it. +TEARDOWN_BUDGET_SECONDS = ( + SIGTERM_GRACE_PERIOD_SECONDS # terminate_tree(): the grace before the SIGKILL + + REAP_DEADLINE_SECONDS # terminate_tree(): reaping the killed process + + DRAIN_DEADLINE_SECONDS # close(): the first join, while the pipe is still open + + CLOSE_JOIN_SECONDS # close(): the second, after the pipe is closed under the reader +) + +# Windows gives a child the parent's console unless told otherwise, and a child on that +# console can read the renderer's keystrokes through CONIN$ regardless of where its +# standard input handle points. CREATE_NO_WINDOW gives it a console of its own instead. +if sys.platform == "win32": + CREATION_FLAGS = subprocess.CREATE_NO_WINDOW +else: + CREATION_FLAGS = 0 + + +class LegacyPipeProcess(TerminalProcess): + """One command, one pipe carrying its merged stdout and stderr, one reader thread.""" + + def __init__(self) -> None: + super().__init__() + # No admission callable: the responder starts QUIESCED, so an escape sequence the + # target prints is rendered and nothing is ever owed to it. + self.query_responder = TerminalQueryResponder() + # The parser still reports the queries it sees, so they are accounted for on the + # render-only side rather than silently dropped. A pipe has no line discipline to + # apply ONLCR, so the normalizer performs that translation itself — without it a + # stream of bare linefeeds renders as a whitespace staircase. + self.normalizer = OutputNormalizer(reply_handler=self.query_responder.answer, translate_newlines=True) + + self._proc: Optional[subprocess.Popen] = None + self._reader: Optional[threading.Thread] = None + self._spawned = False + self._closed = False + self._closing = threading.Event() + self._reaped = False + self._stdout_redirected = False + + # ---------------------------------------------------------------- public API + + def spawn( + self, + command: Sequence[str], + cwd: Optional[str] = None, + env: Optional[dict] = None, + terminal_size: Tuple[int, int] = (TERMINAL_COLUMNS, TERMINAL_ROWS), + stop_event: Optional[threading.Event] = None, + ) -> None: + if self._spawned: + raise RuntimeError("LegacyPipeProcess instances are single-use") + self._spawned = True + if stop_event is not None and stop_event.is_set(): + # A cancellation already observed must not start the target: the script would + # run its side effects before the wait loop could notice. + raise RenderCancelledError() + columns, rows = terminal_size + self.normalizer.resize(columns, rows) + try: + self._proc = subprocess.Popen( + list(command), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=cwd, + env=child_environment(env), + start_new_session=(sys.platform != "win32"), + creationflags=CREATION_FLAGS, + ) + except OSError as exc: + raise TerminalLaunchError(f"Could not start the script: {exc}") from exc + self._widen_pipe() + # Drain in a background thread: without continuous draining a script that + # outproduces the pipe buffer blocks on write and never exits. + self._reader = threading.Thread(target=self._reader_main, name="codeplain-pipe-reader", daemon=True) + self._reader.start() + + def poll(self) -> Optional[int]: + if self._proc is None: + return None + returncode = self._proc.poll() + if returncode is not None: + self._reaped = True + return returncode + + def write_input(self, data: bytes) -> InputWriteResult: + """Always closed: this backend hands the child `DEVNULL`, by design.""" + return InputWriteResult(InputDisposition.CLOSED, 0) + + def resize(self, columns: int, rows: int) -> None: + """There is no terminal to resize; only the rendering parser follows the size.""" + self.normalizer.resize(columns, rows) + + def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: + proc = self._proc + if proc is None or self._reaped: + return + if sys.platform == "win32": + self._terminate_windows(proc, grace) + return + # The leader is deliberately left unreaped until after the escalation: a zombie + # leader pins the group id against reuse, so the SIGKILL below can never reach a + # recycled group. The grace therefore watches the whole group, not the leader — + # members that outlive an instantly-dying leader get their grace too, and a group + # that empties within it is never SIGKILLed at all. + escalate = True + try: + try: + self._signal(proc, terminal=False) + deadline = time.monotonic() + grace + while time.monotonic() < deadline: + if self._group_spent(proc.pid): + escalate = False + break + time.sleep(GRACE_TICK_SECONDS) + finally: + if escalate: + self._signal(proc, terminal=True) + finally: + # Reaped in a finally of its own: an exception escaping the grace loop has + # already escalated above, and skipping the reap here would leave a zombie + # whose eventual collection unpins the group id mid-retry. + try: + proc.wait(timeout=REAP_DEADLINE_SECONDS) + except subprocess.TimeoutExpired: + console.debug(f"process {proc.pid} outlived the reap deadline") + else: + self._reaped = True + + def _terminate_windows(self, proc: subprocess.Popen, grace: float) -> None: + """TerminateProcess is already terminal, so there is nothing to escalate to.""" + self._signal_process(proc, terminal=False) + try: + proc.wait(timeout=grace + REAP_DEADLINE_SECONDS) + except subprocess.TimeoutExpired: + console.debug(f"process {proc.pid} outlived the reap deadline") + return + self._reaped = True + + def _group_spent(self, pgid: int) -> bool: + """True once the group has no live member left to signal. + + macOS reports a group whose remaining members are all zombies as EPERM rather + than ESRCH; both mean the grace has done its work. + """ + try: + os.killpg(pgid, 0) + except (ProcessLookupError, PermissionError): + return True + except OSError: + return False + return False + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._closing.set() # from here a failing read is expected closure, not a fault + stalled = False + if self._reader is not None and self._reader.ident is not None: + self._reader.join(timeout=DRAIN_DEADLINE_SECONDS) + if self._reader.is_alive(): + # A descendant is holding the write end open. The read end is redirected to + # devnull so any read that returns sees end-of-file; a read the kernel keeps + # parked past the second join is published as a stall below. + self._close_stdout() + self._reader.join(timeout=CLOSE_JOIN_SECONDS) + stalled = self._reader.is_alive() + self._close_stdout() + self.normalizer.finalize() + if stalled: + self._publish_reader_stall() + + # -------------------------------------------------------------------- internals + + def _widen_pipe(self) -> None: + """Best-effort 1MB pipe buffer, so bursts of output need fewer reader wakeups.""" + if sys.platform == "linux": + assert self._proc is not None and self._proc.stdout is not None + try: + fcntl.fcntl(self._proc.stdout.fileno(), F_SETPIPE_SIZE, PIPE_SIZE_KB * 1024) + except OSError as exc: # a lowered fs.pipe-max-size is not a launch failure + console.debug(f"could not widen the output pipe: {exc}") + + def _signal(self, proc: subprocess.Popen, terminal: bool) -> None: + """Signals the child's whole group, falling back to the child alone. + + `start_new_session` makes the child its own group leader, so the group id is the + child's pid itself — resolvable even after the leader has exited and been reaped, + which `os.getpgid()` on the pid no longer is. + """ + if sys.platform == "win32": + self._signal_process(proc, terminal) + return + try: + os.killpg(proc.pid, signal.SIGKILL if terminal else signal.SIGTERM) + except OSError: + self._signal_process(proc, terminal) + + def _signal_process(self, proc: subprocess.Popen, terminal: bool) -> None: + if terminal: + proc.kill() + else: + proc.terminate() + + def _close_stdout(self) -> None: + """Releases the read end without touching the buffered stream's lock. + + `BufferedReader.close()` takes the same internal lock the reader thread holds while + parked inside `read1()`, so a foreground close would deadlock exactly when the pipe + has to be broken. A raw `os.close()` would free the fd number for reuse under that + parked read instead. `os.dup2()` of devnull replaces the descriptor atomically: it + never blocks, never recycles the number, and a reader that wakes later reads + end-of-file. The buffered object itself is only closed once the reader is gone. + """ + proc = self._proc + if proc is None or proc.stdout is None: + return + stream = proc.stdout + if not self._stdout_redirected: + self._stdout_redirected = True + try: + devnull = os.open(os.devnull, os.O_RDONLY) + except OSError: + devnull = -1 + if devnull >= 0: + try: + os.dup2(devnull, stream.fileno()) + except (OSError, ValueError): + pass + finally: + os.close(devnull) + if (self._reader is None or not self._reader.is_alive()) and not stream.closed: + try: + stream.close() + except OSError: + pass + + def _reader_main(self) -> None: + assert self._proc is not None and self._proc.stdout is not None + stream = self._proc.stdout + reader_exc: Optional[BaseException] = None + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + try: + while True: + chunk = stream.read1(READ_CHUNK_BYTES) + if not chunk: + break + self._feed_output(chunk, decoder) + except (OSError, ValueError) as exc: + # Expected only once the pipe is gone: close() breaks a parked read by closing + # it underneath the reader. The same error while the backend is still active + # is an independent reader failure and has to be published like any other. + if not (self._closing.is_set() or stream.closed): + reader_exc = exc + except BaseException as exc: # nothing here reaches threading.excepthook + reader_exc = exc + finally: + try: + self._flush_decoder(decoder) + self.normalizer.finalize() + except BaseException as exc: + reader_exc = reader_exc or exc + self.reader_exc = reader_exc # stored while still unobservable + if reader_exc is not None: + self.reader_failed.set() diff --git a/render_machine/_posix_pty.py b/render_machine/_posix_pty.py new file mode 100644 index 00000000..875d6ed2 --- /dev/null +++ b/render_machine/_posix_pty.py @@ -0,0 +1,1079 @@ +"""POSIX PTY backend for `TerminalProcess`. + +One pseudoterminal backs the target's fds 0, 1 and 2. `spawn()` allocates it, launches +`pty_exec.py`, and completes a framed handshake that ends with an acknowledgment barrier: +the parent records the target's process group before the target is allowed to run, so +there is never a moment where a descendant exists that termination cannot reach. + +A single reader thread owns `master_fd` for its whole lifetime. It is the only code that +reads from, writes to, or changes the terminal mode of that descriptor; every producer of +input enqueues a whole logical item and rings a doorbell instead of borrowing the fd. +""" + +import codecs +import collections +import errno +import fcntl +import os +import select +import signal +import struct +import subprocess +import sys +import termios +import threading +import time +from typing import Callable, Deque, List, Optional, Sequence, Tuple + +from plain2code_console import console +from render_machine import pty_exec +from render_machine.output_normalizer import OutputNormalizer +from render_machine.terminal_process import ( + DRAIN_DEADLINE_SECONDS, + DRAIN_MAX_BYTES, + DRAIN_QUIET_PERIOD_SECONDS, + GRACE_TICK_SECONDS, + HANDSHAKE_TIMEOUT_SECONDS, + INPUT_WRITE_BUDGET_BYTES, + LAUNCHER_STDERR_CAP_BYTES, + MAX_INPUT_ITEM_BYTES, + MAX_PENDING_INPUT_BYTES, + MAX_PENDING_INPUT_ITEMS, + OWNER_PARENT, + OWNER_READER, + POLL_INTERVAL_SECONDS, + READ_CHUNK_BYTES, + REAP_DEADLINE_SECONDS, + RESERVED_INPUT_BYTES, + RESERVED_INPUT_ITEMS, + SIGTERM_GRACE_PERIOD_SECONDS, + TERMINAL_COLUMNS, + TERMINAL_ROWS, + InputDisposition, + InputWriteResult, + TerminalEnvironmentError, + TerminalLaunchError, + TerminalProcess, + TerminalProcessError, + TerminalReaderError, + terminal_child_environment, +) +from render_machine.terminal_queries import ResolveCallback, TerminalQueryResponder, reply_resolution + +if sys.platform == "win32": # pragma: no cover - the PTY backend is POSIX-only + raise ImportError("render_machine._posix_pty is POSIX-only") + +_LAUNCHER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pty_exec.py") + +# Grace given to a launcher that never reached the target. It has not exec'd and never +# forks, so termination is immediate and the full grace would only slow failures down. +ROLLBACK_GRACE_SECONDS = 0.1 + +# What one full teardown of this backend may spend, phase by phase and in sequence. A +# caller waiting on a render derives its own bound from this, so it cannot report a stuck +# teardown while the backend is still inside the budget its own constants grant it. +TEARDOWN_BUDGET_SECONDS = ( + SIGTERM_GRACE_PERIOD_SECONDS # terminate_tree(): the grace before the SIGKILL + + REAP_DEADLINE_SECONDS # terminate_tree(): reaping the killed group + + DRAIN_DEADLINE_SECONDS # close(): the reader's final drain + + REAP_DEADLINE_SECONDS # close(): the rest of the same reader join +) + + +class _ProtocolError(Exception): + """The launcher's status stream did not follow the handshake protocol.""" + + +def _close_quietly(fd: Optional[int]) -> None: + if fd is None: + return + try: + os.close(fd) + except OSError: + pass + + +def _signal_group(pgid: int, sig: int) -> bool: + """The only killpg site in this module. False once the group has nothing left to signal. + + ESRCH: the group is gone. + EPERM: verified on macOS — killpg() returns EPERM, not ESRCH, when the group's only + remaining member is our own unreaped zombie leader. That is the NORMAL state after a + graceful exit, so it must not raise. + + Both are terminal for the group, which is what lets signal 0 serve as a liveness probe + without a second killpg site. + """ + try: + os.killpg(pgid, sig) + except ProcessLookupError: # ESRCH — nothing left + return False + except PermissionError: # EPERM — zombie-only group + console.debug(f"killpg({pgid}, {sig}): EPERM, treating as terminal") + return False + return True + + +def _background_reap(proc: subprocess.Popen) -> None: + try: + proc.wait() + except BaseException: # nothing here can be reported anywhere useful + pass + + +def _reap(proc: subprocess.Popen, deadline_seconds: float) -> None: + """Bounded reap. SIGKILL is not instantaneous, so the foreground wait cannot be open-ended.""" + try: + proc.wait(timeout=deadline_seconds) + except subprocess.TimeoutExpired: + console.debug(f"process {proc.pid} outlived the reap deadline; reaping it in the background") + threading.Thread(target=_background_reap, args=(proc,), daemon=True).start() + + +class _Receipt: + """Resolution of one queued input item. Resolved exactly once, by whoever retires it. + + `on_resolve` lets a producer observe that terminal transition without ever waiting for + it, which is what the reader needs when it is the producer. + """ + + def __init__(self, on_resolve: Optional[ResolveCallback] = None) -> None: + self._event = threading.Event() + self.error: Optional[BaseException] = None + self.disposition: Optional[InputDisposition] = None + self.resolutions = 0 + self._on_resolve = on_resolve + + def resolve(self, disposition: InputDisposition, error: Optional[BaseException] = None) -> None: + self.resolutions += 1 + if self._event.is_set(): + return + self.disposition = disposition + self.error = error + self._event.set() + if self._on_resolve is not None: + try: + self._on_resolve(disposition, error) + except BaseException as exc: # a completion callback must never strand the queue + console.debug(f"input completion callback raised: {exc!r}") + + @property + def resolved(self) -> bool: + return self._event.is_set() + + +class _InputItem: + """One whole logical write, plus the optional transaction that must bracket it.""" + + def __init__( + self, + data: bytes, + receipt: _Receipt, + reserved: bool, + prepare: Optional[Callable[[], None]], + finish: Optional[Callable[[], None]], + sequence: int, + ) -> None: + self.data = data + self.receipt = receipt + self.reserved = reserved + self.prepare = prepare + self.finish = finish + self.sequence = sequence + self.cursor = 0 + self.prepared = False + + def finish_once(self) -> Optional[BaseException]: + """Closes the transaction the item opened, at most once, and never raises. + + Whoever retires the item runs it — the pump on completion, teardown on a close + that takes the item mid-flight — so a prepared item can never be dropped with the + terminal left in the mode `prepare` put it in. + """ + if not self.prepared or self.finish is None: + return None + self.prepared = False + try: + self.finish() + except BaseException as exc: + return exc + return None + + +class _InputQueue: + """Bounded, byte-accounted, ordered input queue. + + Admission, the accepting flag, byte accounting, and sequence assignment share one + lock. Dequeue is not completion: the item under the reader's cursor stays accounted + for and keeps its receipt attached until its last native byte completes or teardown + fails it, so capacity is released exactly once at that terminal transition. + """ + + def __init__( + self, + max_item_bytes: int = MAX_INPUT_ITEM_BYTES, + max_pending_bytes: int = MAX_PENDING_INPUT_BYTES, + reserved_bytes: int = RESERVED_INPUT_BYTES, + max_pending_items: int = MAX_PENDING_INPUT_ITEMS, + reserved_items: int = RESERVED_INPUT_ITEMS, + ) -> None: + self._lock = threading.Lock() + self._items: Deque[_InputItem] = collections.deque() + self._current: Optional[_InputItem] = None + self._pending_bytes = 0 + self._sequence = 0 + self._accepting = True + self._max_item_bytes = max_item_bytes + self._max_pending_bytes = max_pending_bytes + self._reserved_bytes = reserved_bytes + self._max_pending_items = max_pending_items + self._reserved_items = reserved_items + + def submit( + self, + data: bytes, + reserved: bool = False, + prepare: Optional[Callable[[], None]] = None, + finish: Optional[Callable[[], None]] = None, + on_resolve: Optional[ResolveCallback] = None, + ) -> Tuple[InputWriteResult, _Receipt]: + receipt = _Receipt(on_resolve) + size = len(data) # measured on the caller's view; nothing is copied until it is admitted + enqueued = False + with self._lock: + byte_budget, item_budget = self._budget(reserved) + queued = len(self._items) + (0 if self._current is None else 1) + if not self._accepting: + result = InputWriteResult(InputDisposition.CLOSED, 0) + elif size == 0: + # Nothing to deliver, so it never becomes an entry: an empty item would + # otherwise grow the queue without ever touching the byte budget. + result = InputWriteResult(InputDisposition.ACCEPTED, 0) + elif size > self._max_item_bytes: + result = InputWriteResult(InputDisposition.BACKPRESSURE, 0) + elif self._pending_bytes + size > byte_budget or queued >= item_budget: + result = InputWriteResult(InputDisposition.BACKPRESSURE, 0) + else: + self._sequence += 1 + self._items.append(_InputItem(bytes(data), receipt, reserved, prepare, finish, self._sequence)) + self._pending_bytes += size + result = InputWriteResult(InputDisposition.ACCEPTED, size) + enqueued = True + if not enqueued: # nothing will retire it later, so it resolves here + receipt.resolve(result.disposition) + return result, receipt + + def _budget(self, reserved: bool) -> Tuple[int, int]: + """Remaining admission budget in both dimensions, bytes first.""" + if reserved: + return self._max_pending_bytes, self._max_pending_items + return self._max_pending_bytes - self._reserved_bytes, self._max_pending_items - self._reserved_items + + def has_pending(self) -> bool: + with self._lock: + return self._current is not None or bool(self._items) + + def pending_bytes(self) -> int: + with self._lock: + return self._pending_bytes + + def pending_items(self) -> int: + with self._lock: + return len(self._items) + (0 if self._current is None else 1) + + def current(self) -> Optional[_InputItem]: + """The item under the cursor, promoting the next waiting item when there is none.""" + with self._lock: + if self._current is None and self._items: + self._current = self._items.popleft() + return self._current + + def complete_current(self, error: Optional[BaseException] = None) -> None: + with self._lock: + item = self._current + if item is None: + return + self._current = None + self._pending_bytes -= len(item.data) + item.receipt.resolve(InputDisposition.CLOSED if error is not None else InputDisposition.ACCEPTED, error) + + def stop_accepting(self, closing: threading.Event) -> None: + """Marks the queue non-accepting and signals shutdown under the same lock. + + No producer can then enqueue behind the reader's fail_all(). + """ + with self._lock: + self._accepting = False + closing.set() + + def close_and_fail_all(self, error: Optional[BaseException] = None) -> List[_InputItem]: + with self._lock: + self._accepting = False + items = list(self._items) + self._items.clear() + if self._current is not None: + items.append(self._current) + self._current = None + self._pending_bytes = 0 + for item in items: # callbacks run outside the lock and cannot re-enter the queue + # The in-flight item may hold an open transaction; closing it is teardown's + # job now, and it happens before the receipt reports the item retired. + finish_error = item.finish_once() + try: + item.receipt.resolve(InputDisposition.CLOSED, error or finish_error) + except BaseException as exc: # a receipt must never strand its siblings + console.debug(f"input receipt callback raised: {exc!r}") + return items + + +class _ReaderBundle: + """The descriptors whose ownership moves from the parent to the reader in one step. + + `owner` is the single field that decides. Rollback and reader consult it, so they can + never disagree and there is no state in which a descriptor has left one owner without + reaching the other. + """ + + def __init__(self, master_fd: int, wakeup_r: int, err_w: int) -> None: + self.owner = OWNER_PARENT + self.master_fd: Optional[int] = master_fd + self.wakeup_r: Optional[int] = wakeup_r + self.err_w: Optional[int] = err_w + self._lock = threading.Lock() + + def _take(self, name: str) -> Optional[int]: + with self._lock: # swap first, close only what the swap returned + fd = getattr(self, name) + setattr(self, name, None) + return fd + + def take_master(self) -> Optional[int]: + return self._take("master_fd") + + def with_master(self, operation) -> bool: + """Runs `operation(master_fd)` while the descriptor cannot be taken from under it. + + The lock is the same one `_take` swaps under, so the descriptor is either still + owned for the whole call or the call never starts. False when it is already gone. + """ + with self._lock: + if self.master_fd is None: + return False + operation(self.master_fd) + return True + + def take_wakeup_r(self) -> Optional[int]: + return self._take("wakeup_r") + + def take_err_w(self) -> Optional[int]: + return self._take("err_w") + + def close_all(self) -> None: + for name in ("master_fd", "wakeup_r", "err_w"): + _close_quietly(self._take(name)) + + +class _CappedDiagnostic: + """Keeps the head and the tail of a stream while the middle keeps being discarded.""" + + def __init__(self, cap: int = LAUNCHER_STDERR_CAP_BYTES) -> None: + self._cap = cap + self._head = bytearray() + self._tail = bytearray() + self.total = 0 + + def feed(self, chunk: bytes) -> None: + self.total += len(chunk) + if len(self._head) < self._cap: + room = self._cap - len(self._head) + self._head += chunk[:room] + chunk = chunk[room:] + if chunk: + self._tail += chunk + del self._tail[: max(0, len(self._tail) - self._cap)] + + def text(self) -> str: + head = bytes(self._head).decode("utf-8", "replace") + if not self._tail: + return head + omitted = self.total - len(self._head) - len(self._tail) + return f"{head}\n...[{omitted} bytes omitted]...\n" + bytes(self._tail).decode("utf-8", "replace") + + +class _HandshakeParser: + """Strict bounded state machine over the launcher's framed status records. + + Accepts exactly STARTED -> SESSION_READY -> EOF as success. Everything else — unknown + kinds, duplicate or out-of-order markers, a marker carrying a payload, a declared + length above the cap, a truncated record at EOF, trailing bytes after FAILED — is a + protocol failure on the environment-error channel. + """ + + def __init__(self) -> None: + self._buffer = bytearray() + self.started = False + self.session_ready = False + self.failure_payload: Optional[bytes] = None + + def feed(self, chunk: bytes) -> None: + self._buffer += chunk + while True: + if self.failure_payload is not None: + if self._buffer: + raise _ProtocolError("the launcher wrote trailing bytes after its failure record") + return + if len(self._buffer) < pty_exec.HEADER_SIZE: + return + kind = self._buffer[0] + length = int.from_bytes(bytes(self._buffer[1:5]), "big") + self._validate_header(kind, length) + if len(self._buffer) < pty_exec.HEADER_SIZE + length: + return + payload = bytes(self._buffer[pty_exec.HEADER_SIZE : pty_exec.HEADER_SIZE + length]) + del self._buffer[: pty_exec.HEADER_SIZE + length] + self._accept(kind, payload) + + def _validate_header(self, kind: int, length: int) -> None: + if kind not in (pty_exec.STARTED, pty_exec.SESSION_READY, pty_exec.FAILED): + raise _ProtocolError(f"unknown handshake record type 0x{kind:02x}") + if length > pty_exec.MAX_PAYLOAD: # rejected before allocating or waiting for a body + raise _ProtocolError(f"handshake record declares {length} bytes, above the {pty_exec.MAX_PAYLOAD} cap") + if kind != pty_exec.FAILED and length: + raise _ProtocolError("a handshake marker record must carry no payload") + + def _accept(self, kind: int, payload: bytes) -> None: + if kind == pty_exec.STARTED: + if self.started: + raise _ProtocolError("duplicate STARTED record") + self.started = True + elif kind == pty_exec.SESSION_READY: + if not self.started or self.session_ready: + raise _ProtocolError("out-of-order SESSION_READY record") + self.session_ready = True + else: + if not self.started: + raise _ProtocolError("FAILED record before STARTED") + self.failure_payload = payload + + def eof(self) -> None: + if self._buffer: + raise _ProtocolError("the launcher's status stream ended mid-record") + if not self.started: + raise _ProtocolError("the interpreter died before running the launcher") + if not self.session_ready: + raise _ProtocolError("the launcher exited after STARTED without a ready session") + + +class PosixPtyProcess(TerminalProcess): + """One command, one pseudoterminal, one reader thread.""" + + def __init__(self) -> None: + super().__init__() + + self._proc: Optional[subprocess.Popen] = None + self._pgid: Optional[int] = None + self._reaped = False + self._spawned = False + self._closed = False + self._acked = False + + self._bundle: Optional[_ReaderBundle] = None + self._reader: Optional[threading.Thread] = None + self._gate = threading.Event() + self._closing = threading.Event() + self._input_queue = _InputQueue() + self._drain_deadline: Optional[float] = None + self._veof_byte = b"\x04" + self._veof_saved: Optional[list] = None + + self._fd_lock = threading.Lock() + self._pending_master_fd: Optional[int] = None + self._pending_slave_fd: Optional[int] = None + self._child_fds: Tuple[int, ...] = () + self._wakeup_w: Optional[int] = None + self._err_r: Optional[int] = None + self._status_r: Optional[int] = None + self._ack_w: Optional[int] = None + + self.launcher_stderr = _CappedDiagnostic() + + # The parser runs live in the reader, because terminals answer queries: a + # render-afterwards parser would leave a querying target hanging. + self.query_responder = TerminalQueryResponder(self._admit_reply) + self.normalizer = OutputNormalizer(reply_handler=self.query_responder.answer) + + # ---------------------------------------------------------------- public API + + def spawn( + self, + command: Sequence[str], + cwd: Optional[str] = None, + env: Optional[dict] = None, + terminal_size: Tuple[int, int] = (TERMINAL_COLUMNS, TERMINAL_ROWS), + stop_event: Optional[threading.Event] = None, + handshake_timeout: float = HANDSHAKE_TIMEOUT_SECONDS, + ) -> None: + """Allocates the terminal, launches the target, and returns once it is running.""" + if self._spawned: + raise RuntimeError("PosixPtyProcess instances are single-use") + self._spawned = True + self._stop_event = stop_event if stop_event is not None else threading.Event() + deadline = time.monotonic() + handshake_timeout + try: + self._check_cancelled() + self._open_terminal(terminal_size) + self._open_channels() + self._start_child(command, cwd, env) + self._hand_over_to_reader() + self._run_handshake(deadline) + self._close_owned("_status_r") # the handshake has resolved + except BaseException: + self._rollback() + raise + + def poll(self) -> Optional[int]: + if self._proc is None: + return None + returncode = self._proc.poll() + if returncode is not None: + # Popen.poll() reaps, so the pgid may now be recycled; no group signal is + # ever sent again. + self._reaped = True + # The execution outcome is observed, so no client is left to answer. + self.query_responder.quiesce() + return returncode + + def write_input(self, data: bytes) -> InputWriteResult: + result, _ = self._input_queue.submit(data) + if result.disposition is InputDisposition.ACCEPTED: + self._ring_doorbell() + return result + + def resize(self, columns: int, rows: int) -> None: + """Applies the new size on the master, which also raises SIGWINCH in the target. + + Issued under the bundle's ownership lock, so the ioctl can never race the reader + closing the descriptor at teardown. + """ + packed = struct.pack("HHHH", rows, columns, 0, 0) + bundle = self._bundle + applied = bundle is not None and bundle.with_master(lambda fd: fcntl.ioctl(fd, termios.TIOCSWINSZ, packed)) + if not applied: + raise TerminalProcessError("the terminal is no longer available to resize") + self.normalizer.resize(columns, rows) + + def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: + """Signals the recorded group, escalates on the clock, and reaps last. + + A reader failure observed here is recorded by the reader and deliberately not + acted on: returning early would skip the SIGKILL escalation the sequence exists + for. The caller inspects `reader_failed` afterwards. + """ + self.query_responder.quiesce() + proc = self._proc + if proc is None or self._reaped: + return + pgid = self._pgid + try: + try: + self._deliver(proc, pgid, signal.SIGTERM) + self._deliver(proc, pgid, signal.SIGCONT) + deadline = time.monotonic() + grace # independent clock — NOT stop_event + while time.monotonic() < deadline: # never waits on the leader either + if self._group_spent(pgid): + break # the tree handled the SIGTERM; the escalation still follows + self._grace_tick() + finally: + # Unconditional: an interruption mid-grace must still escalate. + self._deliver(proc, pgid, signal.SIGKILL) + finally: + _reap(proc, REAP_DEADLINE_SECONDS) + self._reaped = True + + def close(self) -> None: + if self._closed: + return + self._closed = True + self.query_responder.quiesce() # before either input pump stops + self._drain_deadline = time.monotonic() + DRAIN_DEADLINE_SECONDS + self._input_queue.stop_accepting(self._closing) + self._ring_doorbell() + stalled = False + if self._reader is not None and self._reader.ident is not None: # None when it never started + self._reader.join(timeout=DRAIN_DEADLINE_SECONDS + REAP_DEADLINE_SECONDS) + stalled = self._reader.is_alive() + self._close_owned("_wakeup_w") + self._close_owned("_err_r") + self._close_owned("_status_r") + self._close_owned("_ack_w") + if self._proc is not None and self._proc.stderr is not None: + self._proc.stderr.close() + if self._bundle is not None and self._bundle.owner == OWNER_PARENT: + self._bundle.close_all() # no reader ever took them + if stalled: # every handle this side owns is released first + self._publish_reader_stall() + + # ------------------------------------------------------------- spawn helpers + + def _open_terminal(self, terminal_size: Tuple[int, int]) -> None: + try: + master_fd, slave_fd = os.openpty() + except OSError as exc: + raise TerminalEnvironmentError(f"Could not allocate a pseudoterminal: {exc}") from exc + try: + columns, rows = terminal_size + self.normalizer.resize(columns, rows) + fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, columns, 0, 0)) + self._configure_slave(slave_fd) + os.set_blocking(master_fd, False) + except BaseException: + _close_quietly(master_fd) + _close_quietly(slave_fd) + raise + self._pending_master_fd = master_fd + self._pending_slave_fd = slave_fd + + def _configure_slave(self, slave_fd: int) -> None: + """Sane termios with echo on. ONLCR is left at its default: Option A means real + terminal semantics, and the \\r\\n is dealt with in normalization.""" + attrs = termios.tcgetattr(slave_fd) + attrs[0] |= termios.ICRNL + attrs[1] |= termios.OPOST | termios.ONLCR + attrs[3] |= termios.ICANON | termios.ISIG | termios.ECHO | termios.IEXTEN + termios.tcsetattr(slave_fd, termios.TCSANOW, attrs) + self._veof_byte = bytes([attrs[6][termios.VEOF][0]]) + + def _open_channels(self) -> None: + """Pre-registers every parent-side owner before the API that fills it. + + Nothing is published until every descriptor and both objects exist, so a failure + part-way through closes exactly what it opened and reports on the environment + channel rather than leaking ownerless descriptors behind a raw OSError. + """ + opened: List[int] = [] + + def pipe() -> Tuple[int, int]: + read_fd, write_fd = os.pipe() + opened.extend((read_fd, write_fd)) + return read_fd, write_fd + + try: + status_r, status_w = pipe() + ack_r, ack_w = pipe() + wakeup_r, wakeup_w = pipe() + err_r, err_w = pipe() + os.set_blocking(wakeup_r, False) + os.set_blocking(wakeup_w, False) + master_fd = self._pending_master_fd + assert master_fd is not None + bundle = _ReaderBundle(master_fd, wakeup_r, err_w) + reader = threading.Thread(target=self._reader_main, name="codeplain-pty-reader", daemon=True) + except BaseException as exc: + for fd in opened: + _close_quietly(fd) + if isinstance(exc, (OSError, RuntimeError)): # the terminal's own resources ran out + raise TerminalEnvironmentError(f"Could not open the terminal's control channels: {exc}") from exc + raise + + self._status_r = status_r + self._ack_w = ack_w + self._wakeup_w = wakeup_w + self._err_r = err_r + self._pending_master_fd = None # the bundle owns it from here + self._bundle = bundle + self._reader = reader + self._child_fds = (status_w, ack_r) + + def _start_child(self, command: Sequence[str], cwd: Optional[str], env: Optional[dict]) -> None: + status_w, ack_r = self._child_fds + slave_fd = self._pending_slave_fd + assert slave_fd is not None + argv = [ + sys.executable, + "-I", # isolated: no PYTHONPATH, no user site + "-S", # no site processing, so no sitecustomize and no .pth can fork before STARTED + _LAUNCHER, + str(slave_fd), + str(status_w), + str(ack_r), + "--", + *command, + ] + try: + self._proc = subprocess.Popen( + argv, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + pass_fds=(slave_fd, status_w, ack_r), + close_fds=True, + cwd=cwd, + env=terminal_child_environment(env), + ) + except OSError as exc: + raise TerminalEnvironmentError(f"Could not start the terminal launcher: {exc}") from exc + finally: + # Correctness for the first two: holding them means the master never reaches + # EOF and the launcher's exec is never observable. + for fd in (slave_fd, status_w, ack_r): + _close_quietly(fd) + self._child_fds = () + self._pending_slave_fd = None + + def _hand_over_to_reader(self) -> None: + """Starts the gated reader and commits ownership in a single field assignment.""" + assert self._bundle is not None and self._reader is not None + try: + self._reader.start() + self._bundle.owner = OWNER_READER + finally: + self._gate.set() # an unreleased gate is unrecoverable, so this is never conditional + self._check_reader_failed() + + # ---------------------------------------------------------------- handshake + + def _run_handshake(self, deadline: float) -> None: + parser = _HandshakeParser() + assert self._proc is not None and self._proc.stderr is not None + status_r, err_r = self._status_r, self._err_r + assert status_r is not None and err_r is not None + stderr_fd = self._proc.stderr.fileno() + watched = {status_r, err_r, stderr_fd} + while True: + self._check_cancelled() + self._check_reader_failed() + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TerminalLaunchError(self._launch_message("the launcher hung before exec")) + readable, _, _ = select.select(sorted(watched), [], [], min(remaining, POLL_INTERVAL_SECONDS)) + if stderr_fd in readable and not self._drain_launcher_stderr(stderr_fd): + watched.discard(stderr_fd) + if err_r in readable: + self._consume_reader_edge(watched, err_r) + if status_r in readable and self._advance_handshake(parser, status_r, deadline): + return + + def _advance_handshake(self, parser: _HandshakeParser, status_r: int, deadline: float) -> bool: + """Feeds one status chunk. Returns True once exec has been observed.""" + chunk = os.read(status_r, READ_CHUNK_BYTES) + try: + if not chunk: + parser.eof() + return True + parser.feed(chunk) + except _ProtocolError as exc: + raise TerminalLaunchError(self._launch_message(str(exc))) from exc + if parser.failure_payload is not None: + reason = parser.failure_payload.decode("utf-8", "replace") + raise TerminalLaunchError(self._launch_message(f"the launcher failed: {reason}")) + if parser.session_ready and not self._acked: + self._acknowledge(deadline) + return False + + def _acknowledge(self, deadline: float) -> None: + """Records the group, delivers the VEOF, and only then releases the target.""" + assert self._proc is not None + self._pgid = self._proc.pid # recorded BEFORE the target can run + self._inject_veof(deadline) + self._pre_ack_hook() + self._acked = True + ack_w = self._ack_w + assert ack_w is not None + try: + os.write(ack_w, b"\x01") + except BrokenPipeError: + # The launcher gave up first and its own reason is already on the status + # pipe; recovery continues under the same deadline, never a fresh budget. + console.debug("the launcher closed the acknowledgment pipe before the parent acknowledged") + self._close_owned("_ack_w") + + def _pre_ack_hook(self) -> None: + """The window between the recorded group and the acknowledgment that releases the + target. Empty in production; a test overrides it to hold the window open.""" + + def _inject_veof(self, deadline: float) -> None: + result, receipt = self._input_queue.submit( + self._veof_byte, reserved=True, prepare=self._veof_prepare, finish=self._veof_restore + ) + if result.disposition is not InputDisposition.ACCEPTED: + raise TerminalEnvironmentError(f"The spawn-time EOF was not admitted: {result.disposition.value}") + self._ring_doorbell() + self._await_receipt(receipt, deadline) + + def _await_receipt(self, receipt: _Receipt, deadline: float) -> None: + while not receipt.resolved: + self._check_cancelled() + self._check_reader_failed() + if time.monotonic() >= deadline: + raise TerminalEnvironmentError("The spawn-time EOF was not delivered before the handshake deadline") + time.sleep(POLL_INTERVAL_SECONDS / 4) + if receipt.error is not None: + raise TerminalEnvironmentError(f"The spawn-time EOF failed: {receipt.error!r}") from receipt.error + if receipt.disposition is not InputDisposition.ACCEPTED: + # Teardown resolves receipts before it publishes the reader's failure, so a + # discarded item usually means the reader died; let it publish, then classify. + if self._reader is not None and self._reader.ident is not None: + self._reader.join(timeout=POLL_INTERVAL_SECONDS * 4) + self._check_reader_failed() + raise TerminalEnvironmentError("The spawn-time EOF was discarded before delivery") + + def _drain_launcher_stderr(self, stderr_fd: int) -> bool: + """Keeps the launcher from blocking on a full stderr pipe. False once it is at EOF.""" + chunk = os.read(stderr_fd, READ_CHUNK_BYTES) + if not chunk: + return False + self.launcher_stderr.feed(chunk) # reads continue after the cap; only retention stops + return True + + def _consume_reader_edge(self, watched: set, err_r: int) -> None: + """A readable err_r means 'consult reader_failed', not 'the reader failed'.""" + try: + os.read(err_r, READ_CHUNK_BYTES) + except OSError: + pass + self._check_reader_failed() + watched.discard(err_r) # EOF is level-triggered and permanent + self._close_owned("_err_r") # ownership transfer, so it needs the swap + + def _launch_message(self, reason: str) -> str: + diagnostic = self.launcher_stderr.text() + if diagnostic: + return f"{reason}. Launcher output:\n{diagnostic}" + return f"{reason}." + + def _check_reader_failed(self) -> None: + if self.reader_failed.is_set(): + raise TerminalReaderError(f"The terminal output reader failed: {self.reader_exc!r}") + + # ------------------------------------------------------------------- reader + + def _reader_main(self) -> None: + self._gate.wait() + assert self._bundle is not None + if self._bundle.owner != OWNER_READER: + return # the parent still owns everything; touch nothing, publish nothing + reader_exc: Optional[BaseException] = None + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + try: + self._reader_loop(decoder) + except BaseException as exc: # nothing reaches threading.excepthook + reader_exc = exc + finally: + try: + self._input_queue.close_and_fail_all() + for fd in (self._bundle.take_master(), self._bundle.take_wakeup_r()): + _close_quietly(fd) # independent: one failing close cannot skip the rest + self._flush_decoder(decoder) + self.normalizer.finalize() # the same end-of-stream flush, on the rendered channel + except BaseException as exc: # finalization can fail too + reader_exc = reader_exc or exc + finally: + self.reader_exc = reader_exc # stored while still unobservable + if reader_exc is not None: + self.reader_failed.set() + # LAST — the single edge that publishes "the reader is done and owns nothing" + _close_quietly(self._bundle.take_err_w()) + + def _reader_loop(self, decoder) -> None: + assert self._bundle is not None + master_fd = self._bundle.master_fd + wakeup_r = self._bundle.wakeup_r + assert master_fd is not None and wakeup_r is not None + while True: + want_write = [master_fd] if self._input_queue.has_pending() else [] + readable, writable = self._select([master_fd, wakeup_r], want_write, POLL_INTERVAL_SECONDS) + if wakeup_r in readable: + _drain_doorbell(wakeup_r) # bytes coalesce; state carries the meaning + if self._closing.is_set(): + self._input_queue.close_and_fail_all() + self._drain_remaining(master_fd, decoder) + return + if master_fd in readable and not self._read_once(master_fd, decoder): + return # output always wins over queued input + if master_fd in writable or self._input_queue.has_pending(): + self._flush_input(master_fd, INPUT_WRITE_BUDGET_BYTES) + + def _select(self, rlist, wlist, timeout): + readable, writable, _ = select.select(rlist, wlist, [], timeout) + return readable, writable + + def _read_master(self, fd: int, size: int) -> bytes: + return os.read(fd, size) + + def _write_master(self, fd: int, data: bytes) -> int: + return os.write(fd, data) + + def _read_once(self, master_fd: int, decoder) -> bool: + try: + chunk = self._read_master(master_fd, READ_CHUNK_BYTES) + except BlockingIOError: + return True + except OSError as exc: + if exc.errno == errno.EIO: # normal PTY EOF on Linux once the last slave closes + return False + raise + if not chunk: # normal EOF elsewhere + return False + self._feed_output(chunk, decoder) + return True + + def _flush_input(self, master_fd: int, budget: int) -> None: + """Services the FIFO through one retained cursor, bounded so input cannot starve output.""" + written = 0 + while written < budget: + item = self._input_queue.current() + if item is None: + return + try: + if item.prepare is not None and not item.prepared: + item.prepare() + item.prepared = True + while item.cursor < len(item.data): + try: + count = self._write_master(master_fd, item.data[item.cursor :]) + except BlockingIOError: + return # EAGAIN retains the tail and returns to select() + item.cursor += count + written += count + if written >= budget and item.cursor < len(item.data): + return # a short write retains the suffix for the next iteration + except BaseException as exc: + self._complete_item(item, exc) + raise + error = self._complete_item(item, None) + if error is not None: + raise error + + def _complete_item(self, item: _InputItem, error: Optional[BaseException]) -> Optional[BaseException]: + finish_error = item.finish_once() # the restore is part of the item's contract + error = error or finish_error + self._input_queue.complete_current(error) + return error + + def _drain_remaining(self, master_fd: int, decoder) -> None: + """Catches output already in flight. Bounded by time, by bytes, and by a quiet period.""" + deadline = self._drain_deadline or (time.monotonic() + DRAIN_DEADLINE_SECONDS) + drained = 0 + while drained < DRAIN_MAX_BYTES: + remaining = deadline - time.monotonic() + if remaining <= 0: + return + readable, _ = self._select([master_fd], [], min(remaining, DRAIN_QUIET_PERIOD_SECONDS)) + if not readable: + return # nothing more is in flight + try: + chunk = self._read_master(master_fd, READ_CHUNK_BYTES) + except BlockingIOError: + return + except OSError as exc: + # Only the two ways a drain legitimately ends: the PTY reached EOF, or the + # master was released under a backend that is already closing. Anything + # else is an independent read failure and is published like one. + if exc.errno == errno.EIO or (exc.errno == errno.EBADF and self._closing.is_set()): + return + raise + if not chunk: + return + # The same feed path as the loop: drained output belongs on the decoded + # channel too. A query seen here is render-only — replies are already quiesced. + self._feed_output(chunk, decoder) + drained += len(chunk) + + # ------------------------------------------------------------- query replies + + def _admit_reply(self, payload: bytes, on_complete: Callable[[Optional[str]], None]) -> None: + """One non-blocking whole-item admission of a terminal reply, from the reader. + + Replies take the reserved partition because they are terminal protocol: a caller + saturating the queue with input must not be able to starve a required response. + They are never counted as caller input and never affect the input-driver + diagnostic. The queue's cursor preserves the reply across short writes. + """ + result, _ = self._input_queue.submit(payload, reserved=True, on_resolve=reply_resolution(on_complete)) + if result.disposition is InputDisposition.ACCEPTED: + self._ring_doorbell() + + # ----------------------------------------------------------- VEOF injection + + def _veof_prepare(self) -> None: + """Snapshots the terminal mode and clears echo, executed by the reader alone.""" + assert self._bundle is not None and self._bundle.master_fd is not None + fd = self._bundle.master_fd + self._veof_saved = termios.tcgetattr(fd) + attrs = termios.tcgetattr(fd) + attrs[3] &= ~(termios.ECHO | getattr(termios, "ECHOCTL", 0)) + termios.tcsetattr(fd, termios.TCSANOW, attrs) # TCSAFLUSH could discard the byte + + def _veof_restore(self) -> None: + saved, self._veof_saved = self._veof_saved, None + if saved is None: + return + assert self._bundle is not None and self._bundle.master_fd is not None + termios.tcsetattr(self._bundle.master_fd, termios.TCSANOW, saved) + + # ------------------------------------------------------------ teardown bits + + def _deliver(self, proc: subprocess.Popen, pgid: Optional[int], sig: int) -> None: + if pgid is not None: + _signal_group(pgid, sig) + return + try: # pre-ack: the launcher has not exec'd and never forks, so the PID suffices + proc.send_signal(sig) + except (ProcessLookupError, PermissionError, ValueError): + pass + + def _grace_tick(self) -> None: + time.sleep(GRACE_TICK_SECONDS) + + def _group_spent(self, pgid: Optional[int]) -> bool: + """Signal 0 as a liveness probe: True once the group can no longer be signalled. + + Only the grace loop uses it, and only to stop waiting early. Nothing is reaped here + — the SIGKILL and the reap that follow are unconditional — because reaping before + the escalation would recycle the group the escalation still has to reach. + """ + if pgid is None: # pre-ack: no group recorded, so the grace runs to its end + return False + return not _signal_group(pgid, 0) + + def _rollback(self) -> None: + try: + if self._proc is not None: + self.terminate_tree(ROLLBACK_GRACE_SECONDS) + finally: + try: + self.close() + finally: # nothing reached an owner yet on the earliest failure paths + _close_quietly(self._take_owned("_pending_master_fd")) + _close_quietly(self._take_owned("_pending_slave_fd")) + + def _ring_doorbell(self) -> None: + """A notification, not a message: producers mutate state first, then ring.""" + with self._fd_lock: # held so close() cannot free the number under the write + fd = self._wakeup_w + if fd is None: + return + try: + os.write(fd, b"\x01") + except OSError: + # EAGAIN means the pipe is already readable, EPIPE/EBADF mean the reader + # is gone — which is the outcome the write was asking for. + pass + + def _take_owned(self, name: str) -> Optional[int]: + with self._fd_lock: + fd = getattr(self, name) + setattr(self, name, None) + return fd + + def _close_owned(self, name: str) -> None: + _close_quietly(self._take_owned(name)) + + +def _drain_doorbell(fd: int) -> None: + while True: + try: + if not os.read(fd, READ_CHUNK_BYTES): + return + except OSError: + return diff --git a/render_machine/actions/exit_with_error.py b/render_machine/actions/exit_with_error.py index d49b956b..25e03f0f 100644 --- a/render_machine/actions/exit_with_error.py +++ b/render_machine/actions/exit_with_error.py @@ -10,7 +10,13 @@ class ExitWithError(BaseAction): SUCCESSFUL_OUTCOME = "error_handled" def execute(self, render_context: RenderContext, previous_action_payload: Any | None): - console.error(previous_action_payload) + console.error(self._error_message(render_context, previous_action_payload)) + + # The FRID that failed is the one whose fix-loop counts matter most, and it never + # reaches FinishFunctionalRequirement — so the whole render's counts are reported + # here rather than lost with it. + for summary in render_context.fix_loop_metrics.render_summary(): + console.info(summary) render_context.codeplain_api.fail_functional_requirement( render_context.frid_context.frid, @@ -33,3 +39,22 @@ def execute(self, render_context: RenderContext, previous_action_payload: Any | message=render_context.last_error_message or "Unknown error", ).to_payload(), ) + + @staticmethod + def _error_message(render_context: RenderContext, previous_action_payload: Any | None) -> str: + """What the user is told the render stopped for. + + Actions reach this state by three routes: some hand over an encoded RenderError + payload, some a plain string, and some nothing at all. Printing the payload as it + arrives showed a raw dict for the first and the word "None" for the last, so the + reason is unwrapped here and falls back to the same message the returned payload + carries. + """ + if isinstance(previous_action_payload, dict): + error = previous_action_payload.get("error") + if isinstance(error, dict) and error.get("message"): + return error["message"] + elif previous_action_payload: + return str(previous_action_payload) + + return render_context.last_error_message or "Unknown error" diff --git a/render_machine/actions/finish_functional_requirement.py b/render_machine/actions/finish_functional_requirement.py index 7439c583..6842f3fb 100644 --- a/render_machine/actions/finish_functional_requirement.py +++ b/render_machine/actions/finish_functional_requirement.py @@ -1,6 +1,7 @@ from typing import Any from render_machine.actions.commit_implementation_code_changes import CommitImplementationCodeChanges +from render_machine.fix_loop_metrics import report_frid_fix_loop_summary from render_machine.render_context import RenderContext @@ -8,6 +9,8 @@ class FinishFunctionalRequirement(CommitImplementationCodeChanges): SUCCESSFUL_OUTCOME = "functional_requirement_finished" def execute(self, render_context: RenderContext, previous_action_payload: Any | None): + report_frid_fix_loop_summary(render_context, render_context.frid_context.frid) + render_context.plain_module.update_frid_in_module_metadata(render_context.frid_context.frid) super().execute(render_context, previous_action_payload) diff --git a/render_machine/actions/fix_conformance_test.py b/render_machine/actions/fix_conformance_test.py index 5d68cc88..4fa8736f 100644 --- a/render_machine/actions/fix_conformance_test.py +++ b/render_machine/actions/fix_conformance_test.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Optional import diff_utils import file_utils @@ -7,12 +7,33 @@ from plain2code_console import RETRY_COLOR, console from plain2code_exceptions import InternalClientError from render_machine.actions.base_action import BaseAction +from render_machine.fix_loop_metrics import CONFORMANCE_LOOP, STRATEGY_SWITCH_PREFIX, stalled_reason from render_machine.implementation_code_helpers import ImplementationCodeHelpers from render_machine.render_context import RenderContext from render_machine.render_types import RenderError, TestExecutionPhase MAX_CONFORMANCE_TEST_FIX_ATTEMPTS = 20 -MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS = 1 + +# How many times one functionality may have its conformance test regenerated before the +# loop falls back to patching until the attempt limit. The budget is per functionality — +# `ConformanceTestsRunningContext` is rebuilt for each one — so this is not a per-render +# allowance. +# +# It was 1, and that was the difference between a render that finished and a render that +# did not. In a ten-task benchmark run, every render that failed to publish — both +# examples, unit-test and conformance wedges alike — died at the attempt limit below, +# which is only reachable once this budget is spent: one regeneration, then twenty fixes +# that changed nothing, then abandonment. The one render that completed spent exactly one +# regeneration on each of three separate functionalities and cleared the bar with nothing +# to spare; the four that wedged hit a functionality needing a second and had none left. +# Since a regeneration discards a test the loop has already proven it cannot satisfy, +# stopping at the first one abandons the render at precisely the point the move is working. +# +# Three rather than more: each regeneration resets `fix_attempts`, so the worst case for a +# genuinely unfixable functionality is four rounds of patching instead of two, and that +# cost lands on renders that were going to fail anyway. Raise it further only on evidence +# that a fourth regeneration ever rescued anything. +MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS = 3 class FixConformanceTest(BaseAction): @@ -26,6 +47,44 @@ class FixConformanceTest(BaseAction): ISSUE_REASON_CODE_CONFLICTING_REQUIREMENTS = 2 ISSUE_REASON_CODE_CONFLICTING_ACCEPTANCE_TESTS = 3 + @staticmethod + def _should_regenerate_instead_of_patching(render_context: RenderContext, reason: Optional[str]) -> bool: + """Whether the loop has proven that patching the implementation is not working. + + Two shapes of stuck, both observed on real renders. A failure that repeats + identically means the last fixes changed nothing the test can see — one wedged + cli-password-manager render failed conformance 20 times on a functionality with a + streak of 8 while its unit loop never failed once. A loop can also fail every + single time while the failures keep changing: a task-manager render went 40 for + 40 with a longest identical run of two, which no streak threshold can catch. + `stalled_reason` covers both. + + Regenerating the conformance test is a genuinely different move: it discards the + test the loop cannot satisfy rather than editing code against it again. That path + already exists for the attempt limit; this reaches it as soon as there is + evidence, instead of after twenty blind patches. + """ + ctx = render_context.conformance_tests_running_context + + # Bounded by the same re-render budget as the attempt-limit path, so the switch + # cannot cycle: once it is spent, the loop patches until the limit and stops. + if ctx.conformance_tests_render_attempts >= MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS: + return False + + if reason is None: + return False + + console.warning( + f"{STRATEGY_SWITCH_PREFIX} module={render_context.module_name} " + f"frid={ctx.current_testing_frid} loop={CONFORMANCE_LOOP} {reason} " + f"action=regenerate_conformance_tests" + ) + console.info( + f"Patching the implementation has not made the conformance tests for functionality " + f"{ctx.current_testing_frid} pass ({reason}). Regenerating those tests instead." + ) + return True + def execute(self, render_context: RenderContext, previous_action_payload: Any | None): ctx = render_context.conformance_tests_running_context ctx.fix_attempts += 1 @@ -42,6 +101,30 @@ def execute(self, render_context: RenderContext, previous_action_payload: Any | ctx.regenerating_conformance_tests = True return self.REGENERATE_CONFORMANCE_TESTS_OUTCOME, None + stalled = stalled_reason( + render_context.fix_loop_metrics, + CONFORMANCE_LOOP, + module=render_context.module_name, + frid=ctx.current_testing_frid, + ) + # Three rungs, cheapest first. A stuck loop first gets one request that says so — + # the same fix asked differently, which the benchmark evidence says is worth + # trying because the failures it kept patching were often timeouts and missing + # entry points rather than wrong answers. Only when that changes nothing does it + # discard the test, and only then does it give up. + stall_context = None + if stalled and not ctx.asked_with_stall_context: + ctx.asked_with_stall_context = True + stall_context = stalled + console.warning( + f"{STRATEGY_SWITCH_PREFIX} module={render_context.module_name} " + f"frid={ctx.current_testing_frid} loop={CONFORMANCE_LOOP} {stalled} " + f"action=ask_with_stall_context" + ) + elif self._should_regenerate_instead_of_patching(render_context, stalled): + ctx.regenerating_conformance_tests = True + return self.REGENERATE_CONFORMANCE_TESTS_OUTCOME, None + console.info(f"Running conformance tests attempt {ctx.fix_attempts + 1}.") console.info( @@ -133,6 +216,7 @@ def execute(self, render_context: RenderContext, previous_action_payload: Any | render_context.conformance_tests_running_context.current_testing_frid_high_level_implementation_plan, render_context.conformance_tests_running_context.conflicting_requirement_count, run_state=render_context.run_state, + stalled_reason=stall_context, ) code_diff_files_content = {} diff --git a/render_machine/actions/refactor_code.py b/render_machine/actions/refactor_code.py index 60e95ebf..8d062367 100644 --- a/render_machine/actions/refactor_code.py +++ b/render_machine/actions/refactor_code.py @@ -22,7 +22,7 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | render_context.frid_context.refactoring_iteration += 1 if render_context.frid_context.refactoring_iteration >= MAX_REFACTORING_ITERATIONS: - error_message = "Refactoring iterations limit of {MAX_REFACTORING_ITERATIONS} reached for functionality {render_context.frid_context.frid}." + error_message = f"Refactoring iterations limit of {MAX_REFACTORING_ITERATIONS} reached for functionality {render_context.frid_context.frid}." render_context.last_error_message = error_message return ( diff --git a/render_machine/actions/render_functional_requirement.py b/render_machine/actions/render_functional_requirement.py index 07fa924e..c53217f0 100644 --- a/render_machine/actions/render_functional_requirement.py +++ b/render_machine/actions/render_functional_requirement.py @@ -20,9 +20,20 @@ class RenderFunctionalRequirement(BaseAction): def execute(self, render_context: RenderContext, _previous_action_payload: Any | None): if render_context.frid_context.functional_requirement_render_attempts >= MAX_CODE_GENERATION_RETRIES: - error_msg = f"Unittests could not be fixed after rendering the functionality {render_context.frid_context.frid} for the {MAX_CODE_GENERATION_RETRIES} times." + error_msg = ( + f"The renderer was unable to produce an implementation whose unit tests pass for functionality " + f"'{render_context.frid_context.frid}' after rendering it from scratch {MAX_CODE_GENERATION_RETRIES} " + f"times. Please review and rewrite the specification." + ) render_context.last_error_message = error_msg - return self.ITERATION_LIMIT_EXCEEDED_OUTCOME, None + return ( + self.ITERATION_LIMIT_EXCEEDED_OUTCOME, + RenderError.encode( + message=error_msg, + error_type="UNIT_TESTS_FIX_EXHAUSTED", + frid=render_context.frid_context.frid, + ).to_payload(), + ) render_context.frid_context.functional_requirement_render_attempts += 1 diff --git a/render_machine/actions/run_conformance_tests.py b/render_machine/actions/run_conformance_tests.py index 3c7bb1f0..050bd633 100644 --- a/render_machine/actions/run_conformance_tests.py +++ b/render_machine/actions/run_conformance_tests.py @@ -4,6 +4,7 @@ import render_machine.render_utils as render_utils from plain2code_console import console from render_machine.actions.base_action import BaseAction +from render_machine.fix_loop_metrics import CONFORMANCE_LOOP, report_fix_loop_attempt from render_machine.render_context import RenderContext from render_machine.render_types import RenderError @@ -58,6 +59,14 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | render_context, exit_code, conformance_tests_issue ) + report_fix_loop_attempt( + render_context, + loop=CONFORMANCE_LOOP, + frid=render_context.conformance_tests_running_context.current_testing_frid, + passed=exit_code == 0, + output=conformance_tests_issue, + ) + if exit_code == 0: if ( render_context.conformance_tests_running_context.current_testing_module_name diff --git a/render_machine/actions/run_unit_tests.py b/render_machine/actions/run_unit_tests.py index 79e29dbc..322dd990 100644 --- a/render_machine/actions/run_unit_tests.py +++ b/render_machine/actions/run_unit_tests.py @@ -4,6 +4,7 @@ import render_machine.render_utils as render_utils from plain2code_console import console from render_machine.actions.base_action import BaseAction +from render_machine.fix_loop_metrics import UNIT_LOOP, report_fix_loop_attempt from render_machine.render_context import RenderContext from render_machine.render_types import RenderError @@ -31,6 +32,15 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | render_context.script_execution_history.latest_unit_test_output_path = unittests_temp_log_file_path render_context.script_execution_history.should_update_script_outputs = True + + report_fix_loop_attempt( + render_context, + loop=UNIT_LOOP, + frid=render_context.frid_context.frid if render_context.frid_context else None, + passed=exit_code == 0, + output=unittests_issue, + ) + if exit_code == 0: return self.SUCCESSFUL_OUTCOME, None diff --git a/render_machine/code_renderer.py b/render_machine/code_renderer.py index cb8de1a3..d87c742c 100644 --- a/render_machine/code_renderer.py +++ b/render_machine/code_renderer.py @@ -3,6 +3,7 @@ from transitions.extensions.diagrams import HierarchicalGraphMachine +from plain2code_console import console from plain2code_events import ( RenderModuleCompleted, RenderModuleFailed, @@ -79,6 +80,8 @@ def run(self): break if self.render_context.state == States.RENDER_COMPLETED.value: + for summary in self.render_context.fix_loop_metrics.render_summary(): + console.info(summary) self.render_context.event_bus.publish( RenderModuleCompleted(module_name=self.render_context.module_name) ) diff --git a/render_machine/fix_loop_metrics.py b/render_machine/fix_loop_metrics.py new file mode 100644 index 00000000..c46b017f --- /dev/null +++ b/render_machine/fix_loop_metrics.py @@ -0,0 +1,251 @@ +"""Per-FRID accounting for the two fix loops, and detection of a loop that is stuck. + +Both loops — unit tests during implementation, conformance tests afterwards — patch, +re-run the script, and repeat until a budget runs out. Neither noticed when an attempt +changed nothing: benchmark renders spent twenty attempts rewriting one file against one +unchanging assertion before abandoning the render. Two things were missing, and this +module supplies both. + +*Detection*: a failure is fingerprinted, and consecutive identical fingerprints for the +same loop and FRID are counted. A streak means the loop is re-patching without effect, +which is the moment worth reporting — not the exhaustion twenty attempts later. + +*Measurement*: attempts and failures are counted per (module, FRID, loop), so a render +reports how many iterations convergence took rather than only whether it eventually gave +up. Exhaustion is a rare binary event and a poor basis for comparing configurations; +iterations-to-convergence is close to continuous and says something after a single run. + +Recording never affects rendering. These are observations. +""" + +import hashlib +import re +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple + +from plain2code_console import console + +UNIT_LOOP = "unit" +CONFORMANCE_LOOP = "conformance" + +# Parts of a test script's output that differ between two runs of the very same failure. +# Left in place, any one of them would make every attempt look novel and hide a stuck +# loop; over-normalising would do the reverse and merge failures that differ for real, so +# only demonstrably volatile tokens are erased. +_VOLATILE_PATTERNS = ( + re.compile(r"/tmp/[^\s'\"]+"), # renderer scratch paths: /tmp/tmpk8flk7f1.script_output + re.compile(r"\b0x[0-9a-fA-F]+\b"), # memory addresses + re.compile(r"\b[0-9a-fA-F]{8,}\b"), # hashes, uuids, run ids + re.compile(r"\b\d+(?:\.\d+)?\s*m?s\b"), # durations: "1335.821531 ms", "22.5s" + re.compile(r"duration_ms\s+[\d.]+"), +) + + +def failure_fingerprint(output: str) -> str: + """A stable identity for one failure, insensitive to run-to-run noise.""" + normalized = output or "" + for pattern in _VOLATILE_PATTERNS: + normalized = pattern.sub("", normalized) + normalized = " ".join(normalized.split()) + return hashlib.sha1(normalized.encode("utf-8", errors="replace")).hexdigest()[:12] + + +@dataclass +class _LoopCounters: + attempts: int = 0 + failures: int = 0 + max_repeat: int = 1 + last_fingerprint: Optional[str] = None + current_repeat: int = 0 + # Failures since the last pass, whether or not they look alike. A loop can fail every + # single time without ever repeating itself — one benchmark render went 40 for 40 on + # a functionality whose longest identical run was two — and a streak counter cannot + # see that at any threshold. + consecutive_failures: int = 0 + + +@dataclass +class FixLoopMetrics: + """One per render. Keyed by (module, frid) so a re-rendered FRID keeps accumulating.""" + + _counters: Dict[Tuple[str, str], Dict[str, _LoopCounters]] = field(default_factory=dict) + _order: List[Tuple[str, str]] = field(default_factory=list) + + def record(self, loop: str, module: str, frid: str, passed: bool, output: str) -> Optional[int]: + """Records one script run. Returns the streak length when this failure is a + repeat of the one before it in the same loop, otherwise None.""" + key = (module, str(frid)) + if key not in self._counters: + self._counters[key] = {} + self._order.append(key) + counters = self._counters[key].setdefault(loop, _LoopCounters()) + + counters.attempts += 1 + if passed: + counters.last_fingerprint = None + counters.current_repeat = 0 + counters.consecutive_failures = 0 + return None + + counters.failures += 1 + counters.consecutive_failures += 1 + fingerprint = failure_fingerprint(output) + if fingerprint == counters.last_fingerprint: + counters.current_repeat += 1 + counters.max_repeat = max(counters.max_repeat, counters.current_repeat) + return counters.current_repeat + + counters.last_fingerprint = fingerprint + counters.current_repeat = 1 + return None + + def start_over(self, loop: str, module: str, frid: Optional[str]) -> None: + """Forgets that this loop is stuck, without forgetting what it has done. + + Called when the loop is handed a genuinely new problem — a regenerated conformance + test — rather than another patch against the old one. The evidence of being stuck + was evidence about a test that no longer exists, and carrying it over is not a + harmless conservatism: the first failure of the replacement test lands on a stall + counter that is already past its threshold, so the replacement is condemned on one + attempt and regenerated again. A benchmark render spent its whole regeneration + budget in twenty-eight seconds that way, three tests discarded after one attempt + each, none of them given a chance to be the one that worked. + + The cumulative counts survive, because they answer a different question — how much + work this functionality took in total — and the per-render series is indexed on + them. + """ + counters = self._counters_for(loop, module, frid) + if counters is None: + return + counters.consecutive_failures = 0 + counters.current_repeat = 0 + counters.last_fingerprint = None + + def current_streak(self, loop: str, module: str, frid: Optional[str]) -> int: + """How many times in a row this loop has just failed the same way. + + `record` returns the streak as it happens, which is enough to warn but not to + decide: the fix action runs after the test action and needs to ask the question + again, from its own call site. A missing frid answers zero rather than raising, + because nothing was recorded under one either — `report_fix_loop_attempt` skips + those runs. + """ + counters = self._counters_for(loop, module, frid) + return counters.current_repeat if counters else 0 + + def consecutive_failures(self, loop: str, module: str, frid: Optional[str]) -> int: + """How many times in a row this loop has failed, regardless of how it failed.""" + counters = self._counters_for(loop, module, frid) + return counters.consecutive_failures if counters else 0 + + def _counters_for(self, loop: str, module: str, frid: Optional[str]) -> Optional[_LoopCounters]: + if frid is None: + return None # nothing is recorded without one — report_fix_loop_attempt skips those runs + counters = self._counters.get((module, str(frid))) + if not counters or loop not in counters: + return None + return counters[loop] + + def frid_summary(self, module: str, frid: str) -> Optional[str]: + """One greppable line per FRID, or None if no script ran for it.""" + counters = self._counters.get((module, str(frid))) + if not counters: + return None + + parts = [f"[fix-loop] module={module} frid={frid}"] + for loop in (UNIT_LOOP, CONFORMANCE_LOOP): + if loop in counters: + parts.append( + f"{loop}={counters[loop].attempts} " + f"{loop}_failed={counters[loop].failures} " + f"{loop}_max_repeat={counters[loop].max_repeat}" + ) + # The per-loop streaks are what a reader needs — the two loops wedge for + # different reasons and are worth different responses — but the aggregate stays + # because the benchmark series already collected is indexed on this one number, + # and dropping it would strand those runs mid-experiment. + parts.append(f"max_repeat={max(loop.max_repeat for loop in counters.values())}") + return " ".join(parts) + + def render_summary(self) -> List[str]: + """Every FRID that ran a test script, in the order it was first reached.""" + summaries = (self.frid_summary(module, frid) for module, frid in self._order) + return [summary for summary in summaries if summary] + + +# How many identical failures in a row before the loop is called out. Two can happen when +# a patch legitimately addresses something else first; by three the loop is re-patching +# against a failure it is not moving. +REPEATED_FAILURE_WARNING_THRESHOLD = 3 + +# How many failures in a row — alike or not — before the loop is called stuck anyway. +# Repetition proves futility quickly but is not necessary for it: a benchmark render +# failed a functionality's conformance tests 40 times out of 40 with a longest identical +# run of two, which no streak threshold can catch. The highest failure count seen on a +# functionality that then recovered is four, so this sits above that with margin. +CONSECUTIVE_FAILURE_THRESHOLD = 6 + +# Marks the moment a loop stops patching and does something else instead. Greppable on +# purpose, like the other benchmark markers. +STRATEGY_SWITCH_PREFIX = "[strategy-switch]" + + +# Which loops a run of failures — as opposed to a run of *identical* failures — is taken +# as evidence against. Conformance only, and the asymmetry is measured rather than +# cautious. On the conformance side the arm is what catches a loop failing 40 times out of +# 40 with a longest identical run of two, which no streak threshold reaches. On the unit +# side it fired on loops that were working: two renders showed `unit=7 unit_failed=7 +# unit_max_repeat=1` — seven failures, none alike — and both had their functionality +# restarted and scored 0/10, where every render without a restart scored 2–3. Six was +# calibrated on conformance recoveries (the highest failure count on a functionality that +# then recovered was four) and there was never a unit-loop equivalent to calibrate on. +# +# The unit loop keeps the streak arm, where the margin is not in doubt: across three +# renders every healthy functionality finished with unit_max_repeat=1. +LOOPS_JUDGED_ON_CONSECUTIVE_FAILURES = (CONFORMANCE_LOOP,) + + +def stalled_reason(metrics: "FixLoopMetrics", loop: str, module: str, frid: Optional[str]) -> Optional[str]: + """Why this loop looks stuck, or None if it still looks like it is working. + + The streak arm applies to both loops: a loop re-submitting the same fix is stuck + whichever loop it is. The consecutive arm applies only where failing every time has + been shown to mean stuck rather than busy. + """ + streak = metrics.current_streak(loop, module, frid) + if streak >= REPEATED_FAILURE_WARNING_THRESHOLD: + return f"repeated_failure streak={streak}" + + if loop in LOOPS_JUDGED_ON_CONSECUTIVE_FAILURES: + failures = metrics.consecutive_failures(loop, module, frid) + if failures >= CONSECUTIVE_FAILURE_THRESHOLD: + return f"no_progress consecutive_failures={failures}" + + return None + + +def report_fix_loop_attempt(render_context, loop: str, frid: Optional[str], passed: bool, output: str) -> None: + """Records one script run and tells the user when the loop stops making progress.""" + if frid is None: + return + + streak = render_context.fix_loop_metrics.record( + loop, module=render_context.module_name, frid=frid, passed=passed, output=output + ) + + if streak is not None and streak >= REPEATED_FAILURE_WARNING_THRESHOLD: + console.warning( + f"The {loop} tests for functionality {frid} have failed the same way {streak} times in a row. " + f"The last {streak - 1} fix attempts changed nothing that the tests can see." + ) + + +def report_frid_fix_loop_summary(render_context, frid: Optional[str]) -> None: + """Emits the per-FRID counts once the FRID is done, successfully or not.""" + if frid is None: + return + + summary = render_context.fix_loop_metrics.frid_summary(render_context.module_name, frid) + if summary: + console.info(summary) diff --git a/render_machine/output_normalizer.py b/render_machine/output_normalizer.py new file mode 100644 index 00000000..18a2aed1 --- /dev/null +++ b/render_machine/output_normalizer.py @@ -0,0 +1,441 @@ +"""Renders a terminal byte stream instead of stripping bytes out of it. + +Under a PTY `isatty()` is true, so toolchains emit colour, cursor movement, progress-line +rewrites and full-screen repaints. Deleting those bytes would delete the *instruction* +without performing the *operation*: every stale frame would survive and be concatenated. A +tool repainting a status block 200 times would yield 200 stacked copies where a terminal +shows one. + +So the normalizer runs a VT state machine (pyte) over the raw bytes and emits what a +terminal would have shown: the lines that scrolled off, then the final screen. `\\r\\n` +collapses to `\\n` and no SGR survives, because the output is rendered from the screen +buffer rather than filtered out of the stream. + +The parser also runs live in the reader, because terminals answer queries: a target may +emit `ESC[5n`, `ESC[6n` or `ESC[c` and block until the terminal replies. `reply_handler` +receives those replies; a normalizer constructed without one simply renders. +""" + +import collections +import threading +import unicodedata +from typing import Callable, Deque, Dict, List, Optional + +import pyte +from pyte.screens import Char, Margins + +from render_machine.terminal_process import TERMINAL_COLUMNS, TERMINAL_ROWS + +# Head and tail of the scrolled-off transcript. Blind truncation would drop whichever end +# happens to matter; a failing run needs its invocation (head) and its error (tail). +SCROLLBACK_HEAD_LINES = 300 +SCROLLBACK_TAIL_LINES = 1700 + +# Caps on the parser state a target can grow. Both are far above anything a terminal +# renders and far below anything that costs the reader its memory. +MAX_SEQUENCE_BYTES = 4096 +MAX_COMBINING_MARKS = 8 + +# Private DEC modes that swap in the alternate screen buffer. +ALTERNATE_SCREEN_MODES = (47, 1047, 1049) + +# Query kinds reported to the reply handler. +QUERY_DEVICE_STATUS = "device-status" +QUERY_CURSOR_POSITION = "cursor-position" +QUERY_DEVICE_ATTRIBUTES = "device-attributes" + + +def render_line(line: Dict[int, Char], columns: int) -> str: + """One buffer line as plain text. + + The cell after a double-width character holds an empty stub, so a plain join over the + row reproduces what the screen shows without consulting character widths. + """ + return "".join(line[x].data for x in range(columns)).rstrip() + + +def _trim_trailing_blanks(lines: List[str]) -> List[str]: + while lines and not lines[-1]: + lines.pop() + return lines + + +class _RetainedLines: + """Keeps the head and the tail of the scrolled-off transcript.""" + + def __init__(self, head_lines: int, tail_lines: int) -> None: + self._head: List[str] = [] + self._tail: Deque[str] = collections.deque(maxlen=tail_lines) + self._head_lines = head_lines + self.total = 0 + + def append(self, line: str) -> None: + self.total += 1 + if len(self._head) < self._head_lines: + self._head.append(line) + else: + self._tail.append(line) + + def lines(self) -> List[str]: + omitted = self.total - len(self._head) - len(self._tail) + if omitted <= 0: + return self._head + list(self._tail) + return self._head + [f"...[{omitted} lines omitted]..."] + list(self._tail) + + +# Framing states, and the bytes that move between them. +_GROUND, _ESCAPE, _INTERMEDIATE, _CSI, _STRING = range(5) +_ESC = 0x1B +_BEL = 0x07 +_CAN = 0x18 +_SUB = 0x1A +_STRING_INTRODUCERS = frozenset(b"]PX^_") # OSC, DCS, SOS, PM, APC +_ESCAPE_INTERMEDIATES = frozenset(b"#%()") # each takes exactly one more byte + + +class _SequenceGuard: + """Frames a byte stream into plain runs and whole escape sequences, with a size cap. + + pyte 0.8.2 accumulates an unterminated OSC string or CSI parameter inside its parser + coroutine without any bound, so a target that writes `ESC ] 0 ;` and then never + terminates it grows the reader's memory for as long as it runs. An in-progress sequence + is held here instead: the buffer is this class's own, it is capped, and the remainder of + an oversized sequence is dropped rather than parsed. + + Framing never changes what the parser sees — the same bytes arrive in the same order. + It only decides where one `feed()` call ends, which is what makes a parse failure cost + one sequence instead of the rest of an OS-sized read. + """ + + def __init__(self, max_sequence_bytes: int = MAX_SEQUENCE_BYTES) -> None: + self._max_sequence_bytes = max_sequence_bytes + self._state = _GROUND + self._pending = bytearray() + self._dropping = False + self._after_escape = False + self.dropped = 0 + + @property + def pending_bytes(self) -> int: + return len(self._pending) + + def frame(self, data: bytes) -> List[bytes]: + """The units to hand the parser: plain runs and complete escape sequences.""" + units: List[bytes] = [] + index = 0 + length = len(data) + while index < length: + if self._state == _GROUND: + start = data.find(_ESC, index) + if start < 0: + units.append(data[index:]) + break + if start > index: + units.append(data[index:start]) + self._state = _ESCAPE + self._pending += b"\x1b" + index = start + 1 + else: + index = self._consume(data, index, units) + return units + + def _consume(self, data: bytes, index: int, units: List[bytes]) -> int: + length = len(data) + while index < length and self._state != _GROUND: + byte = data[index] + index += 1 + if byte in (_CAN, _SUB): + # CAN and SUB abort a sequence in any state, like a real parser; an + # aborted sequence is discarded, never handed to the parser. + self._reset() + continue + if self._state == _STRING and self._after_escape and byte != 0x5C: + # Only ESC \ terminates a string, but any other ESC-introduced byte still + # ends it: the ESC begins a new escape sequence, exactly as a real + # parser's exit from its string state does. + self._reset() + self._state = _ESCAPE + self._pending += b"\x1b" + if self._state == _ESCAPE and byte == _ESC and not self._dropping: + # ESC restarts the escape state: the previous ESC led nowhere and is + # dropped, and whatever follows this one is parsed as its own sequence. + self._pending.clear() + self._pending += b"\x1b" + continue + if not self._dropping and len(self._pending) >= self._max_sequence_bytes: + self.dropped += 1 + if self._state == _STRING: + # A control string this long is abandoned rather than dropped to a + # terminator that may never come: a stream cut mid-string would + # otherwise swallow the remainder of the transcript. Its payload is + # reclaimed as plain output, so nothing the target wrote is lost. + units.append(bytes(self._pending[2:])) + self._reset() + return index - 1 + # Nothing renders a sequence this long, so the rest of it is parsed by + # nobody and the buffer that held it is released here. + self._dropping = True + self._pending.clear() + if not self._dropping: + self._pending.append(byte) + if self._ends_sequence(byte): + if not self._dropping: + units.append(bytes(self._pending)) + self._reset() + return index + + def flush(self) -> bytes: + """The payload of an unterminated control string, reclaimed as plain output. + + Called at end of stream: a target cut off mid-string never sends the terminator, + and whatever followed the introducer would otherwise vanish from the transcript. + Incomplete sequences of every other kind stay dropped — they carry no payload. + """ + payload = bytes(self._pending[2:]) if self._state == _STRING and not self._dropping else b"" + self._reset() + return payload + + def _ends_sequence(self, byte: int) -> bool: + if self._state == _ESCAPE: + if byte == 0x5B: # [ + self._state = _CSI + elif byte in _STRING_INTRODUCERS: + self._state = _STRING + elif byte in _ESCAPE_INTERMEDIATES: + self._state = _INTERMEDIATE + else: + return True + return False + if self._state == _INTERMEDIATE: + return True + if self._state == _CSI: + return 0x40 <= byte <= 0x7E # the final byte; parameters and controls are lower + if self._after_escape: # only ESC \ terminates a string; ESC anything else does not + self._after_escape = False + return byte == 0x5C + if byte == _ESC: + self._after_escape = True + return False + return byte == _BEL + + def _reset(self) -> None: + self._state = _GROUND + self._pending.clear() + self._dropping = False + self._after_escape = False + + +class _RenderingScreen(pyte.Screen): + """A pyte screen that retains what scrolls off and answers device queries. + + pyte keeps only the visible screen and its `write_process_input()` is a no-op, so both + behaviours are supplied here. + """ + + def __init__( + self, + columns: int, + lines: int, + scrollback: _RetainedLines, + reply_handler: Optional[Callable[[str, bytes], None]], + max_combining_marks: int = MAX_COMBINING_MARKS, + ) -> None: + # Set before super().__init__, which resets the screen and can reach these. + self._scrollback = scrollback + self._reply_handler = reply_handler + self._alternate = False + self._query_kind = QUERY_DEVICE_ATTRIBUTES + self._max_combining_marks = max_combining_marks + self._combining_run = 0 + super().__init__(columns, lines) + + # -------------------------------------------------------------------- drawing + + def draw(self, data: str) -> None: + """Caps how many combining marks one cell can accumulate. + + pyte appends every zero-width combining mark to the previous cell's string, so a + target emitting them in a loop grows one cell without bound. A run past the cap is + dropped: no terminal renders it, and nothing else bounds it. + """ + if data.isascii(): # the common case, and no combining mark is ASCII + self._combining_run = 0 + super().draw(data) + return + super().draw(self._cap_combining_marks(data)) + + def _cap_combining_marks(self, data: str) -> str: + kept: List[str] = [] + run = self._combining_run + for char in data: + if unicodedata.combining(char): + run += 1 + if run > self._max_combining_marks: + continue + else: + run = 0 # a character that advances the cursor starts the next cell's run + kept.append(char) + self._combining_run = run + return "".join(kept) + + # ------------------------------------------------------------------ scrollback + + def index(self) -> None: + """Overloaded to retain the line the scroll pushes off the top.""" + top, bottom = self.margins or Margins(0, self.lines - 1) + if self.cursor.y == bottom and not self._alternate: + self._scrollback.append(render_line(self.buffer[top], self.columns)) + super().index() + + # ------------------------------------------------------------ alternate screen + + def set_mode(self, *modes: int, **kwargs) -> None: + if kwargs.get("private") and any(mode in ALTERNATE_SCREEN_MODES for mode in modes): + self._switch_screen(alternate=True) + super().set_mode(*modes, **kwargs) + + def reset_mode(self, *modes: int, **kwargs) -> None: + if kwargs.get("private") and any(mode in ALTERNATE_SCREEN_MODES for mode in modes): + self._switch_screen(alternate=False) + super().reset_mode(*modes, **kwargs) + + def _switch_screen(self, alternate: bool) -> None: + """Flushes the outgoing screen into the scrollback and starts the incoming one clear. + + A terminal restores the primary screen verbatim and discards the alternate one. The + transcript is a linear log instead, so each switch appends the frame that is leaving + and continues below it — chronological, and still free of every repaint that frame + replaced. + """ + if alternate == self._alternate: + return + self._alternate = alternate + for line in _trim_trailing_blanks(self.screen_lines()): + self._scrollback.append(line) + self.buffer.clear() + self.dirty.update(range(self.lines)) + self.cursor_position() + + # --------------------------------------------------------------- device queries + + def report_device_status(self, mode: int = 0, **kwargs) -> None: + if kwargs.get("private"): + return # DECDSR, which this terminal does not claim to implement + self._query_kind = QUERY_DEVICE_STATUS if mode == 5 else QUERY_CURSOR_POSITION + super().report_device_status(mode) + + def report_device_attributes(self, mode: int = 0, **kwargs) -> None: + self._query_kind = QUERY_DEVICE_ATTRIBUTES + super().report_device_attributes(mode, **kwargs) + + def write_process_input(self, data: str) -> None: + """pyte's reply hook. The reply is terminal protocol, never caller input.""" + handler = self._reply_handler + if handler is None: + return + handler(self._query_kind, data.encode("utf-8")) + + # ------------------------------------------------------------------- rendering + + def screen_lines(self) -> List[str]: + return [render_line(self.buffer[y], self.columns) for y in range(self.lines)] + + +class OutputNormalizer: + """Renders a target's terminal output and answers the queries it emits. + + Fed by the reader thread and read by the foreground, so both entry points take one + lock. `feed()` never raises: a malformed sequence must not take the reader down. Every + piece of parser state a target can grow — an unterminated sequence, one cell's + combining marks — is capped, because the reader is the process's only drainer. + """ + + def __init__( + self, + columns: int = TERMINAL_COLUMNS, + lines: int = TERMINAL_ROWS, + head_lines: int = SCROLLBACK_HEAD_LINES, + tail_lines: int = SCROLLBACK_TAIL_LINES, + reply_handler: Optional[Callable[[str, bytes], None]] = None, + translate_newlines: bool = False, + ) -> None: + # A PTY's line discipline turns every NL into CR-NL (ONLCR) before the bytes reach + # a terminal, so a VT parser may treat a bare linefeed as index-only. A pipe has no + # line discipline: fed verbatim, each line would start in the column the previous + # one ended in — a whitespace staircase. A backend whose stream never crossed a + # line discipline asks for the same translation here, exactly as ONLCR performs + # it: on the raw byte stream, with no regard for the sequences it may split. + self._translate_newlines = translate_newlines + self._lock = threading.Lock() + self._scrollback = _RetainedLines(head_lines, tail_lines) + self._screen = _RenderingScreen(columns, lines, self._scrollback, reply_handler) + self._stream = pyte.ByteStream(self._screen) + self._guard = _SequenceGuard() + self._finalized = False + self.parse_failures = 0 + self.fed_bytes = 0 + + @property + def bounded_sequences(self) -> int: + """Escape sequences dropped for exceeding the size cap.""" + return self._guard.dropped + + def resize(self, columns: int, lines: int) -> None: + """Matches the parser to the terminal the target was actually given.""" + with self._lock: + self._screen.resize(lines, columns) + + def feed(self, data: bytes) -> None: + if not data: + return + with self._lock: + self.fed_bytes += len(data) # what the target wrote, before any translation + if self._translate_newlines: + data = data.replace(b"\n", b"\r\n") + for unit in self._guard.frame(data): + try: + self._stream.feed(unit) + except Exception: + # pyte reinitializes its parser before propagating, so the next unit is + # parsed from a clean state. The guard hands over one sequence at a + # time, so a malformed one costs itself rather than the rest of the + # read — and never the reader that feeds it. + self.parse_failures += 1 + + def finalize(self) -> None: + """Ends the stream: reclaims an unterminated control string's payload as plain + output, then flushes the parser's decoder. Idempotent. + + A trailing incomplete UTF-8 sequence sits in pyte's incremental decoder until it is + finalized, so without this it never reaches the screen and vanishes from the + transcript instead of rendering as U+FFFD. `utf8_decoder` is pyte 0.8.2's decoder + attribute and is reached defensively. + """ + with self._lock: + if self._finalized: + return + self._finalized = True + leftover = self._guard.flush() + if leftover: + try: + self._stream.feed(leftover) + except Exception: + self.parse_failures += 1 + decoder = getattr(self._stream, "utf8_decoder", None) + if decoder is None: + return + try: + tail = decoder.decode(b"", final=True) + if tail: + pyte.Stream.feed(self._stream, tail) # already text, so not ByteStream.feed + except Exception: + self.parse_failures += 1 + + def text(self) -> str: + """The rendered scrollback followed by the final screen, as plain text.""" + with self._lock: + lines = self._scrollback.lines() + self._screen.screen_lines() + while lines and not lines[0]: + del lines[0] + _trim_trailing_blanks(lines) + return "\n".join(lines) + "\n" if lines else "" diff --git a/render_machine/pty_exec.py b/render_machine/pty_exec.py new file mode 100644 index 00000000..785297a7 --- /dev/null +++ b/render_machine/pty_exec.py @@ -0,0 +1,136 @@ +"""Launcher that gives a command its own terminal and then becomes that command. + +Spawned by the POSIX backend as ``python -I -S pty_exec.py + -- ``, it attaches the PTY slave to fds 0, 1 and 2, verifies the +terminal invariants that only a pre-exec child can verify, reports progress to the +parent over a framed status pipe, waits for the parent's acknowledgment, and execs. + +The acknowledgment is a barrier: the parent records the process group before releasing +the target, so the target cannot create a descendant the parent does not know how to +terminate. The proof holds only while nothing but this file runs before the ack, which +is what ``-I -S`` guarantees — hence the deliberately tiny import set (os, sys, select, +signal, all builtin C modules) and the absolute-path spawn. +""" + +import os +import select +import signal +import sys + +if sys.platform == "win32": # pragma: no cover - the launcher is POSIX-only + raise ImportError("render_machine.pty_exec is POSIX-only") + +STARTED = 0x01 # record type: the interpreter reached our code +SESSION_READY = 0x02 # record type: setsid done, pgid == pid; parent may record it +FAILED = 0x03 # record type: payload is the framed error text + +HEADER_SIZE = 5 # one type byte + 4-byte big-endian length +MAX_PAYLOAD = 8192 # bound the error text so a record can never be unbounded + +LAUNCH_FAILURE_EXIT_CODE = 127 + +# Backstop against a parent that is alive but never acknowledges — a bug, not an +# operating condition. It sits comfortably above the parent's own handshake bound so +# the parent's deadline expires first in every realistic failure, leaving one deadline +# owner and one diagnostic path. Tests lower it through the environment to drive the +# launcher-times-out-first boundary deterministically. +ACK_TIMEOUT = 60.0 +ACK_TIMEOUT_ENV = "CODEPLAIN_PTY_ACK_TIMEOUT" + +# The set Popen(restore_signals=True) resets. CPython sets SIGPIPE to SIG_IGN at +# startup and an ignored disposition survives execvpe, so without this the target +# would inherit an ignored SIGPIPE where the pipe backend delivers the default. +RESTORED_SIGNALS = ("SIGPIPE", "SIGXFZ", "SIGXFSZ") + + +def _write_record(fd: int, kind: int, payload: bytes = b"") -> None: + """One type byte + 4-byte big-endian length + payload. Never a bare marker.""" + payload = payload[:MAX_PAYLOAD] + buf = bytes([kind]) + len(payload).to_bytes(4, "big") + payload + while buf: # os.write may write fewer bytes than asked; a short write would + buf = buf[os.write(fd, buf) :] # leave a valid header followed by a truncated payload + + +def _ack_timeout() -> float: + raw = os.environ.get(ACK_TIMEOUT_ENV) + if not raw: + return ACK_TIMEOUT + try: + return float(raw) + except ValueError: + return ACK_TIMEOUT + + +def _await_ack(ack_fd: int, timeout: float) -> None: + """Blocks until the parent acknowledges. EOF or timeout is a launch failure. + + The parent holds the only write end, so a dead parent surfaces as an immediate EOF + rather than as a wait for the timeout. + """ + readable, _, _ = select.select([ack_fd], [], [], timeout) + if not readable: + raise RuntimeError(f"parent did not acknowledge within {timeout} seconds") + if not os.read(ack_fd, 1): + raise RuntimeError("parent closed the acknowledgment pipe without acknowledging") + + +def _assert_invariants() -> None: + pid = os.getpid() + if not (os.isatty(0) and os.isatty(1) and os.isatty(2)): + raise RuntimeError("PTY is not attached to all three descriptors") + if os.getsid(0) != pid or os.getpgrp() != pid: + raise RuntimeError("login_tty did not make this process session and group leader") + if os.tcgetpgrp(0) != os.getpgrp(): + raise RuntimeError("PTY foreground process group is not this process") + tty_fd = os.open("/dev/tty", os.O_RDWR | getattr(os, "O_CLOEXEC", 0)) + try: # proves a controlling terminal exists, not merely that fd 0 + if not os.isatty(tty_fd): # happens to name some terminal device + raise RuntimeError("/dev/tty is not a terminal") + finally: + os.close(tty_fd) + + +def _restore_signals() -> None: + for name in RESTORED_SIGNALS: + if hasattr(signal, name): + signal.signal(getattr(signal, name), signal.SIG_DFL) + + +def _format_launch_error(exc: BaseException) -> bytes: + return f"{type(exc).__name__}: {exc}".encode("utf-8", "replace") + + +def main(slave_fd: int, status_fd: int, ack_fd: int, command: list) -> None: + try: + _write_record(status_fd, STARTED) # before anything that can fail + os.login_tty(slave_fd) # setsid + TIOCSCTTY + dup2 onto 0,1,2 + close slave_fd + if os.tcgetpgrp(0) != os.getpgrp(): + os.tcsetpgrp(0, os.getpgrp()) + _assert_invariants() # in the child, pre-exec — the parent cannot do this + _write_record(status_fd, SESSION_READY) + _await_ack(ack_fd, _ack_timeout()) # barrier: the target must not run before the parent records pgid + os.set_inheritable(status_fd, False) # successful exec closes it -> parent sees EOF + os.set_inheritable(ack_fd, False) + _restore_signals() # SIG_IGN survives exec + os.environ.pop(ACK_TIMEOUT_ENV, None) # a test hook never reaches the target + os.execvpe(command[0], command, os.environ) + except BaseException as exc: + try: + _write_record(status_fd, FAILED, _format_launch_error(exc)) + except BaseException: + pass # the parent falls back to EOF-without-marker plus the stderr pipe + os._exit(LAUNCH_FAILURE_EXIT_CODE) + + +def _run(argv: list) -> None: + slave_fd, status_fd, ack_fd = (int(argv[0]), int(argv[1]), int(argv[2])) + if argv[3] != "--": + raise ValueError(f"expected '--' before the command, got {argv[3]!r}") + main(slave_fd, status_fd, ack_fd, argv[4:]) + + +if __name__ == "__main__": + try: + _run(sys.argv[1:]) + except BaseException: # argv is malformed, so there is no status fd to report on + os._exit(LAUNCH_FAILURE_EXIT_CODE) diff --git a/render_machine/render_context.py b/render_machine/render_context.py index 2ea7e806..0ebecf32 100644 --- a/render_machine/render_context.py +++ b/render_machine/render_context.py @@ -13,6 +13,13 @@ from plain_modules import PlainModule from render_machine import triggers from render_machine.conformance_tests import CONFORMANCE_TESTS_DEFINITION_FILE_NAME, ConformanceTests +from render_machine.fix_loop_metrics import ( + CONFORMANCE_LOOP, + STRATEGY_SWITCH_PREFIX, + UNIT_LOOP, + FixLoopMetrics, + stalled_reason, +) from render_machine.render_types import ( AcceptanceTestPhase, ConformanceTestsRunningContext, @@ -95,6 +102,10 @@ def __init__( self.machine = None self.last_error_message: str | None = None + # Observations only — see render_machine/fix_loop_metrics.py. Deliberately not + # part of the snapshot: a rolled-back FRID still consumed the attempts it made, + # and hiding them would understate what convergence cost. + self.fix_loop_metrics = FixLoopMetrics() def set_machine(self, machine): self.machine = machine @@ -267,6 +278,27 @@ def start_fixing_unit_tests(self, on_limit_exceeded: Callable): self.unit_tests_running_context.fix_attempts += 1 if self.unit_tests_running_context.fix_attempts > MAX_UNITTEST_FIX_ATTEMPTS: on_limit_exceeded() + return + + # A unit loop that has stopped moving gets the same answer as one that ran out of + # attempts, just sooner. The separation is unusually clean here: across three + # benchmark renders every healthy functionality finished with unit_max_repeat=1, + # while the one that wedged reached 17 and burned eleven minutes getting to the + # attempt limit. Nothing has been observed in between, so acting on a streak of + # three risks little and skips that wait. + reason = stalled_reason( + self.fix_loop_metrics, + UNIT_LOOP, + module=self.module_name, + frid=self.frid_context.frid if self.frid_context else None, + ) + if reason is not None: + console.warning( + f"{STRATEGY_SWITCH_PREFIX} module={self.module_name} " + f"frid={self.frid_context.frid if self.frid_context else None} loop={UNIT_LOOP} " + f"{reason} action=give_up_on_patching" + ) + on_limit_exceeded() def _on_unit_test_limit_exceeded_in_implementation(self): self.machine.dispatch(triggers.RESTART_FRID_PROCESSING) @@ -390,6 +422,10 @@ def _handle_test_regeneration(self): ctx.conformance_tests_render_attempts += 1 ctx.fix_attempts = 0 ctx.regenerating_conformance_tests = False + # The stall that triggered this was measured against the test just deleted. Left + # standing, it condemns the replacement on its first failure and the whole + # regeneration budget is spent in seconds on tests that never got a second look. + self.fix_loop_metrics.start_over(CONFORMANCE_LOOP, module=self.module_name, frid=ctx.current_testing_frid) def _handle_retry_after_code_change(self): """Re-run the test that failed and triggered a code change.""" diff --git a/render_machine/render_types.py b/render_machine/render_types.py index 44cda7a7..8d659b39 100644 --- a/render_machine/render_types.py +++ b/render_machine/render_types.py @@ -89,6 +89,11 @@ def __init__( self.regenerating_conformance_tests: bool = False + # Whether this functionality has already had one fix request that told the + # API the loop was stuck. Once spent, a still-stuck loop stops asking and + # discards the test instead, so the middle rung cannot repeat. + self.asked_with_stall_context: bool = False + self.current_testing_frid_high_level_implementation_plan: Optional[str] = None self.previous_conformance_tests_issue_old: Optional[str] = None self.previous_conformance_tests_issue_frid: Optional[str] = None diff --git a/render_machine/render_utils.py b/render_machine/render_utils.py index 790ec61e..ca657f41 100644 --- a/render_machine/render_utils.py +++ b/render_machine/render_utils.py @@ -1,28 +1,78 @@ -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" + +# Every execution gets one end-of-file at spawn. A program that reconfigures its terminal +# before reading discards whatever is queued — `getpass` calls +# `tcsetattr(..., TCSAFLUSH, ...)`, and TCSAFLUSH means exactly that — so the EOF is gone +# by the time the read happens and the program waits for input nobody will send. It costs +# the script its whole timeout, and the fix loop reads that as a defect in the code. +# +# So the EOF is re-delivered while the target is quiet. Nothing else answers a test +# script's terminal reads, which is what makes repeating it safe: a program that is +# reading gets the EOF it was owed, and one that is not is unaffected. +EOF_BYTE = b"\x04" +QUIET_BEFORE_EOF_RESEND_SECONDS = 5.0 +MAX_EOF_RESENDS = 3 + +# 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 +104,345 @@ 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 + eof_resender = _QuietEofResender(process) + while True: + returncode = process.poll() + if returncode is not None: + outcome.target_exited(returncode) + else: + eof_resender.consider() + if stop_event is not None and stop_event.is_set(): + outcome.cancelled() + # An exit observed by this same poll wins over the expired deadline: the target had + # 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) + + +class _QuietEofResender: + """Re-delivers end-of-file to a target that has gone quiet. + + Quiet is the only evidence available from outside: the parent cannot see the child's + `tcsetattr`, so it watches for a target that is alive and has stopped producing + output. That describes a program blocked on a read, and also describes a program that + has nothing left to say. Both want the same answer. + + Bounded rather than continuous. A target that stays quiet through several deliveries + is not waiting on the terminal, and repeating forever would turn a stuck script into + a noisy stuck script. + """ + + def __init__(self, process: TerminalProcess) -> None: + self._process = process + self._enabled = True + self._resends = 0 + self._seen = -1 + self._since = time.monotonic() + + def consider(self) -> None: + if not self._enabled or self._resends >= MAX_EOF_RESENDS: + return + + produced = len(self._process.normalized_output()) + if produced != self._seen: + self._seen = produced + self._since = time.monotonic() + return + + if time.monotonic() - self._since < QUIET_BEFORE_EOF_RESEND_SECONDS: + return + + self._since = time.monotonic() + self._resends += 1 try: - os.killpg(os.getpgid(proc.pid), signal.SIGTERM) - except OSError: - proc.terminate() + self._process.write_input(EOF_BYTE) + except Exception as exc: # a target that cannot be written to is the wait's problem, not ours + self._enabled = False + console.debug(f"the end-of-file could not be re-delivered to a quiet target: {exc!r}") + return + console.debug( + f"re-delivered end-of-file to a quiet target " + f"(attempt {self._resends} of {MAX_EOF_RESENDS}); the spawn-time one may have been flushed" + ) + + +def _teardown(process: TerminalProcess, outcome: _ScriptOutcome) -> None: + """Releases every handle the backend owns, then classifies what teardown revealed.""" + try: + try: + 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: + 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. + """ + if output_file_path is None: + return + raw_file_path = output_file_path + RAW_OUTPUT_SUFFIX 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() + with open(raw_file_path, "wb") as raw_file: + raw_file.write(raw_output) + except OSError as exc: # a diagnostic artifact never changes the published outcome + console.debug(f"could not store the {script_type} script raw output: {exc}", color=MUTED_COLOR) + return + console.debug(f"{script_type} script raw output stored in: {raw_file_path}", color=MUTED_COLOR) + + +def _publish_exit( + script: str, + script_type: str, + exit_code: int, + output: str, + elapsed_time: float, + frid: Optional[str], + module: Optional[str], +) -> tuple[int, str, Optional[str]]: + with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", delete=False, suffix=".script_output") as temp_file: + temp_file.write(f"\n═════════════════════════ {script_type} Script Output ═════════════════════════\n") + temp_file.write(output) + temp_file.write("\n══════════════════════════════════════════════════════════════════════\n") + temp_file_path = temp_file.name + if exit_code != 0: + temp_file.write(f"{script_type} script {script} failed with exit code {exit_code}.\n") + else: + temp_file.write(f"{script_type} script {script} successfully passed.\n") + temp_file.write(f"{script_type} script execution time: {elapsed_time:.2f} seconds.\n") + + console.debug(f"{script_type} script output stored in: {temp_file_path.strip()}", color=MUTED_COLOR) + + if exit_code != 0: + if frid is not None: + console.info( + f"↻ The {script_type} script for functionality ID {frid} of module {module} has failed. " + f"Initiating the patching mode to automatically correct the discrepancies.", + color=RETRY_COLOR, + ) + else: + console.info( + f"↻ The {script_type} script has failed. " + f"Initiating the patching mode to automatically correct the discrepancies.", + color=RETRY_COLOR, + ) + else: + if frid is not None: + console.info( + f"✓ The {script_type} script for functionality ID {frid} of module {module} " + f"has passed successfully.", + color=SUCCESS_COLOR, + ) else: - proc.kill() + console.info(f"✓ All {script_type} scripts have passed successfully.", color=SUCCESS_COLOR) + return exit_code, output, temp_file_path -def _sanitize_script_output(script_output: str) -> str: - # this function removes the escape codes that clear the console - clear_console_escape_codes_pattern = r"(?:\033\[[^a-zA-Z]*[a-zA-Z])*\033\[2J(?:\033\[[^a-zA-Z]*[a-zA-Z])*" - pattern = re.compile(clear_console_escape_codes_pattern) - parts = pattern.split(script_output) +def _publish_environment_error( + script: str, script_type: str, detail: str, output: str +) -> tuple[int, str, Optional[str]]: + """The 69 channel: an infrastructure failure is never handed to the patcher.""" + issue = f"{script_type} script {script} could not be executed: {detail}" + with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", delete=False, suffix=".script_output") as temp_file: + temp_file.write(f"{issue}\n") + if output: + temp_file.write(f"{script_type} script output before the failure:\n{output}") + temp_file_path = temp_file.name + console.warning(f"{issue} {script_type} script output stored in: {temp_file_path}") + if output: + issue = f"{issue}\nPartial {script_type} script output:\n{output}" + return ENVIRONMENT_ERROR_EXIT_CODE, issue, temp_file_path - # take only the part after the last clear console escape code - return parts[-1] if len(parts) > 1 else script_output +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}." -def execute_script( # noqa: C901 + 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}" + ) + + 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( script: str, scripts_args: list[str], script_type: str, @@ -95,7 +451,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,128 +462,51 @@ def execute_script( # noqa: C901 cmd = [script_path] + scripts_args start_time = time.time() - proc = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - encoding="utf-8", - errors="replace", - start_new_session=(sys.platform != "win32"), - ) + execution = _run_script(cmd, script_timeout, stop_event) + elapsed_time = time.time() - start_time + outcome = execution.outcome - if sys.platform == "linux": - # Set the pipe size to 1MB to avoid buffer overflows - fcntl.fcntl(proc.stdout.fileno(), F_SETPIPE_SIZE, PIPE_SIZE_KB * 1024) # 1MB - - # Drain stdout in a background thread to prevent pipe buffer deadlock. - # macOS has a 64KB pipe buffer; without continuous draining, scripts that produce - # more output than that block on write and never exit, causing spurious timeouts. - output_chunks: list[str] = [] - - def _drain_stdout() -> None: - try: - for chunk in iter(lambda: proc.stdout.read(8192), ""): - output_chunks.append(chunk) - except (OSError, ValueError): - pass - - reader = threading.Thread(target=_drain_stdout, daemon=True) - reader.start() - - 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/render_machine/terminal_process.py b/render_machine/terminal_process.py new file mode 100644 index 00000000..05eb1991 --- /dev/null +++ b/render_machine/terminal_process.py @@ -0,0 +1,353 @@ +"""Platform-neutral terminal-process interface, shared constants, and backend dispatch. + +A `TerminalProcess` runs one command with a terminal behind all three of its standard +descriptors and owns every handle that arrangement needs. The POSIX implementation lives +in `render_machine._posix_pty` and the Windows ConPTY implementation in +`render_machine._conpty`. Only this module is imported by callers. +""" + +import importlib +import os +import sys +import threading +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple + +from plain2code_console import console +from plain2code_exceptions import RenderCancelledError + +if TYPE_CHECKING: # both import this module at runtime, so neither may be imported here + from render_machine.output_normalizer import OutputNormalizer + from render_machine.terminal_queries import TerminalQueryResponder + +# Break-glass override, not a tuning knob: set CODEPLAIN_NO_PTY=1 to run scripts on the +# legacy pipe backend when PTY allocation fails in an environment. It is never selected +# automatically — a failed openpty() is an environment error, because a silent downgrade +# would make execution behaviour machine-dependent again. Every use is a bug report worth +# filing; the variable goes away with the legacy path (ENG-34). +NO_PTY_ENV_VAR = "CODEPLAIN_NO_PTY" +NO_PTY_ENABLED_VALUE = "1" + +# Launch, reader, and writer infrastructure failures surface on the renderer's existing +# environment-error channel rather than being handed to the LLM patcher as a test failure. +ENVIRONMENT_ERROR_EXIT_CODE = 69 + +# The terminal the child sees. Fixed rather than inherited: execution behaviour must not +# depend on the size of the window Codeplain happens to be running in. +TERMINAL_COLUMNS = 120 +TERMINAL_ROWS = 40 + +DEFAULT_TERM = "xterm-256color" + +# Every duration below is a monotonic budget, never wall time. +HANDSHAKE_TIMEOUT_SECONDS = 20.0 +SIGTERM_GRACE_PERIOD_SECONDS = 3.0 +# Bounds the delivery of a graceful control byte, which on Windows travels through a +# synchronous pipe a wedged target may never read. It is never the grace period itself: +# queue delay must not silently consume the handler's time. +CONTROL_DELIVERY_DEADLINE_SECONDS = 2.0 +GRACE_TICK_SECONDS = 0.05 +REAP_DEADLINE_SECONDS = 5.0 +DRAIN_DEADLINE_SECONDS = 2.0 +DRAIN_QUIET_PERIOD_SECONDS = 0.1 +POLL_INTERVAL_SECONDS = 0.05 + +# The final drain is bounded by bytes as well as by time: a descendant that escaped the +# process group can keep the master readable forever. +DRAIN_MAX_BYTES = 4 * 1024 * 1024 +READ_CHUNK_BYTES = 65536 + +# Bounds on the ordered input queue. Reserved capacity is an admission partition for +# spawn/control items, never a way to jump the FIFO order. +MAX_INPUT_ITEM_BYTES = 64 * 1024 +MAX_PENDING_INPUT_BYTES = 256 * 1024 +RESERVED_INPUT_BYTES = 8 * 1024 +# The queue is bounded in items as well as in bytes: a queue entry costs far more than +# the bytes it carries, so the byte budget alone does not bound small items. +MAX_PENDING_INPUT_ITEMS = 1024 +RESERVED_INPUT_ITEMS = 64 +INPUT_WRITE_BUDGET_BYTES = 64 * 1024 + +# Head and tail retained from the launcher's stderr, so a flooding launcher cannot hand +# the parent an unbounded buffer while the reads continue. +LAUNCHER_STDERR_CAP_BYTES = 16 * 1024 + +# Published when close() has waited out its bound and the reader is still running: such a +# reader can still append output or fail afterwards, so the transcript it produced cannot +# be trusted and the execution is an environment failure. +READER_STALL_DETAIL = "the terminal output reader did not terminate within its shutdown bound" + +# The two owners a descriptor bundle can have. One field carrying one of these is what +# keeps a rollback and a reader from ever disagreeing about who releases what. +OWNER_PARENT = "parent" +OWNER_READER = "reader" + +# What a timeout diagnostic says about the input the target was given. The spawn-time +# end-of-file is best-effort by nature: a program that flushes or reconfigures its +# terminal before reading — getpass's TCSAFLUSH, a curses initialization — discards the +# queued byte and then blocks on input nothing will send. ConPTY, which cannot deliver +# an end-of-file at all, states its own note instead. +NO_INPUT_NOTE = ( + " An end-of-file was queued at the script's terminal at spawn and re-delivered while " + "the target stayed quiet; a program still waiting after that is blocked on something " + "other than the input it was given." +) + + +class InputDisposition(Enum): + """Immediate whole-item backend admission — never a delivery receipt.""" + + ACCEPTED = "accepted" + BACKPRESSURE = "backpressure" + CLOSED = "closed" + + +@dataclass(frozen=True) +class InputWriteResult: + disposition: InputDisposition + accepted_bytes: int + + +class TerminalProcessError(Exception): + """Base class for failures the terminal backend reports to the renderer.""" + + +class TerminalEnvironmentError(TerminalProcessError): + """Infrastructure failure — reported on the environment-error channel.""" + + exit_code = ENVIRONMENT_ERROR_EXIT_CODE + + +class TerminalLaunchError(TerminalEnvironmentError): + """The launcher never reached the target command.""" + + +class TerminalReaderError(TerminalEnvironmentError): + """The output reader failed, so the target's output is no longer being drained.""" + + +class TerminalProcess: + """Interface implemented by every backend. + + `spawn()` is bounded and cancellable; `close()` is idempotent and releases every + handle the backend owns. Instances are single-use. + + Output accumulation is identical on every backend — one lock over a decoded list and a + raw buffer, fed by whatever read loop the backend runs — so it is implemented here + rather than three times over. A backend supplies its read loop, its normalizer and its + query responder, calls `super().__init__()` before either, and inherits the rest. + """ + + normalizer: "OutputNormalizer" + query_responder: "TerminalQueryResponder" + + def __init__(self) -> None: + self.reader_failed = threading.Event() + self.reader_exc: Optional[BaseException] = None + self._stop_event = threading.Event() + + self._output_lock = threading.Lock() + self._decoded: List[str] = [] + self._raw = bytearray() + + def spawn( + self, + command: Sequence[str], + cwd: Optional[str] = None, + env: Optional[dict] = None, + terminal_size: Tuple[int, int] = (TERMINAL_COLUMNS, TERMINAL_ROWS), + stop_event: Optional[threading.Event] = None, + ) -> None: + raise NotImplementedError + + def poll(self) -> Optional[int]: + """Non-blocking exit status, or None while the target runs. Reaps on completion.""" + raise NotImplementedError + + def read_output(self) -> str: + """Decoded output accumulated since the previous call.""" + with self._output_lock: + text = "".join(self._decoded) + self._decoded.clear() + return text + + def read_raw_output(self) -> bytes: + """Raw output bytes accumulated since the previous call.""" + with self._output_lock: + data = bytes(self._raw) + self._raw.clear() + return data + + def normalized_output(self) -> str: + """The rendered transcript so far. Cumulative, unlike `read_output()`.""" + return self.normalizer.text() + + @property + def terminal_reply_failed(self) -> bool: + """True when a reply the target was waiting for could not be delivered. + + Independent of `reader_failed`: both pumps can be healthy while one required + protocol response was never accepted. + """ + return self.query_responder.reply_failed + + def terminal_reply_detail(self) -> str: + """Query kinds and pressure reasons behind `terminal_reply_failed`.""" + return self.query_responder.failure_detail() + + def _feed_output(self, chunk: bytes, decoder) -> None: + """The one entry point every read loop hands its bytes to.""" + text = decoder.decode(chunk) + with self._output_lock: + self._raw += chunk + if text: + self._decoded.append(text) + self.normalizer.feed(chunk) # outside the output lock: parsing must not block read_output() + + def _flush_decoder(self, decoder) -> None: + tail = decoder.decode(b"", final=True) # a trailing partial sequence becomes U+FFFD + if tail: + with self._output_lock: + self._decoded.append(tail) + + def _check_cancelled(self) -> None: + if self._stop_event.is_set(): + raise RenderCancelledError() + + def write_input(self, data: bytes) -> InputWriteResult: + raise NotImplementedError + + def resize(self, columns: int, rows: int) -> None: + """Applies a new terminal size to the live target and the rendering parser.""" + raise NotImplementedError + + def terminate_tree(self, grace: float = SIGTERM_GRACE_PERIOD_SECONDS) -> None: + raise NotImplementedError + + def close(self) -> None: + raise NotImplementedError + + def no_input_note(self) -> str: + """What a timeout diagnostic says about the end-of-file this backend can give. + + A backend that gives the target end-of-file at spawn needs nothing beyond the + default; one that cannot says so itself. The note belongs to the backend that ran, + not to the platform the renderer is on: under the escape hatch on Windows the pipe + backend delivers end-of-file immediately, and a note keyed on `sys.platform` would + describe a backend that never ran. + """ + return NO_INPUT_NOTE + + def infrastructure_failure(self) -> Optional[str]: + """Detail of a failed backend pump, or None while they are all healthy. + + The output reader is the one pump every backend has. A backend that runs more of + them — the Windows input writer — reports them here too, so the execution loop has + one question to ask rather than one per platform. + """ + if self.reader_failed.is_set(): + return f"the terminal output reader failed: {self.reader_exc!r}" + return None + + def _publish_reader_stall(self) -> None: + """Publishes a reader that close() could not join, and refuses to return quietly. + + A backend whose reader is still running owns handles it has not released and can + still append output, so close() must not report a released backend: the stall is + published on the reader's own channel and raised. + """ + error = TerminalReaderError(READER_STALL_DETAIL) + if self.reader_exc is None: + self.reader_exc = error + self.reader_failed.set() + raise error + + def __enter__(self) -> "TerminalProcess": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + +def pty_disabled_by_environment() -> bool: + """Reads the override from Codeplain's own environment, once per spawn. + + Only the exact value "1" selects the pipe backend; unset, empty, or anything else + leaves the PTY in place. Env-only, so it stays a break-glass control rather than a + configuration axis a workflow can be built on. + """ + return os.environ.get(NO_PTY_ENV_VAR) == NO_PTY_ENABLED_VALUE + + +def child_environment(env: Optional[dict]) -> dict: + """The environment a target runs in, minus the controls it must not observe. + + A rendered script that could see the override could branch on it, which would turn a + support control into part of the contract. + """ + child_env = dict(os.environ if env is None else env) + child_env.pop(NO_PTY_ENV_VAR, None) + return child_env + + +def terminal_child_environment(env: Optional[dict]) -> dict: + """`child_environment()` plus the policy a target with a terminal of its own runs under. + + TERM is declared rather than inherited, so a toolchain's rendering does not depend on + the terminal Codeplain happens to be running in. GIT_TERMINAL_PROMPT is cleared because + git reads the terminal directly — /dev/tty on POSIX, the console on Windows — so neither + a synthetic end-of-file nor a redirected stdin can reach a credential prompt, and failing + is the only bounded outcome. + """ + child_env = child_environment(env) + term = child_env.get("TERM") + child_env["TERM"] = term if term else DEFAULT_TERM + child_env["GIT_TERMINAL_PROMPT"] = "0" + return child_env + + +# Every backend module, each publishing the teardown budget its own constants add up to. +# A module whose platform this is not refuses to import, which is what keeps the budget +# below a question about this machine rather than about the codebase. +_BACKEND_MODULES = ("render_machine._legacy_pipe", "render_machine._posix_pty", "render_machine._conpty") + + +def teardown_budget_seconds() -> float: + """The longest teardown any backend reachable on this platform may spend. + + A caller that waits for a render to stop has to outlast it. The three pipelines are + different lengths — the ConPTY one is much the longest — so a wait assembled from the + POSIX constants would report a render that did not stop while the backend was still + inside the bound its own constants grant it. + """ + budgets = [] + for module_name in _BACKEND_MODULES: + try: + module = importlib.import_module(module_name) + except ImportError: # this backend is not built on this platform + continue + budgets.append(module.TEARDOWN_BUDGET_SECONDS) + return max(budgets) + + +def create_terminal_process() -> TerminalProcess: + """The one construction site: returns the backend this execution runs on.""" + if pty_disabled_by_environment(): + console.warning( + f"{NO_PTY_ENV_VAR}={NO_PTY_ENABLED_VALUE} is set, so this script runs on the legacy pipe " + "backend: terminal semantics are disabled and isatty() will be false in the script. " + "Unset it once the environment problem that needed it is resolved, and please report that problem." + ) + from render_machine._legacy_pipe import LegacyPipeProcess + + return LegacyPipeProcess() + + if sys.platform == "win32": + from render_machine._conpty import ConPtyProcess + + return ConPtyProcess() + + from render_machine._posix_pty import PosixPtyProcess + + return PosixPtyProcess() diff --git a/render_machine/terminal_queries.py b/render_machine/terminal_queries.py new file mode 100644 index 00000000..05e4123e --- /dev/null +++ b/render_machine/terminal_queries.py @@ -0,0 +1,181 @@ +"""Platform-neutral state for the terminal queries the reader answers live. + +A target under `TERM=xterm-256color` may emit a device-status, cursor-position or +device-attributes query and block until the terminal replies. A real terminal always +answers, so the parser has to run in the reader rather than after the fact — and the reply +has to be admitted without the reader ever waiting, since the reader is the only drainer of +the target's output. + +The responder owns the obligation that admission creates, for the item's whole lifecycle: + +* While `ACTIVE`, a query performs exactly one non-blocking whole-item admission and + registers an obligation. Immediate pressure, a native write failure and a teardown + discard all resolve it as not delivered and record `kind` plus `reason`. +* The foreground switches the responder to `QUIESCED` as soon as it observes an execution + outcome, before stopping either input pump. A query first seen after that renders but + records nothing: there is no client left whose query can be answered. +* Obligations registered while `ACTIVE` keep reporting, even when teardown is what + discovers the failure. +* Every failure is counted, but only a bounded sample is retained — one record per distinct + kind and reason. A target that queries in a loop against a closed channel fails a reply + per query, and a diagnostic must not grow with it. + +One lock linearizes the query callback with the `ACTIVE -> QUIESCED` transition, so a +callback either admits while active or observes quiescence — never both, and never neither. +It is reentrant because an admission that is rejected outright resolves its obligation +inside the same call. Completion callbacks update the recorded failures through this same +state but never invoke backend code while holding the lock. + +This is separate from a reader or writer failure: the pumps can be healthy while one +required protocol response could not be accepted. +""" + +import functools +import threading +from dataclasses import dataclass +from enum import Enum +from typing import Callable, List, Optional, Set + +from render_machine.terminal_process import InputDisposition + +# Reasons a reply can fail to reach the target. +REASON_ADMISSION_RAISED = "admission raised" +REASON_DISCARDED = "discarded before delivery" +REASON_WRITE_FAILED = "write failed" + +# How many distinct failures are kept. A target that queries in a loop against a closed +# channel fails one reply per query, so the history is a sample plus a count, never a log. +MAX_TRACKED_FAILURES = 16 + +# A backend admission: hands the reply over without blocking, then resolves the completion +# callback with None when the last native byte lands, or with a reason when it cannot. +CompletionCallback = Callable[[Optional[str]], None] +AdmitReply = Callable[[bytes, CompletionCallback], None] + +# Resolution of one queued input item, as the backend's queue reports it. +ResolveCallback = Callable[[InputDisposition, Optional[BaseException]], None] + + +def reply_resolution(on_complete: CompletionCallback) -> ResolveCallback: + """Maps one queue resolution onto the responder's delivered / not-delivered contract.""" + + def resolved(disposition: InputDisposition, error: Optional[BaseException]) -> None: + if error is not None: + on_complete(f"{REASON_WRITE_FAILED}: {error!r}") + elif disposition is InputDisposition.ACCEPTED: + on_complete(None) + else: + on_complete(f"{REASON_DISCARDED} ({disposition.value})") + + return resolved + + +class ResponderState(Enum): + ACTIVE = "active" + QUIESCED = "quiesced" + + +@dataclass(frozen=True) +class TerminalReplyFailure: + kind: str + reason: str + + def __str__(self) -> str: + return f"{self.kind} reply {self.reason}" + + +class _Obligation: + """One admitted reply, resolved exactly once by whoever retires it.""" + + __slots__ = ("kind", "resolved") + + def __init__(self, kind: str) -> None: + self.kind = kind + self.resolved = False + + +class TerminalQueryResponder: + """Tracks the delivery obligation of every terminal reply the parser produces. + + A responder built without an admission callable — the legacy backend, which has no + input channel — starts quiesced, so a printed escape query creates no obligation. + """ + + def __init__(self, admit: Optional[AdmitReply] = None) -> None: + self._lock = threading.RLock() + self._admit = admit + self._state = ResponderState.ACTIVE if admit is not None else ResponderState.QUIESCED + self._outstanding: Set[_Obligation] = set() + self._failures: List[TerminalReplyFailure] = [] + self.admitted = 0 + self.render_only = 0 + self.failures_recorded = 0 + + @property + def state(self) -> ResponderState: + with self._lock: + return self._state + + @property + def reply_failed(self) -> bool: + with self._lock: + return self.failures_recorded > 0 + + @property + def failures(self) -> List[TerminalReplyFailure]: + """The retained sample: distinct kind and reason pairs, capped.""" + with self._lock: + return list(self._failures) + + @property + def outstanding(self) -> int: + with self._lock: + return len(self._outstanding) + + def failure_detail(self) -> str: + """The sample, then how many failures it does not name. Bounded by construction.""" + with self._lock: + detail = "; ".join(str(failure) for failure in self._failures) + omitted = self.failures_recorded - len(self._failures) + if omitted <= 0: + return detail + summary = f"...[{omitted} further reply failures]..." + return f"{detail}; {summary}" if detail else summary + + def quiesce(self) -> None: + """Idempotent, foreground-triggered. Outstanding obligations keep reporting.""" + with self._lock: + self._state = ResponderState.QUIESCED + + def answer(self, kind: str, payload: bytes) -> None: + """The parser's reply hook, called on the reader thread. Never waits, never raises.""" + with self._lock: + if self._state is ResponderState.QUIESCED or self._admit is None: + self.render_only += 1 + return + obligation = _Obligation(kind) + self._outstanding.add(obligation) + self.admitted += 1 + try: + self._admit(payload, functools.partial(self._resolve, obligation)) + except BaseException as exc: + self._resolve(obligation, f"{REASON_ADMISSION_RAISED} {exc!r}") + + def _resolve(self, obligation: _Obligation, reason: Optional[str]) -> None: + with self._lock: + if obligation.resolved: + return + obligation.resolved = True + self._outstanding.discard(obligation) + if reason is not None: + self._record_failure(obligation.kind, reason) + + def _record_failure(self, kind: str, reason: str) -> None: + """Counts every failure; retains one record per distinct kind and reason, capped.""" + self.failures_recorded += 1 + if len(self._failures) >= MAX_TRACKED_FAILURES: + return # the counter carries the rest, so the list cannot grow + failure = TerminalReplyFailure(kind, reason) + if failure in self._failures: # the cap keeps this scan bounded + return + self._failures.append(failure) diff --git a/requirements.txt b/requirements.txt index e2f10c9c..3acded30 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ gitpython==3.1.55 pytest==9.1.1 textual>=7.5.0 networkx==3.6.1 +pyte==0.8.2 transitions==0.9.3 sentry-sdk==2.66.1 diff --git a/system_config.py b/system_config.py index a97d7f28..c2f04351 100644 --- a/system_config.py +++ b/system_config.py @@ -32,11 +32,10 @@ def _resolve_version() -> str: # codeplain checkout's repo, not the caller's working directory # (codeplain may be run from anywhere). source_dir = os.path.dirname(os.path.abspath(__file__)) - repo = git.Repo(source_dir, search_parent_directories=True) - - # Highest version tag, regardless of branch ancestry (a dev run may sit - # on a feature branch that doesn't descend from the latest release tag). - latest_tag = repo.git.tag("--list", "--sort=-v:refname").splitlines()[0] + with git.Repo(source_dir, search_parent_directories=True) as repo: + # Highest version tag, regardless of branch ancestry (a dev run may sit + # on a feature branch that doesn't descend from the latest release tag). + latest_tag = repo.git.tag("--list", "--sort=-v:refname").splitlines()[0] return latest_tag.lstrip("v") except Exception: return "0.0.0.dev0" diff --git a/tests/diagnostics/node_tty_probe.py b/tests/diagnostics/node_tty_probe.py new file mode 100644 index 00000000..688c76ff --- /dev/null +++ b/tests/diagnostics/node_tty_probe.py @@ -0,0 +1,215 @@ +"""Manual diagnostic: run `node --version` under the ADR-001 stale controlling-TTY topology. + +Usage: + + python tests/diagnostics/node_tty_probe.py + +Runs Node with fd 0 bound to a terminal owned by a session the process has just left — +the same topology `tests/test_pty_characterization.py` constructs — and prints platform, +Node version, exit status or terminating signal, and captured stderr. + +This is not collected by pytest and never gates anything: the observed outcome varies by +macOS and Node version. A green result does not invalidate ADR-001; it only narrows the +blast radius to specific macOS/Node combinations. The script always exits 0, including +when Node is not installed. +""" + +import json +import os +import platform +import re +import shutil +import signal +import subprocess +import sys +import time + +NODE_TIMEOUT_SECONDS = 30 +HARNESS_TIMEOUT_SECONDS = 60 +CLEANUP_TIMEOUT_SECONDS = 10 +KILL_POLL_TIMEOUT_SECONDS = 5 + +# Node leaves the harness's session, so the outer timeout path has to kill it by pid; +# the harness announces the pid on stderr before waiting on it. +NODE_PID_PATTERN = re.compile(r"^node_pid=(\d+)$", re.MULTILINE) + +# Source of the middle process: it owns the terminal, then spawns Node into a new +# session with the terminal still on fd 0. +HARNESS_SOURCE = """ +import fcntl +import json +import os +import subprocess +import sys +import termios + +NODE_TIMEOUT_SECONDS = %d +CLEANUP_TIMEOUT_SECONDS = %d + + +def reap(process): + process.kill() + try: + process.communicate(timeout=CLEANUP_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + pass + + +def main(): + node_path = sys.argv[1] + + if os.getsid(0) != os.getpid(): + os.setsid() + + report = {"harness_sid": os.getsid(0), "error": None} + master_fd, slave_fd = os.openpty() + process = None + try: + fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0) + report["controlling_tty"] = os.ttyname(slave_fd) + + process = subprocess.Popen( + [node_path, "--version"], + stdin=slave_fd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True, + ) + sys.stderr.write("node_pid=%%d\\n" %% process.pid) + sys.stderr.flush() + + stdout, stderr = process.communicate(timeout=NODE_TIMEOUT_SECONDS) + report["returncode"] = process.returncode + report["stdout"] = stdout.decode(errors="replace") + report["stderr"] = stderr.decode(errors="replace") + except subprocess.TimeoutExpired: + reap(process) + report["error"] = "node did not exit within %%d seconds" %% NODE_TIMEOUT_SECONDS + except Exception as exc: + if process is not None and process.poll() is None: + reap(process) + report["error"] = "%%s: %%s" %% (type(exc).__name__, exc) + finally: + # The master is held open for the lifetime of the child so reads on the slave + # cannot fail with EIO. + os.close(slave_fd) + os.close(master_fd) + + sys.stdout.write(json.dumps(report)) + sys.stdout.flush() + + +main() +""" % ( + NODE_TIMEOUT_SECONDS, + CLEANUP_TIMEOUT_SECONDS, +) + + +def kill_process_group(pid): + """Best-effort teardown of the orphaned child; it is a session leader, so pgid == pid.""" + for sig in (signal.SIGTERM, signal.SIGKILL): + try: + os.killpg(pid, sig) + except (ProcessLookupError, PermissionError): + return + deadline = time.monotonic() + KILL_POLL_TIMEOUT_SECONDS + while time.monotonic() < deadline: + try: + os.killpg(pid, 0) + except (ProcessLookupError, PermissionError): + return + time.sleep(0.05) + + +def reap(process): + """Terminates the harness, escalating to SIGKILL, and returns whatever it had written.""" + if process.poll() is None: + process.terminate() + try: + return process.communicate(timeout=CLEANUP_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + try: + return process.communicate(timeout=CLEANUP_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + return b"", b"" + + +def describe_platform(): + if sys.platform == "darwin": + completed = subprocess.run(["sw_vers"], capture_output=True, timeout=30) + return completed.stdout.decode(errors="replace").strip() + return " ".join(platform.uname()) + + +def node_version(node_path): + completed = subprocess.run([node_path, "--version"], capture_output=True, timeout=NODE_TIMEOUT_SECONDS) + return completed.stdout.decode(errors="replace").strip() or "unknown" + + +def describe_exit(report): + if report.get("error"): + return report["error"] + + returncode = report.get("returncode") + if returncode is None: + return "unknown" + if returncode < 0: + return "terminated by signal %d (%s)" % (-returncode, signal.Signals(-returncode).name) + return "exit status %d" % returncode + + +def run_probe(node_path): + process = subprocess.Popen( + [sys.executable, "-c", HARNESS_SOURCE, node_path], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + stdout, stderr = process.communicate(timeout=HARNESS_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + stdout, stderr = reap(process) + match = NODE_PID_PATTERN.search(stderr.decode(errors="replace")) + if match: + kill_process_group(int(match.group(1))) + return {"error": "harness did not finish within %d seconds" % HARNESS_TIMEOUT_SECONDS} + + if process.returncode != 0 or not stdout.strip(): + return {"error": "harness failed: %s" % stderr.decode(errors="replace").strip()} + return json.loads(stdout.decode()) + + +def main(): + print("=== ADR-001 stale controlling-TTY probe (diagnostic, non-gating) ===") + print("platform:") + print(describe_platform()) + + if sys.platform == "win32": + print("node: not probed - the topology relies on setsid() and TIOCSCTTY, which are POSIX-only") + return 0 + + node_path = shutil.which("node") + if node_path is None: + print("node: not installed - nothing to probe") + return 0 + + print("node path: %s" % node_path) + print("node version: %s" % node_version(node_path)) + + report = run_probe(node_path) + print("controlling tty: %s" % report.get("controlling_tty", "n/a")) + print("result: %s" % describe_exit(report)) + print("stdout: %s" % (report.get("stdout", "").strip() or "")) + print("stderr: %s" % (report.get("stderr", "").strip() or "")) + print("A green result does not invalidate ADR-001; it narrows the blast radius.") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as exc: # the diagnostic must never fail the caller + print("probe could not run: %s: %s" % (type(exc).__name__, exc)) + sys.exit(0) diff --git a/tests/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/fixtures/terminal_output/fullscreen.normalized b/tests/fixtures/terminal_output/fullscreen.normalized new file mode 100644 index 00000000..880ff8d6 --- /dev/null +++ b/tests/fixtures/terminal_output/fullscreen.normalized @@ -0,0 +1,13 @@ + BUILD DASHBOARD frame 11 + ---------------------------------------------- + suite-01 running 1/10 cases + suite-02 running 2/10 cases + suite-03 running 3/10 cases + suite-04 running 4/10 cases + suite-05 running 5/10 cases + suite-06 running 6/10 cases + suite-07 passed 7/10 cases + suite-08 passed 8/10 cases + ---------------------------------------------- + elapsed 11s +BUILD FAILED: suite-03 case 7 timed out diff --git a/tests/fixtures/terminal_output/fullscreen.raw b/tests/fixtures/terminal_output/fullscreen.raw new file mode 100644 index 00000000..01b842b5 --- /dev/null +++ b/tests/fixtures/terminal_output/fullscreen.raw @@ -0,0 +1,145 @@ + BUILD DASHBOARD frame 00 + ---------------------------------------------- + suite-01 running 0/10 cases + suite-02 running 0/10 cases + suite-03 running 0/10 cases + suite-04 running 0/10 cases + suite-05 running 0/10 cases + suite-06 running 0/10 cases + suite-07 running 0/10 cases + suite-08 running 0/10 cases + ---------------------------------------------- + elapsed 0s + BUILD DASHBOARD frame 01 + ---------------------------------------------- + suite-01 running 1/10 cases + suite-02 running 2/10 cases + suite-03 running 3/10 cases + suite-04 running 4/10 cases + suite-05 running 5/10 cases + suite-06 running 6/10 cases + suite-07 passed 7/10 cases + suite-08 passed 8/10 cases + ---------------------------------------------- + elapsed 1s + BUILD DASHBOARD frame 02 + ---------------------------------------------- + suite-01 running 2/10 cases + suite-02 running 4/10 cases + suite-03 running 6/10 cases + suite-04 passed 8/10 cases + suite-05 running 0/10 cases + suite-06 running 2/10 cases + suite-07 running 4/10 cases + suite-08 running 6/10 cases + ---------------------------------------------- + elapsed 2s + BUILD DASHBOARD frame 03 + ---------------------------------------------- + suite-01 running 3/10 cases + suite-02 running 6/10 cases + suite-03 passed 9/10 cases + suite-04 running 2/10 cases + suite-05 running 5/10 cases + suite-06 passed 8/10 cases + suite-07 running 1/10 cases + suite-08 running 4/10 cases + ---------------------------------------------- + elapsed 3s + BUILD DASHBOARD frame 04 + ---------------------------------------------- + suite-01 running 4/10 cases + suite-02 passed 8/10 cases + suite-03 running 2/10 cases + suite-04 running 6/10 cases + suite-05 running 0/10 cases + suite-06 running 4/10 cases + suite-07 passed 8/10 cases + suite-08 running 2/10 cases + ---------------------------------------------- + elapsed 4s + BUILD DASHBOARD frame 05 + ---------------------------------------------- + suite-01 running 5/10 cases + suite-02 running 0/10 cases + suite-03 running 5/10 cases + suite-04 running 0/10 cases + suite-05 running 5/10 cases + suite-06 running 0/10 cases + suite-07 running 5/10 cases + suite-08 running 0/10 cases + ---------------------------------------------- + elapsed 5s + BUILD DASHBOARD frame 06 + ---------------------------------------------- + suite-01 running 6/10 cases + suite-02 running 2/10 cases + suite-03 passed 8/10 cases + suite-04 running 4/10 cases + suite-05 running 0/10 cases + suite-06 running 6/10 cases + suite-07 running 2/10 cases + suite-08 passed 8/10 cases + ---------------------------------------------- + elapsed 6s + BUILD DASHBOARD frame 07 + ---------------------------------------------- + suite-01 passed 7/10 cases + suite-02 running 4/10 cases + suite-03 running 1/10 cases + suite-04 passed 8/10 cases + suite-05 running 5/10 cases + suite-06 running 2/10 cases + suite-07 passed 9/10 cases + suite-08 running 6/10 cases + ---------------------------------------------- + elapsed 7s + BUILD DASHBOARD frame 08 + ---------------------------------------------- + suite-01 passed 8/10 cases + suite-02 running 6/10 cases + suite-03 running 4/10 cases + suite-04 running 2/10 cases + suite-05 running 0/10 cases + suite-06 passed 8/10 cases + suite-07 running 6/10 cases + suite-08 running 4/10 cases + ---------------------------------------------- + elapsed 8s + BUILD DASHBOARD frame 09 + ---------------------------------------------- + suite-01 passed 9/10 cases + suite-02 passed 8/10 cases + suite-03 passed 7/10 cases + suite-04 running 6/10 cases + suite-05 running 5/10 cases + suite-06 running 4/10 cases + suite-07 running 3/10 cases + suite-08 running 2/10 cases + ---------------------------------------------- + elapsed 9s + BUILD DASHBOARD frame 10 + ---------------------------------------------- + suite-01 running 0/10 cases + suite-02 running 0/10 cases + suite-03 running 0/10 cases + suite-04 running 0/10 cases + suite-05 running 0/10 cases + suite-06 running 0/10 cases + suite-07 running 0/10 cases + suite-08 running 0/10 cases + ---------------------------------------------- + elapsed 10s + BUILD DASHBOARD frame 11 + ---------------------------------------------- + suite-01 running 1/10 cases + suite-02 running 2/10 cases + suite-03 running 3/10 cases + suite-04 running 4/10 cases + suite-05 running 5/10 cases + suite-06 running 6/10 cases + suite-07 passed 7/10 cases + suite-08 passed 8/10 cases + ---------------------------------------------- + elapsed 11s +BUILD FAILED: suite-03 case 7 timed out diff --git a/tests/fixtures/terminal_output/nohup_build.normalized b/tests/fixtures/terminal_output/nohup_build.normalized new file mode 100644 index 00000000..d98d558c --- /dev/null +++ b/tests/fixtures/terminal_output/nohup_build.normalized @@ -0,0 +1,3 @@ +stdout is a terminal, colour enabled +compiled 12 modules +warning: 1 deprecated call in module 07 diff --git a/tests/fixtures/terminal_output/nohup_build.raw b/tests/fixtures/terminal_output/nohup_build.raw new file mode 100644 index 00000000..2976617d --- /dev/null +++ b/tests/fixtures/terminal_output/nohup_build.raw @@ -0,0 +1,3 @@ +stdout is a terminal, colour enabled + compiling module 01/12 compiling module 02/12 compiling module 03/12 compiling module 04/12 compiling module 05/12 compiling module 06/12 compiling module 07/12 compiling module 08/12 compiling module 09/12 compiling module 10/12 compiling module 11/12 compiling module 12/12 compiled 12 modules +warning: 1 deprecated call in module 07 diff --git a/tests/fixtures/terminal_output/npm_install.normalized b/tests/fixtures/terminal_output/npm_install.normalized new file mode 100644 index 00000000..7c6b6073 --- /dev/null +++ b/tests/fixtures/terminal_output/npm_install.normalized @@ -0,0 +1 @@ +added 69 packages in 3s diff --git a/tests/fixtures/terminal_output/npm_install.raw b/tests/fixtures/terminal_output/npm_install.raw new file mode 100644 index 00000000..59f79041 --- /dev/null +++ b/tests/fixtures/terminal_output/npm_install.raw @@ -0,0 +1,3 @@ +⠙⠹⠸⠼⠴⠦⠧⠇⠏⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⠋⠙ +added 69 packages in 3s +⠙ \ No newline at end of file diff --git a/tests/fixtures/terminal_output/pytest_color.normalized b/tests/fixtures/terminal_output/pytest_color.normalized new file mode 100644 index 00000000..9bacef05 --- /dev/null +++ b/tests/fixtures/terminal_output/pytest_color.normalized @@ -0,0 +1,20 @@ +....F [100%] +======================================================= FAILURES ======================================================= +________________________________________________ test_reports_a_failure ________________________________________________ + + def test_reports_a_failure(): + expected = {"name": "widget", "count": 3} + actual = {"name": "widget", "count": 4} +> assert actual == expected +E AssertionError: assert {'name': 'widget', 'count': 4} == {'name': 'widget', 'count': 3} +E +E Omitting 1 identical items, use -vv to show +E Differing items: +E {'count': 4} != {'count': 3} +E Use -v to get more diff + +test_sample.py:16: AssertionError +=============================================== short test summary info ================================================ +FAILED test_sample.py::test_reports_a_failure - AssertionError: assert {'name': 'widget', 'count': 4} == {'name': 'widge +t', 'count': 3} +1 failed, 4 passed in 0.02s diff --git a/tests/fixtures/terminal_output/pytest_color.raw b/tests/fixtures/terminal_output/pytest_color.raw new file mode 100644 index 00000000..d2225edf --- /dev/null +++ b/tests/fixtures/terminal_output/pytest_color.raw @@ -0,0 +1,19 @@ +....F [100%] +======================================================= FAILURES ======================================================= +________________________________________________ test_reports_a_failure ________________________________________________ + + def test_reports_a_failure(): + expected = {"name": "widget", "count": 3} + actual = {"name": "widget", "count": 4} +> assert actual == expected +E AssertionError: assert {'name': 'widget', 'count': 4} == {'name': 'widget', 'count': 3} +E  +E Omitting 1 identical items, use -vv to show +E Differing items: +E {'count': 4} != {'count': 3} +E Use -v to get more diff + +test_sample.py:16: AssertionError +=============================================== short test summary info ================================================ +FAILED test_sample.py::test_reports_a_failure - AssertionError: assert {'name': 'widget', 'count': 4} == {'name': 'widget', 'count': 3} +1 failed, 4 passed in 0.02s diff --git a/tests/fixtures/terminal_output/spinner.normalized b/tests/fixtures/terminal_output/spinner.normalized new file mode 100644 index 00000000..b04a8d1e --- /dev/null +++ b/tests/fixtures/terminal_output/spinner.normalized @@ -0,0 +1,2 @@ +[####################] 100% done +installed 60 packages diff --git a/tests/fixtures/terminal_output/spinner.raw b/tests/fixtures/terminal_output/spinner.raw new file mode 100644 index 00000000..bf0779c2 --- /dev/null +++ b/tests/fixtures/terminal_output/spinner.raw @@ -0,0 +1,2 @@ + [# ] 0% downloading package-00 [# ] 1% downloading package-01 [# ] 3% downloading package-02 [## ] 5% downloading package-03 [## ] 6% downloading package-04 [## ] 8% downloading package-05 [### ] 10% downloading package-06 [### ] 11% downloading package-07 [### ] 13% downloading package-08 [#### ] 15% downloading package-09 [#### ] 16% downloading package-10 [#### ] 18% downloading package-11 [##### ] 20% downloading package-12 [##### ] 21% downloading package-13 [##### ] 23% downloading package-14 [###### ] 25% downloading package-15 [###### ] 26% downloading package-16 [###### ] 28% downloading package-17 [####### ] 30% downloading package-18 [####### ] 31% downloading package-19 [####### ] 33% downloading package-20 [######## ] 35% downloading package-21 [######## ] 36% downloading package-22 [######## ] 38% downloading package-23 [######### ] 40% downloading package-24 [######### ] 41% downloading package-25 [######### ] 43% downloading package-26 [########## ] 45% downloading package-27 [########## ] 46% downloading package-28 [########## ] 48% downloading package-29 [########### ] 50% downloading package-30 [########### ] 51% downloading package-31 [########### ] 53% downloading package-32 [############ ] 55% downloading package-33 [############ ] 56% downloading package-34 [############ ] 58% downloading package-35 [############# ] 60% downloading package-36 [############# ] 61% downloading package-37 [############# ] 63% downloading package-38 [############## ] 65% downloading package-39 [############## ] 66% downloading package-40 [############## ] 68% downloading package-41 [############### ] 70% downloading package-42 [############### ] 71% downloading package-43 [############### ] 73% downloading package-44 [################ ] 75% downloading package-45 [################ ] 76% downloading package-46 [################ ] 78% downloading package-47 [################# ] 80% downloading package-48 [################# ] 81% downloading package-49 [################# ] 83% downloading package-50 [################## ] 85% downloading package-51 [################## ] 86% downloading package-52 [################## ] 88% downloading package-53 [################### ] 90% downloading package-54 [################### ] 91% downloading package-55 [################### ] 93% downloading package-56 [####################] 95% downloading package-57 [####################] 96% downloading package-58 [####################] 98% downloading package-59 [####################] 100% done +installed 60 packages diff --git a/tests/test_conformance_strategy_switch.py b/tests/test_conformance_strategy_switch.py new file mode 100644 index 00000000..ad19dcf6 --- /dev/null +++ b/tests/test_conformance_strategy_switch.py @@ -0,0 +1,285 @@ +"""Tests for switching strategy when the conformance fix loop stops making progress. + +The loop's failure mode is not slowness, and it comes in two shapes. It can re-send the +same fix request and get back a patch that changes nothing the test can see — a wedged +cli-password-manager render failed conformance 20 times on one functionality with a +streak of 8 while its unit loop never failed once. Or it can fail every single time while +the failures keep changing shape — a task-manager render went 40 for 40 with a longest +identical run of two, which no streak threshold can catch. Both burn the whole budget. + +Regenerating the conformance test is the different move — it discards the test the loop +cannot satisfy instead of editing code against it again. These tests pin when that +happens, and just as importantly when it does not: both thresholds have to sit above what +a healthy functionality does, or every good render pays for it. +""" + +from unittest.mock import MagicMock, patch + +from render_machine.actions.fix_conformance_test import MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS, FixConformanceTest +from render_machine.fix_loop_metrics import ( + CONFORMANCE_LOOP, + CONSECUTIVE_FAILURE_THRESHOLD, + STRATEGY_SWITCH_PREFIX, + UNIT_LOOP, + FixLoopMetrics, + stalled_reason, +) + +MODULE = "vault_cli" +FRID = "2" + + +def render_context(identical_failures=0, render_attempts=0, output="AssertionError: prompt not shown"): + context = MagicMock() + context.module_name = MODULE + context.fix_loop_metrics = FixLoopMetrics() + # (issue reason, response files) — an implementation-code answer that changed + # nothing, which is the shape the tests below care about reaching. + context.codeplain_api.fix_conformance_tests_issue.return_value = [ + FixConformanceTest.ISSUE_REASON_CODE_IMPLEMENTATION_CODE, + {}, + ] + for _ in range(identical_failures): + context.fix_loop_metrics.record(CONFORMANCE_LOOP, module=MODULE, frid=FRID, passed=False, output=output) + + ctx = context.conformance_tests_running_context + ctx.current_testing_frid = FRID + ctx.current_testing_module_name = MODULE + ctx.conformance_tests_render_attempts = render_attempts + ctx.fix_attempts = 4 # mid-loop: well below the attempt limit + ctx.regenerating_conformance_tests = False + # A MagicMock would answer truthily and silently skip the ask-first rung. + ctx.asked_with_stall_context = False + return context + + +def decides_to_regenerate(context): + """The predicate alone, given whatever `stalled_reason` makes of the recorded runs.""" + reason = stalled_reason( + context.fix_loop_metrics, + CONFORMANCE_LOOP, + module=context.module_name, + frid=context.conformance_tests_running_context.current_testing_frid, + ) + with patch("render_machine.actions.fix_conformance_test.console"): + return FixConformanceTest._should_regenerate_instead_of_patching(context, reason) + + +def announced_by_predicate(context): + reason = stalled_reason( + context.fix_loop_metrics, + CONFORMANCE_LOOP, + module=context.module_name, + frid=context.conformance_tests_running_context.current_testing_frid, + ) + with patch("render_machine.actions.fix_conformance_test.console") as console: + FixConformanceTest._should_regenerate_instead_of_patching(context, reason) + return console.warning.call_args[0][0] + + +def test_a_loop_that_repeats_a_failure_three_times_regenerates_the_test(): + assert decides_to_regenerate(render_context(identical_failures=3)) is True + + +def test_a_healthy_functionality_that_repeats_twice_is_left_alone(): + """Observed in a real render: a functionality repeated a failure twice and then + converged. A threshold of two would abandon tests that were about to pass.""" + assert decides_to_regenerate(render_context(identical_failures=2)) is False + + +def test_a_first_failure_does_not_trigger_it(): + assert decides_to_regenerate(render_context(identical_failures=1)) is False + + +def test_a_loop_that_has_not_run_yet_does_not_trigger_it(): + assert decides_to_regenerate(render_context(identical_failures=0)) is False + + +def test_failures_that_differ_do_not_count_as_repeats(): + """A loop making progress produces new failures; only identical ones prove it is + stuck.""" + context = render_context(identical_failures=0) + for output in ("first failure", "second failure", "third failure"): + context.fix_loop_metrics.record(CONFORMANCE_LOOP, module=MODULE, frid=FRID, passed=False, output=output) + + assert decides_to_regenerate(context) is False + + +def test_a_render_with_no_functionality_under_test_does_not_trigger_it(): + """current_testing_frid is optional; nothing is recorded under a missing one.""" + context = render_context(identical_failures=3) + context.conformance_tests_running_context.current_testing_frid = None + + assert decides_to_regenerate(context) is False + + +def test_a_stuck_unit_loop_does_not_regenerate_conformance_tests(): + """The two loops fail for different reasons and warrant different responses.""" + context = render_context(identical_failures=0) + for _ in range(5): + context.fix_loop_metrics.record(UNIT_LOOP, module=MODULE, frid=FRID, passed=False, output="same") + + assert decides_to_regenerate(context) is False + + +def test_the_switch_stops_once_its_budget_is_spent_and_cannot_cycle(): + """Regeneration draws on the same budget as the attempt-limit path. Once it is spent + the loop patches to the limit and stops, rather than regenerating forever.""" + spent = render_context(identical_failures=8, render_attempts=MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS) + + assert decides_to_regenerate(spent) is False + + +def test_a_functionality_may_be_regenerated_more_than_once(): + """The budget that mattered. Every benchmark render that failed to publish died at the + attempt limit, reachable only after this budget ran out — one regeneration, then twenty + fruitless patches. The render that completed needed one regeneration on each of three + functionalities; the ones that wedged needed a second on a single functionality and had + none. Stopping after the first abandons the render where the move is still working.""" + # Deliberately not `range(1, MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS)`: a bound derived + # from the constant makes the test vacuous at the value it is meant to rule out, and it + # passed against a budget of 1 for exactly that reason. One is the count that has to be + # named literally here, because one is what the wedged renders got. + already_regenerated_once = render_context(identical_failures=8, render_attempts=1) + + assert decides_to_regenerate(already_regenerated_once) is True + + assert MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS >= 2, ( + "the budget has to allow a second regeneration for the assertion above to mean " + "anything; at 1 the loop abandons a functionality the move was still working on" + ) + + +def test_the_switch_is_announced_in_a_greppable_form(): + """Benchmark runs are read by tooling before they are read by a person.""" + context = render_context(identical_failures=4) + + announced = announced_by_predicate(context) + assert STRATEGY_SWITCH_PREFIX in announced + assert f"module={MODULE}" in announced + assert f"frid={FRID}" in announced + assert "loop=conformance" in announced + assert "repeated_failure streak=4" in announced + assert "action=regenerate_conformance_tests" in announced + + +def failing_differently(context, times): + for index in range(times): + context.fix_loop_metrics.record( + CONFORMANCE_LOOP, module=MODULE, frid=FRID, passed=False, output=f"failure number {index}" + ) + return context + + +def test_a_loop_that_always_fails_regenerates_even_without_a_repeat(): + """The case a streak trigger cannot see at any threshold: a task-manager render + failed a functionality's conformance tests 40 times out of 40 while its longest + identical run was two, exhausted its whole budget, and the switch stayed silent.""" + context = failing_differently(render_context(), CONSECUTIVE_FAILURE_THRESHOLD) + + assert decides_to_regenerate(context) is True + + +def test_a_loop_short_of_the_consecutive_bound_is_left_alone(): + context = failing_differently(render_context(), CONSECUTIVE_FAILURE_THRESHOLD - 1) + + assert decides_to_regenerate(context) is False + + +def test_a_pass_clears_the_consecutive_count(): + """A loop that gets a test passing is making progress, however many failures it took + to get there.""" + context = failing_differently(render_context(), CONSECUTIVE_FAILURE_THRESHOLD) + context.fix_loop_metrics.record(CONFORMANCE_LOOP, module=MODULE, frid=FRID, passed=True, output="") + failing_differently(context, 1) + + assert decides_to_regenerate(context) is False + + +def test_the_consecutive_arm_is_announced_with_its_own_reason(): + """The two arms mean different things to a reader, so the marker distinguishes + them rather than reporting one cause for both.""" + context = failing_differently(render_context(), CONSECUTIVE_FAILURE_THRESHOLD) + + announced = announced_by_predicate(context) + assert f"no_progress consecutive_failures={CONSECUTIVE_FAILURE_THRESHOLD}" in announced + + +def execute_through_to_the_request(context): + """Runs execute() past the early returns, standing in for the file and spec helpers + it would otherwise reach. Only the request the action builds is under test here.""" + module = "render_machine.actions.fix_conformance_test" + with ( + patch(f"{module}.console"), + patch(f"{module}.plain_spec"), + patch(f"{module}.diff_utils"), + patch(f"{module}.file_utils"), + patch(f"{module}.MemoryManager") as memory, + patch(f"{module}.ImplementationCodeHelpers") as helpers, + ): + memory.fetch_memory_files.return_value = ({}, {}) + helpers.fetch_existing_files.return_value = ({}, {}) + helpers.get_code_diff.return_value = {} + context.conformance_tests.fetch_existing_conformance_test_files.return_value = ({}, {}) + return FixConformanceTest().execute(context, {"previous_conformance_tests_issue": "boom"}) + + +def test_a_stuck_loop_first_asks_again_saying_so(): + """The middle rung. Before discarding the test, the loop sends one more fix request + that reports it is stuck, so the request differs from the ones that achieved nothing + — the failures it kept patching were often timeouts and missing entry points rather + than wrong answers, and nothing in an unchanged request says so.""" + context = render_context(identical_failures=3) + + outcome, _ = execute_through_to_the_request(context) + + assert outcome != FixConformanceTest.REGENERATE_CONFORMANCE_TESTS_OUTCOME + assert context.conformance_tests_running_context.asked_with_stall_context is True + sent = context.codeplain_api.fix_conformance_tests_issue.call_args.kwargs + assert sent["stalled_reason"] == "repeated_failure streak=3" + + +def test_an_ordinary_request_carries_no_stall_reason(): + """A loop still converging must send exactly what it sent before.""" + context = render_context(identical_failures=1) + + execute_through_to_the_request(context) + + assert context.codeplain_api.fix_conformance_tests_issue.call_args.kwargs["stalled_reason"] is None + + +def test_a_loop_still_stuck_after_asking_regenerates(): + """The third rung: asking differently was tried and changed nothing.""" + context = render_context(identical_failures=3) + context.conformance_tests_running_context.asked_with_stall_context = True + + with patch("render_machine.actions.fix_conformance_test.console"): + outcome, _ = FixConformanceTest().execute(context, {"previous_conformance_tests_issue": "boom"}) + + assert outcome == FixConformanceTest.REGENERATE_CONFORMANCE_TESTS_OUTCOME + + +def test_the_action_returns_the_regeneration_outcome_and_marks_the_context(): + """The early return has to reach the state machine the same way the attempt-limit + path does, or the render carries on patching regardless of the decision.""" + context = render_context(identical_failures=3) + context.conformance_tests_running_context.asked_with_stall_context = True + + with patch("render_machine.actions.fix_conformance_test.console"): + outcome, payload = FixConformanceTest().execute(context, {"previous_conformance_tests_issue": "boom"}) + + assert outcome == FixConformanceTest.REGENERATE_CONFORMANCE_TESTS_OUTCOME + assert payload is None + assert context.conformance_tests_running_context.regenerating_conformance_tests is True + + +def test_the_api_is_not_asked_for_another_patch_when_switching(): + """The point of the switch is to stop spending fix requests on a test the loop + cannot satisfy.""" + context = render_context(identical_failures=3) + context.conformance_tests_running_context.asked_with_stall_context = True + + with patch("render_machine.actions.fix_conformance_test.console"): + FixConformanceTest().execute(context, {"previous_conformance_tests_issue": "boom"}) + + context.codeplain_api.fix_conformance_tests_issue.assert_not_called() diff --git a/tests/test_conpty.py b/tests/test_conpty.py new file mode 100644 index 00000000..576ca89a --- /dev/null +++ b/tests/test_conpty.py @@ -0,0 +1,1253 @@ +"""The ConPTY backend on native Windows. + +Every case here allocates real pseudoconsoles, jobs, pipes and processes, so the whole +module is Windows-only and each helper is responsible for leaving nothing behind. The +fault-injection cases fail one native step at a time and assert what the rollback releases: +no process, no thread, no handle, and — on the paths that reach it — no pseudoconsole +closed on the foreground thread. + +The teardown-completes-within-a-bound assertions are only meaningful on a build in the +range where `ClosePseudoConsole()` can block, which is why CI runs this module on a +`windows-2022` image as well as on `windows-latest`. +""" + +import ctypes +import os +import signal +import sys +import textwrap +import threading +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest + +if sys.platform != "win32": + # The module binds kernel32 at import time, so collection has to stop here rather than + # leaving the cases to a skip mark. + pytest.skip("The ConPTY backend is not built off Windows.", allow_module_level=True) + +from ctypes import wintypes # noqa: E402 + +from plain2code_exceptions import RenderCancelledError # noqa: E402 +from render_machine import _conpty # noqa: E402 +from render_machine import render_utils # noqa: E402 +from render_machine import terminal_process # noqa: E402 +from render_machine._conpty import ConPtyProcess # noqa: E402 +from render_machine._legacy_pipe import LegacyPipeProcess # noqa: E402 +from render_machine.terminal_process import ( # noqa: E402 + ENVIRONMENT_ERROR_EXIT_CODE, + NO_INPUT_NOTE, + NO_PTY_ENV_VAR, + InputDisposition, + TerminalEnvironmentError, + create_terminal_process, +) + +# Generous relative to the operations they cover, so a failure means a hang rather than a +# slow machine. +SPAWN_TIMEOUT = 30.0 +WAIT_TIMEOUT = 30.0 +TEARDOWN_BOUND = 25.0 +POLL = 0.05 + +SYNCHRONIZE = 0x00100000 +PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +WAIT_OBJECT_0 = 0 + +# The backend's own binding, used only to assert its declarations and to observe the calls +# it makes. Everything this module calls for its own purposes goes through a second binding, +# so a test never adds a declaration production code then depends on. +kernel32 = _conpty.kernel32 + +probe = ctypes.WinDLL("kernel32", use_last_error=True) +probe.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] +probe.OpenProcess.restype = wintypes.HANDLE +probe.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] +probe.WaitForSingleObject.restype = wintypes.DWORD +probe.CloseHandle.argtypes = [wintypes.HANDLE] +probe.CloseHandle.restype = wintypes.BOOL + + +def write_program(tmp_path: Path, name: str, source: str) -> str: + """Writes a probe program, and refuses to write one that will not parse. + + A target that dies on a parse error reports a bare non-zero exit code and whatever the + terminal happened to catch, which is the least useful evidence available. Compiling here, + while the text is still in hand, fails the test at the write with the source and the line. + """ + path = tmp_path / f"{name}.py" + program = textwrap.dedent(source) + compile(program, str(path), "exec") + path.write_text(program, encoding="utf-8") + return str(path) + + +def command(script: str, *args: str): + return [sys.executable, "-I", script, *args] + + +def wait_for(predicate, timeout=WAIT_TIMEOUT): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(POLL) + return bool(predicate()) + + +def wait_for_output(process, needle, timeout=WAIT_TIMEOUT): + if wait_for(lambda: needle in process.normalized_output(), timeout): + return True + raise AssertionError(f"{needle!r} never reached the transcript, which held {process.normalized_output()!r}") + + +def wait_for_exit(process, timeout=WAIT_TIMEOUT): + assert wait_for(lambda: process.poll() is not None, timeout), "the script never exited" + return process.poll() + + +def process_is_gone(pid: int, timeout=WAIT_TIMEOUT) -> bool: + handle = probe.OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return True # already reaped, so there is nothing left to wait for + try: + return probe.WaitForSingleObject(handle, int(timeout * 1000)) == WAIT_OBJECT_0 + finally: + probe.CloseHandle(handle) + + +def live_backend_threads(): + return [thread for thread in threading.enumerate() if thread.name.startswith("codeplain-conpty-")] + + +class _HandleLedger: + """Every handle the backend opened through kernel32, minus the ones it closed again.""" + + def __init__(self): + self.open = {} + + def opened(self, handle, description): + if handle: + self.open[int(handle)] = description + + def closed(self, handle): + if handle: + self.open.pop(int(handle), None) + + def outstanding(self): + return dict(self.open) + + +@pytest.fixture +def handle_ledger(monkeypatch): + """A ledger of the backend's own handles rather than GetProcessHandleCount(). + + A process-wide count is not a leak detector here: CPython allocates a kernel semaphore + per lock object, so the count moves for reasons that have nothing to do with this + backend, and waiting for it to settle waits on the garbage collector. + """ + ledger = _HandleLedger() + originals = { + name: getattr(kernel32, name) + for name in ( + "CreatePipe", + "CreateJobObjectW", + "OpenThread", + "CreateProcessW", + "CreatePseudoConsole", + "CloseHandle", + "ClosePseudoConsole", + ) + } + + def create_pipe(read_slot, write_slot, attributes, size): + ok = originals["CreatePipe"](read_slot, write_slot, attributes, size) + if ok: + ledger.opened(read_slot._obj.value, "pipe read end") + ledger.opened(write_slot._obj.value, "pipe write end") + return ok + + def create_job(attributes, name): + handle = originals["CreateJobObjectW"](attributes, name) + ledger.opened(handle, "job object") + return handle + + def open_thread(access, inherit, thread_id): + handle = originals["OpenThread"](access, inherit, thread_id) + ledger.opened(handle, "thread handle") + return handle + + def create_process(*arguments): + ok = originals["CreateProcessW"](*arguments) + if ok: + information = arguments[-1]._obj + ledger.opened(information.hProcess, "process handle") + ledger.opened(information.hThread, "thread handle of the process") + return ok + + def create_pseudoconsole(size, input_handle, output_handle, flags, slot): + hresult = originals["CreatePseudoConsole"](size, input_handle, output_handle, flags, slot) + if hresult == 0: + ledger.opened(slot._obj.value, "pseudoconsole") + return hresult + + def close_handle(handle): + ledger.closed(handle if isinstance(handle, int) else handle.value) + return originals["CloseHandle"](handle) + + def close_pseudoconsole(handle): + ledger.closed(handle if isinstance(handle, int) else handle.value) + originals["ClosePseudoConsole"](handle) + + for name, replacement in ( + ("CreatePipe", create_pipe), + ("CreateJobObjectW", create_job), + ("OpenThread", open_thread), + ("CreateProcessW", create_process), + ("CreatePseudoConsole", create_pseudoconsole), + ("CloseHandle", close_handle), + ("ClosePseudoConsole", close_pseudoconsole), + ): + monkeypatch.setattr(kernel32, name, replacement) + return ledger + + +@pytest.fixture(autouse=True) +def no_backend_threads_outlive_the_test(): + """Fails the test that leaked a pump rather than the one that runs after it. + + A reader or writer left running owns handles and keeps appending to a transcript nobody + reads, and every later assertion about threads or handles then measures the leak instead + of its own subject. + """ + yield + assert wait_for( + lambda: not live_backend_threads(), timeout=20.0 + ), f"backend threads outlived the test: {[thread.name for thread in live_backend_threads()]}" + + +@pytest.fixture +def backend(): + process = ConPtyProcess() + try: + yield process + finally: + try: + process.terminate_tree(grace=0.1) + except TerminalEnvironmentError: + pass + try: + process.close() + except TerminalEnvironmentError: + pass + + +# The probe reports what a script sees, one short line at a time: the pseudoconsole wraps at +# the configured width, so a single long line would come back folded. +# Written at column zero and without a single escape sequence: the target parses this file +# on its own, and the two ways a program embedded in a test can arrive malformed — an indent +# no longer shared by every line, and an escape the test source resolves too early — are both +# absent by construction rather than by review. +TERMINAL_PROBE = """ +import ctypes +import os +import traceback + + +def report(): + # Declared: an undeclared call returns c_int, which truncates a handle and reports a + # false negative for both questions below. + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.GetStdHandle.argtypes = [ctypes.c_uint] + kernel32.GetStdHandle.restype = ctypes.c_void_p + kernel32.GetCurrentProcess.argtypes = [] + kernel32.GetCurrentProcess.restype = ctypes.c_void_p + kernel32.GetConsoleMode.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint)] + kernel32.GetConsoleMode.restype = ctypes.c_int + kernel32.IsProcessInJob.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)] + kernel32.IsProcessInJob.restype = ctypes.c_int + + mode = ctypes.c_uint(0) + console = kernel32.GetConsoleMode(kernel32.GetStdHandle(0xFFFFFFF5), ctypes.byref(mode)) + in_job = ctypes.c_int(0) + kernel32.IsProcessInJob(kernel32.GetCurrentProcess(), None, ctypes.byref(in_job)) + return [ + "ISATTY=%s" % (os.isatty(0) and os.isatty(1) and os.isatty(2)), + "CONSOLE=%s" % bool(console), + "INJOB=%s" % bool(in_job.value), + "TERM=%s" % os.environ.get("TERM"), + "DONE", + ] + + +try: + lines = report() +except BaseException: + lines = ["PROBE-FAILED"] + traceback.format_exc().splitlines() + +# Written beside the probe as well as printed: the file survives a target whose standard +# handles are not the terminal's, and it needs no argument that could itself go wrong. +beside_the_probe = os.path.join(os.path.dirname(os.path.abspath(__file__)), "probe.txt") +with open(beside_the_probe, "w", encoding="utf-8") as handle: + for line in lines: + print(line, file=handle) + +for line in lines: + print(line) +""" + + +# ------------------------------------------------------------------ declarations + + +def test_every_handle_returning_call_is_declared_pointer_wide(): + """ctypes converts a return value as c_int unless told otherwise, which truncates a + 64-bit handle long before any ownership rule can help.""" + pointer_width = ctypes.sizeof(ctypes.c_void_p) + + for name in ("CreateJobObjectW", "OpenThread", "GetProcessHeap"): + assert ctypes.sizeof(getattr(kernel32, name).restype) == pointer_width, name + assert ctypes.sizeof(kernel32.HeapAlloc.restype) == pointer_width + assert ctypes.sizeof(_conpty.HANDLE) == pointer_width + + +def test_the_void_calls_are_declared_as_returning_nothing(): + assert kernel32.ClosePseudoConsole.restype is None + assert kernel32.DeleteProcThreadAttributeList.restype is None + + +def test_the_pseudoconsole_calls_are_declared_as_signed_32_bit_results(): + """HRESULT is the inverse of the BOOL convention every other call here uses.""" + assert ctypes.sizeof(kernel32.CreatePseudoConsole.restype) == 4 + assert kernel32.CreatePseudoConsole.restype(-1).value == -1 + + +def test_an_allocated_pointer_survives_the_declared_return_type(): + heap = kernel32.GetProcessHeap() + buffer = kernel32.HeapAlloc(heap, 0, 4096) + try: + assert buffer is not None and buffer > 0 + # A truncating declaration turns a pointer with high bits set into a negative int. + assert buffer == ctypes.c_void_p(buffer).value + finally: + assert kernel32.HeapFree(heap, 0, buffer) + + +def test_a_failing_bool_call_reports_the_captured_last_error(): + ok = probe.CloseHandle(wintypes.HANDLE(0)) + error = ctypes.get_last_error() + + assert not ok + assert str(error) in str(_conpty._win_error("Closing a handle", error)) + + +def test_a_failing_pseudoconsole_call_is_reported_as_its_hresult(): + """A zero-sized console is rejected outright, where invalid handles are not: Windows + Server 2022 accepts handles it will only fail on later. The failure has to be detected as + a nonzero HRESULT rather than read from the last error, which these calls do not promise + to set.""" + session = SimpleNamespace(hPC=_conpty.HPCON(), hPC_valid=False) + + with pytest.raises(TerminalEnvironmentError) as error: + _conpty._create_pseudoconsole(session, 0, 0, -1, -1) + + assert "HRESULT" in str(error.value) + assert session.hPC_valid is False # a failed HRESULT output is never closable + + +def test_a_build_without_pseudoconsole_support_is_an_environment_error(monkeypatch): + monkeypatch.setattr(_conpty, "PSEUDOCONSOLE_AVAILABLE", False) + + with pytest.raises(TerminalEnvironmentError) as error: + _conpty._require_pseudoconsole_support() + + assert str(_conpty.MIN_CONPTY_BUILD) in str(error.value) + assert "fallback" in str(error.value) + + +# --------------------------------------------------------------- backend selection + + +def test_windows_selects_the_conpty_backend(monkeypatch): + monkeypatch.delenv(NO_PTY_ENV_VAR, raising=False) + + process = create_terminal_process() + try: + assert isinstance(process, ConPtyProcess) + finally: + process.close() + + +def test_the_backend_notes_that_it_has_no_synthetic_end_of_file(): + """The timeout diagnostic asks the backend that ran, so this one has to state the + asymmetry itself: a script reading input blocks instead of seeing end-of-file — the + base note describes backends that hand end-of-file at spawn, which this one cannot.""" + note = ConPtyProcess().no_input_note() + + assert note != NO_INPUT_NOTE + assert "blocks until the timeout" in note + assert "end-of-file" in note + + +def test_the_teardown_budget_covers_every_phase_the_shutdown_spends(): + """The CLI derives its own wait from this, and the ConPTY pipeline is the longest one.""" + assert _conpty.TEARDOWN_BUDGET_SECONDS > terminal_process.SIGTERM_GRACE_PERIOD_SECONDS + assert terminal_process.teardown_budget_seconds() >= _conpty.TEARDOWN_BUDGET_SECONDS + + +def test_the_escape_hatch_still_selects_the_pipe_backend_on_windows(monkeypatch): + """The hatch is cross-platform: it is read before the platform branch, not instead of it.""" + monkeypatch.setenv(NO_PTY_ENV_VAR, "1") + + process = create_terminal_process() + try: + assert isinstance(process, LegacyPipeProcess) + finally: + process.close() + + +# ------------------------------------------------------------------- the lifecycle + + +def test_a_script_runs_on_a_real_console_inside_the_job(backend, tmp_path): + script = write_program(tmp_path, "terminal_probe", TERMINAL_PROBE) + report_path = tmp_path / "probe.txt" + + backend.spawn(command(script)) + exit_code = wait_for_exit(backend) + backend.terminate_tree(grace=0.1) + backend.close() + output = backend.normalized_output() + report = report_path.read_text(encoding="utf-8") if report_path.exists() else "(no report was written)" + written = "".join(Path(script).read_text(encoding="utf-8").splitlines(keepends=True)[:5]) + evidence = ( + f"transcript={output!r}\nthe target reported:\n{report}\n" + f"first lines written={written!r}\nliteral={TERMINAL_PROBE[:80]!r}" + ) + + assert exit_code == 0, evidence + assert "ISATTY=True" in output, evidence + assert "CONSOLE=True" in output, evidence + assert "INJOB=True" in output, evidence + assert "TERM=xterm-256color" in output, evidence + + +def test_the_exit_code_is_reported_verbatim(backend, tmp_path): + script = write_program(tmp_path, "exit_seven", "import sys\nsys.exit(7)\n") + + backend.spawn(command(script)) + + assert wait_for_exit(backend) == 7 + + +def test_input_written_through_the_stored_session_reaches_the_script(backend, tmp_path): + """The one field whose absence only shows up when everything else went right.""" + script = write_program( + tmp_path, + "echo_line", + """ + import sys + + print("READY", flush=True) + line = sys.stdin.readline().strip() + print("GOT[%s]" % line, flush=True) + """, + ) + + backend.spawn(command(script)) + assert wait_for_output(backend, "READY") + result = backend.write_input(b"hello\r") + + assert result.disposition is InputDisposition.ACCEPTED + assert wait_for_output(backend, "GOT[hello]") + assert wait_for_exit(backend) == 0 + + +def test_the_script_is_a_member_of_the_sessions_job(backend, tmp_path): + """Membership comes from the attribute list at creation, so there is no window in which + the process exists outside the job.""" + script = write_program(tmp_path, "waits", "print('READY', flush=True)\nimport time\ntime.sleep(120)\n") + + backend.spawn(command(script)) + assert wait_for_output(backend, "READY") + session = backend._owner.session + member = wintypes.BOOL(0) + + assert kernel32.IsProcessInJob(session.proc.process_handle(), session.hJob, ctypes.byref(member)) + assert member.value + + +def test_a_descendant_is_terminated_with_the_script(backend, tmp_path): + script = write_program( + tmp_path, + "spawns_a_child", + f""" + import subprocess + import sys + import time + + child = subprocess.Popen([r"{sys.executable}", "-c", "import time; time.sleep(120)"]) + print("CHILD=%d" % child.pid, flush=True) + time.sleep(120) + """, + ) + + backend.spawn(command(script)) + assert wait_for_output(backend, "CHILD=") + line = [part for part in backend.normalized_output().split() if part.startswith("CHILD=")][0] + descendant = int(line.split("=", 1)[1]) + + started = time.monotonic() + backend.terminate_tree(grace=0.2) + backend.close() + + assert time.monotonic() - started < TEARDOWN_BOUND + assert process_is_gone(descendant) + + +def test_teardown_completes_within_its_bound_for_a_script_that_ignores_everything(backend, tmp_path): + """The failure mode this guards is a hang, not an exception: `ClosePseudoConsole()` + blocks on pre-24H2 builds unless the output pipe is drained or closed.""" + script = write_program( + tmp_path, + "ignores_signals", + """ + import signal + import sys + import time + + signal.signal(signal.SIGINT, signal.SIG_IGN) + print("READY", flush=True) + while True: + print("noise" * 200, flush=True) + time.sleep(0.01) + """, + ) + + backend.spawn(command(script)) + assert wait_for_output(backend, "READY") + + started = time.monotonic() + backend.terminate_tree(grace=0.5) + backend.close() + + assert time.monotonic() - started < TEARDOWN_BOUND + + +def test_the_graceful_signal_reaches_a_registered_handler_before_the_grace_expires(backend, tmp_path): + script = write_program( + tmp_path, + "handles_ctrl_c", + """ + import signal + import sys + import time + + + def handler(signum, frame): + print("HANDLED", flush=True) + sys.exit(42) + + + signal.signal(signal.SIGINT, handler) + print("READY", flush=True) + time.sleep(120) + """, + ) + + backend.spawn(command(script)) + assert wait_for_output(backend, "READY") + + backend.terminate_tree(grace=10.0) + + assert wait_for_output(backend, "HANDLED", timeout=5.0) + assert backend.poll() == 42 # its own exit status, not the job's termination code + + +def test_the_renderers_own_console_is_untouched_by_the_graceful_signal(backend, tmp_path): + """The Windows analogue of signalling our own process group, and the one catastrophic + failure: the control byte goes into the pseudoconsole, never through + `GenerateConsoleCtrlEvent`.""" + script = write_program(tmp_path, "waits", "import time\ntime.sleep(120)\n") + interrupted = threading.Event() + previous = signal.getsignal(signal.SIGINT) + signal.signal(signal.SIGINT, lambda *_: interrupted.set()) + try: + backend.spawn(command(script)) + backend.terminate_tree(grace=0.5) + backend.close() + finally: + signal.signal(signal.SIGINT, previous) + + assert not interrupted.is_set() + + +# --------------------------------------------------------------- fault injection + + +def failing(name): + def raiser(*args, **kwargs): + raise TerminalEnvironmentError(f"{name} failed by injection") + + return raiser + + +def failing_after(monkeypatch, name): + """Fails once the named step has really run, so the rollback faces a real resource. + + Injecting before the call proves only that nothing was allocated; these cases are the + ones that prove the allocation is released. + """ + original = getattr(_conpty, name) + + def wrapper(*args, **kwargs): + result = original(*args, **kwargs) + raise TerminalEnvironmentError(f"{name} failed by injection after the real call") + + monkeypatch.setattr(_conpty, name, wrapper) + + +def failing_on_call(monkeypatch, name, call_index): + """Fails one specific call of a step that runs more than once.""" + original = getattr(_conpty, name) + calls = {"count": 0} + + def wrapper(*args, **kwargs): + calls["count"] += 1 + if calls["count"] == call_index: + raise TerminalEnvironmentError(f"{name} call {call_index} failed by injection") + return original(*args, **kwargs) + + monkeypatch.setattr(_conpty, name, wrapper) + + +@pytest.mark.parametrize( + "step", + [ + "_create_job", + "_set_kill_on_job_close", + "_create_pseudoconsole", + "_initialize_attribute_list", + "_create_process", + "_open_thread_handle", + ], +) +def test_a_failed_step_leaves_no_process_thread_or_handle_behind(monkeypatch, handle_ledger, tmp_path, step): + script = write_program(tmp_path, "never_runs", "print('unreachable')\n") + monkeypatch.setattr(_conpty, step, failing(step)) + process = ConPtyProcess() + + with pytest.raises(TerminalEnvironmentError): + process.spawn(command(script)) + process.close() + + assert "unreachable" not in process.normalized_output() + assert wait_for(lambda: not live_backend_threads(), timeout=10.0) + assert wait_for(lambda: not handle_ledger.outstanding(), timeout=10.0), handle_ledger.outstanding() + + +@pytest.mark.parametrize( + "step", + ["_create_job", "_create_pseudoconsole", "_initialize_attribute_list"], +) +def test_a_failure_after_a_real_native_step_releases_what_that_step_allocated( + monkeypatch, handle_ledger, tmp_path, step +): + script = write_program(tmp_path, "never_runs", "print('unreachable')\n") + failing_after(monkeypatch, step) + process = ConPtyProcess() + + with pytest.raises(TerminalEnvironmentError): + process.spawn(command(script)) + process.close() + + assert wait_for(lambda: not live_backend_threads(), timeout=10.0) + assert wait_for(lambda: not handle_ledger.outstanding(), timeout=10.0), handle_ledger.outstanding() + + +def test_a_failure_after_create_process_leaves_no_surviving_child(monkeypatch, handle_ledger, tmp_path): + """The widest rollback: the child already exists, and the job it was created inside is + what takes it down.""" + script = write_program(tmp_path, "waits", "import time\ntime.sleep(120)\n") + original = _conpty._create_process + created = [] + + def create_then_fail(command_line, directory, environment, attrs, proc): + original(command_line, directory, environment, attrs, proc) + created.append(int(proc.pi.dwProcessId)) + raise TerminalEnvironmentError("injected after the child was created") + + monkeypatch.setattr(_conpty, "_create_process", create_then_fail) + process = ConPtyProcess() + + with pytest.raises(TerminalEnvironmentError): + process.spawn(command(script)) + process.close() + + assert created, "the injection never ran the real call" + assert process_is_gone(created[0]) + assert wait_for(lambda: not live_backend_threads(), timeout=10.0) + assert wait_for(lambda: not handle_ledger.outstanding(), timeout=10.0), handle_ledger.outstanding() + + +@pytest.mark.parametrize("call_index", [1, 2]) +def test_a_failed_pipe_leaves_nothing_behind(monkeypatch, tmp_path, call_index): + """Both `CreatePipe` calls are separate failure sites; the second is the one a coarser + cleanup scope mishandles while the first still looks correct.""" + script = write_program(tmp_path, "never_runs", "print('unreachable')\n") + failing_on_call(monkeypatch, "_create_pipe", call_index) + process = ConPtyProcess() + + with pytest.raises(TerminalEnvironmentError): + process.spawn(command(script)) + process.close() + + assert wait_for(lambda: not live_backend_threads(), timeout=10.0) + + +@pytest.mark.parametrize("call_index", [1, 2]) +def test_a_failed_attribute_update_leaves_nothing_behind(monkeypatch, tmp_path, call_index): + script = write_program(tmp_path, "never_runs", "print('unreachable')\n") + failing_on_call(monkeypatch, "_update_attribute", call_index) + process = ConPtyProcess() + + with pytest.raises(TerminalEnvironmentError): + process.spawn(command(script)) + process.close() + + assert wait_for(lambda: not live_backend_threads(), timeout=10.0) + + +def test_a_reader_that_cannot_start_fails_before_there_is_anything_to_roll_back(monkeypatch, tmp_path): + script = write_program(tmp_path, "never_runs", "print('unreachable')\n") + original = threading.Thread.start + + def refuse(self): + if self.name == "codeplain-conpty-reader": + raise RuntimeError("can't start new thread") + original(self) + + monkeypatch.setattr(threading.Thread, "start", refuse) + process = ConPtyProcess() + + with pytest.raises(RuntimeError): + process.spawn(command(script)) + process.close() + + assert not live_backend_threads() + + +def test_a_reader_that_dies_while_the_process_is_being_created_still_unwinds(monkeypatch, tmp_path): + """The widest window in the sequence: process creation is its slowest step.""" + script = write_program(tmp_path, "waits", "import time\ntime.sleep(120)\n") + process = ConPtyProcess() + original = _conpty._create_process + + def create_then_fail_the_reader(*args, **kwargs): + original(*args, **kwargs) + process.reader_exc = OSError("the reader died during creation") + process.reader_failed.set() + + monkeypatch.setattr(_conpty, "_create_process", create_then_fail_the_reader) + + with pytest.raises(TerminalEnvironmentError): + process.spawn(command(script)) + process.close() + + assert wait_for(lambda: not live_backend_threads(), timeout=10.0) + + +def test_a_zero_return_from_create_process_keeps_its_garbage_fields_unclosed(monkeypatch, tmp_path): + """`CreateProcessW` writes nothing meaningful on failure, so the fields it leaves behind + must never be treated as handles.""" + script = write_program(tmp_path, "never_runs", "print('unreachable')\n") + sentinel = 0x0BADF00D + closed = [] + original_close = kernel32.CloseHandle + + def record(handle): + closed.append(handle) + return original_close(handle) + + def fail_with_sentinels(command_line, directory, environment, attrs, proc): + proc.pi.hProcess = sentinel + proc.pi.hThread = sentinel + raise _conpty._win_error("Starting the script", 2) + + monkeypatch.setattr(kernel32, "CloseHandle", record) + monkeypatch.setattr(_conpty, "_create_process", fail_with_sentinels) + process = ConPtyProcess() + + with pytest.raises(TerminalEnvironmentError): + process.spawn(command(script)) + process.close() + + assert closed, "the rollback closed nothing, so the absence below would prove nothing" + assert sentinel not in closed # the `proc.valid` gate keeps a failed call's fields unclosed + assert wait_for(lambda: not live_backend_threads(), timeout=10.0) + + +def test_a_teardown_that_outlives_its_bound_is_handed_to_the_finalizer(monkeypatch, handle_ledger, backend, tmp_path): + """The foreground returns promptly and reports the failure on the environment channel; + the finalizer, not the foreground, closes the pseudoconsole and releases the session.""" + script = write_program(tmp_path, "waits", "import time\ntime.sleep(120)\n") + closed_on = [] + original_close = kernel32.ClosePseudoConsole + original_wait = _conpty._SessionBundle._await_job_empty + waits = {"count": 0} + + def record(handle): + closed_on.append(threading.current_thread().name) + original_close(handle) + + def expire_once(self, bound): + """Expires the foreground's wait, then lets the finalizer's own attempt succeed.""" + waits["count"] += 1 + return False if waits["count"] == 1 else original_wait(self, bound) + + monkeypatch.setattr(kernel32, "ClosePseudoConsole", record) + monkeypatch.setattr(_conpty._SessionBundle, "_await_job_empty", expire_once) + monkeypatch.setattr(_conpty, "FINALIZER_TICK_SECONDS", 0.05) + + backend.spawn(command(script)) + child = int(backend._owner.session.proc.pi.dwProcessId) + started = time.monotonic() + with pytest.raises(TerminalEnvironmentError) as error: + backend.close() + + assert time.monotonic() - started < TEARDOWN_BOUND + assert "finalizer" in str(error.value) + assert threading.current_thread().name not in closed_on + # Ownership was transferred, not dropped: the session is released on the finalizer's own + # time, the child goes with it, and every handle comes back. + assert wait_for(lambda: closed_on == ["codeplain-conpty-finalizer"], timeout=30.0) + assert wait_for( + lambda: not any(thread.name == "codeplain-conpty-finalizer" for thread in threading.enumerate()), + timeout=30.0, + ) + assert process_is_gone(child) + assert wait_for(lambda: not handle_ledger.outstanding(), timeout=30.0), handle_ledger.outstanding() + + +def native_call_log(monkeypatch): + """One ordered log of the two native calls whose relative order is load-bearing.""" + events = [] + original_close_handle = kernel32.CloseHandle + original_close_pty = kernel32.ClosePseudoConsole + + def close_handle(handle): + events.append(("CloseHandle", handle, threading.current_thread().name)) + return original_close_handle(handle) + + def close_pseudoconsole(handle): + events.append(("ClosePseudoConsole", handle, threading.current_thread().name)) + original_close_pty(handle) + + monkeypatch.setattr(kernel32, "CloseHandle", close_handle) + monkeypatch.setattr(kernel32, "ClosePseudoConsole", close_pseudoconsole) + return events + + +def index_of(events, name, handle=None): + for position, (called, argument, _thread) in enumerate(events): + if called == name and (handle is None or argument == handle): + return position + return None + + +def test_a_finalizer_that_runs_out_of_time_still_closes_the_job_first(monkeypatch, backend, tmp_path): + """The abandoned path is the one most likely to face a live process tree, and + `ClosePseudoConsole()` can block on this build — so kill-on-job-close must not be queued + behind it.""" + script = write_program(tmp_path, "waits", "import time\ntime.sleep(120)\n") + events = native_call_log(monkeypatch) + monkeypatch.setattr(_conpty._SessionBundle, "_await_job_empty", lambda self, bound: False) + monkeypatch.setattr(_conpty, "FINALIZER_DEADLINE_SECONDS", 0.2) + monkeypatch.setattr(_conpty, "FINALIZER_TICK_SECONDS", 0.05) + + backend.spawn(command(script)) + session = backend._owner.session + child = int(session.proc.pi.dwProcessId) + job = session.hJob + with pytest.raises(TerminalEnvironmentError): + backend.close() + + assert wait_for( + lambda: not any(thread.name == "codeplain-conpty-finalizer" for thread in threading.enumerate()), + timeout=30.0, + ) + job_closed = index_of(events, "CloseHandle", job) + pseudoconsole_closed = index_of(events, "ClosePseudoConsole") + assert job_closed is not None, "the abandoned session never released its job" + assert pseudoconsole_closed is not None + assert job_closed < pseudoconsole_closed + assert process_is_gone(child) # kill-on-job-close, which is what closing the job buys + + +def test_a_finalizer_that_cannot_start_leaves_the_session_owned_and_released(monkeypatch, backend, tmp_path): + """Nothing took the session, so the foreground keeps it and releases the natives itself + rather than dropping the only reference to a live job.""" + script = write_program(tmp_path, "waits", "import time\ntime.sleep(120)\n") + events = native_call_log(monkeypatch) + monkeypatch.setattr(_conpty._SessionBundle, "_await_job_empty", lambda self, bound: False) + original_start = threading.Thread.start + + def refuse(self): + if self.name == "codeplain-conpty-finalizer": + raise RuntimeError("can't start new thread") + original_start(self) + + monkeypatch.setattr(threading.Thread, "start", refuse) + + backend.spawn(command(script)) + session = backend._owner.session + child = int(session.proc.pi.dwProcessId) + job = session.hJob + started = time.monotonic() + with pytest.raises(TerminalEnvironmentError) as error: + backend.close() + + assert time.monotonic() - started < TEARDOWN_BOUND + assert "no finalizer thread could be started" in str(error.value) + assert backend._owner is not None # ownership retained rather than dropped + assert index_of(events, "CloseHandle", job) is not None + assert process_is_gone(child) + # Released rather than leaked: the writer was idle on its queue, and the last-resort + # release stops it through the sentinel before it decides about the input handles. + assert not session.in_w.owned and not session.writer_handle.owned + + +# ------------------------------------------------------------------- marshaling + + +@pytest.mark.parametrize( + "kwargs", + [ + {"command": [sys.executable, "-c", "print('x')\x00"]}, + {"cwd": "C:\\builds\x00"}, + {"env": {"NAME\x00": "value"}}, + {"env": {"NAME": "value\x00"}}, + {"env": {"NA=ME": "value"}}, + {"env": {"": "value"}}, + ], +) +def test_an_input_windows_cannot_carry_is_refused_before_any_process_is_created(kwargs, tmp_path): + marker = tmp_path / "ran.txt" + argv = kwargs.pop("command", [sys.executable, "-c", f"open(r'{marker}', 'w').close()"]) + if "env" in kwargs: + kwargs["env"] = dict(os.environ, **kwargs["env"]) + process = ConPtyProcess() + + with pytest.raises(TerminalEnvironmentError): + process.spawn(argv, **kwargs) + process.close() + + assert not marker.exists() # asserted by observation, not only by the exception + + +# ---------------------------------------------------------------- cancellation + + +def test_a_stop_event_set_before_the_spawn_launches_nothing(tmp_path): + marker = tmp_path / "ran.txt" + script = write_program(tmp_path, "marks", f"open(r'{marker}', 'w').close()\n") + stop = threading.Event() + stop.set() + process = ConPtyProcess() + + with pytest.raises(RenderCancelledError): + process.spawn(command(script), stop_event=stop) + process.close() + + assert not wait_for(marker.exists, timeout=2.0) + + +def test_a_cancellation_observed_while_the_session_is_built_never_launches_the_target(monkeypatch, tmp_path): + """The window between the first check and `CreateProcessW` is the whole session setup; + a render cancelled inside it must not run the script's side effects.""" + marker = tmp_path / "ran.txt" + script = write_program(tmp_path, "marks", f"open(r'{marker}', 'w').close()\n") + stop = threading.Event() + original = _conpty._create_pseudoconsole + + def create_then_cancel(*args, **kwargs): + original(*args, **kwargs) + stop.set() + + monkeypatch.setattr(_conpty, "_create_pseudoconsole", create_then_cancel) + process = ConPtyProcess() + + with pytest.raises(RenderCancelledError): + process.spawn(command(script), stop_event=stop) + process.close() + + assert not wait_for(marker.exists, timeout=2.0) + assert wait_for(lambda: not live_backend_threads(), timeout=10.0) + + +# ------------------------------------------------------- the blocked input channel + + +SILENT_READER = """ + import time + + print("READY", flush=True) + time.sleep(120) +""" + + +def writer_progress(backend): + """What the writer has consumed: the item under its cursor and the bytes still owed.""" + queue = backend._writer.queue + current = queue.current() + position = None if current is None else (current.sequence, current.cursor) + return position, queue.pending_bytes() + + +def test_a_saturated_input_channel_still_tears_down_within_the_bound(backend, tmp_path): + """The target never reads, so the writer is expected to end up parked inside a synchronous + `WriteFile` — the state the cancel loop and the bounded join exist for. + + Whether it truly parks is not this side's decision: the pseudoconsole drains the pipe into + its own input buffer, so on some builds every item lands and the writer stays idle. The + bounded-progress sample below distinguishes the two worlds, and each is asserted for what + it can prove — the parked one that the cancel path ran at all, both of them that teardown + stays inside its bound and the tree dies. + """ + script = write_program(tmp_path, "silent_reader", SILENT_READER) + backend.spawn(command(script)) + assert wait_for_output(backend, "READY") + child = int(backend._owner.session.proc.pi.dwProcessId) + + accepted = 0 + for _ in range(64): + result = backend.write_input(b"x" * 4096 + b"\r") + if result.disposition is not InputDisposition.ACCEPTED: + break + accepted += 1 + + first = writer_progress(backend) + time.sleep(0.5) + second = writer_progress(backend) + parked = first == second and first[0] is not None and first[1] > 0 + + started = time.monotonic() + backend.terminate_tree(grace=0.5) + backend.close() + + assert accepted > 0 + if parked: + # A writer that never moved could only be released by the cancel loop. + assert backend._writer.cancels > 0 + assert time.monotonic() - started < TEARDOWN_BOUND + assert process_is_gone(child) + + +PROCESSED_INPUT_OFF = """ + import ctypes + import time + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + handle = kernel32.GetStdHandle(-10) + mode = ctypes.c_uint(0) + kernel32.GetConsoleMode(handle, ctypes.byref(mode)) + kernel32.SetConsoleMode(handle, mode.value & ~0x0001) # ENABLE_PROCESSED_INPUT + + print("READY", flush=True) + time.sleep(120) +""" + + +def test_a_client_that_cleared_processed_input_falls_through_to_forced_termination(backend, tmp_path): + """The best-effort boundary, proven rather than asserted: without ENABLE_PROCESSED_INPUT + the control byte is just a byte, so the grace expires and the job takes the tree.""" + script = write_program(tmp_path, "no_processed_input", PROCESSED_INPUT_OFF) + backend.spawn(command(script)) + assert wait_for_output(backend, "READY") + child = int(backend._owner.session.proc.pi.dwProcessId) + + started = time.monotonic() + backend.terminate_tree(grace=1.0) + backend.close() + + assert time.monotonic() - started < TEARDOWN_BOUND + assert process_is_gone(child) + # Never exited on its own, so no status was ever read: the contrast with the handled + # case, which reports the code its handler chose. + assert backend.poll() is None + + +TERMINAL_QUERY = """ + import msvcrt + import sys + import time + + sys.stdout.write("\\x1b[6n") # device status report: a terminal is expected to answer + sys.stdout.flush() + + reply = "" + deadline = time.monotonic() + 20 + while time.monotonic() < deadline and not reply.endswith("R"): + if msvcrt.kbhit(): + reply += msvcrt.getwch() + else: + time.sleep(0.01) + + print("ANSWERED=%s" % reply.endswith("R"), flush=True) + print("QUERIED", flush=True) +""" + + +def test_a_target_that_queries_the_terminal_receives_its_reply(backend, tmp_path): + """The target reads its own console input back, so this proves delivery rather than the + absence of a recorded failure. Which side answers — the pseudoconsole's own emulator or + the renderer's responder — is not asserted; that a querying target is not left waiting is. + """ + script = write_program(tmp_path, "queries", TERMINAL_QUERY) + + backend.spawn(command(script)) + exit_code = wait_for_exit(backend) + backend.terminate_tree(grace=0.1) + backend.close() + output = backend.normalized_output() + + assert exit_code == 0 + assert "QUERIED" in output + assert "ANSWERED=True" in output + assert backend.terminal_reply_failed is False, backend.terminal_reply_detail() + + +# ----------------------------------------------------- through execute_script + + +SCRIPT_TYPE = "Unit" + + +def write_powershell(tmp_path: Path, name: str, body: str) -> str: + path = tmp_path / f"{name}.ps1" + path.write_text(textwrap.dedent(body), encoding="utf-8") + return str(path) + + +def run_script(script: str, timeout: int, 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_conpty_support.py b/tests/test_conpty_support.py new file mode 100644 index 00000000..5464dbbf --- /dev/null +++ b/tests/test_conpty_support.py @@ -0,0 +1,621 @@ +"""The platform-neutral half of the Windows ConPTY backend. + +Everything here is ordinary Python and runs on every platform, which is the point: the +marshaling rules and the writer protocol are the parts of the backend whose failures are +silent — a truncated command line, a retried cancelled write, a writer parked on a gate +nobody released — and they would otherwise be provable only on a Windows runner. +""" + +import subprocess +import threading +import time + +import pytest + +from plain2code_exceptions import RenderCancelledError +from render_machine import _conpty_support as support +from render_machine._conpty_support import ( + CANCEL_TICK_SECONDS, + GateDecision, + InputLane, + InputQueue, + InputWriter, + Receipt, + WriteAborted, + WriteChannel, + build_command_line, + build_environment_block, + validate_working_directory, +) +from render_machine.terminal_process import InputDisposition, TerminalEnvironmentError + +# Every wait below is bounded, so a failure is a failure rather than a hung suite. +SHORT_TIMEOUT = 5.0 +NUL = "\x00" + + +class FakeChannel(WriteChannel): + """The two native operations, recorded. + + `park` makes a write block until it is cancelled, which is the state a target that has + stopped reading its input leaves the writer in. + """ + + def __init__(self, chunk=None): + self.writes = [] + self.written = bytearray() + self.cancels = 0 + self.chunk = chunk + self.park = False + self.prefix_before_abort = 0 + self.fail = None + self.entered = threading.Event() + self._release = threading.Event() + + def write(self, data: bytes) -> int: + self.writes.append(bytes(data)) + if self.fail is not None: + raise self.fail + if self.park: + self.entered.set() + if not self._release.wait(SHORT_TIMEOUT): + raise AssertionError("the parked write was never cancelled") + self._release.clear() + self.entered.clear() + # A cancelled synchronous write may already have moved a prefix; the completion + # carries no trustworthy cursor either way. + self.written += data[: self.prefix_before_abort] + raise WriteAborted("cancelled") + count = len(data) if self.chunk is None else min(self.chunk, len(data)) + self.written += data[:count] + return count + + def cancel(self) -> None: + self.cancels += 1 + self._release.set() + + +class LateCancelChannel(FakeChannel): + """Ignores its first cancels, the way `CancelSynchronousIo` reports ERROR_NOT_FOUND when + the writer has not entered its write yet.""" + + def __init__(self, ignore_first=1): + super().__init__() + self.ignored = ignore_first + + def cancel(self) -> None: + self.cancels += 1 + if self.ignored > 0: + self.ignored -= 1 + return # nothing was in flight, so the call reached nothing + self._release.set() + + +class SlowSubmitQueue(InputQueue): + """Widens the window between an item becoming visible and whatever the poster does next. + + With the generation published under the same lock as the enqueue, a writer that dequeues + inside this window blocks on that lock before it can acknowledge anything. Published + afterwards, it acknowledges a generation that does not exist yet. + """ + + def submit(self, *args, **kwargs): + result = super().submit(*args, **kwargs) + time.sleep(CANCEL_TICK_SECONDS * 5) + return result + + +class ControlParkChannel(WriteChannel): + """Parks inside the control write and never releases itself on cancel. + + That is what makes a cancel aimed at the control write observable: the test, not the + cancellation, decides when the write completes. + """ + + def __init__(self): + self.entered = threading.Event() + self.release = threading.Event() + self.cancels = 0 + self.written = bytearray() + + def write(self, data: bytes) -> int: + if data[:1] == b"\x03": + self.entered.set() + if not self.release.wait(SHORT_TIMEOUT): + raise AssertionError("the control write was never released") + self.written += data + return len(data) + + def cancel(self) -> None: + self.cancels += 1 + + +def wait_until(predicate, timeout=SHORT_TIMEOUT): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.01) + return predicate() + + +# ------------------------------------------------------------------- marshaling + + +@pytest.mark.parametrize( + "argv", + [ + ["script.ps1", "one two"], + ["script.ps1", 'say "hello"'], + ["script.ps1", "trailing\\\\"], + ["script.ps1", ""], + ["script.ps1", "C:\\path with space\\", "plain"], + ], +) +def test_the_command_line_is_the_quoting_subprocess_already_produces(argv): + """The naive cases: spaces, embedded quotes, trailing backslashes and the empty string + are where hand-rolled quoting produces a different argv without erroring.""" + assert build_command_line(argv) == subprocess.list2cmdline(argv) + + +def test_the_empty_string_argument_is_quoted_rather_than_dropped(): + assert build_command_line(["script.ps1", ""]).endswith('""') + + +def test_an_empty_command_is_refused(): + with pytest.raises(TerminalEnvironmentError): + build_command_line([]) + + +def test_a_nul_in_an_argument_is_refused_before_anything_is_built(): + with pytest.raises(TerminalEnvironmentError) as error: + build_command_line(["script.ps1", f"before{NUL}after"]) + assert "NUL" in str(error.value) + + +def test_a_nul_in_the_working_directory_is_refused(): + with pytest.raises(TerminalEnvironmentError): + validate_working_directory(f"C:\\builds{NUL}") + + +def test_a_working_directory_passes_through_unchanged(): + assert validate_working_directory("C:\\builds") == "C:\\builds" + assert validate_working_directory(None) is None + + +def test_a_nul_in_an_environment_name_is_refused(): + with pytest.raises(TerminalEnvironmentError): + build_environment_block({f"NA{NUL}ME": "value"}) + + +def test_a_nul_in_an_environment_value_is_refused(): + with pytest.raises(TerminalEnvironmentError): + build_environment_block({"NAME": f"va{NUL}lue"}) + + +def test_an_environment_name_carrying_the_block_separator_is_refused(): + with pytest.raises(TerminalEnvironmentError) as error: + build_environment_block({"NA=ME": "value"}) + assert "'='" in str(error.value) + + +def test_an_empty_environment_name_is_refused(): + with pytest.raises(TerminalEnvironmentError): + build_environment_block({"": "value"}) + + +def test_the_environment_block_is_sorted_case_insensitively(): + block = build_environment_block({"beta": "2", "Alpha": "1", "GAMMA": "3"}) + + assert block == f"Alpha=1{NUL}beta=2{NUL}GAMMA=3{NUL}" + + +def test_an_empty_environment_block_still_terminates(): + """The buffer's own terminator supplies the second NUL, so one is enough here.""" + assert build_environment_block({}) == NUL + + +# ------------------------------------------------------------------- the queue + + +def test_admission_accounts_for_the_item_until_it_is_retired(): + queue = InputQueue() + + result, receipt = queue.submit(b"abcd") + + assert result.disposition is InputDisposition.ACCEPTED + assert result.accepted_bytes == 4 + assert queue.pending_bytes() == 4 + queue.next_item(0) # dequeue is not completion + assert queue.pending_bytes() == 4 + queue.retire_current(delivered=True) + assert queue.pending_bytes() == 0 + assert receipt.delivered + + +def test_an_empty_item_never_becomes_an_entry(): + queue = InputQueue() + + result, receipt = queue.submit(b"") + + assert result.disposition is InputDisposition.ACCEPTED + assert queue.pending_items() == 0 + assert receipt.resolved + + +def test_an_oversized_item_is_refused_whole(): + queue = InputQueue(max_item_bytes=4) + + result, _ = queue.submit(b"abcde") + + assert result.disposition is InputDisposition.BACKPRESSURE + assert result.accepted_bytes == 0 + + +def test_a_data_backlog_cannot_crowd_out_the_reserved_partition(): + queue = InputQueue(max_pending_bytes=10, reserved_bytes=4, max_pending_items=10, reserved_items=4) + + assert queue.submit(b"123456")[0].disposition is InputDisposition.ACCEPTED + assert queue.submit(b"7")[0].disposition is InputDisposition.BACKPRESSURE + assert queue.submit(b"7", reserved=True)[0].disposition is InputDisposition.ACCEPTED + + +def test_control_items_are_serviced_ahead_of_queued_data(): + queue = InputQueue() + queue.submit(b"data") + queue.submit(b"\x03", reserved=True, lane=InputLane.CONTROL) + + assert queue.next_item(0).data == b"\x03" + + +def test_a_requeued_item_keeps_its_place_and_its_accounting(): + queue = InputQueue() + queue.submit(b"abc") + queue.next_item(0) + + queue.requeue_current_front() + + assert queue.pending_bytes() == 3 + assert queue.next_item(0).data == b"abc" + + +def test_discarding_data_leaves_the_item_under_the_cursor_alone(): + queue = InputQueue() + queue.submit(b"first") + queue.submit(b"second") + in_flight = queue.next_item(0) + + discarded = queue.discard_pending_data() + + assert [item.data for item in discarded] == [b"second"] + assert queue.current() is in_flight + assert discarded[0].receipt.resolved and not discarded[0].receipt.delivered + + +def test_closing_the_queue_resolves_every_receipt_once(): + queue = InputQueue() + _, first = queue.submit(b"one") + _, second = queue.submit(b"two") + queue.next_item(0) + + queue.close_and_fail_all() + + assert first.resolutions == 1 and second.resolutions == 1 + assert first.attempts == 1 and second.attempts == 1 + assert not first.delivered and not second.delivered + assert queue.submit(b"three")[0].disposition is InputDisposition.CLOSED + + +def test_a_receipt_reports_its_resolution_to_the_producer(): + seen = [] + receipt = Receipt(lambda disposition, error: seen.append((disposition, error))) + + receipt.resolve(InputDisposition.ACCEPTED) + receipt.resolve(InputDisposition.CLOSED) # a second resolution changes nothing + + assert seen == [(InputDisposition.ACCEPTED, None)] + assert receipt.disposition is InputDisposition.ACCEPTED + assert receipt.resolutions == 1 # what took effect + assert receipt.attempts == 2 # what was tried, so a double retirement is still visible + + +# ------------------------------------------------------------------ the writer + + +def start_writer(queue, channel, decision=None): + """Runs the creator's half of the gate protocol and returns the started writer.""" + writer = InputWriter(queue, channel) + writer.start() + native_id = writer.await_ready(time.monotonic() + SHORT_TIMEOUT) + writer.gate.set(GateDecision.RUN if decision is None else decision) + return writer, native_id + + +def test_the_writer_publishes_its_native_id_before_it_parks(): + queue, channel = InputQueue(), FakeChannel() + + writer, native_id = start_writer(queue, channel) + try: + assert native_id is not None and native_id == writer.native_id + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_a_writer_released_with_abort_never_touches_the_pipe(): + queue, channel = InputQueue(), FakeChannel() + queue.submit(b"payload") + + writer, _ = start_writer(queue, channel, decision=GateDecision.ABORT) + + assert wait_until(writer.finished.is_set) + assert channel.writes == [] + assert not writer.failed.is_set() + + +def test_a_writer_that_dies_before_publishing_its_id_still_releases_the_creator(monkeypatch): + def unavailable(): + raise OSError("no native id") + + monkeypatch.setattr(support, "native_thread_id", unavailable) + queue, channel = InputQueue(), FakeChannel() + + writer, native_id = start_writer(queue, channel) + + assert native_id is None + assert wait_until(writer.failed.is_set) + assert channel.writes == [] + + +def test_the_ready_wait_gives_up_at_its_deadline(): + """A writer that never starts must not park the creator forever.""" + writer = InputWriter(InputQueue(), FakeChannel()) # deliberately not started + + assert writer.await_ready(time.monotonic() + 0.05) is None + + +def test_a_whole_item_is_written_and_its_receipt_reports_delivery(): + queue, channel = InputQueue(), FakeChannel(chunk=2) + writer, _ = start_writer(queue, channel) + try: + _, receipt = queue.submit(b"abcdef") + + assert wait_until(lambda: receipt.resolved) + assert receipt.delivered + assert bytes(channel.written) == b"abcdef" + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_an_urgent_control_item_cancels_the_data_write_in_flight(): + queue, channel = InputQueue(), FakeChannel() + channel.park = True + writer, _ = start_writer(queue, channel) + try: + _, data_receipt = queue.submit(b"blocked payload") + assert channel.entered.wait(SHORT_TIMEOUT) + channel.park = False # the control write itself completes + + assert writer.deliver_control(b"\x03", SHORT_TIMEOUT) + + assert bytes(channel.written).endswith(b"\x03") + assert data_receipt.resolved and not data_receipt.delivered + assert channel.cancels >= 1 + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_a_cancelled_write_is_never_retried_and_never_duplicates_its_prefix(): + queue, channel = InputQueue(), FakeChannel() + channel.park = True + channel.prefix_before_abort = 3 + writer, _ = start_writer(queue, channel) + try: + _, data_receipt = queue.submit(b"abcdef") + assert channel.entered.wait(SHORT_TIMEOUT) + channel.park = False + + assert writer.deliver_control(b"\x03", SHORT_TIMEOUT) + + assert channel.writes.count(b"abcdef") == 1 # the buffer is never reissued + assert bytes(channel.written) == b"abc\x03" + assert data_receipt.resolutions == 1 + assert data_receipt.attempts == 1 # retired once, never resolved a second time + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_no_cancel_is_issued_once_the_writer_has_acknowledged_the_generation(): + queue, channel = InputQueue(), FakeChannel() + writer, _ = start_writer(queue, channel) + try: + assert writer.deliver_control(b"\x03", SHORT_TIMEOUT) + settled = writer.cancels + + time.sleep(CANCEL_TICK_SECONDS * 5) + + assert writer.cancels == settled + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_an_undelivered_control_item_reports_failure_rather_than_waiting_out_the_grace(): + queue = InputQueue(max_pending_bytes=0, reserved_bytes=0) # no capacity for anything + writer, _ = start_writer(queue, FakeChannel()) + try: + assert writer.deliver_control(b"\x03", 0.2) is False + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_stopping_an_idle_writer_joins_it(): + """An idle writer is parked on the queue rather than inside a write, so a cancel-only + loop would never join it.""" + queue, channel = InputQueue(), FakeChannel() + writer, _ = start_writer(queue, channel) + + assert writer.stop(SHORT_TIMEOUT) + assert channel.writes == [] + + +def test_stopping_discards_queued_data_rather_than_writing_it(): + queue, channel = InputQueue(), FakeChannel() + channel.park = True + writer, _ = start_writer(queue, channel) + _, first = queue.submit(b"in flight") + assert channel.entered.wait(SHORT_TIMEOUT) + _, second = queue.submit(b"queued behind it") + + assert writer.stop(SHORT_TIMEOUT) + + assert first.resolved and not first.delivered + assert second.resolved and not second.delivered + assert b"queued behind it" not in channel.writes + + +def test_a_write_cancelled_by_the_stop_protocol_is_not_a_writer_failure(): + queue, channel = InputQueue(), FakeChannel() + channel.park = True + writer, _ = start_writer(queue, channel) + queue.submit(b"blocked payload") + assert channel.entered.wait(SHORT_TIMEOUT) + + assert writer.stop(SHORT_TIMEOUT) + + assert not writer.failed.is_set() + + +def test_a_cancellation_nobody_asked_for_is_a_writer_failure(): + queue, channel = InputQueue(), FakeChannel() + channel.fail = WriteAborted("cancelled by nobody") + writer, _ = start_writer(queue, channel) + try: + queue.submit(b"payload") + + assert wait_until(writer.failed.is_set) + assert isinstance(writer.exc, WriteAborted) + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_a_write_failure_is_published_to_the_foreground(): + queue, channel = InputQueue(), FakeChannel() + channel.fail = OSError("the pipe is gone") + writer, _ = start_writer(queue, channel) + try: + _, receipt = queue.submit(b"payload") + + assert wait_until(writer.failed.is_set) + assert isinstance(writer.exc, OSError) + assert receipt.resolved and not receipt.delivered + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_stopping_retries_the_cancel_that_reached_nothing(): + """A cancel issued in the dequeue-to-write gap reports ERROR_NOT_FOUND and the writer + then blocks after it, so a one-shot cancel would hang to the bound.""" + queue, channel = InputQueue(), LateCancelChannel(ignore_first=1) + channel.park = True + writer, _ = start_writer(queue, channel) + queue.submit(b"blocked payload") + assert channel.entered.wait(SHORT_TIMEOUT) + + assert writer.stop(SHORT_TIMEOUT) + + assert channel.cancels >= 2 # the first reached nothing; a later tick landed + assert channel.ignored == 0 + + +def test_a_control_item_is_delivered_even_when_the_first_cancel_reaches_nothing(): + queue, channel = InputQueue(), LateCancelChannel(ignore_first=1) + channel.park = True + writer, _ = start_writer(queue, channel) + try: + queue.submit(b"blocked payload") + assert channel.entered.wait(SHORT_TIMEOUT) + channel.park = False + + assert writer.deliver_control(b"\x03", SHORT_TIMEOUT) + + assert channel.cancels >= 2 + assert bytes(channel.written).endswith(b"\x03") + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_an_idle_writers_control_write_is_never_cancelled(): + """The generation is published under the same lock that makes the item visible, so a + writer that dequeues it immediately has already acknowledged the request the poster is + about to wait on — otherwise the poster cancels the very write it asked for.""" + channel = ControlParkChannel() + writer, _ = start_writer(SlowSubmitQueue(), channel) + delivered = [] + poster = threading.Thread(target=lambda: delivered.append(writer.deliver_control(b"\x03", SHORT_TIMEOUT))) + try: + poster.start() + assert channel.entered.wait(SHORT_TIMEOUT) + cancels_at_entry = channel.cancels + + time.sleep(CANCEL_TICK_SECONDS * 5) + + assert channel.cancels == cancels_at_entry + channel.release.set() + poster.join(SHORT_TIMEOUT) + assert delivered == [True] + finally: + channel.release.set() + poster.join(SHORT_TIMEOUT) + writer.stop(SHORT_TIMEOUT) + + +def test_a_full_data_queue_still_admits_the_graceful_control_byte(): + """Query replies are ordinary admissions, so a query-emitting target cannot fill the + capacity cancellation depends on.""" + queue = InputQueue(max_pending_bytes=64, reserved_bytes=16, max_pending_items=8, reserved_items=2) + channel = FakeChannel() + channel.park = True + writer, _ = start_writer(queue, channel) + try: + replies = [queue.submit(b"reply")[0] for _ in range(16)] + assert channel.entered.wait(SHORT_TIMEOUT) # the writer is genuinely blocked in a write + assert any(result.disposition is InputDisposition.BACKPRESSURE for result in replies) + channel.park = False + + assert writer.deliver_control(b"\x03", SHORT_TIMEOUT) + + # First on the wire: the control lane is serviced ahead of everything queued behind + # the write it preempted. + assert bytes(channel.written).startswith(b"\x03") + finally: + writer.stop(SHORT_TIMEOUT) + + +def test_a_saturated_writer_still_stops_within_the_bound(): + queue, channel = InputQueue(), FakeChannel() + channel.park = True + writer, _ = start_writer(queue, channel) + for _ in range(50): + queue.submit(b"reply") + assert channel.entered.wait(SHORT_TIMEOUT) + + started = time.monotonic() + assert writer.stop(SHORT_TIMEOUT) + + assert time.monotonic() - started < SHORT_TIMEOUT + + +def test_the_ready_wait_reports_a_cancellation_that_arrives_after_readiness(): + """The stop check runs at least once even when the writer is already ready: a render + cancelled during writer startup must not proceed to launch the target.""" + writer = InputWriter(InputQueue(), FakeChannel()) + writer.start() + assert wait_until(writer.ready.is_set) + + def cancelled(): + raise RenderCancelledError() + + try: + with pytest.raises(RenderCancelledError): + writer.await_ready(time.monotonic() + SHORT_TIMEOUT, cancelled) + finally: + writer.stop(SHORT_TIMEOUT) diff --git a/tests/test_exit_with_error.py b/tests/test_exit_with_error.py new file mode 100644 index 00000000..5637e17b --- /dev/null +++ b/tests/test_exit_with_error.py @@ -0,0 +1,65 @@ +"""The operator-facing message on a failed render. + +`ExitWithError` prints the payload the failing action handed over, but not every path +into it supplies one — a render that gave up inside the unit-test fix loop arrives with +`None`, and the user was shown a bare `ERROR codeplain: None` with no reason. The encoded +payload already falls back to `last_error_message`; the console line must agree, so the +message a user sees is never less informative than the one the renderer returns. +""" + +from unittest.mock import MagicMock, patch + +from render_machine.actions.exit_with_error import ExitWithError +from render_machine.render_types import RenderError + + +def render_context(last_error_message=None): + context = MagicMock() + context.last_error_message = last_error_message + context.frid_context.frid = "2" + context.run_state.render_id = "render-id" + return context + + +def executed_with(payload, last_error_message=None): + context = render_context(last_error_message) + with patch("render_machine.actions.exit_with_error.console") as console: + outcome, encoded = ExitWithError().execute(context, payload) + return console.error.call_args[0][0], outcome, encoded + + +def test_the_failing_action_s_own_message_is_shown(): + shown, outcome, _ = executed_with("Conformance tests could not be fixed.") + + assert shown == "Conformance tests could not be fixed." + assert outcome == ExitWithError.SUCCESSFUL_OUTCOME + + +def test_a_missing_payload_falls_back_to_the_last_error_message(): + shown, _, _ = executed_with(None, last_error_message="The Unit Tests script has failed.") + + assert shown == "The Unit Tests script has failed." + + +def test_with_nothing_to_report_the_user_still_gets_words(): + shown, _, _ = executed_with(None) + + assert shown == "Unknown error" + + +def test_an_encoded_error_payload_is_unwrapped_to_its_reason(): + """The conformance-fix-exhausted path arrives as an encoded RenderError, which used + to reach the user as a raw dict repr.""" + payload = RenderError.encode(message="Could not produce an implementation that passes.").to_payload() + + shown, _, _ = executed_with(payload) + + assert shown == "Could not produce an implementation that passes." + + +def test_the_shown_message_matches_the_encoded_one(): + """Two sources of truth for the same failure would let the log and the returned + error disagree about why a render stopped.""" + shown, _, encoded = executed_with(None, last_error_message="The Unit Tests script has failed.") + + assert shown == encoded["error"]["message"] diff --git a/tests/test_fix_loop_metrics.py b/tests/test_fix_loop_metrics.py new file mode 100644 index 00000000..150d41e7 --- /dev/null +++ b/tests/test_fix_loop_metrics.py @@ -0,0 +1,230 @@ +"""Tests for the fix-loop instrumentation. + +Every loss in the retry5-era benchmarks was a fix loop spending its whole budget +re-patching one file against one failure. The loop could not tell it was stuck, and the +only externally visible outcome was a rare binary "did the render abort" — too coarse to +compare configurations against. This turns both into observations: a streak counter that +names a repeated-identical failure while it is happening, and per-FRID attempt counts +that make convergence a continuous measure. + +The fingerprint has to survive the parts of a test-script's output that change on every +run — temp paths, durations, addresses — while still separating genuinely different +failures, since both mistakes destroy the signal in opposite directions. +""" + +from render_machine.fix_loop_metrics import ( + CONFORMANCE_LOOP, + CONSECUTIVE_FAILURE_THRESHOLD, + UNIT_LOOP, + FixLoopMetrics, + failure_fingerprint, + stalled_reason, +) + + +def test_the_same_failure_fingerprints_the_same(): + first = "FAILED test_header.py::test_subtitle\nAssertionError: subtitle not shown" + second = "FAILED test_header.py::test_subtitle\nAssertionError: subtitle not shown" + + assert failure_fingerprint(first) == failure_fingerprint(second) + + +def test_volatile_noise_does_not_change_the_fingerprint(): + """Two runs of one failing suite differ in temp path, duration and address.""" + first = ( + "Output stored in /tmp/tmpk8flk7f1.script_output\n# duration_ms 1335.821531\nat 0x7f3a2b1c AssertionError: x" + ) + second = "Output stored in /tmp/tmpy0wo02yi.script_output\n# duration_ms 22.5\nat 0x55e1ff90 AssertionError: x" + + assert failure_fingerprint(first) == failure_fingerprint(second) + + +def test_a_different_failure_fingerprints_differently(): + assert failure_fingerprint("AssertionError: subtitle not shown") != failure_fingerprint( + "AssertionError: button not found" + ) + + +def test_a_first_failure_is_not_a_repeat(): + metrics = FixLoopMetrics() + + streak = metrics.record(UNIT_LOOP, module="m", frid="1", passed=False, output="boom") + + assert streak is None + + +def test_the_same_failure_twice_reports_a_streak(): + metrics = FixLoopMetrics() + + metrics.record(UNIT_LOOP, module="m", frid="1", passed=False, output="boom") + streak = metrics.record(UNIT_LOOP, module="m", frid="1", passed=False, output="boom") + + assert streak == 2 + + +def test_a_different_failure_restarts_the_streak(): + metrics = FixLoopMetrics() + + metrics.record(UNIT_LOOP, module="m", frid="1", passed=False, output="boom") + metrics.record(UNIT_LOOP, module="m", frid="1", passed=False, output="boom") + streak = metrics.record(UNIT_LOOP, module="m", frid="1", passed=False, output="different") + + assert streak is None + + +def test_the_two_loops_are_counted_apart(): + """A unit-test failure must not extend a conformance streak, or vice versa.""" + metrics = FixLoopMetrics() + + metrics.record(UNIT_LOOP, module="m", frid="1", passed=False, output="boom") + streak = metrics.record(CONFORMANCE_LOOP, module="m", frid="1", passed=False, output="boom") + + assert streak is None + + +def test_each_frid_counts_its_own_attempts(): + metrics = FixLoopMetrics() + + metrics.record(UNIT_LOOP, module="m", frid="1", passed=False, output="a") + metrics.record(UNIT_LOOP, module="m", frid="1", passed=True, output="") + metrics.record(UNIT_LOOP, module="m", frid="2", passed=True, output="") + + assert ( + metrics.frid_summary("m", "1") + == "[fix-loop] module=m frid=1 unit=2 unit_failed=1 unit_max_repeat=1 max_repeat=1" + ) + assert ( + metrics.frid_summary("m", "2") + == "[fix-loop] module=m frid=2 unit=1 unit_failed=0 unit_max_repeat=1 max_repeat=1" + ) + + +def test_a_frid_summary_reports_both_loops_and_the_worst_streak(): + metrics = FixLoopMetrics() + + for _ in range(3): + metrics.record(CONFORMANCE_LOOP, module="m", frid="2", passed=False, output="same") + metrics.record(UNIT_LOOP, module="m", frid="2", passed=True, output="") + + summary = metrics.frid_summary("m", "2") + + assert "conformance=3" in summary + assert "conformance_failed=3" in summary + assert "unit=1" in summary + assert "max_repeat=3" in summary + + +def test_each_loop_reports_its_own_streak(): + """The aggregate cannot say which loop wedged, and the two wedge for different + reasons: a stuck unit loop means the implementation is not moving, a stuck + conformance loop can mean the test script never even ran. Reading a run where the + unit loop repeated seven times and conformance only three, the single number says + 7 and invites the reader to attribute it to conformance.""" + metrics = FixLoopMetrics() + for _ in range(7): + metrics.record(UNIT_LOOP, module="m", frid="3", passed=False, output="same unit failure") + for _ in range(3): + metrics.record(CONFORMANCE_LOOP, module="m", frid="3", passed=False, output="same conformance failure") + + summary = metrics.frid_summary("m", "3") + + assert "unit_max_repeat=7" in summary + assert "conformance_max_repeat=3" in summary + assert "max_repeat=7" in summary # the aggregate stays, for continuity of the series + + +def test_the_current_streak_is_readable_after_the_fact(): + """The fix action runs after the test action and has to ask again, from its own call + site, rather than relying on what record() returned to someone else.""" + metrics = FixLoopMetrics() + for _ in range(3): + metrics.record(CONFORMANCE_LOOP, module="m", frid="1", passed=False, output="same") + + assert metrics.current_streak(CONFORMANCE_LOOP, "m", "1") == 3 + + +def test_the_current_streak_resets_when_the_failure_changes(): + metrics = FixLoopMetrics() + for _ in range(3): + metrics.record(CONFORMANCE_LOOP, module="m", frid="1", passed=False, output="same") + metrics.record(CONFORMANCE_LOOP, module="m", frid="1", passed=False, output="different") + + assert metrics.current_streak(CONFORMANCE_LOOP, "m", "1") == 1 + + +def test_the_current_streak_clears_when_the_loop_passes(): + metrics = FixLoopMetrics() + for _ in range(3): + metrics.record(CONFORMANCE_LOOP, module="m", frid="1", passed=False, output="same") + metrics.record(CONFORMANCE_LOOP, module="m", frid="1", passed=True, output="") + + assert metrics.current_streak(CONFORMANCE_LOOP, "m", "1") == 0 + + +def test_an_unrun_loop_has_no_streak(): + metrics = FixLoopMetrics() + metrics.record(UNIT_LOOP, module="m", frid="1", passed=False, output="boom") + + assert metrics.current_streak(CONFORMANCE_LOOP, "m", "1") == 0 + assert metrics.current_streak(CONFORMANCE_LOOP, "m", "9") == 0 + + +def test_an_unseen_frid_has_no_summary(): + assert FixLoopMetrics().frid_summary("m", "9") is None + + +def test_the_render_summary_covers_every_frid_touched(): + metrics = FixLoopMetrics() + metrics.record(UNIT_LOOP, module="m", frid="1", passed=True, output="") + metrics.record(CONFORMANCE_LOOP, module="m", frid="2", passed=False, output="x") + + lines = metrics.render_summary() + + assert len(lines) == 2 + assert any("frid=1" in line for line in lines) + assert any("frid=2" in line for line in lines) + + +def test_the_render_summary_is_empty_when_no_script_ran(): + """A render that failed before any test script must not emit a misleading summary.""" + assert FixLoopMetrics().render_summary() == [] + + +def test_a_regenerated_test_is_not_condemned_by_the_old_test_s_stall(): + """The bug that spent a whole regeneration budget in twenty-eight seconds. + + Regeneration hands the loop a different test. The stall that justified it was measured + against the test just deleted, so if it survives, the replacement's very first failure + lands on a counter already past the threshold and the replacement is discarded after + one attempt. Four benchmark renders burned three regenerations each that way, 7 -> 8 -> + 9 consecutive failures, all inside half a minute. + """ + metrics = FixLoopMetrics() + for _ in range(CONSECUTIVE_FAILURE_THRESHOLD): + metrics.record(CONFORMANCE_LOOP, module="m", frid="2", passed=False, output="always different %s") + + assert stalled_reason(metrics, CONFORMANCE_LOOP, module="m", frid="2") is not None + + metrics.start_over(CONFORMANCE_LOOP, module="m", frid="2") + + assert stalled_reason(metrics, CONFORMANCE_LOOP, module="m", frid="2") is None + + # One failure of the replacement must not re-trigger the switch on its own. + metrics.record(CONFORMANCE_LOOP, module="m", frid="2", passed=False, output="a new failure") + + assert stalled_reason(metrics, CONFORMANCE_LOOP, module="m", frid="2") is None + + +def test_starting_over_keeps_the_work_already_counted(): + """The cumulative counts answer a different question and the benchmark series is + indexed on them, so a reset must not erase them.""" + metrics = FixLoopMetrics() + for _ in range(4): + metrics.record(CONFORMANCE_LOOP, module="m", frid="2", passed=False, output="identical") + + metrics.start_over(CONFORMANCE_LOOP, module="m", frid="2") + summary = metrics.frid_summary("m", "2") + + assert "conformance=4" in summary + assert "conformance_failed=4" in summary + assert "conformance_max_repeat=4" in summary diff --git a/tests/test_fix_loop_reporting.py b/tests/test_fix_loop_reporting.py new file mode 100644 index 00000000..513d3530 --- /dev/null +++ b/tests/test_fix_loop_reporting.py @@ -0,0 +1,76 @@ +"""Tests for where the fix-loop instrumentation is wired in. + +Counting is only useful if every script run reaches the counter and the counts survive +the paths a render actually takes — including the failing one, where the FRID that +exhausted its budget never reaches FinishFunctionalRequirement and would otherwise take +its numbers with it. +""" + +from unittest.mock import MagicMock, patch + +from render_machine.actions.exit_with_error import ExitWithError +from render_machine.fix_loop_metrics import ( + CONFORMANCE_LOOP, + REPEATED_FAILURE_WARNING_THRESHOLD, + UNIT_LOOP, + FixLoopMetrics, + report_fix_loop_attempt, +) + + +def render_context(): + context = MagicMock() + context.module_name = "m" + context.fix_loop_metrics = FixLoopMetrics() + context.last_error_message = "stopped" + context.frid_context.frid = "1" + return context + + +def test_an_attempt_without_a_frid_is_not_counted(): + """Test scripts also run outside a functionality (module setup); those attempts + belong to no FRID and must not be attributed to one.""" + context = render_context() + + report_fix_loop_attempt(context, loop=UNIT_LOOP, frid=None, passed=False, output="boom") + + assert context.fix_loop_metrics.render_summary() == [] + + +def test_a_repeated_failure_warns_only_once_it_is_clearly_stuck(): + context = render_context() + + with patch("render_machine.fix_loop_metrics.console") as console: + for _ in range(REPEATED_FAILURE_WARNING_THRESHOLD - 1): + report_fix_loop_attempt(context, loop=CONFORMANCE_LOOP, frid="2", passed=False, output="same") + + assert console.warning.call_count == 0, "warned before the loop was demonstrably stuck" + + report_fix_loop_attempt(context, loop=CONFORMANCE_LOOP, frid="2", passed=False, output="same") + + assert console.warning.call_count == 1 + warning = console.warning.call_args[0][0] + assert "functionality 2" in warning + assert f"{REPEATED_FAILURE_WARNING_THRESHOLD} times in a row" in warning + + +def test_progress_keeps_the_loop_quiet(): + context = render_context() + + with patch("render_machine.fix_loop_metrics.console") as console: + for attempt in range(6): + report_fix_loop_attempt(context, loop=UNIT_LOOP, frid="1", passed=False, output=f"failure {attempt}") + + assert console.warning.call_count == 0 + + +def test_a_failed_render_still_reports_its_counts(): + """The exhausted FRID never reaches FinishFunctionalRequirement.""" + context = render_context() + report_fix_loop_attempt(context, loop=CONFORMANCE_LOOP, frid="2", passed=False, output="x") + + with patch("render_machine.actions.exit_with_error.console") as console: + ExitWithError().execute(context, None) + + reported = [call[0][0] for call in console.info.call_args_list] + assert any("[fix-loop]" in line and "frid=2" in line for line in reported) diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py index dac3675d..e0fef30a 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -1,3 +1,4 @@ +import gc import os import tempfile from pathlib import Path @@ -28,14 +29,19 @@ def temp_repo(): # Create and commit initial file file_path = Path(temp_dir) / "test.txt" - file_path.write_text("initial content\nline2\nline3\n") + file_path.write_text("initial content\nline2\nline3\n", newline="\n") - repo = Repo(temp_dir) - repo.index.add(["test.txt"]) + with Repo(temp_dir) as repo: + repo.index.add(["test.txt"]) add_all_files_and_commit(temp_dir, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("1.1")) yield temp_dir + # Repos opened inside the test body may still hold `git cat-file` children; + # on Windows those keep temp_dir undeletable. Collecting here reaps them + # (GitPython terminates the children when its objects are finalized). + gc.collect() + @pytest.fixture def empty_repo(): @@ -43,6 +49,7 @@ def empty_repo(): with tempfile.TemporaryDirectory() as temp_dir: init_git_repo(temp_dir) yield temp_dir + gc.collect() # same reason as in temp_repo def test_empty_diff(temp_repo): @@ -57,7 +64,7 @@ def test_single_file_change(temp_repo): # Modify the file file_path = Path(temp_repo) / "test.txt" - file_path.write_text("modified content\nline2\nline3\n") + file_path.write_text("modified content\nline2\nline3\n", newline="\n") repo.index.add(["test.txt"]) repo.index.commit("Modified test.txt") @@ -83,15 +90,15 @@ def test_multiple_file_changes(temp_repo): # Create and commit second file file2_path = Path(temp_repo) / "file2.txt" - file2_path.write_text("file2 initial\nline2\n") + file2_path.write_text("file2 initial\nline2\n", newline="\n") add_all_files_and_commit(temp_repo, "Added file2.txt", None, "1.2") # Modify both files file1_path = Path(temp_repo) / "test.txt" - file1_path.write_text("file1 modified\nline2\n") + file1_path.write_text("file1 modified\nline2\n", newline="\n") - file2_path.write_text("file2 modified\nline2") + file2_path.write_text("file2 modified\nline2", newline="\n") # Get diff result = diff(temp_repo, "1.1") @@ -130,23 +137,23 @@ def test_multiple_commits_diff(temp_repo): # Create and commit second file file2_path = Path(temp_repo) / "file2.txt" - file2_path.write_text("file2 frid1.1 refactored version\nline2\n") + file2_path.write_text("file2 frid1.1 refactored version\nline2\n", newline="\n") add_all_files_and_commit(temp_repo, REFACTORED_CODE_COMMIT_MESSAGE.format("1.1"), None, "1.1") add_all_files_and_commit(temp_repo, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("1.1"), None, "1.1") - file1_path.write_text("file1 frid1.2 version\nline2\n") - file2_path.write_text("file2 frid1.2 version\nline2\n") + file1_path.write_text("file1 frid1.2 version\nline2\n", newline="\n") + file2_path.write_text("file2 frid1.2 version\nline2\n", newline="\n") add_all_files_and_commit(temp_repo, "implemented frid 1.2", None, "1.2") - file1_path.write_text("file1 frid1.2 refactored version\nline2\n") + file1_path.write_text("file1 frid1.2 refactored version\nline2\n", newline="\n") add_all_files_and_commit(temp_repo, REFACTORED_CODE_COMMIT_MESSAGE.format("1.2"), None, "1.2") add_all_files_and_commit(temp_repo, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("1.2"), None, "1.2") file3_path = Path(temp_repo) / "file3.txt" - file3_path.write_text("file3 frid1.2 new file\nline2\n") + file3_path.write_text("file3 frid1.2 new file\nline2\n", newline="\n") # Get diff result = diff(temp_repo, "1.1") @@ -192,12 +199,12 @@ def test_diff_without_previous_frid_and_no_base_folder(empty_repo): """Test diff without previous frid and no base folder.""" # Create a new file without committing file_path = Path(empty_repo) / "new.txt" - file_path.write_text("new file content\nline2\n") + file_path.write_text("new file content\nline2\n", newline="\n") add_all_files_and_commit(empty_repo, "First commit") # create one more file file_path = Path(empty_repo) / "new2.txt" - file_path.write_text("new file content\nline2\n") + file_path.write_text("new file content\nline2\n", newline="\n") # Get diff result = diff(empty_repo) @@ -227,11 +234,11 @@ def test_diff_without_previous_frid_and_base_folder(temp_repo): """Test diff without previous frid and base folder.""" # Create a commit for the base folder file_path = Path(temp_repo) / "new.txt" - file_path.write_text("base folder content\nline2\n") + file_path.write_text("base folder content\nline2\n", newline="\n") add_all_files_and_commit(temp_repo, BASE_FOLDER_COMMIT_MESSAGE) # update the file - file_path.write_text("updated base folder content\nline2\n") + file_path.write_text("updated base folder content\nline2\n", newline="\n") # Get diff result = diff(temp_repo) @@ -254,7 +261,7 @@ def test_new_file(temp_repo): # Create a new file without committing file_path = Path(temp_repo) / "new.txt" - file_path.write_text("new file content\nline2\n") + file_path.write_text("new file content\nline2\n", newline="\n") # Get diff result = diff(temp_repo, "1.1") @@ -310,9 +317,9 @@ def test_add_all_files_and_commit(temp_repo): """Test adding all files and committing them.""" # Create some test files file1_path = Path(temp_repo) / "file1.txt" - file1_path.write_text("content1") + file1_path.write_text("content1", newline="\n") file2_path = Path(temp_repo) / "file2.txt" - file2_path.write_text("content2") + file2_path.write_text("content2", newline="\n") # Add and commit files repo = add_all_files_and_commit(temp_repo, "Test commit", None, "FR123", "render-id") @@ -332,7 +339,7 @@ def test_add_all_files_and_commit(temp_repo): assert "file1.txt" in tree assert "file2.txt" in tree - file2_path.write_text("content2 modified") + file2_path.write_text("content2 modified", newline="\n") repo = add_all_files_and_commit(temp_repo, "Commit changes on existing file", None, "FR4") commits = list(repo.iter_commits()) assert len(commits) == 4 @@ -348,11 +355,11 @@ def test_revert_changes(temp_repo): """Test reverting changes in the repository.""" # Create and commit initial file file_path = Path(temp_repo) / "test.txt" - file_path.write_text("initial content") + file_path.write_text("initial content", newline="\n") repo = add_all_files_and_commit(temp_repo, "Initial commit", None, "FR123") # Modify the file - file_path.write_text("modified content") + file_path.write_text("modified content", newline="\n") # Verify the file was modified assert file_path.read_text() == "modified content" @@ -371,19 +378,19 @@ def test_revert_to_commit_with_frid(temp_repo): """Test reverting to a specific commit with FRID.""" # Create and commit first version file_path = Path(temp_repo) / "test.txt" - file_path.write_text("version 1") + file_path.write_text("version 1", newline="\n") repo = add_all_files_and_commit( temp_repo, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("FR123"), None, "FR123" ) # Create and commit second version - file_path.write_text("version 2") + file_path.write_text("version 2", newline="\n") repo = add_all_files_and_commit( temp_repo, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("FR456"), None, "FR456" ) # Create and commit third version - file_path.write_text("version 3") + file_path.write_text("version 3", newline="\n") repo = add_all_files_and_commit( temp_repo, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("FR789"), None, "FR789" ) @@ -407,11 +414,11 @@ def test_revert_to_commit_with_frid_and_base_folder(temp_repo): """Test reverting to base folder.""" # Create a commit for the base folder file_path = Path(temp_repo) / "new.txt" - file_path.write_text("base folder content\nline1\n") + file_path.write_text("base folder content\nline1\n", newline="\n") add_all_files_and_commit(temp_repo, BASE_FOLDER_COMMIT_MESSAGE) # create another commit - file_path.write_text("changed file content\nline2\n") + file_path.write_text("changed file content\nline2\n", newline="\n") add_all_files_and_commit(temp_repo, "Another commit") # revert to base folder @@ -425,7 +432,7 @@ def test_revert_to_base_folder_no_commit(temp_repo): """Test reverting to base folder.""" # Create a commit for the base folder file_path = Path(temp_repo) / "new.txt" - file_path.write_text("some content\n") + file_path.write_text("some content\n", newline="\n") add_all_files_and_commit(temp_repo, "FRID", 123) # revert initial commit @@ -452,7 +459,7 @@ def test_get_last_finished_frid_empty_repo(empty_repo): def test_get_last_finished_frid_returns_latest(empty_repo): """Return the module name and frid from the most recent finished-frid commit.""" file_path = Path(empty_repo) / "a.txt" - file_path.write_text("v1") + file_path.write_text("v1", newline="\n") add_all_files_and_commit( empty_repo, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("1"), @@ -460,7 +467,7 @@ def test_get_last_finished_frid_returns_latest(empty_repo): frid="1", ) - file_path.write_text("v2") + file_path.write_text("v2", newline="\n") add_all_files_and_commit( empty_repo, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("2"), @@ -474,7 +481,7 @@ def test_get_last_finished_frid_returns_latest(empty_repo): def test_get_last_finished_frid_ignores_non_finished_commits(empty_repo): """Commits that aren't finished-frid checkpoints must be skipped.""" file_path = Path(empty_repo) / "a.txt" - file_path.write_text("v1") + file_path.write_text("v1", newline="\n") add_all_files_and_commit( empty_repo, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("1"), @@ -483,7 +490,7 @@ def test_get_last_finished_frid_ignores_non_finished_commits(empty_repo): ) # A refactor commit (not a finished-frid checkpoint) comes after. - file_path.write_text("v2") + file_path.write_text("v2", newline="\n") add_all_files_and_commit( empty_repo, REFACTORED_CODE_COMMIT_MESSAGE.format("2"), @@ -498,7 +505,7 @@ def test_get_last_finished_frid_ignores_non_finished_commits(empty_repo): def test_get_last_finished_frid_without_module_name(empty_repo): """Raise InvalidGitRepositoryError when the finished commit omits the module name line.""" file_path = Path(empty_repo) / "a.txt" - file_path.write_text("v1") + file_path.write_text("v1", newline="\n") add_all_files_and_commit( empty_repo, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("7"), diff --git a/tests/test_headless_logging.py b/tests/test_headless_logging.py new file mode 100644 index 00000000..54a7b4b6 --- /dev/null +++ b/tests/test_headless_logging.py @@ -0,0 +1,134 @@ +"""Tests for what a headless render says while it is running. + +Headless suppresses Rich output and attaches no TUI handler, so before this the only +sink was the log file — the process was silent on stdout for its entire run and a +benchmark job could not tell a render wedged for four hours from a healthy one until +the file was collected at the end. A four-hour cli-password-manager render produced +858 lines, all of them in its first two minutes. + +The second half of this file guards the hazard that adding a second handler exposes: +one record is handed to every handler in turn, so a formatter that rewrites +`record.msg` in place corrupts the output of the handlers after it. +""" + +import logging +import sys +from unittest.mock import MagicMock + +import pytest + +from plain2code import setup_logging +from plain2code_logger import LOGGER_NAME, ElapsedTimeFormatter, IndentedFormatter + + +@pytest.fixture +def run_state(): + state = MagicMock() + state.get_live_render_time.return_value = 3661 # 01:01:01 + return state + + +def record(message, args=None): + return logging.LogRecord( + name="codeplain", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg=message, + args=args, + exc_info=None, + ) + + +def test_the_elapsed_formatter_stamps_the_render_time(run_state): + assert ElapsedTimeFormatter(run_state).format(record("hello")) == "[01:01:01] INFO codeplain: hello" + + +def test_continuation_lines_are_indented_past_the_timestamp(run_state): + formatted = ElapsedTimeFormatter(run_state).format(record("first\nsecond")) + + assert formatted == "[01:01:01] INFO codeplain: first\n second" + + +def test_formatting_twice_does_not_indent_twice(run_state): + """Two handlers share one record. Formatting must be idempotent from the record's + point of view, or the file log inherits the stdout log's indentation.""" + formatter = ElapsedTimeFormatter(run_state) + entry = record("first\nsecond") + + first_pass = formatter.format(entry) + second_pass = formatter.format(entry) + + assert first_pass == second_pass + + +def test_two_different_formatters_do_not_corrupt_each_other(run_state): + """The real pairing in a headless render that also logs to a file.""" + entry = record("first\nsecond") + + IndentedFormatter("%(levelname)s:%(name)s:%(message)s").format(entry) + elapsed = ElapsedTimeFormatter(run_state).format(entry) + + assert elapsed == "[01:01:01] INFO codeplain: first\n second" + + +def test_the_indented_formatter_leaves_the_record_alone(run_state): + entry = record("first\nsecond") + + IndentedFormatter("%(levelname)s:%(name)s:%(message)s").format(entry) + + assert entry.msg == "first\nsecond" + + +def test_arguments_are_interpolated_exactly_once(run_state): + """The copy carries an already-interpolated message, so its args must be cleared — + otherwise the parent formatter interpolates a second time and raises.""" + assert ElapsedTimeFormatter(run_state).format(record("value is %s", ("x",))).endswith("value is x") + + +@pytest.fixture +def configured_handlers(run_state): + """setup_logging mutates the process-wide "codeplain" logger; restore it after.""" + logger = logging.getLogger(LOGGER_NAME) + saved_handlers, saved_level = list(logger.handlers), logger.level + + def configure(headless): + logger.handlers = [] + args = MagicMock() + args.verbose = False + args.logging_config_path = None + setup_logging(args, MagicMock(), run_state, log_to_file=False, log_file_path="", headless=headless) + return logger.handlers + + yield configure + + logger.handlers, logger.level = saved_handlers, saved_level + + +def stdout_handlers(handlers): + return [h for h in handlers if type(h) is logging.StreamHandler and h.stream is sys.stdout] + + +def test_a_headless_render_narrates_to_stdout(configured_handlers): + assert len(stdout_handlers(configured_handlers(headless=True))) == 1 + + +def test_the_stdout_handler_carries_the_elapsed_time_format(configured_handlers): + """It has to be readable as a render log, not just present.""" + handler = stdout_handlers(configured_handlers(headless=True))[0] + + assert isinstance(handler.formatter, ElapsedTimeFormatter) + + +def test_an_interactive_render_does_not_duplicate_output_on_stdout(configured_handlers): + """The TUI already draws the log; a second copy on stdout would fight it for the + terminal.""" + assert stdout_handlers(configured_handlers(headless=False)) == [] + + +def test_the_formatter_survives_a_run_state_that_cannot_report_time(): + """A record can be logged before the render clock exists; it must still be readable.""" + broken = MagicMock() + broken.get_live_render_time.side_effect = RuntimeError("no clock yet") + + assert ElapsedTimeFormatter(broken).format(record("hello")) == "[00:00:00] INFO codeplain: hello" diff --git a/tests/test_legacy_pipe.py b/tests/test_legacy_pipe.py new file mode 100644 index 00000000..2f298d59 --- /dev/null +++ b/tests/test_legacy_pipe.py @@ -0,0 +1,436 @@ +"""Tests for the legacy pipe backend behind `TerminalProcess`. + +The backend wraps the pipe path Codeplain shipped before the PTY, so what is asserted here +is that path's behaviour — exit codes, merged streams, a drain that survives more output +than the pipe buffer holds — plus the properties the interface adds: no input channel, a +responder that owes nothing, and normalized output alongside the raw bytes. + +Scripts are executed for real, so every case that runs one is POSIX-only. +""" + +import contextlib +import errno +import json +import os +import stat +import subprocess +import sys +import textwrap +import threading +import time +from pathlib import Path + +import pytest + +from render_machine.terminal_process import READER_STALL_DETAIL, InputDisposition, TerminalReaderError +from render_machine.terminal_queries import ResponderState +from tests import test_render_utils as characterization + +posix_only = pytest.mark.skipif( + sys.platform == "win32", + reason="These cases run POSIX shell and Python scripts directly.", +) + +pytestmark = posix_only + +if sys.platform != "win32": + from render_machine import _legacy_pipe + from render_machine._legacy_pipe import LegacyPipeProcess + +SPAWN_TIMEOUT = 20.0 + +# Larger than the 64KB macOS pipe buffer, so the child blocks on write unless drained. +LARGE_OUTPUT_BYTES = 512 * 1024 + + +def make_script(directory: Path, name: str, program: str) -> str: + """Writes an executable Python script and returns its absolute path.""" + script_path = directory / f"{name}.py" + script_path.write_text(f"#!{sys.executable}\n" + textwrap.dedent(program)) + script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR) + return str(script_path) + + +def make_shell_script(directory: Path, name: str, body: str) -> str: + script_path = directory / f"{name}.sh" + script_path.write_text("#!/bin/sh\n" + textwrap.dedent(body)) + script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR) + return str(script_path) + + +@pytest.fixture +def backend(): + process = LegacyPipeProcess() + try: + yield process + finally: + process.terminate_tree(grace=0.1) + process.close() + + +def wait_for_exit(process, timeout=SPAWN_TIMEOUT): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + returncode = process.poll() + if returncode is not None: + return returncode + time.sleep(0.02) + raise AssertionError(f"the target did not exit within {timeout}s") + + +def run(process, command, timeout=SPAWN_TIMEOUT): + """Spawns, waits for the exit, and closes so every byte has been drained.""" + process.spawn(command) + returncode = wait_for_exit(process, timeout) + process.close() + return returncode + + +def test_exit_code_and_merged_streams_reach_the_caller(tmp_path, backend): + script = make_shell_script(tmp_path, "both_streams", 'echo "on stdout"\necho "on stderr" >&2\nexit 3\n') + + returncode = run(backend, [script]) + + output = backend.read_output() + assert returncode == 3 + assert "on stdout" in output + assert "on stderr" in output + + +def test_output_larger_than_the_pipe_buffer_is_captured_without_deadlock(tmp_path, backend): + script = make_script( + tmp_path, + "large_output", + f""" + import sys + + sys.stdout.write("x" * {LARGE_OUTPUT_BYTES}) + sys.stdout.write("\\nEND-OF-OUTPUT\\n") + """, + ) + + returncode = run(backend, [script], timeout=60) + + output = backend.read_output() + assert returncode == 0 + assert output.count("x") == LARGE_OUTPUT_BYTES + assert output.rstrip().endswith("END-OF-OUTPUT") + + +def test_raw_bytes_are_kept_verbatim_and_the_transcript_is_normalized(tmp_path, backend): + script = make_script( + tmp_path, + "coloured", + """ + import sys + + sys.stdout.write("\\033[31mred\\033[0m\\n") + """, + ) + + assert run(backend, [script]) == 0 + + assert b"\033[31m" in backend.read_raw_output() + assert backend.normalized_output() == "red\n" + + +def test_multiline_pipe_output_is_rendered_without_a_staircase(tmp_path, backend): + """A pipe carries bare linefeeds — no line discipline adds the carriage returns. + + The normalizer is a VT renderer, so feeding it the pipe bytes verbatim would move the + cursor down without returning it to column zero: every line would start where the + previous one ended, padded with the whitespace of a staircase. The backend emulates + ONLCR instead, the same translation the PTY's line discipline applies. + """ + script = make_script( + tmp_path, + "multiline", + """ + import sys + + sys.stdout.write("one\\ntwo\\nthree\\n") + """, + ) + + assert run(backend, [script]) == 0 + + assert backend.normalized_output() == "one\ntwo\nthree\n" + + +def test_a_printed_query_is_rendered_without_creating_an_obligation(tmp_path, backend): + script = make_script( + tmp_path, + "querying", + """ + import sys + + sys.stdout.write("\\033[6nbefore\\033[5nafter\\n") + """, + ) + + assert run(backend, [script]) == 0 + + assert backend.query_responder.state is ResponderState.QUIESCED + assert backend.query_responder.render_only >= 2 + assert backend.query_responder.admitted == 0 + assert backend.terminal_reply_failed is False + assert backend.terminal_reply_detail() == "" + + +def test_write_input_accepts_nothing(tmp_path, backend): + script = make_shell_script(tmp_path, "quiet", "sleep 30\n") + backend.spawn([script]) + + result = backend.write_input(b"anything\n") + + assert result.disposition is InputDisposition.CLOSED + assert result.accepted_bytes == 0 + + +def test_terminate_tree_reaches_a_descendant(tmp_path, backend): + script = make_script( + tmp_path, + "with_descendant", + """ + import os + import sys + import time + + pid = os.fork() + if pid == 0: + time.sleep(300) + os._exit(0) + sys.stdout.write("child %d\\n" % pid) + sys.stdout.flush() + time.sleep(300) + """, + ) + backend.spawn([script]) + + deadline = time.monotonic() + SPAWN_TIMEOUT + reported = "" + while time.monotonic() < deadline and "\n" not in reported: + reported += backend.read_output() + time.sleep(0.02) + assert "\n" in reported + descendant_pid = int(reported.split()[1]) + + backend.terminate_tree(grace=0.5) + + gone_by = time.monotonic() + SPAWN_TIMEOUT + while time.monotonic() < gone_by: + try: + os.kill(descendant_pid, 0) + except OSError: + return + time.sleep(0.02) + raise AssertionError("the descendant outlived terminate_tree()") + + +def test_close_is_idempotent_and_survives_a_process_that_never_spawned(backend): + backend.close() + backend.close() + + assert backend.poll() is None + assert backend.read_output() == "" + + +def test_instances_are_single_use(tmp_path, backend): + script = make_shell_script(tmp_path, "trivial", "true\n") + backend.spawn([script]) + + with pytest.raises(RuntimeError): + backend.spawn([script]) + + +def test_a_command_that_cannot_be_started_is_an_environment_error(tmp_path, backend): + from render_machine.terminal_process import ENVIRONMENT_ERROR_EXIT_CODE, TerminalLaunchError + + missing = str(tmp_path / "not-a-real-script") + + with pytest.raises(TerminalLaunchError) as failure: + backend.spawn([missing]) + + assert failure.value.exit_code == ENVIRONMENT_ERROR_EXIT_CODE + + +def test_a_read_failure_while_the_backend_is_active_is_published(tmp_path, backend): + """An OSError from the read path is expected closure only once the pipe is gone.""" + script = make_shell_script(tmp_path, "chatty", "while true; do printf tick; sleep 0.05; done\n") + + def failing_feed(chunk, decoder): + raise OSError(errno.EIO, "injected reader failure") + + backend._feed_output = failing_feed + backend.spawn([script]) + + deadline = time.monotonic() + SPAWN_TIMEOUT + while not backend.reader_failed.is_set() and time.monotonic() < deadline: + time.sleep(0.02) + + assert backend.reader_failed.is_set() + assert isinstance(backend.reader_exc, OSError) + + +def test_a_reader_that_outlives_its_join_bound_is_published_as_a_reader_failure(tmp_path, backend, monkeypatch): + """close() must not report a released backend while the reader still holds the pipe.""" + monkeypatch.setattr(_legacy_pipe, "DRAIN_DEADLINE_SECONDS", 0.05) + monkeypatch.setattr(_legacy_pipe, "CLOSE_JOIN_SECONDS", 0.05) + script = make_shell_script(tmp_path, "prints_then_waits", 'echo "hello"\nsleep 30\n') + reading = threading.Event() + release = threading.Event() + real_feed = backend._feed_output + + def stalling_feed(chunk, decoder): + reading.set() + release.wait(SPAWN_TIMEOUT) # holds the reader past both joins in close() + real_feed(chunk, decoder) + + backend._feed_output = stalling_feed + backend.spawn([script]) + assert reading.wait(SPAWN_TIMEOUT) + + try: + with pytest.raises(TerminalReaderError) as failure: + backend.close() + assert READER_STALL_DETAIL in str(failure.value) + assert backend.reader_failed.is_set() + finally: + release.set() + + +# --- The terminal-isolation guard ------------------------------------------------ +# +# The escape hatch and the Windows interim both run on this backend, so it has to keep +# the child away from Codeplain's own terminal exactly as the PTY path does. + +# The probe, its constants and the harness terminal are the ones the PTY backend is held +# to, imported rather than restated so the two backends are measured by one yardstick. +KEYSTROKES = characterization.KEYSTROKES +STDIN_READ_LIMIT = characterization.STDIN_READ_LIMIT +IMMEDIATE_EOF_SECONDS = characterization.IMMEDIATE_EOF_SECONDS +STDIN_PROBE_PROGRAM = characterization.STDIN_PROBE_PROGRAM +terminal_on_stdin = characterization.terminal_on_stdin + + +def test_the_child_never_reads_the_renderers_terminal(tmp_path, backend, terminal_on_stdin): + script = make_script(tmp_path, "stdin_probe", STDIN_PROBE_PROGRAM) + os.write(terminal_on_stdin, KEYSTROKES.encode()) + + started = time.monotonic() + assert run(backend, [script]) == 0 + elapsed = time.monotonic() - started + + report = json.loads(backend.read_output().strip()) + assert report["isatty"] is False + assert report["data"] == "" + assert report["read_seconds"] < IMMEDIATE_EOF_SECONDS + assert elapsed < SPAWN_TIMEOUT + + +def test_the_control_case_proves_the_harness_terminal_delivers_keystrokes(tmp_path, terminal_on_stdin): + """Without this the isolation assertion above could hold for the wrong reason.""" + script = make_script(tmp_path, "inheriting_probe", STDIN_PROBE_PROGRAM) + os.write(terminal_on_stdin, KEYSTROKES.encode()) + + process = subprocess.Popen( + [script], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + start_new_session=True, + ) + try: + output, _ = process.communicate(timeout=SPAWN_TIMEOUT) + except subprocess.TimeoutExpired: + process.kill() + process.communicate(timeout=SPAWN_TIMEOUT) + pytest.fail("the control probe never returned from its read of fd 0") + + report = json.loads(output.strip()) + assert report["isatty"] is True + assert report["data"] == KEYSTROKES + + +def test_close_returns_while_a_descendant_holds_the_pipe_open(tmp_path, backend): + """The parked reader holds the buffered stream's lock, so close() must release the + read end without acquiring it — a blocking close deadlocked here. What follows the + release is platform-dependent: BSD kernels wake the parked read and the reader exits + cleanly; Linux keeps it parked and close() publishes the stall. Either way close() + returns within its budget.""" + script = make_shell_script(tmp_path, "leaves_a_holder", "sleep 30 &\necho started\nexit 0\n") + backend.spawn([script]) + returncode = wait_for_exit(backend) + assert returncode == 0 + + started = time.monotonic() + stalled = False + try: + backend.close() + except TerminalReaderError: + stalled = True + elapsed = time.monotonic() - started + + assert elapsed < _legacy_pipe.TEARDOWN_BUDGET_SECONDS + if stalled: + assert READER_STALL_DETAIL in repr(backend.reader_exc) + # The escapee is not this test's subject; reap it so it cannot outlive the run. + with contextlib.suppress(OSError): + os.killpg(backend._proc.pid, 9) + + +def test_terminate_tree_escalates_even_when_the_leader_dies_within_the_grace(tmp_path, backend): + """A group member that traps the graceful signal must still be reached: the + escalation is owed to the group, not only to a leader that failed to die.""" + script = make_shell_script( + tmp_path, + "trapping_member", + "sh -c 'trap \"\" TERM; sleep 30' &\necho started\nexec sleep 30\n", + ) + backend.spawn([script]) + + deadline = time.monotonic() + SPAWN_TIMEOUT + reported = "" + while time.monotonic() < deadline and "started" not in reported: + reported += backend.read_output() + time.sleep(0.02) + assert "started" in reported + + backend.terminate_tree(grace=2.0) + + gone_by = time.monotonic() + SPAWN_TIMEOUT + while time.monotonic() < gone_by: + try: + os.killpg(backend._proc.pid, 0) + except OSError: # ESRCH once empty; EPERM on macOS while only zombies remain + return + time.sleep(0.05) + raise AssertionError("a TERM-trapping group member outlived terminate_tree()") + + +def test_the_grace_covers_group_members_that_outlive_the_leader(tmp_path, backend): + """The grace watches the whole group: a member still cleaning up after the leader + died must finish inside it, and a group that empties is never SIGKILLed at all.""" + script = make_shell_script( + tmp_path, + "member_cleanup", + "sh -c 'trap \"echo member-term; sleep 0.5; echo member-clean; exit 0\" TERM; while :; do sleep 1; done' &\n" + 'trap "exit 0" TERM\n' + "echo started\n" + "while :; do sleep 1; done\n", + ) + backend.spawn([script]) + + deadline = time.monotonic() + SPAWN_TIMEOUT + reported = "" + while time.monotonic() < deadline and "started" not in reported: + reported += backend.read_output() + time.sleep(0.02) + assert "started" in reported + + backend.terminate_tree(grace=5.0) + backend.close() + + output = backend.normalized_output() + assert "member-term" in output + assert "member-clean" in output diff --git a/tests/test_legacy_pipe_windows.py b/tests/test_legacy_pipe_windows.py new file mode 100644 index 00000000..b51fe408 --- /dev/null +++ b/tests/test_legacy_pipe_windows.py @@ -0,0 +1,127 @@ +"""Console detachment on native Windows, for the legacy pipe backend. + +Windows is the one platform where `stdin=DEVNULL` is not enough. A child attached to the +renderer's console can open `CONIN$` and read the console input buffer directly, whatever +its standard input handle points at, so the backend detaches every Windows child from that +console instead. What is asserted here is the detachment itself: the renderer's pid must +not appear in the child's console process list. + +The control case runs the same probe with the same redirections and no creation flags, so +a green assertion above cannot be explained by the redirection alone. It is skipped when +the test process has no console of its own — a CI runner without one has nothing for a +child to inherit, which leaves the guard true for a reason this module cannot claim credit +for. +""" + +import ctypes +import json +import os +import subprocess +import sys +import textwrap +import time +from pathlib import Path +from typing import List + +import pytest + +from render_machine._legacy_pipe import LegacyPipeProcess + +pytestmark = pytest.mark.skipif( + sys.platform != "win32", + reason="Console attachment is a Windows notion, and so is the creation flag under test.", +) + +PROBE_TIMEOUT_SECONDS = 30.0 +POLL_INTERVAL_SECONDS = 0.02 + +# Enough for any console a test child can find itself on; a longer list is truncated +# rather than trusted, because the API reports the required length instead of filling. +MAX_CONSOLE_PIDS = 64 + +# Reports which processes share the console this program is attached to. +CONSOLE_PROBE_PROGRAM = f""" +import ctypes +import json +import os +import sys + +kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) +buffer = (ctypes.c_uint * {MAX_CONSOLE_PIDS})() +count = min(kernel32.GetConsoleProcessList(buffer, {MAX_CONSOLE_PIDS}), {MAX_CONSOLE_PIDS}) +report = {{"pid": os.getpid(), "console_pids": list(buffer[:count])}} +sys.stdout.write(json.dumps(report)) +sys.stdout.flush() +""" + + +def console_pids() -> List[int]: + """The pids attached to this process's console, or an empty list when it has none.""" + if sys.platform != "win32": # unreachable: the module is skipped everywhere else + return [] + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + buffer = (ctypes.c_uint * MAX_CONSOLE_PIDS)() + count = min(kernel32.GetConsoleProcessList(buffer, MAX_CONSOLE_PIDS), MAX_CONSOLE_PIDS) + return list(buffer[:count]) + + +@pytest.fixture +def probe_script(tmp_path: Path) -> str: + script_path = tmp_path / "console_probe.py" + script_path.write_text(textwrap.dedent(CONSOLE_PROBE_PROGRAM)) + return str(script_path) + + +@pytest.fixture +def backend(): + process = LegacyPipeProcess() + try: + yield process + finally: + process.terminate_tree(grace=0.1) + process.close() + + +def run_probe(process: LegacyPipeProcess, script_path: str) -> dict: + """Runs the probe on the backend and returns the report it printed.""" + process.spawn([sys.executable, script_path]) + deadline = time.monotonic() + PROBE_TIMEOUT_SECONDS + while time.monotonic() < deadline: + if process.poll() is not None: + break + time.sleep(POLL_INTERVAL_SECONDS) + else: + raise AssertionError(f"the console probe did not exit within {PROBE_TIMEOUT_SECONDS}s") + process.close() + return json.loads(process.read_output().strip()) + + +def test_the_child_is_not_attached_to_the_renderers_console(backend, probe_script): + report = run_probe(backend, probe_script) + + assert os.getpid() not in report["console_pids"] + # Whatever console the child ended up with is its own, so the probe read a real list + # rather than reporting an empty one because the call failed. + assert report["console_pids"] in ([], [report["pid"]]) + + +def test_the_control_case_proves_the_redirection_alone_does_not_detach(probe_script): + """The same spawn shape minus the creation flags: this child does share the console.""" + if not console_pids(): + pytest.skip("this process has no console, so there is none for a child to inherit") + + process = subprocess.Popen( + [sys.executable, probe_script], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + output, _ = process.communicate(timeout=PROBE_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + process.communicate(timeout=PROBE_TIMEOUT_SECONDS) + pytest.fail("the control probe never reported its console") + + assert os.getpid() in json.loads(output.strip())["console_pids"] diff --git a/tests/test_no_pty_escape_hatch.py b/tests/test_no_pty_escape_hatch.py 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_output_normalizer.py b/tests/test_output_normalizer.py new file mode 100644 index 00000000..ddd8c07e --- /dev/null +++ b/tests/test_output_normalizer.py @@ -0,0 +1,465 @@ +"""Tests for the terminal output normalizer. + +The fixtures under `tests/fixtures/terminal_output/` are real recordings, not synthesized +escape soup: each one is the verbatim byte stream a real tool wrote to the master side of +a pseudoterminal allocated by this project's own PTY backend, at 120x40 under +`TERM=xterm-256color`. + +Every fixture case asserts the rendered result against a committed golden file *and* the +compression ratio, so a regression that reintroduces noise shows up as a number, and one +that deletes output shows up as a diff — needles and an upper ratio bound alone would pass +for a normalizer that dropped nearly everything. +""" + +import re +from pathlib import Path + +import pytest + +from render_machine.output_normalizer import ( + MAX_COMBINING_MARKS, + MAX_SEQUENCE_BYTES, + QUERY_CURSOR_POSITION, + QUERY_DEVICE_ATTRIBUTES, + QUERY_DEVICE_STATUS, + OutputNormalizer, +) + +FIXTURES = Path(__file__).resolve().parent / "fixtures" / "terminal_output" + +# What stripping bytes out of the stream would leave behind, used to show the difference +# between deleting the instruction and performing the operation. +STRIP_PATTERN = re.compile(rb"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b[@-Z\\-_]") + + +def strip_escapes(raw: bytes) -> str: + return STRIP_PATTERN.sub(b"", raw).decode("utf-8", "replace") + + +def normalize(raw: bytes, chunk_size: int = 512, **kwargs) -> OutputNormalizer: + normalizer = OutputNormalizer(**kwargs) + for offset in range(0, len(raw), chunk_size): + normalizer.feed(raw[offset : offset + chunk_size]) + return normalizer + + +def read_fixture(name: str) -> bytes: + return (FIXTURES / name).read_bytes() + + +def read_golden(name: str) -> str: + """The rendering committed alongside the recording, byte for byte.""" + return (FIXTURES / name).with_suffix(".normalized").read_text(encoding="utf-8") + + +# name, max compression ratio, expected present, expected absent +FIXTURE_CASES = [ + pytest.param( + "npm_install.raw", + 0.10, + ["added 69 packages"], + ["⠹"], # every braille spinner frame is erased by the frame after it + id="npm-install", + ), + pytest.param( + "pytest_color.raw", + 0.65, + ["1 failed, 4 passed", "AssertionError", "test_reports_a_failure"], + [], + id="pytest-colour", + ), + pytest.param( + "spinner.raw", + 0.03, + ["[####################] 100% done", "installed 60 packages"], + ["downloading package-30", "downloading package-59"], + id="progress-rewrite", + ), + pytest.param( + "fullscreen.raw", + 0.10, + ["frame 11", "BUILD FAILED: suite-03 case 7 timed out", "suite-08"], + ["frame 00", "frame 05", "frame 10"], + id="full-screen-repaint", + ), + pytest.param( + "nohup_build.raw", + 0.30, + ["stdout is a terminal, colour enabled", "compiled 12 modules", "warning"], + ["compiling module 05"], + id="nohup-detached", + ), +] + + +@pytest.mark.parametrize("name, max_ratio, present, absent", FIXTURE_CASES) +def test_recorded_output_renders_to_plain_text(name, max_ratio, present, absent): + raw = read_fixture(name) + normalizer = normalize(raw) + text = normalizer.text() + + assert normalizer.parse_failures == 0 + assert "\x1b" not in text, "an escape sequence survived rendering" + assert "\r" not in text, "a carriage return survived rendering" + for needle in present: + assert needle in text, f"{needle!r} missing from:\n{text}" + for needle in absent: + assert needle not in text, f"{needle!r} should have been overwritten:\n{text}" + + +@pytest.mark.parametrize("name, max_ratio, present, absent", FIXTURE_CASES) +def test_recorded_output_matches_the_committed_rendering(name, max_ratio, present, absent): + """Equality, because an upper ratio bound alone rewards deleting output.""" + assert normalize(read_fixture(name)).text() == read_golden(name) + + +@pytest.mark.parametrize("name, max_ratio, present, absent", FIXTURE_CASES) +def test_recorded_output_is_compressed_to_what_a_terminal_would_show(name, max_ratio, present, absent): + raw = read_fixture(name) + rendered = normalize(raw).text().encode("utf-8") # bytes to bytes, so the ratio is one unit + ratio = len(rendered) / len(raw) + assert ratio <= max_ratio, f"{name} normalized to {ratio:.3f} of its raw size, above {max_ratio}" + + +@pytest.mark.parametrize("name, max_ratio, present, absent", FIXTURE_CASES) +def test_chunk_boundaries_do_not_change_the_rendering(name, max_ratio, present, absent): + """The reader feeds whatever `read()` returns, so a split sequence must still render.""" + raw = read_fixture(name) + assert normalize(raw, chunk_size=1).text() == normalize(raw, chunk_size=len(raw) + 1).text() + + +def test_stripping_keeps_every_repaint_that_rendering_collapses(): + """The case stripping cannot handle: a tool that repaints the whole screen in place.""" + raw = read_fixture("fullscreen.raw") + stripped = strip_escapes(raw) + rendered = normalize(raw).text() + + assert stripped.count("BUILD DASHBOARD") == 12 + assert rendered.count("BUILD DASHBOARD") == 1 + assert len(rendered) < len(stripped) / 8 + + +def test_a_progress_line_rewrite_collapses_to_its_last_frame(): + raw = read_fixture("spinner.raw") + stripped = strip_escapes(raw) + rendered = normalize(raw).text() + + assert stripped.count("downloading package-") == 60 + assert "downloading package-" not in rendered + assert rendered.count("installed 60 packages") == 1 + + +def test_scrollback_keeps_the_head_and_the_tail_of_a_long_run(): + raw = b"".join(f"line {index:04d}\r\n".encode() for index in range(1000)) + text = normalize(raw, head_lines=5, tail_lines=7).text() + lines = text.splitlines() + + assert lines[:5] == [f"line {index:04d}" for index in range(5)] + assert lines[5].startswith("...[") and lines[5].endswith("lines omitted]...") + assert lines[-1] == "line 0999" + assert len(lines) < 60, "retention must cap the transcript, not just trim its tail" + + +def test_the_final_screen_is_kept_whole_alongside_the_retained_scrollback(): + raw = b"".join(f"line {index:04d}\r\n".encode() for index in range(100)) + text = normalize(raw, lines=10, head_lines=3, tail_lines=3).text() + lines = text.splitlines() + + assert lines[:3] == ["line 0000", "line 0001", "line 0002"] + assert "...[" in lines[3] + assert lines[-1] == "line 0099" + + +def test_cursor_movement_and_erase_are_performed_rather_than_deleted(): + normalizer = OutputNormalizer(columns=20, lines=5) + normalizer.feed(b"first\r\nsecond\r\nthird\r\n") + normalizer.feed(b"\x1b[3A\x1b[Kreplaced\r\n") # up three lines, erase it, rewrite + + assert normalizer.text() == "replaced\nsecond\nthird\n" + + +def test_a_repaint_from_the_home_position_leaves_one_frame(): + normalizer = OutputNormalizer(columns=20, lines=4) + for frame in range(30): + normalizer.feed(f"\x1b[H\x1b[2Jframe {frame}\r\nstill working\r\n".encode()) + + assert normalizer.text() == "frame 29\nstill working\n" + + +def test_the_alternate_screen_is_flushed_in_order_and_left_clear(): + normalizer = OutputNormalizer(columns=40, lines=6) + normalizer.feed(b"primary one\r\nprimary two\r\n") + normalizer.feed(b"\x1b[?1049h") + for frame in range(1, 6): + normalizer.feed(f"\x1b[H\x1b[2Jalt frame {frame}\r\n".encode()) + normalizer.feed(b"\x1b[?1049l") + normalizer.feed(b"back on the primary\r\n") + + assert normalizer.text() == "primary one\nprimary two\nalt frame 5\nback on the primary\n" + + +def test_output_is_kept_when_the_target_never_leaves_the_alternate_screen(): + normalizer = OutputNormalizer(columns=40, lines=6) + normalizer.feed(b"\x1b[?1049h\x1b[H\x1b[2Jonly frame\r\n") + + assert normalizer.text() == "only frame\n" + + +def test_crlf_collapses_and_a_trailing_partial_line_is_kept(): + normalizer = OutputNormalizer(columns=20, lines=5) + normalizer.feed(b"one\r\ntwo\r\nno newline here") + + assert normalizer.text() == "one\ntwo\nno newline here\n" + + +def test_translate_newlines_renders_a_pipe_stream_of_bare_linefeeds(): + normalizer = OutputNormalizer(columns=20, lines=5, translate_newlines=True) + normalizer.feed(b"one\ntwo\nthree\n") + + assert normalizer.text() == "one\ntwo\nthree\n" + + +def test_translate_newlines_leaves_a_crlf_stream_unchanged(): + normalizer = OutputNormalizer(columns=20, lines=5, translate_newlines=True) + normalizer.feed(b"one\r\ntwo\r\nno newline here") + + assert normalizer.text() == "one\ntwo\nno newline here\n" + + +def test_translate_newlines_counts_the_bytes_the_target_wrote(): + normalizer = OutputNormalizer(columns=20, lines=5, translate_newlines=True) + normalizer.feed(b"a\nb\n") + + assert normalizer.fed_bytes == 4 + + +def test_nothing_fed_renders_to_nothing(): + normalizer = OutputNormalizer(columns=20, lines=5) + normalizer.feed(b"") + + assert normalizer.text() == "" + + +def test_split_utf8_across_chunks_renders_one_character(): + normalizer = OutputNormalizer(columns=20, lines=3) + encoded = "héllo wörld".encode("utf-8") + for index in range(len(encoded)): + normalizer.feed(encoded[index : index + 1]) + + assert normalizer.text() == "héllo wörld\n" + + +def test_device_queries_are_answered_as_a_terminal_answers_them(): + replies = [] + normalizer = OutputNormalizer(columns=20, lines=5, reply_handler=lambda kind, data: replies.append((kind, data))) + normalizer.feed(b"abc\x1b[6n") + normalizer.feed(b"\x1b[5n") + normalizer.feed(b"\x1b[c") + + assert replies == [ + (QUERY_CURSOR_POSITION, b"\x1b[1;4R"), + (QUERY_DEVICE_STATUS, b"\x1b[0n"), + (QUERY_DEVICE_ATTRIBUTES, b"\x1b[?6c"), + ] + + +def test_the_cursor_position_report_follows_the_rendered_cursor(): + replies = [] + normalizer = OutputNormalizer(columns=20, lines=5, reply_handler=lambda kind, data: replies.append((kind, data))) + normalizer.feed(b"one\r\ntwo\r\nthr\x1b[6n") + + assert replies == [(QUERY_CURSOR_POSITION, b"\x1b[3;4R")] + + +def test_a_query_leaves_no_trace_in_the_rendered_text(): + replies = [] + normalizer = OutputNormalizer(columns=20, lines=5, reply_handler=lambda kind, data: replies.append((kind, data))) + normalizer.feed(b"before\x1b[6n\x1b[5n\x1b[cafter\r\n") + + assert replies + assert normalizer.text() == "beforeafter\n" + + +def test_a_normalizer_without_a_reply_handler_only_renders(): + normalizer = OutputNormalizer(columns=20, lines=5) + normalizer.feed(b"quiet\x1b[6n\x1b[c\r\n") + + assert normalizer.text() == "quiet\n" + + +def test_a_private_device_status_request_is_not_answered(): + replies = [] + normalizer = OutputNormalizer(columns=20, lines=5, reply_handler=lambda kind, data: replies.append((kind, data))) + normalizer.feed(b"x\x1b[?6n\r\n") + + assert replies == [] + assert normalizer.text() == "x\n" + + +POISON_SEQUENCE = b"\x1b[1;2;3z" # the one sequence the parser is made to reject below + + +def render_with_a_rejected_sequence(monkeypatch, raw: bytes, chunk_size: int) -> OutputNormalizer: + normalizer = OutputNormalizer(columns=40, lines=5) + original = normalizer._stream.feed + + def feed(data): + if data == POISON_SEQUENCE: + raise ValueError("malformed") + original(data) + + monkeypatch.setattr(normalizer._stream, "feed", feed) + for offset in range(0, len(raw), chunk_size): + normalizer.feed(raw[offset : offset + chunk_size]) + return normalizer + + +def test_a_parser_failure_costs_one_sequence_and_rendering_continues(monkeypatch): + """A malformed sequence must cost itself, never the reader that feeds it.""" + raw = b"before " + POISON_SEQUENCE + b"after\r\n" + normalizer = render_with_a_rejected_sequence(monkeypatch, raw, chunk_size=len(raw)) + + assert normalizer.parse_failures == 1 + assert normalizer.text() == "before after\n" + + +def test_parser_failure_recovery_does_not_depend_on_the_read_boundaries(monkeypatch): + """The reader feeds whatever `read()` returns, so recovery cannot cost the rest of it.""" + raw = b"before " + POISON_SEQUENCE + b"after\r\n" + whole = render_with_a_rejected_sequence(monkeypatch, raw, chunk_size=len(raw)) + split = render_with_a_rejected_sequence(monkeypatch, raw, chunk_size=1) + mid = render_with_a_rejected_sequence(monkeypatch, raw, chunk_size=9) + + assert whole.text() == split.text() == mid.text() + assert whole.parse_failures == split.parse_failures == mid.parse_failures == 1 + + +def test_an_unterminated_osc_string_is_bounded_and_its_bytes_stay_visible(): + """pyte would hold every byte of it; the guard caps its buffer and, past the cap, + treats the stream as plain output again — a string cut off mid-write must not + swallow the transcript that follows it.""" + normalizer = OutputNormalizer(columns=40, lines=5) + normalizer.feed(b"\x1b]0;") + for _ in range(200): + normalizer.feed(b"A" * 4096) # a title that never terminates + normalizer.feed(b"\x07after\r\n") + + assert normalizer._guard.pending_bytes <= MAX_SEQUENCE_BYTES + assert normalizer.bounded_sequences == 1 + text = normalizer.text() + assert text.endswith("after\n") + assert "AAAA" in text # the abandoned string's bytes render instead of vanishing + assert len(normalizer._screen.title) <= MAX_SEQUENCE_BYTES + + +def test_an_unterminated_csi_parameter_is_bounded_and_draining_continues(): + normalizer = OutputNormalizer(columns=40, lines=5) + normalizer.feed(b"\x1b[") + for _ in range(200): + normalizer.feed(b"9" * 4096) # a parameter no terminal would ever finish reading + normalizer.feed(b"m") + normalizer.feed(b"still here\r\n") + + assert normalizer._guard.pending_bytes <= MAX_SEQUENCE_BYTES + assert normalizer.bounded_sequences == 1 + assert normalizer.parse_failures == 0 + assert normalizer.text() == "still here\n" + + +def test_an_oversized_osc_string_is_abandoned_and_never_becomes_metadata(): + """Whether its terminator ever arrives cannot be known at the cap, so an oversized + string is reclaimed as plain output either way — it must never grow the title.""" + normalizer = OutputNormalizer(columns=40, lines=5) + normalizer.feed(b"\x1b]0;short title\x07") + normalizer.feed(b"\x1b]0;" + b"B" * (MAX_SEQUENCE_BYTES * 4) + b"\x07") + normalizer.feed(b"work goes on\r\n") + + assert normalizer._screen.title == "short title" + assert normalizer.bounded_sequences == 1 + assert normalizer.text().endswith("work goes on\n") + + +def test_repeated_combining_marks_do_not_grow_one_cell_without_bound(): + normalizer = OutputNormalizer(columns=40, lines=5) + marks = ("́" * 200).encode("utf-8") # combining acute accents, one cell's worth of state + normalizer.feed(b"a") + for _ in range(500): + normalizer.feed(marks) + normalizer.feed(b"\r\nsecond line\r\n") + + first_line = normalizer.text().splitlines()[0] + assert len(first_line) <= MAX_COMBINING_MARKS + 1 + assert first_line.startswith("á") # the first mark still composes with the letter + assert normalizer.text().splitlines()[1] == "second line" + + +def test_a_trailing_partial_utf8_sequence_is_finalized_as_a_replacement_character(): + normalizer = OutputNormalizer(columns=20, lines=3) + normalizer.feed("hé".encode("utf-8")[:-1]) # the stream ends mid-character + + assert normalizer.text() == "h\n" + + normalizer.finalize() + normalizer.finalize() # idempotent: shutdown paths can overlap + + assert normalizer.text() == "h�\n" + + +def test_finalizing_a_complete_stream_changes_nothing(): + normalizer = OutputNormalizer(columns=20, lines=3) + normalizer.feed("héllo\r\n".encode("utf-8")) + before = normalizer.text() + + normalizer.finalize() + + assert normalizer.text() == before == "héllo\n" + + +def test_fed_bytes_counts_every_byte_handed_to_the_parser(): + raw = read_fixture("spinner.raw") + assert normalize(raw).fed_bytes == len(raw) + + +def test_a_truncated_control_string_does_not_swallow_the_output_after_it(): + """A tool killed mid-title-write must not blind the transcript: whatever followed the + unterminated introducer is reclaimed as plain output when the stream ends.""" + normalizer = OutputNormalizer(columns=120, lines=5) + normalizer.feed(b"start\r\n") + normalizer.feed(b"\x1b]0;title") # cut off before its terminator ever arrives + normalizer.feed(b"FAILED: assertion xyz\r\n") + normalizer.finalize() + + text = normalizer.text() + assert "start" in text + assert "FAILED: assertion xyz" in text + + +def test_can_aborts_a_control_string_and_rendering_resumes(): + normalizer = OutputNormalizer(columns=40, lines=5) + normalizer.feed(b"\x1b]0;half a title\x18after\r\n") + + assert normalizer._screen.title == "" # an aborted string is discarded, not dispatched + assert normalizer.text() == "after\n" + + +def test_an_escape_that_is_not_a_terminator_ends_the_string_and_starts_a_sequence(): + normalizer = OutputNormalizer(columns=40, lines=5) + normalizer.feed(b"\x1b]0;half a title\x1b[31mred text\r\n") + + assert normalizer._screen.title == "" # the string was exited, never dispatched + assert normalizer.text() == "red text\n" + + +def test_a_second_escape_restarts_the_sequence_rather_than_corrupting_it(): + normalizer = OutputNormalizer(columns=40, lines=5) + normalizer.feed(b"\x1b\x1b[31mred\r\n") # the first ESC led nowhere + + assert normalizer.text() == "red\n" + + +def test_an_escape_pair_inside_a_string_exits_it_and_the_next_sequence_still_parses(): + normalizer = OutputNormalizer(columns=40, lines=5) + normalizer.feed(b"\x1b]0;discard\x1b\x1b[31mred\r\n") + + assert normalizer._screen.title == "" + assert normalizer.text() == "red\n" diff --git a/tests/test_path_resolution.py b/tests/test_path_resolution.py index 9ee9790d..f0e21fd5 100644 --- a/tests/test_path_resolution.py +++ b/tests/test_path_resolution.py @@ -36,13 +36,17 @@ def test_dotdot_segments_are_normalized(): def test_leading_tilde_is_expanded(monkeypatch): + # os.path.expanduser reads HOME on POSIX and USERPROFILE on Windows. monkeypatch.setenv("HOME", "/home/alice") + monkeypatch.setenv("USERPROFILE", "/home/alice") result = resolve_path("~/scripts/run.sh", "cli", cwd=CWD, config_dir=CONFIG_DIR, spec_dir=SPEC_DIR) assert result == os.path.normpath("/home/alice/scripts/run.sh") def test_tilde_expansion_applies_regardless_of_source(monkeypatch): + # os.path.expanduser reads HOME on POSIX and USERPROFILE on Windows. monkeypatch.setenv("HOME", "/home/alice") + monkeypatch.setenv("USERPROFILE", "/home/alice") for source in ("cli", "config", "default"): result = resolve_path("~/x", source, cwd=CWD, config_dir=CONFIG_DIR, spec_dir=SPEC_DIR) assert result == os.path.normpath("/home/alice/x") 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_plain_modules.py b/tests/test_plain_modules.py index 094a857e..fc3a275f 100644 --- a/tests/test_plain_modules.py +++ b/tests/test_plain_modules.py @@ -7,6 +7,7 @@ import json import os +import sys import tempfile from pathlib import Path @@ -526,3 +527,38 @@ def test_reconcile_metadata_with_git_no_metadata_is_noop(solo_module): solo_module.reconcile_metadata_with_git() assert solo_module.load_module_metadata() is None + + +# -------------------------------------------------------------------------- +# _raise_for_missing_frid_commits +# -------------------------------------------------------------------------- + + +def test_a_broken_tests_repo_does_not_mask_the_missing_build_commit_error(solo_module, monkeypatch): + """The actionable --render-from guidance wins over a raw failure from the tests repo.""" + import git_utils + from plain2code_exceptions import MissingPreviousFunctionalitiesError + + def fake_missing(folder, frids, module_name): + if folder == solo_module.module_build_folder: + return ["1"] + raise RuntimeError("the conformance tests repository is broken") + + monkeypatch.setattr(git_utils, "frids_missing_commits", fake_missing) + + with pytest.raises(MissingPreviousFunctionalitiesError): + solo_module._raise_for_missing_frid_commits(["1"], "2", render_conformance_tests=True) + + +def test_a_broken_tests_repo_with_a_healthy_build_repo_still_fails(solo_module, monkeypatch): + import git_utils + + def fake_missing(folder, frids, module_name): + if folder == solo_module.module_build_folder: + return [] + raise RuntimeError("the conformance tests repository is broken") + + monkeypatch.setattr(git_utils, "frids_missing_commits", fake_missing) + + with pytest.raises(RuntimeError, match="conformance tests repository"): + solo_module._raise_for_missing_frid_commits(["1"], "2", render_conformance_tests=True) diff --git a/tests/test_pty_characterization.py b/tests/test_pty_characterization.py new file mode 100644 index 00000000..fc4259b8 --- /dev/null +++ b/tests/test_pty_characterization.py @@ -0,0 +1,273 @@ +"""Characterization of the stale controlling-TTY topology described in ADR-001. + +The harness rebuilds the process topology `execute_script()` produced before this +branch — a child spawned with `start_new_session=True` whose fd 0 still points at a +terminal owned by the session the child has just left — without touching any +production code. It documents the defect the terminal backend was built to remove; it +never validates the fix. + +Three levels are needed. `os.openpty()` alone yields a terminal owned by no session, +which is a weaker state than the one under study, so a middle process takes the slave +as its controlling terminal via TIOCSCTTY. That process is a subprocess rather than +the test runner itself because `setsid()` in pytest would detach the runner. + + pytest process + └── harness subprocess setsid(), then TIOCSCTTY on the slave + └── probe grandchild start_new_session=True, fd 0 = slave + +The same two-level spawn shape with fd 0 on `/dev/null` is exercised as a control +case, documenting why the defect is reachable only from an interactive terminal. + +Only the constructed topology is asserted. The `termios.tcgetattr(0)` outcome is +recorded rather than asserted, because it varies by host. +""" + +import json +import os +import re +import signal +import subprocess +import sys +import time + +import pytest + +HARNESS_TIMEOUT_SECONDS = 30 +CLEANUP_TIMEOUT_SECONDS = 10 +KILL_POLL_TIMEOUT_SECONDS = 5 + +# The grandchild leaves the harness's session, so the outer timeout path has to kill it +# by pid; the harness announces the pid on stderr before waiting on it. +GRANDCHILD_PID_PATTERN = re.compile(r"^grandchild_pid=(\d+)$", re.MULTILINE) + +# Source of the grandchild. Reports the state of fd 0 as JSON on stdout. +PROBE_SOURCE = """ +import errno +import json +import os +import sys +import termios + +report = { + "pid": os.getpid(), + "sid": os.getsid(0), + "isatty_stdin": os.isatty(0), +} +report["is_session_leader"] = report["sid"] == report["pid"] + +try: + termios.tcgetattr(0) + report["tcgetattr"] = "ok" + report["tcgetattr_errno"] = None + report["tcgetattr_errno_name"] = None +except (OSError, termios.error) as exc: + # termios.error is not an OSError and carries its errno only in args[0]. + code = getattr(exc, "errno", None) + if code is None and exc.args: + code = exc.args[0] + report["tcgetattr"] = "error" + report["tcgetattr_errno"] = code + report["tcgetattr_errno_name"] = errno.errorcode.get(code, str(code)) + +sys.stdout.write(json.dumps(report)) +sys.stdout.flush() +""" + +# Source of the middle process. Owns the terminal, then spawns the grandchild. +HARNESS_SOURCE = """ +import fcntl +import json +import os +import signal +import subprocess +import sys +import termios + +PROBE_TIMEOUT_SECONDS = 20 +CLEANUP_TIMEOUT_SECONDS = 10 + +# Closing the PTY master hangs up the terminal; Linux delivers SIGHUP to this +# process (session leader with the slave as controlling terminal) before the +# report is written. The hangup is teardown noise, not part of the topology. +signal.signal(signal.SIGHUP, signal.SIG_IGN) + + +def reap(process): + process.kill() + try: + process.communicate(timeout=CLEANUP_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + pass + + +def main(): + mode = sys.argv[1] + probe_source = sys.argv[2] + + if os.getsid(0) != os.getpid(): + os.setsid() + + report = {"harness_pid": os.getpid(), "harness_sid": os.getsid(0), "mode": mode, "error": None} + open_fds = [] + process = None + try: + if mode == "pty": + master_fd, slave_fd = os.openpty() + open_fds.extend([slave_fd, master_fd]) + # The slave becomes this session's controlling terminal; the grandchild + # then leaves the session while keeping the slave on fd 0. + fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0) + report["controlling_tty"] = os.ttyname(slave_fd) + stdin_fd = slave_fd + else: + stdin_fd = os.open(os.devnull, os.O_RDONLY) + open_fds.append(stdin_fd) + report["controlling_tty"] = None + + process = subprocess.Popen( + [sys.executable, "-c", probe_source], + stdin=stdin_fd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True, + ) + sys.stderr.write("grandchild_pid=%d\\n" % process.pid) + sys.stderr.flush() + + stdout, stderr = process.communicate(timeout=PROBE_TIMEOUT_SECONDS) + report["probe_exit_code"] = process.returncode + report["probe_stderr"] = stderr.decode(errors="replace") + report["probe"] = json.loads(stdout.decode()) if stdout.strip() else None + except subprocess.TimeoutExpired: + reap(process) + report["error"] = "probe did not report within %d seconds" % PROBE_TIMEOUT_SECONDS + except Exception as exc: + if process is not None and process.poll() is None: + reap(process) + report["error"] = "%s: %s" % (type(exc).__name__, exc) + finally: + # The PTY master is held open for the lifetime of the grandchild so reads on + # the slave cannot fail with EIO. + for fd in open_fds: + os.close(fd) + + sys.stdout.write(json.dumps(report)) + sys.stdout.flush() + + +main() +""" + +pytestmark = pytest.mark.skipif( + sys.platform == "win32", + reason="The topology relies on setsid() and TIOCSCTTY, which are POSIX-only.", +) + + +def _kill_process_group(pid): + """Best-effort teardown of the orphaned grandchild; it is a session leader, so pgid == pid.""" + for sig in (signal.SIGTERM, signal.SIGKILL): + try: + os.killpg(pid, sig) + except (ProcessLookupError, PermissionError): + return + deadline = time.monotonic() + KILL_POLL_TIMEOUT_SECONDS + while time.monotonic() < deadline: + try: + os.killpg(pid, 0) + except (ProcessLookupError, PermissionError): + return + time.sleep(0.05) + + +def _reap(process): + """Terminates the harness, escalating to SIGKILL, and returns whatever it had written.""" + if process.poll() is None: + process.terminate() + try: + return process.communicate(timeout=CLEANUP_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + try: + return process.communicate(timeout=CLEANUP_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + return b"", b"" + + +def _run_harness(mode): + process = subprocess.Popen( + [sys.executable, "-c", HARNESS_SOURCE, mode, PROBE_SOURCE], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + stdout, stderr = process.communicate(timeout=HARNESS_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + stdout, stderr = _reap(process) + match = GRANDCHILD_PID_PATTERN.search(stderr.decode(errors="replace")) + if match: + _kill_process_group(int(match.group(1))) + pytest.fail("harness did not finish within %d seconds" % HARNESS_TIMEOUT_SECONDS) + + stderr_text = stderr.decode(errors="replace") + assert process.returncode == 0, "harness failed: %s" % stderr_text + + stdout_text = stdout.decode(errors="replace") + assert stdout_text.strip(), "harness produced no report; stderr: %s" % stderr_text + + report = json.loads(stdout_text) + assert report["error"] is None, "harness could not build the topology: %s" % report["error"] + assert report["probe"] is not None, "grandchild produced no report: %s" % report.get("probe_stderr") + return report + + +@pytest.fixture(scope="module") +def stale_controlling_tty_report(): + """Runs the three-level harness once and returns the grandchild's report.""" + return _run_harness("pty") + + +@pytest.fixture(scope="module") +def non_tty_stdin_report(): + """Runs the same spawn shape with fd 0 on /dev/null and returns the grandchild's report.""" + return _run_harness("devnull") + + +def test_grandchild_stdin_is_a_terminal(stale_controlling_tty_report): + probe = stale_controlling_tty_report["probe"] + + assert probe["isatty_stdin"] is True + assert stale_controlling_tty_report["probe_exit_code"] == 0 + + +def test_grandchild_left_the_session_that_owns_the_terminal(stale_controlling_tty_report): + probe = stale_controlling_tty_report["probe"] + + assert probe["is_session_leader"] is True + assert probe["sid"] != stale_controlling_tty_report["harness_sid"] + + +def test_grandchild_tcgetattr_outcome_is_recorded(stale_controlling_tty_report, record_property): + probe = stale_controlling_tty_report["probe"] + + record_property("platform", sys.platform) + record_property("tcgetattr", probe["tcgetattr"]) + record_property("tcgetattr_errno", probe["tcgetattr_errno_name"]) + print( + "stale controlling TTY on %s: tcgetattr=%s errno=%s" + % (sys.platform, probe["tcgetattr"], probe["tcgetattr_errno_name"]) + ) + + # The outcome varies by host, so only its presence is asserted. + assert probe["tcgetattr"] in ("ok", "error") + + +def test_grandchild_stdin_is_not_a_terminal_without_a_pty(non_tty_stdin_report): + """Control case: non-interactive callers give the child a non-TTY fd 0.""" + probe = non_tty_stdin_report["probe"] + + assert probe["isatty_stdin"] is False + assert probe["is_session_leader"] is True + assert probe["sid"] != non_tty_stdin_report["harness_sid"] + assert non_tty_stdin_report["probe_exit_code"] == 0 diff --git a/tests/test_quiet_eof_resend.py b/tests/test_quiet_eof_resend.py new file mode 100644 index 00000000..28f1658d --- /dev/null +++ b/tests/test_quiet_eof_resend.py @@ -0,0 +1,128 @@ +"""Tests for re-delivering end-of-file to a target that has gone quiet. + +Every execution gets one end-of-file at spawn. `getpass` calls +`tcsetattr(..., TCSAFLUSH, ...)` before reading, and TCSAFLUSH discards pending input — +so the EOF is gone by the time the read happens and the program waits for input nobody +will send. The script loses its whole timeout, and the unit-test fix loop reads that as a +defect in the code: one benchmark render patched against the resulting failure seventeen +times in a row while its conformance loop never failed once. + +Nothing else answers a test script's terminal reads, so the wait has to answer for +itself. +""" + +import time +from unittest.mock import MagicMock + +import pytest + +from render_machine.render_utils import ( + EOF_BYTE, + MAX_EOF_RESENDS, + QUIET_BEFORE_EOF_RESEND_SECONDS, + _QuietEofResender, +) + + +@pytest.fixture +def target(): + process = MagicMock() + process.transcript = "" + process.normalized_output = lambda: process.transcript + return process + + +def quiet_for(resender, seconds): + """Runs a poll as though `seconds` of silence had passed.""" + resender._since -= seconds + resender.consider() + + +def test_a_quiet_target_is_sent_end_of_file_again(target): + resender = _QuietEofResender(target) + resender.consider() # establishes the baseline + + quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) + + target.write_input.assert_called_once_with(EOF_BYTE) + + +def test_a_target_still_producing_output_is_left_alone(target): + """Output means the program is working, not waiting.""" + resender = _QuietEofResender(target) + resender.consider() + + target.transcript = "still going" + quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) + + target.write_input.assert_not_called() + + +def test_a_briefly_quiet_target_is_left_alone(target): + resender = _QuietEofResender(target) + resender.consider() + + quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS / 2) + + target.write_input.assert_not_called() + + +def test_output_after_a_resend_restarts_the_clock(target): + """A program that answers the end-of-file and carries on is making progress.""" + resender = _QuietEofResender(target) + resender.consider() + quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) + target.write_input.reset_mock() + + target.transcript = "off it goes" + resender.consider() + quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS / 2) + + target.write_input.assert_not_called() + + +def test_the_resends_are_bounded(target): + """A target quiet through every delivery is not waiting on the terminal, and a stuck + script should not also be a noisy one.""" + resender = _QuietEofResender(target) + resender.consider() + + for _ in range(MAX_EOF_RESENDS + 5): + quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) + + assert target.write_input.call_count == MAX_EOF_RESENDS + + +def test_a_target_that_cannot_be_written_to_stops_being_tried(target): + """The wait loop owns what happens to an unwritable target; this must not turn one + broken write into a warning on every poll.""" + target.write_input.side_effect = OSError("the terminal is gone") + resender = _QuietEofResender(target) + resender.consider() + + for _ in range(3): + quiet_for(resender, QUIET_BEFORE_EOF_RESEND_SECONDS) + + assert target.write_input.call_count == 1 + + +def test_the_first_poll_does_not_immediately_resend(target): + """A target gets its quiet period before anything is concluded about it.""" + resender = _QuietEofResender(target) + + resender.consider() + + target.write_input.assert_not_called() + + +def test_a_real_clock_is_used_for_the_quiet_period(target): + """Guards the constant itself: a period short enough to fire between two polls would + write into every healthy script that pauses to think.""" + assert QUIET_BEFORE_EOF_RESEND_SECONDS >= 1.0 + + resender = _QuietEofResender(target) + resender.consider() + time.sleep(0.05) + resender.consider() + + target.write_input.assert_not_called() diff --git a/tests/test_render_failure_payloads.py b/tests/test_render_failure_payloads.py new file mode 100644 index 00000000..8841ce48 --- /dev/null +++ b/tests/test_render_failure_payloads.py @@ -0,0 +1,65 @@ +"""What the renderer reports when a fix loop gives up. + +The conformance-fix loop reports exhaustion as a typed, actionable error. The two +client-side loops that can also give up — re-rendering a functionality because its unit +tests never pass, and refactoring — did not: one returned no payload at all (the user saw +`ERROR codeplain: None`), the other built its message without an f-string prefix and +showed literal braces. Both are user-facing render outcomes, so both carry a reason. +""" + +from unittest.mock import MagicMock + +from render_machine.actions.refactor_code import MAX_REFACTORING_ITERATIONS, RefactorCode +from render_machine.actions.render_functional_requirement import ( + MAX_CODE_GENERATION_RETRIES, + RenderFunctionalRequirement, +) + + +def exhausted_context(**frid_attributes): + context = MagicMock() + context.frid_context.frid = "1" + context.last_error_message = None + for name, value in frid_attributes.items(): + setattr(context.frid_context, name, value) + return context + + +def test_unit_test_exhaustion_reports_a_typed_reason(): + context = exhausted_context(functional_requirement_render_attempts=MAX_CODE_GENERATION_RETRIES) + + outcome, payload = RenderFunctionalRequirement().execute(context, None) + + assert outcome == RenderFunctionalRequirement.ITERATION_LIMIT_EXCEEDED_OUTCOME + assert payload is not None, "a render that stopped here used to hand ExitWithError nothing to print" + assert payload["error"]["type"] == "UNIT_TESTS_FIX_EXHAUSTED" + + +def test_unit_test_exhaustion_names_the_functionality_and_what_to_do(): + context = exhausted_context(functional_requirement_render_attempts=MAX_CODE_GENERATION_RETRIES) + + _, payload = RenderFunctionalRequirement().execute(context, None) + message = payload["error"]["message"] + + assert "'1'" in message + assert "unit tests" in message + assert "specification" in message + + +def test_unit_test_exhaustion_logs_the_same_reason_it_returns(): + context = exhausted_context(functional_requirement_render_attempts=MAX_CODE_GENERATION_RETRIES) + + _, payload = RenderFunctionalRequirement().execute(context, None) + + assert context.last_error_message == payload["error"]["message"] + + +def test_refactoring_exhaustion_interpolates_its_message(): + context = exhausted_context(refactoring_iteration=MAX_REFACTORING_ITERATIONS - 1) + + _, payload = RefactorCode().execute(context, None) + message = payload["error"]["message"] + + assert "{" not in message, "the message was built without an f-string prefix" + assert str(MAX_REFACTORING_ITERATIONS) in message + assert "1" in message diff --git a/tests/test_render_trailer.py b/tests/test_render_trailer.py new file mode 100644 index 00000000..675f3131 --- /dev/null +++ b/tests/test_render_trailer.py @@ -0,0 +1,112 @@ +"""Tests for the render trailer written to the log file. + +The pretty exit summary goes out through Rich's print, which bypasses logging entirely, +so `codeplain.log` simply stopped at whatever was logged last. An artifact that ends +mid-render is indistinguishable from a process that died silently — and when +cli-password-manager delivered a build with no entry point, that missing ending is +exactly what blocked the diagnosis. + +The trailer is therefore both the fix and a probe: it is the last thing written on every +exit path, so an artifact without one proves the log was truncated rather than merely +uninformative. +""" + +import logging +from unittest.mock import MagicMock + +import pytest + +from cli_output.render_summary import RENDER_TRAILER_PREFIX, log_render_trailer + + +def run_state(succeeded=True, cancelled=False): + state = MagicMock() + state.render_succeeded = succeeded + state.render_cancelled = cancelled + state.render_id = "5f1c25b7" + state.rendered_functionalities = 22 + state.render_time_accumulated = 2934 + state.render_generated_code_path = "/int-plainlang-examples/cli-password-manager/dist/" + return state + + +@pytest.fixture +def trailer_lines(caplog): + caplog.set_level(logging.INFO, logger="codeplain") + + def emit(state, spec="vault_cli.plain", error_message=None): + caplog.clear() + log_render_trailer(state, spec, error_message) + return [record.getMessage() for record in caplog.records if RENDER_TRAILER_PREFIX in record.getMessage()] + + return emit + + +def test_a_completed_render_records_its_outcome(trailer_lines): + lines = trailer_lines(run_state()) + + assert len(lines) == 1 + assert "outcome=completed" in lines[0] + + +def test_the_trailer_carries_what_a_later_diagnosis_needs(trailer_lines): + lines = trailer_lines(run_state()) + + assert "render_id=5f1c25b7" in lines[0] + assert "functionalities=22" in lines[0] + assert "render_time_s=2934" in lines[0] + assert "generated_code=/int-plainlang-examples/cli-password-manager/dist/" in lines[0] + + +def test_a_missing_generated_code_path_is_explicit(trailer_lines): + """The run that delivered no entry point reported this field empty; it has to be + legible in the log rather than a blank gap.""" + state = run_state() + state.render_generated_code_path = None + + lines = trailer_lines(state) + + assert "generated_code=-" in lines[0] + + +def test_a_failed_render_records_the_reason(trailer_lines): + lines = trailer_lines(run_state(succeeded=False), error_message="Conformance tests could not be fixed.") + + assert any("outcome=failed" in line for line in lines) + assert any("error=Conformance tests could not be fixed." in line for line in lines) + + +def test_a_cancelled_render_is_not_reported_as_failed(trailer_lines): + lines = trailer_lines(run_state(succeeded=False, cancelled=True)) + + assert "outcome=cancelled" in lines[0] + + +def test_a_failure_without_a_message_still_ends_the_log(trailer_lines): + lines = trailer_lines(run_state(succeeded=False)) + + assert any("outcome=failed" in line for line in lines) + + +def test_the_trailer_is_flushed_so_it_survives_an_abrupt_exit(): + handler = MagicMock() + handler.level = logging.NOTSET # logging compares record.levelno against this + logger = logging.getLogger("codeplain") + logger.addHandler(handler) + try: + log_render_trailer(run_state(), "vault_cli.plain") + finally: + logger.removeHandler(handler) + + assert handler.flush.called + + +def test_a_completed_render_that_still_raised_reports_the_reason(trailer_lines): + """A render can finish its functionalities and raise on the way out — publishing the + build, for instance. That combination printed a success banner, logged no reason, and + exited non-zero, which is how a boundary-audit failure went unnoticed across a whole + benchmark run.""" + lines = trailer_lines(run_state(succeeded=True), error_message="The generated build references ...") + + assert any("outcome=completed" in line for line in lines) + assert any("error=The generated build references ..." in line for line in lines) diff --git a/tests/test_render_utils.py b/tests/test_render_utils.py new file mode 100644 index 00000000..69706577 --- /dev/null +++ b/tests/test_render_utils.py @@ -0,0 +1,746 @@ +"""Characterization of `render_machine.render_utils.execute_script()`. + +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. +""" + +import contextlib +import json +import os +import stat +import subprocess +import sys +import tempfile +import textwrap +import threading +import time +from pathlib import Path + +import pytest + +from plain2code_exceptions import RenderCancelledError +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", + reason="execute_script() runs .ps1 scripts on Windows; these cases use shell scripts.", +) + +SCRIPT_TYPE = "Characterization" + +# Larger than the 64KB macOS pipe buffer, so the child blocks on write unless drained. +LARGE_OUTPUT_BYTES = 512 * 1024 + +CLEAR_SCREEN = "\033[2J" + + +def _make_shell_script(directory: Path, name: str, body: str) -> str: + """Writes an executable /bin/sh script and returns its absolute path.""" + script_path = directory / f"{name}.sh" + script_path.write_text("#!/bin/sh\n" + textwrap.dedent(body)) + script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR) + return str(script_path) + + +def _make_python_script(directory: Path, name: str, program: str) -> str: + """Writes an executable Python script and returns its absolute path. + + The interpreter is named in the shebang rather than wrapped in a shell, so no shell + ever sits between the caller and the program. A shell that inherits a terminal with + pending input wedges on exit on macOS, which the terminal-isolation cases below rely + on not happening. + """ + script_path = directory / f"{name}.py" + script_path.write_text(f"#!{sys.executable}\n" + textwrap.dedent(program)) + script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR) + return str(script_path) + + +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.""" + output_files = [] + + def _run(*args, **kwargs): + exit_code, output, output_file = render_utils.execute_script(*args, **kwargs) + if output_file: + output_files.append(output_file) + return exit_code, output, output_file + + yield _run + + for output_file in output_files: + # 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 +def test_successful_script_returns_zero_with_its_output(tmp_path, run_script): + script = _make_shell_script(tmp_path, "success", 'echo "ran with $1 $2"\n') + + exit_code, output, output_file = run_script(script, ["first", "second"], SCRIPT_TYPE, timeout=30) + + assert exit_code == 0 + assert "ran with first second" in output + assert os.path.isfile(output_file) + + +@posix_only +def test_the_raw_transcript_is_a_named_sibling_of_the_published_output(tmp_path, run_script): + """The unrendered bytes are findable from the returned path, so they can be cleaned up.""" + script = _make_shell_script(tmp_path, "coloured", 'printf "\\033[31mred\\033[0m\\n"\n') + + exit_code, _, output_file = run_script(script, [], SCRIPT_TYPE, timeout=30) + + raw_file = Path(output_file + render_utils.RAW_OUTPUT_SUFFIX) + assert exit_code == 0 + assert b"\033[31m" in raw_file.read_bytes() + + +@posix_only +@pytest.mark.parametrize("expected_exit_code", [1, 3, 69]) +def test_failing_script_exit_code_is_returned_verbatim(tmp_path, run_script, expected_exit_code): + script = _make_shell_script( + tmp_path, + f"exit_{expected_exit_code}", + f'echo "failing"\nexit {expected_exit_code}\n', + ) + + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == expected_exit_code + assert "failing" in output + + +@posix_only +def test_stderr_is_merged_into_the_captured_output(tmp_path, run_script): + script = _make_shell_script( + tmp_path, + "both_streams", + 'echo "on stdout"\necho "on stderr" >&2\n', + ) + + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + + assert exit_code == 0 + assert "on stdout" in output + assert "on stderr" in output + + +@posix_only +def test_output_larger_than_the_pipe_buffer_is_captured_without_deadlock(tmp_path, run_script): + script = _make_python_script( + tmp_path, + "large_output", + f""" + import sys + + sys.stdout.write("x" * {LARGE_OUTPUT_BYTES}) + sys.stdout.write("\\nEND-OF-OUTPUT\\n") + """, + ) + + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=60) + + assert exit_code == 0 + # 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") + + +@posix_only +def test_script_exceeding_the_timeout_returns_124_and_keeps_partial_output(tmp_path, run_script): + script = _make_shell_script( + tmp_path, + "slow", + 'echo "printed before the timeout"\nsleep 30\n', + ) + + exit_code, output, output_file = run_script(script, [], SCRIPT_TYPE, timeout=2) + + assert exit_code == render_utils.TIMEOUT_ERROR_EXIT_CODE + assert exit_code == 124 + assert "did not finish in 2 seconds" in output + assert "printed before the timeout" in output + assert "printed before the timeout" in Path(output_file).read_text() + + +@posix_only +def test_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 + + 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 +def test_script_without_a_path_is_resolved_against_the_working_directory(tmp_path, run_script, monkeypatch): + _make_shell_script(tmp_path, "bare_name", 'echo "resolved from the working directory"\n') + monkeypatch.chdir(tmp_path) + + exit_code, output, _ = run_script("bare_name.sh", [], SCRIPT_TYPE, timeout=30) + + assert exit_code == 0 + assert "resolved from the working directory" in output + + +@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 ------------------------------------------------ +# +# A rendered script must never be able to read the terminal Codeplain itself is +# attached to. The harness therefore has to hold a real terminal: it puts a PTY slave +# on its own fd 0 and writes to the master, which is what a user typing into the TUI +# does. Without that, pytest's fd 0 is not a terminal and the assertion would hold for +# the wrong reason. + +KEYSTROKES = "secret-keystrokes\n" +CONTROL_PROBE_TIMEOUT_SECONDS = 20 +STDIN_READ_LIMIT = 1024 +IMMEDIATE_EOF_SECONDS = 5 + +# Reports what fd 0 is and what a read of it yields. +STDIN_PROBE_PROGRAM = f""" +import json +import os +import sys +import time + +started = time.monotonic() +data = os.read(0, {STDIN_READ_LIMIT}) +report = {{ + "isatty": os.isatty(0), + "data": data.decode(errors="replace"), + "read_seconds": time.monotonic() - started, +}} +sys.stdout.write(json.dumps(report)) +sys.stdout.flush() +""" + + +@pytest.fixture +def terminal_on_stdin(): + """Puts a PTY slave on the test process's fd 0 and yields the master fd.""" + try: + saved_stdin_fd = os.dup(0) + except OSError as exc: + pytest.skip(f"fd 0 cannot be duplicated in this environment: {exc}") + + master_fd, slave_fd = os.openpty() + os.dup2(slave_fd, 0) + try: + yield master_fd + finally: + os.dup2(saved_stdin_fd, 0) + for fd in (saved_stdin_fd, slave_fd, master_fd): + with contextlib.suppress(OSError): + os.close(fd) + + +def _probe_report(output): + return json.loads(output.strip()) + + +@posix_only +def test_terminal_bytes_reach_a_child_that_inherits_stdin(tmp_path, terminal_on_stdin): + """Control case: proves the harness's terminal really does deliver keystrokes.""" + script = _make_python_script(tmp_path, "inheriting_probe", STDIN_PROBE_PROGRAM) + os.write(terminal_on_stdin, KEYSTROKES.encode()) + + # The spawn shape execute_script() uses, minus the stdin redirection under test. + process = subprocess.Popen( + [script], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + start_new_session=True, + ) + try: + output, _ = process.communicate(timeout=CONTROL_PROBE_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + process.communicate(timeout=CONTROL_PROBE_TIMEOUT_SECONDS) + pytest.fail("the control probe never returned from its read of fd 0") + + report = _probe_report(output) + assert report["isatty"] is True + assert report["data"] == KEYSTROKES + + +@posix_only +def test_script_stdin_is_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()) + + started = time.monotonic() + exit_code, output, _ = run_script(script, [], SCRIPT_TYPE, timeout=30) + elapsed = time.monotonic() - started + + assert exit_code == 0 + report = _probe_report(output) + assert report["isatty"] is 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() + + +@posix_only +def test_a_getpass_target_survives_its_terminal_flush(tmp_path, run_script): + """The failure this whole path exists for. + + `getpass` calls `tcsetattr(..., TCSAFLUSH, ...)` before reading, and TCSAFLUSH + discards pending input — so the end-of-file queued at spawn is gone by the time the + read happens and the target waits for input nobody will send. Before the quiet-period + re-delivery this target burned the entire script timeout, and the fix loop read that + as a defect in the generated code. + + The timeout here is well above the quiet period and well below what a hang costs, so + a regression fails the test rather than slowing it down. + """ + script = _make_python_script( + tmp_path, + "getpass_no_driver", + """ + import getpass + + try: + secret = getpass.getpass("Master password: ") + except EOFError: + secret = "" + print(f"GOT:{secret}") + """, + ) + + exit_code, output, _ = run_script( + script, [], SCRIPT_TYPE, timeout=render_utils.QUIET_BEFORE_EOF_RESEND_SECONDS + 20 + ) + + assert exit_code == 0, output + assert "GOT:" in output diff --git a/tests/test_rest_error_codes.py b/tests/test_rest_error_codes.py new file mode 100644 index 00000000..82f42818 --- /dev/null +++ b/tests/test_rest_error_codes.py @@ -0,0 +1,29 @@ +"""Tests for mapping API error codes onto typed client exceptions.""" + +from unittest.mock import MagicMock + +import pytest + +import plain2code_exceptions +from codeplain_REST_api import CodeplainAPI + + +def test_conformance_fix_exhaustion_maps_to_its_typed_exception(): + """The server reports fix-attempt exhaustion as a structured 400; the client raises + the matching typed exception so the render fails with the server's message instead + of a raw HTTP error.""" + api = CodeplainAPI(api_key="test-key", console=MagicMock()) + + with pytest.raises(plain2code_exceptions.ConformanceTestsFixExhausted, match="after 10 attempts"): + api._raise_for_error_code( + { + "error_code": "ConformanceTestsFixExhausted", + "message": "Could not fix conformance tests issue for functional requirement 1 after 10 attempts.", + } + ) + + +def test_an_unknown_error_code_still_falls_through_silently(): + api = CodeplainAPI(api_key="test-key", console=MagicMock()) + + api._raise_for_error_code({"error_code": "SomeFutureCode", "message": "whatever"}) # does not raise diff --git a/tests/test_terminal_process.py b/tests/test_terminal_process.py new file mode 100644 index 00000000..57bc4825 --- /dev/null +++ b/tests/test_terminal_process.py @@ -0,0 +1,1760 @@ +"""Tests for the PTY launcher and the POSIX terminal backend. + +Everything here spawns real processes and allocates real terminals, so the whole module +is POSIX-only. Each helper is responsible for leaving no descriptor and no process +behind — the suite runs against a bounded system PTY limit. +""" + +import errno +import os +import select +import signal +import subprocess +import sys +import threading +import time +from contextlib import contextmanager +from pathlib import Path + +import pytest + +if sys.platform == "win32": + # Parametrize lists below reference the POSIX-only modules at import time, so a + # skipif mark is not enough — collection itself must stop here. + pytest.skip("The POSIX PTY backend is not built on Windows.", allow_module_level=True) + +if sys.platform != "win32": + import termios + + from render_machine import pty_exec + +REPO_ROOT = Path(__file__).resolve().parent.parent +LAUNCHER = str(REPO_ROOT / "render_machine" / "pty_exec.py") + +# Every wait in this module is bounded. These are generous relative to the operations +# they cover, so a failure means a hang rather than a slow machine. +LAUNCH_TIMEOUT = 20.0 +SHORT_TIMEOUT = 5.0 + + +def _read_records(fd, timeout): + """Reads the status pipe to EOF and splits it into (kind, payload) records. + + Deliberately independent of the backend's parser: these cases assert what the + launcher puts on the wire, not what the parent makes of it. + """ + deadline = time.monotonic() + timeout + buffer = b"" + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise AssertionError(f"status pipe did not reach EOF within {timeout}s; buffered {buffer!r}") + readable, _, _ = select.select([fd], [], [], min(remaining, 0.2)) + if not readable: + continue + chunk = os.read(fd, 65536) + if not chunk: + break + buffer += chunk + + records = [] + offset = 0 + while offset < len(buffer): + kind = buffer[offset] + length = int.from_bytes(buffer[offset + 1 : offset + 5], "big") + payload = buffer[offset + 5 : offset + 5 + length] + assert len(payload) == length, f"truncated record in {buffer!r}" + records.append((kind, payload)) + offset += 5 + length + return records + + +class _LauncherSession: + """Parent side of the launcher protocol, reduced to what these cases need.""" + + def __init__(self, proc, master_fd, status_r, ack_w): + self.proc = proc + self.master_fd = master_fd + self.status_r = status_r + self.ack_w = ack_w + self.output = bytearray() + self._drain = threading.Thread(target=self._drain_master, daemon=True) + self._drain.start() + + def _drain_master(self): + while True: + try: + chunk = os.read(self.master_fd, 65536) + except OSError: + return + if not chunk: + return + self.output += chunk + + def ack(self): + os.write(self.ack_w, b"\x01") + + def close_ack(self): + if self.ack_w is not None: + os.close(self.ack_w) + self.ack_w = None + + def records(self, timeout=LAUNCH_TIMEOUT): + return _read_records(self.status_r, timeout) + + def wait(self, timeout=LAUNCH_TIMEOUT): + return self.proc.wait(timeout=timeout) + + def stderr_text(self): + return self.proc.stderr.read().decode("utf-8", "replace") + + +@contextmanager +def launcher_session(command, python=None, env=None): + """Spawns the launcher exactly as the backend does and cleans up unconditionally.""" + master_fd, slave_fd = os.openpty() + status_r, status_w = os.pipe() + ack_r, ack_w = os.pipe() + proc = None + try: + proc = subprocess.Popen( + [ + python or sys.executable, + "-I", + "-S", + LAUNCHER, + str(slave_fd), + str(status_w), + str(ack_r), + "--", + *command, + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + pass_fds=(slave_fd, status_w, ack_r), + close_fds=True, + env=env, + ) + finally: + for fd in (slave_fd, status_w, ack_r): + os.close(fd) + + session = _LauncherSession(proc, master_fd, status_r, ack_w) + try: + yield session + finally: + session.close_ack() + if proc.poll() is None: + proc.kill() + proc.wait(timeout=SHORT_TIMEOUT) + proc.stderr.close() + for fd in (master_fd, status_r): + try: + os.close(fd) + except OSError: + pass + + +def test_write_record_frames_payloads_that_look_like_markers(): + """A payload that begins with — or equals — a marker byte stays a framed payload.""" + for payload in (b"\x01", b"\x02", b"\x03", b"\x02session ready", b"\x01started", b""): + read_fd, write_fd = os.pipe() + try: + pty_exec._write_record(write_fd, pty_exec.FAILED, payload) + os.close(write_fd) + write_fd = None + framed = os.read(read_fd, 65536) + finally: + if write_fd is not None: + os.close(write_fd) + os.close(read_fd) + + assert framed == bytes([pty_exec.FAILED]) + len(payload).to_bytes(4, "big") + payload + + +def test_write_record_bounds_the_payload(): + read_fd, write_fd = os.pipe() + try: + pty_exec._write_record(write_fd, pty_exec.FAILED, b"x" * (pty_exec.MAX_PAYLOAD * 2)) + os.close(write_fd) + write_fd = None + framed = os.read(read_fd, 65536) + finally: + if write_fd is not None: + os.close(write_fd) + os.close(read_fd) + + assert int.from_bytes(framed[1:5], "big") == pty_exec.MAX_PAYLOAD + assert len(framed) == pty_exec.MAX_PAYLOAD + pty_exec.HEADER_SIZE + + +def test_write_record_completes_across_short_writes(monkeypatch): + """A short os.write() must not leave a valid header with a truncated payload.""" + read_fd, write_fd = os.pipe() + real_write = os.write + chunks = [] + + def short_write(fd, data): + if fd != write_fd: + return real_write(fd, data) + count = real_write(fd, data[:1]) + chunks.append(data[:count]) + return count + + payload = b"\x02boom" + try: + monkeypatch.setattr(os, "write", short_write) + pty_exec._write_record(write_fd, pty_exec.FAILED, payload) + monkeypatch.undo() + os.close(write_fd) + write_fd = None + framed = os.read(read_fd, 65536) + finally: + if write_fd is not None: + os.close(write_fd) + os.close(read_fd) + + expected = bytes([pty_exec.FAILED]) + len(payload).to_bytes(4, "big") + payload + assert len(chunks) == len(expected), "the write was not actually fragmented" + assert b"".join(chunks) == expected + assert framed == expected + + +def test_launcher_reports_failure_when_the_ack_pipe_reaches_eof(): + """A parent that dies before acknowledging releases the launcher immediately.""" + with launcher_session(["/bin/sh", "-c", "exit 0"]) as session: + started = time.monotonic() + session.close_ack() + assert session.wait(timeout=SHORT_TIMEOUT) == pty_exec.LAUNCH_FAILURE_EXIT_CODE + elapsed = time.monotonic() - started + records = session.records() + + assert elapsed < SHORT_TIMEOUT + assert [kind for kind, _ in records] == [pty_exec.STARTED, pty_exec.SESSION_READY, pty_exec.FAILED] + assert b"acknowledgment pipe" in records[-1][1] + + +def test_launcher_reports_failure_when_the_ack_timeout_expires(): + """A parent that is alive but wedged must not block the launcher forever.""" + env = dict(os.environ, **{pty_exec.ACK_TIMEOUT_ENV: "0.2"}) + with launcher_session(["/bin/sh", "-c", "exit 0"], env=env) as session: + started = time.monotonic() + assert session.wait(timeout=SHORT_TIMEOUT) == pty_exec.LAUNCH_FAILURE_EXIT_CODE + elapsed = time.monotonic() - started + records = session.records() + + assert elapsed < SHORT_TIMEOUT + assert [kind for kind, _ in records] == [pty_exec.STARTED, pty_exec.SESSION_READY, pty_exec.FAILED] + assert b"did not acknowledge" in records[-1][1] + + +def test_launcher_execs_the_target_after_the_ack(): + with launcher_session(["/bin/sh", "-c", "printf ready; exit 7"]) as session: + records = [] + deadline = time.monotonic() + LAUNCH_TIMEOUT + # The ack is written as soon as SESSION_READY has been observed, exactly as the + # backend does; the status pipe then reaches EOF because exec closes it. + while time.monotonic() < deadline: + readable, _, _ = select.select([session.status_r], [], [], 0.2) + if readable: + break + session.ack() + records = session.records() + assert session.wait(timeout=SHORT_TIMEOUT) == 7 + + assert [kind for kind, _ in records] == [pty_exec.STARTED, pty_exec.SESSION_READY] + assert b"ready" in bytes(session.output) + + +def test_target_receives_restored_signal_dispositions(): + """CPython ignores SIGPIPE and that survives exec; _restore_signals() undoes it.""" + with launcher_session(["/bin/sh", "-c", "kill -PIPE $$; exit 0"]) as session: + deadline = time.monotonic() + LAUNCH_TIMEOUT + while time.monotonic() < deadline: + readable, _, _ = select.select([session.status_r], [], [], 0.2) + if readable: + break + session.ack() + session.records() + returncode = session.wait(timeout=SHORT_TIMEOUT) + + assert returncode == -signal.SIGPIPE + + +def _plant_startup_hooks(tmp_path): + """Builds a throwaway venv whose site-packages runs code at interpreter startup. + + Returns (interpreter, marker_prefix). The venv's own site-packages is used because + a virtual environment disables the user site directory, so PYTHONUSERBASE cannot + carry the plant. + """ + venv_dir = tmp_path / "planted" + subprocess.run( + [sys.executable, "-m", "venv", "--without-pip", str(venv_dir)], + check=True, + capture_output=True, + timeout=LAUNCH_TIMEOUT, + ) + interpreter = venv_dir / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") + site_packages = subprocess.run( + [str(interpreter), "-c", "import sysconfig; print(sysconfig.get_paths()['purelib'])"], + check=True, + capture_output=True, + text=True, + timeout=LAUNCH_TIMEOUT, + ).stdout.strip() + + marker_prefix = tmp_path / "startup" + Path(site_packages, "sitecustomize.py").write_text( + f"open({str(marker_prefix)!r} + '.sitecustomize', 'w').write('ran')\n" + ) + Path(site_packages, "zzz_probe.pth").write_text( + f"import builtins; open({str(marker_prefix)!r} + '.pth', 'w').write('ran')\n" + ) + return str(interpreter), marker_prefix + + +def test_launcher_runs_no_startup_customization(tmp_path): + """`-I -S` is part of the ack barrier's proof: nothing may run before STARTED.""" + interpreter, marker_prefix = _plant_startup_hooks(tmp_path) + sitecustomize_marker = Path(f"{marker_prefix}.sitecustomize") + pth_marker = Path(f"{marker_prefix}.pth") + + subprocess.run([interpreter, "-c", "pass"], check=True, capture_output=True, timeout=LAUNCH_TIMEOUT) + assert sitecustomize_marker.exists(), "the planted sitecustomize.py never ran, so the test proves nothing" + assert pth_marker.exists(), "the planted .pth never ran, so the test proves nothing" + sitecustomize_marker.unlink() + pth_marker.unlink() + + with launcher_session(["/bin/sh", "-c", "exit 0"], python=interpreter) as session: + deadline = time.monotonic() + LAUNCH_TIMEOUT + while time.monotonic() < deadline: + readable, _, _ = select.select([session.status_r], [], [], 0.2) + if readable: + break + session.ack() + records = session.records() + assert session.wait(timeout=SHORT_TIMEOUT) == 0 + + assert [kind for kind, _ in records] == [pty_exec.STARTED, pty_exec.SESSION_READY] + assert not sitecustomize_marker.exists() + assert not pth_marker.exists() + + +# --------------------------------------------------------------------- backend + +if sys.platform != "win32": + from plain2code_exceptions import RenderCancelledError + from render_machine import _posix_pty + from render_machine.terminal_process import ( + SIGTERM_GRACE_PERIOD_SECONDS, + InputDisposition, + TerminalEnvironmentError, + TerminalLaunchError, + ) + +SPAWN_TIMEOUT = 10.0 + + +@contextmanager +def terminal(**spawn_kwargs): + """Spawns a command through the backend and always tears it down.""" + command = spawn_kwargs.pop("command") + process = _posix_pty.PosixPtyProcess() + try: + process.spawn(command, **spawn_kwargs) + yield process + finally: + try: + process.terminate_tree(grace=0.05) + finally: + process.close() + + +def wait_for_exit(process, timeout=SPAWN_TIMEOUT): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + returncode = process.poll() + if returncode is not None: + return returncode + time.sleep(0.02) + raise AssertionError(f"the target did not exit within {timeout}s") + + +def wait_for_output(process, needle, timeout=SPAWN_TIMEOUT): + deadline = time.monotonic() + timeout + collected = "" + while time.monotonic() < deadline: + collected += process.read_output() + if needle in collected: + return collected + time.sleep(0.02) + raise AssertionError(f"{needle!r} never appeared in {collected!r}") + + +def write_launcher(tmp_path, name, source): + path = tmp_path / name + path.write_text(source) + return str(path) + + +def stub_launcher(tmp_path, name, body): + """A launcher that runs the real protocol with `body` applied to the module first.""" + source = ( + "import sys\n" + f"sys.path.insert(0, {str(REPO_ROOT)!r})\n" + "from render_machine import pty_exec\n" + f"{body}\n" + "pty_exec._run(sys.argv[1:])\n" + ) + return write_launcher(tmp_path, name, source) + + +def test_spawn_runs_the_target_and_reports_its_exit_code(): + with terminal(command=["/bin/sh", "-c", "printf hello; exit 3"]) as process: + wait_for_output(process, "hello") + assert wait_for_exit(process) == 3 + + +def test_read_output_round_trip(): + with terminal(command=["/bin/sh", "-c", "printf 'one\\ntwo\\n'"]) as process: + collected = wait_for_output(process, "two") + assert wait_for_exit(process) == 0 + + assert "one" in collected and "two" in collected + # ONLCR is left at its default, so the terminal supplies the carriage returns. + assert "\r\n" in collected + + +def test_poll_returns_none_until_the_target_exits(): + with terminal(command=["/bin/sh", "-c", "sleep 0.3"]) as process: + assert process.poll() is None + assert wait_for_exit(process) == 0 + assert process.poll() == 0 + + +def test_handshake_reports_a_launcher_that_reached_our_code_and_failed(): + process = _posix_pty.PosixPtyProcess() + try: + with pytest.raises(TerminalLaunchError) as failure: + process.spawn(["/nonexistent/command/for/tests"]) + finally: + process.close() + + assert failure.value.exit_code == 69 + assert "the launcher failed" in str(failure.value) + + +def test_handshake_reports_launcher_invariant_failures(tmp_path, monkeypatch): + launcher = stub_launcher( + tmp_path, + "invariant_launcher.py", + "def _fail():\n" + " raise RuntimeError('PTY is not attached to all three descriptors')\n" + "pty_exec._assert_invariants = _fail", + ) + monkeypatch.setattr(_posix_pty, "_LAUNCHER", launcher) + + process = _posix_pty.PosixPtyProcess() + try: + with pytest.raises(TerminalLaunchError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"]) + finally: + process.close() + + assert failure.value.exit_code == 69 + assert "PTY is not attached to all three descriptors" in str(failure.value) + + +def test_handshake_reports_an_interpreter_that_died_before_our_code(tmp_path, monkeypatch): + launcher = write_launcher(tmp_path, "unparseable_launcher.py", "def broken(:\n") + monkeypatch.setattr(_posix_pty, "_LAUNCHER", launcher) + + process = _posix_pty.PosixPtyProcess() + try: + with pytest.raises(TerminalLaunchError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"]) + finally: + process.close() + + assert failure.value.exit_code == 69 + assert "the interpreter died before running the launcher" in str(failure.value) + assert "SyntaxError" in str(failure.value) + + +def test_handshake_reports_a_launcher_that_hangs_before_exec(tmp_path, monkeypatch): + launcher = write_launcher(tmp_path, "hanging_launcher.py", "import time\ntime.sleep(120)\n") + monkeypatch.setattr(_posix_pty, "_LAUNCHER", launcher) + + process = _posix_pty.PosixPtyProcess() + started = time.monotonic() + try: + with pytest.raises(TerminalLaunchError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"], handshake_timeout=1.0) + finally: + process.close() + + assert time.monotonic() - started < SPAWN_TIMEOUT + assert "hung before exec" in str(failure.value) + + +def test_handshake_rejects_records_whose_payload_looks_like_a_marker(tmp_path, monkeypatch): + """A framed error payload equal to a marker byte is still a failure, never a success.""" + for payload in ("b'\\x02'", "b'\\x01'", "b'\\x02 looks like a marker'"): + launcher = write_launcher( + tmp_path, + f"marker_launcher_{abs(hash(payload))}.py", + "import os, sys\n" + f"sys.path.insert(0, {str(REPO_ROOT)!r})\n" + "from render_machine import pty_exec\n" + "status_fd = int(sys.argv[2])\n" + "pty_exec._write_record(status_fd, pty_exec.STARTED)\n" + f"pty_exec._write_record(status_fd, pty_exec.FAILED, {payload})\n" + "os._exit(127)\n", + ) + monkeypatch.setattr(_posix_pty, "_LAUNCHER", launcher) + + process = _posix_pty.PosixPtyProcess() + try: + with pytest.raises(TerminalLaunchError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"]) + finally: + process.close() + + assert failure.value.exit_code == 69 + assert "the launcher failed" in str(failure.value) + + +def test_spawn_and_close_leak_no_descriptors(): + def open_fd_count(): + return len(os.listdir("/dev/fd")) + + with terminal(command=["/bin/sh", "-c", "printf warmup"]) as process: + wait_for_exit(process) + + baseline = open_fd_count() + for _ in range(3): + with terminal(command=["/bin/sh", "-c", "printf run"]) as process: + wait_for_exit(process) + assert open_fd_count() == baseline + + +def test_openpty_failure_is_an_environment_error(monkeypatch): + monkeypatch.setattr(_posix_pty.os, "openpty", lambda: (_ for _ in ()).throw(OSError(23, "too many open files"))) + process = _posix_pty.PosixPtyProcess() + try: + with pytest.raises(TerminalEnvironmentError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"]) + finally: + process.close() + + assert failure.value.exit_code == 69 + assert "too many open files" in str(failure.value) + + +def fail_nth_call(monkeypatch, module, name, error, nth): + """Lets the first `nth - 1` calls through and fails the one after them.""" + real = getattr(module, name) + state = {"calls": 0} + + def failing(*args, **kwargs): + state["calls"] += 1 + if state["calls"] == nth: + raise error + return real(*args, **kwargs) + + monkeypatch.setattr(module, name, failing) + return state + + +@pytest.mark.parametrize("nth", [1, 2, 3, 4]) +def test_a_failing_channel_pipe_rolls_back_the_descriptors_already_opened(monkeypatch, nth): + """One case per os.pipe() in _open_channels: the earlier pairs must not survive it.""" + baseline = open_fd_count() + state = fail_nth_call(monkeypatch, _posix_pty.os, "pipe", OSError(errno.EMFILE, "too many open files"), nth) + + process = _posix_pty.PosixPtyProcess() + with pytest.raises(TerminalEnvironmentError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"]) + process.close() + monkeypatch.undo() + + assert state["calls"] == nth + assert failure.value.exit_code == 69 + assert "too many open files" in str(failure.value) + assert open_fd_count() == baseline + + +@pytest.mark.parametrize("nth", [2, 3]) +def test_a_failing_doorbell_mode_change_rolls_back_every_channel(monkeypatch, nth): + """The doorbell's os.set_blocking() calls run with all four pipe pairs already open.""" + baseline = open_fd_count() + error = OSError(errno.EBADF, "injected set_blocking failure") + state = fail_nth_call(monkeypatch, _posix_pty.os, "set_blocking", error, nth) + + process = _posix_pty.PosixPtyProcess() + with pytest.raises(TerminalEnvironmentError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"]) + process.close() + monkeypatch.undo() + + assert state["calls"] == nth # the first call belongs to the master, not to the doorbell + assert failure.value.exit_code == 69 + assert "injected set_blocking failure" in str(failure.value) + assert open_fd_count() == baseline + + +def test_a_failing_reader_thread_construction_rolls_back_every_channel(monkeypatch): + """The last construction step in _open_channels; nothing has an owner before it.""" + baseline = open_fd_count() + real_thread = _posix_pty.threading.Thread + + def failing_thread(*args, **kwargs): + if kwargs.get("name") == "codeplain-pty-reader": + raise RuntimeError("can't start new thread") + return real_thread(*args, **kwargs) + + monkeypatch.setattr(_posix_pty.threading, "Thread", failing_thread) + process = _posix_pty.PosixPtyProcess() + with pytest.raises(TerminalEnvironmentError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"]) + process.close() + monkeypatch.undo() + + assert failure.value.exit_code == 69 + assert "can't start new thread" in str(failure.value) + assert process._bundle is None and process._reader is None # nothing was published + assert open_fd_count() == baseline + + +def test_write_input_reports_whole_item_admission(): + # The first read consumes the end-of-file queued at spawn; the second proves the + # terminal is still open afterwards and later input is delivered normally. On a + # pseudoterminal VEOF ends a read, it does not close the channel. + with terminal(command=["/bin/sh", "-c", "read spawn_eof; read line; printf 'got:%s' \"$line\""]) as process: + result = process.write_input(b"payload\n") + assert result.disposition is InputDisposition.ACCEPTED + assert result.accepted_bytes == len(b"payload\n") + wait_for_output(process, "got:payload") + assert wait_for_exit(process) == 0 + + +def test_write_input_reports_backpressure_for_an_oversized_item(): + with terminal(command=["/bin/sh", "-c", "sleep 5"]) as process: + result = process.write_input(b"x" * (_posix_pty.MAX_INPUT_ITEM_BYTES + 1)) + assert result.disposition is InputDisposition.BACKPRESSURE + assert result.accepted_bytes == 0 + + +class _OversizedItem: + """Reports a size but refuses to be copied, so a copy-before-validate is visible.""" + + def __len__(self): + return _posix_pty.MAX_INPUT_ITEM_BYTES + 1 + + def __bytes__(self): + raise AssertionError("the oversized item was copied before it was rejected") + + +def test_an_oversized_item_is_rejected_before_it_is_copied(): + queue = _posix_pty._InputQueue() + result, receipt = queue.submit(_OversizedItem()) + + assert result.disposition is InputDisposition.BACKPRESSURE + assert result.accepted_bytes == 0 + assert receipt.resolutions == 1 + assert queue.pending_items() == 0 + + +def test_an_empty_item_never_becomes_a_queue_entry(): + """Zero-length items cost no bytes, so admitting them would grow the queue unbounded.""" + queue = _posix_pty._InputQueue() + for _ in range(10_000): + result, receipt = queue.submit(b"") + assert result.disposition is InputDisposition.ACCEPTED + assert result.accepted_bytes == 0 + assert receipt.resolutions == 1 + + assert queue.pending_items() == 0 + assert queue.pending_bytes() == 0 + assert not queue.has_pending() + + +def test_the_input_queue_bounds_the_item_count_as_well_as_the_bytes(): + """Single-byte items exhaust the item budget long before the byte budget.""" + queue = _posix_pty._InputQueue() + accepted = 0 + while queue.submit(b"x")[0].disposition is InputDisposition.ACCEPTED: + accepted += 1 + if accepted > _posix_pty.MAX_PENDING_INPUT_ITEMS: + raise AssertionError("the queue admitted more items than its item budget allows") + + assert accepted == _posix_pty.MAX_PENDING_INPUT_ITEMS - _posix_pty.RESERVED_INPUT_ITEMS + assert queue.pending_bytes() == accepted + assert queue.pending_bytes() < _posix_pty.MAX_PENDING_INPUT_BYTES, "the byte budget was not the binding limit" + # The reserved partition is an admission partition, so control items still fit. + assert queue.submit(b"x", reserved=True)[0].disposition is InputDisposition.ACCEPTED + + +# ------------------------------------------------------------------- lifecycle + + +def make_script(directory, name, body): + """Writes an executable /bin/sh script and returns its absolute path.""" + path = Path(directory) / f"{name}.sh" + path.write_text("#!/bin/sh\n" + body) + path.chmod(0o755) + return str(path) + + +def wait_until_gone(pid, timeout=SPAWN_TIMEOUT): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + os.kill(pid, 0) + except ProcessLookupError: + return True + time.sleep(0.02) + return False + + +def reported_pid(process, label, timeout=SPAWN_TIMEOUT): + collected = wait_for_output(process, f"{label}:", timeout) + for line in collected.replace("\r", "").splitlines(): + if line.startswith(f"{label}:"): + return int(line.split(":", 1)[1]) + raise AssertionError(f"no {label} pid in {collected!r}") + + +def test_close_returns_while_the_reader_is_parked_and_a_descendant_holds_the_slave(tmp_path): + """The regression guard for the verified macOS close()-on-a-blocked-read hang. + + The failure mode is a hang rather than an exception, so the assertion is on elapsed + time: `close()` must return and the reader must join while both the leader and a + descendant still hold the slave open. + """ + script = make_script(tmp_path, "holder", "sleep 20 &\nprintf 'descendant:%s\\n' \"$!\"\nsleep 20\n") + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script]) + descendant = reported_pid(process, "descendant") + assert process.poll() is None + assert process._reader is not None and process._reader.is_alive() + + started = time.monotonic() + process.close() + elapsed = time.monotonic() - started + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert elapsed < _posix_pty.DRAIN_DEADLINE_SECONDS + SHORT_TIMEOUT + assert not process._reader.is_alive() + assert process.reader_exc is None + assert wait_until_gone(descendant) + + +def test_reader_exits_cleanly_when_the_leader_exits_with_a_descendant_on_the_slave(tmp_path): + """Either the hangup or the last slave close ends the stream; neither may raise.""" + script = make_script(tmp_path, "leaver", "sleep 20 &\nprintf 'descendant:%s\\n' \"$!\"\nexit 0\n") + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script]) + descendant = reported_pid(process, "descendant") + assert wait_for_exit(process) == 0 + started = time.monotonic() + process.close() + elapsed = time.monotonic() - started + finally: + process.terminate_tree(grace=0.05) + process.close() + if not wait_until_gone(descendant, timeout=0.5): + os.kill(descendant, signal.SIGKILL) + + assert elapsed < _posix_pty.DRAIN_DEADLINE_SECONDS + SHORT_TIMEOUT + assert process.reader_exc is None + + +# How long the delayed-ack backend below holds the acknowledgment window open. Nothing +# waits it out: every case that uses it ends the window itself. +ACK_WINDOW_SECONDS = 5.0 + + +class _DelayedAckProcess(_posix_pty.PosixPtyProcess): + """Holds the acknowledgment for a bounded time, so the barrier's window is opened + rather than raced. The wait is cancellable and reader-aware, like the path it sits in.""" + + def __init__(self, delay=ACK_WINDOW_SECONDS): + super().__init__() + self.delay = delay + self.entered = threading.Event() + + def _pre_ack_hook(self): + self.entered.set() + until = time.monotonic() + self.delay + while time.monotonic() < until: + self._check_cancelled() + self._check_reader_failed() + time.sleep(min(0.02, max(0.0, until - time.monotonic()))) + + +def test_cancellation_inside_the_ack_window_leaves_nothing_behind(): + """Deterministic through the delayed-ack hook: the window is opened, not raced. + + The cancellation waits for the hook to be entered, so it can never land before + SESSION_READY however slowly the launcher gets there. + """ + stop_event = threading.Event() + process = _DelayedAckProcess() + entered = process.entered + + def cancel_inside_the_window(): + if entered.wait(SPAWN_TIMEOUT): + stop_event.set() + + canceller = threading.Thread(target=cancel_inside_the_window, daemon=True) + canceller.start() + try: + with pytest.raises(RenderCancelledError): + process.spawn(["/bin/sh", "-c", "sleep 30"], stop_event=stop_event) + finally: + canceller.join(timeout=SHORT_TIMEOUT) + process.close() + + assert entered.is_set() + launcher_pid = process._proc.pid + assert process._proc.returncode is not None + assert wait_until_gone(launcher_pid) + + +def test_cancellation_after_the_ack_reaps_a_forked_descendant(tmp_path): + script = make_script( + tmp_path, + "forker", + "sleep 30 &\nprintf 'descendant:%s\\n' \"$!\"\nsleep 30\n", + ) + stop_event = threading.Event() + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script], stop_event=stop_event) + descendant = reported_pid(process, "descendant") + stop_event.set() + process.terminate_tree(grace=0.2) + finally: + process.close() + + assert wait_until_gone(descendant) + + +# The grace is a bound, not a delay, so a tree that handled the SIGTERM must not hold the +# teardown for the rest of it. +GRACE_EARLY_EXIT_CEILING = 1.0 + + +@pytest.mark.skipif( + sys.platform == "linux", + reason="An unreaped group leader stays signallable on Linux, so the probe cannot observe the exit there.", +) +def test_a_tree_that_exits_on_sigterm_does_not_wait_out_the_whole_grace(tmp_path): + """The group is probed on every tick with signal 0; the SIGKILL and the reap still follow.""" + script = make_script(tmp_path, "sleeper", "sleep 120\n") + process = _posix_pty.PosixPtyProcess() + process.spawn([script]) + try: + started = time.monotonic() + process.terminate_tree(grace=SIGTERM_GRACE_PERIOD_SECONDS) + elapsed = time.monotonic() - started + finally: + process.close() + + assert elapsed < GRACE_EARLY_EXIT_CEILING + assert process._proc.returncode is not None + + +def test_launcher_ack_timeout_beats_the_parents_ack(): + """The parent's write hits a closed pipe; the launcher's own reason must surface.""" + env = dict(os.environ, **{pty_exec.ACK_TIMEOUT_ENV: "0.2"}) + process = _DelayedAckProcess(delay=2.0) + try: + with pytest.raises(TerminalLaunchError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"], env=env, handshake_timeout=10.0) + finally: + process.close() + + assert failure.value.exit_code == 69 + assert "did not acknowledge" in str(failure.value) + + +def test_codeplains_own_process_group_is_never_signalled(tmp_path, monkeypatch): + signalled = [] + real_killpg = os.killpg + + def recording_killpg(pgid, sig): + signalled.append(pgid) + return real_killpg(pgid, sig) + + monkeypatch.setattr(_posix_pty.os, "killpg", recording_killpg) + own_pgid = os.getpgrp() + + # Cancellation before the handshake completes, where no group has been recorded yet. + hanging = write_launcher(tmp_path, "hang.py", "import time\ntime.sleep(120)\n") + monkeypatch.setattr(_posix_pty, "_LAUNCHER", hanging) + stop_event = threading.Event() + threading.Timer(0.2, stop_event.set).start() + process = _posix_pty.PosixPtyProcess() + try: + with pytest.raises(RenderCancelledError): + process.spawn(["/bin/sh", "-c", "sleep 30"], stop_event=stop_event, handshake_timeout=10.0) + finally: + process.close() + + # Cancellation inside the ack window, and termination after a normal spawn. + monkeypatch.undo() + monkeypatch.setattr(_posix_pty.os, "killpg", recording_killpg) + stop_event = threading.Event() + threading.Timer(0.2, stop_event.set).start() + process = _DelayedAckProcess() + try: + with pytest.raises(RenderCancelledError): + process.spawn(["/bin/sh", "-c", "sleep 30"], stop_event=stop_event) + finally: + process.close() + + with terminal(command=["/bin/sh", "-c", "sleep 30"]) as running: + running.terminate_tree(grace=0.1) + + assert own_pgid not in signalled + assert signalled, "the recorded group should still be signalled on the ordinary path" + + +def test_killpg_has_exactly_one_call_site(): + """A bare os.killpg(os.getpgid(...)) anywhere is the F1 defect returning.""" + source = Path(_posix_pty.__file__).read_text() + assert source.count("os.killpg(") == 1 + assert "getpgid" not in source + + +def test_spawn_without_a_stop_event_runs_end_to_end(): + process = _posix_pty.PosixPtyProcess() + try: + process.spawn(["/bin/sh", "-c", "printf done; exit 0"]) + wait_for_output(process, "done") + assert wait_for_exit(process) == 0 + finally: + process.close() + + +def test_spawn_time_veof_lets_a_single_read_script_exit_promptly(tmp_path): + script = make_script(tmp_path, "single_read", "read line\nprintf 'read-returned:%s\\n' \"$?\"\nexit 0\n") + started = time.monotonic() + with terminal(command=[script]) as process: + wait_for_output(process, "read-returned:") + assert wait_for_exit(process) == 0 + assert time.monotonic() - started < SHORT_TIMEOUT + + +def test_spawn_time_veof_leaves_no_trace(tmp_path): + """Echo is disabled around the injection, so a silent command stays byte-empty.""" + script = make_script(tmp_path, "silent", "exit 0\n") + with terminal(command=[script]) as process: + assert wait_for_exit(process) == 0 + deadline = time.monotonic() + 1.0 + while time.monotonic() < deadline: + time.sleep(0.05) + raw = process.read_raw_output() + decoded = process.read_output() + + assert raw == b"" + assert decoded == "" + + +def test_a_slow_silent_script_runs_to_completion_untouched(tmp_path): + """The regression guard against the rejected silence timer.""" + script = make_script(tmp_path, "slow_silent", "sleep 1.5\nprintf finished\nexit 0\n") + with terminal(command=[script]) as process: + wait_for_output(process, "finished") + assert wait_for_exit(process) == 0 + + +def arm_fault(process, method, error=None): + """Wraps one reader entry point so a failure can be injected at a chosen moment.""" + real = getattr(process, method) + state = {"armed": False, "calls": 0} + + def faulty(*args, **kwargs): + state["calls"] += 1 + if state["armed"]: + raise error if error is not None else OSError(errno.EBADF, "injected reader failure") + return real(*args, **kwargs) + + setattr(process, method, faulty) + return state + + +def open_fd_count(): + return len(os.listdir("/dev/fd")) + + +def test_close_is_idempotent_and_survives_a_partial_spawn(monkeypatch): + baseline = open_fd_count() + + monkeypatch.setattr( + _posix_pty.subprocess, "Popen", lambda *a, **k: (_ for _ in ()).throw(OSError(2, "no interpreter")) + ) + process = _posix_pty.PosixPtyProcess() + with pytest.raises(TerminalEnvironmentError): + process.spawn(["/bin/sh", "-c", "exit 0"]) + process.close() + process.close() + + assert open_fd_count() == baseline + + +def test_failure_before_the_reader_starts_closes_the_parent_owned_descriptors(monkeypatch): + """No reader exists to close them, so spawn()'s except path has to.""" + baseline = open_fd_count() + monkeypatch.setattr( + _posix_pty.subprocess, "Popen", lambda *a, **k: (_ for _ in ()).throw(OSError(2, "no interpreter")) + ) + process = _posix_pty.PosixPtyProcess() + with pytest.raises(TerminalEnvironmentError): + process.spawn(["/bin/sh", "-c", "exit 0"]) + + assert process._bundle is not None + assert process._bundle.owner == "parent" + assert process._bundle.master_fd is None + assert process._bundle.wakeup_r is None + assert process._bundle.err_w is None + assert open_fd_count() == baseline + + +def test_closing_err_r_transfers_ownership_rather_than_sharing_it(tmp_path): + """A descriptor number is reusable the instant it is freed, so the field is swapped + to None before the close and only what the swap returned is closed.""" + process = _posix_pty.PosixPtyProcess() + unrelated = None + unrelated_path = tmp_path / "unrelated.txt" + try: + process.spawn(["/bin/sh", "-c", "sleep 5"]) + process._close_owned("_err_r") # the transfer the handshake performs on a reader edge + unrelated = os.open(str(unrelated_path), os.O_CREAT | os.O_RDWR, 0o600) + process.terminate_tree(grace=0.05) + process.close() + process.close() + os.write(unrelated, b"still mine") # close() must not have taken this number + finally: + if unrelated is not None: + os.close(unrelated) + process.close() + + assert unrelated_path.read_bytes() == b"still mine" + + +def test_descriptor_counts_are_stable_across_failing_spawns(tmp_path, monkeypatch): + """Covers the ack pair and the launcher's stderr as well as the reader bundle.""" + hanging = write_launcher(tmp_path, "hang_fd.py", "import time\ntime.sleep(120)\n") + process = _posix_pty.PosixPtyProcess() + try: + process.spawn(["/bin/sh", "-c", "exit 0"]) + wait_for_exit(process) + finally: + process.close() + + baseline = open_fd_count() + for _ in range(2): + failing = _posix_pty.PosixPtyProcess() + with pytest.raises(TerminalLaunchError): + failing.spawn(["/nonexistent/command/for/tests"]) + failing.close() + assert open_fd_count() == baseline + + monkeypatch.setattr(_posix_pty, "_LAUNCHER", hanging) + hung = _posix_pty.PosixPtyProcess() + with pytest.raises(TerminalLaunchError): + hung.spawn(["/bin/sh", "-c", "exit 0"], handshake_timeout=0.5) + hung.close() + monkeypatch.undo() + assert open_fd_count() == baseline + + +def test_a_launcher_that_floods_stderr_does_not_stall_the_handshake(tmp_path, monkeypatch): + flood = write_launcher( + tmp_path, + "flood.py", + "import os\n" + "payload = b'HEAD' + b'x' * (512 * 1024) + b'TAIL'\n" + "while payload:\n" + " payload = payload[os.write(2, payload):]\n" + "os._exit(3)\n", + ) + monkeypatch.setattr(_posix_pty, "_LAUNCHER", flood) + + process = _posix_pty.PosixPtyProcess() + started = time.monotonic() + try: + with pytest.raises(TerminalLaunchError) as failure: + process.spawn(["/bin/sh", "-c", "exit 0"], handshake_timeout=SPAWN_TIMEOUT) + finally: + process.close() + + assert time.monotonic() - started < SPAWN_TIMEOUT + assert "the interpreter died before running the launcher" in str(failure.value) + diagnostic = process.launcher_stderr + assert diagnostic.total > 512 * 1024, "the flood was not read to completion" + text = diagnostic.text() + assert text.startswith("HEAD") and text.endswith("TAIL") + assert len(text) < 2 * _posix_pty.LAUNCHER_STDERR_CAP_BYTES + 128 + + +def test_escalation_is_driven_by_the_clock_not_by_the_leaders_exit(tmp_path): + """The leader dies on SIGTERM at once; the descendant that ignores it must still go.""" + script = make_script( + tmp_path, + "escalation", + "( trap '' TERM; printf 'descendant:%s\\n' \"$$\"; sleep 30 ) &\nsleep 30\n", + ) + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script]) + descendant = reported_pid(process, "descendant") + process.terminate_tree(grace=0.3) + finally: + process.close() + + assert wait_until_gone(descendant) + + +def test_teardown_tolerates_a_zombie_only_group(tmp_path): + """The graceful path: the leader has exited and only our unreaped zombie remains.""" + script = make_script(tmp_path, "quick", "printf bye\nexit 0\n") + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script]) + wait_for_output(process, "bye") + time.sleep(0.3) # let the leader exit without reaping it through poll() + process.terminate_tree(grace=0.1) + finally: + process.close() + + assert process._proc.returncode is not None # reaped despite the EPERM answer + + +def test_teardown_tolerates_a_permission_error_from_killpg(tmp_path, monkeypatch): + """macOS answers EPERM, not ESRCH, for a group holding only our zombie leader.""" + script = make_script(tmp_path, "quick_eperm", "sleep 30\n") + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script]) + + def denying_killpg(pgid, sig): + raise PermissionError(1, "Operation not permitted") + + monkeypatch.setattr(_posix_pty.os, "killpg", denying_killpg) + process.terminate_tree(grace=0.05) + monkeypatch.undo() + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert process._reaped + + +def test_the_grace_period_survives_cancellation(tmp_path): + """stop_event is already set when teardown begins, so the grace runs off its own clock.""" + script = make_script( + tmp_path, + "graceful", + "trap 'printf handled; exit 0' TERM\nprintf ready\nwhile true; do sleep 0.05; done\n", + ) + stop_event = threading.Event() + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script], stop_event=stop_event) + wait_for_output(process, "ready") + stop_event.set() + process.terminate_tree(grace=2.0) + collected = process.read_output() + finally: + process.close() + + assert "handled" in collected + assert process._proc.returncode == 0 + + +def test_an_exception_mid_grace_still_escalates(tmp_path): + script = make_script( + tmp_path, + "interrupted_grace", + "( trap '' TERM; printf 'descendant:%s\\n' \"$$\"; sleep 30 ) &\nsleep 30\n", + ) + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script]) + descendant = reported_pid(process, "descendant") + + def interrupting_tick(): + raise KeyboardInterrupt() + + process._grace_tick = interrupting_tick + # The subject is exception propagation, not liveness: pin the probe so the + # grace loop cannot break before the tick fires on a fast tree. + process._group_spent = lambda pgid: False + with pytest.raises(KeyboardInterrupt): + process.terminate_tree(grace=1.0) + finally: + process.close() + + assert wait_until_gone(descendant) + assert process._proc.returncode is not None + + +def test_a_reader_failure_during_teardown_still_escalates(tmp_path): + """Teardown records the error and runs the sequence to completion before reporting.""" + script = make_script( + tmp_path, + "reader_fault_grace", + "trap '' TERM\n( trap '' TERM; printf 'descendant:%s\\n' \"$$\"; sleep 30 ) &\nsleep 30\n", + ) + process = _posix_pty.PosixPtyProcess() + fault = arm_fault(process, "_select") + try: + process.spawn([script]) + descendant = reported_pid(process, "descendant") + + real_tick = process._grace_tick + + def failing_tick(): + fault["armed"] = True + real_tick() + + process._grace_tick = failing_tick + process._group_spent = lambda pgid: False # the fault must get its tick + process.terminate_tree(grace=0.5) + finally: + process.close() + + assert wait_until_gone(descendant) + assert process.reader_failed.is_set() + with pytest.raises(_posix_pty.TerminalReaderError) as failure: + process._check_reader_failed() + assert failure.value.exit_code == 69 + + +def test_a_reader_failure_during_the_handshake_aborts_it_promptly(): + process = _posix_pty.PosixPtyProcess() + fault = arm_fault(process, "_select") + fault["armed"] = True + started = time.monotonic() + try: + with pytest.raises(_posix_pty.TerminalReaderError) as failure: + process.spawn(["/bin/sh", "-c", "sleep 30"], handshake_timeout=SPAWN_TIMEOUT) + finally: + process.close() + + assert time.monotonic() - started < SPAWN_TIMEOUT # not at the deadline + assert failure.value.exit_code == 69 + assert process._bundle.master_fd is None and process._bundle.wakeup_r is None + assert process._bundle.err_w is None + assert process._proc.returncode is not None # the child was terminated + + +def test_a_failing_read_closes_the_descriptors_and_is_classified(tmp_path): + script = make_script(tmp_path, "chatty", "while true; do printf tick; sleep 0.05; done\n") + process = _posix_pty.PosixPtyProcess() + fault = arm_fault(process, "_read_master") + try: + process.spawn([script]) + wait_for_output(process, "tick") + fault["armed"] = True + deadline = time.monotonic() + SPAWN_TIMEOUT + while not process.reader_failed.is_set() and time.monotonic() < deadline: + time.sleep(0.02) + assert process.reader_failed.is_set() + with pytest.raises(_posix_pty.TerminalReaderError) as failure: + process._check_reader_failed() + process.terminate_tree(grace=0.05) + finally: + process.close() + + assert failure.value.exit_code == 69 + assert process._bundle.master_fd is None and process._bundle.wakeup_r is None + assert process._bundle.err_w is None + assert process._proc.returncode is not None + + +def test_a_failing_final_flush_is_published_with_err_w_closed_last(tmp_path): + script = make_script(tmp_path, "brief", "printf bye\nexit 0\n") + process = _posix_pty.PosixPtyProcess() + process._flush_decoder = lambda decoder: (_ for _ in ()).throw(RuntimeError("injected flush failure")) + try: + process.spawn([script]) + wait_for_output(process, "bye") + deadline = time.monotonic() + SPAWN_TIMEOUT + while not process.reader_failed.is_set() and time.monotonic() < deadline: + time.sleep(0.02) + finally: + process.close() + + assert process.reader_failed.is_set() + assert isinstance(process.reader_exc, RuntimeError) + assert process._bundle.err_w is None # closed last, after everything else was released + + +def test_the_veof_transaction_runs_on_the_reader_and_restores_the_terminal_mode(tmp_path): + script = make_script(tmp_path, "veof_owner", "sleep 5\n") + process = _posix_pty.PosixPtyProcess() + threads = [] + receipts = [] + real_prepare = process._veof_prepare + real_submit = process._input_queue.submit + + def recording_prepare(): + threads.append(threading.current_thread().name) + real_prepare() + + def recording_submit(*args, **kwargs): + result, receipt = real_submit(*args, **kwargs) + receipts.append(receipt) + return result, receipt + + process._veof_prepare = recording_prepare + process._input_queue.submit = recording_submit + try: + process.spawn([script]) + attributes = termios.tcgetattr(process._bundle.master_fd) # read-only probe + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert threads == ["codeplain-pty-reader"], "no parent helper may touch the raw master" + assert receipts and receipts[0].resolutions == 1 + assert attributes[3] & termios.ECHO, "the snapshot was not restored" + + +def test_a_failing_veof_snapshot_prevents_the_ack(): + process = _posix_pty.PosixPtyProcess() + process._veof_prepare = lambda: (_ for _ in ()).throw(OSError(errno.EIO, "injected snapshot failure")) + try: + with pytest.raises(TerminalEnvironmentError) as failure: + process.spawn(["/bin/sh", "-c", "printf ran"]) + finally: + process.close() + + assert failure.value.exit_code == 69 + assert not process._acked + assert process.read_raw_output() == b"" # the target never ran + + +def test_a_failing_veof_restore_is_attempted_and_reported(): + process = _posix_pty.PosixPtyProcess() + attempts = [] + + def failing_restore(): + attempts.append("restore") + raise OSError(errno.EIO, "injected restore failure") + + process._veof_restore = failing_restore + try: + with pytest.raises(TerminalEnvironmentError) as failure: + process.spawn(["/bin/sh", "-c", "printf ran"]) + finally: + process.close() + + assert attempts == ["restore"] # every path that changed the mode attempts the restore + assert failure.value.exit_code == 69 + assert not process._acked + + +def test_the_veof_survives_an_eagain_mid_item(tmp_path): + script = make_script(tmp_path, "veof_eagain", "read line\nprintf 'read-returned:%s\\n' \"$?\"\n") + process = _posix_pty.PosixPtyProcess() + real_write = process._write_master + state = {"blocked": False} + + def blocking_once(fd, data): + if not state["blocked"]: + state["blocked"] = True + raise BlockingIOError(errno.EAGAIN, "injected EAGAIN") + return real_write(fd, data) + + process._write_master = blocking_once + try: + process.spawn([script]) + wait_for_output(process, "read-returned:") + assert wait_for_exit(process) == 0 + finally: + process.close() + + assert state["blocked"] + + +def test_close_during_an_in_flight_fragmented_item_fails_its_receipt_once(tmp_path): + script = make_script(tmp_path, "fragmented_close", "sleep 10\n") + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script]) + real_write = process._write_master + state = {"calls": 0} + + def stalling_write(fd, data): + state["calls"] += 1 + if state["calls"] == 1: + return real_write(fd, data[:1]) + raise BlockingIOError(errno.EAGAIN, "held mid-item") + + process._write_master = stalling_write + payload = b"abcdef" + result, receipt = process._input_queue.submit(payload) + assert result.disposition is InputDisposition.ACCEPTED + process._ring_doorbell() + + deadline = time.monotonic() + SPAWN_TIMEOUT + while state["calls"] < 2 and time.monotonic() < deadline: + time.sleep(0.02) + assert state["calls"] >= 2 + # Dequeue is not completion: the retained cursor still counts against the cap. + assert process._input_queue.pending_bytes() == len(payload) + + process.close() + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert receipt.resolutions == 1 + assert receipt.disposition is InputDisposition.CLOSED + assert process._input_queue.pending_bytes() == 0 + + +def test_close_finishes_an_in_flight_compound_item_before_failing_its_receipt(tmp_path): + """An EAGAIN'd echo-suppressed item still has its transaction closed by teardown. + + Without that, a close during the spawn-time VEOF publishes CLOSED with the terminal + left in the mode `prepare` put it in. + """ + script = make_script(tmp_path, "compound_close", "sleep 10\n") + process = _posix_pty.PosixPtyProcess() + prepared = threading.Event() + finished = [] + + def prepare(): + process._veof_prepare() + prepared.set() + + def finish(): + process._veof_restore() + finished.append(termios.tcgetattr(process._bundle.master_fd)) # the reader still owns it + + try: + process.spawn([script]) + + def held_write(fd, data): + raise BlockingIOError(errno.EAGAIN, "held mid-item") + + process._write_master = held_write + result, receipt = process._input_queue.submit(b"\x04", reserved=True, prepare=prepare, finish=finish) + assert result.disposition is InputDisposition.ACCEPTED + process._ring_doorbell() + + assert prepared.wait(SPAWN_TIMEOUT) + assert not termios.tcgetattr(process._bundle.master_fd)[3] & termios.ECHO + process.close() + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert len(finished) == 1, "the in-flight transaction was never closed" + assert finished[0][3] & termios.ECHO, "the terminal mode was not restored" + assert receipt.resolutions == 1 + assert receipt.disposition is InputDisposition.CLOSED + assert process._input_queue.pending_bytes() == 0 + + +def test_a_saturated_doorbell_is_only_a_coalesced_notification(tmp_path): + script = make_script(tmp_path, "doorbell", "sleep 10\n") + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script]) + while True: # fill the doorbell to EAGAIN + try: + os.write(process._wakeup_w, b"\x01" * 4096) + except BlockingIOError: + break + + result = process.write_input(b"after saturation\n") + assert result.disposition is InputDisposition.ACCEPTED + + started = time.monotonic() + process.close() + elapsed = time.monotonic() - started + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert elapsed < _posix_pty.DRAIN_DEADLINE_SECONDS + SHORT_TIMEOUT + assert process._input_queue.pending_bytes() == 0 + + +def test_a_fragmented_logical_write_keeps_its_suffix_ahead_of_later_items(tmp_path): + """The public result stays whole-item; no PARTIAL and no interleaving escape.""" + script = make_script(tmp_path, "ordering", "sleep 10\n") + process = _posix_pty.PosixPtyProcess() + written = [] + released = threading.Event() + try: + process.spawn([script]) + real_write = process._write_master + state = {"held": False} + + def fragmenting_write(fd, data): + count = real_write(fd, data[:2]) + written.append(data[:count]) + if not state["held"]: + state["held"] = True + released.wait(SHORT_TIMEOUT) # hold the reader inside the first item + return count + + process._write_master = fragmenting_write + first = process.write_input(b"AAAAAAAA") + deadline = time.monotonic() + SPAWN_TIMEOUT + while not state["held"] and time.monotonic() < deadline: + time.sleep(0.02) + second = process.write_input(b"BBBB") + third = process.write_input(b"CCCC") + released.set() + + deadline = time.monotonic() + SPAWN_TIMEOUT + while process._input_queue.has_pending() and time.monotonic() < deadline: + time.sleep(0.02) + finally: + released.set() + process.terminate_tree(grace=0.05) + process.close() + + assert [r.disposition for r in (first, second, third)] == [InputDisposition.ACCEPTED] * 3 + assert [r.accepted_bytes for r in (first, second, third)] == [8, 4, 4] + assert b"".join(written) == b"AAAAAAAABBBBCCCC" + + +def test_the_final_drain_is_bounded_against_a_continuously_writing_escapee(tmp_path): + escapee = tmp_path / "escapee.py" + escapee.write_text( + "import os, sys, time\n" + "os.setpgid(0, 0)\n" + "sys.stdout.write('escapee:%d\\n' % os.getpid())\n" + "sys.stdout.flush()\n" + "end = time.monotonic() + 30\n" + "while time.monotonic() < end:\n" + " sys.stdout.write('x' * 4096)\n" + " sys.stdout.flush()\n" + ) + script = make_script(tmp_path, "escaper", f'"{sys.executable}" "{escapee}" &\nsleep 30\n') + + process = _posix_pty.PosixPtyProcess() + escapee_pid = None + try: + process.spawn([script]) + escapee_pid = reported_pid(process, "escapee") + # Both channels clear on read; accumulate so the assertions see the whole + # transcript. Waiting for the flood also guarantees the escapee is still + # writing while close() drains, which is the bound under test. + decoded = wait_for_output(process, "x") + raw = process.read_raw_output() + process.terminate_tree(grace=0.1) # the escapee left the group and survives + started = time.monotonic() + process.close() + elapsed = time.monotonic() - started + decoded += process.read_output() + raw += process.read_raw_output() + finally: + process.close() + if escapee_pid is not None and not wait_until_gone(escapee_pid, timeout=0.5): + os.kill(escapee_pid, signal.SIGKILL) + + assert elapsed < _posix_pty.DRAIN_DEADLINE_SECONDS + SHORT_TIMEOUT + assert "x" in decoded, "what the drain retained must reach the decoded channel too" + assert b"x" in raw + + +def test_output_in_flight_at_close_reaches_the_decoded_channel(tmp_path): + """The reader is parked, so the marker can only be picked up by the final drain.""" + script = make_script(tmp_path, "inflight", "printf 'inflight-marker\\n'\nsleep 10\n") + process = _posix_pty.PosixPtyProcess() + parked = {"calls": 0} + + def parked_read_once(master_fd, decoder): + parked["calls"] += 1 # the master is readable, but the bytes stay in the terminal + time.sleep(0.02) + return True + + process._read_once = parked_read_once + try: + process.spawn([script]) + deadline = time.monotonic() + SPAWN_TIMEOUT + while parked["calls"] < 2 and time.monotonic() < deadline: + time.sleep(0.02) + assert parked["calls"] >= 2, "the target never wrote anything" + process.close() + decoded = process.read_output() + raw = process.read_raw_output() + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert "inflight-marker" in decoded + assert b"inflight-marker" in raw + + +def test_write_input_after_the_reader_closed_the_master_touches_nothing(tmp_path): + unrelated_path = tmp_path / "unrelated.txt" + process = _posix_pty.PosixPtyProcess() + unrelated = None + try: + process.spawn(["/bin/sh", "-c", "printf bye"]) + assert wait_for_exit(process) == 0 + deadline = time.monotonic() + SPAWN_TIMEOUT + while process._bundle.master_fd is not None and time.monotonic() < deadline: + time.sleep(0.02) + assert process._bundle.master_fd is None + process.close() + + unrelated = os.open(str(unrelated_path), os.O_CREAT | os.O_RDWR, 0o600) + result = process.write_input(b"nowhere") + os.write(unrelated, b"untouched") + finally: + if unrelated is not None: + os.close(unrelated) + process.close() + + assert result.disposition is InputDisposition.CLOSED + assert result.accepted_bytes == 0 + assert unrelated_path.read_bytes() == b"untouched" + + +def test_a_jumping_wall_clock_changes_nothing(monkeypatch, tmp_path): + """Every budget is monotonic; wall time is only ever a human timestamp.""" + assert "time.time(" not in Path(_posix_pty.__file__).read_text() + + jumps = iter([10_000.0, -10_000.0]) + real_time = time.time + + def jumping_time(): + try: + return real_time() + next(jumps) + except StopIteration: + return real_time() + + monkeypatch.setattr(time, "time", jumping_time) + script = make_script(tmp_path, "clock", "printf steady\nsleep 30\n") + started = time.monotonic() + process = _posix_pty.PosixPtyProcess() + try: + process.spawn([script]) + wait_for_output(process, "steady") + assert process.poll() is None # not terminated early + process.terminate_tree(grace=0.2) + finally: + process.close() + + assert time.monotonic() - started < SPAWN_TIMEOUT + assert process._proc.returncode is not None + + +def test_the_interpreter_exits_while_the_reader_and_the_reaper_are_still_blocked(tmp_path): + """Every thread this design starts is a daemon; a non-daemon one hangs shutdown. + + The target outlives the outer bound by a wide margin, so the reader is still blocked + on the master when the bound expires: only daemon threads let the interpreter exit + inside it. + """ + driver = tmp_path / "daemon_threads.py" + driver.write_text( + "import subprocess, sys, threading\n" + f"sys.path.insert(0, {str(REPO_ROOT)!r})\n" + "from render_machine import _posix_pty\n" + "never_set = threading.Event()\n" + "class StuckProc:\n" + " pid = -1\n" + " def wait(self, timeout=None):\n" + " if timeout is not None:\n" + " raise subprocess.TimeoutExpired('stuck', timeout)\n" + " never_set.wait()\n" + "process = _posix_pty.PosixPtyProcess()\n" + "process.spawn(['/bin/sh', '-c', 'sleep 300'])\n" + "_posix_pty._reap(StuckProc(), 0.01)\n" + "assert process._reader.is_alive()\n" + "sys.stdout.write('ready:%d\\n' % process._pgid)\n" + "sys.stdout.flush()\n" + ) + started = time.monotonic() + completed = subprocess.run([sys.executable, str(driver)], capture_output=True, text=True, timeout=SPAWN_TIMEOUT) + elapsed = time.monotonic() - started + + assert "ready:" in completed.stdout, completed.stderr + target_pgid = int(completed.stdout.split("ready:", 1)[1].split()[0]) + try: + assert completed.returncode == 0 + assert elapsed < SPAWN_TIMEOUT + finally: # the driver exits without terminating its target + try: + os.killpg(target_pgid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + + +def framed(kind, payload=b""): + return bytes([kind]) + len(payload).to_bytes(4, "big") + payload + + +def test_the_handshake_parser_accepts_only_started_then_session_ready_then_eof(): + parser = _posix_pty._HandshakeParser() + parser.feed(framed(pty_exec.STARTED)) + parser.feed(framed(pty_exec.SESSION_READY)) + assert parser.session_ready + parser.eof() # the only success case + + +@pytest.mark.parametrize( + "chunks", + [ + [bytes([0x7F]) + (0).to_bytes(4, "big")], # unknown record type + [bytes([pty_exec.STARTED]) + (pty_exec.MAX_PAYLOAD + 1).to_bytes(4, "big")], # oversized length + [framed(pty_exec.STARTED, b"payload")], # a marker carrying a payload + [framed(pty_exec.SESSION_READY)], # SESSION_READY before STARTED + [framed(pty_exec.STARTED), framed(pty_exec.STARTED)], # duplicate marker + [framed(pty_exec.STARTED), framed(pty_exec.SESSION_READY), framed(pty_exec.SESSION_READY)], + [framed(pty_exec.STARTED), framed(pty_exec.FAILED, b"boom"), b"trailing"], + ], +) +def test_the_handshake_parser_rejects_malformed_frames(chunks): + """An oversized length is rejected before its body is allocated or waited for.""" + parser = _posix_pty._HandshakeParser() + with pytest.raises(_posix_pty._ProtocolError): + for chunk in chunks: + parser.feed(chunk) + + +@pytest.mark.parametrize( + "chunks", + [ + [framed(pty_exec.STARTED), b"\x02\x00"], # truncated header at EOF + [framed(pty_exec.STARTED), bytes([pty_exec.FAILED]) + (8).to_bytes(4, "big") + b"half"], + [framed(pty_exec.STARTED)], # EOF after only STARTED + [], # EOF with no marker at all + ], +) +def test_the_handshake_parser_rejects_incomplete_streams_at_eof(chunks): + parser = _posix_pty._HandshakeParser() + for chunk in chunks: + parser.feed(chunk) + with pytest.raises(_posix_pty._ProtocolError): + parser.eof() + + +def test_the_handshake_parser_reassembles_fragmented_records(): + parser = _posix_pty._HandshakeParser() + stream = framed(pty_exec.STARTED) + framed(pty_exec.SESSION_READY) + for index in range(len(stream)): + parser.feed(stream[index : index + 1]) + assert parser.started and parser.session_ready + parser.eof() diff --git a/tests/test_terminal_queries.py b/tests/test_terminal_queries.py new file mode 100644 index 00000000..3ca4aa90 --- /dev/null +++ b/tests/test_terminal_queries.py @@ -0,0 +1,502 @@ +"""Tests for the live terminal query responder. + +The responder cases are platform-neutral and run everywhere. The backend cases spawn a real +target on a real pseudoterminal, so they are POSIX-only; every boundary they assert is +driven through a hook, never through a sleep. +""" + +import sys +import threading +import time +from pathlib import Path + +import pytest + +from render_machine.output_normalizer import QUERY_CURSOR_POSITION, QUERY_DEVICE_ATTRIBUTES, QUERY_DEVICE_STATUS +from render_machine.terminal_process import InputDisposition, InputWriteResult +from render_machine.terminal_queries import ( + MAX_TRACKED_FAILURES, + ResponderState, + TerminalQueryResponder, + reply_resolution, +) + +posix_only = pytest.mark.skipif(sys.platform == "win32", reason="The POSIX PTY backend is not built on Windows.") + +if sys.platform != "win32": + from render_machine import _posix_pty + +SPAWN_TIMEOUT = 20.0 + + +class _Admissions: + """Records every admission and hands back the completion callback.""" + + def __init__(self, immediate_reason=None, raises=None): + self.payloads = [] + self.completions = [] + self._immediate_reason = immediate_reason + self._raises = raises + + def __call__(self, payload, on_complete): + self.payloads.append(payload) + if self._raises is not None: + raise self._raises + if self._immediate_reason is not None: + on_complete(self._immediate_reason) + return + self.completions.append(on_complete) + + +def test_a_responder_without_an_input_channel_starts_quiesced(): + """The legacy backend has nowhere to write a reply, so a query creates no obligation.""" + responder = TerminalQueryResponder() + + responder.answer(QUERY_CURSOR_POSITION, b"\x1b[1;1R") + + assert responder.state is ResponderState.QUIESCED + assert responder.reply_failed is False + assert responder.render_only == 1 + assert responder.admitted == 0 + + +def test_an_admitted_reply_that_completes_leaves_no_failure(): + admissions = _Admissions() + responder = TerminalQueryResponder(admissions) + + responder.answer(QUERY_CURSOR_POSITION, b"\x1b[1;1R") + assert responder.outstanding == 1 + admissions.completions[0](None) + + assert admissions.payloads == [b"\x1b[1;1R"] + assert responder.reply_failed is False + assert responder.outstanding == 0 + + +def test_immediate_admission_pressure_records_the_kind_and_the_reason(): + admissions = _Admissions(immediate_reason="discarded before delivery (backpressure)") + responder = TerminalQueryResponder(admissions) + + responder.answer(QUERY_DEVICE_STATUS, b"\x1b[0n") + + assert responder.reply_failed is True + assert [(failure.kind, failure.reason) for failure in responder.failures] == [ + (QUERY_DEVICE_STATUS, "discarded before delivery (backpressure)") + ] + assert responder.outstanding == 0 + + +def test_an_admission_that_raises_is_recorded_rather_than_propagated(): + """The reader feeds the parser; a reply must never be able to take it down.""" + responder = TerminalQueryResponder(_Admissions(raises=RuntimeError("no channel"))) + + responder.answer(QUERY_DEVICE_ATTRIBUTES, b"\x1b[?6c") + + assert responder.reply_failed is True + assert "admission raised" in responder.failures[0].reason + assert responder.outstanding == 0 + + +def test_a_reply_admitted_while_active_still_reports_after_quiescence(): + admissions = _Admissions() + responder = TerminalQueryResponder(admissions) + + responder.answer(QUERY_CURSOR_POSITION, b"\x1b[1;1R") + responder.quiesce() + admissions.completions[0]("discarded before delivery (closed)") + + assert responder.reply_failed is True + assert responder.failures[0].kind == QUERY_CURSOR_POSITION + + +def test_a_query_first_seen_after_quiescence_renders_and_records_nothing(): + admissions = _Admissions() + responder = TerminalQueryResponder(admissions) + + responder.quiesce() + responder.answer(QUERY_CURSOR_POSITION, b"\x1b[1;1R") + + assert admissions.payloads == [] + assert responder.render_only == 1 + assert responder.reply_failed is False + + +def test_quiescing_from_inside_an_admission_keeps_that_obligation(): + """The lock linearizes the two: a callback admits while active, or observes quiescence.""" + responder = TerminalQueryResponder() + completions = [] + + def admit(payload, on_complete): + responder.quiesce() # the transition cannot interleave with this callback + completions.append(on_complete) + + responder._admit = admit + responder._state = ResponderState.ACTIVE + + responder.answer(QUERY_CURSOR_POSITION, b"\x1b[2;3R") + responder.answer(QUERY_DEVICE_STATUS, b"\x1b[0n") # after the transition: render-only + completions[0]("write failed: OSError(5)") + + assert responder.render_only == 1 + assert [failure.kind for failure in responder.failures] == [QUERY_CURSOR_POSITION] + + +def test_quiesce_is_idempotent(): + responder = TerminalQueryResponder(_Admissions()) + + responder.quiesce() + responder.quiesce() + + assert responder.state is ResponderState.QUIESCED + + +def test_a_completion_resolves_its_obligation_exactly_once(): + admissions = _Admissions() + responder = TerminalQueryResponder(admissions) + + responder.answer(QUERY_CURSOR_POSITION, b"\x1b[1;1R") + admissions.completions[0]("write failed: OSError(5)") + admissions.completions[0]("discarded before delivery (closed)") + + assert len(responder.failures) == 1 + + +def test_failure_detail_names_every_query_kind_and_reason(): + responder = TerminalQueryResponder(_Admissions(immediate_reason="discarded before delivery (backpressure)")) + + responder.answer(QUERY_CURSOR_POSITION, b"\x1b[1;1R") + responder.answer(QUERY_DEVICE_STATUS, b"\x1b[0n") + + detail = responder.failure_detail() + assert QUERY_CURSOR_POSITION in detail and QUERY_DEVICE_STATUS in detail + assert detail.count("backpressure") == 2 + + +def test_a_repeated_failure_is_counted_once_and_summarized(): + """A target that queries in a loop against a closed channel must not grow the history.""" + admissions = _Admissions(immediate_reason="discarded before delivery (closed)") + responder = TerminalQueryResponder(admissions) + + for _ in range(5000): + responder.answer(QUERY_CURSOR_POSITION, b"\x1b[1;1R") + + assert responder.failures_recorded == 5000 + assert len(responder.failures) == 1 # one kind, one reason + detail = responder.failure_detail() + assert "4999 further reply failures" in detail + assert len(detail) < 200 + + +def test_distinct_failure_reasons_are_sampled_rather_than_accumulated(): + """Reasons carry exception text, so distinctness cannot be an excuse to keep them all.""" + admissions = _Admissions() + responder = TerminalQueryResponder(admissions) + + for _ in range(2000): + responder.answer(QUERY_DEVICE_STATUS, b"\x1b[0n") + for index, complete in enumerate(admissions.completions): + complete(f"write failed: OSError({index})") + + assert responder.failures_recorded == 2000 + assert len(responder.failures) == MAX_TRACKED_FAILURES + assert responder.outstanding == 0 + assert len(responder.failure_detail()) < 2000 + + +def run_racing(first, second, reversed_order: bool) -> None: + """Releases both threads together, from a third one, so neither is ahead by construction.""" + go = threading.Event() + threads = [threading.Thread(target=lambda call=call: (go.wait(), call())) for call in (first, second)] + if reversed_order: # started in both orders, since the starter is itself a head start + threads.reverse() + for thread in threads: + thread.start() + go.set() + for thread in threads: + thread.join(SPAWN_TIMEOUT) + assert not thread.is_alive() + + +def test_a_query_racing_quiescence_is_admitted_or_render_only_but_never_both(): + """Two threads, one lock: the callback either admits while active or observes the switch.""" + outcomes = {"admitted": 0, "render_only": 0} + for attempt in range(200): + admissions = _Admissions() + responder = TerminalQueryResponder(admissions) + + run_racing( + lambda: responder.answer(QUERY_CURSOR_POSITION, b"\x1b[1;1R"), + responder.quiesce, + reversed_order=bool(attempt % 2), + ) + + assert responder.state is ResponderState.QUIESCED + assert responder.admitted + responder.render_only == 1 + assert responder.outstanding == responder.admitted + for complete in admissions.completions: + complete("discarded before delivery (closed)") + assert responder.outstanding == 0 + assert responder.reply_failed is bool(responder.admitted) + outcomes["admitted"] += responder.admitted + outcomes["render_only"] += responder.render_only + + assert min(outcomes.values()) > 0, f"the race never went both ways: {outcomes}" + + +def test_a_completion_racing_teardown_resolves_its_obligation_exactly_once(): + """Teardown discards while the backend reports the write: one obligation, one record.""" + for attempt in range(200): + admissions = _Admissions() + responder = TerminalQueryResponder(admissions) + responder.answer(QUERY_CURSOR_POSITION, b"\x1b[1;1R") + complete = admissions.completions[0] + + def teardown(): + responder.quiesce() + complete("discarded before delivery (closed)") + + run_racing( + lambda: complete("write failed: OSError(5)"), + teardown, + reversed_order=bool(attempt % 2), + ) + + assert responder.failures_recorded == 1 + assert len(responder.failures) == 1 + assert responder.failures[0].kind == QUERY_CURSOR_POSITION + assert responder.outstanding == 0 + + +# --------------------------------------------------------------- backend integration + + +def write_target(tmp_path: Path, name: str, source: str) -> str: + path = tmp_path / name + path.write_text(source) + return str(path) + + +# Switches to noncanonical, no-echo mode first, exactly as a real query emitter does: in +# canonical mode the newline-less reply never satisfies read(), and with echo on the reply +# bytes would land in the raw transcript. +READS_THE_REPLY = """ +import os +import sys +import termios + +fd = sys.stdin.fileno() +saved = termios.tcgetattr(fd) +raw = termios.tcgetattr(fd) +raw[3] &= ~(termios.ICANON | termios.ECHO) +raw[6][termios.VMIN] = 1 +raw[6][termios.VTIME] = 0 +termios.tcsetattr(fd, termios.TCSANOW, raw) +try: + sys.stdout.write("\\x1b[6n") + sys.stdout.flush() + reply = b"" + while not reply.endswith(b"R"): + chunk = os.read(fd, 1) + if not chunk: + sys.stdout.write("no reply\\n") + sys.stdout.flush() + raise SystemExit(3) + reply += chunk +finally: + termios.tcsetattr(fd, termios.TCSANOW, saved) + +reply = reply[reply.index(b"\\x1b") :] # the spawn-time EOF byte is still queued ahead of it +row, column = reply[2:-1].split(b";") +sys.stdout.write("answered row %s column %s\\n" % (row.decode(), column.decode())) +sys.stdout.flush() +""" + +# Emits the query and carries on without waiting for it, which is what leaves the reply to +# fail on its own timeline. +ABANDONS_THE_REPLY = """ +import sys + +sys.stdout.write("\\x1b[6n") +sys.stdout.flush() +sys.stdout.write("carried on\\n") +sys.stdout.flush() +""" + + +def run_target(script, **spawn_kwargs): + """Spawns a target, drains it to exit, and always tears it down.""" + process = _posix_pty.PosixPtyProcess() + process.spawn([sys.executable, script], **spawn_kwargs) + return process + + +def drain_to_exit(process, timeout=SPAWN_TIMEOUT): + deadline = time.monotonic() + timeout + raw = bytearray() + while time.monotonic() < deadline: + raw += process.read_raw_output() + returncode = process.poll() + if returncode is not None: + raw += process.read_raw_output() + return returncode, bytes(raw) + time.sleep(0.01) + raise AssertionError(f"the target did not exit within {timeout}s; output so far {bytes(raw)!r}") + + +@posix_only +def test_a_live_cursor_position_query_is_answered_and_the_target_completes(tmp_path): + """The reply reaches a target that is blocked reading it, so it completes, not times out.""" + script = write_target(tmp_path, "reads_the_reply.py", READS_THE_REPLY) + process = run_target(script) + caller_writes = [] + original_write_input = process.write_input + process.write_input = lambda data: caller_writes.append(data) or original_write_input(data) + try: + returncode, raw = drain_to_exit(process) + normalized = process.normalized_output() + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert returncode == 0 + assert "answered row 1 column 1" in normalized + assert process.query_responder.admitted == 1 + assert process.terminal_reply_failed is False + # The reply is terminal protocol: it is not caller input and it is in neither transcript. + assert caller_writes == [] + assert b"\x1b[1;1R" not in raw + assert "\x1b" not in normalized + + +@posix_only +def test_immediate_reply_pressure_is_recorded_without_stalling_the_reader(tmp_path): + script = write_target(tmp_path, "abandons_the_reply.py", ABANDONS_THE_REPLY) + process = _posix_pty.PosixPtyProcess() + original_submit = process._input_queue.submit + + def rejecting_submit(data, reserved=False, prepare=None, finish=None, on_resolve=None): + if not data.startswith(b"\x1b"): # the spawn-time EOF still goes through + return original_submit(data, reserved=reserved, prepare=prepare, finish=finish, on_resolve=on_resolve) + receipt = _posix_pty._Receipt(on_resolve) + receipt.resolve(InputDisposition.BACKPRESSURE) + return InputWriteResult(InputDisposition.BACKPRESSURE, 0), receipt + + process._input_queue.submit = rejecting_submit + try: + process.spawn([sys.executable, script]) + returncode, _ = drain_to_exit(process) + normalized = process.normalized_output() + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert returncode == 0 + assert "carried on" in normalized, "the reader kept draining after the reply was refused" + assert process.terminal_reply_failed is True + assert process.query_responder.failures[0].kind == QUERY_CURSOR_POSITION + assert "backpressure" in process.terminal_reply_detail() + assert process.reader_failed.is_set() is False + + +@posix_only +def test_a_reply_discarded_at_teardown_still_records_a_failure(tmp_path): + """Admitted while ACTIVE, so the obligation survives the transition teardown makes.""" + script = write_target(tmp_path, "abandons_the_reply.py", ABANDONS_THE_REPLY) + process = _posix_pty.PosixPtyProcess() + original_flush = process._flush_input + + def stall_replies(master_fd, budget): + item = process._input_queue.current() + if item is not None and item.data.startswith(b"\x1b"): + return # a reply never reaches the fd; the spawn-time EOF still does + original_flush(master_fd, budget) + + # Installed before the spawn, so no reply can complete before the stall is in place. + process._flush_input = stall_replies + try: + process.spawn([sys.executable, script]) + drain_to_exit(process) + assert process.query_responder.admitted == 1 + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert process.query_responder.state is ResponderState.QUIESCED + assert process.terminal_reply_failed is True + assert process.query_responder.failures[0].kind == QUERY_CURSOR_POSITION + assert "discarded before delivery" in process.terminal_reply_detail() + + +@posix_only +def test_a_reply_that_fails_its_native_write_is_recorded_separately_from_the_reader(tmp_path): + script = write_target(tmp_path, "abandons_the_reply.py", ABANDONS_THE_REPLY) + process = _posix_pty.PosixPtyProcess() + original_write = process._write_master + + def failing_write(fd, data): + if data.startswith(b"\x1b"): + raise OSError(5, "injected write failure") + return original_write(fd, data) + + process._write_master = failing_write + try: + process.spawn([sys.executable, script]) + deadline = time.monotonic() + SPAWN_TIMEOUT + while time.monotonic() < deadline and not process.terminal_reply_failed: + time.sleep(0.01) + finally: + process.terminate_tree(grace=0.05) + process.close() + + assert process.terminal_reply_failed is True + failure = process.query_responder.failures[0] + assert failure.kind == QUERY_CURSOR_POSITION + assert "write failed" in failure.reason + assert process.reader_failed.is_set() is True # an independent signal, not the same one + + +@posix_only +def test_a_query_seen_only_after_quiescence_renders_and_records_nothing(tmp_path): + script = write_target(tmp_path, "abandons_the_reply.py", ABANDONS_THE_REPLY) + process = run_target(script) + try: + drain_to_exit(process) # poll() observed the outcome, so the responder is quiesced + assert process.query_responder.state is ResponderState.QUIESCED + admitted_before = process.query_responder.admitted + + process.normalizer.feed(b"\x1b[6ntrailing frame\r\n") # the reader's byte-feed hook + + assert process.query_responder.admitted == admitted_before + assert process.query_responder.render_only == 1 + assert process.terminal_reply_failed is False + assert "trailing frame" in process.normalized_output() + finally: + process.terminate_tree(grace=0.05) + process.close() + + +# ------------------------------------------------------------- reply resolution +# +# One queue resolution, mapped onto the responder's delivered / not-delivered contract. +# Both backends admit their replies through it. + + +def test_a_delivered_reply_reports_no_reason(): + reasons = [] + reply_resolution(reasons.append)(InputDisposition.ACCEPTED, None) + + assert reasons == [None] + + +def test_a_failed_reply_reports_the_write_failure(): + reasons = [] + reply_resolution(reasons.append)(InputDisposition.ACCEPTED, OSError("gone")) + + assert "write failed" in reasons[0] + + +def test_a_discarded_reply_reports_the_disposition(): + reasons = [] + reply_resolution(reasons.append)(InputDisposition.CLOSED, None) + + assert "discarded" in reasons[0] and "closed" in reasons[0] 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 diff --git a/tests/test_unit_strategy_switch.py b/tests/test_unit_strategy_switch.py new file mode 100644 index 00000000..6a652587 --- /dev/null +++ b/tests/test_unit_strategy_switch.py @@ -0,0 +1,138 @@ +"""Tests for giving up on patching when the unit fix loop stops making progress. + +The unit loop has the same problem as the conformance one and a different remedy: its +escape hatch restarts the functionality from scratch rather than discarding a test file. +That is destructive enough that only one kind of evidence justifies it. Repetition does: +across three renders every healthy functionality finished with `unit_max_repeat=1`, and +nothing was observed between that and the 17 reached by the one that wedged, so a streak +of three is a state healthy renders do not enter. + +A run of failures that are merely consecutive does not. That arm was applied here too at +first, calibrated on conformance recoveries because no unit-loop equivalent existed, and +it fired on loops that were working: `unit=7 unit_failed=7 unit_max_repeat=1` in two +renders, both restarted, both 0/10, against 2-3 for every render without a restart. It now +applies to the conformance loop only. +""" + +from unittest.mock import MagicMock, patch + +import render_machine.render_context as render_context_module +from render_machine.fix_loop_metrics import ( + CONFORMANCE_LOOP, + CONSECUTIVE_FAILURE_THRESHOLD, + STRATEGY_SWITCH_PREFIX, + UNIT_LOOP, + FixLoopMetrics, +) +from render_machine.render_context import MAX_UNITTEST_FIX_ATTEMPTS, RenderContext + +MODULE = "vault_cli" +FRID = "2" + + +def context(identical_failures=0, attempts=1, output="AssertionError: vault not initialized"): + instance = MagicMock(spec=RenderContext) + instance.module_name = MODULE + instance.fix_loop_metrics = FixLoopMetrics() + instance.frid_context = MagicMock() + instance.frid_context.frid = FRID + instance.unit_tests_running_context = MagicMock() + instance.unit_tests_running_context.fix_attempts = attempts + for _ in range(identical_failures): + instance.fix_loop_metrics.record(UNIT_LOOP, module=MODULE, frid=FRID, passed=False, output=output) + return instance + + +def gave_up(instance): + """Whether start_fixing_unit_tests reached the give-up handler.""" + on_limit_exceeded = MagicMock() + with patch.object(render_context_module, "console"): + RenderContext.start_fixing_unit_tests(instance, on_limit_exceeded) + return on_limit_exceeded.called + + +def test_a_unit_loop_repeating_a_failure_three_times_gives_up_early(): + assert gave_up(context(identical_failures=3)) is True + + +def test_two_repeats_are_left_alone(): + """No healthy functionality in the benchmark data ever reached two, so this is + already past normal — but the remedy discards the whole functionality, so it waits + for the same evidence the conformance side does.""" + assert gave_up(context(identical_failures=2)) is False + + +def test_a_healthy_loop_is_left_alone(): + assert gave_up(context(identical_failures=0)) is False + + +def test_a_unit_loop_failing_every_time_but_differently_is_left_alone(): + """Measured, not cautious. Two renders showed `unit=7 unit_failed=7 + unit_max_repeat=1` — seven failures, none alike — and both had their functionality + restarted and scored 0/10, where every render without a restart scored 2-3. Seven + different failures is a loop working through issues one at a time, and restarting + discards all of it. + + The conformance loop keeps this arm; there, failing every time really does mean + stuck, and it is what catches a 40-out-of-40 run whose longest identical streak is + two.""" + instance = context() + for index in range(CONSECUTIVE_FAILURE_THRESHOLD * 2): + instance.fix_loop_metrics.record( + UNIT_LOOP, module=MODULE, frid=FRID, passed=False, output=f"failure number {index}" + ) + + assert gave_up(instance) is False + + +def test_the_attempt_limit_still_catches_a_unit_loop_that_never_repeats(): + """Removing the arm does not make such a loop run forever: it stops where it always + did, at the attempt limit.""" + instance = context(attempts=MAX_UNITTEST_FIX_ATTEMPTS) + for index in range(CONSECUTIVE_FAILURE_THRESHOLD * 2): + instance.fix_loop_metrics.record( + UNIT_LOOP, module=MODULE, frid=FRID, passed=False, output=f"failure number {index}" + ) + + assert gave_up(instance) is True + + +def test_a_stuck_conformance_loop_does_not_restart_the_functionality(): + """The conformance loop has its own, far cheaper remedy; it must not reach this one.""" + instance = context() + for _ in range(8): + instance.fix_loop_metrics.record(CONFORMANCE_LOOP, module=MODULE, frid=FRID, passed=False, output="same") + + assert gave_up(instance) is False + + +def test_the_attempt_limit_still_ends_the_loop_on_its_own(): + """The streak arm is an early exit, not a replacement: a loop that never repeats and + never accumulates enough consecutive failures still stops at the limit.""" + assert gave_up(context(attempts=MAX_UNITTEST_FIX_ATTEMPTS)) is True + + +def test_the_early_exit_is_announced_in_a_greppable_form(): + instance = context(identical_failures=4) + + with patch.object(render_context_module, "console") as console: + RenderContext.start_fixing_unit_tests(instance, MagicMock()) + + announced = console.warning.call_args[0][0] + assert STRATEGY_SWITCH_PREFIX in announced + assert f"module={MODULE}" in announced + assert f"frid={FRID}" in announced + assert "loop=unit" in announced + assert "repeated_failure streak=4" in announced + assert "action=give_up_on_patching" in announced + + +def test_hitting_the_attempt_limit_is_not_announced_as_a_switch(): + """The limit path is ordinary exhaustion, not a decision the loop made about its own + progress; labelling it a strategy switch would inflate every benchmark count.""" + instance = context(attempts=MAX_UNITTEST_FIX_ATTEMPTS) + + with patch.object(render_context_module, "console") as console: + RenderContext.start_fixing_unit_tests(instance, MagicMock()) + + assert not console.warning.called