From 340fd411c6beb31f60d7a266f271b9e6b621e88b Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Mon, 19 Oct 2026 22:24:31 -0400 Subject: [PATCH 1/2] ci(unit-tests): cap the size of files tests write on every pytest step (#1969) - Add util/cap_file_size.py: sets RLIMIT_FSIZE, soft and hard, to 2 GiB (keeping a lower hard limit), restores SIGXFSZ's default and execs the command, so a write past the cap fails with `File too large`. - Run every pytest and unittest command in unit-tests.yml through it: test, integration, pypcap-parity and their KTLO twins, the engine cells, unittest-ordering, project-tests and gate. project-tests drops its own `ulimit -f`, so the limit lives in one place. - Pin it in tests/project/test_file_size_caps_1969.py: a test command without the script fails, and the script is run against a small limit. - The step stubs in the tier guard and the #1732 pin hand the script to the real interpreter, so they still record the capped command. - Describe the caps on the workflows page. Closes #1969 --- .github/workflows/unit-tests.yml | 30 +-- docs/source/contributing/workflows.rst | 26 +++ tests/project/test_file_size_caps_1969.py | 190 ++++++++++++++++++ tests/project/test_project_pins_once_1954.py | 5 +- .../test_sweeps_on_latest_only_1732.py | 5 +- tests/project/test_unit_tests_queue.py | 4 +- tests/test_tier_guard.py | 5 +- util/cap_file_size.py | 70 +++++++ 8 files changed, 311 insertions(+), 24 deletions(-) create mode 100644 tests/project/test_file_size_caps_1969.py create mode 100644 util/cap_file_size.py diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index fdca8b697f..3be705e766 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -255,9 +255,9 @@ jobs: if [ "$COVERAGE" = "true" ]; then # Logs every measured process and its parent, for the check below. COVERAGE_DEBUG=pid,process COVERAGE_DEBUG_FILE="$PWD/coverage-processes.log" \ - python -m coverage run --rcfile=.github/coverage.toml -m pytest "$@" + python util/cap_file_size.py python -m coverage run --rcfile=.github/coverage.toml -m pytest "$@" else - python -m pytest "$@" + python util/cap_file_size.py python -m pytest "$@" fi # The table goes to the job summary. The numbers also go to an artifact @@ -615,7 +615,7 @@ jobs: echo "Fixture-dependent selection: $selection" # shellcheck disable=SC2086 set -- $selection - python -m pytest -q -n auto --dist load \ + python util/cap_file_size.py python -m pytest -q -n auto --dist load \ --durations=30 --durations-min=1.0 --junitxml="$RUNNER_TEMP/junit.xml" "$@" # See the `test` job's step of the same name. @@ -1051,7 +1051,7 @@ jobs: esac echo "Running: $TEST_PATHS" # shellcheck disable=SC2086 - if ! python -m pytest -q -n auto --dist load $TEST_PATHS; then + if ! python util/cap_file_size.py python -m pytest -q -n auto --dist load $TEST_PATHS; then echo "::${ANNOTATION_LEVEL} title=$ENGINE tests failed on Python $PY::See the 'Python $PY ($ENGINE)' group in this step's log." exit 1 fi @@ -1368,7 +1368,7 @@ jobs: exit 1 fi echo "Fixture-dependent selection: $selection" - python -m pytest -q -n auto --dist load $selection + python util/cap_file_size.py python -m pytest -q -n auto --dist load $selection # #1672: see the `test` job's step of the same name. - name: KTLO summary @@ -1622,7 +1622,7 @@ jobs: # under the job's 45 so a hang reports as this step timing out. - name: Run tests/${{ matrix.leg }} and the root-level modules under plain unittest timeout-minutes: 20 - run: python util/run_unittest_leg.py ${{ matrix.leg }} ${{ matrix.exclude }} --verbose + run: python util/cap_file_size.py python util/run_unittest_leg.py ${{ matrix.leg }} ${{ matrix.exclude }} --verbose # ``CHANGELOG.md`` is generated from the newest entry under # ``docs/source/changelog/`` by ``util/changelog_md.py``, so it falls out of step @@ -1800,16 +1800,16 @@ jobs: # `test`'s pytest flags, with the pins as the selection. A selection that # cannot be computed, or is empty, fails the step rather than letting a - # bare `pytest` run the whole suite. `ulimit -f` caps any file a test - # writes at 2 GiB (bash counts 1024-byte blocks), as experimental.yml's - # `prlimit --fsize` does. The step cap is several times the run's - # length, under the job's 20. + # bare `pytest` run the whole suite. The step cap is several times the + # run's length, under the job's 20. #1969: every pytest and unittest + # command in this file runs under util/cap_file_size.py, which caps any + # file a test writes at 2 GiB, as experimental.yml's `prlimit --fsize` + # does; tests/project/test_file_size_caps_1969.py pins that. - name: Run the project pins if: ${{ needs.changes.outputs.code != 'false' }} timeout-minutes: 10 shell: bash run: | - ulimit -f 2097152 if ! pins=$(python -c "from tests._tiers import project_pin_paths; print(' '.join(project_pin_paths()))"); then echo "::error title=Could not compute the project pins::tests._tiers.project_pin_paths() failed -- see the traceback above." exit 1 @@ -1820,18 +1820,18 @@ jobs: fi # shellcheck disable=SC2086 set -- $pins - python -m pytest -q -n auto --dist load \ + python util/cap_file_size.py python -m pytest -q -n auto --dist load \ --durations=30 --durations-min=1.0 --junitxml="$RUNNER_TEMP/junit.xml" "$@" - name: Run tests/project and the root-level modules under plain unittest if: ${{ needs.changes.outputs.code == 'false' }} timeout-minutes: 15 - run: python util/run_unittest_leg.py project --verbose + run: python util/cap_file_size.py python util/run_unittest_leg.py project --verbose - name: Run the other tests that read docs/ or Markdown if: ${{ needs.changes.outputs.code == 'false' }} timeout-minutes: 5 - run: python -m unittest -v tests.corekit.test_sentinel_exports_unit tests.utilities.test_config_unit tests.vendor.test_vendor_variables_1726_unit + run: python util/cap_file_size.py python -m unittest -v tests.corekit.test_sentinel_exports_unit tests.utilities.test_config_unit tests.vendor.test_vendor_variables_1726_unit # The verdict a shipping workflow asks for: the full suite, fixtures and # all, on one interpreter. It is deliberately not the matrix -- the matrix @@ -1909,7 +1909,7 @@ jobs: # scope here. `--dist load` is pinned explicitly; see the `test` job # above for why. - name: Run full test suite - run: python -m pytest -q -n auto --dist load + run: python util/cap_file_size.py python -m pytest -q -n auto --dist load # Ruleset 23497679's required_status_checks requires these contexts: this # job's `Required checks passed`, and a `Compat Python` one per supported version, diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index 3a33496377..01874ddfa0 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -1009,6 +1009,32 @@ those pins drive read the tier file on the runner's own ``python3``, and :file:`tests/project/test_python_versions_1660.py` checks those skips in a child interpreter that cannot import ``tomllib``. +File Size Caps (`#1969 `__) +------------------------------------------------------------------------------- + +A test that writes without bound fills the runner's disk rather than failing: `#1372 +`__'s ``out.json`` grew to +several gigabytes on every run. So every pytest and unittest command in +:file:`.github/workflows/unit-tests.yml` runs as ``python util/cap_file_size.py +python -m ...``: ``test``'s ``Run unit tests``; ``integration``'s, +``pypcap-parity``'s and ``gate``'s ``Run full test suite``; the KTLO twins of +``test``, ``integration`` and ``pypcap-parity``, which share their steps; the +engine cells that ``engine-tests``' ``Write the per-cell script`` writes; +``unittest-ordering``'s cells; and each run step of ``project-tests``. + +``util/cap_file_size.py`` sets ``RLIMIT_FSIZE``, soft and hard, to 2 GiB, or keeps +a lower hard limit, and execs the command, so the step's exit status and +``continue-on-error`` behave as before and every process the command starts +inherits the cap. A write past it raises ``OSError: [Errno 27] File too large`` in +Python, since CPython ignores ``SIGXFSZ``, so the test errors; any other program is +killed by the signal, whose default action the script restores before it execs. +``experimental.yml``'s legs get the same 2 GiB through ``prlimit --fsize`` (see +above). + +:file:`tests/project/test_file_size_caps_1969.py` fails when a command in +``unit-tests.yml`` runs pytest, unittest or :file:`util/run_unittest_leg.py` +without the script, and runs the script against a small limit. + Python Versions --------------- diff --git a/tests/project/test_file_size_caps_1969.py b/tests/project/test_file_size_caps_1969.py new file mode 100644 index 0000000000..156fe93c21 --- /dev/null +++ b/tests/project/test_file_size_caps_1969.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +"""#1969: every pytest and unittest command in ``unit-tests.yml`` caps the files it writes. + +A test that writes without bound fills the runner's disk (#1372's ``out.json``). +:file:`util/cap_file_size.py` sets ``RLIMIT_FSIZE`` and execs the command, and is +the one place the limit is written down. This module pins both halves: every step +of :file:`.github/workflows/unit-tests.yml` that runs pytest, unittest or +:file:`util/run_unittest_leg.py` does so through the script, so a new step without +it fails here; and the script caps what its command writes, so that a write past +the cap errors rather than passes. + +""" + +from __future__ import annotations + +import errno +import os +import pathlib +import re +import signal +import subprocess # nosec: B404 +import sys +import tempfile +import unittest + +from tests._workflows import WORKFLOWS, load, loads + +try: + import resource +except ImportError: # pragma: no cover - not on Windows + resource = None # type: ignore[assignment] + +ROOT = pathlib.Path(__file__).resolve().parents[2] +SCRIPT = ROOT / 'util' / 'cap_file_size.py' +DOC = ROOT / 'docs' / 'source' / 'contributing' / 'workflows.rst' +WORKFLOW = 'unit-tests.yml' +#: How a step runs a command under the cap. +CAP = 'python util/cap_file_size.py ' +#: A command that runs tests: pytest or unittest as a module, coverage over +#: either, or util/run_unittest_leg.py. +RUNS_TESTS = re.compile(r'-m (?:pytest|unittest|coverage run)\b|util/run_unittest_leg\.py\b') +#: The jobs that run tests, each of which must be found running some. +TEST_JOBS = frozenset({ + 'test', 'test-ktlo', 'integration', 'integration-ktlo', 'engine-tests', + 'pypcap-parity', 'pypcap-parity-ktlo', 'unittest-ordering', 'project-tests', 'gate', +}) + +if str(ROOT / 'util') not in sys.path: + sys.path.insert(0, str(ROOT / 'util')) + +import cap_file_size # noqa: E402 pylint: disable=wrong-import-position + + +def commands(script: 'str') -> 'list[str]': + """The logical lines of ``script``, continuations joined, comments and blanks dropped.""" + lines = [] + for line in script.replace('\\\n', ' ').splitlines(): + line = line.strip() + if line and not line.startswith('#'): + lines.append(line) + return lines + + +def suite_commands(workflow: 'dict[str, object]') -> 'list[tuple[str, str, str]]': + """Every ``(job, step, command)`` of ``workflow`` that runs tests.""" + found = [] + for job_id, spec in workflow['jobs'].items(): # type: ignore[attr-defined] + for entry in spec.get('steps', ()): + for line in commands(str(entry.get('run', ''))): + if RUNS_TESTS.search(line): + found.append((job_id, entry.get('name', entry.get('id', '?')), line)) + return found + + +def uncapped(workflow: 'dict[str, object]') -> 'list[tuple[str, str, str]]': + """The test commands of ``workflow`` that do not run through :data:`CAP`.""" + bad = [] + for where in suite_commands(workflow): + line = where[2] + start = RUNS_TESTS.search(line).start() # type: ignore[union-attr] + at = line.find(CAP) + if at == -1 or at + len(CAP) > start or not line[at + len(CAP):].startswith('python '): + bad.append(where) + return bad + + +class TestEveryTestStepIsCapped(unittest.TestCase): + """Each pytest and unittest command in ``unit-tests.yml`` runs under the cap.""" + + def setUp(self) -> None: + self.text = (WORKFLOWS / WORKFLOW).read_text(encoding='utf-8') + self.workflow = load(WORKFLOW) + + def test_every_test_command_runs_through_the_script(self) -> None: + self.assertEqual(uncapped(self.workflow), []) + + def test_every_test_job_is_seen(self) -> None: + # The scan is not vacuous: each job that runs tests is found doing so, + # the KTLO ones through their YAML alias, the engine cells inside the + # script their step writes. + seen = {job_id for job_id, _, _ in suite_commands(self.workflow)} + self.assertLessEqual(TEST_JOBS, seen) + + def test_a_doctored_copy_without_the_cap_is_caught(self) -> None: + bare = uncapped(loads(self.text.replace(CAP, ''))) + self.assertLessEqual(TEST_JOBS, {job_id for job_id, _, _ in bare}) + extra = loads(self.text.replace( + ' - name: Run full test suite\n run: ' + CAP, + ' - name: Run a new suite\n run: python -m pytest tests/project\n' + ' - name: Run full test suite\n run: ' + CAP, 1)) + self.assertEqual(uncapped(extra), [('gate', 'Run a new suite', 'python -m pytest tests/project')]) + + def test_the_cap_has_one_mechanism(self) -> None: + for word in ('ulimit -f', 'prlimit'): + self.assertEqual([line for line in commands(self.text) if word in line], [], word) + + +@unittest.skipIf(resource is None, 'RLIMIT_FSIZE is POSIX') +class TestTheScript(unittest.TestCase): + """util/cap_file_size.py caps the file size of the command it execs.""" + + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with + self.addCleanup(self.tmp.cleanup) + self.path = os.path.join(self.tmp.name, 'out') + + def capped(self, limit: 'int', *command: 'str', **kwargs: 'object') -> 'subprocess.CompletedProcess[str]': + """``command`` run by the script, with :data:`cap_file_size.LIMIT` set to ``limit``.""" + main = ('import sys; sys.path.insert(0, sys.argv[1]); import cap_file_size; ' + 'cap_file_size.LIMIT = int(sys.argv[2]); cap_file_size.main(sys.argv[3:])') + return subprocess.run([sys.executable, '-c', main, str(SCRIPT.parent), str(limit), *command], # nosec: B603 + cwd=self.tmp.name, text=True, check=False, timeout=60, **kwargs) # type: ignore[call-overload] + + def test_the_limit_is_two_gib(self) -> None: + self.assertEqual(cap_file_size.LIMIT, 2 * 1024 ** 3) + + def test_the_command_runs_under_the_limit(self) -> None: + _, hard = resource.getrlimit(resource.RLIMIT_FSIZE) + expected = cap_file_size.LIMIT if hard == resource.RLIM_INFINITY else min(cap_file_size.LIMIT, hard) + probe = 'import resource; print(*resource.getrlimit(resource.RLIMIT_FSIZE))' + result = subprocess.run([sys.executable, str(SCRIPT), sys.executable, '-c', probe], # nosec: B603 + capture_output=True, text=True, check=False, timeout=60) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.split(), [str(expected)] * 2) + + def test_a_lower_hard_limit_is_kept(self) -> None: + def lower() -> None: + resource.setrlimit(resource.RLIMIT_FSIZE, (1 << 20, 1 << 20)) + + probe = 'import resource; print(*resource.getrlimit(resource.RLIMIT_FSIZE))' + result = self.capped(1 << 30, sys.executable, '-c', probe, capture_output=True, + preexec_fn=lower) # pylint: disable=subprocess-popen-preexec-fn + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.split(), [str(1 << 20)] * 2) + + def test_a_python_write_past_the_limit_raises_efbig(self) -> None: + writer = ('import sys\nwith open(sys.argv[1], "wb") as file:\n' + ' file.write(b"x" * 8192)\n') + result = self.capped(4096, sys.executable, '-c', writer, self.path, capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn(f'OSError: [Errno {errno.EFBIG}] File too large', result.stderr) + self.assertEqual(os.path.getsize(self.path), 4096) + + def test_another_program_is_killed_by_sigxfsz(self) -> None: + # CPython ignores SIGXFSZ, and an ignored signal stays ignored across + # exec, so this holds only because the script restores the default. + with open(self.path, 'wb') as file: + result = self.capped(4096, 'head', '-c', '8192', '/dev/zero', stdout=file, + stderr=subprocess.PIPE) + self.assertEqual(result.returncode, -signal.SIGXFSZ, result.stderr) + self.assertEqual(os.path.getsize(self.path), 4096) + + def test_no_command_is_a_usage_error(self) -> None: + result = subprocess.run([sys.executable, str(SCRIPT)], capture_output=True, text=True, # nosec: B603 + check=False, timeout=60) + self.assertEqual(result.returncode, cap_file_size.USAGE) + self.assertIn('usage:', result.stderr) + + +class TestTheWorkflowsPage(unittest.TestCase): + """docs/source/contributing/workflows.rst names the script for the cap.""" + + def test_the_page_names_the_script(self) -> None: + text = DOC.read_text(encoding='utf-8') + self.assertIn('``util/cap_file_size.py``', text) + self.assertNotIn('ulimit -f', text) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_project_pins_once_1954.py b/tests/project/test_project_pins_once_1954.py index 984e040fe8..6c5e1a48ad 100644 --- a/tests/project/test_project_pins_once_1954.py +++ b/tests/project/test_project_pins_once_1954.py @@ -60,8 +60,7 @@ def test_it_runs_on_latest_with_what_test_installs(self) -> None: def test_it_runs_under_the_caps(self) -> None: """xdist as ``test`` runs it, a 2 GiB file cap, and a step cap under the job's.""" run = str(step(JOB, STEP)['run']) - self.assertIn('ulimit -f 2097152\n', run) - self.assertIn('python -m pytest -q -n auto --dist load', run) + self.assertIn('python util/cap_file_size.py python -m pytest -q -n auto --dist load', run) self.assertIn(_tiers.PROJECT_PINS_SELECTOR, run) self.assertLess(step(JOB, STEP)['timeout-minutes'], job(*JOB)['timeout-minutes']) @@ -71,7 +70,7 @@ class TestEveryPinStillRunsWhereTheMatrixDoesNot(unittest.TestCase): def test_the_gate_runs_the_whole_suite(self) -> None: self.assertEqual(step(('unit-tests.yml', 'gate'), 'Run full test suite')['run'], - 'python -m pytest -q -n auto --dist load') + 'python util/cap_file_size.py python -m pytest -q -n auto --dist load') def test_the_ordering_cell_runs_every_pin(self) -> None: self.assertIn('project', job('unit-tests.yml', 'unittest-ordering')['strategy']['matrix']['leg']) diff --git a/tests/project/test_sweeps_on_latest_only_1732.py b/tests/project/test_sweeps_on_latest_only_1732.py index 79d5e391eb..024dfa5b5b 100644 --- a/tests/project/test_sweeps_on_latest_only_1732.py +++ b/tests/project/test_sweeps_on_latest_only_1732.py @@ -73,8 +73,9 @@ #: The unit tier's own flags, which every leg passes. TIER_FLAGS = ('--ignore=tests/integration', '--ignore-glob=*_runtime.py', '--ignore-glob=*_regression.py') #: A ``python`` that writes each argument on a line of ``$ARGS_FILE``, and hands a -#: ``-c`` to the real one, which is how the steps ask tests/_tiers.py. -STUB = ('#!/bin/sh\nif [ "$1" = -c ]; then exec "$REAL_PYTHON" "$@"; fi\n' +#: ``-c`` to the real one, which is how the steps ask tests/_tiers.py, and +#: util/cap_file_size.py, which execs this stub with the command it caps (#1969). +STUB = ('#!/bin/sh\ncase "$1" in -c|util/cap_file_size.py) exec "$REAL_PYTHON" "$@";; esac\n' 'printf \'%s\\n\' "$@" > "$ARGS_FILE"\n') #: A directory holding sweeps, hand-picked round trips, and modules that only import #: the sweeps' harnesses' packet builders, for :class:`TestTheIgnoresAreTheLatestTier`. diff --git a/tests/project/test_unit_tests_queue.py b/tests/project/test_unit_tests_queue.py index 7653e2bed4..7fa0b4e85a 100644 --- a/tests/project/test_unit_tests_queue.py +++ b/tests/project/test_unit_tests_queue.py @@ -571,7 +571,7 @@ class TestDocsOnlyPathRunsEveryDocsReader(unittest.TestCase): def listed(self) -> 'set[str]': """The modules project-tests' second run step names.""" script = step(job('project-tests'), 'Run the other tests that read docs/ or Markdown') - command = re.search(r'(?m)^ run: python -m unittest -v (.+)$', script) + command = re.search(r'(?m)^ run: python util/cap_file_size.py python -m unittest -v (.+)$', script) self.assertIsNotNone(command) return set(command.group(1).split()) # type: ignore[union-attr] @@ -597,7 +597,7 @@ def test_the_scan_is_not_blind(self) -> None: #: citation matches -- so moving a job without renumbering the prose fails here. CITATIONS: 'dict[str, list[tuple[str, Optional[str]]]]' = { 'docs/source/contributing/releasing.rst': [ - (' gate:', 'run: python -m pytest -q -n auto --dist load'), + (' gate:', 'run: python util/cap_file_size.py python -m pytest -q -n auto --dist load'), (' changelog:', 'fi'), ], 'docs/source/contributing/workflows.rst': [ diff --git a/tests/test_tier_guard.py b/tests/test_tier_guard.py index c3673e1727..c08224d1f9 100644 --- a/tests/test_tier_guard.py +++ b/tests/test_tier_guard.py @@ -323,8 +323,9 @@ def test_a_leg_that_asks_for_the_wrong_list_is_caught(self) -> None: #: The experimental legs' workflow (#1732), whose unit-tier step leaves the ``LATEST`` tier out too. EXPERIMENTAL_WORKFLOW = _tiers.ROOT / '.github' / 'workflows' / 'experimental.yml' #: A ``python`` that writes each argument on a line of ``$ARGS_FILE``, and hands a -#: ``-c`` to the real one, which is how the steps ask tests/_tiers.py for the pins. -PYTHON_STUB = ('#!/bin/sh\nif [ "$1" = -c ]; then exec "$REAL_PYTHON" "$@"; fi\n' +#: ``-c`` to the real one, which is how the steps ask tests/_tiers.py for the pins, and +#: util/cap_file_size.py, which execs this stub with the command it caps (#1969). +PYTHON_STUB = ('#!/bin/sh\ncase "$1" in -c|util/cap_file_size.py) exec "$REAL_PYTHON" "$@";; esac\n' 'printf \'%s\\n\' "$@" > "$ARGS_FILE"\n') diff --git a/util/cap_file_size.py b/util/cap_file_size.py new file mode 100644 index 0000000000..1261520497 --- /dev/null +++ b/util/cap_file_size.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +"""Run a command with every file it writes capped at :data:`LIMIT` bytes (#1969). + +A test that writes without bound fills the runner's disk rather than failing: +#1372's ``out.json`` grew to several gigabytes on every run. Each pytest and +unittest step of :file:`.github/workflows/unit-tests.yml` therefore runs as:: + + python util/cap_file_size.py python -m pytest ... + +which sets ``RLIMIT_FSIZE``, soft and hard, to :data:`LIMIT` and then replaces +itself with the command, so the command's exit status and signals are the step's +own. The limit is inherited by every process the command starts, pytest-xdist's +workers and a test's subprocesses alike, and an unprivileged process cannot raise +the hard limit again. A hard limit already below :data:`LIMIT` is kept. + +A write past the limit fails in the kernel with ``EFBIG``, and the writer is sent +``SIGXFSZ``. CPython ignores that signal, so in a Python process the write raises +:exc:`OSError` (``[Errno 27] File too large``) and the test errors. Any other +program is killed by it. This script restores the default action before it +execs, as :mod:`subprocess` does for a child, since an ignored signal stays +ignored across ``exec``. + +:data:`LIMIT` is the one copy of the number; :file:`.github/workflows/experimental.yml` +gives its legs the same cap through ``prlimit --fsize``. + +""" + +from __future__ import annotations + +import os +import resource +import signal +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import NoReturn, Optional + +__all__ = ['LIMIT', 'cap', 'main'] + +#: The largest file, in bytes, the command may write: 2 GiB. +LIMIT = 2 * 1024 ** 3 +#: Exit status for a missing command, as :mod:`argparse` uses for a usage error. +USAGE = 2 + + +def cap(limit: 'int' = LIMIT) -> 'int': + """Cap ``RLIMIT_FSIZE`` of this process at ``limit``, or its hard limit if lower; return the cap.""" + _, hard = resource.getrlimit(resource.RLIMIT_FSIZE) + if hard != resource.RLIM_INFINITY: + limit = min(limit, hard) + resource.setrlimit(resource.RLIMIT_FSIZE, (limit, limit)) + return limit + + +def main(argv: 'Optional[list[str]]' = None) -> 'NoReturn': + """Cap the file size at :data:`LIMIT` and exec ``argv``, the command and its arguments.""" + command = sys.argv[1:] if argv is None else argv + if not command: + print(f'usage: {os.path.basename(sys.argv[0])} COMMAND [ARGUMENT ...]', file=sys.stderr) + sys.exit(USAGE) + cap(LIMIT) + signal.signal(signal.SIGXFSZ, signal.SIG_DFL) + sys.stdout.flush() + sys.stderr.flush() + os.execvp(command[0], command) # nosec: B606 - the step's own command + + +if __name__ == '__main__': + main() From 94593673a883f3dcfccc20cce5d019df5a11b894 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Mon, 19 Oct 2026 23:32:35 -0400 Subject: [PATCH 2/2] test(project): keep the step stubs from measuring a second coverage controller (#1969) - The python stubs in the tier guard and the #1732 pin handed util/cap_file_size.py to the real interpreter. On the coverage leg that process was measured, logged its `coverage run --rcfile` argv as a second controller and exec'd before saving, so the #1387 guard failed. - The stubs now exec the capped command themselves, as the script does, with no real interpreter; each module gains a test that the real one is not started for it. Refs #1969 --- .../test_sweeps_on_latest_only_1732.py | 29 +++++++++++++++++-- tests/test_tier_guard.py | 29 +++++++++++++++++-- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/tests/project/test_sweeps_on_latest_only_1732.py b/tests/project/test_sweeps_on_latest_only_1732.py index 024dfa5b5b..649a0ea086 100644 --- a/tests/project/test_sweeps_on_latest_only_1732.py +++ b/tests/project/test_sweeps_on_latest_only_1732.py @@ -73,9 +73,11 @@ #: The unit tier's own flags, which every leg passes. TIER_FLAGS = ('--ignore=tests/integration', '--ignore-glob=*_runtime.py', '--ignore-glob=*_regression.py') #: A ``python`` that writes each argument on a line of ``$ARGS_FILE``, and hands a -#: ``-c`` to the real one, which is how the steps ask tests/_tiers.py, and -#: util/cap_file_size.py, which execs this stub with the command it caps (#1969). -STUB = ('#!/bin/sh\ncase "$1" in -c|util/cap_file_size.py) exec "$REAL_PYTHON" "$@";; esac\n' +#: ``-c`` to the real one, which is how the steps ask tests/_tiers.py. util/cap_file_size.py's +#: command it execs, as the script does, with no real interpreter: under the coverage +#: leg one would be measured, be logged as a second ``coverage run --rcfile`` +#: controller, and exec before saving, which fails the #1387 guard (#1969). +STUB = ('#!/bin/sh\ncase "$1" in -c) exec "$REAL_PYTHON" "$@";; util/cap_file_size.py) shift; exec "$@";; esac\n' 'printf \'%s\\n\' "$@" > "$ARGS_FILE"\n') #: A directory holding sweeps, hand-picked round trips, and modules that only import #: the sweeps' harnesses' packet builders, for :class:`TestTheIgnoresAreTheLatestTier`. @@ -148,6 +150,27 @@ def pytest_args(argv: 'list[str]') -> 'list[str]': raise AssertionError(f'no `-m pytest` in {argv}') +@unittest.skipIf(shutil.which('sh') is None, 'the stub is a sh script') +class TestTheStubRunsNoRealInterpreterForTheCap(unittest.TestCase): + """#1969: :data:`STUB` records the command util/cap_file_size.py caps without a real ``python``.""" + + def test_the_coverage_command_is_recorded_without_the_real_interpreter(self) -> None: + with tempfile.TemporaryDirectory() as temp: + for name, text in (('python', STUB), ('real', '#!/bin/sh\necho "$@" >> "$REAL_LOG"\n')): + path = pathlib.Path(temp, name) + path.write_text(text, encoding='utf-8') + path.chmod(0o755) + record, real = pathlib.Path(temp, 'args'), pathlib.Path(temp, 'real.log') + env = dict(os.environ, ARGS_FILE=str(record), REAL_PYTHON=str(pathlib.Path(temp, 'real')), + REAL_LOG=str(real), PATH=temp + os.pathsep + os.environ.get('PATH', '')) + command = ['-m', 'coverage', 'run', '--rcfile=.github/coverage.toml', '-m', 'pytest', '-q'] + result = subprocess.run(['python', 'util/cap_file_size.py', 'python', *command], env=env, # nosec B603 B607 + capture_output=True, text=True, check=False, timeout=60) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(real.exists(), 'the real interpreter ran the capped coverage command') + self.assertEqual(record.read_text(encoding='utf-8').splitlines(), command) + + def ignored(argv: 'list[str]') -> 'list[str]': """The modules the arguments ``--ignore``, less the unit tier's own flags.""" return [arg.split('=', 1)[1] for arg in pytest_args(argv) if arg.startswith('--ignore=') and arg not in TIER_FLAGS] diff --git a/tests/test_tier_guard.py b/tests/test_tier_guard.py index c08224d1f9..2d3f0585ff 100644 --- a/tests/test_tier_guard.py +++ b/tests/test_tier_guard.py @@ -323,9 +323,11 @@ def test_a_leg_that_asks_for_the_wrong_list_is_caught(self) -> None: #: The experimental legs' workflow (#1732), whose unit-tier step leaves the ``LATEST`` tier out too. EXPERIMENTAL_WORKFLOW = _tiers.ROOT / '.github' / 'workflows' / 'experimental.yml' #: A ``python`` that writes each argument on a line of ``$ARGS_FILE``, and hands a -#: ``-c`` to the real one, which is how the steps ask tests/_tiers.py for the pins, and -#: util/cap_file_size.py, which execs this stub with the command it caps (#1969). -PYTHON_STUB = ('#!/bin/sh\ncase "$1" in -c|util/cap_file_size.py) exec "$REAL_PYTHON" "$@";; esac\n' +#: ``-c`` to the real one, which is how the steps ask tests/_tiers.py for the pins. util/cap_file_size.py's +#: command it execs, as the script does, with no real interpreter: under the coverage +#: leg one would be measured, be logged as a second ``coverage run --rcfile`` +#: controller, and exec before saving, which fails the #1387 guard (#1969). +PYTHON_STUB = ('#!/bin/sh\ncase "$1" in -c) exec "$REAL_PYTHON" "$@";; util/cap_file_size.py) shift; exec "$@";; esac\n' 'printf \'%s\\n\' "$@" > "$ARGS_FILE"\n') @@ -359,6 +361,27 @@ def stubbed_argv(script: 'str', env: 'dict[str, str]') -> 'list[str]': return record.read_text(encoding='utf-8').splitlines() +@unittest.skipIf(shutil.which('sh') is None, 'the stub is a sh script') +class PythonStubTests(unittest.TestCase): + """#1969: :data:`PYTHON_STUB` records the command util/cap_file_size.py caps without a real ``python``.""" + + def test_the_capped_command_is_recorded_without_the_real_interpreter(self) -> None: + with tempfile.TemporaryDirectory() as temp: + for name, text in (('python', PYTHON_STUB), ('real', '#!/bin/sh\necho "$@" >> "$REAL_LOG"\n')): + path = pathlib.Path(temp, name) + path.write_text(text, encoding='utf-8') + path.chmod(0o755) + record, real = pathlib.Path(temp, 'args'), pathlib.Path(temp, 'real.log') + env = dict(os.environ, ARGS_FILE=str(record), REAL_PYTHON=str(pathlib.Path(temp, 'real')), + REAL_LOG=str(real), PATH=temp + os.pathsep + os.environ.get('PATH', '')) + command = ['-m', 'pytest', '-q', '-n', 'auto'] + result = subprocess.run(['python', 'util/cap_file_size.py', 'python', *command], env=env, + capture_output=True, text=True, check=False, timeout=60) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(real.exists(), 'the real interpreter ran the capped command') + self.assertEqual(record.read_text(encoding='utf-8').splitlines(), command) + + def leg_ignores(text: 'str', latest: 'str') -> 'list[str]': """The modules a ``test`` leg's ``Run unit tests`` step ``--ignore``\\ s, ``latest`` or not.""" script = run_script(job_section(text, 'test'), 'Run unit tests')