diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index 4b9ee3a..64a8d08 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -895,11 +895,30 @@ function authHeaders(h){ h = Object.assign({}, h||{}); if(TOKEN) h['Authorizatio // Base d'URL : "" en local (page à "/"), "/u/" servie via le relais ajean.link. // Garde les chemins absolus (/api/…) corrects derrière le tunnel. const API_BASE = location.pathname.replace(/\/(index\.html)?$/, ''); +// Délai maximal d'un appel /api/* ordinaire. Sans lui, une requête que le +// navigateur met en file d'attente (plafond de ~6 connexions par domaine, atteint +// dès qu'on laisse traîner plusieurs onglets Jean) reste suspendue POUR TOUJOURS : +// ni réponse, ni erreur, et une UI figée sur « chargement… » sans rien à afficher. +// Mieux vaut une erreur franche. Les flux longs (SSE) passent leur propre signal +// et ne sont donc jamais concernés. +const API_TIMEOUT_MS = 30000; async function jfetch(u, opts){ opts = opts || {}; opts.headers = authHeaders(opts.headers); if(u.charAt(0) === '/') u = API_BASE + u; - let r = await fetch(u, opts); + let timer=null; + if(!opts.signal){ + const ac=new AbortController(); + opts.signal=ac.signal; + timer=setTimeout(()=>ac.abort(), API_TIMEOUT_MS); + } + let r; + try{ r = await fetch(u, opts); } + catch(e){ + if(e && e.name==='AbortError'){ throw new Error('requête sans réponse après 30 s (trop d\'onglets Jean ouverts ?)'); } + throw e; + } + finally{ if(timer) clearTimeout(timer); } if(r.status === 401){ const k = await askPrompt('Clé de pilotage jean requise :', {title:'Authentification', placeholder:'clé…'}); if(k){ TOKEN = k.trim(); localStorage.setItem('jean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); } @@ -1861,8 +1880,25 @@ function handleDelta(d){ } // Flux d'abonnement permanent + reconnexion auto (from=lastSeq → pas de // re-téléchargement complet après une coupure / bascule d'appareil). +// Un onglet caché RELÂCHE son flux SSE. Sans ça, chaque onglet Jean laissé +// ouvert monopolise une des ~6 connexions simultanées autorisées par domaine : +// au-delà, toute requête (journal, installation du moteur…) reste en file +// d'attente sans jamais partir ni échouer — un blocage silencieux très +// déroutant. Au retour de l'onglet on se reconnecte, et le replay depuis +// lastSeq rattrape tout ce qui s'est passé entre-temps. +let streamPaused=false; +document.addEventListener('visibilitychange', ()=>{ + if(document.hidden){ + streamPaused=true; + if(streamAbort) try{ streamAbort.abort(); }catch(e){} + } else if(streamPaused){ + streamPaused=false; + } +}); async function connectStream(){ while(true){ + // Onglet en arrière-plan : on n'ouvre aucune connexion, on attend le retour. + while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); } streamAbort=new AbortController(); try{ const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq}),signal:streamAbort.signal}); diff --git a/internal/jean/ui/src/js/04-api.js b/internal/jean/ui/src/js/04-api.js index 74796a2..46b8893 100644 --- a/internal/jean/ui/src/js/04-api.js +++ b/internal/jean/ui/src/js/04-api.js @@ -5,11 +5,30 @@ function authHeaders(h){ h = Object.assign({}, h||{}); if(TOKEN) h['Authorizatio // Base d'URL : "" en local (page à "/"), "/u/" servie via le relais ajean.link. // Garde les chemins absolus (/api/…) corrects derrière le tunnel. const API_BASE = location.pathname.replace(/\/(index\.html)?$/, ''); +// Délai maximal d'un appel /api/* ordinaire. Sans lui, une requête que le +// navigateur met en file d'attente (plafond de ~6 connexions par domaine, atteint +// dès qu'on laisse traîner plusieurs onglets Jean) reste suspendue POUR TOUJOURS : +// ni réponse, ni erreur, et une UI figée sur « chargement… » sans rien à afficher. +// Mieux vaut une erreur franche. Les flux longs (SSE) passent leur propre signal +// et ne sont donc jamais concernés. +const API_TIMEOUT_MS = 30000; async function jfetch(u, opts){ opts = opts || {}; opts.headers = authHeaders(opts.headers); if(u.charAt(0) === '/') u = API_BASE + u; - let r = await fetch(u, opts); + let timer=null; + if(!opts.signal){ + const ac=new AbortController(); + opts.signal=ac.signal; + timer=setTimeout(()=>ac.abort(), API_TIMEOUT_MS); + } + let r; + try{ r = await fetch(u, opts); } + catch(e){ + if(e && e.name==='AbortError'){ throw new Error('requête sans réponse après 30 s (trop d\'onglets Jean ouverts ?)'); } + throw e; + } + finally{ if(timer) clearTimeout(timer); } if(r.status === 401){ const k = await askPrompt('Clé de pilotage jean requise :', {title:'Authentification', placeholder:'clé…'}); if(k){ TOKEN = k.trim(); localStorage.setItem('jean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); } diff --git a/internal/jean/ui/src/js/09-stream.js b/internal/jean/ui/src/js/09-stream.js index 2181d67..7831639 100644 --- a/internal/jean/ui/src/js/09-stream.js +++ b/internal/jean/ui/src/js/09-stream.js @@ -96,8 +96,25 @@ function handleDelta(d){ } // Flux d'abonnement permanent + reconnexion auto (from=lastSeq → pas de // re-téléchargement complet après une coupure / bascule d'appareil). +// Un onglet caché RELÂCHE son flux SSE. Sans ça, chaque onglet Jean laissé +// ouvert monopolise une des ~6 connexions simultanées autorisées par domaine : +// au-delà, toute requête (journal, installation du moteur…) reste en file +// d'attente sans jamais partir ni échouer — un blocage silencieux très +// déroutant. Au retour de l'onglet on se reconnecte, et le replay depuis +// lastSeq rattrape tout ce qui s'est passé entre-temps. +let streamPaused=false; +document.addEventListener('visibilitychange', ()=>{ + if(document.hidden){ + streamPaused=true; + if(streamAbort) try{ streamAbort.abort(); }catch(e){} + } else if(streamPaused){ + streamPaused=false; + } +}); async function connectStream(){ while(true){ + // Onglet en arrière-plan : on n'ouvre aucune connexion, on attend le retour. + while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); } streamAbort=new AbortController(); try{ const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq}),signal:streamAbort.signal});