wilenPxs Claude Fable 5 commited on
Commit
d1dc8e5
·
1 Parent(s): 77c855f

Suivi de job robuste pendant les phases CPU (fix Reconnexion au suivi)

Browse files

- Cause : serveur mono-process — la porte harnais (100 graines sympy)
monopolise le GIL plusieurs minutes, le proxy HF renvoie 502 et l UI
abandonnait le suivi apres ~1 min alors que le job continuait.
- Serveur : yield 1 ms entre les graines du harnais + commutation de threads
a 1 ms (setswitchinterval) — /api/jobs/<id> repond pendant le calcul.
- UI : le suivi n abandonne PLUS JAMAIS avant 45 min — backoff leger puis
mode lent 10 s avec message explicite (le traitement continue) ; recalage
automatique a la premiere reponse.
- UI : reprise du suivi apres rechargement de page (job id persiste en
localStorage, raccrochage automatique au job en cours).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

app/__init__.py CHANGED
@@ -35,6 +35,12 @@ def create_app():
35
  _setup_logging()
36
  _load_env()
37
 
 
 
 
 
 
 
38
  from flask import Flask
39
  from app.config import TEMPLATES_DIR
40
  from app.server import register_routes
 
35
  _setup_logging()
36
  _load_env()
37
 
38
+ # Serveur mono-process : le job (thread CPU-bound — harnais sympy) et les
39
+ # requêtes de suivi partagent le GIL. Commutation à 1 ms (défaut 5 ms) pour
40
+ # que /api/jobs/<id> réponde pendant les phases de calcul (coût ~négligeable).
41
+ import sys
42
+ sys.setswitchinterval(0.001)
43
+
44
  from flask import Flask
45
  from app.config import TEMPLATES_DIR
46
  from app.server import register_routes
app/validation/harness.py CHANGED
@@ -23,6 +23,7 @@ import logging
23
  import random
24
  import re
25
  import sys
 
26
  import traceback
27
  import types
28
  import warnings as _warnings
@@ -384,6 +385,10 @@ def validate_text(text: str, seeds: int = 100) -> dict:
384
 
385
  for s in range(seeds):
386
  random.seed(s)
 
 
 
 
387
  env: dict = {"rd": random, "random": random}
388
  out: dict = {}
389
 
 
23
  import random
24
  import re
25
  import sys
26
+ import time as _time
27
  import traceback
28
  import types
29
  import warnings as _warnings
 
385
 
386
  for s in range(seeds):
387
  random.seed(s)
388
+ # Céder la main entre les graines : en contexte serveur (mono-process),
389
+ # 100 graines de sympy monopolisent le GIL et rendent le suivi de job
390
+ # injoignable (proxy HF → 502) — 1 ms/graine rétablit la réactivité.
391
+ _time.sleep(0.001)
392
  env: dict = {"rd": random, "random": random}
393
  out: dict = {}
394
 
app/web/templates/index.html CHANGED
@@ -477,7 +477,10 @@
477
  'use strict';
478
 
479
  const POLL_MS = 1000;
480
- const POLL_MAX_FAILURES = 12; // hoquets transitoires tolérés avant d'abandonner le suivi
 
 
 
481
  let currentJobId = null;
482
  let pollTimer = null;
483
  let pollFailures = 0;
@@ -486,7 +489,20 @@ let loadedFiles = []; // [{filename, content}]
486
  let jobResults = []; // résultats par fichier
487
  let activeResultIdx = 0;
488
 
489
- document.addEventListener("DOMContentLoaded", () => { fetchModels(); bindTabs(); });
 
 
 
 
 
 
 
 
 
 
 
 
 
490
 
