Claude commited on
Commit
24ef9e8
·
unverified ·
1 Parent(s): 4f11aa7

fix(tesseract): timeout sous-processus — un binaire figé gelait tout le run sans log

Browse files

Symptôme production : benchmark Tesseract « charge un document puis
gèle », aucun log, aucune erreur, aucun timeout ne se déclenche.

Cause racine (pré-existante, indépendante du commit retry/image qui
ne touche pas le chemin OCR Tesseract) : les trois appels
pytesseract (image_to_string / image_to_data / image_to_alto_xml)
n'avaient PAS de paramètre `timeout`. pytesseract par défaut
(`timeout=0`) attend le sous-processus `tesseract` indéfiniment. Si
le binaire se fige sur une page, le thread worker reste bloqué dans
un appel C non-interruptible : le CorpusRunner ne peut pas le tuer
(documenté pipeline/runner.py), son timeout/doc est donc inopérant,
les workers du pool se bloquent → run gelé, SSE sans événement à
émettre (d'où « logs show nothing »).

Fix : `timeout_seconds` configurable (défaut 120.0 s, donc sûr sans
config ; `0` = opt-out explicite déconseillé), validé `>= 0`,
forwardé aux 3 appels pytesseract. factory.py:120 passe déjà
`**kwargs` → actif automatiquement dans le chemin web, tunable. Un
sous-processus figé devient un échec borné du document (mappé en
OCRAdapterError, le run continue) au lieu d'un gel infini.

Note (hors scope, non corrigé ici) : state.py:170 fait
`asyncio.Queue.put_nowait()` depuis le thread worker (cross-thread,
sans loop.call_soon_threadsafe). C'est un défaut réel mais masqué en
livraison SSE saccadée ~30 s par la boucle keepalive
`wait_for(timeout=30)` — latence, pas gel permanent. À traiter
séparément si besoin.

Régression : TestTesseractSubprocessTimeout (5 tests). Suite ciblée
OCR : 66 passed.

https://claude.ai/code/session_01KTzTK55Hxu8AR72xJUjcUW

picarones/adapters/ocr/tesseract.py CHANGED
@@ -92,6 +92,16 @@ class TesseractAdapter(BaseOCRAdapter):
92
  tesseract_cmd:
93
  Chemin custom vers l'exécutable ``tesseract``. Défaut
94
  ``None`` (laisse pytesseract trouver l'installation système).
 
 
 
 
 
 
 
 
 
 
95
 
96
  Raises
97
  ------
@@ -131,6 +141,7 @@ class TesseractAdapter(BaseOCRAdapter):
131
  tesseract_cmd: str | None = None,
132
  expose_confidences: bool = True,
133
  expose_alto: bool = False,
 
134
  ) -> None:
135
  if not name or not name.strip():
136
  raise OCRAdapterError(
@@ -158,6 +169,11 @@ class TesseractAdapter(BaseOCRAdapter):
158
  raise OCRAdapterError(
159
  f"TesseractAdapter : oem doit être ∈ [0, 3], reçu {oem}.",
160
  )
 
 
 
 
 
161
  self._name = name
162
  self._lang = lang
163
  self._psm = psm
@@ -165,6 +181,7 @@ class TesseractAdapter(BaseOCRAdapter):
165
  self._tesseract_cmd = tesseract_cmd
166
  self._expose_confidences = expose_confidences
167
  self._expose_alto = expose_alto
 
168
 
169
  @property
170
  def name(self) -> str:
@@ -247,6 +264,7 @@ class TesseractAdapter(BaseOCRAdapter):
247
  image,
248
  lang=self._lang,
249
  config=custom_config,
 
250
  )
251
  except Exception as exc:
252
  raise OCRAdapterError(
@@ -348,6 +366,7 @@ class TesseractAdapter(BaseOCRAdapter):
348
  lang=self._lang,
349
  config=custom_config,
350
  output_type=pytesseract_module.Output.DICT,
 
351
  )
352
  except Exception as exc: # noqa: BLE001 — best-effort
353
  logger.warning(
@@ -407,6 +426,7 @@ class TesseractAdapter(BaseOCRAdapter):
407
  image,
408
  lang=self._lang,
409
  config=custom_config,
 
410
  )
411
  except Exception as exc: # noqa: BLE001 — best-effort
412
  logger.warning(
 
92
  tesseract_cmd:
93
  Chemin custom vers l'exécutable ``tesseract``. Défaut
94
  ``None`` (laisse pytesseract trouver l'installation système).
95
+ timeout_seconds:
96
+ Délai max (s) du sous-processus ``tesseract`` par image.
97
+ Défaut ``120.0``. **Garde-fou critique** : un appel
98
+ ``pytesseract`` sans timeout bloque indéfiniment le thread
99
+ worker si le binaire ``tesseract`` se fige sur une page
100
+ (le ``CorpusRunner`` ne peut pas interrompre un sous-processus
101
+ bloquant — cf. ``pipeline/runner.py``), ce qui gèle tout le
102
+ run sans log ni erreur. Avec un timeout, pytesseract tue le
103
+ sous-processus et lève : le doc échoue proprement et le run
104
+ continue. ``0`` désactive explicitement (déconseillé).
105
 
106
  Raises
107
  ------
 
141
  tesseract_cmd: str | None = None,
142
  expose_confidences: bool = True,
143
  expose_alto: bool = False,
144
+ timeout_seconds: float = 120.0,
145
  ) -> None:
146
  if not name or not name.strip():
147
  raise OCRAdapterError(
 
169
  raise OCRAdapterError(
170
  f"TesseractAdapter : oem doit être ∈ [0, 3], reçu {oem}.",
171
  )
172
+ if timeout_seconds < 0:
173
+ raise OCRAdapterError(
174
+ "TesseractAdapter : timeout_seconds doit être >= 0 "
175
+ f"(0 = désactivé), reçu {timeout_seconds}.",
176
+ )
177
  self._name = name
178
  self._lang = lang
179
  self._psm = psm
 
181
  self._tesseract_cmd = tesseract_cmd
182
  self._expose_confidences = expose_confidences
183
  self._expose_alto = expose_alto
184
+ self._timeout = float(timeout_seconds)
185
 
186
  @property
187
  def name(self) -> str:
 
264
  image,
265
  lang=self._lang,
266
  config=custom_config,
267
+ timeout=self._timeout,
268
  )
269
  except Exception as exc:
270
  raise OCRAdapterError(
 
366
  lang=self._lang,
367
  config=custom_config,
368
  output_type=pytesseract_module.Output.DICT,
369
+ timeout=self._timeout,
370
  )
371
  except Exception as exc: # noqa: BLE001 — best-effort
372
  logger.warning(
 
426
  image,
427
  lang=self._lang,
428
  config=custom_config,
429
+ timeout=self._timeout,
430
  )
431
  except Exception as exc: # noqa: BLE001 — best-effort
432
  logger.warning(
tests/adapters/ocr/test_sprint_a14_s30_tesseract_adapter.py CHANGED
@@ -388,3 +388,69 @@ class TestTesseractAdapterExecute:
388
  encoding="utf-8",
389
  )
390
  assert out_text == "Hello world"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  encoding="utf-8",
389
  )
390
  assert out_text == "Hello world"
391
+
392
+
393
+ class TestTesseractSubprocessTimeout:
394
+ """Garde-fou anti-freeze : un sous-processus ``tesseract`` figé
395
+ bloquait indéfiniment le thread worker (le CorpusRunner ne peut
396
+ pas tuer un sous-processus bloquant) → run gelé sans log ni
397
+ erreur. ``pytesseract`` doit recevoir un ``timeout`` pour
398
+ convertir le blocage infini en échec borné du document."""
399
+
400
+ def _img(self, tmp_path: Path) -> Path:
401
+ p = tmp_path / "page.png"
402
+ p.write_bytes(b"\x89PNG\r\n\x1a\n")
403
+ return p
404
+
405
+ def test_rejects_negative_timeout(self) -> None:
406
+ with pytest.raises(OCRAdapterError, match="timeout_seconds"):
407
+ TesseractAdapter(timeout_seconds=-1)
408
+
409
+ def test_zero_timeout_allowed_explicit_optout(self) -> None:
410
+ TesseractAdapter(timeout_seconds=0) # ne lève pas
411
+
412
+ @patch("PIL.Image.open")
413
+ @patch("pytesseract.image_to_string")
414
+ def test_timeout_forwarded_to_pytesseract(
415
+ self, mock_its: MagicMock, mock_open: MagicMock, tmp_path: Path,
416
+ ) -> None:
417
+ mock_its.return_value = "x"
418
+ mock_open.return_value.__enter__.return_value = MagicMock()
419
+ adapter = TesseractAdapter(
420
+ expose_confidences=False, timeout_seconds=37.5,
421
+ )
422
+ adapter.execute(
423
+ inputs={ArtifactType.IMAGE: _make_image_artifact(
424
+ str(self._img(tmp_path)))},
425
+ params={},
426
+ context=_make_context(),
427
+ )
428
+ assert mock_its.call_args.kwargs["timeout"] == 37.5
429
+
430
+ def test_default_timeout_is_bounded_not_infinite(self) -> None:
431
+ # Le défaut DOIT être > 0 : c'est précisément l'absence de
432
+ # timeout qui gelait le run en production.
433
+ adapter = TesseractAdapter()
434
+ assert adapter._timeout > 0
435
+
436
+ @patch("PIL.Image.open")
437
+ @patch("pytesseract.image_to_string")
438
+ def test_subprocess_timeout_becomes_bounded_error(
439
+ self, mock_its: MagicMock, mock_open: MagicMock, tmp_path: Path,
440
+ ) -> None:
441
+ # pytesseract lève ``RuntimeError('Tesseract process timeout')``
442
+ # quand le sous-processus dépasse ``timeout``. L'adapter doit
443
+ # le mapper en OCRAdapterError (doc échoué, run continue) au
444
+ # lieu de bloquer indéfiniment.
445
+ mock_its.side_effect = RuntimeError("Tesseract process timeout")
446
+ mock_open.return_value.__enter__.return_value = MagicMock()
447
+ adapter = TesseractAdapter(
448
+ expose_confidences=False, timeout_seconds=5,
449
+ )
450
+ with pytest.raises(OCRAdapterError, match="Tesseract"):
451
+ adapter.execute(
452
+ inputs={ArtifactType.IMAGE: _make_image_artifact(
453
+ str(self._img(tmp_path)))},
454
+ params={},
455
+ context=_make_context(),
456
+ )