Claude commited on
Commit
78846f6
·
unverified ·
1 Parent(s): f7c129e

fix(ci): cure du hang _python_exit sur Python 3.12 ubuntu (cause racine identifiée)

Browse files

Suite à 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

picarones/measurements/runner/orchestration.py CHANGED
@@ -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(
tests/conftest.py CHANGED
@@ -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) Désactivation préventive du thread daemon de tqdm.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,