491
  async function fetchModels() {
492
  try {
@@ -863,6 +879,7 @@ async function run() {
863
  throw new Error(err.error || `HTTP ${res.status}`);
864
  }
865
  currentJobId = (await res.json()).job_id;
 
866
  poll();
867
  } catch (e) {
868
  setProgress("error", "Échec", "—");
@@ -875,6 +892,7 @@ async function run() {
875
  function jobFinished() {
876
  document.getElementById("run-btn").disabled = false;
877
  document.getElementById("stop-btn").hidden = true;
 
878
  }
879
 
880
  /* Arrêt coopératif : le serveur stoppe au prochain point d'étape (l'appel
@@ -920,6 +938,7 @@ async function poll() {
920
  }
921
 
922
  pollFailures = 0; // réponse valide → on repart à zéro
 
923
  jobResults = data.results || [];
924
  if (data.status === "running") {
925
  setProgress("running", data.step_label || "…", `${data.files_done}/${data.files_total}`);
@@ -950,26 +969,35 @@ async function poll() {
950
  jobFinished();
951
  }
952
  } catch (e) {
953
- // Erreur TRANSITOIRE (proxy HF, worker occupé en calcul, réseau) : on
954
- // réessaie avec un léger backoff. Le job continue côté serveur.
 
 
955
  pollFailures++;
956
- if (pollFailures >= POLL_MAX_FAILURES) {
 
957
  setProgress("error", "Connexion interrompue", "—");
958
  setStatus("error",
959
- "Suivi interrompu après plusieurs tentatives (" + e.message + "). "
960
- + "Le traitement continue peut-être côté serveur : recharge la page dans ~1 min "
961
- + "pour le retrouver, ou relance avec moins de fichiers à la fois.");
962
  jobFinished();
963
  return;
964
  }
965
- setStatus("warn", `Reconnexion au suivi… (${pollFailures}/${POLL_MAX_FAILURES})`);
966
- pollTimer = setTimeout(poll, Math.min(6000, POLL_MS * (1 + pollFailures))); // backoff léger
 
 
 
 
 
 
967
  }
968
  }
969
 
970
  function clearAll() {
971
  if (pollTimer) clearTimeout(pollTimer);
972
  currentJobId = null;
 
973
  document.getElementById("stop-btn").hidden = true;
974
  document.getElementById("run-btn").disabled = false;
975
  loadedFiles = []; jobResults = []; activeResultIdx = 0;
 
477
  'use strict';
478
 
479
  const POLL_MS = 1000;
480
+ const POLL_SLOW_AFTER = 12; // au-delà : mode lent (10 s) — on n'abandonne PAS
481
+ const POLL_SLOW_MS = 10000;
482
+ const POLL_GIVEUP_MS = 45 * 60 * 1000; // plafond absolu de reconnexion (45 min)
483
+ let pollFirstFailureAt = null;
484
  let currentJobId = null;
485
  let pollTimer = null;
486
  let pollFailures = 0;
 
489
  let jobResults = []; // résultats par fichier
490
  let activeResultIdx = 0;
491
 
492
+ document.addEventListener("DOMContentLoaded", () => {
493
+ fetchModels(); bindTabs();
494
+ // Reprise après rechargement : si un job était en cours, on se raccroche
495
+ // (le serveur le garde JOB_TTL=30 min après sa fin ; 404 → suivi perdu).
496
+ const saved = localStorage.getItem("pyxi_job_id");
497
+ if (saved) {
498
+ currentJobId = saved;
499
+ document.getElementById("run-btn").disabled = true;
500
+ const sb = document.getElementById("stop-btn");
501
+ sb.hidden = false; sb.disabled = false; sb.textContent = "⏹ Arrêter";
502
+ setProgress("running", "Reprise du suivi du job en cours…", "…");
503
+ poll();
504
+ }
505
+ });
506
 
507
  async function fetchModels() {
508
  try {
 
879
  throw new Error(err.error || `HTTP ${res.status}`);
880
  }
881
  currentJobId = (await res.json()).job_id;
882
+ localStorage.setItem("pyxi_job_id", currentJobId);
883
  poll();
884
  } catch (e) {
885
  setProgress("error", "Échec", "—");
 
892
  function jobFinished() {
893
  document.getElementById("run-btn").disabled = false;
894
  document.getElementById("stop-btn").hidden = true;
895
+ localStorage.removeItem("pyxi_job_id");
896
  }
897
 
898
  /* Arrêt coopératif : le serveur stoppe au prochain point d'étape (l'appel
 
938
  }
939
 
940
  pollFailures = 0; // réponse valide → on repart à zéro
941
+ pollFirstFailureAt = null;
942
  jobResults = data.results || [];
943
  if (data.status === "running") {
944
  setProgress("running", data.step_label || "…", `${data.files_done}/${data.files_total}`);
 
969
  jobFinished();
970
  }
971
  } catch (e) {
972
+ // Erreur TRANSITOIRE : pendant les phases de calcul (harnais 100 graines),
973
+ // le serveur mono-process peut être injoignable PLUSIEURS MINUTES le job
974
+ // continue. On ne renonce donc JAMAIS avant le plafond absolu : backoff
975
+ // léger, puis mode lent (10 s) ; le suivi se recale à la 1re réponse.
976
  pollFailures++;
977
+ if (!pollFirstFailureAt) pollFirstFailureAt = Date.now();
978
+ if (Date.now() - pollFirstFailureAt > POLL_GIVEUP_MS) {
979
  setProgress("error", "Connexion interrompue", "—");
980
  setStatus("error",
981
+ "Suivi injoignable depuis 45 min (" + e.message + "). "
982
+ + "Recharge la page ; si le serveur a redémarré, relance le job.");
 
983
  jobFinished();
984
  return;
985
  }
986
+ const slow = pollFailures >= POLL_SLOW_AFTER;
987
+ setStatus("warn",
988
+ slow
989
+ ? "Serveur occupé (phase de calcul) — le traitement CONTINUE côté serveur, "
990
+ + `reconnexion toutes les 10 s… (${Math.round((Date.now() - pollFirstFailureAt) / 1000)} s)`
991
+ : `Reconnexion au suivi… (${pollFailures})`);
992
+ pollTimer = setTimeout(
993
+ poll, slow ? POLL_SLOW_MS : Math.min(6000, POLL_MS * (1 + pollFailures)));
994
  }
995
  }
996
 
997
  function clearAll() {
998
  if (pollTimer) clearTimeout(pollTimer);
999
  currentJobId = null;
1000
+ localStorage.removeItem("pyxi_job_id");
1001
  document.getElementById("stop-btn").hidden = true;
1002
  document.getElementById("run-btn").disabled = false;
1003
  loadedFiles = []; jobResults = []; activeResultIdx = 0;