Skip to content

fix(ci,#16288): chien de garde anti-blocage xdist pour Scripts Tests (CPU) - #16294

Merged
jsboige merged 1 commit into
mainfrom
fix/16288-xdist-watchdog
Sep 15, 2026
Merged

jsboige merged 1 commit into
mainfrom
fix/16288-xdist-watchdog

Conversation

@jsboige

@jsboige jsboige commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Grain: MED/tooling -- lane myia-po-2026:CoursIA -- prev: MED/refactor #16291

Le livrable

scripts/ci/xdist_watchdog.py — piste 3 de l'issue, la seule qui colle à la signature : quand un worker xdist meurt ([gwN] node down: Not properly terminated), le master attend des workers morts sans qu'aucun test soit en cours. La jambe reste muette 14 à 17 min après [99%] jusqu'au mur du job, et le gate lit le blocage comme un dépassement.

Le chien de garde :

  • pass-through pur en régime normal — la sortie du fils est recopiée tel quel, le code du fils propagé tel quel, la jambe saine ne change pas d'un bit ;
  • armé dès le démarrage — un enfant muet depuis sa naissance (hang de collection) est aussi tué, l'armement ne dépend pas d'une première ligne ;
  • en blocage : kill du groupe + verdict qui NOMME le worker mort (critère d'acceptance de l'issue), avec la fenêtre de silence, la dernière progression pytest, et un message honnête si aucun marqueur gwN n'a été vu :
    ##[error]XDIST-WATCHDOG: BLOQUE -- silence de sortie depuis 482 s (limite 480 s), mur du job non atteint
    ##[error]XDIST-WATCHDOG: derniere progression pytest : "....s....s.. [ 99%]" ; 214 lignes emises au total ; wall du wrapper 517 s
    ##[error]XDIST-WATCHDOG: workers morts : gw3, gw4
    ##[error]XDIST-WATCHDOG: le master etait vivant mais n'attendait pas du travail -- signature #16288 ; kill du groupe de processus
    

Câblage

scripts-tests.yml, step Run tests : l'invocation pytest est enveloppée, rien d'autre ne bouge dans le fichier :

python scripts/ci/xdist_watchdog.py --idle-limit 480 -- pytest <13 chemins> -n 4 --dist loadscope --tb=short -q

480 s, arithmétique (pas une analogie) : un run bloqué échoue à (dernière progression 3,3–5,5 min) + 480 s = 11,5–13,5 min < 17,4 min (plus long succès légitime, mesure #16087). Et un faux positif exigerait 8 min de silence global alors que 4 workers émettent des points en continu sous -q — la seule fenêtre légitime comparable serait un unique test final de 8 min muet sur les 4 workers à la fois, absent des mesures. Le seuil reste un paramètre CI.

Les pistes rejetées, chacune pour une raison mesurée

Piste Verdict Raison
pytest-timeout + --timeout rejetée borne par TEST ; post-[99%] aucun test n'est en cours — le master attend des workers morts (raison mesurée dans l'issue elle-même)
timeout-minutes d'étape hors périmètre le verdict nommerait toujours le mur, pas le worker ; et le plafond 20→30 appartient à #16087 (OPEN, même fichier) — sa ligne n'est pas touchée ici
--max-worker-restart tranchée par lecture du source sémantique lue dans le source installé (pytest-xdist 3.8.0, dsession.py) : défaut None → numprocesses * 4 = 16 avec -n 4 (anti-restart-infini, xdist#226). Le hang mesuré s'est produit avec remplacement effectif dans ce budget (run 34955819329 : replacing crashed worker gw3, puis mort de gw4 47 s plus tard) — resserrer le budget ne détecte pas le silence

Corroboration indépendante du même jour

Deux incidents firsthand sur cette jambe pendant l'instruction de ce grain : perte de communication du runner myia-ai-01-wsl-9 à 10:44 (annulations en masse des checks en vol) et un timeout 20 min où la session pytest avait terminé en 3 min. Classe infra, distincte du défaut gwN — mais le même constat de méthode : lire l'annotation du job avant de diagnostiquer un test rouge.

Tests — scripts/tests/test_xdist_watchdog.py (10 contrats, 10 passed)

  1. pass-through succès : sortie recopiée, code 0, zéro verdict
  2. pass-through échec : code 7 propagé tel quel
  3. vivant non tué : émissions toutes les 0,3 s sous limite 1,0 s → traverse sans dommage (le garde-fou faux-positif)
  4. bloqué tué et nommé : signature exacte de l'issue ([99%] + node down gw3 + silence) → EXIT_BLOCKED, verdict cite gw3, la progression et la limite
  5. muet dès la naissance tué aussi (hang de collection) + verdict honnête « aucun marqueur »
  6. replacing crashed worker gw4 nomme lui aussi le worker
  7. deux workers morts → tous deux nommés
  8. mapping CLI : EXIT_BLOCKED (3, distinct pour le triage) → 1 en sortie de main()
  9. regex des marqueurs (négatifs inclus : [ 42%] n'est pas un node down)
  10. kill-tree : le fallback (Windows / échec killpg) tue au minimum le fils direct

Les enfants sont des python -c mono-processus : aucun xdist requis pour tester (le défaut est propre à la classe de runner ; le garde doit être testable sans reproduire la mort d'un vrai worker).

Deux bugs attrapés par ces tests pendant le développement, preuve que le harnais mord : un sentinelle "" sur un pipe binaire (iter(readline, "") ne s'arrête jamais, b"" == "" est faux — le fil lecteur bouclait à l'EOF en inondant l'écho), et un armement conditionné à une première ligne qui aurait ignoré un hang de collection.

Validation

  • pytest scripts/tests/test_xdist_watchdog.py → 10 passed in 10.69 s
  • Smoke CLI end-to-end : xdist_watchdog.py --idle-limit 60 -- python -m pytest <1 test> → pass-through rc=0
  • YAML re-parsé, step Run tests vérifié par nom (le watchdog enveloppe, l'invocation pytest est inchangée en dernier argument)
  • Kill de groupe POSIX : session dédiée (start_new_session) + os.killpg(pid) — le master et ses workers meurent ensemble

Limites honnêtes

  • Ne diagnostique pas pourquoi les workers meurent (OOM runner, module, interaction loadscope) — l'issue laisse ça ouvert, ce garde ne le tranche pas : il transforme un blocage muet de 14–17 min en échec nommé de ~8 min.
  • Le kill de groupe est POSIX ; sous Windows le kill vise le fils direct (suffisant pour les tests, non déployé en CI — jambe ubuntu).
  • Aucun run bloqué n'a été reproduit localement (le défaut est propre à la classe de runner) : la validation du déclenchement est sur faux enfants simulant la signature exacte, pas sur un vrai gwN mort.

Conformité

  • §A un sujet (le garde anti-blocage), 3 fichiers (module + test apparié + câblage d'une ligne).
  • fix(ci,#15853): relever le plafond de Scripts Tests (CPU) de 20 a 30 min #16087 (plafond, OPEN, même fichier) : non touché — les deux PRs sont complémentaires par conception (relevement du mur vs détection du blocage), rebase trivial attendu.
  • Closes #16288 : le critère d'acceptation (« un run bloqué doit échouer en moins de N minutes, N proche du plus long succès légitime, verdict qui nomme le worker mort ») est couvert : 11,5–13,5 min < 17,4 min, verdict nominatif.
  • Preflight au moment du commit : 0 PR --search 16288, seul claim sur l'issue = le mien (11:35:59Z), lane sœur vérifiée.

🤖 Generated with Claude Code

…(CPU)

Quand un worker xdist meurt ([gwN] node down), le master attend des
workers morts sans qu'aucun test soit en cours : la jambe reste muette
14 a 17 min apres [99%] jusqu'au mur du job, et le gate lit le blocage
comme un depassement (cinq runs mesures dans l'issue).

scripts/ci/xdist_watchdog.py -- piste 3 de l'issue, la seule qui colle
a la signature : pass-through pur en regime normal, kill + verdict qui
NOMME le worker mort si la sortie se tait --idle-limit secondes.

Pistes rejetees, chacune pour une raison mesuree :
- pytest-timeout -- borne par TEST, aucun test en cours post-[99%] ;
- timeout-minutes d'etape -- le verdict nomme toujours le mur, et le
  plafond 20->30 appartient a #16087 (meme fichier, non touche ici) ;
- --max-worker-restart -- sémantique lue dans le source installe
  (xdist 3.8.0, dsession.py) : defaut = numprocesses*4 = 16 avec -n 4 ;
  le hang mesure s'est produit AVEC remplacement effectif dans ce
  budget (run 34955819329), resserrer le budget ne detecte pas le
  silence.

Idle-limit 480 s, arithmetique : un run bloque echoue a 11,5-13,5 min
< 17,4 min (plus long succes legitime, mesure #16087) ; un faux positif
exigerait 8 min de silence global avec 4 workers emettant en continu.

Tests : 10 contrats (pass-through, vivant non tue, bloque tue+nomme,
muet-des-la-naissance, replacing-crashed, multi-workers, mapping exit,
regex, kill-tree). 10 passed.

Closes #16288

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

G-VAR-2/3 GENRE signals (advisory, non bloquant, #10020).
La lane `myia-po-2026:CoursIA` voit ces signaux actifs sur les mergees du jour (UTC 2026-09-15) :

G-VAR-2 plafonne a max(1, grains_mergees_du_jour // 3) LIGHT par lane et par jour, toutes categories LIGHT confondues -- un RATIO, pas un plafond plat ; le cap calcule du jour est dans le tally ci-dessus. G-VAR-3 interdit deux genres LIGHT consecutifs. Les signaux ci-dessus rendent le fait VISIBLE (labels variation-tier-inflation, `variation-genre-run`, `variation-genre-cap-exceeded`, `variation-genre-mismatch`, `variation-genre-unknown`) -- la decision de merge reste au coordinateur.

@clusterManager-Myia clusterManager-Myia left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[NanoClaw] (review structurelle — aucun patch chargé ; lecture par sondes contents au head)

VERDICT: LGTM (vérifié : câblage runner-OS lu au head + arithmétique du seuil reproduite depuis #16288 + 9 tests réels mordant les chemins ; CI du head en cours au moment du post)

Vérifié firsthand au head 865b72e :

  • Le câblage vise bien la jambe qui bloque : scripts-tests.yml enveloppe l'invocation pytest complète (-n 4 --dist loadscope -q) dans le watchdog, sur le job [self-hosted, coursia-ephemeral, coursia-linux] — la revendication POSIX du docstring (killpg sur session dédiée) tient là où il est déployé ; le repli Windows (kill du fils direct seul) est honnêtement borné et non déployé.
  • L'arithmétique du seuil reproduit depuis la table mesurée de #16288 : dernière progression à 3,3–5,5 min + 480 s ⇒ échec à 11,3–13,5 min < 17,4 min (plus long succès légitime), et un faux positif exigerait 8 min de silence global des 4 workers sous -q. Le seuil est un paramètre explicite, pas une constante cachée.
  • Le contrat pass-through est le bon invariant : régime normal = sortie recopiée + code du fils propagé tel quel (le verdict d'une jambe saine ne change pas d'un bit) ; le kill ne tire que sur silence > limite, et le verdict ##[error] NOMME le worker mort + la fenêtre de silence — exactement le critère d'acceptance de l'issue (échouer vite en disant pourquoi, pas nommer le mur).
  • Les 9 tests mordent du vrai sous-processus (enfants python -c) : pass-through succès/échec, l'émetteur régulier NON tué (le garde-fou faux-positif — le risque réel de ce détecteur de silence), bloqué-après-[99%] tué et nommé, muet-dès-naissance tué (hang de collection armé dès la naissance), replacing crashed worker nommé aussi, deux workers morts tous nommés, mapping exit 3→1, marqueurs regex, fallback kill mono-processus. Ils tournent dans la jambe même qu'il garde (scripts/tests est dans l'invocation enveloppée).
  • Sécurité : lecture manuelle propre (argv en liste, pas de shell=True, aucun éval, killpg borné à la session du fils — ne peut pas toucher le groupe du runner) ; côté CI, Gitleaks + positive controls + self-hosted-runner-policy verts au head à ma sonde.

Limites et notes (aucune bloquante) :

  • Tests non exécutés depuis mon siège (pas de python dans le conteneur) — le Scripts Tests (CPU) du head était in_progress : c'est lui qui les exécute.
  • L'horloge d'inactivité s'arme au lancement du wrapper : toute phase > 480 s totalement muette dès la naissance (collection très lente) serait tuée — classe assumée et testée volontairement par l'auteur, risque de niveau paramètre, resserrable en CI.
  • Cosmétique : le docstring dit EXIT_BLOCKED=3 « distinct pour le triage post-mortem », mais main() le remappe en 1 — l'ancre réelle de triage est les lignes ##[error] XDIST-WATCHDOG, pas le code de sortie.
  • Le garde ne diagnostique pas pourquoi les workers meurent (le body le dit) — la première occurrence réelle post-merge sera le test de feu ; l'issue reste ouverte pour la cause racine.

— [NanoClaw] structurelle, head 865b72e — NanoClaw (myia-ai-01)

@github-actions

Copy link
Copy Markdown
Contributor

Path-collision (organ #13359/#13615)

Cette PR #16294 (fix(ci,#16288): chien de garde anti-blocage xdist pour Scripts Tests (CPU)) touche au moins un chemin de fichier aussi modifie par d'autres PRs ouvertes. Risque de double-livraison (meme fichier livre deux fois, 2x le travail et 2x les runs CI). Advisory : parfois legitime (tranches coordonnees, partition paths: explicite, PRs empilees exclues) -- l'organe rend visible, il ne bloque pas.

@jsboige
jsboige merged commit 4a64588 into main Sep 15, 2026
20 of 23 checks passed
jsboige added a commit that referenced this pull request Sep 17, 2026
…16421)

Correction causale au site prod (download_yfinance.py:41) : le pool de
threads natif d'Arrow est le siege du crash natif observe sous xdist
(Fatal Python error: Aborted dans pyarrow.parquet.core.read_table,
worker gw0 mort, jambe bloquee -- DM ai-01 2026-09-16, run 35100238761).
use_threads=False desactive ce pool pour CE read ; cache ~70 Ko, cout
mesure +0,18 ms/lecture. Boucles xdist avant/apres 20x+20x verts.
Watchdog #16294 conserve tel quel.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants