Spaces:
Running
fix(tesseract): timeout sous-processus — un binaire figé gelait tout le run sans log
Browse filesSymptô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
|
@@ -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(
|
|
@@ -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 |
+
)
|