diff --git a/README.md b/README.md index 0aa6a54..352cd40 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,31 @@ qu'une sortie non nulle comme un échec ; sinon il abandonnerait le cas nominal quelques centaines de millisecondes. La sortie de Firefox est redirigée vers `/firefox.log`, et ses dernières lignes -accompagnent tout échec de démarrage — c'est le seul endroit où il explique son refus. +utiles accompagnent tout échec de démarrage — c'est le seul endroit où il explique son +refus. Les lignes `update.go:193: cannot change mount namespace …` sont du bavardage +systématique de snapd, sans rapport avec Firefox : elles sont comptées puis écartées de +l'extrait, sans quoi elles chasseraient la vraie erreur. + +Le démarrage laisse **150 s** au navigateur. Ce n'est pas de la prudence : un snap lancé à +froid sur une VM, avec un profil neuf à construire, dépasse couramment la minute. L'agent +signale sa patience toutes les 20 s dans l'historique de la VM. + +#### Verrou de profil bloqué + +Le profil est exclusif à l'agent : personne n'y travaille à la main en temps normal. Un +lancement qui échoue sans que le processus Firefox s'arrête (l'ancien comportement, avant +correction) y laisse un verrou — et chaque tentative suivante se heurte alors au dialogue +« Firefox est déjà ouvert », qui n'ouvre jamais le port de pilotage et bloque +indéfiniment, y compris un lancement manuel de diagnostic sur ce même profil. + +Deux garde-fous, l'un préventif et l'un correctif : + +- un lancement qui échoue termine désormais le processus qu'il venait de créer, plutôt que + de le laisser tourner comme verrou pour la prochaine tentative ; +- avant chaque lancement, l'agent identifie (sous Linux, via `/proc//cmdline`) tout + processus dont la ligne de commande référence exactement ce profil, le termine, puis + efface le verrou. La correspondance porte sur le chemin exact du profil : rien d'autre + ne peut être atteint par erreur. **Au premier passage en BiDi, une nouvelle fenêtre Firefox s'ouvre** : pointe la source « capture de fenêtre » d'OBS dessus une fois. Ensuite elle survit aux enregistrements diff --git a/packages/agent/src/firefox.ts b/packages/agent/src/firefox.ts index 1ac75f1..1d2ccd8 100644 --- a/packages/agent/src/firefox.ts +++ b/packages/agent/src/firefox.ts @@ -8,6 +8,15 @@ import { assertWebUrl, type BrowserLog } from './browser.ts'; import type { FullscreenOutcome } from './fullscreen.ts'; import { sessionEnv } from './x11.ts'; +/** + * Attente maximale de l'ouverture du port de pilotage. + * + * Généreuse à dessein : un snap Firefox démarrant à froid sur une VM, avec un + * profil neuf à construire, dépasse couramment la minute. Ce délai ne coûte + * rien quand tout va bien — la connexion aboutit dès que le port répond. + */ +const LAUNCH_TIMEOUT_MS = 150_000; + /** * Firefox piloté par l'agent, via WebDriver BiDi. * @@ -271,6 +280,12 @@ export class FirefoxController { const blocked = profileRefusedBySnap(this.settings.command, profile); if (blocked) throw new Error(blocked); + // Une instance bloquée d'un lancement précédent — celui-ci n'a jamais pu + // se connecter et n'a donc jamais été arrêtée — laisserait le verrou en + // place : chaque nouvelle tentative se heurterait alors indéfiniment au + // dialogue « Firefox est déjà ouvert », qui n'ouvre jamais le port. + await reclaimProfile(profile, this.log); + writeProfilePrefs(profile); const args = [ @@ -317,21 +332,38 @@ export class FirefoxController { // passe la main au snap et rend aussitôt la main. Le vrai navigateur // démarre derrière — abandonner ici tuerait le cas nominal. if (code !== 0 && !this.client?.isOpen) { + // Sans le journal : il est ajouté une seule fois, au rattrapage. fatal = `Firefox s'est arrêté (code ${code}) sans ouvrir le port de pilotage. ` + - `Le profil ${profile} est-il déjà ouvert dans une autre instance ? ` + - tail(logPath); + `Le profil ${profile} est-il déjà ouvert dans une autre instance ?`; } }); child.unref(); - const client = await BidiClient.open(this.settings.remotePort, 45_000, () => fatal).catch( - (err: Error) => { - this.lastError = `${err.message} ${tail(logPath)}`.trim(); - throw new Error(this.lastError); - }, - ); - await this.adopt(client); + // Le premier démarrage d'un snap sur une VM dépasse couramment la minute : + // décompression, mise en place du confinement, profil neuf à construire. + // Un délai de 45 s abandonnait un navigateur qui était simplement lent. + const started = Date.now(); + const heartbeat = setInterval(() => { + this.log('info', `Firefox pas encore joignable après ${Math.round((Date.now() - started) / 1000)} s — on patiente`); + }, 20_000); + heartbeat.unref?.(); + + try { + const client = await BidiClient.open(this.settings.remotePort, LAUNCH_TIMEOUT_MS, () => fatal); + await this.adopt(client); + this.log('info', `Firefox piloté prêt après ${Math.round((Date.now() - started) / 1000)} s`); + } catch (err) { + this.lastError = `${message(err)} ${tail(logPath)}`.trim(); + // Sans cela, ce processus devient le verrou bloqué de la prochaine + // tentative — exactement le symptôme que reclaimProfile() rattrape par + // ailleurs, mais autant ne pas le produire. + if (this.child?.pid) killGroup(this.child.pid); + this.child = null; + throw new Error(this.lastError); + } finally { + clearInterval(heartbeat); + } } /** Établit la session BiDi et retient l'onglet à piloter. */ @@ -438,6 +470,98 @@ export class FirefoxController { } } +// --- Verrou de profil ---------------------------------------------------------- + +/** + * Libère le profil d'une instance bloquée, avant de le confier à une nouvelle. + * + * Le profil est exclusif à l'agent : aucun humain n'y travaille en temps + * normal. Un verrou signale donc soit une instance qu'un lancement précédent + * a laissée pendre (le délai de connexion a expiré sans que le processus soit + * arrêté), soit un lancement manuel de diagnostic resté ouvert — dans les deux + * cas, l'interrompre est sans conséquence. Sans ce nettoyage, chaque nouvelle + * tentative se heurte au dialogue « Firefox est déjà ouvert », qui n'ouvre + * jamais le port de pilotage et bloque indéfiniment. + */ +async function reclaimProfile(profile: string, log: BrowserLog): Promise { + const killed = await killProcessesUsingProfile(profile); + if (killed > 0) { + log('warn', `${killed} instance(s) Firefox bloquée(s) sur ce profil, arrêtée(s)`); + await delay(500); // laisser le noyau libérer les descripteurs de fichier avant de relire le verrou + } + + // Supprimés seulement après : effacer le verrou d'un processus encore vivant + // laisserait le prochain lancement écrire dans le même profil en parallèle. + for (const name of ['lock', '.parentlock', 'parent.lock']) { + try { + fs.rmSync(path.join(profile, name), { force: true }); + } catch { + /* absent, rien à faire */ + } + } +} + +/** + * Termine les processus dont la ligne de commande référence exactement ce + * profil (`--profile `). + * + * Réservé à Linux, où `/proc//cmdline` rend le contrôle précis et sans + * dépendance externe : c'est aussi la seule plateforme où l'agent tourne en + * production. La correspondance porte sur le chemin exact du profil — jamais + * sur un nom de processus — donc rien en dehors de ce que l'agent a lui-même + * lancé sur ce profil ne peut être atteint par erreur. + */ +async function killProcessesUsingProfile(profile: string): Promise { + if (process.platform !== 'linux') return 0; + + let entries: string[]; + try { + entries = fs.readdirSync('/proc').filter((entry) => /^\d+$/.test(entry)); + } catch { + return 0; + } + + let killed = 0; + for (const entry of entries) { + const pid = Number(entry); + if (pid === process.pid) continue; + + let args: string[]; + try { + args = fs.readFileSync(`/proc/${entry}/cmdline`, 'utf8').split('\0').filter(Boolean); + } catch { + continue; // processus disparu entre la liste et la lecture, ou inaccessible + } + + const at = args.indexOf('--profile'); + if (at === -1 || args[at + 1] !== profile) continue; + + killGroup(pid); + killed++; + } + return killed; +} + +/** + * Tue un processus et, si possible, tout son groupe. + * + * Un lancement détaché (`detached: true`) devient le meneur de son propre + * groupe : ne signaler que son PID laisserait vivre les processus de + * contenu de Firefox, qui héritent de ce groupe sans en dépendre du PID + * principal. + */ +function killGroup(pid: number): void { + try { + process.kill(-pid, 'SIGKILL'); + } catch { + try { + process.kill(pid, 'SIGKILL'); + } catch { + /* déjà mort */ + } + } +} + // --- Confinement snap --------------------------------------------------------- /** @@ -530,8 +654,22 @@ function locate(command: string): string | null { return null; } -/** Dernières lignes du journal de Firefox, pour accompagner un échec. */ -function tail(logPath: string, lines = 4): string { +/** + * Bavardage de snapd, systématique au démarrage d'un snap et sans rapport avec + * un échec de Firefox. + * + * Le filtrer n'est pas cosmétique : ces lignes arrivent en dernier et + * chassaient la vraie erreur d'un extrait trop court. + */ +const SNAPD_NOISE = [ + /^update\.go:\d+: cannot change mount namespace/i, + /because it would affect the host in/i, + /^snap-update-ns failed/i, + /^cannot create symlink in "\/var\/lib\/snapd/i, +]; + +/** Dernières lignes utiles du journal de Firefox, pour accompagner un échec. */ +function tail(logPath: string, lines = 20): string { let content: string; try { content = fs.readFileSync(logPath, 'utf8'); @@ -539,12 +677,21 @@ function tail(logPath: string, lines = 4): string { return ''; } - const kept = content - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .slice(-lines); - return kept.length ? `Firefox a écrit : ${kept.join(' / ')}` : ''; + const all = content.split('\n').map((line) => line.trim()).filter(Boolean); + const useful = all.filter((line) => !SNAPD_NOISE.some((pattern) => pattern.test(line))); + const dropped = all.length - useful.length; + const kept = useful.slice(-lines); + + if (kept.length === 0) { + return dropped > 0 + ? `Firefox n'a écrit que ${dropped} ligne(s) de bavardage snapd — journal complet : ${logPath}` + : `Firefox n'a rien écrit — journal : ${logPath}`; + } + return ( + `Firefox a écrit : ${kept.join(' / ')}` + + (dropped > 0 ? ` (+${dropped} ligne(s) de bavardage snapd ignorées)` : '') + + ` — journal complet : ${logPath}` + ); } // --- Profil dédié -------------------------------------------------------------