Skip to content

Commit 217224e

Browse files
authored
ci(unit-tests): cap the size of files tests write on every pytest step (#1969) (#1973)
* 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 * 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
1 parent d886994 commit 217224e

8 files changed

Lines changed: 357 additions & 24 deletions

File tree

‎.github/workflows/unit-tests.yml‎

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -255,9 +255,9 @@ jobs:
255255
if [ "$COVERAGE" = "true" ]; then
256256
# Logs every measured process and its parent, for the check below.
257257
COVERAGE_DEBUG=pid,process COVERAGE_DEBUG_FILE="$PWD/coverage-processes.log" \
258-
python -m coverage run --rcfile=.github/coverage.toml -m pytest "$@"
258+
python util/cap_file_size.py python -m coverage run --rcfile=.github/coverage.toml -m pytest "$@"
259259
else
260-
python -m pytest "$@"
260+
python util/cap_file_size.py python -m pytest "$@"
261261
fi
262262
263263
# The table goes to the job summary. The numbers also go to an artifact
@@ -615,7 +615,7 @@ jobs:
615615
echo "Fixture-dependent selection: $selection"
616616
# shellcheck disable=SC2086
617617
set -- $selection
618-
python -m pytest -q -n auto --dist load \
618+
python util/cap_file_size.py python -m pytest -q -n auto --dist load \
619619
--durations=30 --durations-min=1.0 --junitxml="$RUNNER_TEMP/junit.xml" "$@"
620620
621621
# See the `test` job's step of the same name.
@@ -1051,7 +1051,7 @@ jobs:
10511051
esac
10521052
echo "Running: $TEST_PATHS"
10531053
# shellcheck disable=SC2086
1054-
if ! python -m pytest -q -n auto --dist load $TEST_PATHS; then
1054+
if ! python util/cap_file_size.py python -m pytest -q -n auto --dist load $TEST_PATHS; then
10551055
echo "::${ANNOTATION_LEVEL} title=$ENGINE tests failed on Python $PY::See the 'Python $PY ($ENGINE)' group in this step's log."
10561056
exit 1
10571057
fi
@@ -1368,7 +1368,7 @@ jobs:
13681368
exit 1
13691369
fi
13701370
echo "Fixture-dependent selection: $selection"
1371-
python -m pytest -q -n auto --dist load $selection
1371+
python util/cap_file_size.py python -m pytest -q -n auto --dist load $selection
13721372
13731373
# #1672: see the `test` job's step of the same name.
13741374
- name: KTLO summary
@@ -1622,7 +1622,7 @@ jobs:
16221622
# under the job's 45 so a hang reports as this step timing out.
16231623
- name: Run tests/${{ matrix.leg }} and the root-level modules under plain unittest
16241624
timeout-minutes: 20
1625-
run: python util/run_unittest_leg.py ${{ matrix.leg }} ${{ matrix.exclude }} --verbose
1625+
run: python util/cap_file_size.py python util/run_unittest_leg.py ${{ matrix.leg }} ${{ matrix.exclude }} --verbose
16261626

16271627
# ``CHANGELOG.md`` is generated from the newest entry under
16281628
# ``docs/source/changelog/`` by ``util/changelog_md.py``, so it falls out of step
@@ -1800,16 +1800,16 @@ jobs:
18001800
18011801
# `test`'s pytest flags, with the pins as the selection. A selection that
18021802
# cannot be computed, or is empty, fails the step rather than letting a
1803-
# bare `pytest` run the whole suite. `ulimit -f` caps any file a test
1804-
# writes at 2 GiB (bash counts 1024-byte blocks), as experimental.yml's
1805-
# `prlimit --fsize` does. The step cap is several times the run's
1806-
# length, under the job's 20.
1803+
# bare `pytest` run the whole suite. The step cap is several times the
1804+
# run's length, under the job's 20. #1969: every pytest and unittest
1805+
# command in this file runs under util/cap_file_size.py, which caps any
1806+
# file a test writes at 2 GiB, as experimental.yml's `prlimit --fsize`
1807+
# does; tests/project/test_file_size_caps_1969.py pins that.
18071808
- name: Run the project pins
18081809
if: ${{ needs.changes.outputs.code != 'false' }}
18091810
timeout-minutes: 10
18101811
shell: bash
18111812
run: |
1812-
ulimit -f 2097152
18131813
if ! pins=$(python -c "from tests._tiers import project_pin_paths; print(' '.join(project_pin_paths()))"); then
18141814
echo "::error title=Could not compute the project pins::tests._tiers.project_pin_paths() failed -- see the traceback above."
18151815
exit 1
@@ -1820,18 +1820,18 @@ jobs:
18201820
fi
18211821
# shellcheck disable=SC2086
18221822
set -- $pins
1823-
python -m pytest -q -n auto --dist load \
1823+
python util/cap_file_size.py python -m pytest -q -n auto --dist load \
18241824
--durations=30 --durations-min=1.0 --junitxml="$RUNNER_TEMP/junit.xml" "$@"
18251825
18261826
- name: Run tests/project and the root-level modules under plain unittest
18271827
if: ${{ needs.changes.outputs.code == 'false' }}
18281828
timeout-minutes: 15
1829-
run: python util/run_unittest_leg.py project --verbose
1829+
run: python util/cap_file_size.py python util/run_unittest_leg.py project --verbose
18301830

18311831
- name: Run the other tests that read docs/ or Markdown
18321832
if: ${{ needs.changes.outputs.code == 'false' }}
18331833
timeout-minutes: 5
1834-
run: python -m unittest -v tests.corekit.test_sentinel_exports_unit tests.utilities.test_config_unit tests.vendor.test_vendor_variables_1726_unit
1834+
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
18351835

18361836
# The verdict a shipping workflow asks for: the full suite, fixtures and
18371837
# all, on one interpreter. It is deliberately not the matrix -- the matrix
@@ -1909,7 +1909,7 @@ jobs:
19091909
# scope here. `--dist load` is pinned explicitly; see the `test` job
19101910
# above for why.
19111911
- name: Run full test suite
1912-
run: python -m pytest -q -n auto --dist load
1912+
run: python util/cap_file_size.py python -m pytest -q -n auto --dist load
19131913

19141914
# Ruleset 23497679's required_status_checks requires these contexts: this
19151915
# job's `Required checks passed`, and a `Compat Python` one per supported version,

‎docs/source/contributing/workflows.rst‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,6 +1010,32 @@ those pins drive read the tier file on the runner's own ``python3``, and
10101010
:file:`tests/project/test_python_versions_1660.py` checks those skips in a child
10111011
interpreter that cannot import ``tomllib``.
10121012

1013+
File Size Caps (`#1969 <https://github.com/JarryShaw/PyPCAPKit/issues/1969>`__)
1014+
-------------------------------------------------------------------------------
1015+
1016+
A test that writes without bound fills the runner's disk rather than failing: `#1372
1017+
<https://github.com/JarryShaw/PyPCAPKit/issues/1372>`__'s ``out.json`` grew to
1018+
several gigabytes on every run. So every pytest and unittest command in
1019+
:file:`.github/workflows/unit-tests.yml` runs as ``python util/cap_file_size.py
1020+
python -m ...``: ``test``'s ``Run unit tests``; ``integration``'s,
1021+
``pypcap-parity``'s and ``gate``'s ``Run full test suite``; the KTLO twins of
1022+
``test``, ``integration`` and ``pypcap-parity``, which share their steps; the
1023+
engine cells that ``engine-tests``' ``Write the per-cell script`` writes;
1024+
``unittest-ordering``'s cells; and each run step of ``project-tests``.
1025+
1026+
``util/cap_file_size.py`` sets ``RLIMIT_FSIZE``, soft and hard, to 2 GiB, or keeps
1027+
a lower hard limit, and execs the command, so the step's exit status and
1028+
``continue-on-error`` behave as before and every process the command starts
1029+
inherits the cap. A write past it raises ``OSError: [Errno 27] File too large`` in
1030+
Python, since CPython ignores ``SIGXFSZ``, so the test errors; any other program is
1031+
killed by the signal, whose default action the script restores before it execs.
1032+
``experimental.yml``'s legs get the same 2 GiB through ``prlimit --fsize`` (see
1033+
above).
1034+
1035+
:file:`tests/project/test_file_size_caps_1969.py` fails when a command in
1036+
``unit-tests.yml`` runs pytest, unittest or :file:`util/run_unittest_leg.py`
1037+
without the script, and runs the script against a small limit.
1038+
10131039
Python Versions
10141040
---------------
10151041

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
# -*- coding: utf-8 -*-
2+
"""#1969: every pytest and unittest command in ``unit-tests.yml`` caps the files it writes.
3+
4+
A test that writes without bound fills the runner's disk (#1372's ``out.json``).
5+
:file:`util/cap_file_size.py` sets ``RLIMIT_FSIZE`` and execs the command, and is
6+
the one place the limit is written down. This module pins both halves: every step
7+
of :file:`.github/workflows/unit-tests.yml` that runs pytest, unittest or
8+
:file:`util/run_unittest_leg.py` does so through the script, so a new step without
9+
it fails here; and the script caps what its command writes, so that a write past
10+
the cap errors rather than passes.
11+
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import errno
17+
import os
18+
import pathlib
19+
import re
20+
import signal
21+
import subprocess # nosec: B404
22+
import sys
23+
import tempfile
24+
import unittest
25+
26+
from tests._workflows import WORKFLOWS, load, loads
27+
28+
try:
29+
import resource
30+
except ImportError: # pragma: no cover - not on Windows
31+
resource = None # type: ignore[assignment]
32+
33+
ROOT = pathlib.Path(__file__).resolve().parents[2]
34+
SCRIPT = ROOT / 'util' / 'cap_file_size.py'
35+
DOC = ROOT / 'docs' / 'source' / 'contributing' / 'workflows.rst'
36+
WORKFLOW = 'unit-tests.yml'
37+
#: How a step runs a command under the cap.
38+
CAP = 'python util/cap_file_size.py '
39+
#: A command that runs tests: pytest or unittest as a module, coverage over
40+
#: either, or util/run_unittest_leg.py.
41+
RUNS_TESTS = re.compile(r'-m (?:pytest|unittest|coverage run)\b|util/run_unittest_leg\.py\b')
42+
#: The jobs that run tests, each of which must be found running some.
43+
TEST_JOBS = frozenset({
44+
'test', 'test-ktlo', 'integration', 'integration-ktlo', 'engine-tests',
45+
'pypcap-parity', 'pypcap-parity-ktlo', 'unittest-ordering', 'project-tests', 'gate',
46+
})
47+
48+
if str(ROOT / 'util') not in sys.path:
49+
sys.path.insert(0, str(ROOT / 'util'))
50+
51+
import cap_file_size # noqa: E402 pylint: disable=wrong-import-position
52+
53+
54+
def commands(script: 'str') -> 'list[str]':
55+
"""The logical lines of ``script``, continuations joined, comments and blanks dropped."""
56+
lines = []
57+
for line in script.replace('\\\n', ' ').splitlines():
58+
line = line.strip()
59+
if line and not line.startswith('#'):
60+
lines.append(line)
61+
return lines
62+
63+
64+
def suite_commands(workflow: 'dict[str, object]') -> 'list[tuple[str, str, str]]':
65+
"""Every ``(job, step, command)`` of ``workflow`` that runs tests."""
66+
found = []
67+
for job_id, spec in workflow['jobs'].items(): # type: ignore[attr-defined]
68+
for entry in spec.get('steps', ()):
69+
for line in commands(str(entry.get('run', ''))):
70+
if RUNS_TESTS.search(line):
71+
found.append((job_id, entry.get('name', entry.get('id', '?')), line))
72+
return found
73+
74+
75+
def uncapped(workflow: 'dict[str, object]') -> 'list[tuple[str, str, str]]':
76+
"""The test commands of ``workflow`` that do not run through :data:`CAP`."""
77+
bad = []
78+
for where in suite_commands(workflow):
79+
line = where[2]
80+
start = RUNS_TESTS.search(line).start() # type: ignore[union-attr]
81+
at = line.find(CAP)
82+
if at == -1 or at + len(CAP) > start or not line[at + len(CAP):].startswith('python '):
83+
bad.append(where)
84+
return bad
85+
86+
87+
class TestEveryTestStepIsCapped(unittest.TestCase):
88+
"""Each pytest and unittest command in ``unit-tests.yml`` runs under the cap."""
89+
90+
def setUp(self) -> None:
91+
self.text = (WORKFLOWS / WORKFLOW).read_text(encoding='utf-8')
92+
self.workflow = load(WORKFLOW)
93+
94+
def test_every_test_command_runs_through_the_script(self) -> None:
95+
self.assertEqual(uncapped(self.workflow), [])
96+
97+
def test_every_test_job_is_seen(self) -> None:
98+
# The scan is not vacuous: each job that runs tests is found doing so,
99+
# the KTLO ones through their YAML alias, the engine cells inside the
100+
# script their step writes.
101+
seen = {job_id for job_id, _, _ in suite_commands(self.workflow)}
102+
self.assertLessEqual(TEST_JOBS, seen)
103+
104+
def test_a_doctored_copy_without_the_cap_is_caught(self) -> None:
105+
bare = uncapped(loads(self.text.replace(CAP, '')))
106+
self.assertLessEqual(TEST_JOBS, {job_id for job_id, _, _ in bare})
107+
extra = loads(self.text.replace(
108+
' - name: Run full test suite\n run: ' + CAP,
109+
' - name: Run a new suite\n run: python -m pytest tests/project\n'
110+
' - name: Run full test suite\n run: ' + CAP, 1))
111+
self.assertEqual(uncapped(extra), [('gate', 'Run a new suite', 'python -m pytest tests/project')])
112+
113+
def test_the_cap_has_one_mechanism(self) -> None:
114+
for word in ('ulimit -f', 'prlimit'):
115+
self.assertEqual([line for line in commands(self.text) if word in line], [], word)
116+
117+
118+
@unittest.skipIf(resource is None, 'RLIMIT_FSIZE is POSIX')
119+
class TestTheScript(unittest.TestCase):
120+
"""util/cap_file_size.py caps the file size of the command it execs."""
121+
122+
def setUp(self) -> None:
123+
self.tmp = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with
124+
self.addCleanup(self.tmp.cleanup)
125+
self.path = os.path.join(self.tmp.name, 'out')
126+
127+
def capped(self, limit: 'int', *command: 'str', **kwargs: 'object') -> 'subprocess.CompletedProcess[str]':
128+
"""``command`` run by the script, with :data:`cap_file_size.LIMIT` set to ``limit``."""
129+
main = ('import sys; sys.path.insert(0, sys.argv[1]); import cap_file_size; '
130+
'cap_file_size.LIMIT = int(sys.argv[2]); cap_file_size.main(sys.argv[3:])')
131+
return subprocess.run([sys.executable, '-c', main, str(SCRIPT.parent), str(limit), *command], # nosec: B603
132+
cwd=self.tmp.name, text=True, check=False, timeout=60, **kwargs) # type: ignore[call-overload]
133+
134+
def test_the_limit_is_two_gib(self) -> None:
135+
self.assertEqual(cap_file_size.LIMIT, 2 * 1024 ** 3)
136+
137+
def test_the_command_runs_under_the_limit(self) -> None:
138+
_, hard = resource.getrlimit(resource.RLIMIT_FSIZE)
139+
expected = cap_file_size.LIMIT if hard == resource.RLIM_INFINITY else min(cap_file_size.LIMIT, hard)
140+
probe = 'import resource; print(*resource.getrlimit(resource.RLIMIT_FSIZE))'
141+
result = subprocess.run([sys.executable, str(SCRIPT), sys.executable, '-c', probe], # nosec: B603
142+
capture_output=True, text=True, check=False, timeout=60)
143+
self.assertEqual(result.returncode, 0, result.stderr)
144+
self.assertEqual(result.stdout.split(), [str(expected)] * 2)
145+
146+
def test_a_lower_hard_limit_is_kept(self) -> None:
147+
def lower() -> None:
148+
resource.setrlimit(resource.RLIMIT_FSIZE, (1 << 20, 1 << 20))
149+
150+
probe = 'import resource; print(*resource.getrlimit(resource.RLIMIT_FSIZE))'
151+
result = self.capped(1 << 30, sys.executable, '-c', probe, capture_output=True,
152+
preexec_fn=lower) # pylint: disable=subprocess-popen-preexec-fn
153+
self.assertEqual(result.returncode, 0, result.stderr)
154+
self.assertEqual(result.stdout.split(), [str(1 << 20)] * 2)
155+
156+
def test_a_python_write_past_the_limit_raises_efbig(self) -> None:
157+
writer = ('import sys\nwith open(sys.argv[1], "wb") as file:\n'
158+
' file.write(b"x" * 8192)\n')
159+
result = self.capped(4096, sys.executable, '-c', writer, self.path, capture_output=True)
160+
self.assertNotEqual(result.returncode, 0)
161+
self.assertIn(f'OSError: [Errno {errno.EFBIG}] File too large', result.stderr)
162+
self.assertEqual(os.path.getsize(self.path), 4096)
163+
164+
def test_another_program_is_killed_by_sigxfsz(self) -> None:
165+
# CPython ignores SIGXFSZ, and an ignored signal stays ignored across
166+
# exec, so this holds only because the script restores the default.
167+
with open(self.path, 'wb') as file:
168+
result = self.capped(4096, 'head', '-c', '8192', '/dev/zero', stdout=file,
169+
stderr=subprocess.PIPE)
170+
self.assertEqual(result.returncode, -signal.SIGXFSZ, result.stderr)
171+
self.assertEqual(os.path.getsize(self.path), 4096)
172+
173+
def test_no_command_is_a_usage_error(self) -> None:
174+
result = subprocess.run([sys.executable, str(SCRIPT)], capture_output=True, text=True, # nosec: B603
175+
check=False, timeout=60)
176+
self.assertEqual(result.returncode, cap_file_size.USAGE)
177+
self.assertIn('usage:', result.stderr)
178+
179+
180+
class TestTheWorkflowsPage(unittest.TestCase):
181+
"""docs/source/contributing/workflows.rst names the script for the cap."""
182+
183+
def test_the_page_names_the_script(self) -> None:
184+
text = DOC.read_text(encoding='utf-8')
185+
self.assertIn('``util/cap_file_size.py``', text)
186+
self.assertNotIn('ulimit -f', text)
187+
188+
189+
if __name__ == '__main__':
190+
unittest.main()

‎tests/project/test_project_pins_once_1954.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@ def test_it_runs_on_latest_with_what_test_installs(self) -> None:
6060
def test_it_runs_under_the_caps(self) -> None:
6161
"""xdist as ``test`` runs it, a 2 GiB file cap, and a step cap under the job's."""
6262
run = str(step(JOB, STEP)['run'])
63-
self.assertIn('ulimit -f 2097152\n', run)
64-
self.assertIn('python -m pytest -q -n auto --dist load', run)
63+
self.assertIn('python util/cap_file_size.py python -m pytest -q -n auto --dist load', run)
6564
self.assertIn(_tiers.PROJECT_PINS_SELECTOR, run)
6665
self.assertLess(step(JOB, STEP)['timeout-minutes'], job(*JOB)['timeout-minutes'])
6766

@@ -71,7 +70,7 @@ class TestEveryPinStillRunsWhereTheMatrixDoesNot(unittest.TestCase):
7170

7271
def test_the_gate_runs_the_whole_suite(self) -> None:
7372
self.assertEqual(step(('unit-tests.yml', 'gate'), 'Run full test suite')['run'],
74-
'python -m pytest -q -n auto --dist load')
73+
'python util/cap_file_size.py python -m pytest -q -n auto --dist load')
7574

7675
def test_the_ordering_cell_runs_every_pin(self) -> None:
7776
self.assertIn('project', job('unit-tests.yml', 'unittest-ordering')['strategy']['matrix']['leg'])

0 commit comments

Comments
 (0)