Claude commited on
Commit
fbe1c00
·
unverified ·
1 Parent(s): b784716

feat(pipeline): RunControl + RunContext.deadline + DeadlineExceeded

Browse files

Préparation du terrain pour la migration adapter atomique (cf.
ADR-0001). N'introduit pas de breaking change — la signature
``execute(inputs, params, context)`` reste inchangée à ce stade.
Les nouveaux types coexistent sans être encore branchés.

## ``Deadline`` Pydantic-compat

Ajout de ``__get_pydantic_core_schema__`` pour utiliser ``Deadline``
comme type de champ Pydantic. Validation accepte ``Deadline``
direct ou dict ``{"remaining_seconds": float | None}`` (entrée
JSON / IPC). Sérialisation passe par ``to_dict()``.

L'import de ``pydantic_core`` dans la couche ``domain`` est
ajouté à la whitelist ``EXTERNAL_ALLOWED`` : c'est le backend
Rust de ``pydantic`` et son API officielle pour types custom,
sémantiquement indissociable.

## ``RunContext.deadline``

Nouveau champ Pydantic ``deadline: Deadline = Field(default_factory=
Deadline.infinite)``. Default ``infinite`` → les ~31 sites de
construction de ``RunContext`` dans les tests existants continuent
à fonctionner sans modification.

Round-trip ``model_dump_json`` / ``model_validate_json`` préserve
la sémantique cross-process : l'overhead de dispatch n'est pas
facturé au receveur (test
``test_json_roundtrip_preserves_handoff_semantics``).

## ``RunControl`` (nouveau module)

État runtime mutable d'un step d'exécution, séparé de ``RunContext``
parce qu'il porte du non-sérialisable :

