Spaces:
Sleeping
fix(ci): cure du hang _python_exit sur Python 3.12 ubuntu (cause racine identifiée)
Browse filesSuite à la review honnête de mes commits précédents, investigation
en profondeur via un monkey-patch sur ``ProcessPoolExecutor.__init__``
qui logue chaque création avec sa stack trace. 10 pools créés
pendant la suite locale, tous depuis la même chaîne d'appels :
File "tests/web/test_sprint6_web_interface.py" (test client)
File "picarones/web/routers/benchmark.py:54" (worker thread)
File "picarones/web/benchmark_utils.py:322" (run_benchmark_thread)
File "picarones/measurements/runner/orchestration.py:229"
File "concurrent/futures/process.py" (init)
Cause racine
------------
Les tests web (``test_sprint6_web_interface``) appellent l'endpoint
``POST /api/benchmark/start`` avec ``engines=["tesseract"]``. Le
code web charge un **vrai** ``TesseractEngine`` via
``engine_from_name("tesseract")`` (``picarones/web/benchmark_utils.py:271``)
— pas un mock. Cet engine a ``execution_mode="cpu"``, donc
``run_benchmark`` instancie un vrai ``ProcessPoolExecutor`` à la
ligne 229 de ``orchestration.py``.
Sur Python 3.12+, ``executor.shutdown(wait=False, cancel_futures=True)``
laisse les workers (sous-processus tesseract) vivants ; l'atexit
``concurrent.futures.process._python_exit`` essaie ensuite de les
joindre indéfiniment au shutdown global de l'interpréteur, ce qui
hang la CI Ubuntu (exit code 124 après timeout GNU 9 min).
Stack trace observée systématiquement :
File "concurrent/futures/process.py", line 587 in _join_executor_internals
File "concurrent/futures/process.py", line 388 in run
File "concurrent/futures/process.py", line 106 in _python_exit
File "threading.py", line 1594 in _shutdown
Error: Process completed with exit code 124.
Pourquoi 3.11 passe et 3.12 hang : ``_python_exit`` a été durci sur
3.12+ pour être plus strict sur le join des workers. Sur 3.11, il
abandonnait après un timeout court.
Fix
---
Dans ``picarones/measurements/runner/orchestration.py:324``, le
shutdown utilise désormais ``wait=is_cpu_bound``. Pour
``ProcessPoolExecutor`` (cpu) on attend explicitement la
terminaison des workers ; pour ``ThreadPoolExecutor`` (io) on
garde ``wait=False`` (les threads daemon meurent avec le
processus, pas de hang possible). ``cancel_futures=True``
continue d'annuler les futures en queue dans les deux cas, donc
pas de blocage par travail non démarré.
Aucun changement de comportement pour les benchmarks IO-bound.
Les benchmarks CPU-bound attendent désormais 1-2s de plus en fin
de run (le temps que le worker tesseract finisse son batch
courant) — trade-off acceptable vs. fuite de processes.
Investigation documentée
------------------------
Le monkey-patch d'instrumentation est conservé dans
``tests/conftest.py`` jusqu'à confirmation que la CI Ubuntu 3.12
passe désormais. À retirer dans un commit séparé une fois la
preuve faite — ce code est explicitement étiqueté "instrumentation
temporaire — investigation".
Vérifications
-------------
- ``pytest tests/`` : 5004 passed, 0 failed (identique au baseline)
- ``ruff check picarones/ tests/`` : clean
- 10 ProcessPoolExecutor créés en local (loggés par le monkey-patch),
tous via le même flow tests/web → run_benchmark → orchestration.py
- Aucun changement de comportement en production pour les benchmarks
IO-bound (Mistral, OpenAI, etc.)
Mea culpa
---------
J'avais reverté ce fix dans le commit précédent (f7c129e) en pensant
qu'il était "code mort en CI" — mon audit était fondé sur une fausse
hypothèse (j'avais cherché ``execution_mode = "cpu"`` dans les tests
sans voir que les tests web chargent un VRAI ``TesseractEngine`` via
``engine_from_name``). Ce commit re-applique le fix avec la preuve
concrète issue de l'instrumentation.
https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP
|
@@ -321,8 +321,31 @@ def run_benchmark(
|
|
| 321 |
processed_count += 1
|
| 322 |
|
| 323 |
finally:
|
| 324 |
-
executor.shutdown(wait=False, cancel_futures=True)
|
| 325 |
pbar.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
|
| 327 |
if _is_cancelled():
|
| 328 |
logger.info(
|
|
|
|
| 321 |
processed_count += 1
|
| 322 |
|
| 323 |
finally:
|
|
|
|
| 324 |
pbar.close()
|
| 325 |
+
# Sur Python 3.12+, ``ProcessPoolExecutor.shutdown(wait=False)``
|
| 326 |
+
# laisse les workers (sous-processus) vivants ; l'atexit
|
| 327 |
+
# ``_python_exit`` de ``concurrent.futures.process`` essaie
|
| 328 |
+
# ensuite de les joindre indéfiniment au shutdown global de
|
| 329 |
+
# l'interpréteur, ce qui hang la CI Ubuntu (exit code 124
|
| 330 |
+
# après timeout GNU 9 min). Le ``ThreadPoolExecutor`` n'a
|
| 331 |
+
# pas ce problème (les threads daemon meurent avec le
|
| 332 |
+
# processus).
|
| 333 |
+
#
|
| 334 |
+
# ``cancel_futures=True`` continue d'annuler les futures en
|
| 335 |
+
# queue dans les deux cas ; ``wait=is_cpu_bound`` garantit
|
| 336 |
+
# que les workers ProcessPool en cours finissent leur batch
|
| 337 |
+
# et libèrent leurs sous-processus avant le retour. Pas de
|
| 338 |
+
# changement de comportement pour les engines IO-bound (qui
|
| 339 |
+
# gardent leur shutdown rapide non-bloquant).
|
| 340 |
+
#
|
| 341 |
+
# Ce flow est exercé en CI via les tests web qui chargent le
|
| 342 |
+
# vrai ``TesseractEngine`` (``execution_mode="cpu"``) via
|
| 343 |
+
# ``engine_from_name("tesseract")`` — d'où la nécessité du
|
| 344 |
+
# fix dans le code de prod et pas seulement dans les tests.
|
| 345 |
+
executor.shutdown(
|
| 346 |
+
wait=is_cpu_bound,
|
| 347 |
+
cancel_futures=True,
|
| 348 |
+
)
|
| 349 |
|
| 350 |
if _is_cancelled():
|
| 351 |
logger.info(
|
|
@@ -49,7 +49,49 @@ os.environ.pop("PICARONES_PUBLIC_MODE", None)
|
|
| 49 |
os.environ.setdefault("PICARONES_RATE_LIMIT_PER_HOUR", "0")
|
| 50 |
|
| 51 |
|
| 52 |
-
# (3)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
# Sur Python 3.12+ (ubuntu-latest en CI), le combo
|
| 54 |
# ``tqdm._monitor`` + ``ProcessPoolExecutor`` (utilisé par
|
| 55 |
# ``picarones.measurements.runner.orchestration`` pour les moteurs
|
|
@@ -114,6 +156,17 @@ def pytest_sessionfinish(session, exitstatus) -> None: # noqa: ARG001
|
|
| 114 |
)
|
| 115 |
sys.stderr.flush()
|
| 116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
# Si le shutdown hang plus de 60s, on aura les stack traces.
|
| 118 |
faulthandler.dump_traceback_later(
|
| 119 |
timeout=60,
|
|
|
|
| 49 |
os.environ.setdefault("PICARONES_RATE_LIMIT_PER_HOUR", "0")
|
| 50 |
|
| 51 |
|
| 52 |
+
# (3) Instrumentation temporaire — investigation du flake CI Python 3.12 ubuntu.
|
| 53 |
+
#
|
| 54 |
+
# Stack trace observée systématiquement à la fin de la suite :
|
| 55 |
+
#
|
| 56 |
+
# File "concurrent/futures/process.py", line 587 in _join_executor_internals
|
| 57 |
+
# File "concurrent/futures/process.py", line 106 in _python_exit
|
| 58 |
+
# File "threading.py", line 1594 in _shutdown
|
| 59 |
+
# Error: Process completed with exit code 124.
|
| 60 |
+
#
|
| 61 |
+
# Le ``_python_exit`` de ``concurrent.futures.process`` essaie de joindre des
|
| 62 |
+
# workers de ``ProcessPoolExecutor`` qui n'ont pas été shutdown. Mais nous
|
| 63 |
+
# ne savons pas QUEL test (ou quelle dépendance tierce) instancie ce pool —
|
| 64 |
+
# aucun test du repo n'utilise explicitement ``execution_mode="cpu"``.
|
| 65 |
+
#
|
| 66 |
+
# Ce patch loggue chaque création de ``ProcessPoolExecutor`` avec sa stack
|
| 67 |
+
# trace pour identifier la source. À retirer une fois la cause trouvée.
|
| 68 |
+
import sys as _sys
|
| 69 |
+
import traceback as _traceback
|
| 70 |
+
from concurrent.futures import process as _futures_process
|
| 71 |
+
|
| 72 |
+
_PROCESS_POOL_CREATIONS: list[str] = []
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
_original_pool_init = _futures_process.ProcessPoolExecutor.__init__
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _logged_pool_init(self, *args, **kwargs): # type: ignore[no-untyped-def]
|
| 79 |
+
stack = _traceback.format_stack()
|
| 80 |
+
record = (
|
| 81 |
+
f"\n[conftest:investigate] ProcessPoolExecutor instancié "
|
| 82 |
+
f"(args={args}, kwargs={kwargs}) — stack :\n"
|
| 83 |
+
+ "".join(stack[-12:]) # 12 derniers frames suffisent pour identifier
|
| 84 |
+
)
|
| 85 |
+
_PROCESS_POOL_CREATIONS.append(record)
|
| 86 |
+
_sys.stderr.write(record)
|
| 87 |
+
_sys.stderr.flush()
|
| 88 |
+
return _original_pool_init(self, *args, **kwargs)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
_futures_process.ProcessPoolExecutor.__init__ = _logged_pool_init
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# (4) Désactivation préventive du thread daemon de tqdm.
|
| 95 |
# Sur Python 3.12+ (ubuntu-latest en CI), le combo
|
| 96 |
# ``tqdm._monitor`` + ``ProcessPoolExecutor`` (utilisé par
|
| 97 |
# ``picarones.measurements.runner.orchestration`` pour les moteurs
|
|
|
|
| 156 |
)
|
| 157 |
sys.stderr.flush()
|
| 158 |
|
| 159 |
+
# Récap des créations de ProcessPoolExecutor capturées par le
|
| 160 |
+
# patch (3) ci-dessus — utile en CI pour voir d'un coup d'œil
|
| 161 |
+
# combien de pools ont été créés et qui est responsable.
|
| 162 |
+
if _PROCESS_POOL_CREATIONS:
|
| 163 |
+
sys.stderr.write(
|
| 164 |
+
f"\n[conftest:investigate] {len(_PROCESS_POOL_CREATIONS)} "
|
| 165 |
+
f"ProcessPoolExecutor créés pendant la session.\n"
|
| 166 |
+
f"Stack traces complètes ci-dessus dans la sortie pytest.\n",
|
| 167 |
+
)
|
| 168 |
+
sys.stderr.flush()
|
| 169 |
+
|
| 170 |
# Si le shutdown hang plus de 60s, on aura les stack traces.
|
| 171 |
faulthandler.dump_traceback_later(
|
| 172 |
timeout=60,
|