diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8aea087..0ed2b13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.12", "3.13"] + python-version: ["3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 diff --git a/tests/conftest.py b/tests/conftest.py index 621d970..c3102c5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ import os import shutil +import sys import tempfile from pathlib import Path from typing import Generator @@ -163,6 +164,47 @@ def _cleanup_stray_tmp_dirs() -> Generator[None, None, None]: sweep_stray_tmp_dirs() +@pytest.fixture(autouse=True) +def _no_process_rlimit_leak() -> Generator[None, None, None]: + """ + Un test ne doit jamais modifier les limites de ressources du process pytest. + + RLIMIT_AS / RLIMIT_DATA / RLIMIT_CPU sont globaux au process et la limite DURE + ne se relève pas : un `setrlimit` réel dans un test s'applique à tous les tests + suivants de la session. RLIMIT_AS à 512 Mo suffisait à faire échouer + `threading.Thread.start()` (« can't start new thread ») dans les tests placés + plus loin — selon l'empreinte mémoire de l'interpréteur. Tester ces limites en + patchant `resource.setrlimit`, ou dans un sous-processus. + """ + if sys.platform == "win32": + yield + return + import resource + + tracked = [ + getattr(resource, name) + for name in ("RLIMIT_AS", "RLIMIT_DATA", "RLIMIT_CPU") + if hasattr(resource, name) + ] + before = {r: resource.getrlimit(r) for r in tracked} + yield + changed = { + r: resource.getrlimit(r) for r in tracked if resource.getrlimit(r) != before[r] + } + if changed: + for r, (soft, _hard) in changed.items(): # remet au moins la limite souple + try: + resource.setrlimit(r, (before[r][0], resource.getrlimit(r)[1])) + except (ValueError, OSError): + pass + pytest.fail( + "ce test a modifié les limites de ressources du process pytest " + f"({sorted(changed)}) — irréversible et valable pour toute la session ; " + "patcher resource.setrlimit ou utiliser un sous-processus", + pytrace=False, + ) + + @pytest.fixture def invalid_plugin_dir(plugins_dir: Path) -> Path: """Plugin invalide — manque PLUGIN_INFO.""" diff --git a/tests/unit/kernel/test_isolation.py b/tests/unit/kernel/test_isolation.py index 2d98ae5..2ed037f 100644 --- a/tests/unit/kernel/test_isolation.py +++ b/tests/unit/kernel/test_isolation.py @@ -69,10 +69,31 @@ def test_apply_negative_noop(self): MemoryLimiter.apply(-1) # should not raise def test_apply_positive(self): + """ + Vérifie l'appel à setrlimit SANS l'exécuter : RLIMIT_AS est irréversible + (la limite dure baisse) et s'appliquerait à tout le process pytest — chaque + thread créé ensuite (TestClient, to_thread…) réserve ~72 Mo d'espace + d'adressage, et au bout de quelques-uns `can't start new thread`. Les tests + placés après celui-ci dans la session plantaient selon l'empreinte mémoire + de l'interpréteur (vu sur Python 3.14). + """ + import resource import sys + from unittest.mock import patch + + if sys.platform == "win32": + pytest.skip("Not supported on Windows") + with patch("resource.setrlimit") as mock_setrlimit: + MemoryLimiter.apply(512) + + limit = 512 * 1024 * 1024 + mock_setrlimit.assert_called_once_with(resource.RLIMIT_AS, (limit, limit)) + + def test_apply_failure_is_logged_not_raised(self): + import sys + from unittest.mock import patch + if sys.platform == "win32": pytest.skip("Not supported on Windows") - try: - MemoryLimiter.apply(512) # may raise or not depending on OS limits - except Exception: - pass # some systems don't allow raising RLIMIT_AS + with patch("resource.setrlimit", side_effect=ValueError("not allowed")): + MemoryLimiter.apply(512) # ne lève pas