- ``cancel_event`` : ``threading.Event`` partagé caller ↔ adapter.
- ``cancel_handles`` : fonctions enregistrées par l'adapter pour
permettre au runner de kill cross-thread (ex : fermer un client
HTTP, qui fait lever l'exception dans le thread bloqué).

Thread-safe : enregistrement et déclenchement concurrents validés
par stress test (20 threads). Idempotent : double ``trigger_cancel``
= no-op. Un handle enregistré après ``trigger_cancel`` est appelé
immédiatement (l'adapter ne peut pas s'enregistrer et continuer
comme si de rien n'était). Un handle qui lève n'empêche pas les
autres d'être appelés (log warning explicite, pas d'absorption
silencieuse).

## ``DeadlineExceeded`` (nouvelle exception)

Sous-classe transverse d'``AdapterStepError``. Permet à un
adapter de signaler explicitement l'expiration de la deadline
propagée, distincte d'un échec fonctionnel arbitraire. Ne
descend pas d'``OCRAdapterError`` / ``LLMAdapterError`` /
``VLMAdapterError`` — c'est une cause de terminaison transverse,
pas un échec de domaine.

## Validation

- 92 nouveaux tests verts (50 Deadline + 14 RunControl +
15 RunContext.deadline + 13 error_hierarchy).
- **1147 tests non-régression** verts : pipeline (183), adapters
(523), app/services (71), integration (370).
- mypy strict sur domain/ passe.
- Sprint narrative ratchet à 479 (baseline).
- Lint ruff propre.

Imports canoniques :

- ``from picarones.domain import Deadline``
- ``from picarones.domain.errors import DeadlineExceeded``
- ``from picarones.pipeline.run_control import RunControl, RunCancelledError``

https://claude.ai/code/session_01B93huMjNh4CG2rNcexgDeL

picarones/domain/deadline.py CHANGED
@@ -83,6 +83,9 @@ from __future__ import annotations
83
  import time
84
  from typing import Any
85
 
 
 
 
86
  from picarones.domain.errors import PicaronesError
87
 
88
 
@@ -316,5 +319,46 @@ class Deadline:
316
  return "Deadline(expired)"
317
  return f"Deadline(remaining={remaining:.3f}s)"
318
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
 
320
  __all__ = ["Deadline"]
 
83
  import time
84
  from typing import Any
85
 
86
+ from pydantic import GetCoreSchemaHandler
87
+ from pydantic_core import core_schema
88
+
89
  from picarones.domain.errors import PicaronesError
90
 
91
 
 
319
  return "Deadline(expired)"
320
  return f"Deadline(remaining={remaining:.3f}s)"
321
 
322
+ # ──────────────────────────────────────────────────────────────────
323
+ # Compat Pydantic (utilisable comme type de champ dans un BaseModel)
324
+ # ──────────────────────────────────────────────────────────────────
325
+
326
+ @classmethod
327
+ def __get_pydantic_core_schema__(
328
+ cls,
329
+ source_type: Any, # noqa: ARG003
330
+ handler: GetCoreSchemaHandler, # noqa: ARG003
331
+ ) -> core_schema.CoreSchema:
332
+ """Permet à Pydantic de valider/sérialiser un ``Deadline``.
333
+
334
+ Validation accepte deux formes :
335
+
336
+ - une instance ``Deadline`` directe (pass-through, runtime
337
+ typique) ;
338
+ - un dict ``{"remaining_seconds": float | None}`` (entrée
339
+ JSON / IPC — auto-reconstitué relatif au monotonic du
340
+ process receveur via ``from_dict``).
341
+
342
+ Sérialisation : appelle ``to_dict()`` (forme stable et
343
+ transposable).
344
+ """
345
+
346
+ def _validate(value: Any) -> "Deadline":
347
+ if isinstance(value, cls):
348
+ return value
349
+ if isinstance(value, dict):
350
+ return cls.from_dict(value)
351
+ raise ValueError(
352
+ f"Deadline : impossible de valider une valeur de type "
353
+ f"{type(value).__name__} (attendu Deadline ou dict).",
354
+ )
355
+
356
+ return core_schema.no_info_plain_validator_function(
357
+ _validate,
358
+ serialization=core_schema.plain_serializer_function_ser_schema(
359
+ lambda d: d.to_dict(),
360
+ ),
361
+ )
362
+
363
 
364
  __all__ = ["Deadline"]
picarones/domain/errors.py CHANGED
@@ -71,10 +71,48 @@ class AdapterStepError(PicaronesError):
71
  """
72
 
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  __all__ = [
75
  "PicaronesError",
76
  "ArtifactValidationError",
77
  "ProjectionError",
78
  "CorpusSpecError",
79
  "AdapterStepError",
 
80
  ]
 
71
  """
72
 
73
 
74
+ class DeadlineExceeded(AdapterStepError):
75
+ """Un adapter a détecté l'expiration de la ``Deadline`` reçue
76
+ via le ``RunContext``.
77
+
78
+ Levée par un adapter qui respecte coopérativement la deadline
79
+ propagée (cf. [ADR-0001](../../docs/explanation/adr/0001-multi-domain-execution.md)).
80
+ Distincte des autres ``AdapterStepError`` pour permettre au
81
+ runner de catégoriser la cause de terminaison comme
82
+ ``DEADLINE_EXCEEDED_COOPERATIVE`` plutôt que comme un échec
83
+ fonctionnel — c'est l'information critique pour l'audit
84
+ institutionnel.
85
+
86
+ Sémantique
87
+ ----------
88
+
89
+ Un adapter doit lever ``DeadlineExceeded`` quand :
90
+
91
+ - Sa boucle interne (multi-step, multi-page) détecte
92
+ ``context.deadline.is_expired()`` avant d'avoir fini.
93
+ - Son SDK lève une exception type ``TimeoutError`` /
94
+ ``httpx.TimeoutException`` (etc.) parce qu'on lui a passé
95
+ ``context.deadline.as_sdk_timeout()`` qui s'est déclenché.
96
+ → l'adapter wrap et re-lève en ``DeadlineExceeded``.
97
+
98
+ Un adapter ne doit PAS lever ``DeadlineExceeded`` pour :
99
+
100
+ - Un timeout HTTP interne au SDK non lié à la deadline
101
+ (ex : timeout TCP par défaut du SDK) → ``AdapterStepError``
102
+ standard.
103
+ - Une expiration côté serveur (ex : 504 Gateway Timeout) →
104
+ ``AdapterStepError`` standard avec message explicite.
105
+
106
+ Le runner traduit cette distinction en cause de terminaison
107
+ structurée dans le résultat — ne pas la perdre.
108
+ """
109
+
110
+
111
  __all__ = [
112
  "PicaronesError",
113
  "ArtifactValidationError",
114
  "ProjectionError",
115
  "CorpusSpecError",
116
  "AdapterStepError",
117
+ "DeadlineExceeded",
118
  ]
picarones/pipeline/run_control.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``RunControl`` — état runtime mutable d'un step d'exécution.
2
+
3
+ Compagnon de ``RunContext`` (cf. ``pipeline/types.py``), porte le
4
+ côté **non sérialisable** d'un step :
5
+
6
+ - ``cancel_event`` : signal de cancellation coopératif (le caller
7
+ set l'event ; l'adapter le check entre opérations bloquantes).
8
+ - ``cancel_handles`` : fonctions enregistrées par l'adapter pour
9
+ permettre au runner de tuer cross-thread l'opération en cours
10
+ (ex : fermer un client HTTP, ce qui fait lever l'exception dans
11
+ le thread bloqué sur la requête).
12
+
13
+ Pourquoi séparé de ``RunContext`` ?
14
+ -----------------------------------
15
+
16
+ ``RunContext`` est inclus dans le ``RunManifest`` d'audit ; il doit
17
+ rester sérialisable JSON (Pydantic ``model_dump_json()`` round-trip).
18
+ ``threading.Event`` et les ``Callable`` enregistrés par les adapters
19
+ ne sont pas sérialisables et ne survivent pas à un transfert
20
+ cross-process.
21
+
22
+ Décision architecturale validée : ``RunControl`` est un paramètre
23
+ supplémentaire à ``execute()``, **pas** un champ de ``RunContext``.
24
+ Cf. [ADR-0001 décision #13](../../docs/explanation/adr/0001-multi-domain-execution.md).
25
+
26
+ Thread-safety
27
+ -------------
28
+
29
+ Les opérations sur ``cancel_handles`` sont protégées par un lock
30
+ interne — un adapter peut enregistrer son handle depuis un thread
31
+ pendant que le runner appelle ``trigger_cancel()`` depuis un autre.
32
+
33
+ L'``cancel_event`` est un ``threading.Event`` standard, thread-safe
34
+ par construction.
35
+
36
+ Limites assumées
37
+ ----------------
38
+
39
+ - ``RunControl`` ne survit pas à un transfert cross-process. Pour
40
+ les adapters exécutés dans un subprocess, le runner construit
41
+ un ``RunControl`` local au subprocess à partir d'un protocole
42
+ IPC dédié (cf. design du ``SubprocessExecutor`` qui sera livré).
43
+ - Pas de hiérarchie parent/enfant pour l'instant — un seul niveau
44
+ de RunControl par doc. Une éventuelle composition (un step
45
+ hérite d'un RunControl plus restrictif) viendra si un besoin
46
+ concret apparaît.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import threading
52
+ from typing import Callable
53
+
54
+ from picarones.domain.errors import PicaronesError
55
+
56
+ #: Type d'un handle de cancellation enregistré par un adapter.
57
+ #: Sans argument, sans valeur de retour. Idempotent (le runner peut
58
+ #: appeler tous les handles enregistrés sans craindre une double
59
+ #: exécution).
60
+ CancelHandle = Callable[[], None]
61
+
62
+
63
+ class RunControl:
64
+ """État runtime mutable d'un step en cours d'exécution.
65
+
66
+ Construit par le runner pour chaque ``execute()``, passé à
67
+ l'adapter comme paramètre additionnel de la signature
68
+ ``execute(inputs, params, context, control)``
69
+ (cf. ADR-0001).
70
+
71
+ Le ``RunControl`` n'est pas immuable : l'adapter le mute en
72
+ appelant ``register_cancel_handle``. L'immutabilité serait
73
+ contre-productive ici (l'adapter doit pouvoir s'auto-enregistrer
74
+ pour permettre au runner de le tuer).
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ cancel_event: threading.Event | None = None,
80
+ ) -> None:
81
+ """Construit un ``RunControl``.
82
+
83
+ Parameters
84
+ ----------
85
+ cancel_event:
86
+ ``threading.Event`` partagé avec le caller pour signaler
87
+ une cancellation. Si ``None``, un event local est créé
88
+ (utile en tests ou pour les usages où il n'y a pas de
89
+ caller à signaler).
90
+ """
91
+ self._cancel_event = (
92
+ cancel_event if cancel_event is not None else threading.Event()
93
+ )
94
+ self._cancel_handles: list[CancelHandle] = []
95
+ self._handles_lock = threading.Lock()
96
+ self._cancel_triggered = False
97
+
98
+ # ──────────────────────────────────────────────────────────────────
99
+ # Cancellation event (coopératif)
100
+ # ──────────────────────────────────────────────────────────────────
101
+
102
+ @property
103
+ def cancel_event(self) -> threading.Event:
104
+ """L'``Event`` partagé ; l'adapter peut le passer à un
105
+ ``wait()`` ou le check directement via ``is_set()``."""
106
+ return self._cancel_event
107
+
108
+ def is_cancelled(self) -> bool:
109
+ """Raccourci pour ``self.cancel_event.is_set()``."""
110
+ return self._cancel_event.is_set()
111
+
112
+ def raise_if_cancelled(self) -> None:
113
+ """Lève ``RunCancelledError`` si la cancellation a été
114
+ signalée. L'adapter peut appeler ça entre opérations
115
+ bloquantes pour cooperate proprement."""
116
+ if self._cancel_event.is_set():
117
+ raise RunCancelledError(
118
+ "Run cancelled by caller (cancel_event signalled).",
119
+ )
120
+
121
+ # ────────────────────────────────────────────────────────��─────────
122
+ # Handles de cancellation cross-thread (kill réseau, kill subprocess)
123
+ # ──────────────────────────────────────────────────────────────────
124
+
125
+ def register_cancel_handle(self, handle: CancelHandle) -> None:
126
+ """Enregistre un handle de cancellation cross-thread.
127
+
128
+ L'adapter appelle cette méthode au début de son
129
+ ``execute()`` pour exposer un mécanisme de kill au runner.
130
+ Exemples :
131
+
132
+ - HTTP : ``control.register_cancel_handle(my_httpx_client.close)``
133
+ — fermer le client cross-thread fait lever l'exception
134
+ dans le thread bloqué sur la requête.
135
+ - Subprocess : ``control.register_cancel_handle(my_proc.terminate)``
136
+ — termine le subprocess si la deadline expire.
137
+
138
+ Plusieurs handles peuvent être enregistrés. Tous seront
139
+ appelés (séquentiellement, dans l'ordre d'enregistrement)
140
+ lors de ``trigger_cancel()``.
141
+
142
+ Si la cancellation a déjà été déclenchée, le handle est
143
+ appelé immédiatement — l'adapter ne peut pas s'enregistrer
144
+ et continuer comme si de rien n'était.
145
+ """
146
+ if not callable(handle):
147
+ raise PicaronesError(
148
+ f"RunControl.register_cancel_handle : handle doit être "
149
+ f"callable, reçu {type(handle).__name__}.",
150
+ )
151
+ with self._handles_lock:
152
+ if self._cancel_triggered:
153
+ # Cancellation déjà déclenchée — appeler le handle
154
+ # immédiatement plutôt que de le stocker pour rien.
155
+ # Hors du lock pour ne pas hold during user code.
156
+ triggered = True
157
+ else:
158
+ self._cancel_handles.append(handle)
159
+ triggered = False
160
+ if triggered:
161
+ _safely_invoke(handle)
162
+
163
+ def trigger_cancel(self) -> None:
164
+ """Appelle tous les handles enregistrés et set l'``cancel_event``.
165
+
166
+ Appelée par le runner quand une deadline est dépassée ou
167
+ qu'un cancellation utilisateur arrive. Idempotente — un
168
+ second appel est un no-op (les handles ont déjà été
169
+ consommés).
170
+ """
171
+ with self._handles_lock:
172
+ if self._cancel_triggered:
173
+ return
174
+ self._cancel_triggered = True
175
+ handles_to_call = list(self._cancel_handles)
176
+ self._cancel_handles.clear()
177
+ # Set l'event *avant* d'appeler les handles : si un handle
178
+ # check ``is_cancelled()`` en cascade, il verra True.
179
+ self._cancel_event.set()
180
+ for handle in handles_to_call:
181
+ _safely_invoke(handle)
182
+
183
+ @property
184
+ def cancel_triggered(self) -> bool:
185
+ """``True`` si ``trigger_cancel()`` a déjà été appelé.
186
+
187
+ Différent de ``is_cancelled()`` : l'``cancel_event`` peut
188
+ être set par le caller via ``cancel_event.set()`` direct,
189
+ sans que les handles aient été appelés. Cette propriété
190
+ traque spécifiquement l'invocation de ``trigger_cancel``.
191
+ """
192
+ return self._cancel_triggered
193
+
194
+
195
+ class RunCancelledError(PicaronesError):
196
+ """Levée par ``RunControl.raise_if_cancelled()`` quand la
197
+ cancellation a été signalée.
198
+
199
+ Distincte de ``DeadlineExceeded`` : la cancellation est un signal
200
+ explicite du caller (utilisateur, deadline globale du run), pas
201
+ une expiration de la deadline du doc en cours. Le runner
202
+ catégorise en ``USER_CANCELLED_MID_EXECUTION`` dans le
203
+ ``TerminationLedger`` (cf. ADR-0001).
204
+ """
205
+
206
+
207
+ def _safely_invoke(handle: CancelHandle) -> None:
208
+ """Appelle un handle de cancellation en absorbant les exceptions.
209
+
210
+ Si un handle lève (ex : socket déjà fermé), on log mais on
211
+ continue à appeler les autres handles — un handle défaillant
212
+ ne doit pas bloquer la cancellation des autres ressources.
213
+
214
+ Le log doit être suffisamment explicite pour qu'un audit
215
+ institutionnel puisse retrouver la trace ; on n'absorbe pas
216
+ silencieusement.
217
+ """
218
+ import logging
219
+
220
+ try:
221
+ handle()
222
+ except Exception as exc: # noqa: BLE001
223
+ logging.getLogger(__name__).warning(
224
+ "[run_control] cancel handle %r a levé : %s — ignoré, "
225
+ "appel des handles suivants.",
226
+ getattr(handle, "__name__", repr(handle)),
227
+ exc,
228
+ )
229
+
230
+
231
+ __all__ = [
232
+ "CancelHandle",
233
+ "RunCancelledError",
234
+ "RunControl",
235
+ ]
picarones/pipeline/types.py CHANGED
@@ -16,13 +16,14 @@ from typing import Any
16
  from pydantic import BaseModel, ConfigDict, Field
17
 
18
  from picarones.domain.artifacts import Artifact
 
19
 
20
 
21
  class RunContext(BaseModel):
22
  """Contexte d'exécution passé à chaque ``StepExecutor.execute()``.
23
 
24
- Le caller (typiquement ``app/services/benchmark_service`` au
25
- S19) construit un ``RunContext`` par document et le passe à
26
  l'executor pour chaque étape.
27
 
28
  Attributs
@@ -41,11 +42,21 @@ class RunContext(BaseModel):
41
  URI/chemin du workspace dans lequel l'adapter peut écrire
42
  ses artefacts intermédiaires. ``None`` autorisé pour les
43
  adapters qui n'écrivent rien sur disque (mode in-memory).
44
-
45
- Anti-sur-ingénierie : pas de logger injecté, pas d'horloge
46
- abstraite, pas de cancellation token. Ces extras viendront
47
- quand un caller en aura concrètement besoin (probablement S7
48
- pour la cancellation, S8 pour le timeout réel).
 
 
 
 
 
 
 
 
 
 
49
  """
50
 
51
  model_config = ConfigDict(frozen=True, extra="forbid")
@@ -54,6 +65,7 @@ class RunContext(BaseModel):
54
  code_version: str = Field(min_length=1, max_length=128)
55
  pipeline_name: str = Field(min_length=1, max_length=128)
56
  workspace_uri: str | None = Field(default=None, max_length=2048)
 
57
 
58
 
59
  class StepResult(BaseModel):
 
16
  from pydantic import BaseModel, ConfigDict, Field
17
 
18
  from picarones.domain.artifacts import Artifact
19
+ from picarones.domain.deadline import Deadline
20
 
21
 
22
  class RunContext(BaseModel):
23
  """Contexte d'exécution passé à chaque ``StepExecutor.execute()``.
24
 
25
+ Le caller (typiquement ``app/services/benchmark_service``)
26
+ construit un ``RunContext`` par document et le passe à
27
  l'executor pour chaque étape.
28
 
29
  Attributs
 
42
  URI/chemin du workspace dans lequel l'adapter peut écrire
43
  ses artefacts intermédiaires. ``None`` autorisé pour les
44
  adapters qui n'écrivent rien sur disque (mode in-memory).
45
+ deadline:
46
+ ``Deadline`` propagée à l'adapter. L'adapter doit la
47
+ passer à son SDK via ``deadline.as_sdk_timeout()`` et la
48
+ check via ``deadline.is_expired()`` entre opérations
49
+ bloquantes (cf.
50
+ [ADR-0001](../../docs/explanation/adr/0001-multi-domain-execution.md)).
51
+ Default : ``Deadline.infinite()`` — pas de contrainte
52
+ temporelle. Le runner construit une deadline finie à
53
+ partir de ``timeout_seconds_per_doc`` du ``RunSpec``.
54
+
55
+ Le ``RunContext`` reste un type valeur **sérialisable** pour
56
+ pouvoir être inclus dans le ``RunManifest`` d'audit. Le côté
57
+ runtime mutable (cancel_event, cancel_handles réseau,
58
+ ledger) vit dans ``RunControl`` (cf. ``run_control.py``),
59
+ passé séparément à l'adapter par le runner.
60
  """
61
 
62
  model_config = ConfigDict(frozen=True, extra="forbid")
 
65
  code_version: str = Field(min_length=1, max_length=128)
66
  pipeline_name: str = Field(min_length=1, max_length=128)
67
  workspace_uri: str | None = Field(default=None, max_length=2048)
68
+ deadline: Deadline = Field(default_factory=Deadline.infinite)
69
 
70
 
71
  class StepResult(BaseModel):
tests/architecture/test_layer_dependencies.py CHANGED
@@ -86,7 +86,16 @@ def _layer_index(name: str) -> int:
86
  #: des couches plus internes). Liste blanche stricte ; tout import
87
  #: hors stdlib qui n'est pas dans cette liste fait échouer le test.
88
  EXTERNAL_ALLOWED: dict[str, frozenset[str]] = {
89
- "domain": frozenset({"pydantic", "typing_extensions", "annotated_types"}),
 
 
 
 
 
 
 
 
 
90
  "evaluation": frozenset({
91
  "pydantic", "typing_extensions", "annotated_types",
92
  "numpy", "scipy", "jiwer", "rapidfuzz",
 
86
  #: des couches plus internes). Liste blanche stricte ; tout import
87
  #: hors stdlib qui n'est pas dans cette liste fait échouer le test.
88
  EXTERNAL_ALLOWED: dict[str, frozenset[str]] = {
89
+ "domain": frozenset({
90
+ "pydantic", "typing_extensions", "annotated_types",
91
+ # ``pydantic_core`` est le backend Rust de ``pydantic`` et
92
+ # son API officielle pour définir des types valeur custom
93
+ # via ``__get_pydantic_core_schema__`` (cf.
94
+ # ``picarones/domain/deadline.py``). Sémantiquement
95
+ # indissociable de ``pydantic`` — pas une lib externe
96
+ # distincte au sens de la whitelist.
97
+ "pydantic_core",
98
+ }),
99
  "evaluation": frozenset({
100
  "pydantic", "typing_extensions", "annotated_types",
101
  "numpy", "scipy", "jiwer", "rapidfuzz",
tests/domain/test_error_hierarchy.py CHANGED
@@ -19,7 +19,11 @@ from picarones.adapters.llm.base import LLMAdapterError
19
  from picarones.adapters.ocr.base import OCRAdapterError
20
  from picarones.adapters.storage import JobStoreError
21
  from picarones.adapters.vlm.base import VLMAdapterError
22
- from picarones.domain.errors import AdapterStepError, PicaronesError
 
 
 
 
23
 
24
 
25
  class TestErrorInheritance:
@@ -65,3 +69,37 @@ class TestPolymorphicCatch:
65
  def test_picarones_catches_jobstore(self) -> None:
66
  with pytest.raises(PicaronesError):
67
  raise JobStoreError("store boom")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  from picarones.adapters.ocr.base import OCRAdapterError
20
  from picarones.adapters.storage import JobStoreError
21
  from picarones.adapters.vlm.base import VLMAdapterError
22
+ from picarones.domain.errors import (
23
+ AdapterStepError,
24
+ DeadlineExceeded,
25
+ PicaronesError,
26
+ )
27
 
28
 
29
  class TestErrorInheritance:
 
69
  def test_picarones_catches_jobstore(self) -> None:
70
  with pytest.raises(PicaronesError):
71
  raise JobStoreError("store boom")
72
+
73
+
74
+ class TestDeadlineExceeded:
75
+ """``DeadlineExceeded`` est une sous-classe d'``AdapterStepError``.
76
+
77
+ Permet à un adapter de signaler explicitement l'expiration de
78
+ la deadline propagée via ``context.deadline``, distincte d'un
79
+ échec fonctionnel arbitraire — le runner s'en sert pour
80
+ catégoriser la cause de terminaison.
81
+ """
82
+
83
+ def test_inherits_adapter_step_error(self) -> None:
84
+ assert issubclass(DeadlineExceeded, AdapterStepError)
85
+ assert issubclass(DeadlineExceeded, PicaronesError)
86
+
87
+ def test_picarones_catches_deadline_exceeded(self) -> None:
88
+ with pytest.raises(PicaronesError):
89
+ raise DeadlineExceeded("deadline boom")
90
+
91
+ def test_adapter_step_error_catches_deadline_exceeded(self) -> None:
92
+ """Un caller qui catche ``AdapterStepError`` génériquement
93
+ attrape aussi ``DeadlineExceeded`` — backwards compatible
94
+ avec le code existant qui ne distingue pas la cause."""
95
+ with pytest.raises(AdapterStepError):
96
+ raise DeadlineExceeded("deadline boom")
97
+
98
+ def test_deadline_exceeded_not_caught_by_ocr_specific(self) -> None:
99
+ """``DeadlineExceeded`` est *transverse* aux domaines OCR /
100
+ LLM / VLM — il ne descend pas de leur sous-classe spécifique.
101
+ Un caller qui catche ``OCRAdapterError`` doit aussi catcher
102
+ ``DeadlineExceeded`` séparément s'il veut distinguer."""
103
+ assert not issubclass(DeadlineExceeded, OCRAdapterError)
104
+ assert not issubclass(DeadlineExceeded, LLMAdapterError)
105
+ assert not issubclass(DeadlineExceeded, VLMAdapterError)
tests/pipeline/test_run_context_deadline.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests de l'intégration ``Deadline`` ↔ ``RunContext`` (Pydantic).
2
+
3
+ Phase 2 du chantier ADR-0001 : ``RunContext`` porte désormais un
4
+ champ ``deadline: Deadline`` (default infinite). Ces tests vérifient :
5
+
6
+ - Construction par défaut (deadline infinie).
7
+ - Construction avec ``deadline`` explicite (Deadline ou dict).
8
+ - ``model_dump_json`` / ``model_validate_json`` round-trip avec
9
+ sémantique cross-process correcte (overhead de dispatch non
10
+ facturé).
11
+ - ``model_dump(mode="python")`` retourne un dict avec
12
+ ``remaining_seconds``.
13
+ - ``frozen=True, extra="forbid"`` préservés.
14
+ - Non-régression : RunContext sans deadline explicite continue de
15
+ fonctionner.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import time
22
+
23
+ import pytest
24
+ from pydantic import ValidationError
25
+
26
+ from picarones.domain.deadline import Deadline
27
+ from picarones.pipeline.types import RunContext
28
+
29
+
30
+ class TestDefaultDeadline:
31
+ def test_no_deadline_kwarg_defaults_to_infinite(self) -> None:
32
+ """Compat ascendante : un caller qui n'a jamais entendu parler
33
+ de Deadline construit ``RunContext`` sans paramètre, et reçoit
34
+ une deadline infinie (aucune contrainte temporelle)."""
35
+ ctx = RunContext(
36
+ document_id="d1",
37
+ code_version="0.10.0",
38
+ pipeline_name="p",
39
+ )
40
+ assert ctx.deadline.is_infinite is True
41
+
42
+ def test_explicit_infinite(self) -> None:
43
+ ctx = RunContext(
44
+ document_id="d1",
45
+ code_version="0.10.0",
46
+ pipeline_name="p",
47
+ deadline=Deadline.infinite(),
48
+ )
49
+ assert ctx.deadline.is_infinite is True
50
+
51
+ def test_explicit_finite(self) -> None:
52
+ d = Deadline.in_seconds(30.0)
53
+ ctx = RunContext(
54
+ document_id="d1",
55
+ code_version="0.10.0",
56
+ pipeline_name="p",
57
+ deadline=d,
58
+ )
59
+ assert ctx.deadline.is_infinite is False
60
+ # Même objet ou un objet équivalent : Pydantic peut copier en
61
+ # interne. Vérifions la sémantique.
62
+ r = ctx.deadline.remaining_seconds()
63
+ assert r is not None and 29.0 < r <= 30.0
64
+
65
+
66
+ class TestPydanticValidation:
67
+ def test_validate_from_dict(self) -> None:
68
+ """Un payload JSON arrivé avec ``{"remaining_seconds": 30.0}``
69
+ doit être validé comme un ``Deadline``."""
70
+ payload = {
71
+ "document_id": "d1",
72
+ "code_version": "0.10.0",
73
+ "pipeline_name": "p",
74
+ "deadline": {"remaining_seconds": 30.0},
75
+ }
76
+ ctx = RunContext.model_validate(payload)
77
+ assert isinstance(ctx.deadline, Deadline)
78
+ r = ctx.deadline.remaining_seconds()
79
+ assert r is not None and 29.0 < r <= 30.0
80
+
81
+ def test_validate_from_dict_infinite(self) -> None:
82
+ payload = {
83
+ "document_id": "d1",
84
+ "code_version": "0.10.0",
85
+ "pipeline_name": "p",
86
+ "deadline": {"remaining_seconds": None},
87
+ }
88
+ ctx = RunContext.model_validate(payload)
89
+ assert ctx.deadline.is_infinite is True
90
+
91
+ def test_validate_rejects_garbage(self) -> None:
92
+ """Un type non reconnu doit être rejeté avec une erreur
93
+ Pydantic claire — pas absorbé silencieusement."""
94
+ payload = {
95
+ "document_id": "d1",
96
+ "code_version": "0.10.0",
97
+ "pipeline_name": "p",
98
+ "deadline": "60s", # string non reconnu
99
+ }
100
+ with pytest.raises((ValidationError, ValueError)):
101
+ RunContext.model_validate(payload)
102
+
103
+
104
+ class TestSerializationRoundtrip:
105
+ def test_dump_python_yields_dict_form(self) -> None:
106
+ d = Deadline.in_seconds(20.0)
107
+ ctx = RunContext(
108
+ document_id="d1",
109
+ code_version="0.10.0",
110
+ pipeline_name="p",
111
+ deadline=d,
112
+ )
113
+ out = ctx.model_dump(mode="python")
114
+ assert "deadline" in out
115
+ assert "remaining_seconds" in out["deadline"]
116
+ # Le budget sérialisé est ~20s.
117
+ r = out["deadline"]["remaining_seconds"]
118
+ assert r is not None and 19.0 < r <= 20.0
119
+
120
+ def test_dump_json_yields_dict_form(self) -> None:
121
+ d = Deadline.in_seconds(15.0)
122
+ ctx = RunContext(
123
+ document_id="d1",
124
+ code_version="0.10.0",
125
+ pipeline_name="p",
126
+ deadline=d,
127
+ )
128
+ s = ctx.model_dump_json()
129
+ parsed = json.loads(s)
130
+ assert "deadline" in parsed
131
+ assert parsed["deadline"]["remaining_seconds"] is not None
132
+ assert 14.0 < parsed["deadline"]["remaining_seconds"] <= 15.0
133
+
134
+ def test_dump_infinite_yields_null(self) -> None:
135
+ ctx = RunContext(
136
+ document_id="d1",
137
+ code_version="0.10.0",
138
+ pipeline_name="p",
139
+ )
140
+ out = ctx.model_dump(mode="python")
141
+ assert out["deadline"] == {"remaining_seconds": None}
142
+
143
+ def test_json_roundtrip_preserves_handoff_semantics(self) -> None:
144
+ """Propriété critique : entre serialize et deserialize, le
145
+ budget visible côté receveur reste cohérent avec le moment
146
+ du handoff. Sleep de 300ms entre les deux = perdu côté
147
+ wall-clock mais pas côté contrat (le receveur voit ~30s)."""
148
+ original = Deadline.in_seconds(30.0)
149
+ ctx = RunContext(
150
+ document_id="d1",
151
+ code_version="0.10.0",
152
+ pipeline_name="p",
153
+ deadline=original,
154
+ )
155
+ json_str = ctx.model_dump_json()
156
+
157
+ # Simule un dispatch lent.
158
+ time.sleep(0.3)
159
+
160
+ rebuilt = RunContext.model_validate_json(json_str)
161
+ r = rebuilt.deadline.remaining_seconds()
162
+ assert r is not None
163
+ # Le receveur voit ~30s (le sleep n'a pas mangé le budget).
164
+ # La valeur exacte dépend du timing entre serialize et
165
+ # validate — sera entre handoff_remaining et handoff_remaining
166
+ # car le receveur reconstruit à validate_time.
167
+ assert 29.5 < r <= 30.0, (
168
+ f"deadline reçue {r:.3f}s — dispatch overhead facturé "
169
+ f"alors qu'il ne devrait pas"
170
+ )
171
+
172
+
173
+ class TestFrozenAndForbidExtra:
174
+ def test_runcontext_still_frozen(self) -> None:
175
+ ctx = RunContext(
176
+ document_id="d1",
177
+ code_version="0.10.0",
178
+ pipeline_name="p",
179
+ )
180
+ with pytest.raises(ValidationError):
181
+ ctx.document_id = "d2" # type: ignore[misc]
182
+
183
+ def test_runcontext_still_forbid_extra(self) -> None:
184
+ with pytest.raises(ValidationError, match="forbidden|extra"):
185
+ RunContext(
186
+ document_id="d1",
187
+ code_version="0.10.0",
188
+ pipeline_name="p",
189
+ unknown_field="x", # type: ignore[call-arg]
190
+ )
191
+
192
+ def test_deadline_field_cannot_be_mutated(self) -> None:
193
+ d = Deadline.in_seconds(10.0)
194
+ ctx = RunContext(
195
+ document_id="d1",
196
+ code_version="0.10.0",
197
+ pipeline_name="p",
198
+ deadline=d,
199
+ )
200
+ # Pydantic frozen → on ne peut pas réaffecter le champ.
201
+ with pytest.raises(ValidationError):
202
+ ctx.deadline = Deadline.infinite() # type: ignore[misc]
203
+
204
+
205
+ class TestNonRegression:
206
+ """Vérifie que l'ajout du champ ``deadline`` n'a pas cassé
207
+ l'API existante pour les callers qui construisent ``RunContext``
208
+ sans le paramètre."""
209
+
210
+ def test_legacy_construction_still_works(self) -> None:
211
+ """Les ~31 constructions de RunContext dans les tests
212
+ existants ne passent pas ``deadline`` — elles doivent
213
+ continuer à compiler et à fonctionner."""
214
+ ctx = RunContext(
215
+ document_id="d1",
216
+ code_version="0.10.0",
217
+ pipeline_name="p",
218
+ )
219
+ # Tous les champs anciens présents et corrects.
220
+ assert ctx.document_id == "d1"
221
+ assert ctx.code_version == "0.10.0"
222
+ assert ctx.pipeline_name == "p"
223
+ assert ctx.workspace_uri is None
224
+
225
+ def test_legacy_construction_with_workspace(self) -> None:
226
+ ctx = RunContext(
227
+ document_id="d1",
228
+ code_version="0.10.0",
229
+ pipeline_name="p",
230
+ workspace_uri="/tmp/runtime",
231
+ )
232
+ assert ctx.workspace_uri == "/tmp/runtime"
233
+ assert ctx.deadline.is_infinite is True
tests/pipeline/test_run_control.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests de ``picarones.pipeline.run_control.RunControl``.
2
+
3
+ Le ``RunControl`` est l'état runtime mutable d'un step en cours
4
+ d'exécution. Couvre :
5
+
6
+ - Création (avec / sans cancel_event injecté).
7
+ - Cancellation coopérative via ``cancel_event``.
8
+ - ``raise_if_cancelled()`` lève ``RunCancelledError`` quand signalé.
9
+ - ``register_cancel_handle`` enregistre des handles (thread-safe).
10
+ - ``trigger_cancel`` appelle tous les handles + set l'event.
11
+ - Idempotence de ``trigger_cancel``.
12
+ - Un handle enregistré après ``trigger_cancel`` est appelé immédiatement.
13
+ - Un handle qui lève n'empêche pas les autres d'être appelés.
14
+ - Thread-safety : enregistrement concurrent depuis plusieurs threads.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import threading
21
+ import time
22
+
23
+ import pytest
24
+
25
+ from picarones.domain.errors import PicaronesError
26
+ from picarones.pipeline.run_control import RunCancelledError, RunControl
27
+
28
+
29
+ class TestConstruction:
30
+ def test_default_creates_local_cancel_event(self) -> None:
31
+ rc = RunControl()
32
+ assert isinstance(rc.cancel_event, threading.Event)
33
+ assert rc.is_cancelled() is False
34
+ assert rc.cancel_triggered is False
35
+
36
+ def test_external_cancel_event_is_shared(self) -> None:
37
+ event = threading.Event()
38
+ rc = RunControl(cancel_event=event)
39
+ assert rc.cancel_event is event
40
+ # Setting the external event reflects in the control.
41
+ event.set()
42
+ assert rc.is_cancelled() is True
43
+
44
+
45
+ class TestCancellationCooperative:
46
+ def test_is_cancelled_reflects_event(self) -> None:
47
+ rc = RunControl()
48
+ assert rc.is_cancelled() is False
49
+ rc.cancel_event.set()
50
+ assert rc.is_cancelled() is True
51
+
52
+ def test_raise_if_cancelled_no_raise_when_not_cancelled(self) -> None:
53
+ rc = RunControl()
54
+ rc.raise_if_cancelled() # ne doit pas lever
55
+
56
+ def test_raise_if_cancelled_raises_when_signalled(self) -> None:
57
+ rc = RunControl()
58
+ rc.cancel_event.set()
59
+ with pytest.raises(RunCancelledError, match="cancel_event signalled"):
60
+ rc.raise_if_cancelled()
61
+
62
+
63
+ class TestCancelHandles:
64
+ def test_register_and_trigger_calls_handle(self) -> None:
65
+ rc = RunControl()
66
+ called = []
67
+
68
+ def my_close() -> None:
69
+ called.append("closed")
70
+
71
+ rc.register_cancel_handle(my_close)
72
+ assert called == [] # pas encore appelé
73
+ rc.trigger_cancel()
74
+ assert called == ["closed"]
75
+ assert rc.is_cancelled() is True
76
+ assert rc.cancel_triggered is True
77
+
78
+ def test_multiple_handles_called_in_order(self) -> None:
79
+ rc = RunControl()
80
+ order: list[int] = []
81
+
82
+ rc.register_cancel_handle(lambda: order.append(1))
83
+ rc.register_cancel_handle(lambda: order.append(2))
84
+ rc.register_cancel_handle(lambda: order.append(3))
85
+ rc.trigger_cancel()
86
+ assert order == [1, 2, 3]
87
+
88
+ def test_trigger_cancel_is_idempotent(self) -> None:
89
+ rc = RunControl()
90
+ called = []
91
+ rc.register_cancel_handle(lambda: called.append("once"))
92
+ rc.trigger_cancel()
93
+ rc.trigger_cancel() # second call should be no-op
94
+ assert called == ["once"]
95
+
96
+ def test_handle_registered_after_trigger_called_immediately(self) -> None:
97
+ rc = RunControl()
98
+ rc.trigger_cancel() # set state first
99
+ called = []
100
+ rc.register_cancel_handle(lambda: called.append("late"))
101
+ assert called == ["late"], (
102
+ "un handle enregistré après cancel doit être appelé "
103
+ "immédiatement — l'adapter ne peut pas s'enregistrer et "
104
+ "continuer comme si de rien n'était"
105
+ )
106
+
107
+ def test_failing_handle_does_not_block_others(
108
+ self, caplog: pytest.LogCaptureFixture,
109
+ ) -> None:
110
+ """Un handle qui lève (ex : socket déjà fermé) ne doit pas
111
+ empêcher les handles suivants d'être appelés."""
112
+ rc = RunControl()
113
+ called: list[str] = []
114
+
115
+ def good_handle_1() -> None:
116
+ called.append("1")
117
+
118
+ def bad_handle() -> None:
119
+ raise RuntimeError("intentional failure")
120
+
121
+ def good_handle_2() -> None:
122
+ called.append("2")
123
+
124
+ rc.register_cancel_handle(good_handle_1)
125
+ rc.register_cancel_handle(bad_handle)
126
+ rc.register_cancel_handle(good_handle_2)
127
+ with caplog.at_level(logging.WARNING):
128
+ rc.trigger_cancel()
129
+ assert called == ["1", "2"], (
130
+ "good_handle_2 doit être appelé même si bad_handle a levé"
131
+ )
132
+ # Le warning doit être loggé explicitement (pas absorbé silently).
133
+ assert any(
134
+ "cancel handle" in rec.message and "intentional failure" in rec.message
135
+ for rec in caplog.records
136
+ ), "le log warning doit mentionner le handle qui a échoué"
137
+
138
+ def test_register_rejects_non_callable(self) -> None:
139
+ rc = RunControl()
140
+ with pytest.raises(PicaronesError, match="doit être callable"):
141
+ rc.register_cancel_handle("not a function") # type: ignore[arg-type]
142
+
143
+ def test_cancel_event_set_before_handles_invoked(self) -> None:
144
+ """Quand un handle est appelé, ``cancel_event`` est déjà set —
145
+ ainsi un handle qui check ``is_cancelled()`` en cascade voit
146
+ l'état cohérent."""
147
+ rc = RunControl()
148
+ observed_cancel_state: list[bool] = []
149
+
150
+ def my_handle() -> None:
151
+ observed_cancel_state.append(rc.is_cancelled())
152
+
153
+ rc.register_cancel_handle(my_handle)
154
+ rc.trigger_cancel()
155
+ assert observed_cancel_state == [True]
156
+
157
+
158
+ class TestThreadSafety:
159
+ def test_concurrent_register_and_trigger(self) -> None:
160
+ """Stress test : 10 threads enregistrent des handles tandis
161
+ qu'un 11e appelle trigger. Aucun handle ne doit être perdu
162
+ ni double-appelé (modulo le contrat "registered-after-trigger
163
+ appelé immédiatement")."""
164
+ rc = RunControl()
165
+ n_handles = 20
166
+ called: list[int] = []
167
+ called_lock = threading.Lock()
168
+
169
+ def make_handle(i: int):
170
+ def handle() -> None:
171
+ with called_lock:
172
+ called.append(i)
173
+ return handle
174
+
175
+ start_barrier = threading.Barrier(n_handles + 1)
176
+
177
+ def register_thread(i: int) -> None:
178
+ start_barrier.wait()
179
+ time.sleep(0.001 * (i % 3)) # un peu de jitter
180
+ rc.register_cancel_handle(make_handle(i))
181
+
182
+ def trigger_thread() -> None:
183
+ start_barrier.wait()
184
+ time.sleep(0.01) # laisser quelques registers passer avant
185
+ rc.trigger_cancel()
186
+
187
+ registers = [
188
+ threading.Thread(target=register_thread, args=(i,))
189
+ for i in range(n_handles)
190
+ ]
191
+ trigger = threading.Thread(target=trigger_thread)
192
+ for t in registers:
193
+ t.start()
194
+ trigger.start()
195
+ for t in registers:
196
+ t.join(timeout=5.0)
197
+ trigger.join(timeout=5.0)
198
+
199
+ # Tous les handles doivent avoir été appelés (qu'ils aient
200
+ # été enregistrés avant ou après trigger).
201
+ assert sorted(called) == list(range(n_handles)), (
202
+ f"handles appelés : {sorted(called)} ; attendu "
203
+ f"{list(range(n_handles))}"
204
+ )
205
+
206
+ def test_concurrent_trigger_calls_idempotent(self) -> None:
207
+ """Plusieurs threads appelant trigger en même temps : un
208
+ seul ensemble de handles appelé."""
209
+ rc = RunControl()
210
+ called: list[int] = []
211
+ called_lock = threading.Lock()
212
+
213
+ for i in range(5):
214
+ def make_h(idx: int):
215
+ def h() -> None:
216
+ with called_lock:
217
+ called.append(idx)
218
+ return h
219
+ rc.register_cancel_handle(make_h(i))
220
+
221
+ def trigger() -> None:
222
+ rc.trigger_cancel()
223
+
224
+ threads = [threading.Thread(target=trigger) for _ in range(10)]
225
+ for t in threads:
226
+ t.start()
227
+ for t in threads:
228
+ t.join(timeout=5.0)
229
+
230
+ assert sorted(called) == [0, 1, 2, 3, 4]