02.10.06 — L’acteur Playwright : un thread propriétaire#
Dossier source :
src/ocarina/infra/playwright/L’adapter Playwright reprend la structure de l’adapter Selenium fichier pour fichier (
create_driver,create_drivers_pool,create_screenshotter,driver_healthcheck,mixins), avec un fichier en plus :driver.py. Ce fichier mérite un chapitre à lui seul. C’est lui qui réconcilie l’API sync de Playwright, intrinsèquement liée à un thread, avec le modèle threadé d’Ocarina (pool, warmup, Watcher).
Le problème : l’API sync de Playwright est thread-affine#
L’API sync de Playwright lie chaque objet qu’elle renvoie (Playwright, Browser, BrowserContext, Page, Locator, …) au thread qui a appelé sync_playwright().start(). Sous le capot, c’est un greenlet épinglé à ce thread. Y toucher depuis un autre thread lève :
greenlet.error: cannot switch to a different threadÇa entre en collision frontale avec le modèle de threading d’Ocarina, qui repose sur trois points de contact concurrents avec un même driver :
┌──────────────────┐
│ warmup thread │ WebDriversPool.warmup() pré-construit les drivers
│ (1, dédié) │ dans un thread dédié, puis les pousse dans la Queue.
└────────┬─────────┘
│ build → Queue
▼
┌──────────────────┐
│ worker threads │ Les workers acquièrent un driver depuis la Queue
│ (N, parallèles) │ et déroulent la chaîne de test dessus.
└────────┬─────────┘
│ + en parallèle
▼
┌──────────────────┐
│ watcher daemon │ Le Watcher poll à côté de la chaîne, dans son
│ (1 par test) │ propre daemon thread, et lit la page.
└──────────────────┘Un driver est donc construit dans un thread (warmup) et utilisé depuis un autre (worker), pendant qu’un troisième (watcher) l’observe. Avec l’API sync de Playwright telle quelle, c’est trois greenlet.error garantis.
La solution : un acteur épinglé à un seul thread#
PlaywrightDriver emballe Playwright dans un acteur (Actor model) : il possède un unique thread propriétaire (owner thread). Tous les objets Playwright vivent sur ce thread, et personne d’autre n’y touche jamais directement. Chaque interaction est marshallisée vers le thread propriétaire via submit().
warmup / worker / watcher threads owner thread (1, privé)
───────────────────────────────── ───────────────────────
┌───────────────────┐
driver.submit(fn) ──────┐ │ Playwright │
│ Queue[(fn,fut)] │ Browser │
driver.submit(fn) ──────┼────────────────────▶│ BrowserContext │
│ │ Page, Locator │
driver.submit(fn) ──────┘ │ │
▲ │ fn(page) tourne │
│ future.result() │ ICI, et ICI │
└────────────────────────────────────│ seulement │
résultat (données brutes) └───────────────────┘Le handle (PlaywrightDriver) est donc sûr à créer dans un thread et à utiliser depuis un autre : seul le travail touche Playwright, et ce travail tourne toujours sur le thread propriétaire. La pool, le warmup et la parallélisation des workers d’Ocarina survivent intacts, sans renoncer à l’API sync.
def submit[T](self, fn: Callable[[Page], T]) -> T:
if threading.get_ident() == self._owner_ident:
raise RuntimeError("Re-entrant submit() on the owner thread would deadlock.")
if self._dead:
raise DriverDiedError("PlaywrightDriver is dead: a previous call exceeded its timeout.")
if self._closed:
raise RuntimeError("PlaywrightDriver has been disposed.")
future = self._owner.submit(lambda: fn(self._page))
try:
return future.result(timeout=self._call_timeout_s)
except FuturesTimeoutError as exc:
self._dead = True
self._closed = True
raise DriverDiedError(...) from excPourquoi pas ThreadPoolExecutor(max_workers=1)#
Un ThreadPoolExecutor(max_workers=1) ferait presque l’affaire : un seul worker, les soumissions traitées dans l’ordre. Presque. Le problème est à la sortie du process.
Les workers d’un ThreadPoolExecutor ne sont pas des daemons : ils sont joints par un hook atexit. Si le worker est coincé sur un pipe Playwright mort (le navigateur a crashé, le transport ne répond plus), il ne revient jamais et ce join à la sortie fige le process pour toujours. Sur un run de CI, c’est un job qui ne se termine pas.
Ocarina remplace donc l’executor par un _OwnerThread : un thread daemon unique qui draine une Queue de (callable, Future).
_OwnerThread._run() (daemon)
───────────────────────────────────────────────
while True:
item = queue.get() # bloque jusqu'à la prochaine soumission
if item is None: # sentinelle de stop
return
fn, future = item
try: future.set_result(fn())
except: future.set_exception(...) # toute erreur renvoyée au callerLa différence est dans la gestion de la mort du thread :
ThreadPoolExecutor(max_workers=1) | _OwnerThread (daemon) |
|---|---|
Worker non-daemon, joint à l’atexit | Daemon : abandonné à la sortie du process |
Worker coincé sur un pipe mort → join reste bloqué à l’exit | Worker coincé → jamais joint, le process sort quand même |
Pas de contrôle sur le join | On ne joint jamais nous-mêmes (un future en cours n’est pas annulable) |
Le coût assumé : un leak par mort. Quand un driver meurt coincé, son thread, l’appel bloqué et sa closure restent référencés jusqu’à la sortie du process. C’est l’arbitrage délibéré contre le fait de figer tout le run. Mieux vaut fuir un thread mort que ne jamais terminer. En fin d’exécution, ce leak est alors pris en charge puisque le daemon sera disposé.
Le contrat de submit#
Trois règles, toutes vérifiées par le code :
- Renvoyer des données brutes.
fndoit retourner du plat et thread-safe (str,bool,bytes,None) : jamais unePage, unLocatorou unElementHandlevivants, qui sont liés au thread propriétaire et inutilisables ailleurs.PlaywrightTitleMixinmontre le pattern :return self._driver.submit(lambda page: page.title()). - Pas de ré-entrance. Un
submit()(ouquit()) appelé depuis le thread propriétaire attendrait un future que ce même thread est censé résoudre : un deadlock. Le code le détecte (threading.get_ident() == self._owner_ident) et lève unRuntimeErrorexplicite plutôt que de figer. - L’appel est borné.
future.result(timeout=call_timeout)pose un seuil.
call_timeout : un seuil de liveness, pas une deadline#
Le point le plus subtil. call_timeout (180s par défaut) n’est pas une deadline par opération. C’est un seuil de liveness : il ne sert qu’à transformer un blocage infini sur un thread propriétaire mort en un échec borné et éventuel.
Il est volontairement découplé de wait_timeout (qui, lui, borne les auto-waits de Playwright) et réglé généreusement, bien au-dessus du plus lent submit légitime possible.
submit(fn)
│
├── le future résout avant call_timeout ──────────▶ retourne le résultat ✓
│
└── call_timeout dépassé
│ l'owner thread est ENCORE coincé sur fn
▼
driver marqué _dead = True, _closed = True
le future en cours est ABANDONNÉ (non annulable)
│
▼
raise DriverDiedError ──▶ le caller retry avec un driver fraisOn monte le call_timeout dès qu’un seul appel tourne légitimement plus longtemps.
Attention : il reste possible d’optimiser ce dit appel en le divisant en plusieurs submit, ça reste l’approche la plus recommandée avant tout.
is_dead ≠ is_closed#
| Propriété | Signification | Le driver est… |
|---|---|---|
is_closed | quit() a été appelé : disposition volontaire. | disposé normalement |
is_dead | un appel a dépassé call_timeout : l’owner thread est coincé. | à remplacer |
driver_healthcheck fait levier sur ce point :
def playwright_driver_healthcheck(driver: PlaywrightDriver) -> None:
if driver.is_dead:
raise DriverDiedError(...) # vraiment mort
if driver.is_closed:
return # disposé volontairement → course bénigne, on sort
try:
driver.submit(lambda page: page.title()) # ping minimaliste
except DriverDiedError:
raise
except Exception as exc:
raise DriverDiedError from excLe boot est borné lui aussi#
Un driver peut crasher pendant son démarrage (lancement raté, profil verrouillé). Le boot est donc marshallisé comme n’importe quel appel, et borné par le même call_timeout :
boot = self._owner.submit(lambda: self._boot(...))
try:
self._page = boot.result(timeout=self._call_timeout_s)
except FuturesTimeoutError as exc:
raise DriverDiedError("Playwright boot did not complete…") from exc
except PlaywrightError as exc:
raise DriverDiedError("Playwright boot failed…") from exc
finally:
if not booted:
self._dead = True
self._closed = True
self._owner.stop()L’acteur s’insère sans rien changer#
Comme PlaywrightDriver expose quit() et save_screenshot(), il satisfait les contrats génériques existants : la disposition (dispose: Effect) du DriverBuilder et le protocole ScreenshotDriver du Screenshotter. Rien d’autre dans infra/ ne sait jamais qu’il parle à Playwright.
DriverBuilder.dispose ──▶ driver.quit() (teardown marshallisé, borné)
Screenshotter ──▶ driver.save_screenshot() (submit → page.screenshot)
healthcheck ──▶ driver.submit(page.title)quit() est idempotent et marshallise son propre teardown (stop tracing, close context, stop Playwright) sur le thread propriétaire, borné par call_timeout, puis demande l’arrêt. S’il est appelé depuis le thread propriétaire, il lève, puisque comme dans le cas de ré-entrance avec submit, ce serait un deadlock.
Pool et warmup#
create_playwright_drivers_pool réutilise le WebDriversPool agnostique sans modification :
drivers_pool = WebDriversPool(
create_driver=lambda: create_playwright_driver(browser=..., headless=..., ...),
max_size=max_size,
warmup_timeout=warmup_timeout,
)
atexit.register(drivers_pool.shutdown)Chaque driver possède un thread privé, donc le warmup est sûr : un driver créé dans le thread de warmup peut être consommé par n’importe quel worker parce que tous les appels Playwright sont marshallisés vers le thread propriétaire.
Le thread qui crée le driver n’est jamais le thread qui exécute Playwright. Le warmup peut donc pré-construire à l’avance, les workers piochent dans la Queue, et personne ne déclenche de greenlet.error.
Le Watcher : observer, pas muter#
Un Watcher poll dans son propre daemon thread. Un watcher Playwright PEUT lire la page depuis son callback, via watcher.driver.submit(...), marshallisé comme le reste, donc sûr entre threads. Mais c’est gouverné par une convention, pas par une interdiction :
- OBSERVER, pas MUTER. Le watcher tourne en parallèle de la chaîne de test ; muter la page (click/fill) depuis un watcher corromprait l’état du test. Le read-only est la responsabilité de l’utilisateur.
- Toujours passer par
submit, jamais toucherpagedirectement. - Renvoyer du plat depuis la lambda (
str/bool/bytes), jamais unLocatorou unElementHandlevivants. - Caveat de performance : chaque lecture du watcher se sérialise sur le thread propriétaire, à côté des
submitde la chaîne de test. La contention croît avec la fréquence de poll (poll_interval). Les watchers Selenium, qui partagent le driver directement, sont plus libres ici.
def watch_banner(watcher: PlaywrightWatcher) -> None:
text = watcher.driver.submit(
lambda page: page.inner_text("#cookie-banner")
if page.locator("#cookie-banner").count()
else ""
)
if text and text not in watcher.cache:
watcher.cache.add(text)
watcher.report(f"Cookie banner: {text!r}", label="BANNER")Vidéo et trace#
| Option | Effet |
|---|---|
record_video_dir | Enregistre une vidéo de la session (doit être posé à la création du contexte : Playwright ne l’active pas après coup). |
trace_dir | Capture une trace Playwright (trace_<id>.zip, à ouvrir avec playwright show-trace). |
Conclusion#
L’API sync de Playwright est épinglée à un thread. L’acteur réconcilie les deux en confinant Playwright. Le reste de l’infra ne voit qu’un driver ordinaire avec quit() et save_screenshot().