diff --git a/DEPLOY.md b/DEPLOY.md index 0e1e86e..7481a24 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -242,7 +242,8 @@ La rotation des jetons se fait alors depuis le dashboard, agent par agent. | Le conteneur redémarre en boucle | `docker compose logs server` — le plus souvent `ADMIN_PASSWORD` ou `SESSION_SECRET` absent de `.env` | | `unable to open database file` (`ERR_SQLITE_ERROR`, errcode 14) | Le répertoire `./data` de l'hôte n'appartient pas à l'uid du conteneur. L'entrypoint le corrige automatiquement ; si tu as forcé un `user:` dans le compose, il ne peut plus le faire — aligne alors la propriété à la main : `sudo chown -R 1000:1000 data` | | Agent absent du dashboard | `--server` erroné, ou `ENROLLMENT_TOKEN` différent de celui du serveur. `journalctl -u stream-control-agent -f` | -| Agent en ligne, OBS déconnecté | obs-websocket désactivé ou mot de passe erroné. Le diagnostic teste le port | +| Agent en ligne, OBS déconnecté — `ECONNREFUSED` | Rien n'écoute : OBS n'est pas lancé, ou *Outils → Paramètres du serveur WebSocket* n'est pas activé. Le mot de passe n'intervient pas à ce stade | +| Agent en ligne, OBS déconnecté — erreur d'authentification | Le mot de passe du dashboard et celui d'OBS diffèrent. Laisse-le vide **uniquement** si l'authentification est décochée dans OBS | | Rappel plein écran sans effet (Linux) | Session Wayland au lieu de X11, ou `DISPLAY` inaccessible au service. Le diagnostic signale les deux | | Rappel plein écran sans effet (Windows) | La tâche ne tourne pas dans la session interactive : vérifie que le compte de `-RunAsUser` est bien celui ouvert sur la VM | | Agent en double après réinstallation | `agent.config.json` avait été supprimé : l'agent s'est ré-enrôlé. Supprime le doublon dans le dashboard | diff --git a/packages/agent/src/obs.ts b/packages/agent/src/obs.ts index 69a390d..71a63f3 100644 --- a/packages/agent/src/obs.ts +++ b/packages/agent/src/obs.ts @@ -33,6 +33,43 @@ type ObsSnapshot = Pick< >; const RECONNECT_DELAY_MS = 5000; +const CONNECT_TIMEOUT_MS = 10_000; + +/** Borne une promesse qui pourrait ne jamais se résoudre. */ +async function withTimeout(promise: Promise, ms: number, message: string): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +/** + * Traduit une erreur de connexion en message actionnable. Le brut + * (« ECONNREFUSED ») dit ce qui a échoué, jamais quoi faire ensuite. + */ +function describeConnectionError(err: unknown): string { + const message = err instanceof Error ? err.message : String(err); + const sysCode = (err as NodeJS.ErrnoException).code; + const obsCode = (err as { code?: unknown }).code; + + if (sysCode === 'ECONNREFUSED' || message.includes('ECONNREFUSED')) { + return `${message} — rien n'écoute sur ce port. Lance OBS, puis Outils → Paramètres du serveur WebSocket → coche « Activer le serveur WebSocket ».`; + } + if (obsCode === 4009 || /authent/i.test(message)) { + return `${message} — mot de passe obs-websocket incorrect ou manquant. Dans OBS : Outils → Paramètres du serveur WebSocket → Afficher les informations de connexion.`; + } + if (sysCode === 'ENOTFOUND' || sysCode === 'EHOSTUNREACH' || sysCode === 'ETIMEDOUT') { + return `${message} — hôte injoignable. Vérifie l'adresse configurée pour cet agent.`; + } + return message; +} /** * Enveloppe obs-websocket : maintient la session, expose les actions du @@ -101,16 +138,24 @@ export class ObsController extends EventEmitter { this.connecting = (async () => { try { - const info = await this.obs.connect(url, this.settings.password || undefined, { - rpcVersion: 1, - }); + // Un port ouvert par autre chose qu'obs-websocket ne renvoie jamais de + // poignée de main : sans ce délai, la promesse resterait éternellement + // en attente et, comme elle est réutilisée ci-dessus, toute tentative + // ultérieure serait bloquée jusqu'au redémarrage de l'agent. + const info = await withTimeout( + this.obs.connect(url, this.settings.password || undefined, { rpcVersion: 1 }), + CONNECT_TIMEOUT_MS, + `aucune réponse après ${CONNECT_TIMEOUT_MS / 1000} s — le port répond mais ne parle pas obs-websocket ?`, + ); this.connected = true; this.lastError = undefined; this.cachedVersion = info.obsWebSocketVersion; this.emit('log', 'info', `Connecté à OBS ${url} (obs-websocket ${info.obsWebSocketVersion})`); } catch (err) { this.connected = false; - this.lastError = err instanceof Error ? err.message : String(err); + this.lastError = describeConnectionError(err); + // Après un délai dépassé, le socket peut rester à demi ouvert. + await this.obs.disconnect().catch(() => undefined); throw new Error(`Connexion à OBS impossible (${url}) : ${this.lastError}`); } finally { this.connecting = null;